diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6af1b56..9ab1421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,22 @@ jobs: - run: npm run lint - run: npm test - run: npm run pack:dry-run + + compatibility: + name: ${{ matrix.os }} / Node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run pack:dry-run diff --git a/package-lock.json b/package-lock.json index 139a3c3..a082f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@xmemo/client", - "version": "0.4.181", + "version": "0.4.182", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xmemo/client", - "version": "0.4.181", + "version": "0.4.182", "license": "MIT", "bin": { "memory-os": "bin/memory-os.js", diff --git a/package.json b/package.json index 215b912..88c53a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xmemo/client", - "version": "0.4.181", + "version": "0.4.182", "description": "Privacy-first CLI client and MCP setup tool for XMemo.", "mcpName": "io.github.yonro/xmemo", "type": "module", @@ -12,6 +12,7 @@ "files": [ "bin", "docs/assets", + "scripts", "src", "skills", "plugins/kiro", @@ -21,7 +22,7 @@ ], "scripts": { "lint": "node scripts/check-js.mjs", - "test": "node --test \"test/**/*.test.js\"", + "test": "node scripts/run-tests.mjs", "pack:dry-run": "npm 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" diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..fdfc0fb --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,26 @@ +import { readdir } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const testDirectory = path.join(repositoryRoot, 'test'); +const testFiles = (await readdir(testDirectory)) + .filter((name) => name.endsWith('.test.js')) + .sort() + .map((name) => path.join(testDirectory, name)); + +if (testFiles.length === 0) { + console.error(`No root CLI tests found in ${testDirectory}.`); + process.exitCode = 1; +} else { + const child = spawn(process.execPath, ['--test', ...testFiles], { stdio: 'inherit' }); + child.on('error', (error) => { + console.error(`Could not start the Node test runner: ${error.message}`); + process.exitCode = 1; + }); + child.on('exit', (code, signal) => { + if (signal) process.exitCode = 1; + else process.exitCode = code ?? 1; + }); +} diff --git a/server.json b/server.json index 1ab6f64..8a322b9 100644 --- a/server.json +++ b/server.json @@ -51,7 +51,7 @@ { "registryType": "npm", "identifier": "@xmemo/client", - "version": "0.4.181", + "version": "0.4.182", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/api/client.js b/src/api/client.js index 6df9021..87da675 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -42,8 +42,9 @@ export function createServiceClient({ throw new UsageError('timeoutMs must be a positive integer.'); } - async function request({ method, path, query, body, sideEffect = false, retry = 'none', operation, timeoutMs: requestTimeoutMs = timeoutMs }) { + async function request({ method, path, query, body, sideEffect = false, retry = 'none', operation, timeoutMs: requestTimeoutMs = timeoutMs, deadlineMs }) { if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) throw new UsageError('Request timeout must be a positive integer.'); + if (deadlineMs !== undefined && (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0)) throw new UsageError('Request deadline must be a positive integer.'); if (io.signal?.aborted) throw new InterruptedError('Local request interrupted before transmission.'); const url = buildUrl(serviceBaseUrl, path, query); const headers = { @@ -64,10 +65,13 @@ export function createServiceClient({ } const attempts = retry === 'bounded' && !sideEffect ? 2 : 1; + const deadline = deadlineMs === undefined ? null : Date.now() + deadlineMs; let lastError; for (let attempt = 0; attempt < attempts; attempt += 1) { try { - const { response, payload } = await fetchWithTimeout(url, init, requestTimeoutMs, io, async (response) => { + const remaining = deadline === null ? requestTimeoutMs : deadline - Date.now(); + if (remaining <= 0) throw new ServiceClientError(`Service request deadline exceeded: ${method} ${path}.`, { code: 'REQUEST_DEADLINE_EXCEEDED' }); + const { response, payload } = await fetchWithTimeout(url, init, Math.min(requestTimeoutMs, remaining), io, async (response) => { let payload; try { payload = await readJsonResponse(response, maxResponseBytes); } catch (error) { @@ -79,7 +83,14 @@ export function createServiceClient({ if (!response.ok) { const safePayload = safeErrorData(payload, token); const details = classifyHttpFailure(response.status, safePayload); - if (details.retryable && attempt + 1 < attempts) continue; + if (details.retryable && attempt + 1 < attempts) { + const retryAfterMs = retryDelayMs(response.headers?.get?.('retry-after'), attempt); + if (deadline !== null && retryAfterMs >= deadline - Date.now()) { + throw new ServiceClientError(`Service request deadline exceeded while waiting to retry: ${method} ${path}.`, { code: 'REQUEST_DEADLINE_EXCEEDED', data: { retryAfterMs } }); + } + await waitForRetry(retryAfterMs, io); + continue; + } if ((details.httpStatus === 404 || details.httpStatus === 405) && operation?.contractRequired) { throw new ContractRequiredError(`Server contract is unavailable for ${operation.name ?? path}.`, details); } @@ -116,6 +127,36 @@ export function createServiceClient({ return Object.freeze({ baseUrl: serviceBaseUrl, request }); } +function retryDelayMs(retryAfter, attempt) { + if (typeof retryAfter === 'string' && /^\d+(?:\.\d+)?$/u.test(retryAfter.trim())) return Math.ceil(Number(retryAfter) * 1000); + if (typeof retryAfter === 'string') { + const dateMs = Date.parse(retryAfter); + if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now()); + } + const baseMs = attempt === 0 ? 250 : 1000; + return Math.round(baseMs * (0.8 + Math.random() * 0.4)); +} + +async function waitForRetry(delayMs, io) { + if (delayMs <= 0) return; + if (io.signal?.aborted) throw new InterruptedError('Local request interrupted while waiting to retry.'); + await new Promise((resolve, reject) => { + let settled = false; + const finish = (callback) => { + if (settled) return; + settled = true; + io.signal?.removeEventListener?.('abort', abort); + callback(); + }; + const timer = setTimeout(() => finish(resolve), delayMs); + const abort = () => { + clearTimeout(timer); + finish(() => reject(new InterruptedError('Local request interrupted while waiting to retry.'))); + }; + io.signal?.addEventListener?.('abort', abort, { once: true }); + }); +} + function buildUrl(baseUrl, path, query) { const url = new URL(endpointUrl(baseUrl, path)); if (query && typeof query === 'object') { diff --git a/src/api/confirmation.js b/src/api/confirmation.js index d66f6e4..31a687b 100644 --- a/src/api/confirmation.js +++ b/src/api/confirmation.js @@ -5,6 +5,7 @@ import { ConfirmationRequiredError } from './errors.js'; export async function confirmRemoteAction(args, io, message) { if (hasFlag(args, '--yes')) return; + if (io.preflightOnly) throw new ConfirmationRequiredError(message); if (hasFlag(args, '--json') || !io.stdin?.isTTY) throw new ConfirmationRequiredError(message); let accepted; if (typeof io.confirm === 'function') { diff --git a/src/api/contracts/command-registry.js b/src/api/contracts/command-registry.js index b6f7ef5..525f685 100644 --- a/src/api/contracts/command-registry.js +++ b/src/api/contracts/command-registry.js @@ -32,6 +32,15 @@ export const COMMAND_REGISTRY = Object.freeze([ availability: 'current', ...read }, + { + command: 'memory.read', + domain: 'memory', + method: 'GET', + path: '/api/v1/memories/{memory_id}/explain', + scopes: ['memory:read'], + availability: 'current', + ...read + }, { command: 'context.recall', domain: 'context', diff --git a/src/api/contracts/help-schema.js b/src/api/contracts/help-schema.js index 1972f17..ddb5723 100644 --- a/src/api/contracts/help-schema.js +++ b/src/api/contracts/help-schema.js @@ -6,11 +6,13 @@ const common = Object.freeze({ '--json': { type: 'boolean', description: '输出单个 JSON envelope。' }, '--base-url': { type: 'https-url', description: '目标 XMemo 服务地址。' }, '--timeout-ms': { type: 'integer>0', description: '单次 HTTP 请求超时。' }, + '--timeout': { type: 'duration', description: '单次 HTTP 请求超时;兼容 --timeout-ms。' }, + '--deadline': { type: 'duration', description: '整个服务调用的总预算,例如 30s。' }, '--allow-legacy-credential': { type: 'boolean', description: '仅允许无 origin 元数据的旧凭证连接默认服务;推荐重新登录迁移。' } }); const INPUT_COMMANDS = new Set([ - 'memory.add', 'memory.search', 'context.recall', 'state.save', 'state.restore', + 'memory.add', 'memory.search', 'memory.read', 'context.recall', 'state.save', 'state.restore', 'restart.snapshot', 'restart.restore', 'knowledge.add', 'knowledge.search', 'knowledge.read', 'knowledge.update', 'dream.preview', 'dream.show', 'dream.apply', 'cloud-skill.add', 'cloud-skill.list', 'cloud-skill.show', 'cloud-skill.update', 'cloud-skill.run' @@ -20,23 +22,24 @@ const option = (type, description) => ({ type, description }); const COMMAND_OPTIONS = Object.freeze({ 'memory.add': { '--content': option('string', '记忆正文。'), '--path': option('string', '记忆路径。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。'), '--team': option('id', '团队 ID。') }, 'memory.search': { '': option('string', '检索文本。'), '--limit': option('integer>0', '结果上限。'), '--team': option('id', '团队 ID。'), '--bucket': option('string', '数据桶。'), '--path': option('string', '路径过滤。'), '--prefer-working': option('boolean', '优先 working 记忆。') }, - 'context.recall': { '': option('string', '召回目标。'), '--include-knowledge': option('boolean', '包含知识库结果。'), '--team': option('id', '团队 ID。') }, + 'memory.read': { '': option('id', '完整记忆 ID。'), '--team': option('id', '团队 ID。') }, + 'context.recall': { '': option('string', '召回目标。'), '--max-tokens': option('integer>0', '上下文 token 预算。'), '--max-items': option('integer>0', '最大记忆条目数。'), '--include-knowledge': option('boolean', '包含知识库结果。'), '--team': option('id', '团队 ID。') }, 'state.save': { '--state-key': option('string', '状态槽。'), '--content': option('string', '状态正文。'), '--current-task': option('string', '当前任务。'), '--next-action': option('string', '下一动作。'), '--blocked-reason': option('string', '阻塞原因。'), '--ttl-seconds': option('integer>=0', '存活时间。') }, 'state.restore': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, 'restart.snapshot': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, - 'restart.restore': { '--snapshot-id': option('id', '快照 ID。'), '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, + 'restart.restore': { '--snapshot-id': option('id', '快照 ID。'), '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。'), '--preview': option('boolean', '只读取恢复结果,不修改工作状态。'), '--apply': option('boolean', '恢复工作状态并记录事件;需要 --yes。'), '--yes': option('boolean', '确认应用恢复。') }, 'knowledge.add': { '--base': option('id', '知识库 ID。'), '--create-base': option('string', '显式新建知识库。'), '--title': option('string', '条目标题。'), '--text': option('string', '文本内容。'), '--file': option('path', '文本或文档文件。'), '--document': option('id', '已有 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '创建为发布状态。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, 'knowledge.search': { '': option('string', '检索文本。'), '--base': option('id', '知识库 ID。'), '--limit': option('integer>0', '结果上限。'), '--cursor': option('string', '服务端游标。'), '--team': option('id', '团队 ID。') }, - 'knowledge.read': { '': option('id', '知识条目 ID。'), '--offset': option('integer>=0', '正文偏移。'), '--limit-chars': option('integer>0', '本页字符数。'), '--team': option('id', '团队 ID。') }, + 'knowledge.read': { '': option('id', '知识条目 ID。'), '--offset': option('integer>=0', '正文偏移。'), '--limit-chars': option('integer>0', '本页字符数。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') }, 'knowledge.update': { '': option('id', '知识条目 ID。'), '--from': option('path', 'knowledge read JSON。'), '--text': option('string', '新文本。'), '--file': option('path', '新文本文件。'), '--document': option('id', '同一来源 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '修改线上内容或发布草稿。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, 'dream.preview': { '--window-days': option('1..365', '回看天数。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--idempotency-key': option('string', '复用同一预览意图。'), '--team': option('id', '团队 ID。') }, - 'dream.show': { '': option('id', 'Dream run ID。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--team': option('id', '团队 ID。') }, + 'dream.show': { '': option('id', 'Dream run ID。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') }, 'dream.apply': { '': option('id', 'Dream run ID。'), '--item': option('id', '单个候选 ID。'), '--from': option('path', 'dream show JSON。'), '--yes': option('boolean', '确认写入。'), '--team': option('id', '团队 ID。') }, 'cloud-skill.add': { '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--name': option('string', '显示名。'), '--slug': option('string', '唯一 slug。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, 'cloud-skill.list': { '--team': option('id', '团队 ID。') }, - 'cloud-skill.show': { '': option('id', '技能 ID。'), '--draft': option('boolean', '读取最新维护版本。'), '--team': option('id', '团队 ID。') }, + 'cloud-skill.show': { '': option('id', '技能 ID。'), '--draft': option('boolean', '读取最新维护版本。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') }, 'cloud-skill.update': { '': option('id', '技能 ID。'), '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--from': option('path', 'cloud-skill show JSON。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, - 'cloud-skill.run': { '': option('id', '技能 ID。'), '--script': option('logical-path', '明确脚本入口。'), '--input': option('path', '包含 input_args 的 JSON。'), '--from': option('path', 'published show JSON。'), '--yes': option('boolean', '确认远端执行。'), '--timeout-seconds': option('1..60', '脚本运行上限。'), '--team': option('id', '团队 ID。') } + 'cloud-skill.run': { '': option('id', '技能 ID。'), '--script': option('logical-path', '明确脚本入口。'), '--input': option('path', '包含 input_args 的 JSON。'), '--from': option('path', 'published show JSON。'), '--yes': option('boolean', '确认远端执行。'), '--timeout-seconds': option('1..60', '脚本运行上限;兼容 --execution-timeout 30s。'), '--team': option('id', '团队 ID。') } }); const CONFIRMATION = Object.freeze({ @@ -86,3 +89,19 @@ export function writeServiceHelpSchema(io, command) { writeLine(io.stdout, JSON.stringify(schema)); return true; } + +export function writeHumanServiceHelp(io, command) { + const schema = serviceHelpSchema(command); + if (!schema) return false; + writeLine(io.stdout, `Usage: xmemo ${command.replace('.', ' ')} [options]`); + writeLine(io.stdout, ''); + writeLine(io.stdout, 'Options:'); + for (const [name, details] of Object.entries(schema.options)) { + writeLine(io.stdout, ` ${name.padEnd(22)} ${details.description}`); + } + writeLine(io.stdout, ''); + writeLine(io.stdout, `Example: ${schema.examples[0].invocation}`); + if (schema.confirmation) writeLine(io.stdout, `Impact: confirmation required (${schema.confirmation.flag}).`); + writeLine(io.stdout, 'Exit codes: 0 success; 2 input; 3 authentication; 4 permission; 6 conflict; 7 service; 10 confirmation; 11 unknown outcome.'); + return true; +} diff --git a/src/api/contracts/input-schema.js b/src/api/contracts/input-schema.js index 7bc27a9..7b769d7 100644 --- a/src/api/contracts/input-schema.js +++ b/src/api/contracts/input-schema.js @@ -1,6 +1,7 @@ const definitions = { 'memory.add': ['content path bucket scope team_id metadata:object memory_type', { content: 'Synthetic memory', path: 'examples/cli' }], 'memory.search': ['query limit:integer team_id bucket path prefer_working:boolean', { query: 'Synthetic', limit: 5 }], + 'memory.read': ['memory_id team_id', { memory_id: 'memory-id' }], 'context.recall': ['query include_knowledge:boolean team_id scope limit:integer max_items:integer max_tokens:integer path bucket memory_type status threshold:number prefer_working:boolean', { query: 'Synthetic', max_items: 5, include_knowledge: false }], 'state.save': ['state_key content current_task next_action blocked_reason metadata:object source bucket scope path ttl_seconds:integer', { state_key: 'active_task', current_task: 'Review changes', next_action: 'Run checks' }], 'state.restore': ['state_key bucket scope', { state_key: 'active_task' }], diff --git a/src/api/input.js b/src/api/input.js index 20d098f..c94ef25 100644 --- a/src/api/input.js +++ b/src/api/input.js @@ -2,9 +2,14 @@ import { optionValue } from '../core/args.js'; import { UsageError } from '../core/errors.js'; import { readTextFileBounded, readTextStreamBounded } from './text-input.js'; +const jsonInputCache = new WeakMap(); + export async function readJsonInput(args, io) { const inputPath = optionValue(args, '--input'); if (!inputPath) return null; + const cacheKey = io.inputCacheKey ?? io; + const cached = jsonInputCache.get(cacheKey); + if (cached?.inputPath === inputPath) return cached.value; const normalized = inputPath === '-' ? await readTextStreamBounded(io.stdin, 'JSON input stdin') : await readTextFileBounded(inputPath, 'JSON input'); @@ -13,6 +18,7 @@ export async function readJsonInput(args, io) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('top-level value must be a JSON object'); } + jsonInputCache.set(cacheKey, { inputPath, value }); return value; } catch (error) { throw new UsageError(`Invalid JSON input ${inputPath}: ${error.message}`); @@ -51,11 +57,18 @@ export function optionalBooleanInput(input, key) { } export function assertKnownOptions(args, allowed) { - const allowedSet = new Set(allowed); - const optionsWithValue = new Set(allowed.filter((option) => !['--services', '--json', '--yes', '--wait', '--publish', '--draft', '--include-knowledge', '--prefer-working', '--allow-legacy-credential'].includes(option))); + const allowedSet = new Set([...allowed, '--deadline']); + const optionsWithValue = new Set(allowed.filter((option) => !['--services', '--json', '--yes', '--wait', '--publish', '--draft', '--include-knowledge', '--prefer-working', '--allow-legacy-credential', '--preview', '--apply'].includes(option))); const seen = new Set(); + let endOfOptions = false; for (let index = 0; index < args.length; index += 1) { const token = args[index]; + if (token === '--') { + endOfOptions = true; + continue; + } + if (endOfOptions) continue; + if (token.startsWith('-') && !token.startsWith('--') && token !== '-') throw new UsageError(`Unsupported short option: ${token}.`); if (!token.startsWith('--')) continue; if (!allowedSet.has(token)) throw new UsageError(`Unsupported option: ${token}.`); if (seen.has(token)) throw new UsageError(`Duplicate option: ${token}.`); diff --git a/src/api/local-preflight.js b/src/api/local-preflight.js new file mode 100644 index 0000000..8a5148b --- /dev/null +++ b/src/api/local-preflight.js @@ -0,0 +1,29 @@ +import { baseUrlOption } from '../network/base-url.js'; +import { ConfirmationRequiredError } from './errors.js'; + +class LocalPreflightComplete extends Error {} + +// Executes a command handler only until its first business request. This +// shares the real input cache, but replaces output and interactivity, so all +// local parsing, file, receipt, and range failures are reported before any +// credential lookup or HTTP operation. +export async function preflightServiceHandler(handler, args, io) { + const quietIo = Object.assign(Object.create(io), { + inputCacheKey: io, + preflightOnly: true, + stdin: io.stdin, + stdout: { write() {} }, + stderr: { write() {} } + }); + const context = { + baseUrl: baseUrlOption(args, io.env ?? {}), + signal: io.signal, + client: { request() { throw new LocalPreflightComplete(); } } + }; + try { + await handler(args, quietIo, context); + } catch (error) { + if (error instanceof LocalPreflightComplete || error instanceof ConfirmationRequiredError) return; + throw error; + } +} diff --git a/src/api/read-receipt.js b/src/api/read-receipt.js index 054d095..70ed37a 100644 --- a/src/api/read-receipt.js +++ b/src/api/read-receipt.js @@ -1,4 +1,6 @@ import crypto from 'node:crypto'; +import { access, link, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import { UsageError } from '../core/errors.js'; import { readTextFileBounded } from './text-input.js'; @@ -50,3 +52,39 @@ export async function readAndValidateReceipt(filePath, { baseUrl, resource, scop if (typeof receipt.displayedRevision !== 'string' || !receipt.displayedRevision.trim()) throw new UsageError('Read receipt has no valid displayed revision; read the resource again.'); return receipt; } + +export async function writeReadReceipt(filePath, receipt) { + await prepareReadReceiptOut(filePath); + const temporaryPath = path.join(path.dirname(path.resolve(filePath)), `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + try { + await writeFile(temporaryPath, `${JSON.stringify(receipt)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + await link(temporaryPath, filePath); + } catch (error) { + if (error?.code === 'EEXIST') throw new UsageError(`Receipt file already exists: ${filePath}. Choose a new path; XMemo will not overwrite it.`); + throw new UsageError(`Could not save receipt to ${filePath}: ${error.message}`); + } finally { + await unlink(temporaryPath).catch(() => {}); + } +} + +// Validate the user-selected destination before an otherwise successful read +// performs remote work. The final write remains no-clobber and atomic, so a +// file created by another process after this probe is still never replaced. +export async function prepareReadReceiptOut(filePath) { + if (typeof filePath !== 'string' || !filePath.trim()) throw new UsageError('--receipt-out requires a file path.'); + try { + await access(filePath); + throw new UsageError(`Receipt file already exists: ${filePath}. Choose a new path; XMemo will not overwrite it.`); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const directory = path.dirname(path.resolve(filePath)); + const probePath = path.join(directory, `.${path.basename(filePath)}.${crypto.randomUUID()}.probe`); + try { + await writeFile(probePath, '', { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + } catch (error) { + throw new UsageError(`Could not save receipt to ${filePath}: ${error.message}`); + } finally { + await unlink(probePath).catch(() => {}); + } +} diff --git a/src/api/service-context.js b/src/api/service-context.js index 2c38bf2..bb0b6cd 100644 --- a/src/api/service-context.js +++ b/src/api/service-context.js @@ -1,4 +1,4 @@ -import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; +import { hasFlag, optionValue, parseDurationMs, parseIntegerInRange } from '../core/args.js'; import { DEFAULT_SERVICE_URL, TOKEN_ENV_VAR, LEGACY_TOKEN_ENV_VAR, AGENT_ID_ENV_VAR, AGENT_INSTANCE_ENV_VAR } from '../core/constants.js'; import { readStoredCredential, resolveCredentialToken } from '../network/auth.js'; import { baseUrlOption } from '../network/base-url.js'; @@ -50,5 +50,10 @@ export async function serviceContext(args, io) { agentId: io.env[AGENT_ID_ENV_VAR] ?? 'xmemo-cli', agentInstanceId: io.env[AGENT_INSTANCE_ENV_VAR] }); - return { client, baseUrl: client.baseUrl, tokenSource: environmentToken ? 'environment' : 'credential-file', signal: io.signal }; + const deadlineMs = optionValue(args, '--deadline') ? parseDurationMs(optionValue(args, '--deadline'), '--deadline') : undefined; + const deadlineClient = Object.freeze({ + ...client, + request: (request) => client.request({ ...request, ...(deadlineMs === undefined || request.deadlineMs !== undefined ? {} : { deadlineMs }) }) + }); + return { client: deadlineClient, baseUrl: client.baseUrl, tokenSource: environmentToken ? 'environment' : 'credential-file', signal: io.signal }; } diff --git a/src/api/service-output.js b/src/api/service-output.js index 0177917..54a7399 100644 --- a/src/api/service-output.js +++ b/src/api/service-output.js @@ -1,6 +1,13 @@ import { writeLine } from '../core/io.js'; export function writeHumanServiceResult(io, command, data, meta = {}) { + if (command === 'memory.search') return writeMemorySearch(io, data, meta); + if (command === 'memory.read') return writeMemoryRead(io, data); + if (command === 'context.recall') return writeContextRecall(io, data); + if (command === 'knowledge.read') return writeKnowledgeRead(io, data); + if (command === 'dream.show') return writeDreamShow(io, data); + if (command === 'cloud-skill.show') return writeCloudSkillShow(io, data, meta); + if (command === 'cloud-skill.run') return writeCloudSkillRun(io, data); writeLine(io.stdout, `${command} completed.`); if (data !== undefined) writeLine(io.stdout, JSON.stringify(data, null, 2)); if (meta.nextCursor) writeLine(io.stdout, `Next cursor: ${meta.nextCursor}`); @@ -8,3 +15,82 @@ export function writeHumanServiceResult(io, command, data, meta = {}) { for (const warning of meta.warnings) writeLine(io.stderr, `Warning: ${warning}`); } } + +function writeContextRecall(io, data) { + const items = Array.isArray(data?.items) ? data.items : Array.isArray(data?.memories) ? data.memories : []; + const budget = data?.budget ?? data?.usage ?? {}; + writeLine(io.stdout, `Recalled ${items.length} context ${items.length === 1 ? 'item' : 'items'}.`); + if (budget.used_tokens !== undefined || budget.max_tokens !== undefined) writeLine(io.stdout, `Token budget: ${budget.used_tokens ?? 'unknown'} / ${budget.max_tokens ?? 'unknown'}.`); + if (typeof data?.context_text === 'string' && data.context_text) writeLine(io.stdout, data.context_text); + else if (items.length === 0) writeLine(io.stdout, 'No matching context was returned. Refine the query or adjust the current filters.'); +} + +function writeKnowledgeRead(io, data) { + const content = data?.revision?.canonical_content; + if (typeof content === 'string') writeLine(io.stdout, content); + else writeLine(io.stdout, 'Knowledge revision has no readable content. Use --json to inspect the response.'); + const item = data?.item ?? {}; + if (item.item_id ?? item.id) writeLine(io.stdout, `ID: ${item.item_id ?? item.id} · Revision: ${item.current_revision_id ?? 'unknown'}`); +} + +function writeDreamShow(io, data) { + const run = data?.run ?? {}; + const items = Array.isArray(data?.items) ? data.items : []; + writeLine(io.stdout, `Dream run ${run.run_id ?? run.id ?? 'unknown'}: ${run.status ?? 'unknown'} · ${items.length} candidate(s).`); + for (const item of items) writeLine(io.stdout, `- ${item.title ?? item.summary ?? item.id ?? 'Unnamed candidate'} [${item.id ?? 'unknown'}]`); + if (items.length) writeLine(io.stdout, `Review and apply one: xmemo dream apply ${run.run_id ?? run.id} --item --from `); +} + +function writeCloudSkillShow(io, data, meta) { + const skill = data?.skill ?? {}; + const revision = data?.displayed_revision ?? {}; + const components = Array.isArray(data?.components) ? data.components : []; + writeLine(io.stdout, `${skill.name ?? skill.slug ?? skill.skill_id ?? 'Cloud Skill'} · ${revision.status ?? 'unknown'} revision ${revision.revision_id ?? 'unknown'}.`); + const scripts = components.filter((component) => String(component?.type ?? '').toLowerCase() === 'script').map((component) => component.logical_path).filter(Boolean); + if (scripts.length) writeLine(io.stdout, `Scripts: ${scripts.join(', ')}`); + else writeLine(io.stdout, 'This skill has readable instructions but no executable script.'); + for (const warning of meta.warnings ?? []) writeLine(io.stderr, `Warning: ${warning}`); +} + +function writeCloudSkillRun(io, data) { + writeLine(io.stdout, `Cloud Skill run: ${data?.status ?? 'unknown'} · exit code ${data?.exit_code ?? 'unknown'}.`); + const output = data?.output ?? data?.stdout; + if (typeof output === 'string' && output) writeLine(io.stdout, output); + if (data?.output_truncated) writeLine(io.stderr, 'Warning: remote output was truncated. Use --json to inspect response metadata.'); +} + +function writeMemoryRead(io, data) { + const memory = data?.memory ?? data?.item ?? data?.record ?? data; + const content = typeof memory?.content === 'string' ? memory.content : null; + if (content) writeLine(io.stdout, content); + else writeLine(io.stdout, 'Memory details were returned without readable content. Use --json to inspect the service response.'); + const id = memory?.memory_id ?? memory?.id; + const path = memory?.path ?? memory?.memory_path; + const version = memory?.version; + const details = [id && `ID: ${id}`, path && `Path: ${path}`, version !== undefined && `Version: ${version}`].filter(Boolean); + if (details.length) writeLine(io.stdout, details.join(' · ')); +} + +function writeMemorySearch(io, data, meta) { + const results = Array.isArray(data) ? data : Array.isArray(data?.results) ? data.results : []; + if (results.length === 0) { + writeLine(io.stdout, 'No matching memories found. Try a more specific query or adjust --path, --bucket, or --team.'); + return; + } + writeLine(io.stdout, `Found ${results.length} matching ${results.length === 1 ? 'memory' : 'memories'}.`); + for (const [index, item] of results.entries()) { + const id = item?.memory_id ?? item?.id ?? 'unknown'; + const location = item?.path ?? item?.memory_path ?? 'unknown path'; + const content = typeof item?.content === 'string' ? item.content.replace(/\s+/gu, ' ').trim() : '(content unavailable)'; + writeLine(io.stdout, `${index + 1}. ${location} [${id}]`); + writeLine(io.stdout, ` ${content.length > 180 ? `${content.slice(0, 177)}...` : content}`); + } + if (meta.nextCursor) writeLine(io.stdout, `More results: rerun with the returned cursor in JSON output.`); +} + +export function writeHumanServiceFailure(io, error) { + writeLine(io.stderr, `Error: ${error.message}`); + if (error?.outcome === 'unknown') writeLine(io.stderr, 'Remote change may have happened, but it was not confirmed. Do not automatically repeat the write.'); + if (error?.data?.run_id) writeLine(io.stderr, `Run: ${error.data.run_id}`); + if (error?.nextAction) writeLine(io.stderr, `Next: ${error.nextAction}`); +} diff --git a/src/cli.js b/src/cli.js index 00d4385..e9812f9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -18,27 +18,37 @@ import { profileCommand } from './commands/profile.js'; import { setupCommand } from './commands/setup.js'; import { uninstallCommand } from './commands/uninstall.js'; import { updateCommand } from './commands/update.js'; +import { skillCommand } from './commands/skill.js'; import { envCommand, writePrivacy } from './config/env.js'; import { UsageError } from './core/errors.js'; -import { writeHelp } from './ui/help.js'; +import { writeHelp, writeStart } from './ui/help.js'; import { defaultIo, writeLine } from './core/io.js'; import { contextCommand, memoryCommand, restartCommand, stateCommand } from './commands/service.js'; import { knowledgeCommand } from './commands/knowledge.js'; import { dreamCommand } from './commands/dream.js'; import { cloudSkillCommand } from './commands/cloud-skill.js'; -import { hasFlag } from './core/args.js'; +import { hasFlag, parseDurationMs } from './core/args.js'; import { errorToExitCode } from './api/errors.js'; import { writeFailure } from './api/envelope.js'; export async function run(args, io = defaultIo()) { try { + args = normalizeCliArgs(args); const command = args[0] ?? 'help'; - if (command === '--help' || command === '-h' || command === 'help') { + if (command === '--help' || command === '-h') { writeHelp(io); return 0; } + if (command === 'help') { + if (args.length === 1) { + writeHelp(io); + return 0; + } + return await run([...args.slice(1), '--help'], io); + } + if (command === '--version' || command === '-v' || command === 'version') { writeLine(io.stdout, CLI_VERSION); return 0; @@ -64,6 +74,10 @@ export async function run(args, io = defaultIo()) { return await setupCommand(args.slice(1), io); } + if (command === 'skill') { + return await skillCommand(args.slice(1), io); + } + if (command === 'uninstall') { return await uninstallCommand(args.slice(1), io); } @@ -105,39 +119,23 @@ export async function run(args, io = defaultIo()) { return 0; } - if (command === 'memory') { - return await memoryCommand(args.slice(1), io); - } - - if (command === 'context') { - return await contextCommand(args.slice(1), io); - } - - if (command === 'state') { - return await stateCommand(args.slice(1), io); - } - - if (command === 'restart') { - return await restartCommand(args.slice(1), io); - } - - if (command === 'knowledge') { - return await knowledgeCommand(args.slice(1), io); - } - - if (command === 'dream') { - return await dreamCommand(args.slice(1), io); + if (command === 'start') { + writeStart(io); + return 0; } - if (command === 'cloud-skill') { - return await cloudSkillCommand(args.slice(1), io); - } + if (command === 'memory') return await memoryCommand(args.slice(1), io); + if (command === 'context') return await contextCommand(args.slice(1), io); + if (command === 'state') return await stateCommand(args.slice(1), io); + if (command === 'restart') return await restartCommand(args.slice(1), io); + if (command === 'knowledge') return await knowledgeCommand(args.slice(1), io); + if (command === 'dream') return await dreamCommand(args.slice(1), io); + if (command === 'cloud-skill') return await cloudSkillCommand(args.slice(1), io); throw new UsageError(`Unknown command: ${command}`); } catch (error) { if (hasFlag(args, '--json') && ['memory', 'context', 'state', 'restart', 'knowledge', 'dream', 'cloud-skill'].includes(args[0])) { - const command = [args[0] ?? 'help', args[1]].filter(Boolean).join('.'); - writeFailure(io, command, error); + writeFailure(io, [args[0] ?? 'help', args[1]].filter(Boolean).join('.'), error); return errorToExitCode(error); } if (error instanceof UsageError) { @@ -151,3 +149,44 @@ export async function run(args, io = defaultIo()) { } } +function normalizeCliArgs(args) { + const expanded = []; + let endOfOptions = false; + for (const argument of args) { + if (endOfOptions) { + expanded.push(argument); + continue; + } + if (argument === '--') { + endOfOptions = true; + expanded.push(argument); + continue; + } + const equalOption = /^--([^=]+)=(.*)$/u.exec(argument); + if (equalOption) expanded.push(`--${equalOption[1]}`, equalOption[2]); + else expanded.push(argument); + } + for (let index = 0; index < expanded.length; index += 1) { + if (expanded[index] === '--') break; + if (expanded[index] === '--timeout') { + expanded[index] = '--timeout-ms'; + expanded[index + 1] = String(parseDurationMs(expanded[index + 1], '--timeout')); + } + if (expanded[index] === '--wait-timeout') { + expanded[index + 1] = String(parseDurationMs(expanded[index + 1], '--wait-timeout')); + } + if (expanded[index] === '--execution-timeout') { + const milliseconds = parseDurationMs(expanded[index + 1], '--execution-timeout'); + if (milliseconds % 1000 !== 0) throw new UsageError('--execution-timeout must be expressed in whole seconds.'); + expanded[index] = '--timeout-seconds'; + expanded[index + 1] = String(milliseconds / 1000); + } + } + // --json is a global flag: accept it before a command without giving it + // precedence over the command router. + const marker = expanded.indexOf('--'); + const optionArgs = marker === -1 ? expanded : expanded.slice(0, marker); + const bodyArgs = marker === -1 ? [] : expanded.slice(marker); + return [...optionArgs.filter((argument) => argument !== '--json'), ...optionArgs.filter((argument) => argument === '--json'), ...bodyArgs]; +} + diff --git a/src/commands/auth.js b/src/commands/auth.js index 8461393..b5b8843 100644 --- a/src/commands/auth.js +++ b/src/commands/auth.js @@ -352,4 +352,3 @@ async function authorizePlaintextStorage(args, io, { action, interactive }) { } return true; } - diff --git a/src/commands/cloud-skill.js b/src/commands/cloud-skill.js index 1025cfd..0d7b6be 100644 --- a/src/commands/cloud-skill.js +++ b/src/commands/cloud-skill.js @@ -1,20 +1,22 @@ import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; import { UsageError } from '../core/errors.js'; import { writeLine } from '../core/io.js'; -import { readAndValidateReceipt, createReadReceipt } from '../api/read-receipt.js'; +import { readAndValidateReceipt, createReadReceipt, prepareReadReceiptOut, writeReadReceipt } from '../api/read-receipt.js'; import { assertKnownOptions, assertNoUnknownInputFields, booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; import { ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; import { writeFailure, writeSuccess } from '../api/envelope.js'; import { serviceContext } from '../api/service-context.js'; -import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceHelp, writeServiceHelpSchema } from '../api/contracts/help-schema.js'; import { collectSkillFiles, readSkillFile } from '../api/upload-input.js'; -import { writeHumanServiceResult } from '../api/service-output.js'; +import { writeHumanServiceFailure, writeHumanServiceResult } from '../api/service-output.js'; import { confirmRemoteAction } from '../api/confirmation.js'; +import { preflightServiceHandler } from '../api/local-preflight.js'; export async function cloudSkillCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `cloud-skill.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `cloud-skill.${subcommand}`)) return 0; writeLine(io.stdout, 'Cloud Skill commands:'); writeLine(io.stdout, ' xmemo cloud-skill add --file SKILL.md|--dir [--publish --yes] [--json]'); writeLine(io.stdout, ' xmemo cloud-skill list [--team ] [--json]'); @@ -25,7 +27,7 @@ export async function cloudSkillCommand(args, io) { } if (subcommand === 'add') return await run('cloud-skill.add', args.slice(1), io, addSkill); if (subcommand === 'list') return await run('cloud-skill.list', args.slice(1), io, listSkills); - if (subcommand === 'show') return await run('cloud-skill.show', args.slice(1), io, showSkill); + if (subcommand === 'show') return await run('cloud-skill.show', args.slice(1), io, showSkill, preflightReceiptOut); if (subcommand === 'update') return await run('cloud-skill.update', args.slice(1), io, updateSkill); if (subcommand === 'run') return await run('cloud-skill.run', args.slice(1), io, runSkill); throw new UsageError(`Unknown cloud-skill command: ${subcommand}`); @@ -103,7 +105,7 @@ async function listSkills(args, io, context) { } async function showSkill(args, io, context) { - assertKnownOptions(args, ['--draft', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + assertKnownOptions(args, ['--draft', '--team', '--receipt-out', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['skill_id', 'draft', 'team_id']); rejectInputFlagConflicts(input, [['--draft', 'draft'], ['--team', 'team_id']], args); @@ -125,6 +127,7 @@ async function showSkill(args, io, context) { const displayedRevision = targetRevision ?? null; const displayedRevisionData = showDraft ? latestRevision : publishedRevision; const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `cloud-skill:${skillId}`, scope: teamId ?? 'personal', revision: displayedRevision, latestRevision: latestRevision?.revision_id ?? detail.data?.skill?.latest_revision_id, revisionStatus: String(displayedRevisionData?.status ?? (showDraft ? 'draft' : 'published')).toLowerCase(), revisionKind: showDraft ? 'draft' : 'published' }); + if (optionValue(args, '--receipt-out')) await writeReadReceipt(optionValue(args, '--receipt-out'), receipt); return { data: { skill: detail.data?.skill, displayed_revision: displayedRevisionData, components: components.data, executable: !showDraft && scriptCandidates(components.data).length > 0 }, meta: { readReceipt: receipt, warnings: showDraft ? ['This is a maintenance view; run requires a published view.'] : latestRevision?.revision_id !== targetRevision ? ['A newer maintenance revision exists; use show --draft before update.'] : [] } }; } @@ -171,9 +174,11 @@ function scriptCandidates(components) { return [...new Set(scripts.map((component) => String(component?.logical_path ?? '').trim()).filter(Boolean))]; } -async function run(command, args, io, handler) { +async function run(command, args, io, handler, preflight = null) { const outputJson = hasFlag(args, '--json'); try { + if (preflight) await preflight(args); + await preflightServiceHandler(handler, args, io); const context = await serviceContext(args, io); const response = await handler(args, io, context); const data = Object.hasOwn(response, 'data') ? response.data : response; @@ -183,18 +188,29 @@ async function run(command, args, io, handler) { return 0; } catch (error) { if (outputJson) writeFailure(io, command, error); - else writeLine(io.stderr, `Error: ${error.message}`); + else writeHumanServiceFailure(io, error); return errorToExitCode(error); } } function positional(args) { - const optionsWithValue = new Set(['--team', '--script', '--input', '--from', '--file', '--dir', '--name', '--slug', '--timeout-seconds', '--timeout-ms', '--base-url', '--url']); + const optionsWithValue = new Set(['--team', '--script', '--input', '--from', '--file', '--dir', '--name', '--slug', '--receipt-out', '--timeout-seconds', '--timeout-ms', '--deadline', '--base-url', '--url']); + const values = []; + let endOfOptions = false; for (let index = 0; index < args.length; index += 1) { - if (!args[index].startsWith('--')) return args[index]; - if (optionsWithValue.has(args[index])) index += 1; + const token = args[index]; + if (token === '--' && !endOfOptions) { endOfOptions = true; continue; } + if (!endOfOptions && token.startsWith('-') && !token.startsWith('--') && token !== '-') throw new UsageError(`Unsupported short option: ${token}.`); + if (endOfOptions || (!token.startsWith('--') && token !== '-')) values.push(token); + if (!endOfOptions && optionsWithValue.has(token)) index += 1; } - return null; + if (values.length > 1) throw new UsageError('cloud-skill command accepts exactly one positional argument.'); + return values[0] ?? null; +} + +async function preflightReceiptOut(args) { + const receiptPath = optionValue(args, '--receipt-out'); + if (receiptPath) await prepareReadReceiptOut(receiptPath); } async function readSkillSource(args) { diff --git a/src/commands/diagnostics.js b/src/commands/diagnostics.js index d0a3608..19bc7f7 100644 --- a/src/commands/diagnostics.js +++ b/src/commands/diagnostics.js @@ -98,14 +98,17 @@ export async function doctorCommand(args, io) { async function serviceDoctor(args, io) { try { assertKnownOptions(args, ['--services', '--team', '--json', '--base-url', '--url', '--timeout-ms', '--allow-legacy-credential']); + const requested = requestedServices(args); const context = await serviceContext(args, io); const teamId = optionValue(args, '--team'); const checks = []; for (const [name, endpoint, query] of [ + ['memory', '/api/v1/recall', { query: '__xmemo_cli_doctor_read_probe__', limit: 1 }], ['knowledge', '/api/v1/knowledge-bases', { limit: 1, include_archived: false }], ['dream', '/api/v1/me/dream/settings', {}], ['cloud-skill', '/v1/skills', {}] ]) { + if (!requested.has(name)) continue; try { const response = await context.client.request({ method: 'GET', path: endpoint, query: { ...query, team_id: teamId }, retry: 'bounded' }); checks.push({ name, readable: true, ...(name === 'dream' ? { enabled: response.data?.enabled ?? null, mode: response.data?.mode ?? null, canPreview: response.data?.entitlement?.can_preview ?? null, canApply: response.data?.entitlement?.can_apply ?? null } : {}) }); @@ -113,7 +116,7 @@ async function serviceDoctor(args, io) { checks.push({ name, readable: false, code: error.code, httpStatus: error.httpStatus, nextAction: error.nextAction, exitCode: errorToExitCode(error) }); } } - const report = { baseUrl: context.baseUrl, checks, writeReadiness: 'not-tested', cloudSkillWriteContract: 'MOS-01 deployment not verified', notes: ['Read-only checks do not prove write permission, queue health, sandbox readiness, or production availability.'] }; + const report = { baseUrl: context.baseUrl, requestedServices: [...requested], checks, writeReadiness: 'unknown (not tested)', cloudSkillWriteContract: 'unknown (MOS-01 deployment not verified)', notes: ['Read-only checks do not prove write permission, queue health, sandbox readiness, or production availability.'] }; const failed = checks.find((check) => !check.readable); if (failed) throw new ServiceClientError('One or more service read checks failed.', { code: failed.code, httpStatus: failed.httpStatus, data: report, nextAction: failed.nextAction }); if (hasFlag(args, '--json')) writeSuccess(io, 'doctor.services', report); @@ -126,6 +129,23 @@ async function serviceDoctor(args, io) { } } +function requestedServices(args) { + const supported = new Set(['memory', 'knowledge', 'dream', 'cloud-skill']); + const index = args.indexOf('--services'); + const selector = index === -1 ? null : args[index + 1]; + if (selector && !selector.startsWith('-')) { + const names = selector.split(',').map((name) => name.trim()).filter(Boolean); + if (names.length === 0 || names.some((name) => !supported.has(name))) { + throw new UsageError(`--services accepts a comma-separated subset of: ${[...supported].join(', ')}.`); + } + for (let position = 0; position < args.length; position += 1) { + if (!args[position].startsWith('-') && position !== index + 1) throw new UsageError(`Unexpected positional argument: ${args[position]}.`); + } + return new Set(names); + } + return supported; +} + export async function discoveryCommand(args, io) { const subcommand = args[0] ?? 'help'; if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { @@ -230,4 +250,3 @@ export async function smokeCommand(args, io) { } return report.ok ? 0 : 1; } - diff --git a/src/commands/dream.js b/src/commands/dream.js index c8a30bc..5f9001f 100644 --- a/src/commands/dream.js +++ b/src/commands/dream.js @@ -4,18 +4,20 @@ import { UsageError } from '../core/errors.js'; import { writeLine } from '../core/io.js'; import { sleep } from '../core/runtime.js'; import { assertKnownOptions, assertNoUnknownInputFields, booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; -import { createReadReceipt, readAndValidateReceipt } from '../api/read-receipt.js'; +import { createReadReceipt, prepareReadReceiptOut, readAndValidateReceipt, writeReadReceipt } from '../api/read-receipt.js'; import { InterruptedError, PrerequisiteRequiredError, ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; import { writeFailure, writeSuccess } from '../api/envelope.js'; import { serviceContext } from '../api/service-context.js'; -import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; -import { writeHumanServiceResult } from '../api/service-output.js'; +import { writeHumanServiceHelp, writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceFailure, writeHumanServiceResult } from '../api/service-output.js'; import { confirmRemoteAction } from '../api/confirmation.js'; +import { preflightServiceHandler } from '../api/local-preflight.js'; export async function dreamCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `dream.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `dream.${subcommand}`)) return 0; writeLine(io.stdout, 'Dream commands:'); writeLine(io.stdout, ' xmemo dream preview [--window-days ] [--wait] [--json]'); writeLine(io.stdout, ' xmemo dream show [--wait] [--json]'); @@ -23,7 +25,7 @@ export async function dreamCommand(args, io) { return 0; } if (subcommand === 'preview') return await run('dream.preview', args.slice(1), io, previewDream); - if (subcommand === 'show') return await run('dream.show', args.slice(1), io, showDream); + if (subcommand === 'show') return await run('dream.show', args.slice(1), io, showDream, preflightReceiptOut); if (subcommand === 'apply') return await run('dream.apply', args.slice(1), io, applyDream); throw new UsageError(`Unknown dream command: ${subcommand}`); } @@ -69,7 +71,7 @@ async function previewDream(args, io, context) { } async function showDream(args, io, context) { - assertKnownOptions(args, ['--wait', '--wait-timeout', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + assertKnownOptions(args, ['--wait', '--wait-timeout', '--team', '--receipt-out', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['run_id', 'wait', 'wait_timeout', 'team_id']); rejectInputFlagConflicts(input, [['--wait', 'wait'], ['--wait-timeout', 'wait_timeout'], ['--team', 'team_id']], args); @@ -85,6 +87,7 @@ async function showDream(args, io, context) { const run = response.data?.run; const candidateItemIds = Array.isArray(response.data?.items) ? response.data.items.map((item) => item?.id).filter(Boolean) : []; const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `dream-run:${runId}`, scope: teamId ?? 'personal', revision: run?.confirmation_version, settingsVersion: run?.settings_version ?? response.data?.settings_version, candidateItemIds }); + if (optionValue(args, '--receipt-out')) await writeReadReceipt(optionValue(args, '--receipt-out'), receipt); return { data: response.data, meta: { readReceipt: receipt } }; } @@ -144,9 +147,11 @@ async function waitForDream(context, runId, args, initial = null, timeoutOverrid throw new ServiceClientError(`Dream wait timed out; run ${runId} remains available for show.`, { code: 'LOCAL_WAIT_TIMEOUT', outcome: 'known-failure', data: { run_id: runId }, nextAction: `Run \`xmemo dream show ${runId}\` to continue checking.` }); } -async function run(command, args, io, handler) { +async function run(command, args, io, handler, preflight = null) { const outputJson = hasFlag(args, '--json'); try { + if (preflight) await preflight(args); + await preflightServiceHandler(handler, args, io); const context = await serviceContext(args, io); const response = await handler(args, io, context); if (outputJson) writeSuccess(io, command, response?.data ?? response, response?.meta ?? {}); @@ -154,18 +159,29 @@ async function run(command, args, io, handler) { return 0; } catch (error) { if (outputJson) writeFailure(io, command, error); - else writeLine(io.stderr, `Error: ${error.message}`); + else writeHumanServiceFailure(io, error); return errorToExitCode(error); } } function positional(args) { - const optionsWithValue = new Set(['--input', '--window-days', '--idempotency-key', '--wait-timeout', '--team', '--from', '--item', '--timeout-ms', '--base-url', '--url']); + const optionsWithValue = new Set(['--input', '--window-days', '--idempotency-key', '--wait-timeout', '--team', '--from', '--item', '--receipt-out', '--timeout-ms', '--deadline', '--base-url', '--url']); + const values = []; + let endOfOptions = false; for (let index = 0; index < args.length; index += 1) { - if (!args[index].startsWith('--')) return args[index]; - if (optionsWithValue.has(args[index])) index += 1; + const token = args[index]; + if (token === '--' && !endOfOptions) { endOfOptions = true; continue; } + if (!endOfOptions && token.startsWith('-') && !token.startsWith('--') && token !== '-') throw new UsageError(`Unsupported short option: ${token}.`); + if (endOfOptions || (!token.startsWith('--') && token !== '-')) values.push(token); + if (!endOfOptions && optionsWithValue.has(token)) index += 1; } - return null; + if (values.length > 1) throw new UsageError('dream command accepts exactly one positional argument.'); + return values[0] ?? null; +} + +async function preflightReceiptOut(args) { + const receiptPath = optionValue(args, '--receipt-out'); + if (receiptPath) await prepareReadReceiptOut(receiptPath); } function compact(value) { diff --git a/src/commands/knowledge.js b/src/commands/knowledge.js index ec7254c..b4a7873 100644 --- a/src/commands/knowledge.js +++ b/src/commands/knowledge.js @@ -5,22 +5,24 @@ import { hasFlag, optionValue, parseIntegerInRange, parsePositiveInteger } from import { UsageError } from '../core/errors.js'; import { writeLine } from '../core/io.js'; import { booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; -import { createReadReceipt, readAndValidateReceipt } from '../api/read-receipt.js'; +import { createReadReceipt, prepareReadReceiptOut, readAndValidateReceipt, writeReadReceipt } from '../api/read-receipt.js'; import { InterruptedError, PartialCompletionError, ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; import { writeFailure, writeSuccess } from '../api/envelope.js'; import { serviceContext } from '../api/service-context.js'; import { readDocumentInput } from '../api/upload-input.js'; import { readTextFileBounded } from '../api/text-input.js'; -import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceHelp, writeServiceHelpSchema } from '../api/contracts/help-schema.js'; import { sleep } from '../core/runtime.js'; import { assertKnownOptions, assertNoUnknownInputFields } from '../api/input.js'; -import { writeHumanServiceResult } from '../api/service-output.js'; +import { writeHumanServiceFailure, writeHumanServiceResult } from '../api/service-output.js'; import { confirmRemoteAction } from '../api/confirmation.js'; +import { preflightServiceHandler } from '../api/local-preflight.js'; export async function knowledgeCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `knowledge.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `knowledge.${subcommand}`)) return 0; writeLine(io.stdout, 'Knowledge commands:'); writeLine(io.stdout, ' xmemo knowledge add --base (--text |--file |--document ) [--publish --yes] [--json]'); writeLine(io.stdout, ' xmemo knowledge search [--base ] [--cursor ] [--json]'); @@ -30,7 +32,7 @@ export async function knowledgeCommand(args, io) { } if (subcommand === 'add') return await run('knowledge.add', args.slice(1), io, addKnowledge); if (subcommand === 'search') return await run('knowledge.search', args.slice(1), io, searchKnowledge); - if (subcommand === 'read') return await run('knowledge.read', args.slice(1), io, readKnowledge); + if (subcommand === 'read') return await run('knowledge.read', args.slice(1), io, readKnowledge, preflightReceiptOut); if (subcommand === 'update') return await run('knowledge.update', args.slice(1), io, updateKnowledge); throw new UsageError(`Unknown knowledge command: ${subcommand}`); } @@ -131,7 +133,7 @@ async function searchKnowledge(args, io, context) { } async function readKnowledge(args, io, context) { - assertKnownOptions(args, ['--from', '--team', '--offset', '--limit-chars', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + assertKnownOptions(args, ['--from', '--team', '--offset', '--limit-chars', '--receipt-out', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['item_id', 'from', 'team_id', 'offset', 'limit_chars']); rejectInputFlagConflicts(input, [['--from', 'from'], ['--team', 'team_id'], ['--offset', 'offset'], ['--limit-chars', 'limit_chars']], args); @@ -151,6 +153,7 @@ async function readKnowledge(args, io, context) { const revisionResponse = await context.client.request({ method: 'GET', path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}/revisions/${encodeURIComponent(revisionId)}`, query: compact({ offset, limit_chars: limitChars, team_id: teamId }), retry: 'bounded' }); const revision = revisionResponse.data; const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `knowledge-item:${itemId}`, scope: teamId ?? 'personal', revision: revisionId, version: item.version, itemStatus: item.status, content: revision?.canonical_content, sourceType: item.source_type, sourceRef: revision?.source_ref ?? previous?.sourceRef }); + if (optionValue(args, '--receipt-out')) await writeReadReceipt(optionValue(args, '--receipt-out'), receipt); return { data: { item, revision }, meta: { readReceipt: receipt } }; } @@ -262,9 +265,11 @@ async function waitForDocumentExtraction(context, document, args, teamId) { throw new ServiceClientError('Local extraction wait timed out; the uploaded Document remains available.', { code: 'LOCAL_WAIT_TIMEOUT', outcome: 'known-failure', data: { document: current }, nextAction: `Run knowledge add again with --document ${document.document_id} --document-version ${current?.version ?? document.version ?? 1} after extraction completes.` }); } -async function run(command, args, io, handler) { +async function run(command, args, io, handler, preflight = null) { const outputJson = hasFlag(args, '--json'); try { + if (preflight) await preflight(args); + await preflightServiceHandler(handler, args, io); const context = await serviceContext(args, io); const response = await handler(args, io, context); const data = Object.hasOwn(response, 'data') ? response.data : response; @@ -274,18 +279,29 @@ async function run(command, args, io, handler) { return 0; } catch (error) { if (outputJson) writeFailure(io, command, error); - else writeLine(io.stderr, `Error: ${error.message}`); + else writeHumanServiceFailure(io, error); return errorToExitCode(error); } } function positional(args) { - const optionsWithValue = new Set(['--input', '--base', '--title', '--text', '--file', '--document', '--document-version', '--team', '--limit', '--cursor', '--offset', '--limit-chars', '--from', '--timeout-ms', '--base-url', '--url']); + const optionsWithValue = new Set(['--input', '--base', '--title', '--text', '--file', '--document', '--document-version', '--team', '--limit', '--cursor', '--offset', '--limit-chars', '--from', '--receipt-out', '--timeout-ms', '--deadline', '--base-url', '--url']); + const values = []; + let endOfOptions = false; for (let index = 0; index < args.length; index += 1) { - if (!args[index].startsWith('--')) return args[index]; - if (optionsWithValue.has(args[index])) index += 1; + const token = args[index]; + if (token === '--' && !endOfOptions) { endOfOptions = true; continue; } + if (!endOfOptions && token.startsWith('-') && !token.startsWith('--') && token !== '-') throw new UsageError(`Unsupported short option: ${token}.`); + if (endOfOptions || (!token.startsWith('--') && token !== '-')) values.push(token); + if (!endOfOptions && optionsWithValue.has(token)) index += 1; } - return null; + if (values.length > 1) throw new UsageError('knowledge command accepts exactly one positional argument.'); + return values[0] ?? null; +} + +async function preflightReceiptOut(args) { + const receiptPath = optionValue(args, '--receipt-out'); + if (receiptPath) await prepareReadReceiptOut(receiptPath); } async function selectKnowledgeBase(context, io, teamId, outputJson) { diff --git a/src/commands/service.js b/src/commands/service.js index 0a0ea10..7a71d5c 100644 --- a/src/commands/service.js +++ b/src/commands/service.js @@ -2,63 +2,70 @@ import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; import { UsageError } from '../core/errors.js'; import { writeLine } from '../core/io.js'; import { assertKnownOptions, assertNoUnknownInputFields, optionalBooleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; -import { errorToExitCode } from '../api/errors.js'; +import { UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; import { writeFailure, writeSuccess } from '../api/envelope.js'; import { serviceContext } from '../api/service-context.js'; -import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; -import { writeHumanServiceResult } from '../api/service-output.js'; +import { writeHumanServiceHelp, writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceFailure, writeHumanServiceResult } from '../api/service-output.js'; +import { confirmRemoteAction } from '../api/confirmation.js'; export async function memoryCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `memory.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `memory.${subcommand}`)) return 0; writeLine(io.stdout, 'Memory commands:'); writeLine(io.stdout, ' xmemo memory add --content --path [--bucket ] [--json]'); writeLine(io.stdout, ' xmemo memory search [--limit ] [--team ] [--json]'); + writeLine(io.stdout, ' xmemo memory read [--team ] [--json]'); return 0; } - if (subcommand === 'add') return await runServiceCommand('memory.add', args.slice(1), io, memoryAdd); - if (subcommand === 'search') return await runServiceCommand('memory.search', args.slice(1), io, memorySearch); + if (subcommand === 'add') return await runServiceCommand('memory.add', args.slice(1), io, memoryAdd, validateMemoryAdd); + if (subcommand === 'search') return await runServiceCommand('memory.search', args.slice(1), io, memorySearch, validateMemorySearch); + if (subcommand === 'read') return await runServiceCommand('memory.read', args.slice(1), io, memoryRead, validateMemoryRead); throw new UsageError(`Unknown memory command: ${subcommand}`); } export async function contextCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `context.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `context.${subcommand}`)) return 0; writeLine(io.stdout, 'Context commands:'); - writeLine(io.stdout, ' xmemo context recall [--include-knowledge] [--json]'); + writeLine(io.stdout, ' xmemo context recall [--max-tokens ] [--max-items ] [--include-knowledge] [--json]'); return 0; } - if (subcommand === 'recall') return await runServiceCommand('context.recall', args.slice(1), io, contextRecall); + if (subcommand === 'recall') return await runServiceCommand('context.recall', args.slice(1), io, contextRecall, validateContextRecall); throw new UsageError(`Unknown context command: ${subcommand}`); } export async function stateCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `state.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `state.${subcommand}`)) return 0; writeLine(io.stdout, 'State commands:'); writeLine(io.stdout, ' xmemo state save [--content ] [--state-key ] [--json]'); writeLine(io.stdout, ' xmemo state restore [--state-key ] [--json]'); return 0; } - if (subcommand === 'save') return await runServiceCommand('state.save', args.slice(1), io, stateSave); - if (subcommand === 'restore') return await runServiceCommand('state.restore', args.slice(1), io, stateRestore); + if (subcommand === 'save') return await runServiceCommand('state.save', args.slice(1), io, stateSave, validateStateSave); + if (subcommand === 'restore') return await runServiceCommand('state.restore', args.slice(1), io, stateRestore, validateStateRestore); throw new UsageError(`Unknown state command: ${subcommand}`); } export async function restartCommand(args, io) { const subcommand = args[0] ?? 'help'; - if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand === 'help' || hasFlag(args, '--help') || hasFlag(args, '-h')) { if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `restart.${subcommand}`)) return 0; + if (subcommand !== 'help' && writeHumanServiceHelp(io, `restart.${subcommand}`)) return 0; writeLine(io.stdout, 'Restart commands:'); writeLine(io.stdout, ' xmemo restart snapshot [--state-key ] [--json]'); - writeLine(io.stdout, ' xmemo restart restore [--snapshot-id ] [--json]'); + writeLine(io.stdout, ' xmemo restart restore [--snapshot-id ] (--preview|--apply --yes) [--json]'); return 0; } - if (subcommand === 'snapshot') return await runServiceCommand('restart.snapshot', args.slice(1), io, restartSnapshot); - if (subcommand === 'restore') return await runServiceCommand('restart.restore', args.slice(1), io, restartRestore); + if (subcommand === 'snapshot') return await runServiceCommand('restart.snapshot', args.slice(1), io, restartSnapshot, validateRestartSnapshot); + if (subcommand === 'restore') return await runServiceCommand('restart.restore', args.slice(1), io, restartRestore, validateRestartRestore); throw new UsageError(`Unknown restart command: ${subcommand}`); } @@ -78,7 +85,41 @@ async function memoryAdd(args, io, context) { }; if (typeof body.content !== 'string' || !body.content.trim()) throw new UsageError('memory add requires non-empty --content or input.content.'); if (typeof body.path !== 'string' || !body.path.trim()) throw new UsageError('memory add requires non-empty --path or input.path.'); - return await context.client.request({ method: 'POST', path: '/api/v1/remember', body: compact(body), sideEffect: true }); + const response = await context.client.request({ method: 'POST', path: '/api/v1/remember', body: compact(body), sideEffect: true }); + const result = response.data; + if (!result || typeof result !== 'object' || ![result.id, result.memory_id, result.memoryId].some((value) => typeof value === 'string' && value.length > 0)) { + throw new UnknownOutcomeError('Memory write returned without a confirmed memory ID.', { + code: 'WRITE_RECEIPT_MISSING', + data: { status: response.status }, + nextAction: '核对服务端是否已创建记忆;不要自动重试该写入。' + }); + } + return response; +} + +async function validateMemoryAdd(args, io) { + assertKnownOptions(args, ['--content', '--path', '--bucket', '--scope', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['content', 'path', 'bucket', 'scope', 'team_id', 'metadata', 'memory_type']); + rejectInputFlagConflicts(input, [['--content', 'content'], ['--path', 'path'], ['--bucket', 'bucket'], ['--scope', 'scope'], ['--team', 'team_id']], args); + if (typeof (optionValue(args, '--content') ?? input?.content) !== 'string' || !(optionValue(args, '--content') ?? input?.content).trim()) throw new UsageError('memory add requires non-empty --content or input.content.'); + if (typeof (optionValue(args, '--path') ?? input?.path) !== 'string' || !(optionValue(args, '--path') ?? input?.path).trim()) throw new UsageError('memory add requires non-empty --path or input.path.'); +} + +async function memoryRead(args, io, context) { + const input = await readJsonInput(args, io); + const memoryId = singlePositional(args, 'memory read') ?? input?.memory_id; + return await context.client.request({ method: 'GET', path: `/api/v1/memories/${encodeURIComponent(memoryId)}/explain`, query: compact({ team_id: optionValue(args, '--team') ?? input?.team_id }), sideEffect: false, retry: 'bounded' }); +} + +async function validateMemoryRead(args, io) { + assertKnownOptions(args, ['--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['memory_id', 'team_id']); + rejectInputFlagConflicts(input, [['--team', 'team_id']], args); + const positionalId = singlePositional(args, 'memory read'); + if (positionalId && input?.memory_id !== undefined) throw new UsageError('Memory ID cannot be supplied both positionally and in --input.'); + if (typeof (positionalId ?? input?.memory_id) !== 'string' || !(positionalId ?? input?.memory_id).trim()) throw new UsageError('memory read requires a memory ID.'); } async function memorySearch(args, io, context) { @@ -86,8 +127,9 @@ async function memorySearch(args, io, context) { const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['query', 'limit', 'team_id', 'bucket', 'path', 'prefer_working']); rejectInputFlagConflicts(input, [['--limit', 'limit'], ['--team', 'team_id'], ['--bucket', 'bucket'], ['--path', 'path'], ['--prefer-working', 'prefer_working']], args); - const query = positional(args) ?? input?.query; - if (positional(args) && input?.query !== undefined) throw new UsageError('Search query cannot be supplied both positionally and in --input.'); + const queryArg = singlePositional(args, 'memory search'); + const query = queryArg ?? input?.query; + if (queryArg && input?.query !== undefined) throw new UsageError('Search query cannot be supplied both positionally and in --input.'); if (typeof query !== 'string' || !query.trim()) throw new UsageError('memory search requires a query.'); const rawLimit = optionValue(args, '--limit') ?? input?.limit; const data = await context.client.request({ @@ -97,24 +139,56 @@ async function memorySearch(args, io, context) { return data; } +async function validateMemorySearch(args, io) { + assertKnownOptions(args, ['--limit', '--team', '--bucket', '--path', '--prefer-working', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['query', 'limit', 'team_id', 'bucket', 'path', 'prefer_working']); + rejectInputFlagConflicts(input, [['--limit', 'limit'], ['--team', 'team_id'], ['--bucket', 'bucket'], ['--path', 'path'], ['--prefer-working', 'prefer_working']], args); + const queryArg = singlePositional(args, 'memory search'); + if (queryArg && input?.query !== undefined) throw new UsageError('Search query cannot be supplied both positionally and in --input.'); + const query = queryArg ?? input?.query; + if (typeof query !== 'string' || !query.trim()) throw new UsageError('memory search requires a query.'); + const rawLimit = optionValue(args, '--limit') ?? input?.limit; + if (rawLimit !== undefined && rawLimit !== null) parseIntegerInRange(rawLimit, '--limit', { min: 1, max: 5000 }); + optionalBooleanInput(input, 'prefer_working'); +} + async function contextRecall(args, io, context) { - assertKnownOptions(args, ['--include-knowledge', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + assertKnownOptions(args, ['--include-knowledge', '--max-tokens', '--max-items', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['query', 'include_knowledge', 'team_id', 'scope', 'limit', 'max_items', 'max_tokens', 'path', 'bucket', 'memory_type', 'status', 'threshold', 'prefer_working']); - rejectInputFlagConflicts(input, [['--team', 'team_id'], ['--include-knowledge', 'include_knowledge']], args); - const query = positional(args) ?? input?.query; - if (positional(args) && input?.query !== undefined) throw new UsageError('Context query cannot be supplied both positionally and in --input.'); + rejectInputFlagConflicts(input, [['--team', 'team_id'], ['--include-knowledge', 'include_knowledge'], ['--max-tokens', 'max_tokens'], ['--max-items', 'max_items']], args); + const queryArg = singlePositional(args, 'context recall'); + const query = queryArg ?? input?.query; + if (queryArg && input?.query !== undefined) throw new UsageError('Context query cannot be supplied both positionally and in --input.'); if (typeof query !== 'string' || !query.trim()) throw new UsageError('context recall requires a query.'); const body = compact({ ...input, query, include_knowledge: hasFlag(args, '--include-knowledge') ? true : optionalBooleanInput(input, 'include_knowledge'), + max_tokens: optionalRange(optionValue(args, '--max-tokens') ?? input?.max_tokens, '--max-tokens', 1, 100000), + max_items: optionalRange(optionValue(args, '--max-items') ?? input?.max_items, '--max-items', 1, 500), prefer_working: optionalBooleanInput(input, 'prefer_working'), team_id: optionValue(args, '--team') ?? input?.team_id }); return await context.client.request({ method: 'POST', path: '/api/v1/recall/context', body, sideEffect: false, retry: 'bounded' }); } +async function validateContextRecall(args, io) { + assertKnownOptions(args, ['--include-knowledge', '--max-tokens', '--max-items', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['query', 'include_knowledge', 'team_id', 'scope', 'limit', 'max_items', 'max_tokens', 'path', 'bucket', 'memory_type', 'status', 'threshold', 'prefer_working']); + rejectInputFlagConflicts(input, [['--team', 'team_id'], ['--include-knowledge', 'include_knowledge'], ['--max-tokens', 'max_tokens'], ['--max-items', 'max_items']], args); + const queryArg = singlePositional(args, 'context recall'); + if (queryArg && input?.query !== undefined) throw new UsageError('Context query cannot be supplied both positionally and in --input.'); + const query = queryArg ?? input?.query; + if (typeof query !== 'string' || !query.trim()) throw new UsageError('context recall requires a query.'); + optionalBooleanInput(input, 'include_knowledge'); + optionalBooleanInput(input, 'prefer_working'); + optionalRange(optionValue(args, '--max-tokens') ?? input?.max_tokens, '--max-tokens', 1, 100000); + optionalRange(optionValue(args, '--max-items') ?? input?.max_items, '--max-items', 1, 500); +} + async function stateSave(args, io, context) { assertKnownOptions(args, ['--state-key', '--content', '--current-task', '--next-action', '--blocked-reason', '--bucket', '--scope', '--ttl-seconds', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); @@ -149,6 +223,34 @@ async function stateRestore(args, io, context) { return await context.client.request({ method: 'POST', path: '/api/v1/skill/operations', body, sideEffect: false, retry: 'bounded' }); } +async function validateStateSave(args, io) { + assertKnownOptions(args, ['--state-key', '--content', '--current-task', '--next-action', '--blocked-reason', '--bucket', '--scope', '--ttl-seconds', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'metadata', 'source', 'bucket', 'scope', 'path', 'ttl_seconds']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--content', 'content'], ['--current-task', 'current_task'], ['--next-action', 'next_action'], ['--blocked-reason', 'blocked_reason'], ['--bucket', 'bucket'], ['--scope', 'scope'], ['--ttl-seconds', 'ttl_seconds']], args); + const ttl = optionValue(args, '--ttl-seconds') ?? input?.ttl_seconds; + if (ttl !== undefined && ttl !== null) parseIntegerInRange(ttl, '--ttl-seconds', { min: 0, max: 604800 }); + if (!(optionValue(args, '--content') ?? input?.content ?? optionValue(args, '--current-task') ?? input?.current_task ?? optionValue(args, '--next-action') ?? input?.next_action ?? optionValue(args, '--blocked-reason') ?? input?.blocked_reason)) throw new UsageError('state save requires content or a structured state field.'); +} + +async function validateStateRestore(args, io) { + assertKnownOptions(args, ['--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['state_key', 'bucket', 'scope']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); +} + +async function validateRestartSnapshot(args, io) { + assertKnownOptions(args, ['--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['session_id', 'state_key', 'timeline_limit', 'reminder_limit', 'decision_limit', 'metadata', 'source', 'bucket', 'scope', 'path', 'ttl_seconds']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); + optionalRange(input?.timeline_limit, 'timeline_limit', 0, 100); + optionalRange(input?.reminder_limit, 'reminder_limit', 0, 100); + optionalRange(input?.decision_limit, 'decision_limit', 0, 100); + optionalRange(input?.ttl_seconds, 'ttl_seconds', 0, 604800); +} + async function restartSnapshot(args, io, context) { assertKnownOptions(args, ['--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); @@ -168,18 +270,36 @@ async function restartSnapshot(args, io, context) { } async function restartRestore(args, io, context) { - assertKnownOptions(args, ['--snapshot-id', '--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + assertKnownOptions(args, ['--snapshot-id', '--state-key', '--bucket', '--scope', '--preview', '--apply', '--yes', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); const input = await readJsonInput(args, io); assertNoUnknownInputFields(input, ['snapshot_id', 'source_session_id', 'target_session_id', 'state_key', 'restore_state', 'record_restore_event', 'ttl_seconds', 'source', 'bucket', 'scope']); rejectInputFlagConflicts(input, [['--snapshot-id', 'snapshot_id'], ['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); - const body = compact({ ...input, snapshot_id: optionValue(args, '--snapshot-id') ?? input?.snapshot_id, state_key: optionValue(args, '--state-key') ?? input?.state_key, bucket: optionValue(args, '--bucket') ?? input?.bucket, scope: optionValue(args, '--scope') ?? input?.scope, restore_state: optionalBooleanInput(input, 'restore_state'), record_restore_event: optionalBooleanInput(input, 'record_restore_event'), ttl_seconds: optionalRange(input?.ttl_seconds, 'ttl_seconds', 0, 604800) }); + const preview = hasFlag(args, '--preview'); + const apply = hasFlag(args, '--apply'); + if (preview === apply) throw new UsageError('restart restore requires exactly one of --preview or --apply.'); + if (input?.restore_state !== undefined || input?.record_restore_event !== undefined) throw new UsageError('Use --preview or --apply to select restore intent; restore_state and record_restore_event are no longer accepted from --input.'); + if (apply) await confirmRemoteAction(args, io, 'Apply this restart snapshot and record the restore event?'); + const body = compact({ ...input, snapshot_id: optionValue(args, '--snapshot-id') ?? input?.snapshot_id, state_key: optionValue(args, '--state-key') ?? input?.state_key, bucket: optionValue(args, '--bucket') ?? input?.bucket, scope: optionValue(args, '--scope') ?? input?.scope, restore_state: apply, record_restore_event: apply, ttl_seconds: optionalRange(input?.ttl_seconds, 'ttl_seconds', 0, 604800) }); if (!body.snapshot_id && !body.source_session_id && !body.state_key) throw new UsageError('restart restore requires --snapshot-id, source_session_id, or state_key.'); - return await context.client.request({ method: 'POST', path: '/api/v1/restart/restore', body, sideEffect: true }); + return await context.client.request({ method: 'POST', path: '/api/v1/restart/restore', body, sideEffect: apply, retry: preview ? 'bounded' : 'none' }); +} + +async function validateRestartRestore(args, io) { + assertKnownOptions(args, ['--snapshot-id', '--state-key', '--bucket', '--scope', '--preview', '--apply', '--yes', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['snapshot_id', 'source_session_id', 'target_session_id', 'state_key', 'restore_state', 'record_restore_event', 'ttl_seconds', 'source', 'bucket', 'scope']); + rejectInputFlagConflicts(input, [['--snapshot-id', 'snapshot_id'], ['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); + if (hasFlag(args, '--preview') === hasFlag(args, '--apply')) throw new UsageError('restart restore requires exactly one of --preview or --apply.'); + if (input?.restore_state !== undefined || input?.record_restore_event !== undefined) throw new UsageError('Use --preview or --apply to select restore intent; restore_state and record_restore_event are no longer accepted from --input.'); + if (!(optionValue(args, '--snapshot-id') ?? input?.snapshot_id ?? input?.source_session_id ?? optionValue(args, '--state-key') ?? input?.state_key)) { + throw new UsageError('restart restore requires --snapshot-id, source_session_id, or state_key.'); + } } -async function runServiceCommand(command, args, io, handler) { +async function runServiceCommand(command, args, io, handler, validate = null) { const outputJson = hasFlag(args, '--json'); try { + if (validate) await validate(args, io); const context = await serviceContext(args, io); const response = await handler(args, io, context); const data = response?.data ?? response; @@ -192,18 +312,24 @@ async function runServiceCommand(command, args, io, handler) { return 0; } catch (error) { if (outputJson) writeFailure(io, command, error); - else writeLine(io.stderr, `Error: ${error.message}`); + else writeHumanServiceFailure(io, error); return errorToExitCode(error); } } -function positional(args) { - const optionsWithValue = new Set(['--input', '--content', '--path', '--bucket', '--scope', '--team', '--limit', '--state-key', '--current-task', '--next-action', '--blocked-reason', '--ttl-seconds', '--snapshot-id', '--base-url', '--url', '--timeout-ms']); +function singlePositional(args, command) { + const optionsWithValue = new Set(['--input', '--content', '--path', '--bucket', '--scope', '--team', '--limit', '--max-tokens', '--max-items', '--state-key', '--current-task', '--next-action', '--blocked-reason', '--ttl-seconds', '--snapshot-id', '--base-url', '--url', '--timeout-ms', '--deadline']); + const values = []; + let endOfOptions = false; for (let index = 0; index < args.length; index += 1) { - if (!args[index].startsWith('--') && args[index] !== '-') return args[index]; - if (optionsWithValue.has(args[index])) index += 1; + const token = args[index]; + if (token === '--' && !endOfOptions) { endOfOptions = true; continue; } + if (!endOfOptions && token.startsWith('-') && !token.startsWith('--') && token !== '-') throw new UsageError(`Unsupported short option: ${token}.`); + if (endOfOptions || (!token.startsWith('--') && token !== '-')) values.push(token); + if (!endOfOptions && optionsWithValue.has(token)) index += 1; } - return null; + if (values.length > 1) throw new UsageError(`${command} accepts exactly one positional argument.`); + return values[0] ?? null; } function compact(value) { diff --git a/src/commands/skill.js b/src/commands/skill.js new file mode 100644 index 0000000..948b4bd --- /dev/null +++ b/src/commands/skill.js @@ -0,0 +1,111 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { hasFlag, optionValue } from '../core/args.js'; +import { CLI_VERSION, COMMAND_NAME, PACKAGE_NAME } from '../core/constants.js'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; + +const BUNDLED_SKILL_DIR = fileURLToPath(new URL('../../skills/xmemo/', import.meta.url)); +const DEFAULT_INSTALL_DIR = 'xmemo-skill'; + +export async function skillCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return writeHelp(io); + if (subcommand !== 'install') throw new UsageError(`Unknown skill command: ${subcommand}`); + const optionArgs = args.slice(1); + if (hasFlag(optionArgs, '--help') || hasFlag(optionArgs, '-h')) return writeHelp(io); + assertInstallOptions(optionArgs); + + const cwd = io.cwd ?? process.cwd(); + const target = path.resolve(cwd, optionValue(optionArgs, '--target') ?? io.env?.XMEMO_SKILL_DIR ?? DEFAULT_INSTALL_DIR); + const source = BUNDLED_SKILL_DIR; + assertSafeTarget(source, target); + const skillVersion = await bundledSkillVersion(source); + const exists = await pathExists(target); + const force = hasFlag(optionArgs, '--force'); + const dryRun = hasFlag(optionArgs, '--dry-run'); + if (exists && !force) throw new UsageError(`Skill destination already exists: ${target}. Use --force to replace it.`); + + const report = { package: PACKAGE_NAME, cliVersion: CLI_VERSION, skillVersion, source, target, dryRun, force, replaced: exists && !dryRun, installed: false, networkUsed: false, tokenSent: false }; + if (!dryRun) { + await install(source, target, exists); + report.installed = true; + } + if (hasFlag(optionArgs, '--json')) writeLine(io.stdout, JSON.stringify(report, null, 2)); + else { + writeLine(io.stdout, `${dryRun ? 'Would install' : 'Installed'} bundled XMemo Skill ${skillVersion} to ${target}`); + writeLine(io.stdout, `Source: ${PACKAGE_NAME} ${CLI_VERSION} (offline; no credential used)`); + } + return 0; +} + +function writeHelp(io) { + writeLine(io.stdout, 'Skill commands:'); + writeLine(io.stdout, ` ${COMMAND_NAME} skill install [--target ] [--dry-run] [--force] [--json]`); + writeLine(io.stdout, 'Installs the bundled XMemo Skill locally. It never uses the network or credentials.'); + return 0; +} + +function assertInstallOptions(args) { + const flags = new Set(['--dry-run', '--force', '--json']); + const seen = new Set(); + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === '--target') { + if (seen.has(token)) throw new UsageError('Duplicate option: --target.'); + optionValue(args, token); + seen.add(token); + index += 1; + continue; + } + if (!flags.has(token)) throw new UsageError(`Unknown skill install option: ${token}`); + if (seen.has(token)) throw new UsageError(`Duplicate option: ${token}.`); + seen.add(token); + } +} + +function assertSafeTarget(source, target) { + if (target === path.parse(target).root) throw new UsageError('Refusing to install a Skill into a filesystem root.'); + const relative = path.relative(source, target); + if (!relative || (!relative.startsWith('..') && !path.isAbsolute(relative))) throw new UsageError('Skill destination cannot be the bundled source or a directory inside it.'); +} + +async function bundledSkillVersion(source) { + for (const required of ['SKILL.md', path.join('scripts', 'xmemo-skill.mjs')]) { + const stat = await fs.stat(path.join(source, required)).catch(() => null); + if (!stat?.isFile()) throw new UsageError(`The npm package is missing bundled Skill file: ${required}`); + } + const runtime = await fs.readFile(path.join(source, 'scripts', 'xmemo-skill.mjs'), 'utf8'); + const version = runtime.match(/const SKILL_VERSION = '([^']+)'/)?.[1]; + if (!version) throw new UsageError('The bundled XMemo Skill version could not be determined.'); + return version; +} + +async function install(source, target, replace) { + const parent = path.dirname(target); + const base = path.basename(target); + const nonce = `${process.pid}-${randomUUID()}`; + const staging = path.join(parent, `.${base}.xmemo-staging-${nonce}`); + const backup = path.join(parent, `.${base}.xmemo-backup-${nonce}`); + let movedExisting = false; + await fs.mkdir(parent, { recursive: true }); + try { + await fs.cp(source, staging, { recursive: true, errorOnExist: true, force: false }); + if (replace) { await fs.rename(target, backup); movedExisting = true; } + await fs.rename(staging, target); + if (movedExisting) { await fs.rm(backup, { recursive: true, force: true }); movedExisting = false; } + } catch (error) { + if (movedExisting && !await pathExists(target)) await fs.rename(backup, target).catch(() => {}); + throw error; + } finally { + await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +async function pathExists(target) { + try { await fs.access(target); return true; } + catch (error) { if (error?.code === 'ENOENT') return false; throw error; } +} diff --git a/src/core/args.js b/src/core/args.js index c411944..5a811f8 100644 --- a/src/core/args.js +++ b/src/core/args.js @@ -66,6 +66,17 @@ export function parseIntegerInRange(value, name, { min, max }) { return parsed; } +export function parseDurationMs(value, name) { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; + const match = typeof value === 'string' ? /^(\d+)(ms|s|m)?$/u.exec(value.trim()) : null; + if (!match) throw new UsageError(`${name} must be a positive duration such as 250ms, 15s, or 2m.`); + const amount = Number(match[1]); + const multiplier = match[2] === 'm' ? 60_000 : match[2] === 's' ? 1_000 : 1; + const milliseconds = amount * multiplier; + if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) throw new UsageError(`${name} is out of range.`); + return milliseconds; +} + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/network/auth.js b/src/network/auth.js index 576afb1..3744424 100644 --- a/src/network/auth.js +++ b/src/network/auth.js @@ -237,4 +237,3 @@ export function validateToken(token) { throw new UsageError('Token is too short to be a production credential.'); } } - diff --git a/src/ui/help.js b/src/ui/help.js index 0709cee..c82ef98 100644 --- a/src/ui/help.js +++ b/src/ui/help.js @@ -15,6 +15,16 @@ export function writeHelp(io) { writeLine(io.stdout, 'Usage'); writeLine(io.stdout, ` ${COMMAND_NAME} [options]`); writeLine(io.stdout, ''); + writeLine(io.stdout, 'Daily Memory'); + writeLine(io.stdout, ` ${COMMAND_NAME} start`); + writeLine(io.stdout, ' Show a local, no-network first-memory walkthrough.'); + writeLine(io.stdout, ` ${COMMAND_NAME} memory add|search|read ... [--json]`); + writeLine(io.stdout, ' Save or find memory through the XMemo service.'); + writeLine(io.stdout, ` ${COMMAND_NAME} context recall [--json]`); + writeLine(io.stdout, ' Build a bounded context pack for the task at hand.'); + writeLine(io.stdout, ` ${COMMAND_NAME} knowledge|dream|cloud-skill ... [--json]`); + writeLine(io.stdout, ' Work with knowledge, cleanup previews, and reviewed Cloud Skills.'); + writeLine(io.stdout, ''); writeLine(io.stdout, 'Setup'); writeLine(io.stdout, ` ${COMMAND_NAME} setup --all [--write] [--profile] [--force]`); writeLine(io.stdout, ' Detect clients and prepare XMemo configs. Dry-run unless --write/--yes is set.'); @@ -26,36 +36,24 @@ export function writeHelp(io) { writeLine(io.stdout, ' Install or update the native Hermes plugin and shared credential.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'Authentication'); - writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url ] [--scopes ] [--allow-plaintext]`); + writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-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 --allow-plaintext [--base-url ]`); + 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, ''); writeLine(io.stdout, 'Operations'); - writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--base-url ] [--json]`); + writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--services [memory,dream,knowledge,cloud-skill]] [--base-url ] [--json]`); writeLine(io.stdout, ' Validate runtime, service reachability, and integration readiness.'); writeLine(io.stdout, ` ${COMMAND_NAME} status [--url ] [--json]`); writeLine(io.stdout, ' Probe hosted service endpoints and readiness.'); writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run]`); writeLine(io.stdout, ' Check or apply the latest npm package update.'); - writeLine(io.stdout, ` ${COMMAND_NAME} memory add|search ... [--json]`); - writeLine(io.stdout, ' Add or search memory through the REST service.'); - writeLine(io.stdout, ` ${COMMAND_NAME} context recall [--json]`); - writeLine(io.stdout, ' Recall a bounded agent context package.'); - writeLine(io.stdout, ` ${COMMAND_NAME} state save|restore ... [--json]`); - writeLine(io.stdout, ' Save or restore one working state slot.'); - writeLine(io.stdout, ` ${COMMAND_NAME} restart snapshot|restore ... [--json]`); - writeLine(io.stdout, ' Create a restart package or explicitly restore its working state.'); - writeLine(io.stdout, ` ${COMMAND_NAME} knowledge add|search|read|update ... [--json]`); - writeLine(io.stdout, ' Add, search, read, or safely update knowledge items.'); - writeLine(io.stdout, ` ${COMMAND_NAME} dream preview|show|apply ... [--json]`); - writeLine(io.stdout, ' Preview, inspect, or confirm one Dream candidate.'); - writeLine(io.stdout, ` ${COMMAND_NAME} cloud-skill add|list|show|update|run ... [--json]`); - writeLine(io.stdout, ' Inspect and execute Cloud Skills; safe writes require the server CAS contract.'); + writeLine(io.stdout, ` ${COMMAND_NAME} skill install [--target ] [--dry-run] [--force] [--json]`); + writeLine(io.stdout, ' Install the bundled XMemo Skill locally without network access.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'MCP And Profiles'); writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`); @@ -81,3 +79,14 @@ export function writeHelp(io) { writeLine(io.stdout, `Run "${COMMAND_NAME} --help" for command-specific options.`); } +export function writeStart(io) { + writeLine(io.stdout, `${PRODUCT_NAME} quick start`); + writeLine(io.stdout, ''); + writeLine(io.stdout, `1. ${COMMAND_NAME} login`); + writeLine(io.stdout, `2. ${COMMAND_NAME} memory add --content "A useful fact" --path projects/example`); + writeLine(io.stdout, `3. ${COMMAND_NAME} memory search "useful fact"`); + writeLine(io.stdout, `4. ${COMMAND_NAME} context recall "continue the project" --max-tokens 2000 --max-items 8`); + writeLine(io.stdout, ''); + writeLine(io.stdout, 'This guide is local only; it does not sign in, scan clients, or create test memory.'); +} + diff --git a/test/cli.test.js b/test/cli.test.js index a5033bf..7a7146e 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -27,6 +27,17 @@ test('help documents privacy defaults', async () => { assert.match(result.stdout, /never written to project configs/i); }); +test('skill install is an offline bundled compatibility command', async () => { + const result = await invoke(['skill', 'install', '--dry-run', '--target', 'xmemo-skill-test', '--json']); + + assert.equal(result.code, 0); + const report = JSON.parse(result.stdout); + assert.equal(report.installed, false); + assert.equal(report.networkUsed, false); + assert.equal(report.tokenSent, false); + assert.match(report.source, /skills[\\/]xmemo/); +}); + test('update dry-run documents npm global install command', async () => { const result = await invoke(['update', '--dry-run', '--json']); @@ -131,7 +142,6 @@ test('login from stdin stores token in user credential file without printing it' assert.equal(credential.encryption, 'none'); assert.equal(credential.plaintextStorageConsent, true); assert.equal(payload.encryption, 'none'); - assert.equal(credential.metadata.baseUrl, 'https://xmemo.dev'); }); test('login from stdin rejects unencrypted storage without explicit consent', async () => { @@ -165,17 +175,6 @@ test('token add from stdin stores token and status sees user credential', async assert.doesNotMatch(status.stdout, new RegExp(token)); }); -test('token set from stdin binds the credential to the selected service origin', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-token-set-origin-')); - const result = await invoke(['token', 'set', '--from-stdin', '--allow-plaintext', '--base-url', 'https://api.example.test'], { - env: { MEMORY_OS_CONFIG_HOME: tempDir }, - stdin: 'mem_os_test_token_1234567890' - }); - assert.equal(result.code, 0); - const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); - assert.equal(credential.metadata.baseUrl, 'https://api.example.test'); -}); - test('auth status reports login state without printing tokens', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-status-')); const token = 'mem_os_test_token_1234567890'; @@ -198,25 +197,6 @@ test('auth status reports login state without printing tokens', async () => { assert.equal(payload.privacy.projectFilesModified, false); }); -test('auth status exposes only allowlisted credential metadata', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-metadata-')); - const credentialPath = path.join(tempDir, 'credentials.json'); - await fs.writeFile(credentialPath, JSON.stringify({ - token: 'mem_os_test_token_1234567890', - metadata: { - baseUrl: 'https://xmemo.dev', - scopes: ['memory:read', 7], - existingPlaintextSource: 'C:\\private\\hermes.env', - nestedSecret: { token: 'must-not-leak' } - } - })); - const status = await invoke(['auth', 'status', '--json'], { env: { MEMORY_OS_CONFIG_HOME: tempDir } }); - assert.equal(status.code, 0); - const payload = JSON.parse(status.stdout); - assert.deepEqual(payload.credentialMetadata, { baseUrl: 'https://xmemo.dev', scopes: ['memory:read'] }); - assert.doesNotMatch(status.stdout, /hermes\.env|must-not-leak/); -}); - test('auth status shows stored device-login account without token warning noise', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-account-')); const token = 'mem_os_test_token_1234567890'; @@ -266,28 +246,9 @@ test('token status verify uses stored credential without printing it', async () assert.equal(requests.length, 1); assert.equal(requests[0].url, 'https://api.example.test/mcp'); assert.equal(requests[0].init.headers.authorization, `Bearer ${token}`); - assert.equal(requests[0].init.redirect, 'error'); assert.doesNotMatch(status.stdout, new RegExp(token)); }); -test('token status verify refuses to send a stored credential to another origin', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-token-origin-refusal-')); - const token = 'mem_os_test_token_1234567890'; - await invoke(['token', 'add', '--from-stdin', '--allow-plaintext'], { - env: { MEMORY_OS_CONFIG_HOME: tempDir }, - stdin: token - }); - let calls = 0; - const status = await invoke(['token', 'status', '--verify', '--base-url', 'https://attacker.example.test'], { - env: { MEMORY_OS_CONFIG_HOME: tempDir }, - fetch: async () => { calls += 1; return { ok: true, status: 200 }; } - }); - assert.equal(status.code, 2); - assert.equal(calls, 0); - assert.match(status.stderr, /different service origin/); - assert.doesNotMatch(status.stdout + status.stderr, new RegExp(token)); -}); - test('device login stores issued token without printing it', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-login-')); const token = 'mem_os_device_token_1234567890'; @@ -340,25 +301,6 @@ test('device login stores issued token without printing it', async () => { assert.deepEqual(credential.metadata.account, payload.account); }); -test('device login expands service scopes only when explicitly requested', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-scopes-')); - let requested; - const result = await invoke(['login', '--scopes', 'memory:read,knowledge:read,knowledge:write', '--allow-plaintext', '--json'], { - env: { MEMORY_OS_CONFIG_HOME: tempDir }, - fetch: async (url, init) => { - if (url.endsWith('/api/v1/auth/device/start')) { - requested = JSON.parse(init.body).scopes; - return jsonResponse({ device_code: 'device-code', user_code: 'CODE', verification_uri: 'https://xmemo.dev/device', interval: 1, expires_in: 600 }); - } - return jsonResponse({ access_token: 'mem_os_scoped_token_1234567890' }); - } - }); - assert.equal(result.code, 0); - assert.deepEqual(requested, ['memory:read', 'knowledge:read', 'knowledge:write']); - const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); - assert.deepEqual(credential.metadata.scopes, requested); -}); - test('device login waits for the service approval window by default', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-login-window-')); const token = 'mem_os_device_token_1234567890'; @@ -1058,7 +1000,6 @@ test('setup hermes backfills shared credential from existing Hermes env', async const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); assert.equal(credential.token, token); assert.equal(credential.metadata.source, 'hermes-env-sync'); - assert.equal(credential.metadata.baseUrl, 'https://api.example.test'); }); test('setup discovers hosted service without sending token values', async () => { diff --git a/test/service-client.test.js b/test/service-client.test.js index 5712cfc..f35dfa6 100644 --- a/test/service-client.test.js +++ b/test/service-client.test.js @@ -59,6 +59,31 @@ test('CLI-01 retries bounded read-only POST requests but never side effects', as assert.equal(calls, 2); }); +test('UX-04 honors Retry-After and never starts a retry beyond the operation deadline', async () => { + let calls = 0; + const client = createServiceClient({ + baseUrl: 'https://api.example.test', token: 'synthetic-token-value', + io: ioWith(async () => { + calls += 1; + return calls === 1 + ? new Response(JSON.stringify({ detail: 'slow down' }), { status: 429, headers: { 'retry-after': '1' } }) + : new Response(JSON.stringify({ results: [] }), { status: 200 }); + }) + }); + const started = Date.now(); + const response = await client.request({ method: 'GET', path: '/api/v1/recall', retry: 'bounded', deadlineMs: 2000 }); + assert.deepEqual(response.data, { results: [] }); + assert.equal(calls, 2); + assert.ok(Date.now() - started >= 900); + + calls = 0; + await assert.rejects( + () => client.request({ method: 'GET', path: '/api/v1/recall', retry: 'bounded', deadlineMs: 20 }), + (error) => error.code === 'REQUEST_DEADLINE_EXCEEDED' + ); + assert.equal(calls, 1); +}); + test('CLI-02 rejects credential-file origin mismatch and provides migration boundary', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-origin-')); await fs.writeFile(path.join(tempDir, 'credentials.json'), JSON.stringify({ diff --git a/test/service-command.test.js b/test/service-command.test.js index 6899d32..5a3cace 100644 --- a/test/service-command.test.js +++ b/test/service-command.test.js @@ -441,7 +441,7 @@ test('CLI-03 basic REST calls cover memory, context, state, and restart routes', ['state', 'save', '--current-task', 'testing', '--json'], ['state', 'restore', '--json'], ['restart', 'snapshot', '--json'], - ['restart', 'restore', '--snapshot-id', 'snapshot-1', '--json'] + ['restart', 'restore', '--snapshot-id', 'snapshot-1', '--preview', '--json'] ]; for (const args of commands) { io.stdout.value = ''; @@ -510,3 +510,80 @@ test('CLI-08 Cloud Skill run rejects a view from another origin before execution assert.equal(calls, 0); assert.match(envelope.error.message, /origin/); }); + +test('UX-01 memory search help and invalid input never create a service request', async () => { + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('[]'); }, { XMEMO_KEY: '' }); + assert.equal(await run(['--json', 'memory', 'search', '-h'], io), 0); + assert.equal(JSON.parse(io.stdout.value).command, 'memory.search'); + assert.equal(calls, 0); + + io.stdout.value = ''; + assert.equal(await run(['memory', 'search', 'first', 'second', '--json'], io), 2); + assert.equal(JSON.parse(io.stdout.value).error.code, 'INPUT_ERROR'); + assert.equal(calls, 0); + + io.stdout.value = ''; + assert.equal(await run(['memory', 'search', 'query', '--limit=oops', '--json'], io), 2); + assert.equal(JSON.parse(io.stdout.value).error.code, 'INPUT_ERROR'); + assert.equal(calls, 0); +}); + +test('UX-02 memory add requires a confirmed resource ID after a write', async () => { + const io = makeIo(async () => new Response('', { status: 201 })); + const code = await run(['memory', 'add', '--content', 'synthetic', '--path', 'tests/ux', '--json'], io); + assert.equal(code, 11); + const envelope = JSON.parse(io.stdout.value); + assert.equal(envelope.error.code, 'WRITE_RECEIPT_MISSING'); + assert.equal(envelope.error.outcome, 'unknown'); +}); + +test('UX-03 restart restore needs an explicit safe preview or confirmed apply intent', async () => { + const calls = []; + const io = makeIo(async (_url, init) => { + calls.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ snapshot_id: 'snapshot-1' }), { status: 200 }); + }); + assert.equal(await run(['restart', 'restore', '--snapshot-id', 'snapshot-1', '--json'], io), 2); + assert.equal(calls.length, 0); + io.stdout.value = ''; + assert.equal(await run(['restart', 'restore', '--snapshot-id', 'snapshot-1', '--preview', '--json'], io), 0); + assert.deepEqual(calls[0], { snapshot_id: 'snapshot-1', restore_state: false, record_restore_event: false }); + io.stdout.value = ''; + assert.equal(await run(['restart', 'restore', '--snapshot-id', 'snapshot-1', '--apply', '--yes', '--json'], io), 0); + assert.deepEqual(calls[1], { snapshot_id: 'snapshot-1', restore_state: true, record_restore_event: true }); +}); + +test('UX-05 accepts global duration syntax and forwards context budgets', async () => { + let request; + const io = makeIo(async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ context_text: 'synthetic', items: [] }), { status: 200 }); + }); + const code = await run(['context', 'recall', 'continue', '--max-tokens', '2000', '--max-items=8', '--timeout', '15s', '--deadline', '30s', '--json'], io); + assert.equal(code, 0); + assert.equal(JSON.parse(request.init.body).max_tokens, 2000); + assert.equal(JSON.parse(request.init.body).max_items, 8); +}); + +test('UX-06 memory search has a compact human result instead of a raw JSON dump', async () => { + const io = makeIo(async () => new Response(JSON.stringify([{ memory_id: 'memory-1', path: 'projects/demo', content: 'Release checklist for the demo project.' }]), { status: 200 })); + assert.equal(await run(['memory', 'search', 'checklist'], io), 0); + assert.match(io.stdout.value, /Found 1 matching memory/); + assert.match(io.stdout.value, /projects\/demo.*memory-1/); + assert.doesNotMatch(io.stdout.value, /^\[/m); +}); + +test('UX-07 read commands write a minimal receipt without overwriting an existing file', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-receipt-')); + const receiptPath = path.join(tempDir, 'knowledge-view.json'); + const io = makeIo(async (url) => new Response(JSON.stringify(new URL(url).pathname.includes('/revisions/') + ? { canonical_content: 'private body must not be saved in receipt' } + : { current_revision_id: 'revision-1', version: 2, status: 'draft' }), { status: 200 })); + assert.equal(await run(['knowledge', 'read', 'item-1', '--receipt-out', receiptPath, '--json'], io), 0); + const receipt = JSON.parse(await fs.readFile(receiptPath, 'utf8')); + assert.equal(receipt.resource, 'knowledge-item:item-1'); + assert.equal(receipt.displayedRevision, 'revision-1'); + assert.equal(receipt.canonical_content, undefined); + assert.equal(await run(['knowledge', 'read', 'item-1', '--receipt-out', receiptPath, '--json'], io), 2); +}); diff --git a/test/service-http.test.js b/test/service-http.test.js index d443051..b82fea0 100644 --- a/test/service-http.test.js +++ b/test/service-http.test.js @@ -77,6 +77,7 @@ test('CLI-09 actual HTTP + child CLI exercise all 19 frozen commands and pinned const p = r.url.pathname, b = r.body; if (p === '/api/v1/remember') { assert.equal(b.content, '中文 synthetic'); return respond(res, { memory_id: 'm1' }, 201); } if (p === '/api/v1/recall') return respond(res, [{ memory_id: 'm1', content: '中文 synthetic' }]); + if (p === '/api/v1/memories/m1/explain') return respond(res, { memory: { memory_id: 'm1', content: '中文 synthetic', version: 1 } }); if (p === '/api/v1/recall/context') { assert.equal(b.max_items, 3); assert.equal(b.memory_limit, undefined); return respond(res, { items: [{ memory_id: 'm1' }] }); } if (p === '/api/v1/update_state') return respond(res, { state_key: b.state_key, content: b.content }); if (p === '/api/v1/skill/operations') { assert.equal(b.operation, 'state-restore'); return respond(res, { state_key: 'active_task' }); } @@ -112,11 +113,12 @@ test('CLI-09 actual HTTP + child CLI exercise all 19 frozen commands and pinned } await call(['memory', 'add'], { content: '中文 synthetic', path: 'fixture/test' }); await call(['memory', 'search', '中文']); + await call(['memory', 'read', 'm1']); await call(['context', 'recall'], { query: 'synthetic', max_items: 3 }); await call(['state', 'save'], { state_key: 'active_task', content: 'synthetic' }); await call(['state', 'restore']); await call(['restart', 'snapshot']); - await call(['restart', 'restore'], { snapshot_id: 'snap1', restore_state: false, record_restore_event: false }); + await call(['restart', 'restore', '--preview'], { snapshot_id: 'snap1' }); await call(['knowledge', 'add', '--base', 'b1', '--text', 'synthetic']); assert.equal((await call(['knowledge', 'search', 'synthetic'])).meta.nextCursor, 'cursor-2'); const knowledgeView = path.join(directory, 'knowledge view 中文.json'); @@ -153,7 +155,8 @@ test('CLI-09 actual HTTP + child CLI exercise all 19 frozen commands and pinned assert.equal(schema.command, spec.command); assert.ok(schema.inputSchema.examples.length); const failure = await child(process.execPath, [binary, ...spec.command.split('.'), '--json'], { cwd: directory, env: { ...env, XMEMO_KEY: '' } }); - assert.equal(failure.code, 3, failure.stdout); + const commandsWithNoRequiredLocalInput = new Set(['state.restore', 'restart.snapshot', 'dream.preview', 'cloud-skill.list']); + assert.equal(failure.code, commandsWithNoRequiredLocalInput.has(spec.command) ? 3 : 2, failure.stdout); assert.equal(JSON.parse(failure.stdout).ok, false); } assert.equal(api.requests.length, count, 'help and missing credentials must not send HTTP requests'); @@ -177,7 +180,20 @@ test('CLI-09 real HTTP covers delayed body, unknown writes, redaction and missin test('CLI-09 actual npm archive runs outside repo with CLI and independent Skill separated', async (t) => { const directory = await temp(t); const env = environment(directory, 'http://127.0.0.1:1'); - const npm = process.env.npm_execpath ?? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'); + const npmCandidates = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + path.resolve(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + ].filter(Boolean); + let npm; + for (const candidate of npmCandidates) { + try { + await fs.access(candidate); + npm = candidate; + break; + } catch {} + } + assert.ok(npm, `Could not locate npm-cli.js from: ${npmCandidates.join(', ')}`); const packed = await child(process.execPath, [npm, 'pack', '--json', '--ignore-scripts', '--offline', '--pack-destination', directory, '--cache', path.join(directory, 'npm-cache')], { cwd: root, env }); assert.equal(packed.code, 0, packed.stderr); const info = JSON.parse(packed.stdout)[0]; diff --git a/test/service-safety.test.js b/test/service-safety.test.js index c5d886a..fe5a8db 100644 --- a/test/service-safety.test.js +++ b/test/service-safety.test.js @@ -76,8 +76,8 @@ test('CLI-02 explicit domain doctor only reads and never claims write readiness' const methods = []; const streams = io(async (url, init) => { methods.push(init.method); return new Response(JSON.stringify(url.endsWith('/settings') ? { enabled: true, mode: 'preview_only', entitlement: { can_apply: false } } : [])); }); assert.equal(await run(['doctor', '--services', '--json'], streams), 0); - assert.deepEqual(methods, ['GET', 'GET', 'GET']); - assert.equal(JSON.parse(streams.stdout.value).data.writeReadiness, 'not-tested'); + assert.deepEqual(methods, ['GET', 'GET', 'GET', 'GET']); + assert.equal(JSON.parse(streams.stdout.value).data.writeReadiness, 'unknown (not tested)'); const denied = io(async () => new Response('{"detail":"scope missing"}', { status: 403 })); assert.equal(await run(['doctor', '--services', '--json'], denied), 4); assert.equal(JSON.parse(denied.stdout.value).ok, false);