diff --git a/.gitignore b/.gitignore index 7315407..56879d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .ensue-key +ensue-memory.zip +scripts/pipx.pyz +scripts/.pipx/ +skills/ensue-memory/scripts/* diff --git a/repackage_skill.sh b/repackage_skill.sh new file mode 100755 index 0000000..e472cbd --- /dev/null +++ b/repackage_skill.sh @@ -0,0 +1,50 @@ +#!/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" +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..." + +# 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" +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 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..88b6b03 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,3 @@ +"""Ensue CLI - Command line interface for the Ensue Memory Network.""" + +__version__ = "0.1.2" diff --git a/scripts/ensue-cli.py b/scripts/ensue-cli.py new file mode 100755 index 0000000..c8bdcf2 --- /dev/null +++ b/scripts/ensue-cli.py @@ -0,0 +1,315 @@ +#!/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: + 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, use_rich=False): + """Print MCP result, optionally with rich JSON formatting.""" + if hasattr(result, "content"): + for item in result.content: + if hasattr(item, "text"): + if use_rich: + try: + console.print(JSON(item.text)) + except Exception: + console.print(item.text) + else: + click.echo(item.text) + else: + if use_rich: + console.print(JSON(json.dumps(result, indent=2))) + else: + click.echo(json.dumps(result, indent=2)) + + +# ============================================================================ +# Dynamic Click CLI +# ============================================================================ + +TYPE_MAP = { + "integer": click.INT, + "number": click.FLOAT, + "boolean": click.BOOL, +} + + +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): + 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() + 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() + if v is not None + } + try: + result = run_async(call_tool(url, token, tool["name"], args)) + 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, use_rich=use_rich) + + 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: + 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): + if name not in self.tools: + return None + return build_command(self.tools[name]) + + +@click.group(cls=MCPToolsCLI) +@click.version_option() +@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. + 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..8276c62 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,41 @@ 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 '' -``` - -## Batch Operations - -These methods support native batching (1-100 items per call): +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. -**create_memory** - batch create with `items` array: +Usage: ```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} -]}' +ensue-cli.py '' ``` -**get_memory** - batch read with `key_names` array: +Examples: ```bash -${CLAUDE_PLUGIN_ROOT}/scripts/ensue-api.sh get_memory '{"keys":["ns/key1","ns/key2","ns/key3"]}' +ensue-cli.py list_keys --limit 5 +ensue-cli.py get_memory --key-names '["a"]' ``` -**delete_memory** - batch delete with `key_names` array: +## 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. + +**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.