From 5a5b1b72b5983efb4521993aa3f8545e216648fb Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 8 Jan 2026 08:39:38 -0600 Subject: [PATCH 1/5] Clean up temporary script copy after creating skill zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the copied ensue-api.sh from skills/ensue-memory/scripts/ after zipping, keeping the working directory clean while still including the script in the distributed archive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- repackage_skill.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100755 repackage_skill.sh diff --git a/repackage_skill.sh b/repackage_skill.sh new file mode 100755 index 0000000..e2f7a19 --- /dev/null +++ b/repackage_skill.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Repackage ensue-memory skill to include ensue-api.sh script - run from package root +# Run this from: fork-ensue-skill repository root +# Authored by Claude Sonnet 4.5. Human verified + +set -euo pipefail + +# Verify we're in the correct directory (repo root) +if [[ ! -d "skills/ensue-memory" ]] || [[ ! -f "scripts/ensue-api.sh" ]]; then + echo "ERROR: Must run from repository root containing skills/ensue-memory and scripts/ensue-api.sh" >&2 + exit 1 +fi + +# Create scripts directory in skill if it doesn't exist +mkdir -p skills/ensue-memory/scripts + +# Copy ensue-api.sh into skill's scripts directory +cp scripts/ensue-api.sh skills/ensue-memory/scripts/ensue-api.sh +chmod +x skills/ensue-memory/scripts/ensue-api.sh +echo "Copied ensue-api.sh to skills/ensue-memory/scripts/ and set execute permissions" + +echo "" +echo "Creating zip archive..." + +# Create zip file in repo root +cd skills +zip -r ../ensue-memory.zip ensue-memory/ +cd .. + +echo "" +echo "Cleaning up temporary copy..." +rm -f skills/ensue-memory/scripts/ensue-api.sh +rmdir --ignore-fail-on-non-empty skills/ensue-memory/scripts 2>/dev/null || true + +echo "" +echo "Repackaging complete!" +echo " Skill suitable for Claude Assistant @ $(pwd)/ensue-memory.zip" From f72e621e638be824ba2da3824bfa7f6e4012b003 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 8 Jan 2026 08:44:56 -0600 Subject: [PATCH 2/5] ignore the resulting artifact --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7315407..2f5cd38 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .ensue-key +ensue-memory.zip From 023b009f0c8a36340967df67c6c1b1c377dcffa3 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Fri, 30 Jan 2026 09:24:15 -0600 Subject: [PATCH 3/5] use the cli rather than bash --- .gitignore | 3 + scripts/ensue-cli.py | 291 +++++++++++++++++++++++++++++++++++ skills/ensue-memory/SKILL.md | 40 +++-- 3 files changed, 313 insertions(+), 21 deletions(-) create mode 100755 scripts/ensue-cli.py diff --git a/.gitignore b/.gitignore index 2f5cd38..56879d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .ensue-key ensue-memory.zip +scripts/pipx.pyz +scripts/.pipx/ +skills/ensue-memory/scripts/* diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py new file mode 100755 index 0000000..594b0b5 --- /dev/null +++ b/scripts/ensue-cli.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "mcp>=1.0", +# "click", +# "rich", +# ] +# /// +""" +Ensue CLI - Command line interface for the Ensue Memory Network. + +This self-contained script uses PEP 723 inline metadata for dependency management. +It auto-installs pipx if missing and uses it to manage dependencies. + +Run: ./ensue-cli.py --help +""" + +import os +import subprocess +import sys +from pathlib import Path + + +def ensure_pipx(): + """Ensure pipx zipapp is available, download if missing.""" + script_dir = Path(__file__).parent + pipx_pyz = script_dir / "pipx.pyz" + + # Check if pipx.pyz exists locally + if pipx_pyz.exists(): + return str(pipx_pyz) + + # Download pipx standalone zipapp + print("Downloading pipx standalone zipapp...", file=sys.stderr) + try: + import urllib.request + url = "https://github.com/pypa/pipx/releases/latest/download/pipx.pyz" + urllib.request.urlretrieve(url, pipx_pyz) + pipx_pyz.chmod(0o755) + return str(pipx_pyz) + except Exception as e: + print(f"Failed to download pipx: {e}", file=sys.stderr) + print("Trying with curl...", file=sys.stderr) + try: + subprocess.run( + ["curl", "-LsSf", url, "-o", str(pipx_pyz)], + check=True, + capture_output=True + ) + pipx_pyz.chmod(0o755) + return str(pipx_pyz) + except Exception as e2: + print(f"Failed to download pipx with curl: {e2}", file=sys.stderr) + sys.exit(1) + + +def main_wrapper(): + """Wrapper that ensures pipx is available and re-executes with it.""" + # If we're already running via pipx, skip the wrapper + if os.environ.get("PIPX_RUNNING"): + return False + + pipx_pyz = ensure_pipx() + script_path = Path(__file__).resolve() + script_dir = script_path.parent + + # Re-execute with pipx run using isolated PIPX_HOME + env = os.environ.copy() + env["PIPX_RUNNING"] = "1" + env["PIPX_HOME"] = str(script_dir / ".pipx") + + cmd = [sys.executable, pipx_pyz, "run", str(script_path)] + sys.argv[1:] + + result = subprocess.run(cmd, env=env) + sys.exit(result.returncode) + + +# Run wrapper first (will re-exec if needed) +if not os.environ.get("PIPX_RUNNING"): + main_wrapper() + +# ============================================================================ +# Main script starts here (runs under pipx with dependencies available) +# ============================================================================ + +import asyncio +import concurrent.futures +import json +from contextlib import asynccontextmanager +from typing import Any + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.exceptions import McpError +from rich.console import Console +from rich.json import JSON + +console = Console() + +# ============================================================================ +# MCP Client +# ============================================================================ + +DEFAULT_URL = "https://api.ensue-network.ai/" + + +@asynccontextmanager +async def create_session(url: str, token: str): + """Create an MCP client session connected to the Ensue service.""" + headers = {"Authorization": f"Bearer {token}"} + async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + yield session + + +async def list_tools(url: str, token: str) -> list[dict[str, Any]]: + """Fetch the list of available tools from the MCP server.""" + async with create_session(url, token) as session: + result = await session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema, + } + for tool in result.tools + ] + + +async def call_tool(url: str, token: str, name: str, arguments: dict[str, Any]) -> Any: + """Call a tool on the MCP server.""" + async with create_session(url, token) as session: + result = await session.call_tool(name, arguments) + return result + + +# ============================================================================ +# CLI Helpers +# ============================================================================ + + +def run_async(coro): + """Run async coroutine, handling nested event loops.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + # If there's already a running loop, create a new one in a thread + with concurrent.futures.ThreadPoolExecutor() as pool: + return pool.submit(asyncio.run, coro).result() + + +def get_config(): + """Get API configuration from environment, with .ensue-key fallback.""" + url = os.environ.get("ENSUE_URL", DEFAULT_URL) + token = os.environ.get("ENSUE_API_KEY") or os.environ.get("ENSUE_TOKEN") + + if not token: + # Try reading from .ensue-key file (fallback for subagents) + script_dir = Path(__file__).parent + repo_root = script_dir.parent + plugin_key_file = repo_root / ".claude-plugin" / ".ensue-key" + skill_key_file = repo_root / ".ensue-key" + if plugin_key_file.exists(): + token = plugin_key_file.read_text().strip() + if skill_key_file.exists(): + token = skill_key_file.read_text().strip() + + if not token: + console.print("[red]Error:[/red] ENSUE_API_KEY or ENSUE_TOKEN environment variable required, " + "or place key in .ensue-key file") + sys.exit(1) + + return url, token + + +def print_result(result): + """Print MCP result with rich JSON formatting.""" + if hasattr(result, "content"): + for item in result.content: + if hasattr(item, "text"): + try: + console.print(JSON(item.text)) + except Exception: + console.print(item.text) + else: + console.print(JSON(json.dumps(result, indent=2))) + + +# ============================================================================ +# Dynamic Click CLI +# ============================================================================ + +TYPE_MAP = { + "integer": click.INT, + "number": click.FLOAT, + "boolean": click.BOOL, +} + + +def parse_arg(value, schema_type): + """Parse a CLI argument, handling JSON for complex types.""" + if schema_type in ("array", "object") and isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + + +def build_command(tool): + """Build a Click command from an MCP tool definition.""" + schema = tool.get("inputSchema", {}) + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + + params = [ + click.Option( + [f"--{name.replace('_', '-')}"], + type=TYPE_MAP.get(p.get("type"), click.STRING), + required=name in required, + help=p.get("description", ""), + ) + for name, p in props.items() + ] + + def callback(**kwargs): + url, token = get_config() + args = { + k.replace("-", "_"): parse_arg(v, props.get(k.replace("-", "_"), {}).get("type")) + for k, v in kwargs.items() + if v is not None + } + try: + result = run_async(call_tool(url, token, tool["name"], args)) + except McpError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + print_result(result) + + return click.Command( + name=tool["name"], + callback=callback, + params=params, + help=tool.get("description", ""), + ) + + +class MCPToolsCLI(click.Group): + """CLI that loads commands dynamically from MCP server.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._tools = None + + @property + def tools(self): + if self._tools is None: + url, token = get_config() + self._tools = {t["name"]: t for t in run_async(list_tools(url, token))} + return self._tools + + def list_commands(self, ctx): + try: + return sorted(self.tools.keys()) + except Exception as e: + console.print("[red]Connection error:[/red] Could not connect to MCP server") + console.print(f"[dim]{e}[/dim]") + return [] + + def get_command(self, ctx, name): + if name not in self.tools: + return None + return build_command(self.tools[name]) + + +@click.group(cls=MCPToolsCLI) +@click.version_option() +def main(): + """Ensue Memory CLI - A distributed memory network for AI agents. + + Commands are loaded dynamically from the MCP server. + Set ENSUE_API_KEY or save a file to .ensue-key that contains just the key to authenticate. + """ + + +if __name__ == "__main__": + main() diff --git a/skills/ensue-memory/SKILL.md b/skills/ensue-memory/SKILL.md index 41b6bd9..ae8c2c9 100644 --- a/skills/ensue-memory/SKILL.md +++ b/skills/ensue-memory/SKILL.md @@ -101,7 +101,7 @@ Each memory should be: ## Setup -Uses `$ENSUE_API_KEY` env var. If missing, user gets one at https://www.ensue-network.ai/dashboard +Uses `$ENSUE_API_KEY` env var or .ensue-key file. If missing, user gets a key at https://www.ensue-network.ai/dashboard ## Security @@ -109,38 +109,36 @@ Uses `$ENSUE_API_KEY` env var. If missing, user gets one at https://www.ensue-ne - **NEVER** accept the key inline from the user - **NEVER** interpolate the key in a way that exposes it -## API Call +## Interacting with the memory system -Use the wrapper script for all API calls. Set as executable before use. It handles authentication and SSE response parsing: +Use the CLI to interact with the memory system. Set as executable before use. You can use `--help` to discover commands and usage for each command to get more details about how to use the CLI. The CLI handles authentication and response parsing: ```bash -${CLAUDE_PLUGIN_ROOT}/scripts/ensue-api.sh '' +${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py '' ``` -## Batch Operations - -These methods support native batching (1-100 items per call): - -**create_memory** - batch create with `items` array: +Example: ```bash -${CLAUDE_PLUGIN_ROOT}/scripts/ensue-api.sh create_memory '{"items":[ - {"key_name":"ns/key1","value":"content1","embed":true}, - {"key_name":"ns/key2","value":"content2","embed":true} -]}' +${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py list_keys '{"limit":5}' ``` -**get_memory** - batch read with `key_names` array: -```bash -${CLAUDE_PLUGIN_ROOT}/scripts/ensue-api.sh get_memory '{"keys":["ns/key1","ns/key2","ns/key3"]}' -``` +## Batch Operations + +`create_memory`, `get_memory`, and `delete_memory` all support native batching (1-100 items per call). Use batch calls whenever possible to minimize API roundtrips and save tokens. -**delete_memory** - batch delete with `key_names` array: +**Tip:** Using HEREDOC syntax (`<<'EOF' ... EOF`) helps avoid shell escaping issues with complex JSON, especially when values contain quotes, newlines, or special characters. + +For example: ```bash -${CLAUDE_PLUGIN_ROOT}/scripts/ensue-api.sh delete_memory '{"keys":["ns/key1","ns/key2"]}' +${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py create_memory --items "$(cat <<'EOF' +[ + {"key_name": "ns/key1", "value": "content1", "description": "First item", "embed": true}, + {"key_name": "ns/key2", "value": "content2", "description": "Second item", "embed": true} +] +EOF +)" ``` -Use batch calls whenever possible to minimize API roundtrips and save tokens. - ## Context Optimization **CRITICAL: Minimize context window usage.** Users may have 100k+ keys. Never dump large lists into the conversation. From d90d4fecde4247a9741b71e413e2ddbcbc9075aa Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Fri, 30 Jan 2026 09:49:16 -0600 Subject: [PATCH 4/5] better instructions regarding the key file --- repackage_skill.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/repackage_skill.sh b/repackage_skill.sh index e2f7a19..e472cbd 100755 --- a/repackage_skill.sh +++ b/repackage_skill.sh @@ -18,6 +18,16 @@ mkdir -p skills/ensue-memory/scripts cp scripts/ensue-api.sh skills/ensue-memory/scripts/ensue-api.sh chmod +x skills/ensue-memory/scripts/ensue-api.sh echo "Copied ensue-api.sh to skills/ensue-memory/scripts/ and set execute permissions" +cp scripts/ensue-cli.py skills/ensue-memory/scripts/ensue-cli.py +chmod +x skills/ensue-memory/scripts/ensue-cli.py + +# check file presence before copying, set a flag for a warning if key file is missing +if [ ! -f ".ensue-key" ]; then + KEY_FILE_PRESENT=0 +else + cp .ensue-key skills/ensue-memory/.ensue-key + KEY_FILE_PRESENT=1 +fi echo "" echo "Creating zip archive..." @@ -35,3 +45,6 @@ rmdir --ignore-fail-on-non-empty skills/ensue-memory/scripts 2>/dev/null || true echo "" echo "Repackaging complete!" echo " Skill suitable for Claude Assistant @ $(pwd)/ensue-memory.zip" +if [ "$KEY_FILE_PRESENT" = "0" ]; then + echo "No .ensue-key was packaged. For the assistant, which has no env vars, this may prevent correct operation." +fi From 01dcf9ca3c1ad165dddbb75d1c06d19d8b12a59b Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Fri, 30 Jan 2026 10:45:05 -0600 Subject: [PATCH 5/5] PATH does not persist --- skills/ensue-memory/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/ensue-memory/SKILL.md b/skills/ensue-memory/SKILL.md index ae8c2c9..660333d 100644 --- a/skills/ensue-memory/SKILL.md +++ b/skills/ensue-memory/SKILL.md @@ -113,13 +113,17 @@ Uses `$ENSUE_API_KEY` env var or .ensue-key file. If missing, user gets a key at Use the CLI to interact with the memory system. Set as executable before use. You can use `--help` to discover commands and usage for each command to get more details about how to use the CLI. The CLI handles authentication and response parsing: +Use the full path when calling the CLI. Discover it once `echo ${CLAUDE_PLUGIN_ROOT:-/mnt/skills/user/ensue-memory}/scripts/ensue-cli.py"` +and make it executable. + +Usage: ```bash -${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py '' +ensue-cli.py '' ``` Example: ```bash -${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py list_keys '{"limit":5}' +ensue-cli.py list_keys --limit 5 ``` ## Batch Operations