@xmemo/client
Advanced tools
| <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="440" viewBox="0 0 1200 440" role="img" aria-labelledby="title desc"> | ||
| <title id="title">XMemo CLI architecture</title> | ||
| <desc id="desc">AI clients connect through XMemo CLI using native integrations, hosted MCP, or a local stdio server, while credentials remain outside project files.</desc> | ||
| <defs> | ||
| <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> | ||
| <path d="M 0 0 L 10 5 L 0 10 z" class="arrow-head"/> | ||
| </marker> | ||
| <style> | ||
| .canvas { fill: #f8fafc; stroke: #e2e8f0; } | ||
| .card { fill: #ffffff; stroke: #cbd5e1; } | ||
| .core { fill: #f5f3ff; stroke: #8b5cf6; } | ||
| .endpoint { fill: #fdf4ff; stroke: #d946ef; } | ||
| .title { fill: #0f172a; font: 700 22px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .label { fill: #0f172a; font: 650 17px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .body { fill: #475569; font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .caption { fill: #64748b; font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .line { fill: none; stroke: #94a3b8; stroke-width: 2; marker-end: url(#arrow); } | ||
| .arrow-head { fill: #94a3b8; } | ||
| .accent-blue { fill: #06b6d4; } | ||
| .accent-violet { fill: #8b5cf6; } | ||
| .accent-pink { fill: #ec4899; } | ||
| @media (prefers-color-scheme: dark) { | ||
| .canvas { fill: #0d1117; stroke: #30363d; } | ||
| .card { fill: #161b22; stroke: #3d444d; } | ||
| .core { fill: #211a36; stroke: #a78bfa; } | ||
| .endpoint { fill: #2a1830; stroke: #e879f9; } | ||
| .title, .label { fill: #f0f6fc; } | ||
| .body { fill: #b1bac4; } | ||
| .caption { fill: #8b949e; } | ||
| .line { stroke: #6e7681; } | ||
| .arrow-head { fill: #6e7681; } | ||
| } | ||
| </style> | ||
| </defs> | ||
| <rect class="canvas" x="1" y="1" width="1198" height="438" rx="22"/> | ||
| <text class="title" x="48" y="56">One CLI, three connection paths</text> | ||
| <text class="caption" x="48" y="82">Choose the integration that fits the client without moving credentials into the repository.</text> | ||
| <rect class="card" x="48" y="126" width="246" height="234" rx="16"/> | ||
| <rect class="accent-blue" x="48" y="126" width="7" height="234" rx="3"/> | ||
| <text class="label" x="80" y="168">AI clients</text> | ||
| <text class="body" x="80" y="204">Codex · Cursor · Gemini</text> | ||
| <text class="body" x="80" y="232">Copilot · Kiro · Grok</text> | ||
| <text class="body" x="80" y="260">OpenClaw · Hermes</text> | ||
| <text class="body" x="80" y="288">and other MCP clients</text> | ||
| <text class="caption" x="80" y="328">Editors, CLIs, and agents</text> | ||
| <rect class="core" x="366" y="126" width="246" height="234" rx="16"/> | ||
| <rect class="accent-violet" x="366" y="126" width="7" height="234" rx="3"/> | ||
| <text class="label" x="398" y="168">XMemo CLI</text> | ||
| <text class="body" x="398" y="204">Install and update</text> | ||
| <text class="body" x="398" y="232">Authenticate and diagnose</text> | ||
| <text class="body" x="398" y="260">Generate safe client config</text> | ||
| <text class="body" x="398" y="288">Install behavior profiles</text> | ||
| <text class="caption" x="398" y="328">npm package: @xmemo/client</text> | ||
| <rect class="card" x="684" y="112" width="228" height="78" rx="14"/> | ||
| <text class="label" x="712" y="144">Native adapters</text> | ||
| <text class="caption" x="712" y="169">OpenClaw · Hermes</text> | ||
| <rect class="card" x="684" y="204" width="228" height="78" rx="14"/> | ||
| <text class="label" x="712" y="236">Hosted MCP</text> | ||
| <text class="caption" x="712" y="261">https://xmemo.dev/mcp</text> | ||
| <rect class="card" x="684" y="296" width="228" height="78" rx="14"/> | ||
| <text class="label" x="712" y="328">Local stdio</text> | ||
| <text class="caption" x="712" y="353">xmemo-mcp</text> | ||
| <rect class="endpoint" x="984" y="126" width="168" height="234" rx="16"/> | ||
| <rect class="accent-pink" x="984" y="126" width="7" height="234" rx="3"/> | ||
| <text class="label" x="1016" y="168">XMemo</text> | ||
| <text class="body" x="1016" y="204">Durable memory</text> | ||
| <text class="body" x="1016" y="232">Search and recall</text> | ||
| <text class="body" x="1016" y="260">Project context</text> | ||
| <text class="body" x="1016" y="288">Governance</text> | ||
| <text class="caption" x="1016" y="328">User-owned context</text> | ||
| <path class="line" d="M294 243 H354"/> | ||
| <path class="line" d="M612 243 H650 V151 H672"/> | ||
| <path class="line" d="M612 243 H672"/> | ||
| <path class="line" d="M612 243 H650 V335 H672"/> | ||
| <path class="line" d="M912 151 H948 V210 H972"/> | ||
| <path class="line" d="M912 243 H972"/> | ||
| <path class="line" d="M912 335 H948 V276 H972"/> | ||
| <text class="caption" x="48" y="405">Credentials stay in user-scoped storage, environment variables, OAuth, or an enterprise secret manager.</text> | ||
| </svg> |
| <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="270" viewBox="0 0 1200 270" role="img" aria-labelledby="title desc"> | ||
| <title id="title">XMemo CLI setup workflow</title> | ||
| <desc id="desc">Install, authenticate, diagnose, connect a client, then recall and remember with XMemo.</desc> | ||
| <defs> | ||
| <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> | ||
| <path d="M 0 0 L 10 5 L 0 10 z" class="arrow-head"/> | ||
| </marker> | ||
| <style> | ||
| .canvas { fill: #f8fafc; stroke: #e2e8f0; } | ||
| .step { fill: #ffffff; stroke: #cbd5e1; } | ||
| .number { fill: #7c3aed; } | ||
| .number-text { fill: #ffffff; font: 700 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .label { fill: #0f172a; font: 650 17px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| .code { fill: #475569; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } | ||
| .line { fill: none; stroke: #94a3b8; stroke-width: 2; marker-end: url(#arrow); } | ||
| .arrow-head { fill: #94a3b8; } | ||
| @media (prefers-color-scheme: dark) { | ||
| .canvas { fill: #0d1117; stroke: #30363d; } | ||
| .step { fill: #161b22; stroke: #3d444d; } | ||
| .number { fill: #8b5cf6; } | ||
| .label { fill: #f0f6fc; } | ||
| .code { fill: #b1bac4; } | ||
| .line { stroke: #6e7681; } | ||
| .arrow-head { fill: #6e7681; } | ||
| } | ||
| </style> | ||
| </defs> | ||
| <rect class="canvas" x="1" y="1" width="1198" height="268" rx="22"/> | ||
| <rect class="step" x="42" y="72" width="190" height="126" rx="15"/> | ||
| <circle class="number" cx="70" cy="101" r="16"/> | ||
| <text class="number-text" x="66" y="106">1</text> | ||
| <text class="label" x="94" y="108">Install</text> | ||
| <text class="code" x="64" y="153">npm install -g</text> | ||
| <text class="code" x="64" y="175">@xmemo/client</text> | ||
| <rect class="step" x="274" y="72" width="190" height="126" rx="15"/> | ||
| <circle class="number" cx="302" cy="101" r="16"/> | ||
| <text class="number-text" x="298" y="106">2</text> | ||
| <text class="label" x="326" y="108">Authenticate</text> | ||
| <text class="code" x="296" y="164">xmemo login</text> | ||
| <rect class="step" x="506" y="72" width="190" height="126" rx="15"/> | ||
| <circle class="number" cx="534" cy="101" r="16"/> | ||
| <text class="number-text" x="530" y="106">3</text> | ||
| <text class="label" x="558" y="108">Diagnose</text> | ||
| <text class="code" x="528" y="164">xmemo doctor</text> | ||
| <rect class="step" x="738" y="72" width="190" height="126" rx="15"/> | ||
| <circle class="number" cx="766" cy="101" r="16"/> | ||
| <text class="number-text" x="762" y="106">4</text> | ||
| <text class="label" x="790" y="108">Connect</text> | ||
| <text class="code" x="760" y="153">xmemo setup</text> | ||
| <text class="code" x="760" y="175"><client></text> | ||
| <rect class="step" x="970" y="72" width="188" height="126" rx="15"/> | ||
| <circle class="number" cx="998" cy="101" r="16"/> | ||
| <text class="number-text" x="994" y="106">5</text> | ||
| <text class="label" x="1022" y="108">Use XMemo</text> | ||
| <text class="code" x="992" y="153">recall · remember</text> | ||
| <text class="code" x="992" y="175">context · timeline</text> | ||
| <path class="line" d="M232 135 H262"/> | ||
| <path class="line" d="M464 135 H494"/> | ||
| <path class="line" d="M696 135 H726"/> | ||
| <path class="line" d="M928 135 H958"/> | ||
| </svg> |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| # XMemo Skill Change Log | ||
| ## 1.1.0 | ||
| - `scripts/xmemo-skill.mjs`: align the advertised and runtime version at `1.1.0` while preserving the `XMemo Memory` package identity and formal-account-first login policy. | ||
| - `scripts/xmemo-skill.mjs`: add the discovery-compatible `auth-status` alias and `auth claim-deny` for the server's two-phase temporary-account bind flow. | ||
| - `scripts/xmemo-skill.mjs`: read temporary item/expiry limits from `/.well-known/xmemo-agent.json`, disclose them immediately after registration, and use the documented production limits as a non-blocking fallback when discovery is unavailable. | ||
| - `scripts/xmemo-skill.mjs`: route temporary `search` to `/v1/memories/search`, keep `recall` on `/v1/recall`, and retain temporary access only for `remember`, `recall`, and `search`. | ||
| - `scripts/xmemo-skill.mjs`: parse `--metadata` as a JSON object, parse `--explain` and `--prefer_working` as strict booleans, and validate state `--ttl_seconds` against the hosted `0..604800` contract. | ||
| - `scripts/xmemo-skill.mjs`: retain the established formal device-login scopes, including `ledger:read`; no server API contract or destructive memory command was added. | ||
| - `SKILL.md` and references: document the formal-account default, temporary limits, status alias, bind-denial flow, and typed argument examples without exposing credential values. | ||
| - Tests: cover dynamic temporary limits, temporary search routing, bind denial and pending-token cleanup, typed arguments, the `auth-status` alias, version output, and documentation invariants. | ||
| ## 1.0.9 | ||
| - Removed the non-runtime `skill-card.md` file. No user-facing, documentation, or runtime behavior changed in this marketplace release. | ||
| ## 1.0.8 | ||
| - `scripts/xmemo-skill.mjs`: advance the standalone runtime to `1.0.8` while preserving the existing REST operations, formal-login flow, temporary sandbox, and explicit plaintext fallback. | ||
| - `scripts/xmemo-skill.mjs`: stop displaying token prefixes and prevent `logout` from revoking an externally managed `XMEMO_KEY` unless `--revoke-environment-token` is explicitly supplied. | ||
| - `scripts/xmemo-skill.mjs`: add `doctor --anonymous`, command-specific login/register/logout help, `--version`, strict command parameter allowlists, required-argument validation, and sensitive command-line option rejection. | ||
| - `scripts/xmemo-skill.mjs`: require HTTPS for remote custom origins while retaining loopback HTTP for local development, warn before authenticated custom-origin requests, and add bounded request timeouts plus an 8 MiB response limit. | ||
| - `scripts/xmemo-skill.mjs`: honor device-login expiry, preserve the established formal-account memory and ledger scope set, redact sensitive fields from every JSON operation response, and sanitize human-readable server content for terminal safety. | ||
| - `SKILL.md` and references: document the compatible logout/anonymous-doctor behavior, timeout and origin boundaries, Node.js requirement, and copyable POSIX/PowerShell token-input examples. | ||
| - Tests: cover anonymous diagnostics, external environment-token logout, token-prefix suppression, unsafe origin and secret-option rejection, timeout/response limits, JSON redaction, the established formal-login scope set, device-login expiry, command help, and version output. | ||
| - `scripts/xmemo-skill.mjs`: keep `XMEMO_KEY` as the highest-priority credential source and never copy an environment token into local storage. | ||
| - `scripts/xmemo-skill.mjs`: require explicit `--allow-plaintext` consent before `login`, `auth add`, or temporary registration writes any bearer credential; replace the inaccurate “stored securely” claim with the exact storage path and an unencrypted-storage warning. | ||
| - `scripts/xmemo-skill.mjs`: restrict the XMemo credential directory/file to `0700`/`0600` where POSIX permissions are supported, record consent metadata, and warn when reading a legacy unmarked plaintext credential. | ||
| - `scripts/xmemo-skill.mjs`: minimize temporary credential metadata, redact token-shaped fields from JSON claim/error output, and clear pending confirmation data after handoff. | ||
| - `SKILL.md` and references: document credential precedence, explicit plaintext consent, temporary bind-URL handling, and migration guidance while keeping formal account login recommended. | ||
| - `scripts/xmemo-skill.mjs`: add an explicit, policy-gated `register --reason unattended|declined` fallback for the server's unauthenticated agent registration. Formal `login` remains the primary path. | ||
| - `scripts/xmemo-skill.mjs`: persist temporary credentials locally, route their allowed `remember`/`recall`/`search` requests to the temporary REST sandbox, reject unsupported commands clearly, and support claim-status/claim-confirm formal-token handoff. | ||
| - `SKILL.md` and references: document the temporary sandbox limits, required user disclosure, bind URL, and formal-account upgrade path. | ||
| - `scripts/xmemo-skill.mjs`: normalize successful list payloads (`result.results`, `result.todos`, or a bare array), so `recall`, `search`, and `todo-list` never call `forEach` on an API wrapper object. | ||
| - `scripts/xmemo-skill.mjs`: extract IDs from object or string results for `remember` and `expense-add`, preventing `[object Object]` output. | ||
| - `scripts/xmemo-skill.mjs`: parse every REST response through one guarded JSON helper. Empty or non-JSON gateway responses now include the HTTP status and a bounded server-response preview. | ||
| - `scripts/xmemo-skill.mjs`: add global and command-level `--help`, clear unknown-command errors, and `--compact` rendering for recall/search. | ||
| - `SKILL.md` and `references/*.md`: make every command relative to the Skill root (`node scripts/xmemo-skill.mjs ...`) and document compact output and help. | ||
| - `test/xmemo-standalone-skill.test.js`: add regression coverage for wrapped list payloads, object IDs, help output, and non-JSON responses. |
+6
-4
| { | ||
| "name": "@xmemo/client", | ||
| "version": "0.4.178", | ||
| "version": "0.4.179", | ||
| "description": "Privacy-first CLI and MCP setup helper for XMemo.", | ||
@@ -8,8 +8,9 @@ "mcpName": "io.github.yonro/xmemo", | ||
| "bin": { | ||
| "xmemo-mcp": "bin/mcp-stdio.js", | ||
| "xmemo": "bin/memory-os.js", | ||
| "memory-os": "bin/memory-os.js", | ||
| "xmemo-mcp": "bin/mcp-stdio.js" | ||
| "memory-os": "bin/memory-os.js" | ||
| }, | ||
| "files": [ | ||
| "bin", | ||
| "docs/assets", | ||
| "src", | ||
@@ -26,3 +27,4 @@ "skills", | ||
| "pack:dry-run": "npm pack --dry-run", | ||
| "prepublishOnly": "npm run lint && npm test && npm run pack:dry-run" | ||
| "release:check": "node scripts/check-release-version.mjs", | ||
| "prepublishOnly": "npm run release:check && npm run lint && npm test && npm run pack:dry-run" | ||
| }, | ||
@@ -29,0 +31,0 @@ "engines": { |
@@ -14,3 +14,3 @@ { | ||
| "license": "UNLICENSED", | ||
| "logo": "assets/logo.svg", | ||
| "logo": "assets/logo.png", | ||
| "keywords": [ | ||
@@ -17,0 +17,0 @@ "kiro", |
@@ -8,3 +8,3 @@ # XMemo Kiro Power | ||
| - `mcp.json` adds the hosted XMemo MCP server at `https://xmemo.dev/mcp`. | ||
| - `assets/logo.svg` reuses the canonical XMemo marketplace icon source used by the existing ChatGPT/Claude listing assets. | ||
| - `assets/logo.png` is the canonical XMemo product mark used by the hosted application and marketplace listings. | ||
| - `steering/AGENTS.md` tells Kiro when to use XMemo memory. | ||
@@ -11,0 +11,0 @@ |
@@ -45,3 +45,3 @@ # XMemo Kiro Power Setup Guide | ||
| ```bash | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin --allow-plaintext | ||
| ``` | ||
@@ -48,0 +48,0 @@ |
@@ -14,3 +14,3 @@ { | ||
| "license": "UNLICENSED", | ||
| "logo": "assets/logo.svg", | ||
| "logo": "assets/logo.png", | ||
| "keywords": [ | ||
@@ -17,0 +17,0 @@ "cursor", |
@@ -8,3 +8,3 @@ # XMemo Cursor Plugin | ||
| - `mcp.json` adds the hosted XMemo MCP server at `https://xmemo.dev/mcp`. | ||
| - `assets/logo.svg` reuses the canonical XMemo marketplace icon source used by the existing ChatGPT/Claude listing assets. | ||
| - `assets/logo.png` is the canonical XMemo product mark used by the hosted application and marketplace listings. | ||
| - `rules/AGENTS.mdc` tells Cursor when to use XMemo memory. | ||
@@ -11,0 +11,0 @@ - `skills/agents/SKILL.md` gives Cursor a safe workflow for recall, writes, TODOs, and destructive memory actions. |
+338
-531
@@ -1,708 +0,515 @@ | ||
| # XMemo CLI | ||
| <div align="center"> | ||
| <a href="https://xmemo.dev"> | ||
| <img src="./plugins/xmemo/assets/logo.png" width="132" alt="XMemo logo"> | ||
| </a> | ||
| [](https://smithery.ai/servers/xmemo/xmemo) | ||
| [](https://lobehub.com/mcp/yonro-memory-os-cli) | ||
| <h1>XMemo CLI</h1> | ||
| `@xmemo/client` is the privacy-first command line entry point for XMemo client | ||
| setup. It is intentionally small: the npm package contains only the CLI and | ||
| setup/helper assets needed on a user's machine: the CLI runtime, client setup | ||
| profiles, XMemo skills, and marketplace plugin metadata. | ||
| <p><strong>One private memory layer for every AI agent.</strong></p> | ||
| <p> | ||
| Install, authenticate, diagnose, and connect XMemo across editors, | ||
| CLIs, and autonomous agents from one production-ready command line. | ||
| </p> | ||
| `@yonro/xmemo-client` is reserved as a Yonro fallback package. The CLI exposes | ||
| `xmemo` as the primary command and keeps `memory-os` as a compatibility alias. | ||
| <p> | ||
| <a href="https://github.com/yonro/memory-os-cli/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/yonro/memory-os-cli/ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=CI"></a> | ||
| <a href="https://www.npmjs.com/package/@xmemo/client"><img alt="npm version" src="https://img.shields.io/npm/v/@xmemo/client?style=flat-square&logo=npm&logoColor=white&label=npm"></a> | ||
| <a href="https://www.npmjs.com/package/@xmemo/client"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@xmemo/client?style=flat-square&logo=npm&logoColor=white&label=downloads"></a> | ||
| <a href="https://www.npmjs.com/package/@xmemo/client"><img alt="Node.js version" src="https://img.shields.io/node/v/@xmemo/client?style=flat-square&logo=nodedotjs&logoColor=white&label=node"></a> | ||
| <a href="./LICENSE"><img alt="MIT license" src="https://img.shields.io/npm/l/@xmemo/client?style=flat-square&label=license"></a> | ||
| <a href="https://github.com/yonro/memory-os-cli/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/yonro/memory-os-cli?style=flat-square&logo=github&label=stars"></a> | ||
| </p> | ||
| The XMemo server, database, token registry, deployment files, logs, and | ||
| internal scripts are not part of this npm package. | ||
| <p> | ||
| <a href="https://modelcontextprotocol.io/"><img alt="MCP compatible" src="https://img.shields.io/badge/MCP-compatible-2563eb?style=flat-square"></a> | ||
| <a href="https://xmemo.dev"><img alt="XMemo Cloud" src="https://img.shields.io/badge/XMemo-Cloud-7c3aed?style=flat-square"></a> | ||
| <a href="#security-by-default"><img alt="Privacy first" src="https://img.shields.io/badge/privacy-first-334155?style=flat-square&logo=shield&logoColor=white"></a> | ||
| <a href="https://lobehub.com/mcp/yonro-memory-os-cli"><img alt="LobeHub" src="https://img.shields.io/badge/LobeHub-MCP-ec4899?style=flat-square"></a> | ||
| <a href="https://glama.ai/mcp/servers/yonro/memory-os-cli"><img alt="Glama quality score" src="https://glama.ai/mcp/servers/yonro/memory-os-cli/badges/score.svg"></a> | ||
| </p> | ||
| > **XMemo CLI is the top-level control plane** — use `xmemo login`, `xmemo doctor`, `xmemo setup <client>`, and smoke checks before hand-editing MCP config. Hosted MCP remains the universal runtime path for clients that do not have a native integration. | ||
| <p> | ||
| <a href="#quick-start">Quick start</a> · | ||
| <a href="#supported-integrations">Integrations</a> · | ||
| <a href="#connection-modes">Connection modes</a> · | ||
| <a href="#command-reference">Commands</a> · | ||
| <a href="#security-by-default">Security</a> | ||
| </p> | ||
| </div> | ||
| ## XMemo Runtime Overview | ||
| --- | ||
| **XMemo** is a user-owned memory system that lets AI agents persistently store, search, recall, update, and manage notes and memory fragments across sessions, projects, and tools. | ||
| `@xmemo/client` is the official control plane for connecting AI tools to | ||
| [XMemo](https://xmemo.dev). It makes setup repeatable, keeps credentials out of | ||
| project files, and gives every supported client a consistent path to durable, | ||
| user-owned memory. | ||
| - **Top-level CLI**: `xmemo` from `@xmemo/client` | ||
| - **Native integrations**: OpenClaw XMemo memory plugin and Hermes `hermes-xmemo` provider | ||
| - **MCP Endpoint**: `https://xmemo.dev/mcp` (Streamable HTTP) | ||
| - **Auth**: Bearer Token (`XMEMO_KEY`) or MCP OAuth | ||
| - **Tools** (20): `get_mcp_identity`, `remember`, `recall`, `recall_context`, `memory_stats`, `update_memory`, `explain_memory`, `restore_memory`, `add_expense`, `list_ledger_transactions`, `get_monthly_ledger_summary`, `forget`, `create_memory_todo`, `list_memory_todos`, `complete_memory_todo`, `list_memory_versions`, `get_timeline`, `record_event`, `update_state`, `get_project_context` | ||
| - **Clients**: Kimi, Claude, Cursor, Copilot, Gemini, Grok, Windsurf, Cline, Trae, Zed, Qwen, and more | ||
| The package is deliberately small: the CLI runtime, safe client configuration, | ||
| behavior profiles, XMemo skills, and marketplace metadata. Server code, | ||
| databases, deployment files, logs, and internal operations remain outside the | ||
| npm distribution. | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "XMemo": { | ||
| "type": "streamable-http", | ||
| "url": "https://xmemo.dev/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer ${XMEMO_KEY}" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ## Architecture | ||
| See [MCP Setup](#mcp-setup) for detailed client configuration. | ||
| <p align="center"> | ||
| <img src="./docs/assets/xmemo-cli-architecture.svg" width="100%" alt="XMemo CLI architecture"> | ||
| </p> | ||
| ## Install | ||
| | | | | ||
| | --- | --- | | ||
| | **Package** | [`@xmemo/client`](https://www.npmjs.com/package/@xmemo/client) | | ||
| | **Primary command** | `xmemo` | | ||
| | **Local MCP command** | `xmemo-mcp` | | ||
| | **Hosted MCP** | `https://xmemo.dev/mcp` | | ||
| | **Runtime** | Node.js 20 or later | | ||
| | **License** | MIT | | ||
| ```bash | ||
| npm install -g @xmemo/client | ||
| ``` | ||
| ## Why XMemo CLI | ||
| Upgrade an existing global install: | ||
| - **One control plane** — login, diagnostics, configuration, profiles, updates, | ||
| and smoke checks share one predictable interface. | ||
| - **Private by design** — generated project configuration references a | ||
| credential; it never embeds the credential value. | ||
| - **Native where it matters** — OpenClaw and Hermes use dedicated memory | ||
| integrations instead of duplicating the same capability through MCP. | ||
| - **Portable everywhere else** — hosted Streamable HTTP MCP and local stdio | ||
| cover modern editors, terminals, and agent runtimes. | ||
| - **Safe automation** — supported setup and removal paths offer preview, | ||
| dry-run, or explicit confirmation before making changes. | ||
| - **Small supply-chain surface** — the npm package is governed by an explicit | ||
| file allowlist and release provenance. | ||
| ```bash | ||
| xmemo update | ||
| ``` | ||
| ## Quick start | ||
| This runs `npm install -g @xmemo/client@latest`. Use `xmemo update --dry-run` | ||
| to print the exact command without changing anything. | ||
| ## Commands | ||
| ```bash | ||
| xmemo update | ||
| npm install -g @xmemo/client | ||
| xmemo login | ||
| xmemo doctor | ||
| xmemo setup codex | ||
| xmemo setup codex --dry-run | ||
| xmemo setup cursor | ||
| xmemo setup cursor --dry-run | ||
| xmemo setup copilot | ||
| xmemo setup copilot --dry-run | ||
| xmemo setup openclaw | ||
| xmemo setup openclaw --dry-run | ||
| xmemo setup openclaw --with-mcp | ||
| xmemo setup openclaw --mcp-only | ||
| xmemo setup hermes | ||
| xmemo setup hermes --with-mcp | ||
| xmemo setup hermes --mcp-only | ||
| xmemo setup gemini | ||
| xmemo setup gemini --dry-run | ||
| xmemo setup antigravity | ||
| xmemo setup antigravity --dry-run | ||
| xmemo setup kiro | ||
| xmemo setup kiro --dry-run | ||
| xmemo setup grok | ||
| xmemo setup grok --dry-run | ||
| xmemo mcp add antigravity2 | ||
| xmemo mcp add antigravity2 --write | ||
| xmemo doctor | ||
| xmemo discovery show | ||
| xmemo setup | ||
| xmemo login | ||
| xmemo auth status | ||
| xmemo status | ||
| xmemo token status | ||
| xmemo token add --from-stdin | ||
| xmemo env example --shell bash | ||
| xmemo mcp list | ||
| xmemo mcp config --client generic | ||
| xmemo mcp config --client antigravity | ||
| xmemo mcp add antigravity --write | ||
| xmemo profile status codex | ||
| xmemo profile install gemini | ||
| xmemo profile install antigravity | ||
| xmemo smoke --client codex | ||
| xmemo privacy | ||
| ``` | ||
| ## Enterprise privacy and security defaults | ||
| Replace `codex` with your client. Preview a configuration before writing it: | ||
| - No telemetry or analytics. | ||
| - `xmemo doctor`, `xmemo discovery show`, and `xmemo status` do not send tokens. | ||
| - MCP config generated by the CLI references `XMEMO_KEY` or uses the client's | ||
| MCP OAuth flow; it does not write token values into project files. | ||
| - The CLI generates one stable non-secret `XMEMO_AGENT_INSTANCE_ID` per device | ||
| and stores it in user-scoped config outside git. All agents on the same device | ||
| share this instance ID. | ||
| - `xmemo setup <client>` can install a marker-scoped XMemo memory behavior | ||
| profile for the selected agent. The profile contains instructions only; it | ||
| never embeds token values. | ||
| - `xmemo login` stores the issued credential in the user-scoped XMemo CLI | ||
| config directory, shows the approved account when the server provides it, | ||
| and does not require extra token configuration afterward. | ||
| - `xmemo token add` remains available for existing tokens and still avoids | ||
| project files, shell history, and printed token values. | ||
| - Legacy `xmemo token set` refuses plaintext credential storage unless | ||
| `--allow-plaintext` is explicitly provided. | ||
| - The npm package uses a `files` whitelist so only `bin`, `src`, `skills`, | ||
| published plugin metadata/assets, `README.md`, and `LICENSE` are published. | ||
| ## Token flow | ||
| Recommended personal-user flow: | ||
| ```bash | ||
| xmemo login | ||
| xmemo auth status | ||
| xmemo token status --verify | ||
| xmemo setup cursor --dry-run | ||
| ``` | ||
| `xmemo login` uses the hosted device-login flow when the service advertises it: | ||
| the CLI shows a browser URL and one-time code, the user authorizes in XMemo, and | ||
| the CLI stores the issued MCP token in the user-scoped credential file. When the | ||
| service returns approved account metadata, the CLI prints the account label so | ||
| users can confirm which XMemo account was connected. No manual token setup is | ||
| needed after a successful `xmemo login`; `xmemo token status --verify` is only | ||
| an optional connectivity check. The CLI waits for the full browser authorization | ||
| window by default; use `--timeout-ms` only to shorten or extend that approval | ||
| window, and `--http-timeout-ms` only for individual service requests. | ||
| <p align="center"> | ||
| <img src="./docs/assets/xmemo-cli-workflow.svg" width="100%" alt="XMemo CLI setup workflow"> | ||
| </p> | ||
| Users who already have a token can configure it directly without shell profiles: | ||
| > [!TIP] | ||
| > Start with `xmemo login`, `xmemo doctor`, and `xmemo setup <client>`. | ||
| > Hand-edit MCP configuration only when a client has no verified setup path. | ||
| ```bash | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin | ||
| xmemo token status --verify | ||
| ``` | ||
| ## Supported integrations | ||
| This is the preferred fallback while a hosted service is rolling out device | ||
| login. It still avoids project files, MCP config files, logs, and chat | ||
| transcripts. | ||
| | Client | Recommended command | Connection | | ||
| | --- | --- | --- | | ||
| | **Codex** | `xmemo setup codex` | Hosted MCP + behavior profile | | ||
| | **Cursor** | `xmemo setup cursor` | Hosted MCP + behavior profile | | ||
| | **Copilot CLI** | `xmemo setup copilot` | Local authenticated proxy | | ||
| | **Gemini CLI** | `xmemo setup gemini` | Hosted MCP + OAuth | | ||
| | **Antigravity** | `xmemo setup antigravity` | Hosted MCP + OAuth | | ||
| | **OpenClaw** | `xmemo setup openclaw` | Native memory plugin + Skill | | ||
| | **Hermes** | `xmemo setup hermes` | Native memory provider | | ||
| | **Kiro** | `xmemo setup kiro` | Hosted MCP | | ||
| | **Grok** | `xmemo setup grok` | Hosted MCP | | ||
| | **Other MCP clients** | `xmemo mcp config --client generic` | Generated template | | ||
| Tokens should be created by the XMemo website or enterprise console, then | ||
| stored with `xmemo login`, `xmemo token add`, a user environment variable, or an | ||
| enterprise secret manager: | ||
| The client registry also covers Windsurf, Cline, Continue, Claude Desktop, | ||
| Claude Code, Kimi Code, Zed, JetBrains, OpenCode, Qwen, Trae, and compatible | ||
| MCP hosts. Run `xmemo mcp list` for the current machine-readable catalog. | ||
| ```bash | ||
| export XMEMO_KEY="your-token" | ||
| ``` | ||
| ## Connection modes | ||
| PowerShell: | ||
| ### Hosted MCP | ||
| ```powershell | ||
| [Environment]::SetEnvironmentVariable("XMEMO_KEY", "your-token", "User") | ||
| ``` | ||
| The recommended universal path is the XMemo Streamable HTTP endpoint: | ||
| Do not commit tokens to source control, MCP config files, `.env` files, logs, or | ||
| chat transcripts. | ||
| ## Hosted discovery setup | ||
| Hosted setup uses the XMemo public discovery contracts. The CLI reads | ||
| secret-free discovery and onboarding status documents, then tells the user where | ||
| the API, MCP endpoint, docs, and any server-advertised onboarding links are. | ||
| ```bash | ||
| xmemo doctor | ||
| xmemo discovery show | ||
| xmemo setup | ||
| ``` | ||
| Discovery requests do not send `XMEMO_KEY` or any Authorization | ||
| header. Token creation still happens in the website or enterprise console; the | ||
| public service discovery document does not return token values. | ||
| The hosted default service/base URL is `https://xmemo.dev`, so normal users do | ||
| not need to type a service address. The MCP endpoint is discovered from that | ||
| base URL and written as `https://xmemo.dev/mcp`; `https://mcp.xmemo.dev` is not | ||
| the current canonical setup URL. Use `--url <service-url>` or `XMEMO_URL` only | ||
| for private, enterprise, or self-hosted deployments. `MEMORY_OS_URL` remains | ||
| accepted as a compatibility alias. | ||
| Generate and write a client config from discovery: | ||
| ```bash | ||
| xmemo setup codex | ||
| xmemo setup codex --url "https://your-private-service.example" | ||
| xmemo setup cursor | ||
| xmemo setup copilot | ||
| xmemo setup openclaw | ||
| xmemo setup hermes | ||
| xmemo setup gemini | ||
| xmemo setup antigravity | ||
| ``` | ||
| `xmemo setup <client>` is the unified setup entry point. For write-capable | ||
| clients, it applies the user-scoped config directly; use `--dry-run` to preview | ||
| without writing. Codex/Cursor configs reference `XMEMO_KEY`; OAuth-native | ||
| clients such as Gemini CLI and Antigravity use the client's MCP OAuth flow | ||
| instead. `xmemo setup openclaw` is a custom OpenClaw installer: it installs or | ||
| updates the native `@xmemo/openclaw-memory` plugin and the XMemo Skill, and does | ||
| not add the hosted MCP server unless `--with-mcp` is passed. No generated config | ||
| embeds a token value. Write-capable client configs also include stable | ||
| non-secret agent identity headers where the client format supports them. | ||
| `--yes` remains accepted for Codex and Cursor as a compatibility no-op. | ||
| After writing MCP config, `xmemo setup <client>` prompts: | ||
| ```text | ||
| Write XMemo memory behavior profile to <path>? [Y/n] | ||
| https://xmemo.dev/mcp | ||
| ``` | ||
| The default is `Y`, so pressing Enter writes a marker-scoped profile that nudges | ||
| the agent to recall/search XMemo at the start of non-trivial work and remember | ||
| high-signal decisions after meaningful changes. Use `n` or `--no-profile` to | ||
| configure MCP only. Use `--dry-run` to preview without writing config or profile | ||
| files, and `--profile-target <path>` to choose a different behavior profile | ||
| target. | ||
| OAuth-capable clients complete authentication in the browser. Other clients | ||
| reference `XMEMO_KEY` without copying its value into repository files. | ||
| ### Uninstall | ||
| Generic configuration shape: | ||
| Remove the XMemo MCP server entry from one or all detected client configs: | ||
| ```bash | ||
| xmemo uninstall --all --dry-run | ||
| xmemo uninstall --all --yes | ||
| xmemo uninstall cursor --yes | ||
| xmemo uninstall --all --yes --profiles | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "XMemo": { | ||
| "type": "streamable-http", | ||
| "url": "https://xmemo.dev/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer ${XMEMO_KEY}" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| `xmemo uninstall --all` scans the same clients as `setup --all` and removes only | ||
| the `XMemo` entry (and legacy names such as `memory_os`) from each detected | ||
| config file. Other MCP servers are preserved. By default it shows a summary and | ||
| asks for confirmation; pass `--yes` (or `-y`) to skip the prompt, or `--dry-run` | ||
| to preview without modifying files. | ||
| Client configuration keys differ; prefer `xmemo setup <client>` over copying | ||
| this generic example directly. | ||
| Pass `--profiles` to also remove installed behavior profiles (Codex `AGENTS.md`, | ||
| Cursor memory profile, etc.). Identity files and credentials are not removed, so | ||
| a later `xmemo setup --all` can re-enable XMemo with the same agent instance ID. | ||
| ### Local stdio MCP | ||
| Default behavior profile targets: | ||
| `xmemo-mcp` is the dedicated stdio entry point for marketplaces and clients | ||
| that launch a local process. Safe discovery exposes 20 tools, three prompts, | ||
| and two documentation resources without a token. Tool execution still requires | ||
| authentication. | ||
| ```text | ||
| codex ./AGENTS.md | ||
| cursor ~/.cursor/memory-profile.md | ||
| gemini ~/.gemini/GEMINI.md | ||
| antigravity ~/.gemini/antigravity/MEMORY.md | ||
| ``` | ||
| After a global installation: | ||
| Antigravity 2.0 currently uses the lower-level MCP writer because its stable | ||
| user config path is separate from the original Antigravity profile. Preview the | ||
| generated OAuth-first config with: | ||
| ```bash | ||
| xmemo mcp add antigravity2 --url https://xmemo.dev | ||
| xmemo-mcp | ||
| ``` | ||
| Write it to the default Antigravity 2.0 config path with: | ||
| Install-free MCP configuration: | ||
| ```bash | ||
| xmemo mcp add antigravity2 --url https://xmemo.dev --write | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "XMemo": { | ||
| "command": "npx", | ||
| "args": [ | ||
| "-y", | ||
| "--package", | ||
| "@xmemo/client@latest", | ||
| "xmemo-mcp" | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| The generated config uses `https://xmemo.dev/mcp`, contains no bearer token, and | ||
| expects Antigravity 2.0 to complete MCP OAuth in the browser on first use. | ||
| `XMEMO_AGENT_INSTANCE_ID` is a device-level identifier shared by all agents on | ||
| the same machine, generated and stored when `--write` is used. | ||
| `xmemo mcp serve` is equivalent when the CLI is already installed. | ||
| ## MCP setup | ||
| ### Native integrations | ||
| List supported client generators: | ||
| OpenClaw and Hermes have dedicated memory providers. Their default setup avoids | ||
| installing a second, duplicate XMemo tool surface. | ||
| ```bash | ||
| xmemo mcp list | ||
| ``` | ||
| # Native OpenClaw plugin + XMemo Skill | ||
| xmemo setup openclaw | ||
| Current write-capable clients: | ||
| ```text | ||
| codex ~/.codex/config.toml | ||
| cursor ~/.cursor/mcp.json | ||
| copilot ~/.copilot/mcp-config.json | ||
| gemini ~/.gemini/settings.json | ||
| antigravity ~/.gemini/antigravity/mcp_config.json | ||
| antigravity2 ~/.antigravity2/mcp.json | ||
| kiro ~/.kiro/settings/mcp.json | ||
| # Native Hermes memory provider | ||
| xmemo setup hermes | ||
| ``` | ||
| For clients without a verified user-scoped write path, generate a read-only | ||
| template and apply it manually after review: | ||
| Add hosted MCP only when an explicit fallback is desired: | ||
| ```bash | ||
| xmemo mcp config --client generic --base-url "https://your-private-service.example" --json | ||
| xmemo setup openclaw --with-mcp | ||
| xmemo setup hermes --with-mcp | ||
| ``` | ||
| Codex, Cursor, Copilot CLI, Gemini CLI, Antigravity, OpenClaw, Hermes, and Kiro | ||
| have write-capable setup helpers. Antigravity 2.0 is write-capable through | ||
| `xmemo mcp add antigravity2 --write`. | ||
| Other client writes should only be added after their official user-scoped config | ||
| format is verified. | ||
| Use `--mcp-only` to skip the native integration and install only the hosted MCP | ||
| fallback. | ||
| OpenClaw and Hermes use the same setup modes: | ||
| ## Authentication | ||
| | Command | Result | | ||
| |---------|--------| | ||
| | `xmemo setup <openclaw|hermes>` | Install/update the native integration and sync credentials. MCP is not installed. | | ||
| | `xmemo setup <openclaw|hermes> --with-mcp` | Install/update the native integration, sync credentials, and add hosted MCP fallback. | | ||
| | `xmemo setup <openclaw|hermes> --mcp-only` | Add hosted MCP fallback only. Native plugin/Skill install and native credential sync are skipped. | | ||
| ### Browser login | ||
| ### OpenClaw | ||
| Recommended for personal accounts: | ||
| Recommended OpenClaw setup: | ||
| ```bash | ||
| xmemo login | ||
| xmemo setup openclaw | ||
| openclaw xmemo status | ||
| xmemo auth status | ||
| ``` | ||
| `xmemo setup openclaw` installs or updates OpenClaw's native XMemo memory plugin | ||
| from `@xmemo/openclaw-memory`, installs the XMemo Skill with `openclaw skills | ||
| install xmemo --force`, and then runs `openclaw xmemo status --json` so the user | ||
| can see whether credentials are available. The native plugin reads the same | ||
| user-scoped XMemo credential used by `xmemo login` and `xmemo token add | ||
| --from-stdin`, so normal users do not need to separately configure an OpenClaw | ||
| API key. | ||
| The CLI uses the hosted device-login flow, waits for browser approval, and | ||
| asks once before storing the issued credential unencrypted in the current | ||
| user's XMemo config directory. The exact path is shown before approval, file | ||
| permissions are restricted where the operating system supports it, and the | ||
| credential value is never printed. Prefer `XMEMO_KEY` or a managed secret store | ||
| on shared systems. | ||
| Hosted MCP is not installed by default because it creates a second XMemo tool | ||
| surface beside the native memory plugin. If you intentionally want that fallback | ||
| too, run: | ||
| For non-interactive automation, record the same decision explicitly: | ||
| ```bash | ||
| xmemo setup openclaw --with-mcp | ||
| xmemo login --allow-plaintext | ||
| ``` | ||
| The fallback MCP entry references `Authorization: Bearer ${XMEMO_KEY}` and does | ||
| not embed the token value. To install only the hosted MCP fallback without the | ||
| native plugin or Skill, run: | ||
| ### Existing token | ||
| Pipe an existing token through stdin so it does not appear in command history: | ||
| ```bash | ||
| xmemo setup openclaw --mcp-only | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin --allow-plaintext | ||
| xmemo token status --verify | ||
| ``` | ||
| Use `--no-skill` only when the XMemo Skill is managed by another deployment | ||
| path. Use `--openclaw-bin <path>` for custom OpenClaw binary locations. | ||
| PowerShell: | ||
| ### Hermes | ||
| Recommended Hermes setup: | ||
| ```bash | ||
| xmemo login | ||
| xmemo setup hermes | ||
| ```powershell | ||
| $xmemoToken = Read-Host "XMemo token" | ||
| $xmemoToken | xmemo token add --from-stdin --allow-plaintext | ||
| Remove-Variable xmemoToken | ||
| ``` | ||
| `xmemo setup hermes` is Hermes-aware rather than a raw MCP writer. It installs | ||
| or updates the native `hermes-xmemo` package with `python -m pip install -U | ||
| hermes-xmemo`, runs `hermes-xmemo install`, then resolves the XMemo token from, | ||
| in order, process environment (`XMEMO_KEY`, `MEMORY_OS_API_KEY`, or | ||
| `MEMORY_OS_MCP_TOKEN`), the shared `@xmemo/client` credential written by | ||
| `xmemo login` / `xmemo token add --from-stdin`, and an existing | ||
| `$HERMES_HOME/.env`. It syncs the token to Hermes' `$HERMES_HOME/.env` as | ||
| `XMEMO_KEY`, which keeps the native `hermes-xmemo` plugin and any optional | ||
| Hermes MCP fallback on the same credential. | ||
| For CI and managed workstations, expose `XMEMO_KEY` through the platform's | ||
| secret manager. Do not commit it to `.env`, MCP configuration, logs, issue | ||
| reports, or chat transcripts. | ||
| Hosted MCP is not installed by default because it creates a second XMemo tool | ||
| surface beside the native memory provider. If you intentionally want that | ||
| fallback too, run: | ||
| ## Command reference | ||
| ```bash | ||
| xmemo setup hermes --with-mcp | ||
| ``` | ||
| <details> | ||
| <summary><strong>Lifecycle and diagnostics</strong></summary> | ||
| The fallback MCP entry references `Authorization:Bearer ${XMEMO_KEY}` and does | ||
| not embed the token value. To install only the hosted MCP fallback without the | ||
| native plugin or native credential sync, run: | ||
| ```bash | ||
| xmemo setup hermes --mcp-only | ||
| xmemo --version | ||
| xmemo update | ||
| xmemo update --dry-run | ||
| xmemo doctor | ||
| xmemo discovery show | ||
| xmemo status | ||
| xmemo privacy | ||
| ``` | ||
| If an older Hermes plugin setup already created `$HERMES_HOME/.env`, | ||
| `xmemo setup hermes` can backfill the same token into the shared `@xmemo/client` | ||
| credential file so future XMemo CLI and MCP flows reuse it. Use `--no-plugin` | ||
| only when the native Hermes plugin is managed by another deployment path. Use | ||
| `--hermes-home <path>` for non-default Hermes homes. The legacy | ||
| `hermes memory setup xmemo` flow remains supported by the plugin and can reuse | ||
| the shared credential when present. | ||
| </details> | ||
| ### Copilot CLI | ||
| <details> | ||
| <summary><strong>Authentication</strong></summary> | ||
| Copilot CLI has `/mcp` management and reads user MCP configuration from | ||
| `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json`). XMemo writes | ||
| a local proxy server entry there: | ||
| ```bash | ||
| xmemo login | ||
| xmemo setup copilot | ||
| xmemo mcp proxy | ||
| xmemo auth status | ||
| xmemo auth-status --verify | ||
| xmemo token status --verify | ||
| xmemo token add --from-stdin --allow-plaintext | ||
| xmemo env example --shell bash | ||
| ``` | ||
| `xmemo setup copilot` writes `XMemo` to Copilot CLI's user MCP config and | ||
| does not include token or identity headers. Use `xmemo setup copilot --dry-run` | ||
| to preview without writing. `xmemo mcp proxy` reads the token saved by | ||
| `xmemo login` or `xmemo token add --from-stdin`, adds the XMemo bearer token and | ||
| local agent identity, then forwards requests to `https://xmemo.dev/mcp`. If | ||
| Copilot CLI is already open, reload MCP config or restart Copilot CLI after | ||
| setup. | ||
| If you specifically want the older environment-variable template, run: | ||
| </details> | ||
| <details> | ||
| <summary><strong>Client setup</strong></summary> | ||
| ```bash | ||
| xmemo mcp config --client copilot-cli --remote-env | ||
| xmemo setup <client> | ||
| xmemo setup <client> --dry-run | ||
| xmemo setup --all | ||
| xmemo setup openclaw [--with-mcp|--mcp-only] | ||
| xmemo setup hermes [--with-mcp|--mcp-only] | ||
| ``` | ||
| ### Codex | ||
| </details> | ||
| Recommended Codex setup: | ||
| <details> | ||
| <summary><strong>MCP and behavior profiles</strong></summary> | ||
| ```bash | ||
| xmemo setup codex | ||
| xmemo mcp serve | ||
| xmemo mcp list | ||
| xmemo mcp config --client generic | ||
| xmemo mcp add <client> --write | ||
| xmemo mcp proxy | ||
| xmemo profile install <client> | ||
| xmemo profile status <client> | ||
| xmemo profile uninstall <client> | ||
| xmemo smoke --client codex | ||
| ``` | ||
| `setup codex` writes the MCP config to user-scoped Codex config and, by default, | ||
| installs the XMemo Codex behavior profile into the current project's `AGENTS.md` | ||
| between these markers. Use `xmemo setup codex --dry-run` to preview without | ||
| writing or `xmemo setup codex --no-profile` to skip the behavior profile. | ||
| </details> | ||
| ```html | ||
| <!-- memory-os:codex-profile:start --> | ||
| <!-- memory-os:codex-profile:end --> | ||
| ``` | ||
| <details> | ||
| <summary><strong>Safe removal</strong></summary> | ||
| Repeat installs update only that marker block. Remove it with: | ||
| ```bash | ||
| xmemo profile uninstall codex | ||
| xmemo uninstall <client> --dry-run | ||
| xmemo uninstall <client> --yes | ||
| xmemo uninstall --all --dry-run | ||
| xmemo uninstall --all --yes --profiles | ||
| ``` | ||
| Advanced: generate a Codex MCP config snippet without touching files: | ||
| Only XMemo-owned entries and marker-scoped behavior profiles are removed. | ||
| Unrelated MCP servers, credentials, and device identity remain intact. | ||
| ```bash | ||
| xmemo mcp add codex --url "$XMEMO_URL" | ||
| ``` | ||
| </details> | ||
| Write it to the default Codex config path: | ||
| Run `xmemo help` or `xmemo <command> --help` for complete, version-matched | ||
| options. | ||
| ```bash | ||
| xmemo mcp add codex --url "$XMEMO_URL" --write | ||
| ``` | ||
| ## Client notes | ||
| The generated config references `XMEMO_KEY`, includes the non-secret | ||
| `X-Memory-OS-Agent-ID` / `X-Memory-OS-Agent-Instance-ID` attribution headers, | ||
| and does not include the token value. | ||
| <details> | ||
| <summary><strong>Codex and Cursor</strong></summary> | ||
| Codex MCP-depth checks: | ||
| ```bash | ||
| xmemo mcp profile codex | ||
| xmemo profile install codex --dry-run | ||
| xmemo profile install codex | ||
| xmemo profile status codex | ||
| xmemo setup codex | ||
| xmemo smoke --client codex | ||
| ``` | ||
| `xmemo mcp profile codex` prints the recommended memory behavior profile: | ||
| recall/search at the start of non-trivial tasks, write back high-signal | ||
| decisions and fixes, and never store secrets. `xmemo smoke --client codex` | ||
| checks the local Codex TOML config for the `XMemo` MCP server, | ||
| `bearer_token_env_var = "XMEMO_KEY"`, token presence in the environment, and | ||
| absence of embedded token values. | ||
| ### Cursor | ||
| Cursor marketplace plugin assets live in `.cursor-plugin/marketplace.json` and | ||
| `plugins/xmemo/`. The marketplace plugin is OAuth-first and its `mcp.json` stores | ||
| only `https://xmemo.dev/mcp`; it must not contain `Authorization`, `Bearer`, or | ||
| `XMEMO_KEY`. | ||
| Recommended Cursor setup: | ||
| ```bash | ||
| xmemo setup cursor | ||
| ``` | ||
| `setup cursor` merges the Cursor MCP config into the default Cursor user config | ||
| path. Use `xmemo setup cursor --dry-run` to preview without writing. The | ||
| lower-level equivalent remains: | ||
| Both setup paths write a user-scoped MCP entry and can install a marker-scoped | ||
| memory behavior profile. Use `--no-profile` to configure MCP only. Cursor's | ||
| public marketplace plugin remains OAuth-first and contains no bearer-token | ||
| configuration. | ||
| ```bash | ||
| xmemo mcp add cursor --url "$XMEMO_URL" --write | ||
| ``` | ||
| </details> | ||
| The CLI refuses to overwrite an existing `XMemo`, `memory_os`, or `memory-os` | ||
| MCP server entry. Edit the config manually if you need to rotate the endpoint. | ||
| Cursor configs include | ||
| `X-Memory-OS-Agent-ID` and `X-Memory-OS-Agent-Instance-ID`; the instance ID is | ||
| non-secret and stored under the user's XMemo CLI config directory. By default, | ||
| the setup prompt also installs a Cursor behavior profile at | ||
| `~/.cursor/memory-profile.md`; answer `n` or pass `--no-profile` to skip it. | ||
| Use this direct-key setup only for local/manual installs where Cursor OAuth is | ||
| unavailable; public plugin submission should use the OAuth-first plugin config. | ||
| <details> | ||
| <summary><strong>Gemini CLI and Antigravity</strong></summary> | ||
| ### Gemini CLI | ||
| Recommended Gemini CLI setup: | ||
| ```bash | ||
| xmemo setup gemini | ||
| xmemo setup antigravity | ||
| ``` | ||
| `setup gemini` merges an XMemo MCP server into Gemini CLI's user settings at | ||
| `~/.gemini/settings.json`. It writes a remote HTTP server using Gemini's | ||
| `httpUrl` key plus `X-Memory-OS-Agent-ID` and `X-Memory-OS-Agent-Instance-ID` | ||
| headers. Use `xmemo setup gemini --dry-run` to preview without writing. | ||
| These clients use hosted MCP OAuth. Their generated configuration carries no | ||
| token value; restart the client and complete the browser login on first use. | ||
| Unlike Codex/Cursor, the Gemini config carries **no token**: authentication uses | ||
| Gemini CLI's built-in MCP OAuth flow (a one-time browser login on first use). | ||
| This is deliberate — Gemini redacts environment variables matching | ||
| `*KEY*`/`*TOKEN*`/`*AUTH*` during header expansion, so an `${XMEMO_KEY}` | ||
| reference would not survive. OAuth avoids storing any secret in the config and | ||
| still grants the full XMemo tool profile. After setup, restart Gemini CLI and | ||
| run `/mcp` (or the first XMemo tool call) to complete the OAuth login. By | ||
| default, the setup prompt also installs a Gemini behavior profile at | ||
| `~/.gemini/GEMINI.md`; answer `n` or pass `--no-profile` to skip it. | ||
| </details> | ||
| The CLI refuses to overwrite an existing `XMemo`, `memory_os`, or `memory-os` | ||
| MCP server entry. Edit the config manually if you need to rotate the endpoint. | ||
| <details> | ||
| <summary><strong>OpenClaw</strong></summary> | ||
| ### Antigravity | ||
| Recommended Antigravity setup: | ||
| ```bash | ||
| xmemo setup antigravity | ||
| xmemo login | ||
| xmemo setup openclaw | ||
| openclaw xmemo status | ||
| ``` | ||
| `setup antigravity` merges an XMemo MCP server into Antigravity's user MCP config | ||
| at `~/.gemini/antigravity/mcp_config.json`. It writes Antigravity's | ||
| `serverUrl` shape plus `X-Memory-OS-Agent-ID` and | ||
| `X-Memory-OS-Agent-Instance-ID` headers. Like Gemini CLI, the config carries | ||
| **no token**: restart Antigravity and complete the MCP OAuth flow on first use. | ||
| By default, the setup prompt also installs an Antigravity behavior profile at | ||
| `~/.gemini/antigravity/MEMORY.md`; answer `n` or pass `--no-profile` to skip it. | ||
| The setup command installs or updates `@xmemo/openclaw-memory`, installs the | ||
| XMemo Skill, reuses the shared XMemo credential, and checks plugin status. | ||
| The lower-level equivalent is: | ||
| </details> | ||
| <details> | ||
| <summary><strong>Hermes</strong></summary> | ||
| ```bash | ||
| xmemo mcp add antigravity --write | ||
| xmemo login | ||
| xmemo setup hermes | ||
| ``` | ||
| Use `xmemo setup antigravity` for normal installs because it performs discovery | ||
| and chooses the recommended Antigravity path automatically. Use | ||
| `xmemo mcp add antigravity --write` when you want the generic MCP writer | ||
| directly, for example with `--url` or `--config` in advanced/multi-client setup. | ||
| The CLI refuses to overwrite an existing `XMemo`, `memory_os`, or `memory-os` | ||
| MCP server entry. Edit the config manually if you need to rotate the endpoint. | ||
| The setup command installs or updates `hermes-xmemo`, configures the native | ||
| provider, and synchronizes the user-scoped XMemo credential with Hermes. | ||
| ### Kiro | ||
| </details> | ||
| Recommended Kiro setup: | ||
| <details> | ||
| <summary><strong>Copilot CLI</strong></summary> | ||
| ```bash | ||
| xmemo setup kiro | ||
| xmemo login | ||
| xmemo setup copilot | ||
| xmemo mcp proxy | ||
| ``` | ||
| `setup kiro` merges an XMemo MCP server into Kiro's user MCP config at | ||
| `~/.kiro/settings/mcp.json`. It writes a remote HTTP server using Kiro's | ||
| standard `url` key plus `X-Memory-OS-Agent-ID` and `X-Memory-OS-Agent-Instance-ID` | ||
| headers. Use `xmemo setup kiro --dry-run` to preview without writing. | ||
| Copilot CLI receives a local proxy entry. The proxy reads the credential from | ||
| user-scoped storage, adds identity metadata, and forwards requests to hosted | ||
| MCP without writing secrets into Copilot configuration. | ||
| The Kiro config includes `Authorization: Bearer ${env:XMEMO_KEY}` so | ||
| authentication uses the environment variable approach. Set your XMemo token with: | ||
| </details> | ||
| ```bash | ||
| xmemo login | ||
| # or | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin | ||
| ``` | ||
| ## Security by default | ||
| After setup, restart Kiro or reload MCP servers for the changes to take effect. | ||
| The CLI refuses to overwrite an existing `XMemo`, `memory_os`, or `memory-os` | ||
| MCP server entry. Edit the config manually if you need to rotate the endpoint. | ||
| | Control | Default behavior | | ||
| | --- | --- | | ||
| | **Telemetry** | No CLI analytics or usage telemetry | | ||
| | **Credential output** | Token values are never printed | | ||
| | **Project files** | Generated configuration references secrets; it does not embed them | | ||
| | **Discovery** | `doctor`, `discovery show`, and public capability discovery send no token | | ||
| | **Identity** | One stable, non-secret agent-instance ID is stored outside git | | ||
| | **Writes** | Setup supports preview/dry-run; broad removal requires confirmation | | ||
| | **Local credential storage** | Interactive login asks first; non-interactive writes require `--allow-plaintext`; stored tokens are unencrypted | | ||
| | **Package contents** | An npm `files` allowlist excludes tests, operations, logs, and server code | | ||
| ### Grok (xAI) | ||
| Credential precedence and compatibility aliases are documented by: | ||
| Recommended Grok setup: | ||
| ```bash | ||
| xmemo setup grok | ||
| xmemo env example --shell bash | ||
| xmemo privacy | ||
| ``` | ||
| `setup grok` appends an XMemo MCP server to Grok's user config at | ||
| `~/.grok/config.toml` using Grok's TOML `[mcp_servers.XMemo]` format with `url`, | ||
| `bearer_token_env_var = "XMEMO_KEY"`, and an `[mcp_servers.XMemo.http_headers]` | ||
| table carrying `X-Memory-OS-Agent-ID`. Use `xmemo setup grok --dry-run` to | ||
| preview without writing. | ||
| For private or self-hosted deployments, set `XMEMO_URL` or pass | ||
| `--url <service-url>`. `MEMORY_OS_URL` remains a compatibility alias. | ||
| Set your XMemo token with: | ||
| ## Package boundary | ||
| ```bash | ||
| xmemo login | ||
| # or | ||
| printf '%s\n' 'your-token' | xmemo token add --from-stdin | ||
| Published to npm: | ||
| ```text | ||
| bin/ | ||
| docs/assets/ | ||
| src/ | ||
| skills/ | ||
| plugins/kiro/ | ||
| plugins/xmemo/ | ||
| README.md | ||
| LICENSE | ||
| ``` | ||
| Grok is also fully compatible with Claude Code: it automatically reads Claude | ||
| Code marketplaces, plugins, skills, and MCP servers. So `xmemo setup claude-code` | ||
| plus the repo's `.claude-plugin/`, `.mcp.json`, and `skills/` are discovered by | ||
| Grok with zero extra configuration. Use `xmemo setup grok` when you want the | ||
| server written directly into Grok's own `~/.grok/config.toml`. | ||
| Not published: | ||
| After setup, restart Grok or run `/mcps` to reload MCP servers. The CLI refuses | ||
| to overwrite an existing `XMemo` MCP server entry; edit the config manually if | ||
| you need to rotate the endpoint. | ||
| ```text | ||
| .github/ | ||
| docs/analysis/ | ||
| docs/architecture/ | ||
| test/ | ||
| coverage/ | ||
| server code | ||
| database migrations | ||
| deployment files | ||
| logs and local state | ||
| ``` | ||
| ### Antigravity 2.0 | ||
| ## Development | ||
| Recommended Antigravity 2.0 setup: | ||
| ```bash | ||
| xmemo mcp add antigravity2 --write | ||
| npm install | ||
| npm run release:check | ||
| npm run lint | ||
| npm test | ||
| npm run pack:dry-run | ||
| ``` | ||
| Use a dry preview first if you want to inspect the exact JSON before writing: | ||
| Before proposing a release, run the complete package gate: | ||
| ```bash | ||
| xmemo mcp add antigravity2 | ||
| npm run prepublishOnly | ||
| ``` | ||
| Antigravity 2.0 uses a separate config path from the original Antigravity | ||
| profile. The default write target is `~/.antigravity2/mcp.json`; on Windows the | ||
| server-side config contract also documents `%APPDATA%\\Antigravity 2.0\\mcp.json` | ||
| as the Antigravity 2.0 user config location. Pass `--config <path>` when you | ||
| want to write to a specific file. | ||
| The local stdio server can be inspected directly: | ||
| The generated `XMemo` entry uses hosted HTTP MCP: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "XMemo": { | ||
| "type": "http", | ||
| "url": "https://xmemo.dev/mcp" | ||
| } | ||
| } | ||
| } | ||
| ```bash | ||
| node bin/mcp-stdio.js | ||
| ``` | ||
| No token value is written. Restart Antigravity 2.0 after setup and complete the | ||
| MCP OAuth browser flow on first use. If you use `--write`, the CLI also prepares | ||
| a device-level `XMEMO_AGENT_INSTANCE_ID` shared by all agents on the same | ||
| machine so XMemo can attribute activity consistently without embedding secrets. | ||
| ## Release model | ||
| This repository is the source for the `@xmemo/client` npm package. Releases | ||
| should be published from GitHub Actions on tags or GitHub Releases, not from a | ||
| developer workstation. | ||
| Normal releases are produced by GitHub Actions from the exact tagged commit, | ||
| not from a mutable branch checkout or a developer workstation: | ||
| Recommended flow: | ||
| ```text | ||
| develop -> test -> tag/release -> GitHub Actions -> npm publish --provenance | ||
| develop → version sync → test → tag → GitHub Actions → npm publish --provenance | ||
| ``` | ||
| ## Package boundary | ||
| Version-bearing files must stay synchronized: | ||
| Included in npm: | ||
| - `package.json` | ||
| - `package-lock.json` | ||
| - `server.json` | ||
| - `lhm.plugin.json` | ||
| ```text | ||
| bin/ | ||
| src/ | ||
| README.md | ||
| LICENSE | ||
| ``` | ||
| `node scripts/check-release-version.mjs --tag vX.Y.Z` verifies the tag and every | ||
| version-bearing file before publication. The separate npm publish workflow is | ||
| manual recovery only, so creating a GitHub Release cannot publish twice. | ||
| Excluded from npm: | ||
| ## Documentation and support | ||
| ```text | ||
| .github/ | ||
| test/ | ||
| coverage/ | ||
| server code | ||
| database migrations | ||
| deployment files | ||
| logs | ||
| local state | ||
| secrets | ||
| ``` | ||
| - [XMemo](https://xmemo.dev) | ||
| - [XMemo MCP guide](https://xmemo.dev/product/mcp) | ||
| - [MCP server reference](./MCP-README.md) | ||
| - [Issues](https://github.com/yonro/memory-os-cli/issues) | ||
| - [Releases](https://github.com/yonro/memory-os-cli/releases) | ||
| ## License | ||
| [MIT](./LICENSE) © 2025–2026 Yonro |
@@ -6,2 +6,12 @@ # XMemo Skill Operations | ||
| ## Contents | ||
| - Runtime selection | ||
| - Account policy and temporary fallback | ||
| - Command matrix | ||
| - Examples | ||
| - Direct execution details | ||
| - Output and terminal safety | ||
| - Limitations | ||
| ## Runtime Selection | ||
@@ -11,7 +21,57 @@ | ||
| 1. **Bundled Skill script** (`node skills/xmemo/scripts/xmemo-skill.mjs <command>`), which directly integrates with the XMemo REST API using stored credentials. | ||
| 1. **Bundled Skill script** (`node scripts/xmemo-skill.mjs <command>`), which directly integrates with the XMemo REST API using stored credentials. Run commands from the Skill root with Node.js 20 or newer. | ||
| 2. **XMemo MCP tools** (when running in environments that natively host the XMemo MCP server). | ||
| If no credential is stored, the script reports the login or token add command as the repair path. Never paste a raw token into chat. | ||
| Credential resolution is `XMEMO_KEY` first, then the user-scoped credential | ||
| file. An environment token is never copied into that file. If no credential is | ||
| available, the script reports the formal login or token-add repair path. Never | ||
| paste a raw token into chat. | ||
| The zero-dependency runtime cannot provide one portable operating-system | ||
| keychain implementation. Commands that create or replace a local credential | ||
| therefore require `--allow-plaintext`. This flag explicitly permits an | ||
| unencrypted user-file credential; the script prints a warning and applies | ||
| private POSIX permissions where supported. Prefer `XMEMO_KEY` or a managed | ||
| secret store when this local trust boundary is not acceptable. | ||
| ## Account policy and temporary fallback | ||
| Use `login` or `auth add` by default. They provide a formal, account-backed | ||
| credential and the full command set. Do not automatically choose a temporary | ||
| token just because it is convenient. | ||
| Only use the fallback after the human explicitly declines formal registration, | ||
| or in unattended automation with no human available: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs register --reason declined --allow-plaintext | ||
| node scripts/xmemo-skill.mjs register --reason unattended --allow-plaintext | ||
| ``` | ||
| The fallback stores its token in the explicitly approved user credential file and can use only | ||
| `remember`, `recall`, and `search` in an isolated temporary memory space. Show | ||
| the returned bind URL only to the intended user; do not publish or log it. The | ||
| script reads `/.well-known/xmemo-agent.json` and discloses the current cap and | ||
| expiry immediately after registration. The current policy is 100 items, expiry | ||
| after 14 days without successful memory activity, and an absolute maximum of | ||
| 30 days from registration. Formal registration removes these sandbox limits. | ||
| After their web claim, complete the | ||
| one-time formal-token handoff with: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs auth claim-status | ||
| node scripts/xmemo-skill.mjs auth claim-confirm | ||
| ``` | ||
| If the user does not approve the pending bind, reject it as the temporary-token | ||
| holder and keep the isolated temporary credential: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs auth claim-deny | ||
| ``` | ||
| For a legacy temporary credential that predates recorded consent, append | ||
| `--allow-plaintext` to the claim command once. Successful handoff overwrites the | ||
| temporary credential and removes pending confirmation data. | ||
| ## Command matrix | ||
@@ -30,3 +90,6 @@ | ||
| | `expense-add` | Record a ledger expense | | ||
| | `doctor` | Check service health and auth status | | ||
| | `doctor` | Check service health and auth status; add `--anonymous` to omit credentials | | ||
| | `auth status` / `auth-status` | Show local auth state; add `--verify` for server validation | | ||
| | `auth claim-status` / `auth claim-confirm` / `auth claim-deny` | Inspect, approve, or reject the two-phase temporary bind | | ||
| | `logout` | Revoke/remove a local credential; externally managed `XMEMO_KEY` requires explicit revocation | | ||
@@ -38,5 +101,3 @@ ## Examples | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs remember \ | ||
| --content "Use pnpm for package management in this repo" \ | ||
| --path "projects/memory-os-cli/conventions" | ||
| node scripts/xmemo-skill.mjs remember --content "Use pnpm for package management in this repo" --path "projects/memory-os-cli/conventions" | ||
| ``` | ||
@@ -47,16 +108,27 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs recall \ | ||
| --query "package manager convention for memory-os-cli" | ||
| node scripts/xmemo-skill.mjs recall --query "package manager convention for memory-os-cli" --compact | ||
| ``` | ||
| Structured arguments are parsed before transmission. Pass metadata as a JSON | ||
| object and boolean query controls as the literal values `true` or `false`: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs remember --content "Verified decision" --path "projects/demo/decisions" --metadata '{"source":"review"}' | ||
| node scripts/xmemo-skill.mjs search --query "active implementation" --explain true --prefer_working false --compact | ||
| ``` | ||
| ### Save handoff state | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs save-state --key active_task | ||
| node scripts/xmemo-skill.mjs save-state --key active_task | ||
| ``` | ||
| `--ttl_seconds` accepts `0` through `604800` (seven days), matching the hosted | ||
| state-operation contract. A value of `0` requests the server's non-expiring | ||
| state behavior for that item. | ||
| ### Restore handoff state | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs restore-state --key active_task | ||
| node scripts/xmemo-skill.mjs restore-state --key active_task | ||
| ``` | ||
@@ -67,4 +139,3 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs todo-add \ | ||
| --content "Add unit tests for ledger expense command" | ||
| node scripts/xmemo-skill.mjs todo-add --content "Add unit tests for ledger expense command" | ||
| ``` | ||
@@ -75,14 +146,45 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs expense-add \ | ||
| --item "team lunch" --amount 42.5 --currency USD | ||
| node scripts/xmemo-skill.mjs expense-add --item "team lunch" --amount 42.5 --currency USD | ||
| ``` | ||
| ### Add an existing token without command-line exposure | ||
| POSIX shell: | ||
| ```text | ||
| printf '%s' "$XMEMO_KEY" | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| PowerShell: | ||
| ```powershell | ||
| $env:XMEMO_KEY | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| ## Direct Skill execution details | ||
| If the Skill file itself is not available, you can still run direct operations by calling the XMemo REST API using tools like curl or any HTTP client. | ||
| Use the bundled script or an available XMemo MCP/native integration. Do not | ||
| improvise REST calls when the Skill artifact is missing; restore the package or | ||
| use the documented hosted MCP path so authentication, redaction, and argument | ||
| validation remain intact. | ||
| ## Output and terminal safety | ||
| `remember` and `expense-add` print the server-returned memory or ledger ID. | ||
| `recall` and `search` accept `--compact` to render each memory on one shortened | ||
| line; use `--json` when a caller needs the complete redacted response payload. | ||
| Human-readable output removes terminal control sequences. For the exact accepted | ||
| parameters of any command, run | ||
| `node scripts/xmemo-skill.mjs <command> --help`; use `--version` to identify the | ||
| runtime and `--timeout-ms <ms>` to bound each network request. | ||
| ## Limitations | ||
| - The commands call the hosted endpoints on `xmemo.dev`. They require a network connection and a valid credential. | ||
| - Custom HTTPS origins are supported and receive the credential used by | ||
| authenticated commands. Use only trusted origins. Plain HTTP is accepted only | ||
| for localhost/loopback development. | ||
| - Responses larger than 8 MiB are rejected, and requests default to a 30-second | ||
| timeout. | ||
| - `save-state` / `restore-state` map to `update_state` / `_get_active_state_item` under the hood; they capture/resume server-side active task state. | ||
| - Offline memory storage or local sync is not implemented. |
@@ -11,3 +11,3 @@ # XMemo Skill Troubleshooting | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs doctor | ||
| ``` | ||
@@ -20,3 +20,5 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs auth status | ||
| node scripts/xmemo-skill.mjs auth status | ||
| # equivalent discovery-compatible alias | ||
| node scripts/xmemo-skill.mjs auth-status | ||
| ``` | ||
@@ -27,7 +29,35 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs login | ||
| node scripts/xmemo-skill.mjs login --allow-plaintext | ||
| # or | ||
| echo "$XMEMO_KEY" | node skills/xmemo/scripts/xmemo-skill.mjs auth add --from-stdin | ||
| printf '%s' "$XMEMO_KEY" | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| PowerShell token-add equivalent: | ||
| ```powershell | ||
| $env:XMEMO_KEY | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| `XMEMO_KEY` remains the preferred credential source and is never copied to the | ||
| local credential file. The commands above include `--allow-plaintext` because | ||
| device login and `auth add` must retain a token for later standalone commands. | ||
| The flag explicitly permits unencrypted storage in the current user's XMemo | ||
| directory; the script prints the exact path and a warning before writing it. | ||
| Formal login is recommended. If and only if a human is unavailable or has | ||
| explicitly declined registration for now, create a limited temporary sandbox: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs register --reason unattended --allow-plaintext | ||
| ``` | ||
| Temporary credentials work only for `remember`, `recall`, and `search`. Give | ||
| the displayed bind URL to the user, then use `auth claim-confirm` after their | ||
| claim to receive the formal credential. The script displays the current | ||
| temporary item and time limits immediately after registration. The current | ||
| policy is 100 items, 14 days without successful memory activity, and 30 days | ||
| maximum from registration. Do not share the bind URL publicly. If the user | ||
| rejects a pending bind, run `node scripts/xmemo-skill.mjs auth claim-deny` to | ||
| reject it server-side and clear the local pending confirmation value. | ||
| New users should create or sign in to an XMemo account at `https://xmemo.dev` | ||
@@ -44,3 +74,4 @@ before approving the device-login code. The browser page must show the same | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs auth status --verify | ||
| node scripts/xmemo-skill.mjs auth status --verify | ||
| node scripts/xmemo-skill.mjs auth-status --verify | ||
| ``` | ||
@@ -55,8 +86,16 @@ | ||
| Check the hosted service without sending a token: | ||
| Check the hosted service and current credential together: | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs doctor | ||
| ``` | ||
| When a credential is available, `doctor` sends it so the service can report | ||
| authentication validity. To check service health without any Authorization | ||
| header, run: | ||
| ```text | ||
| node scripts/xmemo-skill.mjs doctor --anonymous | ||
| ``` | ||
| If this fails: | ||
@@ -66,3 +105,8 @@ | ||
| - Check DNS, VPN, or corporate proxy settings. | ||
| - Try an explicit base URL: `node skills/xmemo/scripts/xmemo-skill.mjs doctor --base-url https://xmemo.dev`. | ||
| - Try an explicit base URL: `node scripts/xmemo-skill.mjs doctor --base-url https://xmemo.dev`. | ||
| - Increase the per-request timeout only when the service is known to be slow: | ||
| `node scripts/xmemo-skill.mjs doctor --timeout-ms 60000`. | ||
| - Custom service origins must use HTTPS. Plain HTTP is accepted only for | ||
| localhost/loopback development, and authenticated commands warn before sending | ||
| a credential to a non-default origin. | ||
@@ -73,5 +117,11 @@ ## 5. Common errors | ||
| |---------|--------------|--------| | ||
| | `No XMemo credential found` | Not logged in | Run the `login` command | | ||
| | `No XMemo credential found` | Not logged in | Set `XMEMO_KEY`, or run `node scripts/xmemo-skill.mjs login --allow-plaintext` | | ||
| | `Refusing unencrypted credential storage` | Missing explicit consent | Prefer `XMEMO_KEY`, or rerun the credential-writing command with `--allow-plaintext` | | ||
| | `Authentication failed (HTTP 401)` | Token invalid/expired | Run `login` or add a new token | | ||
| | `Remote XMemo server is not reachable` | Network or service outage | Check network/VPN/proxy | | ||
| | `XMemo base URL must use HTTPS` | Insecure non-loopback service URL | Use HTTPS, or localhost HTTP only for local development | | ||
| | `Request timed out` | Service/network exceeded the request deadline | Retry after checking service health, or set a bounded `--timeout-ms` | | ||
| | `Unknown option` | Unsupported or misspelled command parameter | Run the command with `--help`; do not pass tokens as flags | | ||
| | `--metadata must be a JSON object` | Metadata is invalid JSON, an array, or a scalar | Pass one JSON object, for example `'{"source":"review"}'` | | ||
| | `--explain must be true or false` | A boolean parameter used another spelling | Pass the literal `true` or `false` | | ||
| | `Method not found` | Server does not expose the requested operation | Server-side capability gap | | ||
@@ -84,2 +134,9 @@ | ||
| - Prefer `login` for interactive authentication. | ||
| - Prefer `XMEMO_KEY` or a managed secret store over plaintext file storage. | ||
| - `auth status` reports the credential source but never prints a token prefix. | ||
| - `logout` leaves externally managed `XMEMO_KEY` unchanged by default. Unset the | ||
| variable to stop using it; pass `--revoke-environment-token` only when remote | ||
| revocation is explicitly intended. | ||
| - `--allow-plaintext` means the local token is unencrypted and may be read by | ||
| processes running as the same operating-system user. | ||
| - Treat `X-Memory-OS-Agent-ID` as an attribution signal, not authorization proof. |
@@ -14,5 +14,47 @@ #!/usr/bin/env node | ||
| import readline from 'node:readline'; | ||
| import { randomUUID } from 'node:crypto'; | ||
| const SKILL_VERSION = '1.0.0'; | ||
| const SKILL_VERSION = '1.1.0'; | ||
| const credentialsPath = path.join(os.homedir(), '.xmemo', 'skill-credentials.json'); | ||
| const registrationPath = path.join(os.homedir(), '.xmemo', 'skill-registration.json'); | ||
| const SCRIPT_COMMAND = 'node scripts/xmemo-skill.mjs'; | ||
| const PLAINTEXT_STORAGE = 'plaintext-user-file'; | ||
| const DEFAULT_BASE_URL = 'https://xmemo.dev'; | ||
| const DEFAULT_TIMEOUT_MS = 30_000; | ||
| const MAX_TIMEOUT_MS = 300_000; | ||
| const MAX_RESPONSE_BYTES = 8_388_608; | ||
| const DEFAULT_TEMPORARY_LIMITS = Object.freeze({ | ||
| max_items: 100, | ||
| ttl_seconds: 1_209_600, | ||
| max_lifetime_seconds: 2_592_000, | ||
| }); | ||
| const warnedCredentialOrigins = new Set(); | ||
| const REST_COMMANDS = new Set([ | ||
| 'remember', 'recall', 'search', 'save-state', 'restore-state', 'state-save', 'state-restore', | ||
| 'todo-add', 'todo-list', 'todo-done', 'expense-add', 'doctor', | ||
| ]); | ||
| const COMMAND_FLAGS = { | ||
| login: new Set(), | ||
| register: new Set(['reason']), | ||
| logout: new Set(), | ||
| doctor: new Set(), | ||
| remember: new Set(['content', 'path', 'metadata', 'logic_path', 'bucket', 'scope', 'team_id']), | ||
| recall: new Set(['query', 'limit', 'threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']), | ||
| search: new Set(['query', 'limit', 'threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']), | ||
| 'save-state': new Set(['key', 'state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'ttl_seconds', 'bucket', 'scope']), | ||
| 'state-save': new Set(['key', 'state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'ttl_seconds', 'bucket', 'scope']), | ||
| 'restore-state': new Set(['key', 'state_key', 'bucket', 'scope']), | ||
| 'state-restore': new Set(['key', 'state_key', 'bucket', 'scope']), | ||
| 'todo-add': new Set(['content', 'due_at', 'bucket', 'scope', 'path']), | ||
| 'todo-list': new Set(['bucket', 'scope', 'status']), | ||
| 'todo-done': new Set(['id', 'todo_id', 'note']), | ||
| 'expense-add': new Set(['item', 'amount', 'currency', 'transaction_date', 'date', 'path', 'bucket', 'scope']), | ||
| }; | ||
| const AUTH_FLAGS = { | ||
| status: new Set(), | ||
| add: new Set(['from-stdin']), | ||
| 'claim-status': new Set(), | ||
| 'claim-confirm': new Set(), | ||
| 'claim-deny': new Set(), | ||
| }; | ||
@@ -23,4 +65,11 @@ // Helper to parse arguments | ||
| json: false, | ||
| baseUrl: process.env.XMEMO_BASE_URL || 'https://xmemo.dev', | ||
| baseUrl: process.env.XMEMO_BASE_URL || DEFAULT_BASE_URL, | ||
| timeoutMs: process.env.XMEMO_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), | ||
| verify: false, | ||
| compact: false, | ||
| help: false, | ||
| version: false, | ||
| allowPlaintext: false, | ||
| anonymous: false, | ||
| revokeEnvironmentToken: false, | ||
| }; | ||
@@ -33,15 +82,45 @@ const positionals = []; | ||
| if (arg.startsWith('--')) { | ||
| const key = arg.slice(2); | ||
| const rawKey = arg.slice(2); | ||
| const equalsIndex = rawKey.indexOf('='); | ||
| const key = equalsIndex === -1 ? rawKey : rawKey.slice(0, equalsIndex); | ||
| const inlineValue = equalsIndex === -1 ? undefined : rawKey.slice(equalsIndex + 1); | ||
| if (key === 'json') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.json = true; | ||
| } else if (key === 'verify') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.verify = true; | ||
| } else if (key === 'compact') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.compact = true; | ||
| } else if (key === 'help') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.help = true; | ||
| } else if (key === 'version') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.version = true; | ||
| } else if (key === 'allow-plaintext') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.allowPlaintext = true; | ||
| } else if (key === 'from-stdin') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| flags[key] = true; | ||
| } else if (key === 'anonymous') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.anonymous = true; | ||
| } else if (key === 'revoke-environment-token') { | ||
| rejectBooleanValue(key, inlineValue); | ||
| options.revokeEnvironmentToken = true; | ||
| } else if (key === 'base-url') { | ||
| options.baseUrl = args[++i]; | ||
| } else if (key.includes('=')) { | ||
| const [k, v] = key.split('=', 2); | ||
| flags[k] = v; | ||
| const parsed = readOptionValue(args, i, key, inlineValue); | ||
| options.baseUrl = parsed.value; | ||
| i = parsed.index; | ||
| } else if (key === 'timeout-ms') { | ||
| const parsed = readOptionValue(args, i, key, inlineValue); | ||
| options.timeoutMs = parsed.value; | ||
| i = parsed.index; | ||
| } else { | ||
| // next arg is the value | ||
| flags[key] = args[++i]; | ||
| const parsed = readOptionValue(args, i, key, inlineValue); | ||
| flags[key] = parsed.value; | ||
| i = parsed.index; | ||
| } | ||
@@ -54,2 +133,6 @@ } else if (arg.startsWith('-')) { | ||
| options.verify = true; | ||
| } else if (key === 'h') { | ||
| options.help = true; | ||
| } else { | ||
| throw new Error(`Unknown short option: -${key}`); | ||
| } | ||
@@ -63,4 +146,258 @@ } else { | ||
| function rejectBooleanValue(key, inlineValue) { | ||
| if (inlineValue !== undefined) { | ||
| throw new Error(`--${key} does not accept a value; pass it as a bare flag.`); | ||
| } | ||
| } | ||
| function readOptionValue(args, index, key, inlineValue) { | ||
| if (inlineValue !== undefined) { | ||
| if (!inlineValue) throw new Error(`--${key} requires a value.`); | ||
| return { value: inlineValue, index }; | ||
| } | ||
| const value = args[index + 1]; | ||
| if (value === undefined || value.startsWith('--')) { | ||
| throw new Error(`--${key} requires a value.`); | ||
| } | ||
| return { value, index: index + 1 }; | ||
| } | ||
| function printUsage(command) { | ||
| const commonOptions = '[--json] [--base-url <url>] [--timeout-ms <ms>]'; | ||
| if (command === 'auth') { | ||
| console.log(`Usage:\n ${SCRIPT_COMMAND} auth status [--verify] ${commonOptions}\n ${SCRIPT_COMMAND} auth add --from-stdin --allow-plaintext\n ${SCRIPT_COMMAND} auth claim-status [--allow-plaintext]\n ${SCRIPT_COMMAND} auth claim-confirm [--allow-plaintext]\n ${SCRIPT_COMMAND} auth claim-deny [--allow-plaintext]\n\nAlias: ${SCRIPT_COMMAND} auth-status [--verify]\nXMEMO_KEY remains the preferred non-file credential source. --allow-plaintext explicitly permits unencrypted user-file storage.\nRun \`${SCRIPT_COMMAND} --help\` to list all commands.`); | ||
| return; | ||
| } | ||
| const directUsage = { | ||
| login: `login --allow-plaintext ${commonOptions}`, | ||
| register: `register --reason <unattended|declined> --allow-plaintext ${commonOptions}`, | ||
| logout: `logout [--revoke-environment-token] ${commonOptions}`, | ||
| }; | ||
| if (directUsage[command]) { | ||
| console.log(`Usage:\n ${SCRIPT_COMMAND} ${directUsage[command]}`); | ||
| if (command === 'logout') { | ||
| console.log('\nXMEMO_KEY is externally managed and is not revoked unless --revoke-environment-token is explicitly passed.'); | ||
| } | ||
| return; | ||
| } | ||
| if (REST_COMMANDS.has(command)) { | ||
| const commandUsage = { | ||
| remember: 'remember --content <text> [--path <path>] [--metadata <json-object>]', | ||
| recall: 'recall --query <text> [--limit <n>] [--explain <true|false>] [--prefer_working <true|false>] [--compact]', | ||
| search: 'search --query <text> [--limit <n>] [--explain <true|false>] [--prefer_working <true|false>] [--compact]', | ||
| 'save-state': 'save-state --key <key> [--content <text>] [--ttl_seconds <0..604800>]', | ||
| 'restore-state': 'restore-state --key <key>', | ||
| 'state-save': 'state-save --key <key> [--content <text>] [--ttl_seconds <0..604800>] (legacy alias)', | ||
| 'state-restore': 'state-restore --key <key> (legacy alias)', | ||
| 'todo-add': 'todo-add --content <text>', | ||
| 'todo-list': 'todo-list', | ||
| 'todo-done': 'todo-done --id <todo_id>', | ||
| 'expense-add': 'expense-add --item <text> --amount <number> --currency <code>', | ||
| doctor: 'doctor [--anonymous]', | ||
| }; | ||
| console.log(`Usage:\n ${SCRIPT_COMMAND} ${commandUsage[command]} ${commonOptions}`); | ||
| return; | ||
| } | ||
| console.log(`XMemo Standalone Skill Runtime\n\nUsage:\n ${SCRIPT_COMMAND} <command> [options]\n\nCommands:\n login --allow-plaintext Start formal device login and explicitly permit local token storage\n register --reason <unattended|declined> --allow-plaintext\n Start limited temporary memory only when formal login is unavailable\n logout Revoke and remove a local credential\n auth status [--verify] Show local or verified auth status\n auth-status [--verify] Alias for auth status\n auth add --from-stdin --allow-plaintext\n Store a formal token read from standard input\n auth claim-status [--allow-plaintext]\n Check temporary-account claim status\n auth claim-confirm [--allow-plaintext]\n Confirm a pending human claim and accept formal token handoff\n auth claim-deny [--allow-plaintext]\n Decline a pending bind and keep isolated temporary access\n remember --content <text> --path <path>\n recall --query <text> [--limit <n>] [--compact]\n search --query <text> [--limit <n>] [--compact]\n save-state --key <key> [--content <text>] (aliases: state-save)\n restore-state --key <key> (aliases: state-restore)\n todo-add --content <text>\n todo-list\n todo-done --id <todo_id>\n expense-add --item <text> --amount <number> --currency <code>\n doctor [--anonymous]\n\nCredential resolution:\n XMEMO_KEY Preferred; never copied to the local credential file\n User credential file Read only as a fallback\n\nGlobal options:\n --json Print the API response as JSON\n --base-url <url> Override ${DEFAULT_BASE_URL}; HTTPS or loopback HTTP only\n --timeout-ms <ms> Per-request timeout (default: ${DEFAULT_TIMEOUT_MS})\n --compact Shorten recall/search content for terminals\n --allow-plaintext Explicitly permit unencrypted user-file credential storage\n --version Show the Skill runtime version\n --help, -h Show this help\n\nRun \`${SCRIPT_COMMAND} <command> --help\` for command-specific usage.`); | ||
| } | ||
| function parsePositiveInteger(value, name, max = Number.MAX_SAFE_INTEGER) { | ||
| if (!/^\d+$/.test(String(value ?? ''))) { | ||
| throw new Error(`${name} must be a positive integer.`); | ||
| } | ||
| const parsed = Number(value); | ||
| if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > max) { | ||
| throw new Error(`${name} must be between 1 and ${max}.`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function parseIntegerInRange(value, name, min, max) { | ||
| if (!/^\d+$/.test(String(value ?? ''))) { | ||
| throw new Error(`${name} must be an integer between ${min} and ${max}.`); | ||
| } | ||
| const parsed = Number(value); | ||
| if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { | ||
| throw new Error(`${name} must be between ${min} and ${max}.`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function parseJsonObject(value, name) { | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(String(value)); | ||
| } catch { | ||
| throw new Error(`${name} must be a valid JSON object.`); | ||
| } | ||
| if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { | ||
| throw new Error(`${name} must be a JSON object.`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function parseStrictBoolean(value, name) { | ||
| if (value === true || value === 'true') return true; | ||
| if (value === false || value === 'false') return false; | ||
| throw new Error(`${name} must be true or false.`); | ||
| } | ||
| function isLoopbackHostname(hostname) { | ||
| const normalized = String(hostname || '').toLowerCase(); | ||
| return normalized === 'localhost' | ||
| || normalized === '127.0.0.1' | ||
| || normalized === '::1' | ||
| || normalized === '[::1]'; | ||
| } | ||
| function normalizeBaseUrl(value) { | ||
| let url; | ||
| try { | ||
| url = new URL(value); | ||
| } catch { | ||
| throw new Error(`Invalid XMemo base URL: ${value}`); | ||
| } | ||
| if (url.username || url.password) { | ||
| throw new Error('XMemo base URL must not contain embedded credentials.'); | ||
| } | ||
| if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) { | ||
| throw new Error('XMemo base URL must use HTTPS. Plain HTTP is allowed only for localhost/loopback development.'); | ||
| } | ||
| url.hash = ''; | ||
| url.search = ''; | ||
| return url.toString().replace(/\/$/, ''); | ||
| } | ||
| function validateCommandInput(command, subcommand, positionals, options, flags) { | ||
| const expectedPositionals = command === 'auth' ? 2 : 1; | ||
| if (positionals.length > expectedPositionals) { | ||
| throw new Error(`Unexpected positional argument: ${positionals[expectedPositionals]}`); | ||
| } | ||
| if (options.anonymous && command !== 'doctor') { | ||
| throw new Error('--anonymous is supported only by doctor.'); | ||
| } | ||
| if (options.revokeEnvironmentToken && command !== 'logout') { | ||
| throw new Error('--revoke-environment-token is supported only by logout.'); | ||
| } | ||
| const allowedFlags = command === 'auth' | ||
| ? AUTH_FLAGS[subcommand] || new Set() | ||
| : COMMAND_FLAGS[command] || new Set(); | ||
| for (const key of Object.keys(flags)) { | ||
| if (/token|api[-_]?key|bearer|authorization|cookie|secret/i.test(key) && key !== 'from-stdin') { | ||
| throw new Error(`Refusing sensitive command-line option --${key}. Use XMEMO_KEY or --from-stdin where documented.`); | ||
| } | ||
| if (!allowedFlags.has(key)) { | ||
| throw new Error(`Unknown option for ${command}${subcommand ? ` ${subcommand}` : ''}: --${key}`); | ||
| } | ||
| } | ||
| const required = { | ||
| remember: ['content'], | ||
| recall: ['query'], | ||
| search: ['query'], | ||
| 'todo-add': ['content'], | ||
| 'todo-done': ['id|todo_id'], | ||
| 'expense-add': ['item', 'amount'], | ||
| }; | ||
| for (const requirement of required[command] || []) { | ||
| const alternatives = requirement.split('|'); | ||
| if (!alternatives.some((key) => flags[key] !== undefined && String(flags[key]).trim())) { | ||
| throw new Error(`${command} requires --${alternatives.join(' or --')}.`); | ||
| } | ||
| } | ||
| if (flags.limit !== undefined) parsePositiveInteger(flags.limit, '--limit', 100); | ||
| if (flags.ttl_seconds !== undefined) parseIntegerInRange(flags.ttl_seconds, '--ttl_seconds', 0, 604_800); | ||
| if (flags.metadata !== undefined) flags.metadata = parseJsonObject(flags.metadata, '--metadata'); | ||
| if (flags.explain !== undefined) flags.explain = parseStrictBoolean(flags.explain, '--explain'); | ||
| if (flags.prefer_working !== undefined) flags.prefer_working = parseStrictBoolean(flags.prefer_working, '--prefer_working'); | ||
| if (flags.threshold !== undefined) { | ||
| const threshold = Number(flags.threshold); | ||
| if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) { | ||
| throw new Error('--threshold must be a number between 0 and 1.'); | ||
| } | ||
| } | ||
| if (flags.amount !== undefined && !Number.isFinite(Number(flags.amount))) { | ||
| throw new Error('--amount must be numeric.'); | ||
| } | ||
| } | ||
| function parseJsonResponse(res, context) { | ||
| const body = typeof res.body === 'string' ? res.body.trim() : ''; | ||
| if (!body) { | ||
| throw new Error(`${context}: server returned an empty response (HTTP ${res.statusCode}).`); | ||
| } | ||
| try { | ||
| return JSON.parse(body); | ||
| } catch { | ||
| const safeBody = sanitizeTerminalText(body); | ||
| const preview = safeBody.length > 2_000 ? `${safeBody.slice(0, 2_000)}…` : safeBody; | ||
| throw new Error(`${context}: server returned a non-JSON response (HTTP ${res.statusCode}): ${preview}`); | ||
| } | ||
| } | ||
| function extractList(result) { | ||
| if (Array.isArray(result)) return result; | ||
| if (Array.isArray(result?.results)) return result.results; | ||
| if (Array.isArray(result?.todos)) return result.todos; | ||
| return []; | ||
| } | ||
| function extractId(result) { | ||
| if (typeof result === 'string') return result; | ||
| if (result?.id) return result.id; | ||
| if (result?.memory_id) return result.memory_id; | ||
| return JSON.stringify(result) ?? String(result ?? ''); | ||
| } | ||
| function apiErrorMessage(data, fallback = 'Operation failed') { | ||
| const candidate = data?.error?.message || data?.error_description || data?.detail || data?.error; | ||
| if (typeof candidate === 'string') return sanitizeTerminalText(candidate); | ||
| if (candidate !== undefined && candidate !== null) return safeJson(candidate); | ||
| return fallback; | ||
| } | ||
| function redactSensitiveResponse(value) { | ||
| if (value === null || typeof value !== 'object') return value; | ||
| if (Array.isArray(value)) return value.map(redactSensitiveResponse); | ||
| const sensitiveKeys = new Set([ | ||
| 'access_token', 'refresh_token', 'id_token', 'temporary_token', 'formal_token', | ||
| 'confirmation_token', 'pending_confirmation_token', 'device_code', 'token', | ||
| 'authorization', 'api_key', 'apikey', 'cookie', 'set-cookie', | ||
| ]); | ||
| return Object.fromEntries(Object.entries(value).map(([key, item]) => [ | ||
| key, | ||
| sensitiveKeys.has(key.toLowerCase()) ? '[REDACTED]' : redactSensitiveResponse(item), | ||
| ])); | ||
| } | ||
| function safeJson(value) { | ||
| return JSON.stringify(redactSensitiveResponse(value)); | ||
| } | ||
| function sanitizeTerminalText(value) { | ||
| return String(value ?? '') | ||
| .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') | ||
| .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, ''); | ||
| } | ||
| function formatMemoryContent(content, compact) { | ||
| const value = sanitizeTerminalText(content); | ||
| const rendered = compact ? value.replace(/\s+/g, ' ').trim() : value; | ||
| const limit = compact ? 280 : 2_000; | ||
| return rendered.length > limit ? `${rendered.slice(0, limit)}… (truncated)` : rendered; | ||
| } | ||
| function formatDuration(seconds) { | ||
| if (seconds % 86_400 === 0) return `${seconds / 86_400} days`; | ||
| if (seconds % 3_600 === 0) return `${seconds / 3_600} hours`; | ||
| return `${seconds} seconds`; | ||
| } | ||
| // HTTP request helper | ||
| function makeHttpRequest(baseUrl, apiPath, method, body = null, headers = {}) { | ||
| function makeHttpRequest(baseUrl, apiPath, method, body = null, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS) { | ||
| return new Promise((resolve, reject) => { | ||
@@ -82,9 +419,35 @@ try { | ||
| }; | ||
| const authorizationHeader = Object.entries(reqHeaders) | ||
| .find(([key]) => key.toLowerCase() === 'authorization')?.[1]; | ||
| if (authorizationHeader && url.origin !== new URL(DEFAULT_BASE_URL).origin && !warnedCredentialOrigins.has(url.origin)) { | ||
| warnedCredentialOrigins.add(url.origin); | ||
| console.error(`⚠️ Sending an XMemo credential to custom origin ${url.origin}. Continue only if this host is trusted.`); | ||
| } | ||
| let settled = false; | ||
| const settleResolve = (value) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(value); | ||
| }; | ||
| const settleReject = (error) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| reject(error); | ||
| }; | ||
| const req = client.request(url, options, (res) => { | ||
| let data = ''; | ||
| let responseBytes = 0; | ||
| res.on('data', (chunk) => { | ||
| responseBytes += Buffer.byteLength(chunk); | ||
| if (responseBytes > MAX_RESPONSE_BYTES) { | ||
| const error = new Error(`Server response exceeded the ${MAX_RESPONSE_BYTES}-byte safety limit.`); | ||
| settleReject(error); | ||
| res.destroy(); | ||
| return; | ||
| } | ||
| data += chunk; | ||
| }); | ||
| res.on('end', () => { | ||
| resolve({ | ||
| settleResolve({ | ||
| statusCode: res.statusCode, | ||
@@ -95,6 +458,9 @@ headers: res.headers, | ||
| }); | ||
| res.on('error', settleReject); | ||
| res.on('aborted', () => settleReject(new Error('Server response was interrupted.'))); | ||
| }); | ||
| req.on('error', (err) => { | ||
| reject(err); | ||
| req.setTimeout(timeoutMs, () => { | ||
| req.destroy(new Error(`Request timed out after ${timeoutMs} ms.`)); | ||
| }); | ||
| req.on('error', settleReject); | ||
| if (bodyStr) { | ||
@@ -110,6 +476,31 @@ req.write(bodyStr); | ||
| async function fetchTemporaryLimits(baseUrl, timeoutMs) { | ||
| try { | ||
| const res = await makeHttpRequest(baseUrl, '/.well-known/xmemo-agent.json', 'GET', null, {}, timeoutMs); | ||
| if (res.statusCode < 200 || res.statusCode >= 300) return { ...DEFAULT_TEMPORARY_LIMITS }; | ||
| const data = parseJsonResponse(res, 'Temporary-memory policy discovery'); | ||
| const limits = data?.temporary_token?.limits; | ||
| const max_items = Number(limits?.max_items); | ||
| const ttl_seconds = Number(limits?.ttl_seconds); | ||
| const max_lifetime_seconds = Number(limits?.max_lifetime_seconds); | ||
| if (![max_items, ttl_seconds, max_lifetime_seconds].every(Number.isSafeInteger) | ||
| || max_items <= 0 || ttl_seconds <= 0 || max_lifetime_seconds <= 0) { | ||
| return { ...DEFAULT_TEMPORARY_LIMITS }; | ||
| } | ||
| return { max_items, ttl_seconds, max_lifetime_seconds }; | ||
| } catch { | ||
| // Discovery must not make an otherwise available registration endpoint unusable. | ||
| return { ...DEFAULT_TEMPORARY_LIMITS }; | ||
| } | ||
| } | ||
| // Read credential helper | ||
| async function getStoredToken() { | ||
| const credential = await getStoredCredential(); | ||
| return credential?.token || null; | ||
| } | ||
| async function getStoredCredential() { | ||
| if (process.env.XMEMO_KEY) { | ||
| return process.env.XMEMO_KEY; | ||
| return { token: process.env.XMEMO_KEY, credential_type: 'environment', storage: 'environment' }; | ||
| } | ||
@@ -119,3 +510,7 @@ try { | ||
| const parsed = JSON.parse(data); | ||
| return parsed.token || null; | ||
| if (!parsed.token) return null; | ||
| if (parsed.storage !== PLAINTEXT_STORAGE || parsed.plaintext_storage_consent !== true) { | ||
| console.error(`⚠️ Legacy plaintext XMemo credential detected at ${credentialsPath}. Rotate it with XMEMO_KEY, or explicitly recreate it with --allow-plaintext.`); | ||
| } | ||
| return parsed; | ||
| } catch { | ||
@@ -126,12 +521,165 @@ return null; | ||
| // Save credential helper | ||
| async function saveToken(token) { | ||
| await fs.mkdir(path.dirname(credentialsPath), { recursive: true }); | ||
| async function bestEffortChmod(targetPath, mode) { | ||
| try { | ||
| await fs.chmod(targetPath, mode); | ||
| } catch { | ||
| // Some platforms do not implement POSIX permission bits. Never claim this is encryption. | ||
| } | ||
| } | ||
| function plaintextStorageAllowed(options, credential = null) { | ||
| return options?.allowPlaintext === true | ||
| || (credential?.storage === PLAINTEXT_STORAGE && credential?.plaintext_storage_consent === true); | ||
| } | ||
| function requirePlaintextStorageConsent(options, action) { | ||
| if (options?.allowPlaintext === true) return; | ||
| throw new Error(`${action} needs to persist a token between commands. XMEMO_KEY is preferred and is never copied to disk. To explicitly permit unencrypted storage in ${credentialsPath}, rerun with --allow-plaintext.`); | ||
| } | ||
| function warnPlaintextStorage() { | ||
| console.error(`⚠️ Plaintext credential storage explicitly enabled. The token will be stored unencrypted at ${credentialsPath} and may be read by processes running as your OS user. Prefer XMEMO_KEY or a managed secret store; never share or commit this file.`); | ||
| } | ||
| // Save credential helper. Every caller must prove explicit consent or carry forward recorded consent. | ||
| async function saveToken(token, details = {}, { allowPlaintext = false, warn = false } = {}) { | ||
| if (!allowPlaintext) { | ||
| throw new Error(`Refusing unencrypted credential storage without --allow-plaintext. Prefer XMEMO_KEY.`); | ||
| } | ||
| if (warn) warnPlaintextStorage(); | ||
| const credentialDir = path.dirname(credentialsPath); | ||
| await fs.mkdir(credentialDir, { recursive: true, mode: 0o700 }); | ||
| await bestEffortChmod(credentialDir, 0o700); | ||
| const { | ||
| token: _discardToken, | ||
| created_at: _discardCreatedAt, | ||
| storage: _discardStorage, | ||
| plaintext_storage_consent: _discardConsent, | ||
| plaintext_storage_consent_at: _discardConsentAt, | ||
| claim_code: _discardClaimCode, | ||
| ...safeDetails | ||
| } = details; | ||
| const data = JSON.stringify({ | ||
| token, | ||
| created_at: new Date().toISOString(), | ||
| credential_type: 'formal', | ||
| ...safeDetails, | ||
| storage: PLAINTEXT_STORAGE, | ||
| plaintext_storage_consent: true, | ||
| plaintext_storage_consent_at: new Date().toISOString(), | ||
| }, null, 2); | ||
| await fs.writeFile(credentialsPath, data, 'utf8'); | ||
| await fs.writeFile(credentialsPath, `${data}\n`, { encoding: 'utf8', mode: 0o600 }); | ||
| await bestEffortChmod(credentialsPath, 0o600); | ||
| } | ||
| async function getInstallationFingerprint() { | ||
| try { | ||
| const data = JSON.parse(await fs.readFile(registrationPath, 'utf8')); | ||
| if (typeof data.installation_fingerprint === 'string' && data.installation_fingerprint) { | ||
| return data.installation_fingerprint; | ||
| } | ||
| } catch { | ||
| // Create a non-secret stable ID below when no local registration file exists. | ||
| } | ||
| const installation_fingerprint = randomUUID(); | ||
| const registrationDir = path.dirname(registrationPath); | ||
| await fs.mkdir(registrationDir, { recursive: true, mode: 0o700 }); | ||
| await bestEffortChmod(registrationDir, 0o700); | ||
| await fs.writeFile(registrationPath, `${JSON.stringify({ installation_fingerprint, created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); | ||
| await bestEffortChmod(registrationPath, 0o600); | ||
| return installation_fingerprint; | ||
| } | ||
| function printMemoryResults(result, compact) { | ||
| const results = extractList(result); | ||
| if (results.length === 0) { | ||
| console.log('No matching memories found.'); | ||
| return; | ||
| } | ||
| results.forEach((item, index) => { | ||
| console.log(`[${index + 1}] ID: ${sanitizeTerminalText(item?.id || item?.memory_id || '(unknown)')} | Path: ${sanitizeTerminalText(item?.path || '(unknown)')}`); | ||
| console.log(`Content: ${formatMemoryContent(item?.content, compact)}`); | ||
| console.log('---'); | ||
| }); | ||
| } | ||
| async function requestTemporaryMemoryOperation(command, options, flags, credential) { | ||
| const headers = { Authorization: `Bearer ${credential.token}` }; | ||
| let res; | ||
| if (command === 'remember') { | ||
| const body = Object.fromEntries(Object.entries(flags).filter(([, value]) => value !== undefined)); | ||
| body.content = flags.content || ''; | ||
| body.path = flags.path || 'memories'; | ||
| res = await makeHttpRequest(options.baseUrl, '/v1/remember', 'POST', body, headers, options.timeoutMs); | ||
| } else { | ||
| const params = new URLSearchParams({ query: flags.query || '', limit: String(flags.limit || 5) }); | ||
| for (const key of ['threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']) { | ||
| if (flags[key] !== undefined) params.set(key, String(flags[key])); | ||
| } | ||
| const apiPath = command === 'search' ? '/v1/memories/search' : '/v1/recall'; | ||
| res = await makeHttpRequest(options.baseUrl, `${apiPath}?${params}`, 'GET', null, headers, options.timeoutMs); | ||
| } | ||
| const data = parseJsonResponse(res, `Temporary ${command} request`); | ||
| if (res.statusCode < 200 || res.statusCode >= 300) { | ||
| const challenge = data?.detail; | ||
| if (res.statusCode === 428 && challenge?.errorType === 'binding_confirmation_required') { | ||
| const allowPlaintext = plaintextStorageAllowed(options, credential); | ||
| const pending = { | ||
| credential_type: 'temporary', | ||
| agent_id: credential.agent_id, | ||
| bind_url: credential.bind_url, | ||
| registration_reason: credential.registration_reason, | ||
| pending_confirmation_token: challenge.confirmation_token, | ||
| }; | ||
| await saveToken(credential.token, pending, { allowPlaintext, warn: options.allowPlaintext && !credential.plaintext_storage_consent }); | ||
| if (options.json) { | ||
| console.log(safeJson(data)); | ||
| } else { | ||
| console.error('Your human account has a pending bind confirmation. Run "auth claim-confirm" to finish the formal-token handoff. Do not share the bind URL or confirmation value.'); | ||
| } | ||
| } else { | ||
| console.error(`Temporary ${command} failed: ${apiErrorMessage(data, safeJson(data))}`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| if (options.json) { | ||
| console.log(safeJson(data)); | ||
| process.exit(0); | ||
| } | ||
| if (command === 'remember') { | ||
| console.log(`✅ Saved to temporary XMemo memory.\nID: ${sanitizeTerminalText(extractId(data.result || data))}`); | ||
| } else { | ||
| printMemoryResults(data.result || data, options.compact); | ||
| } | ||
| } | ||
| async function claimStatus(baseUrl, credential, options) { | ||
| const res = await makeHttpRequest(baseUrl, '/v1/agents/status', 'GET', null, { | ||
| Authorization: `Bearer ${credential.token}`, | ||
| }, options.timeoutMs); | ||
| const data = parseJsonResponse(res, 'Claim status request'); | ||
| if (res.statusCode < 200 || res.statusCode >= 300) { | ||
| throw new Error(`Claim status request failed: ${apiErrorMessage(data, safeJson(data))}`); | ||
| } | ||
| if (typeof data.formal_token === 'string' && data.formal_token) { | ||
| const allowPlaintext = plaintextStorageAllowed(options, credential); | ||
| await saveToken(data.formal_token, { credential_type: 'formal', agent_id: credential.agent_id }, { | ||
| allowPlaintext, | ||
| warn: options.allowPlaintext && !credential.plaintext_storage_consent, | ||
| }); | ||
| console.log('✅ Formal XMemo credential received and stored in the explicitly approved user credential file. Temporary access has been replaced.'); | ||
| return data; | ||
| } | ||
| if (options.json) { | ||
| console.log(safeJson(data)); | ||
| } else { | ||
| console.log(`Claim status: ${sanitizeTerminalText(data.status || 'unknown')}`); | ||
| } | ||
| return data; | ||
| } | ||
| // Read stdin helper | ||
@@ -152,12 +700,39 @@ async function readStdin() { | ||
| async function main() { | ||
| const { command, subcommand, options, flags } = parseArgs(process.argv.slice(2)); | ||
| let { command, subcommand, positionals, options, flags } = parseArgs(process.argv.slice(2)); | ||
| if (command === 'auth-status') { | ||
| command = 'auth'; | ||
| subcommand = 'status'; | ||
| positionals = ['auth', 'status', ...positionals.slice(1)]; | ||
| } | ||
| if (options.help) { | ||
| printUsage(command); | ||
| process.exit(0); | ||
| } | ||
| if (options.version) { | ||
| console.log(SKILL_VERSION); | ||
| process.exit(0); | ||
| } | ||
| if (!command) { | ||
| console.log('XMemo Standalone Skill Runtime. Use one of: login, logout, auth status, auth add, remember, recall, search, state-save, state-restore, todo-add, todo-list, todo-done, expense-add, doctor.'); | ||
| printUsage(); | ||
| process.exit(0); | ||
| } | ||
| if (!['login', 'register', 'logout', 'auth'].includes(command) && !REST_COMMANDS.has(command)) { | ||
| console.error(`Unknown command: ${command}`); | ||
| printUsage(); | ||
| process.exit(1); | ||
| } | ||
| options.baseUrl = normalizeBaseUrl(options.baseUrl); | ||
| options.timeoutMs = parsePositiveInteger(options.timeoutMs, '--timeout-ms', MAX_TIMEOUT_MS); | ||
| validateCommandInput(command, subcommand, positionals, options, flags); | ||
| // 1. LOGIN | ||
| if (command === 'login') { | ||
| try { | ||
| requirePlaintextStorageConsent(options, 'Device login'); | ||
| const res = await makeHttpRequest(options.baseUrl, '/v1/auth/device/start', 'POST', { | ||
@@ -169,17 +744,34 @@ client_id: 'xmemo-skill', | ||
| scopes: ['memory:read', 'memory:write', 'memory:restore', 'ledger:write', 'ledger:read'] | ||
| }); | ||
| }, {}, options.timeoutMs); | ||
| const data = parseJsonResponse(res, 'Device login start'); | ||
| if (res.statusCode !== 200) { | ||
| console.error('Failed to start device login:', res.body); | ||
| console.error(`Failed to start device login: ${apiErrorMessage(data, safeJson(data))}`); | ||
| process.exit(1); | ||
| } | ||
| const data = JSON.parse(res.body); | ||
| const verificationUrl = data.verification_uri_complete || data.verification_uri; | ||
| if (!data.device_code || !verificationUrl) { | ||
| console.error('Failed to start device login: the service response omitted the device code or verification URL.'); | ||
| process.exit(1); | ||
| } | ||
| console.log(`To verify this device, open the following URL in your browser:\n`); | ||
| console.log(` ${data.verification_uri_complete}\n`); | ||
| console.log(`Or enter the code: ${data.user_code}`); | ||
| console.log(` ${sanitizeTerminalText(verificationUrl)}\n`); | ||
| console.log(`Or enter the code: ${sanitizeTerminalText(data.user_code)}`); | ||
| console.log(`\nWaiting for authorization...`); | ||
| const deviceCode = data.device_code; | ||
| const interval = (data.interval || 5) * 1000; | ||
| const intervalSeconds = Number(data.interval); | ||
| const expiresInSeconds = Number(data.expires_in); | ||
| let pollInterval = Number.isFinite(intervalSeconds) && intervalSeconds > 0 | ||
| ? Math.max(1, intervalSeconds * 1000) | ||
| : 5000; | ||
| const expiresInMs = Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 | ||
| ? Math.max(1, expiresInSeconds * 1000) | ||
| : 600_000; | ||
| const loginDeadline = Date.now() + expiresInMs; | ||
| const poll = async () => { | ||
| if (Date.now() >= loginDeadline) { | ||
| console.error('Login failed: the device authorization code expired before approval.'); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
@@ -189,11 +781,12 @@ const pollRes = await makeHttpRequest(options.baseUrl, '/v1/auth/device/token', 'POST', { | ||
| grant_type: 'urn:ietf:params:oauth:grant-type:device_code' | ||
| }); | ||
| const pollData = JSON.parse(pollRes.body); | ||
| }, {}, options.timeoutMs); | ||
| const pollData = parseJsonResponse(pollRes, 'Device login polling'); | ||
| if (pollData.error) { | ||
| if (pollData.error === 'authorization_pending') { | ||
| setTimeout(poll, interval); | ||
| setTimeout(poll, Math.min(pollInterval, Math.max(1, loginDeadline - Date.now()))); | ||
| } else if (pollData.error === 'slow_down') { | ||
| setTimeout(poll, interval + 5000); | ||
| pollInterval += 5000; | ||
| setTimeout(poll, Math.min(pollInterval, Math.max(1, loginDeadline - Date.now()))); | ||
| } else { | ||
| console.error(`Login failed: ${pollData.error_description || pollData.error}`); | ||
| console.error(`Login failed: ${sanitizeTerminalText(pollData.error_description || pollData.error)}`); | ||
| process.exit(1); | ||
@@ -203,4 +796,5 @@ } | ||
| try { | ||
| await saveToken(pollData.access_token); | ||
| console.log('✅ Authorization successful. Credentials stored securely.'); | ||
| await saveToken(pollData.access_token, { credential_type: 'formal' }, { allowPlaintext: options.allowPlaintext, warn: true }); | ||
| console.log(`✅ Authorization successful. Token stored in the explicitly approved user credential file: ${credentialsPath}`); | ||
| console.log('Token value was not printed. Project files were not modified.'); | ||
| process.exit(0); | ||
@@ -211,9 +805,16 @@ } catch (err) { | ||
| } | ||
| } else { | ||
| console.error('Login failed: the token endpoint returned neither an access token nor a recognized pending status.'); | ||
| process.exit(1); | ||
| } | ||
| } catch (e) { | ||
| console.error('Network error during polling:', e.message); | ||
| setTimeout(poll, interval); | ||
| if (Date.now() >= loginDeadline) { | ||
| console.error('Login failed: the device authorization window expired after repeated polling errors.'); | ||
| process.exit(1); | ||
| } | ||
| console.error('Login polling error:', e.message); | ||
| setTimeout(poll, Math.min(pollInterval, Math.max(1, loginDeadline - Date.now()))); | ||
| } | ||
| }; | ||
| setTimeout(poll, interval); | ||
| setTimeout(poll, Math.min(pollInterval, expiresInMs)); | ||
| } catch (e) { | ||
@@ -226,5 +827,57 @@ console.error('Login error:', e.message); | ||
| // 1b. LIMITED NO-ACCOUNT-START REGISTRATION (explicit fallback only) | ||
| if (command === 'register') { | ||
| const reason = flags.reason; | ||
| if (!['unattended', 'declined'].includes(reason)) { | ||
| console.error(`Temporary registration is a conditional fallback. Use "${SCRIPT_COMMAND} register --reason unattended --allow-plaintext" when no human can log in, or "--reason declined --allow-plaintext" after the human explicitly declines formal registration.`); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
| requirePlaintextStorageConsent(options, 'Temporary registration'); | ||
| } catch (e) { | ||
| console.error(`Temporary registration refused: ${e.message}`); | ||
| process.exit(1); | ||
| } | ||
| if (await getStoredToken()) { | ||
| console.error(`A credential is already configured. Formal login is the recommended path; use "${SCRIPT_COMMAND} login" to refresh it instead of creating temporary access.`); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
| const limits = await fetchTemporaryLimits(options.baseUrl, options.timeoutMs); | ||
| const installation_fingerprint = await getInstallationFingerprint(); | ||
| const res = await makeHttpRequest(options.baseUrl, '/v1/agents/register', 'POST', { | ||
| entry_type: 'skill', | ||
| client_name: 'xmemo-skill', | ||
| client_version: SKILL_VERSION, | ||
| installation_fingerprint, | ||
| runtime: `node ${process.version}`, | ||
| skill_package_id: 'xmemo-memory', | ||
| metadata: { registration_reason: reason }, | ||
| }, {}, options.timeoutMs); | ||
| const data = parseJsonResponse(res, 'Temporary registration'); | ||
| if (res.statusCode < 200 || res.statusCode >= 300 || !data.temporary_token) { | ||
| throw new Error(apiErrorMessage(data, safeJson(data))); | ||
| } | ||
| await saveToken(data.temporary_token, { | ||
| credential_type: 'temporary', | ||
| agent_id: data.agent_id, | ||
| bind_url: data.bind_url, | ||
| registration_reason: reason, | ||
| }, { allowPlaintext: options.allowPlaintext, warn: true }); | ||
| if (options.json) { | ||
| console.log(safeJson({ agent_id: data.agent_id, bind_url: data.bind_url, status: data.status, limits })); | ||
| } else { | ||
| console.log(`✅ Temporary XMemo memory enabled for this installation.\nThis is a limited sandbox, not a formal account.\nTemporary limits: up to ${limits.max_items} items; expires after ${formatDuration(limits.ttl_seconds)} without successful memory activity; maximum ${formatDuration(limits.max_lifetime_seconds)} from registration.\nComplete formal registration (recommended): ${sanitizeTerminalText(data.bind_url)}\nDo not share this bind URL publicly. After the human claim, run "${SCRIPT_COMMAND} auth claim-confirm" to accept the formal credential.`); | ||
| } | ||
| process.exit(0); | ||
| } catch (e) { | ||
| console.error('Temporary registration failed:', e.message); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| // 2. LOGOUT | ||
| if (command === 'logout') { | ||
| const token = await getStoredToken(); | ||
| const credential = await getStoredCredential(); | ||
| const token = credential?.token; | ||
| if (!token) { | ||
@@ -234,13 +887,59 @@ console.log('No active login found.'); | ||
| } | ||
| if (credential.storage === 'environment' && !options.revokeEnvironmentToken) { | ||
| const result = { | ||
| status: 'environment_credential_unchanged', | ||
| credential_source: 'XMEMO_KEY', | ||
| remote_revoked: false, | ||
| local_file_removed: false, | ||
| }; | ||
| if (options.json) { | ||
| console.log(safeJson(result)); | ||
| } else { | ||
| console.log('XMEMO_KEY is externally managed. No token was revoked and no local credential file was changed.'); | ||
| console.log('Unset XMEMO_KEY in the launching environment to log out, or pass --revoke-environment-token to explicitly revoke that token.'); | ||
| } | ||
| process.exit(0); | ||
| } | ||
| let remoteRevoked = false; | ||
| let revokeError = null; | ||
| try { | ||
| await makeHttpRequest(options.baseUrl, '/v1/auth/token/revoke-self', 'POST', {}, { | ||
| const revokeRes = await makeHttpRequest(options.baseUrl, '/v1/auth/token/revoke-self', 'POST', {}, { | ||
| 'Authorization': `Bearer ${token}` | ||
| }); | ||
| } catch { | ||
| // Ignored: delete local credentials anyway | ||
| }, options.timeoutMs); | ||
| remoteRevoked = revokeRes.statusCode >= 200 && revokeRes.statusCode < 300; | ||
| if (!remoteRevoked) revokeError = `HTTP ${revokeRes.statusCode}`; | ||
| } catch (error) { | ||
| revokeError = sanitizeTerminalText(error.message); | ||
| } | ||
| try { | ||
| await fs.unlink(credentialsPath); | ||
| } catch {} | ||
| console.log('✅ Logged out successfully.'); | ||
| let localFileRemoved = false; | ||
| if (credential.storage !== 'environment') { | ||
| try { | ||
| await fs.unlink(credentialsPath); | ||
| localFileRemoved = true; | ||
| } catch (error) { | ||
| if (error?.code !== 'ENOENT') throw error; | ||
| } | ||
| } | ||
| const result = { | ||
| status: remoteRevoked ? 'logged_out' : 'local_logout_completed', | ||
| credential_source: credential.storage === 'environment' ? 'XMEMO_KEY' : 'user-credential-file', | ||
| remote_revoked: remoteRevoked, | ||
| local_file_removed: localFileRemoved, | ||
| ...(revokeError ? { remote_revoke_error: revokeError } : {}), | ||
| }; | ||
| if (options.json) { | ||
| console.log(safeJson(result)); | ||
| } else if (credential.storage === 'environment') { | ||
| console.log(remoteRevoked | ||
| ? '✅ The externally managed XMEMO_KEY token was explicitly revoked. Unset XMEMO_KEY in the launching environment.' | ||
| : `The XMEMO_KEY token could not be revoked (${revokeError}). It remains externally managed.`); | ||
| } else if (remoteRevoked) { | ||
| console.log('✅ Logged out successfully. The remote token was revoked and the local credential file was removed.'); | ||
| } else { | ||
| console.log(`Local credential file removed. Remote revocation could not be confirmed${revokeError ? ` (${revokeError})` : ''}.`); | ||
| } | ||
| process.exit(0); | ||
@@ -252,3 +951,4 @@ } | ||
| if (subcommand === 'status') { | ||
| const token = await getStoredToken(); | ||
| const credential = await getStoredCredential(); | ||
| const token = credential?.token; | ||
| if (!token) { | ||
@@ -263,3 +963,7 @@ if (options.json) { | ||
| const maskedToken = token.includes(':') ? `${token.split(':')[0]}:***` : '***'; | ||
| const credentialSource = credential?.storage === 'environment' | ||
| ? 'XMEMO_KEY' | ||
| : credential?.credential_type === 'temporary' | ||
| ? 'temporary-user-credential-file' | ||
| : 'formal-user-credential-file'; | ||
| if (options.verify) { | ||
@@ -269,15 +973,16 @@ try { | ||
| 'Authorization': `Bearer ${token}` | ||
| }); | ||
| }, options.timeoutMs); | ||
| const data = parseJsonResponse(res, 'Token verification'); | ||
| if (res.statusCode === 200) { | ||
| const data = JSON.parse(res.body); | ||
| if (options.json) { | ||
| console.log(JSON.stringify({ status: 'valid', scopes: data.scopes, setup_state: data.setup_state })); | ||
| console.log(safeJson({ status: 'valid', credential_source: credentialSource, scopes: data.scopes, setup_state: data.setup_state })); | ||
| } else { | ||
| console.log(`Status: Logged in (verified)\nToken Prefix: ${maskedToken}\nScopes: ${data.scopes.join(', ')}`); | ||
| const scopes = Array.isArray(data.scopes) ? data.scopes : []; | ||
| console.log(`Status: Logged in (verified)\nCredential Source: ${credentialSource}\nScopes: ${scopes.join(', ')}`); | ||
| } | ||
| } else { | ||
| if (options.json) { | ||
| console.log(JSON.stringify({ status: 'invalid' })); | ||
| console.log(safeJson({ status: 'invalid', credential_source: credentialSource })); | ||
| } else { | ||
| console.error('Status: Invalid or expired token.'); | ||
| console.error(`Status: Invalid or expired token.${data ? ` ${apiErrorMessage(data, '')}` : ''}`); | ||
| } | ||
@@ -292,5 +997,6 @@ process.exit(1); | ||
| if (options.json) { | ||
| console.log(JSON.stringify({ status: 'logged_in', token_prefix: maskedToken })); | ||
| console.log(safeJson({ status: 'logged_in', credential_source: credentialSource })); | ||
| } else { | ||
| console.log(`Status: Logged in\nToken Prefix: ${maskedToken}`); | ||
| const kind = credential?.credential_type === 'temporary' ? 'Temporary access' : 'Logged in'; | ||
| console.log(`Status: ${kind}\nCredential Source: ${credentialSource}`); | ||
| } | ||
@@ -303,2 +1009,8 @@ } | ||
| if (flags['from-stdin'] !== undefined || process.argv.includes('--from-stdin')) { | ||
| try { | ||
| requirePlaintextStorageConsent(options, 'auth add'); | ||
| } catch (e) { | ||
| console.error(`Credential storage refused: ${e.message}`); | ||
| process.exit(1); | ||
| } | ||
| const token = await readStdin(); | ||
@@ -310,4 +1022,5 @@ if (!token) { | ||
| try { | ||
| await saveToken(token); | ||
| console.log('✅ Credentials saved.'); | ||
| await saveToken(token, { credential_type: 'formal' }, { allowPlaintext: options.allowPlaintext, warn: true }); | ||
| console.log(`✅ Credential stored in the explicitly approved user credential file: ${credentialsPath}`); | ||
| console.log('Token value was not printed. Project files were not modified.'); | ||
| process.exit(0); | ||
@@ -319,8 +1032,70 @@ } catch (err) { | ||
| } else { | ||
| console.error('Error: Run "auth add --from-stdin" to supply token.'); | ||
| console.error(`Error: Run "${SCRIPT_COMMAND} auth add --from-stdin --allow-plaintext" to supply and explicitly store a token.`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| if (subcommand === 'claim-status' || subcommand === 'claim-confirm' || subcommand === 'claim-deny') { | ||
| const credential = await getStoredCredential(); | ||
| if (!credential?.token || credential.credential_type !== 'temporary') { | ||
| console.error('Error: Claim commands require a locally stored temporary credential from "register".'); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
| if (subcommand === 'claim-deny') { | ||
| const denyRes = await makeHttpRequest(options.baseUrl, '/v1/agents/bind/deny-current-user', 'POST', {}, { | ||
| Authorization: `Bearer ${credential.token}`, | ||
| }, options.timeoutMs); | ||
| const denyData = parseJsonResponse(denyRes, 'Claim denial'); | ||
| if (denyRes.statusCode < 200 || denyRes.statusCode >= 300) { | ||
| throw new Error(apiErrorMessage(denyData, safeJson(denyData))); | ||
| } | ||
| const allowPlaintext = plaintextStorageAllowed(options, credential); | ||
| await saveToken(credential.token, { | ||
| credential_type: 'temporary', | ||
| agent_id: credential.agent_id, | ||
| bind_url: credential.bind_url, | ||
| registration_reason: credential.registration_reason, | ||
| }, { allowPlaintext, warn: options.allowPlaintext && !credential.plaintext_storage_consent }); | ||
| if (options.json) { | ||
| console.log(safeJson(denyData)); | ||
| } else { | ||
| console.log('Pending account binding declined. The credential remains limited to isolated temporary memory; formal account login is still recommended.'); | ||
| } | ||
| process.exit(0); | ||
| } | ||
| const status = await claimStatus(options.baseUrl, credential, options); | ||
| if (subcommand === 'claim-confirm' && !status.formal_token) { | ||
| const confirmation_token = status.confirmation_token || credential.pending_confirmation_token; | ||
| if (!confirmation_token) { | ||
| console.error(`No pending human claim confirmation is available. Current status: ${sanitizeTerminalText(status.status || 'unknown')}. Open the stored bind URL first: ${sanitizeTerminalText(credential.bind_url || '(unavailable)')}`); | ||
| process.exit(1); | ||
| } | ||
| const confirmRes = await makeHttpRequest(options.baseUrl, '/v1/agents/bind/confirm-current-user', 'POST', { confirmation_token }, { | ||
| Authorization: `Bearer ${credential.token}`, | ||
| }, options.timeoutMs); | ||
| const confirmData = parseJsonResponse(confirmRes, 'Claim confirmation'); | ||
| if (confirmRes.statusCode < 200 || confirmRes.statusCode >= 300) { | ||
| throw new Error(apiErrorMessage(confirmData, safeJson(confirmData))); | ||
| } | ||
| if (credential.pending_confirmation_token) { | ||
| const allowPlaintext = plaintextStorageAllowed(options, credential); | ||
| await saveToken(credential.token, { | ||
| credential_type: 'temporary', | ||
| agent_id: credential.agent_id, | ||
| bind_url: credential.bind_url, | ||
| registration_reason: credential.registration_reason, | ||
| }, { allowPlaintext, warn: options.allowPlaintext && !credential.plaintext_storage_consent }); | ||
| } | ||
| await claimStatus(options.baseUrl, credential, options); | ||
| } | ||
| process.exit(0); | ||
| } catch (e) { | ||
| console.error('Claim flow failed:', e.message); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| console.error('Unknown auth subcommand. Use "status" or "add".'); | ||
| console.error(`Unknown auth subcommand: ${subcommand || '(missing)'}`); | ||
| printUsage('auth'); | ||
| process.exit(1); | ||
@@ -330,3 +1105,4 @@ } | ||
| // 4. REST OPERATIONS (remember, recall, search, update, forget, state-save, state-restore, todo-*, expense-*, doctor) | ||
| const token = await getStoredToken(); | ||
| const credential = command === 'doctor' && options.anonymous ? null : await getStoredCredential(); | ||
| const token = credential?.token; | ||
@@ -339,6 +1115,10 @@ // Doctor can be anonymous | ||
| arguments: {} | ||
| }); | ||
| const data = JSON.parse(res.body); | ||
| }, {}, options.timeoutMs); | ||
| const data = parseJsonResponse(res, 'Doctor health check'); | ||
| if (res.statusCode < 200 || res.statusCode >= 300 || data.ok === false) { | ||
| console.error(`Doctor health check failed: ${apiErrorMessage(data, safeJson(data))}`); | ||
| process.exit(1); | ||
| } | ||
| if (options.json) { | ||
| console.log(JSON.stringify(data)); | ||
| console.log(safeJson(data)); | ||
| } else { | ||
@@ -356,10 +1136,24 @@ console.log(`XMemo Service Status: OK\nAuthentication: Missing/Unauthenticated`); | ||
| if (!token) { | ||
| console.error('Error: No XMemo credential found. Please run "node xmemo-skill.mjs login" or set process.env.XMEMO_KEY.'); | ||
| console.error(`Error: No XMemo credential found. Preferred: set XMEMO_KEY. For formal account login with explicit local storage consent, run "${SCRIPT_COMMAND} login --allow-plaintext". For a limited temporary sandbox only when permitted, run "${SCRIPT_COMMAND} register --reason unattended|declined --allow-plaintext".`); | ||
| process.exit(1); | ||
| } | ||
| if (credential?.credential_type === 'temporary') { | ||
| if (['remember', 'recall', 'search'].includes(command)) { | ||
| try { | ||
| await requestTemporaryMemoryOperation(command, options, flags, credential); | ||
| } catch (e) { | ||
| console.error('Temporary memory request failed:', e.message); | ||
| process.exit(1); | ||
| } | ||
| return; | ||
| } | ||
| console.error(`Temporary access supports only remember, recall, and search in its isolated sandbox. Complete formal registration at ${sanitizeTerminalText(credential.bind_url || 'the bind URL shown at registration')} to use ${command}.`); | ||
| process.exit(1); | ||
| } | ||
| // Normalize commands for operations mapping | ||
| let opName = command; | ||
| if (command === 'save-state') opName = 'state-save'; | ||
| if (command === 'restore-state') opName = 'state-restore'; | ||
| if (command === 'save-state' || command === 'state-save') opName = 'state-save'; | ||
| if (command === 'restore-state' || command === 'state-restore') opName = 'state-restore'; | ||
@@ -372,12 +1166,13 @@ try { | ||
| 'Authorization': `Bearer ${token}` | ||
| }); | ||
| }, options.timeoutMs); | ||
| const data = parseJsonResponse(res, `${opName} request`); | ||
| const succeeded = res.statusCode >= 200 && res.statusCode < 300 && data.ok !== false; | ||
| if (options.json) { | ||
| console.log(res.body); | ||
| process.exit(res.statusCode === 200 ? 0 : 1); | ||
| console.log(safeJson(data)); | ||
| process.exit(succeeded ? 0 : 1); | ||
| } | ||
| const data = JSON.parse(res.body); | ||
| if (!data.ok) { | ||
| console.error(`Error: ${data.error?.message || 'Operation failed'} (Code: ${data.error?.code || 'error'})`); | ||
| if (!succeeded) { | ||
| console.error(`Error: ${apiErrorMessage(data)} (Code: ${data.error?.code || `HTTP ${res.statusCode}`})`); | ||
| process.exit(1); | ||
@@ -389,3 +1184,3 @@ } | ||
| if (isValid) { | ||
| console.log(`XMemo Service Status: OK\nAuthentication: Valid\nScopes: ${(data.result?.scopes || []).join(', ')}`); | ||
| console.log(`XMemo Service Status: OK\nAuthentication: Valid\nScopes: ${extractList(data.result?.scopes).join(', ')}`); | ||
| } else { | ||
@@ -396,3 +1191,3 @@ console.log(`XMemo Service Status: OK\nAuthentication: Invalid`); | ||
| } else if (opName === 'recall' || opName === 'search') { | ||
| const results = data.result || []; | ||
| const results = extractList(data.result); | ||
| if (results.length === 0) { | ||
@@ -402,4 +1197,4 @@ console.log('No matching memories found.'); | ||
| results.forEach((item, index) => { | ||
| console.log(`[${index + 1}] ID: ${item.id} | Path: ${item.path}`); | ||
| console.log(`Content: ${item.content}`); | ||
| console.log(`[${index + 1}] ID: ${sanitizeTerminalText(item?.id || item?.memory_id || '(unknown)')} | Path: ${sanitizeTerminalText(item?.path || '(unknown)')}`); | ||
| console.log(`Content: ${formatMemoryContent(item?.content, options.compact)}`); | ||
| console.log(`---`); | ||
@@ -409,3 +1204,3 @@ }); | ||
| } else if (opName === 'todo-list') { | ||
| const todos = data.result || []; | ||
| const todos = extractList(data.result); | ||
| if (todos.length === 0) { | ||
@@ -415,11 +1210,11 @@ console.log('No TODOs found.'); | ||
| todos.forEach((todo) => { | ||
| console.log(`- [${todo.status === 'done' ? 'x' : ' '}] ${todo.content} (ID: ${todo.id})`); | ||
| console.log(`- [${todo?.status === 'done' ? 'x' : ' '}] ${sanitizeTerminalText(todo?.content || '')} (ID: ${sanitizeTerminalText(todo?.id || todo?.memory_id || '(unknown)')})`); | ||
| }); | ||
| } | ||
| } else if (opName === 'state-restore') { | ||
| console.log(`Working State restored:\nKey: ${data.result?.state_key}\nContent: ${data.result?.content}`); | ||
| console.log(`Working State restored:\nKey: ${sanitizeTerminalText(data.result?.state_key)}\nContent: ${formatMemoryContent(data.result?.content, false)}`); | ||
| } else if (opName === 'remember') { | ||
| console.log(`✅ Saved to XMemo.\nID: ${data.result}`); | ||
| console.log(`✅ Saved to XMemo.\nID: ${sanitizeTerminalText(extractId(data.result))}`); | ||
| } else if (opName === 'expense-add') { | ||
| console.log(`✅ Expense recorded.\nID: ${data.result}`); | ||
| console.log(`✅ Expense recorded.\nID: ${sanitizeTerminalText(extractId(data.result))}`); | ||
| } else { | ||
@@ -434,2 +1229,5 @@ console.log(`✅ Operation succeeded.`); | ||
| main(); | ||
| main().catch((error) => { | ||
| console.error(`Error: ${sanitizeTerminalText(error?.message || error)}`); | ||
| process.exit(1); | ||
| }); |
+91
-24
@@ -14,9 +14,14 @@ --- | ||
| 1. **Bundled Skill script** at `skills/xmemo/scripts/xmemo-skill.mjs` (Primary standalone direct REST API integration, fully self-contained and zero-dependency). | ||
| 1. **Bundled Skill script** at `scripts/xmemo-skill.mjs` (primary standalone direct REST API integration, fully self-contained and zero-dependency). | ||
| 2. **XMemo MCP tools** (when running in environments that natively host the XMemo MCP server). | ||
| If no credential is available, run: | ||
| Run bundled commands from the Skill root with Node.js 20 or newer. | ||
| Credential lookup always prefers the `XMEMO_KEY` environment variable. When it | ||
| is present, the script does not copy its value into a local credential file. | ||
| If no credential is available, use formal account login (recommended): | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs login | ||
| node scripts/xmemo-skill.mjs login --allow-plaintext | ||
| ``` | ||
@@ -29,8 +34,43 @@ | ||
| or, if you already have a token: | ||
| The standalone zero-dependency script has no cross-platform operating-system | ||
| keychain integration. `--allow-plaintext` is therefore an explicit decision to | ||
| store the issued token unencrypted in the current user's XMemo directory so | ||
| later commands can use it. The script prints the exact path, restricts POSIX | ||
| permissions where supported, never prints the token, and never writes it into | ||
| the project. Prefer `XMEMO_KEY` or a managed secret store when plaintext local | ||
| storage is not acceptable. | ||
| Formal registration/login is the default and recommended path. It gives the | ||
| user account-backed memory and the full command set. | ||
| Only when no human can complete login (`unattended`) or the human explicitly | ||
| declines registration for now (`declined`), use the explicit temporary fallback: | ||
| ```text | ||
| echo "TOKEN_VALUE" | node skills/xmemo/scripts/xmemo-skill.mjs auth add --from-stdin | ||
| node scripts/xmemo-skill.mjs register --reason unattended --allow-plaintext | ||
| ``` | ||
| Temporary access is an isolated, limited memory sandbox. It only supports | ||
| `remember`, `recall`, and `search`. The script reads the current public policy | ||
| before registration and immediately discloses its item cap, inactivity expiry, | ||
| and maximum lifetime (currently 100 items, 14 days of inactivity, and 30 days | ||
| from registration). Show the returned bind URL to the user and do not share | ||
| that URL publicly. Run | ||
| `node scripts/xmemo-skill.mjs auth claim-confirm` after they claim it. Temporary | ||
| and pending-confirmation values inherit the same explicit plaintext-storage | ||
| consent and are replaced or cleared during formal-token handoff. | ||
| or, if you already have a token, pipe it without putting the value in the | ||
| command line. POSIX shell: | ||
| ```text | ||
| printf '%s' "$XMEMO_KEY" | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| PowerShell: | ||
| ```powershell | ||
| $env:XMEMO_KEY | node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| ``` | ||
| Never ask the user to paste a raw token into chat, logs, or project files. | ||
@@ -48,4 +88,5 @@ | ||
| purchase or income. | ||
| - **Confirm destructive actions.** Always confirm the exact target before | ||
| `forget`, overwrite, or broad cleanup operations. | ||
| - **Confirm destructive actions.** The bundled script does not expose memory | ||
| deletion or overwrite commands. Use an authorized product surface with an | ||
| explicit target and user confirmation if such an operation is required. | ||
| - **Read provenance correctly.** `agent_id`, `agent_instance_id`, and | ||
@@ -57,15 +98,19 @@ `agent_boundary` are attribution signals, not authorization boundaries. | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs remember --content "..." --path "..." | ||
| node skills/xmemo/scripts/xmemo-skill.mjs recall --query "..." | ||
| node skills/xmemo/scripts/xmemo-skill.mjs search --query "..." --limit 5 | ||
| node skills/xmemo/scripts/xmemo-skill.mjs save-state --key active_task | ||
| node skills/xmemo/scripts/xmemo-skill.mjs restore-state --key active_task | ||
| node skills/xmemo/scripts/xmemo-skill.mjs todo-add --content "..." | ||
| node skills/xmemo/scripts/xmemo-skill.mjs todo-list | ||
| node skills/xmemo/scripts/xmemo-skill.mjs todo-done --id <todo_id> | ||
| node skills/xmemo/scripts/xmemo-skill.mjs expense-add --item "..." --amount 12.5 --currency USD | ||
| node skills/xmemo/scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs remember --content "..." --path "..." | ||
| node scripts/xmemo-skill.mjs recall --query "..." --compact | ||
| node scripts/xmemo-skill.mjs search --query "..." --limit 5 --compact | ||
| node scripts/xmemo-skill.mjs save-state --key active_task | ||
| node scripts/xmemo-skill.mjs restore-state --key active_task | ||
| node scripts/xmemo-skill.mjs todo-add --content "..." | ||
| node scripts/xmemo-skill.mjs todo-list | ||
| node scripts/xmemo-skill.mjs todo-done --id <todo_id> | ||
| node scripts/xmemo-skill.mjs expense-add --item "..." --amount 12.5 --currency USD | ||
| node scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs doctor --anonymous | ||
| node scripts/xmemo-skill.mjs register --reason <unattended|declined> --allow-plaintext | ||
| ``` | ||
| The script supports JSON output with --json. It never prints token values. | ||
| The script supports JSON output with `--json`, command-specific usage with | ||
| `--help`, `--version`, per-request timeouts with `--timeout-ms`, and compact | ||
| recall/search output with `--compact`. It never prints token values or prefixes. | ||
@@ -77,8 +122,17 @@ ## Direct CLI Commands | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs auth status [--verify] | ||
| node skills/xmemo/scripts/xmemo-skill.mjs auth add --from-stdin | ||
| node skills/xmemo/scripts/xmemo-skill.mjs logout | ||
| node skills/xmemo/scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs auth status [--verify] | ||
| node scripts/xmemo-skill.mjs auth-status [--verify] | ||
| node scripts/xmemo-skill.mjs auth add --from-stdin --allow-plaintext | ||
| node scripts/xmemo-skill.mjs auth claim-status [--allow-plaintext] | ||
| node scripts/xmemo-skill.mjs auth claim-confirm [--allow-plaintext] | ||
| node scripts/xmemo-skill.mjs auth claim-deny [--allow-plaintext] | ||
| node scripts/xmemo-skill.mjs logout [--revoke-environment-token] | ||
| node scripts/xmemo-skill.mjs doctor | ||
| ``` | ||
| `logout` revokes and removes a user credential file. When `XMEMO_KEY` supplies | ||
| the active credential, logout leaves that externally managed token unchanged | ||
| unless `--revoke-environment-token` is explicitly passed; unset the environment | ||
| variable in the launching environment to stop using it. | ||
| ## Setup And Repair | ||
@@ -89,6 +143,13 @@ | ||
| ```text | ||
| node skills/xmemo/scripts/xmemo-skill.mjs doctor | ||
| node skills/xmemo/scripts/xmemo-skill.mjs auth status --verify | ||
| node scripts/xmemo-skill.mjs doctor | ||
| node scripts/xmemo-skill.mjs doctor --anonymous | ||
| node scripts/xmemo-skill.mjs auth status --verify | ||
| node scripts/xmemo-skill.mjs auth-status --verify | ||
| node scripts/xmemo-skill.mjs auth claim-status | ||
| ``` | ||
| `doctor` retains authenticated diagnosis when a credential is available. | ||
| `doctor --anonymous` performs the same service-health check without sending an | ||
| Authorization header. | ||
| For detailed examples, read `references/operations.md`. For auth, network, and service diagnosis, read `references/troubleshooting.md`. | ||
@@ -116,2 +177,8 @@ | ||
| screenshots, repositories, issue comments, marketplace metadata, or shared logs. | ||
| - Prefer `XMEMO_KEY` or a managed secret store. Use `--allow-plaintext` only | ||
| after accepting that processes running as the same operating-system user may | ||
| read the local credential file. | ||
| - The default service is `https://xmemo.dev`. Custom HTTPS origins are supported | ||
| but receive credentials when an authenticated command runs; use only trusted | ||
| hosts. Plain HTTP is rejected except for localhost/loopback development. | ||
| - Use synthetic data for marketplace demos and screenshots. | ||
@@ -118,0 +185,0 @@ - Do not claim a marketplace integration is certified unless there is explicit |
+4
-0
@@ -72,2 +72,6 @@ import { | ||
| if (command === 'auth-status') { | ||
| return await authCommand(['status', ...args.slice(1)], io); | ||
| } | ||
| if (command === 'token') { | ||
@@ -74,0 +78,0 @@ return await tokenCommand(args.slice(1), io); |
+109
-24
@@ -10,3 +10,4 @@ import { hasFlag, optionValue, parsePositiveInteger } from '../core/args.js'; | ||
| storeTokenValue, | ||
| validateToken | ||
| validateToken, | ||
| credentialsPath | ||
| } from '../network/auth.js'; | ||
@@ -24,4 +25,9 @@ import { baseUrlOption } from '../network/base-url.js'; | ||
| import { readAll } from '../core/runtime.js'; | ||
| import { createInterface } from 'node:readline/promises'; | ||
| export async function loginCommand(args, io) { | ||
| if (hasHelpFlag(args)) { | ||
| writeLoginHelp(io); | ||
| return 0; | ||
| } | ||
| const outputJson = hasFlag(args, '--json'); | ||
@@ -35,3 +41,10 @@ const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin'); | ||
| if (fromStdin) { | ||
| const result = await storeTokenFromStdin(io, { source: 'stdin' }); | ||
| const consented = await authorizePlaintextStorage(args, io, { | ||
| action: 'Importing a token from stdin', | ||
| interactive: false | ||
| }); | ||
| if (!consented) { | ||
| return 0; | ||
| } | ||
| const result = await storeTokenFromStdin(io, { source: 'stdin' }, { allowPlaintext: true }); | ||
| if (outputJson) { | ||
@@ -41,3 +54,4 @@ writeLine(io.stdout, JSON.stringify(result, null, 2)); | ||
| writeLine(io.stdout, `${PRODUCT_NAME} login complete.`); | ||
| writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, `Credential stored in the approved user file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, 'Storage: unencrypted; file access is restricted to the current OS user where supported.'); | ||
| writeLine(io.stdout, 'Token value was not printed. Project files were not modified.'); | ||
@@ -48,2 +62,10 @@ } | ||
| const consented = await authorizePlaintextStorage(args, io, { | ||
| action: 'Browser login', | ||
| interactive: !outputJson | ||
| }); | ||
| if (!consented) { | ||
| return 0; | ||
| } | ||
| const start = await startDeviceLogin(baseUrl, httpTimeoutMs, io); | ||
@@ -63,3 +85,8 @@ const loginTimeoutMs = loginTimeoutOption | ||
| const token = await pollDeviceLogin(baseUrl, start, loginTimeoutMs, httpTimeoutMs, io, { pollOnce }); | ||
| const result = await storeTokenValue(token.accessToken, { source: 'device-login', account: token.account }, io.env); | ||
| const result = await storeTokenValue( | ||
| token.accessToken, | ||
| { source: 'device-login', account: token.account }, | ||
| io.env, | ||
| { allowPlaintext: true } | ||
| ); | ||
| const payload = { | ||
@@ -76,9 +103,9 @@ ...result, | ||
| } else { | ||
| writeLine(io.stdout, 'Login complete. Token stored securely in the user-scoped XMemo CLI config directory.'); | ||
| writeLine(io.stdout, `${PRODUCT_NAME} login complete.`); | ||
| if (token.account) { | ||
| writeLine(io.stdout, `Signed in as: ${formatAccount(token.account)}`); | ||
| } | ||
| writeLine(io.stdout, `Credential path: ${result.credentialPath}`); | ||
| writeLine(io.stdout, 'No extra token configuration is required.'); | ||
| writeLine(io.stdout, `Optional check: ${COMMAND_NAME} token status --verify`); | ||
| writeLine(io.stdout, `Credential stored in the approved user file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, 'Storage: unencrypted; token value was not printed.'); | ||
| writeLine(io.stdout, `Optional check: ${COMMAND_NAME} auth status --verify`); | ||
| } | ||
@@ -95,3 +122,3 @@ return 0; | ||
| writeLine(io.stdout, ''); | ||
| writeLine(io.stdout, `Use \`${COMMAND_NAME} login\` to sign in and \`${COMMAND_NAME} token add --from-stdin\` to store an existing token.`); | ||
| writeLine(io.stdout, `Use \`${COMMAND_NAME} login\` to sign in and \`${COMMAND_NAME} token add --from-stdin --allow-plaintext\` to store an existing token.`); | ||
| return 0; | ||
@@ -113,7 +140,7 @@ } | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext`); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`); | ||
| writeLine(io.stdout, ''); | ||
| writeLine(io.stdout, `${COMMAND_NAME} login is the recommended personal-user path.`); | ||
| writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin stores a token in the user-scoped XMemo CLI config directory.`); | ||
| writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin requires explicit consent to unencrypted user-file storage.`); | ||
| return 0; | ||
@@ -130,7 +157,12 @@ } | ||
| } | ||
| const result = await storeTokenFromStdin(io, { source: 'token-add' }); | ||
| await authorizePlaintextStorage(args, io, { | ||
| action: 'Adding an existing token', | ||
| interactive: false | ||
| }); | ||
| const result = await storeTokenFromStdin(io, { source: 'token-add' }, { allowPlaintext: true }); | ||
| if (hasFlag(args, '--json')) { | ||
| writeLine(io.stdout, JSON.stringify(result, null, 2)); | ||
| } else { | ||
| writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, `Credential stored in the approved user file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, 'Storage: unencrypted; file access is restricted to the current OS user where supported.'); | ||
| writeLine(io.stdout, 'Token value was not printed. Project files were not modified.'); | ||
@@ -145,13 +177,11 @@ } | ||
| } | ||
| await authorizePlaintextStorage(args, io, { | ||
| action: 'Setting a token', | ||
| interactive: false | ||
| }); | ||
| const token = (await readAll(io.stdin)).trim(); | ||
| validateToken(token); | ||
| if (!hasFlag(args, '--allow-plaintext')) { | ||
| writeLine(io.stderr, 'Token was read from stdin but was not stored.'); | ||
| writeLine(io.stderr, 'Enterprise default refuses plaintext token storage without --allow-plaintext.'); | ||
| writeLine(io.stderr, `Preferred personal-user path: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin.`); | ||
| return 2; | ||
| } | ||
| const result = await storeTokenValue(token, { source: 'token-set' }, io.env); | ||
| writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`); | ||
| const result = await storeTokenValue(token, { source: 'token-set' }, io.env, { allowPlaintext: true }); | ||
| writeLine(io.stdout, `Credential stored in the approved user file: ${result.credentialPath}`); | ||
| writeLine(io.stdout, 'Storage: unencrypted; file access is restricted to the current OS user where supported.'); | ||
| writeLine(io.stdout, 'Token value was not printed. Do not commit this file.'); | ||
@@ -182,3 +212,5 @@ return 0; | ||
| path: credential.path, | ||
| storage: credential.storage ?? null | ||
| storage: credential.storage ?? null, | ||
| encryption: credential.encryption ?? (hasUserCredential ? 'unknown' : null), | ||
| plaintextStorageConsent: credential.plaintextStorageConsent ?? false | ||
| }, | ||
@@ -199,3 +231,3 @@ account: credential.account ?? null, | ||
| writeCredentialStatus(report, io, { mode }); | ||
| writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\`.`); | ||
| writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin --allow-plaintext\`.`); | ||
| } | ||
@@ -238,2 +270,5 @@ return 1; | ||
| writeLine(io.stdout, `User credential file: ${report.userCredentialFile.present ? 'present' : 'missing'} (${report.userCredentialFile.path})`); | ||
| if (report.userCredentialFile.present) { | ||
| writeLine(io.stdout, `Credential encryption: ${report.userCredentialFile.encryption}`); | ||
| } | ||
| if (report.account) { | ||
@@ -245,1 +280,51 @@ writeLine(io.stdout, `Account: ${formatAccount(report.account)}`); | ||
| function hasHelpFlag(args) { | ||
| return hasFlag(args, '--help') || hasFlag(args, '-h'); | ||
| } | ||
| function writeLoginHelp(io) { | ||
| writeLine(io.stdout, 'Login command:'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url <url>] [--allow-plaintext]`); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} login --from-stdin --allow-plaintext [--json]`); | ||
| writeLine(io.stdout, ''); | ||
| writeLine(io.stdout, 'Interactive browser login asks once before storing the issued token unencrypted.'); | ||
| writeLine(io.stdout, 'Use --allow-plaintext to record that consent non-interactively. XMEMO_KEY remains preferred for managed environments.'); | ||
| } | ||
| async function authorizePlaintextStorage(args, io, { action, interactive }) { | ||
| const credentialPath = credentialsPath(io.env); | ||
| writeLine(io.stderr, `${action} will store the XMemo token unencrypted at:`); | ||
| writeLine(io.stderr, ` ${credentialPath}`); | ||
| writeLine(io.stderr, 'File access is restricted to the current OS user where supported. Prefer XMEMO_KEY or a managed secret store on shared systems.'); | ||
| if (hasFlag(args, '--allow-plaintext')) { | ||
| return true; | ||
| } | ||
| if (!interactive) { | ||
| throw new UsageError('Unencrypted credential storage requires --allow-plaintext in non-interactive mode.'); | ||
| } | ||
| let accepted; | ||
| if (typeof io.confirm === 'function') { | ||
| accepted = await io.confirm('Continue with unencrypted credential storage? [y/N] '); | ||
| } else { | ||
| if (!io.stdin?.isTTY) { | ||
| throw new UsageError('Interactive confirmation is unavailable. Re-run with --allow-plaintext after reviewing the storage notice.'); | ||
| } | ||
| const prompt = createInterface({ input: io.stdin, output: io.stderr }); | ||
| try { | ||
| const answer = await prompt.question('Continue with unencrypted credential storage? [y/N] '); | ||
| accepted = /^(y|yes)$/i.test(answer.trim()); | ||
| } finally { | ||
| prompt.close(); | ||
| } | ||
| } | ||
| if (!accepted) { | ||
| writeLine(io.stderr, 'Login cancelled. No credential was stored.'); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
@@ -196,3 +196,8 @@ import fs from 'node:fs/promises'; | ||
| if (shouldBackfillSharedCredential && !mcpOnly) { | ||
| await storeTokenValue(credential.token, { source: 'hermes-env-sync' }, io.env); | ||
| await storeTokenValue( | ||
| credential.token, | ||
| { source: 'hermes-env-sync', existingPlaintextSource: hermesEnvPath }, | ||
| io.env, | ||
| { allowPlaintext: true } | ||
| ); | ||
| selectedClient.credential.sharedCredentialBackfilled = true; | ||
@@ -199,0 +204,0 @@ } |
@@ -90,3 +90,3 @@ import { hasFlag, optionValue, parsePositiveInteger } from '../core/args.js'; | ||
| if (useLocalProxy) { | ||
| writeLine(io.stdout, `Requires credential: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin`); | ||
| writeLine(io.stdout, `Requires credential: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin --allow-plaintext`); | ||
| writeLine(io.stdout, `Run local proxy: ${template.requiresLocalCommand}`); | ||
@@ -93,0 +93,0 @@ } else { |
@@ -87,3 +87,3 @@ import { | ||
| }, | ||
| requiresCredential: [`${COMMAND_NAME} login`, `${COMMAND_NAME} token add --from-stdin`], | ||
| requiresCredential: [`${COMMAND_NAME} login`, `${COMMAND_NAME} token add --from-stdin --allow-plaintext`], | ||
| requiresLocalCommand: `${COMMAND_NAME} mcp proxy --port ${new URL(proxyUrl).port || DEFAULT_PROXY_PORT}`, | ||
@@ -90,0 +90,0 @@ agentIdentity: { |
@@ -28,3 +28,3 @@ import http from 'node:http'; | ||
| if (!token) { | ||
| throw new UsageError(`No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\` first.`); | ||
| throw new UsageError(`No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin --allow-plaintext\` first.`); | ||
| } | ||
@@ -113,2 +113,1 @@ validateToken(token); | ||
| } | ||
@@ -145,3 +145,3 @@ /** | ||
| const STATIC_TOOLS = STATIC_TOOL_NAMES.map((name) => ({ | ||
| export const STATIC_TOOLS = STATIC_TOOL_NAMES.map((name) => ({ | ||
| name, | ||
@@ -161,3 +161,3 @@ description: STATIC_TOOL_DESCRIPTIONS[name], | ||
| const STATIC_PROMPTS = [ | ||
| export const STATIC_PROMPTS = [ | ||
| { | ||
@@ -187,2 +187,49 @@ name: 'remember', | ||
| const GETTING_STARTED_RESOURCE = `# XMemo MCP quick start | ||
| XMemo gives AI agents durable, user-owned memory across clients and sessions. | ||
| 1. Install the CLI: \`npm install -g @xmemo/client\` | ||
| 2. Sign in: \`xmemo login\` | ||
| 3. Check the connection: \`xmemo doctor\` | ||
| 4. Configure a client: \`xmemo setup <client>\` | ||
| For stdio MCP clients, run \`xmemo-mcp\` or \`xmemo mcp serve\`. | ||
| For Streamable HTTP clients, connect to \`https://xmemo.dev/mcp\`. | ||
| Documentation: https://xmemo.dev/product/mcp | ||
| `; | ||
| const SECURITY_RESOURCE = `# XMemo security and privacy | ||
| - Credentials are read from the user-scoped XMemo credential store or the | ||
| \`XMEMO_KEY\` environment variable. | ||
| - Generated project configuration never embeds token values. | ||
| - Discovery, tools, prompts, and these documentation resources are available | ||
| without a token; tool execution requires authentication. | ||
| - Never paste XMemo credentials into prompts, source files, logs, or public | ||
| issue reports. | ||
| - Destructive operations such as \`forget\` require an explicit user request. | ||
| `; | ||
| export const STATIC_RESOURCES = [ | ||
| { | ||
| uri: 'xmemo://docs/getting-started', | ||
| name: 'XMemo MCP quick start', | ||
| description: 'Installation, authentication, and connection guidance for XMemo MCP.', | ||
| mimeType: 'text/markdown' | ||
| }, | ||
| { | ||
| uri: 'xmemo://docs/security', | ||
| name: 'XMemo security and privacy', | ||
| description: 'Credential handling, privacy boundaries, and destructive-action guidance.', | ||
| mimeType: 'text/markdown' | ||
| } | ||
| ]; | ||
| const STATIC_RESOURCE_CONTENT = new Map([ | ||
| ['xmemo://docs/getting-started', GETTING_STARTED_RESOURCE], | ||
| ['xmemo://docs/security', SECURITY_RESOURCE] | ||
| ]); | ||
| function handlePromptsGet(id, params, ctx) { | ||
@@ -215,5 +262,24 @@ const name = params?.name; | ||
| function handleResourcesRead(id, params) { | ||
| const uri = params?.uri; | ||
| const resource = STATIC_RESOURCES.find((item) => item.uri === uri); | ||
| const text = STATIC_RESOURCE_CONTENT.get(uri); | ||
| if (!resource || text === undefined) { | ||
| return makeError(id, -32002, `Resource not found: ${uri || '(missing uri)'}`); | ||
| } | ||
| return makeResult(id, { | ||
| contents: [ | ||
| { | ||
| uri: resource.uri, | ||
| mimeType: resource.mimeType, | ||
| text | ||
| } | ||
| ] | ||
| }); | ||
| } | ||
| const SERVER_CAPABILITIES = { | ||
| tools: {}, | ||
| prompts: {} | ||
| prompts: {}, | ||
| resources: {} | ||
| }; | ||
@@ -305,3 +371,5 @@ | ||
| case 'resources/list': | ||
| return makeResult(id, { resources: [] }); | ||
| return makeResult(id, { resources: STATIC_RESOURCES }); | ||
| case 'resources/read': | ||
| return handleResourcesRead(id, params); | ||
| case 'ping': | ||
@@ -308,0 +376,0 @@ return makeResult(id, {}); |
+20
-5
@@ -29,3 +29,9 @@ import fs from 'node:fs/promises'; | ||
| token_type: 'mcp_token', | ||
| scopes: ['memory:read', 'memory:write'] | ||
| scopes: [ | ||
| 'memory:read', | ||
| 'memory:write', | ||
| 'memory:restore', | ||
| 'ledger:write', | ||
| 'ledger:read' | ||
| ] | ||
| }, timeoutMs, io); | ||
@@ -84,10 +90,13 @@ | ||
| export async function storeTokenFromStdin(io, metadata = {}) { | ||
| export async function storeTokenFromStdin(io, metadata = {}, options = {}) { | ||
| const token = (await readAll(io.stdin)).trim(); | ||
| validateToken(token); | ||
| return await storeTokenValue(token, metadata, io.env); | ||
| return await storeTokenValue(token, metadata, io.env, options); | ||
| } | ||
| export async function storeTokenValue(token, metadata, env) { | ||
| export async function storeTokenValue(token, metadata, env, options = {}) { | ||
| validateToken(token); | ||
| if (options.allowPlaintext !== true) { | ||
| throw new UsageError('Refusing to store an unencrypted token without explicit plaintext-storage consent.'); | ||
| } | ||
| const credentialPath = credentialsPath(env); | ||
@@ -100,3 +109,4 @@ await writePlaintextCredential(credentialPath, token, metadata); | ||
| projectFilesModified: false, | ||
| storage: 'user-scoped-credential-file' | ||
| storage: 'user-scoped-credential-file', | ||
| encryption: 'none' | ||
| }; | ||
@@ -116,2 +126,4 @@ } | ||
| storage: stringValue(parsed, ['storage']), | ||
| encryption: stringValue(parsed, ['encryption']), | ||
| plaintextStorageConsent: parsed.plaintextStorageConsent === true, | ||
| account: accountFromPayload(parsed.metadata) | ||
@@ -183,2 +195,5 @@ }; | ||
| storage: 'user-scoped-credential-file', | ||
| encryption: 'none', | ||
| plaintextStorageConsent: true, | ||
| plaintextStorageConsentAt: new Date().toISOString(), | ||
| createdAt: new Date().toISOString(), | ||
@@ -185,0 +200,0 @@ metadata, |
+7
-5
@@ -29,8 +29,10 @@ import { | ||
| writeLine(io.stdout, 'Authentication'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>]`); | ||
| writeLine(io.stdout, ' Start browser device login or save a token from stdin.'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url <url>] [--allow-plaintext]`); | ||
| writeLine(io.stdout, ' Start browser login; interactive use asks before unencrypted storage.'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} auth status [--verify]`); | ||
| writeLine(io.stdout, ' Check login state and optionally verify the active credential.'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`); | ||
| writeLine(io.stdout, ' Check the local credential without printing secrets.'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`); | ||
| writeLine(io.stdout, ' Store an existing XMemo token from stdin.'); | ||
| writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext`); | ||
| writeLine(io.stdout, ' Store an existing token after explicit consent to unencrypted storage.'); | ||
| writeLine(io.stdout, ''); | ||
@@ -63,3 +65,3 @@ writeLine(io.stdout, 'Operations'); | ||
| writeLine(io.stdout, ' - Dry-run first for setup and uninstall flows.'); | ||
| writeLine(io.stdout, ' - Tokens stay in XMEMO_KEY or user-scoped credentials; they are never written to project configs.'); | ||
| writeLine(io.stdout, ' - XMEMO_KEY is preferred; approved local credentials are unencrypted and never written to project configs.'); | ||
| writeLine(io.stdout, ' - Config writes preserve unrelated servers and settings.'); | ||
@@ -66,0 +68,0 @@ writeLine(io.stdout, ''); |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="XMemo Claude connector icon"> | ||
| <defs> | ||
| <radialGradient id="halo" cx="28%" cy="18%" r="78%"> | ||
| <stop offset="0" stop-color="#8df7d5"/> | ||
| <stop offset="0.36" stop-color="#22d3ee"/> | ||
| <stop offset="0.74" stop-color="#102033"/> | ||
| <stop offset="1" stop-color="#070b18"/> | ||
| </radialGradient> | ||
| <linearGradient id="mark" x1="180" x2="844" y1="238" y2="786" gradientUnits="userSpaceOnUse"> | ||
| <stop offset="0" stop-color="#f2fff9"/> | ||
| <stop offset="0.42" stop-color="#8df7d5"/> | ||
| <stop offset="1" stop-color="#22d3ee"/> | ||
| </linearGradient> | ||
| <filter id="glow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB"> | ||
| <feDropShadow dx="0" dy="24" stdDeviation="46" flood-color="#22d3ee" flood-opacity="0.36"/> | ||
| <feDropShadow dx="0" dy="0" stdDeviation="18" flood-color="#8df7d5" flood-opacity="0.46"/> | ||
| </filter> | ||
| </defs> | ||
| <rect width="1024" height="1024" rx="220" fill="#070b18"/> | ||
| <circle cx="272" cy="214" r="440" fill="url(#halo)" opacity="0.64"/> | ||
| <circle cx="842" cy="820" r="420" fill="#14b8a6" opacity="0.18"/> | ||
| <path fill="#ffffff" opacity="0.08" d="M126 746c160 70 310 88 448 56 138-31 246-110 324-235v235c-88 78-197 125-326 141-156 19-304-7-446-79Z"/> | ||
| <g filter="url(#glow)"> | ||
| <path fill="url(#mark)" d="M224 724V300h118l170 230 170-230h118v424H672V502L552 670h-80L352 502v222Z"/> | ||
| <path fill="#e6fff8" opacity="0.92" d="M382 300h96l34 48 34-48h96L512 486Z"/> | ||
| </g> | ||
| </svg> |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="XMemo Claude connector icon"> | ||
| <defs> | ||
| <radialGradient id="halo" cx="28%" cy="18%" r="78%"> | ||
| <stop offset="0" stop-color="#8df7d5"/> | ||
| <stop offset="0.36" stop-color="#22d3ee"/> | ||
| <stop offset="0.74" stop-color="#102033"/> | ||
| <stop offset="1" stop-color="#070b18"/> | ||
| </radialGradient> | ||
| <linearGradient id="mark" x1="180" x2="844" y1="238" y2="786" gradientUnits="userSpaceOnUse"> | ||
| <stop offset="0" stop-color="#f2fff9"/> | ||
| <stop offset="0.42" stop-color="#8df7d5"/> | ||
| <stop offset="1" stop-color="#22d3ee"/> | ||
| </linearGradient> | ||
| <filter id="glow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB"> | ||
| <feDropShadow dx="0" dy="24" stdDeviation="46" flood-color="#22d3ee" flood-opacity="0.36"/> | ||
| <feDropShadow dx="0" dy="0" stdDeviation="18" flood-color="#8df7d5" flood-opacity="0.46"/> | ||
| </filter> | ||
| </defs> | ||
| <rect width="1024" height="1024" rx="220" fill="#070b18"/> | ||
| <circle cx="272" cy="214" r="440" fill="url(#halo)" opacity="0.64"/> | ||
| <circle cx="842" cy="820" r="420" fill="#14b8a6" opacity="0.18"/> | ||
| <path fill="#ffffff" opacity="0.08" d="M126 746c160 70 310 88 448 56 138-31 246-110 324-235v235c-88 78-197 125-326 141-156 19-304-7-446-79Z"/> | ||
| <g filter="url(#glow)"> | ||
| <path fill="url(#mark)" d="M224 724V300h118l170 230 170-230h118v424H672V502L552 670h-80L352 502v222Z"/> | ||
| <path fill="#e6fff8" opacity="0.92" d="M382 300h96l34 48 34-48h96L512 486Z"/> | ||
| </g> | ||
| </svg> |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
421506
47.28%69
4.55%6653
15.97%516
-27.22%22
10%