From 1cb52a17f0bf5cf887e4bb717b0fdab4aa72b309 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 8 Jan 2026 08:39:38 -0600 Subject: [PATCH 01/17] 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 09dbe92b373423edbf65a8dbd09f9d14e3de6528 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 8 Jan 2026 08:44:56 -0600 Subject: [PATCH 02/17] 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 09f00475c2990b380de9d45869e1bf5bd6c32beb Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Mon, 12 Jan 2026 17:42:23 -0600 Subject: [PATCH 03/17] use the cli instead --- .gitignore | 2 + scripts/ensue-cli.py | 222 +++++++++++++++++++++++++++++++++++ skills/ensue-memory/SKILL.md | 38 ++++-- 3 files changed, 254 insertions(+), 8 deletions(-) create mode 100755 scripts/ensue-cli.py diff --git a/.gitignore b/.gitignore index 7315407..971f510 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .ensue-key +scripts/pipx.pyz +scripts/.pipx/ diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py new file mode 100755 index 0000000..f01586b --- /dev/null +++ b/scripts/ensue-cli.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "mcp>=1.0", +# ] +# /// +""" +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 '' + +Example: + ./ensue-cli.py list_keys '{"limit":5}' +""" + +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 json +from contextlib import asynccontextmanager +from typing import Any + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +# ============================================================================ +# MCP Client (embedded from ensue-cli/ensue_cli/client.py) +# ============================================================================ + +@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 Implementation +# ============================================================================ + +DEFAULT_URL = "https://api.ensue-network.ai/" + + +def get_config(): + """Get API configuration from environment.""" + 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 + key_file = repo_root / ".claude-plugin" / ".ensue-key" + if key_file.exists(): + token = key_file.read_text().strip() + + if not token: + print(json.dumps({"error": "ENSUE_API_KEY or ENSUE_TOKEN not set"}), file=sys.stderr) + sys.exit(1) + + return url, token + + +def format_result(result): + """Format MCP result as JSON string for output.""" + if hasattr(result, "content"): + # MCP response object - extract text content + for item in result.content: + if hasattr(item, "text"): + # Return the text content (should be JSON from server) + return item.text + # Fallback to JSON serialization + return json.dumps(result, default=str) + + +async def main_async(): + """Main async entry point.""" + if len(sys.argv) < 2: + print(json.dumps({ + "error": "Usage: ensue-cli.py [json_args]", + "example": "./ensue-cli.py list_keys '{\"limit\":5}'" + }), file=sys.stderr) + sys.exit(1) + + command = sys.argv[1] + args_str = sys.argv[2] if len(sys.argv) > 2 else "{}" + + # Parse JSON arguments + try: + arguments = json.loads(args_str) + except json.JSONDecodeError as e: + print(json.dumps({ + "error": f"Invalid JSON arguments: {e}", + "received": args_str + }), file=sys.stderr) + sys.exit(1) + + # Get configuration + try: + url, token = get_config() + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": f"Configuration error: {e}"}), file=sys.stderr) + sys.exit(1) + + # Call the tool + try: + result = await call_tool(url, token, command, arguments) + output = format_result(result) + print(output) + except Exception as e: + print(json.dumps({ + "error": str(e), + "command": command, + "arguments": arguments + }), file=sys.stderr) + sys.exit(1) + + +def main(): + """Main entry point.""" + asyncio.run(main_async()) + + +if __name__ == "__main__": + main() diff --git a/skills/ensue-memory/SKILL.md b/skills/ensue-memory/SKILL.md index 0185751..73914cb 100644 --- a/skills/ensue-memory/SKILL.md +++ b/skills/ensue-memory/SKILL.md @@ -111,10 +111,15 @@ Uses `$ENSUE_API_KEY` env var. If missing, user gets one at https://www.ensue-ne ## API Call -Use the wrapper script for all API calls. Set as executable before use. It handles authentication and SSE response parsing: +Use the wrapper CLI for all API calls. Set as executable before use. You can use `--help` for each command to get more details about how to use the CLI. The CLI handles authentication and response parsing: ```bash -./scripts/ensue-api.sh '' +./scripts/ensue-cli.py '' +``` + +Example: +```bash +./scripts/ensue-cli.py list_keys '{"limit":5}' ``` ## Batch Operations @@ -123,22 +128,39 @@ These methods support native batching (1-100 items per call): **create_memory** - batch create with `items` array: ```bash -./scripts/ensue-api.sh create_memory '{"items":[ - {"key_name":"ns/key1","value":"content1","embed":true}, - {"key_name":"ns/key2","value":"content2","embed":true} -]}' +./scripts/ensue-cli.py create_memory "$(cat <<'EOF' +{ + "items": [ + {"key_name": "ns/key1", "value": "content1", "embed": true}, + {"key_name": "ns/key2", "value": "content2", "embed": true} + ] +} +EOF +)" ``` **get_memory** - batch read with `key_names` array: ```bash -./scripts/ensue-api.sh get_memory '{"key_names":["ns/key1","ns/key2","ns/key3"]}' +./scripts/ensue-cli.py get_memory "$(cat <<'EOF' +{ + "key_names": ["ns/key1", "ns/key2", "ns/key3"] +} +EOF +)" ``` **delete_memory** - batch delete with `key_names` array: ```bash -./scripts/ensue-api.sh delete_memory '{"key_names":["ns/key1","ns/key2"]}' +./scripts/ensue-cli.py delete_memory "$(cat <<'EOF' +{ + "key_names": ["ns/key1", "ns/key2"] +} +EOF +)" ``` +**Tip:** Using HEREDOC syntax (`<<'EOF' ... EOF`) helps avoid shell escaping issues with complex JSON, especially when values contain quotes, newlines, or special characters. + Use batch calls whenever possible to minimize API roundtrips and save tokens. ## Context Optimization From 2864dc60591d439f4fac3c531640a4b98956eeec Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 29 Jan 2026 17:51:22 -0600 Subject: [PATCH 04/17] fix key file variable names --- scripts/ensue-cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py index f01586b..0c2ab6b 100755 --- a/scripts/ensue-cli.py +++ b/scripts/ensue-cli.py @@ -145,12 +145,15 @@ def get_config(): # Try reading from .ensue-key file (fallback for subagents) script_dir = Path(__file__).parent repo_root = script_dir.parent - key_file = repo_root / ".claude-plugin" / ".ensue-key" - if key_file.exists(): - token = key_file.read_text().strip() + 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 = skil_key_file.read_text().strip() if not token: - print(json.dumps({"error": "ENSUE_API_KEY or ENSUE_TOKEN not set"}), file=sys.stderr) + print(json.dumps({"error": f"ENSUE_API_KEY or ENSUE_TOKEN env var not set, and {plugin_key_file} and {skill_key_file}"}), file=sys.stderr) sys.exit(1) return url, token From 34c54e99a5dc73fb235d61462b1d7ff3b1da674a Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 29 Jan 2026 17:51:47 -0600 Subject: [PATCH 05/17] ignore side effects of packaging skill for assistant --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8898c61..56879d6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ ensue-memory.zip scripts/pipx.pyz scripts/.pipx/ +skills/ensue-memory/scripts/* From 6b39b24a0a8883ed865950aecd32e36bfcdebd52 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 29 Jan 2026 17:59:47 -0600 Subject: [PATCH 06/17] warn if key file missing from package --- 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 a7fde7a48c0ca7621616afaa96b884588244b84d Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 29 Jan 2026 19:47:26 -0600 Subject: [PATCH 07/17] carry mcp server error resposnes more clearly --- scripts/ensue-cli.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py index 3814cc4..594b0b5 100755 --- a/scripts/ensue-cli.py +++ b/scripts/ensue-cli.py @@ -93,6 +93,7 @@ def main_wrapper(): 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 @@ -233,7 +234,11 @@ def callback(**kwargs): for k, v in kwargs.items() if v is not None } - result = run_async(call_tool(url, token, tool["name"], args)) + 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( From bc07217aec5907eed7e4816f0fac0c2cc967c532 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Fri, 30 Jan 2026 09:21:38 -0600 Subject: [PATCH 08/17] Match CLI --- skills/ensue-memory/SKILL.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/skills/ensue-memory/SKILL.md b/skills/ensue-memory/SKILL.md index e104cc6..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,10 +109,9 @@ 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 - -Use the wrapper CLI for all API calls. Set as executable before use. You can use `--help` for each command to get more details about how to use the CLI. The CLI handles authentication and response parsing: +## Interacting with the memory system +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-cli.py '' @@ -131,14 +130,13 @@ ${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py list_keys '{"limit":5}' For example: ```bash -${CLAUDE_PLUGIN_ROOT:-.}/scripts/ensue-cli.py create_memory "$(cat <<'EOF' -{ - "items": [ - {"key_name": "ns/key1", "value": "content1", "embed": true}, - {"key_name": "ns/key2", "value": "content2", "embed": true} - ] -} +${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 +)" ``` ## Context Optimization From 5a5b1b72b5983efb4521993aa3f8545e216648fb Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Thu, 8 Jan 2026 08:39:38 -0600 Subject: [PATCH 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 From c6aef31a83dbcf0de1d51a4c80965ae6d27dc73d Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Sat, 31 Jan 2026 13:09:33 -0600 Subject: [PATCH 14/17] catch and report clean MCP errors without stack trace --- scripts/ensue-cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py index 594b0b5..429d02a 100755 --- a/scripts/ensue-cli.py +++ b/scripts/ensue-cli.py @@ -236,8 +236,9 @@ def callback(**kwargs): } try: result = run_async(call_tool(url, token, tool["name"], args)) - except McpError as e: - click.echo(f"Error: {e}", err=True) + except* McpError as eg: + for err in eg.exceptions: + click.echo(f"Error (from Ensue MCP server): {err}", err=True) sys.exit(1) print_result(result) From 733676b4aec48519fe1cb5b5907aedb8d02d9a59 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Sat, 31 Jan 2026 13:09:33 -0600 Subject: [PATCH 15/17] catch and report clean MCP errors without stack trace --- scripts/ensue-cli.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py index 429d02a..7dca1bd 100755 --- a/scripts/ensue-cli.py +++ b/scripts/ensue-cli.py @@ -201,6 +201,18 @@ def print_result(result): } +def _find_mcp_errors(exc): + """Recursively extract McpError instances from (nested) exception groups.""" + if isinstance(exc, McpError): + return [exc] + if isinstance(exc, BaseExceptionGroup): + errors = [] + for sub in exc.exceptions: + errors.extend(_find_mcp_errors(sub)) + return errors + return [] + + 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): @@ -236,10 +248,13 @@ def callback(**kwargs): } try: result = run_async(call_tool(url, token, tool["name"], args)) - except* McpError as eg: - for err in eg.exceptions: - click.echo(f"Error (from Ensue MCP server): {err}", err=True) - sys.exit(1) + except BaseException as e: + mcp_errors = _find_mcp_errors(e) + if mcp_errors: + for err in mcp_errors: + click.echo(f"Error (from Ensue MCP server): {err}", err=True) + sys.exit(1) + raise print_result(result) return click.Command( From 0a2006c5f4e26b07b1b5cb63bee7f11f105734fe Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Sat, 31 Jan 2026 13:25:09 -0600 Subject: [PATCH 16/17] add a get_memory example to establish that the batch commands use JSON lists --- skills/ensue-memory/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/ensue-memory/SKILL.md b/skills/ensue-memory/SKILL.md index 660333d..8276c62 100644 --- a/skills/ensue-memory/SKILL.md +++ b/skills/ensue-memory/SKILL.md @@ -121,9 +121,10 @@ Usage: ensue-cli.py '' ``` -Example: +Examples: ```bash ensue-cli.py list_keys --limit 5 +ensue-cli.py get_memory --key-names '["a"]' ``` ## Batch Operations From 90aff066f860b16468932ee4f59b282dbbf7cfd6 Mon Sep 17 00:00:00 2001 From: Russell Pierce Date: Mon, 2 Feb 2026 09:02:12 -0600 Subject: [PATCH 17/17] preserve whitespace --- scripts/ensue-cli.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py index 7dca1bd..c8bdcf2 100755 --- a/scripts/ensue-cli.py +++ b/scripts/ensue-cli.py @@ -170,24 +170,30 @@ def get_config(): 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") + click.echo("Error: ENSUE_API_KEY or ENSUE_TOKEN environment variable required, " + "or place key in .ensue-key file", err=True) sys.exit(1) return url, token -def print_result(result): - """Print MCP result with rich JSON formatting.""" +def print_result(result, use_rich=False): + """Print MCP result, optionally 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) + if use_rich: + try: + console.print(JSON(item.text)) + except Exception: + console.print(item.text) + else: + click.echo(item.text) else: - console.print(JSON(json.dumps(result, indent=2))) + if use_rich: + console.print(JSON(json.dumps(result, indent=2))) + else: + click.echo(json.dumps(result, indent=2)) # ============================================================================ @@ -241,6 +247,7 @@ def build_command(tool): def callback(**kwargs): url, token = get_config() + use_rich = click.get_current_context().find_root().params.get("use_rich", False) args = { k.replace("-", "_"): parse_arg(v, props.get(k.replace("-", "_"), {}).get("type")) for k, v in kwargs.items() @@ -255,7 +262,7 @@ def callback(**kwargs): click.echo(f"Error (from Ensue MCP server): {err}", err=True) sys.exit(1) raise - print_result(result) + print_result(result, use_rich=use_rich) return click.Command( name=tool["name"], @@ -283,8 +290,8 @@ 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]") + click.echo("Connection error: Could not connect to MCP server", err=True) + click.echo(str(e), err=True) return [] def get_command(self, ctx, name): @@ -295,7 +302,8 @@ def get_command(self, ctx, name): @click.group(cls=MCPToolsCLI) @click.version_option() -def main(): +@click.option("--rich", "use_rich", is_flag=True, default=False, help="Enable rich terminal formatting.") +def main(use_rich): """Ensue Memory CLI - A distributed memory network for AI agents. Commands are loaded dynamically from the MCP server.