diff --git a/AGENTS.md b/AGENTS.md index 62e0e8a..a8c2d33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ pnpm clean # rm -rf dist bin | `login --key ` | Persist API Key | | `logout [--purge]` | Clear credentials / remove config dir | | `tools` | List MCP tools + schemas | -| `call [--kv ...]` | Invoke a tool (`key=value` scalars, `key:=value` JSON) | +| `call [--args ]` | Invoke a tool (arguments as a JSON object) | | `config [--set-url ...] [--set-auth-header ...]` | View / modify configuration | Global options: `--json`, `--url `, `--auth-header
`, `--version` @@ -102,10 +102,11 @@ Env vars > Config file > Defaults `--url`, `--auth-header`, `--json` are per-invocation overrides, don't write to file. -### --kv Syntax (httpie-style) +### --args Syntax -- `key=value` — scalar, auto-infer string/number/boolean/null -- `key:=value` — JSON, `JSON.parse` failure is an explicit error (no silent fallback) +- `--args ''` — pass all arguments as a single JSON object +- No `--args` needed for parameterless tools +- Invalid JSON or non-object values fail explicitly with a formatted error ### Dual-Channel Distribution diff --git a/README.md b/README.md index f0d9504..92705c1 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ wf login --key wf tools # 3. Call a tool -wf call --kv page=1 pageSize=10 +wf call --args '{"page":1,"pageSize":10}' --json ``` ## Commands @@ -89,15 +89,17 @@ wf call --kv page=1 pageSize=10 ### wf call arguments ```bash -# Call a tool with scalar arguments (auto-inferred: string, number, boolean, null) -wf call --kv key1=value1 key2=value2 key3=null +# Pass all arguments as a JSON object +wf call --args '{"page":1,"pageSize":10,"filter":{"tag":"tool"}}' --json -# JSON value via key:=value (httpie-style, for tools that accept JSON fields) -wf call --kv metadata:='{"timeout":5000}' tags:='["a","b"]' +# Parameterless tools — no --args needed +wf call --json -# key:=value parse error fails explicitly (no silent fallback to string) -wf call --kv bad:='{not json}' -# Error: --kv key:=value expects valid JSON, got: "{not json}" +# Invalid JSON or non-object values fail explicitly +wf call --args 'not json' --json +# Error: --args expects valid JSON, got: "not json" +wf call --args '[1,2,3]' --json +# Error: --args expects a JSON object, got: "[1,2,3]" ``` ## Configuration diff --git a/README.zh-CN.md b/README.zh-CN.md index e30accb..f0d1e95 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,7 +64,7 @@ wf login --key wf tools # 3. 调用工具 -wf call --kv page=1 pageSize=10 +wf call --args '{"page":1,"pageSize":10}' --json ``` ## 命令 @@ -89,15 +89,17 @@ wf call --kv page=1 pageSize=10 ### wf call 传参 ```bash -# 标量 key=value(自动推断:string、number、boolean、null) -wf call --kv key1=value1 key2=value2 key3=null +# 所有参数作为 JSON 对象传入 +wf call --args '{"page":1,"pageSize":10,"filter":{"tag":"tool"}}' --json -# JSON 值用 key:=value 语法(httpie 风格,工具接受 JSON 字段时使用) -wf call --kv metadata:='{"timeout":5000}' tags:='["a","b"]' +# 无参数工具——不需要 --args +wf call --json -# key:=value 解析失败会直接报错,不会静默降级为字符串 -wf call --kv bad:='{not json}' -# Error: --kv key:=value expects valid JSON, got: "{not json}" +# 无效 JSON 或非对象类型会直接报错 +wf call --args 'not json' --json +# Error: --args expects valid JSON, got: "not json" +wf call --args '[1,2,3]' --json +# Error: --args expects a JSON object, got: "[1,2,3]" ``` ## 配置 diff --git a/package.json b/package.json index 4b3efa5..fb97694 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@weavefox/cli", - "version": "0.0.3", + "version": "0.0.4", "description": "WeaveFox CLI - Call server-side open capabilities via MCP protocol, for developers and AI agents", "license": "MIT", "repository": { diff --git a/skills/weavefox/SKILL.md b/skills/weavefox/SKILL.md index c9bf7e8..985058c 100644 --- a/skills/weavefox/SKILL.md +++ b/skills/weavefox/SKILL.md @@ -46,11 +46,11 @@ Returns all server-side tools with their names, descriptions, and parameter sche ## Call a Tool ```bash -# Scalar arguments (auto-inferred: string, number, boolean, null) -wf call --kv key1=value1 key2=value2 --json +# Pass all arguments as a JSON object +wf call --args '{"key":"value","num":42,"nested":{"obj":true}}' --json -# JSON arguments (httpie-style key:=value) -wf call --kv key:='{"nested":"object"}' --json +# Parameterless tools — no --args needed +wf call --json ``` ## Parse Output diff --git a/src/config.ts b/src/config.ts index 428ddc3..e0a9431 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,7 +13,7 @@ import { join } from 'node:path'; const DEFAULT_MCP_URL = 'https://www.weavefox.cn/mcp'; const DEFAULT_AUTH_HEADER = 'Authorization'; -const CONFIG_DIR = join(homedir(), '.weavefox'); +export const CONFIG_DIR = join(homedir(), '.weavefox'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); const CONFIG_FILE_MODE = 0o600; diff --git a/src/index.ts b/src/index.ts index 1d4d07a..6cbeccc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ * wf login --key Persist API Key * wf logout [--purge] Clear credentials (optionally remove dir) * wf tools List MCP tools exposed by the server - * wf call [--kv ...] Invoke a tool (-kv key=value | key:=value) + * wf call [--args ...] Invoke a tool (--args '') * wf config [--set-url ] Show / override MCP server URL * * Global options: @@ -30,6 +30,11 @@ import { WeaveFoxCliError, } from './mcp-client.js'; import { outputToolResult, outputToolList } from './format.js'; +import { startUpdateCheck } from './update-check.js'; + +// Start the update check early so it runs in parallel with the command. +// The promise is cached for 1 hour — most invocations resolve from disk instantly. +const updatePromise = startUpdateCheck(pkg.version); export const cli = cac('wf') .version(pkg.version) @@ -41,7 +46,7 @@ export const cli = cac('wf') cli .command('login', 'Save your WeaveFox API Key locally') .option('--key ', 'Your WeaveFox API Key') - .action((options) => { + .action(async (options) => { if (!options.key) { console.error( pc.red('Error: ') + 'Please provide a key: ' + pc.cyan('wf login --key '), @@ -50,12 +55,13 @@ cli } setConfig({ apiKey: options.key }); console.log(pc.green('✓') + ' API Key saved to ' + pc.dim(getConfigPath())); + await printUpdateNotice(options.json); }); cli .command('logout', 'Clear saved credentials') .option('--purge', 'Remove the entire config directory (use before uninstalling)') - .action((options) => { + .action(async (options) => { if (options.purge) { purgeConfig(); console.log(pc.green('✓') + ' Config directory removed: ' + pc.dim(getConfigPath().replace(/config\.json$/, ''))); @@ -64,6 +70,7 @@ cli clearConfig(); console.log(pc.green('✓') + ' Credentials cleared.'); } + await printUpdateNotice(options.json); }); cli @@ -78,14 +85,32 @@ cli cli .command('call ', 'Call a specific MCP tool by name') - .option('--kv ', 'Pass arguments as key=value (scalars) or key:=value (JSON)', { - type: [String], - }) + .option('--args ', 'Pass all arguments as a JSON object') .action(async (toolName, options) => { - const { json, url, authHeader } = options; - const args = parseToolArgs(options.kv); + const { json, url, authHeader, args: jsonArgs } = options; await withClient(json, url, authHeader, async (client) => { + let args: Record = {}; + + if (jsonArgs) { + let parsed: unknown; + try { + parsed = JSON.parse(jsonArgs); + } catch { + throw new WeaveFoxCliError( + 'invalid_args', + `--args expects valid JSON, got: "${jsonArgs}"`, + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new WeaveFoxCliError( + 'invalid_args', + `--args expects a JSON object, got: "${jsonArgs}"`, + ); + } + args = parsed as Record; + } + const result = await callTool(client, toolName, args); outputToolResult(result, json); }); @@ -95,7 +120,7 @@ cli .command('config', 'View or modify CLI configuration') .option('--set-url ', 'Set the MCP Server URL') .option('--set-auth-header
', 'Set the auth header name (default: Authorization)') - .action((options) => { + .action(async (options) => { if (options.setUrl) { setConfig({ mcpUrl: options.setUrl }); console.log(pc.green('✓') + ' MCP Server URL updated.'); @@ -113,6 +138,7 @@ cli console.log(` ${pc.cyan('Auth header')} ${config.authHeader}${config.authHeader === 'Authorization' ? pc.dim(' (Bearer)') : ''}`); console.log(` ${pc.cyan('Logged in')} ${hasApiKey() ? pc.green('Yes') : pc.red('No')}`); console.log(); + await printUpdateNotice(options.json); }); /** @@ -152,71 +178,10 @@ async function withClient( if (client) { await closeMcpClient(client); } + await printUpdateNotice(jsonMode); } } -/** - * Parses --kv pairs into a arguments object. - * - * Two syntaxes (httpie-inspired): - * key=value Scalar; parsed via parseKvValue (auto-infers string/number/ - * boolean/null). - * key:=value Explicit JSON; passes the value through JSON.parse. A parse - * failure is thrown, NOT silently downgraded to a string — - * the caller would otherwise get a bogus string that looks - * like JSON but isn't. - * - * Multiple pairs are allowed on one command line; later pairs override - * earlier ones for the same key. - */ -function parseToolArgs(kvPairs: string[] | undefined): Record { - const args: Record = {}; - if (!kvPairs || kvPairs.length === 0) return args; - - for (const pair of kvPairs) { - const jsonSep = pair.indexOf(':='); - const eqSep = pair.indexOf('='); - - if (jsonSep !== -1 && (eqSep === -1 || jsonSep < eqSep)) { - const key = pair.slice(0, jsonSep).trim(); - const raw = pair.slice(jsonSep + 2).trim(); - try { - args[key] = JSON.parse(raw); - } catch { - throw new WeaveFoxCliError( - 'invalid_kv', - `--kv key:=value expects valid JSON, got: "${raw}"`, - ); - } - } else if (eqSep !== -1) { - const key = pair.slice(0, eqSep).trim(); - const value = pair.slice(eqSep + 1).trim(); - args[key] = parseKvValue(value); - } else { - throw new WeaveFoxCliError( - 'invalid_kv', - `--kv expects key=value or key:=value, got: "${pair}"`, - ); - } - } - - return args; -} - -/** - * Auto-infer scalar primitives for --kv key=value. - * Use --kv key:=value for any JSON-typed value (objects, arrays, edge cases). - */ -function parseKvValue(value: string): unknown { - const lower = value.toLowerCase(); - if (lower === 'true') return true; - if (lower === 'false') return false; - if (lower === 'null') return null; - if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10); - if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value); - return value; -} - /** show first 4 + last 4; mask everything in between. */ function maskApiKey(key: string): string { if (!key) return pc.dim('(not set)'); @@ -224,4 +189,19 @@ function maskApiKey(key: string): string { return key.slice(0, 4) + '****' + key.slice(-4); } +/** + * Prints a one-line update notice if a newer version is available. + * Skipped in `--json` mode to avoid polluting machine-readable output. + * The check was started (in parallel) before cli.parse, so by the time + * the command finishes the result is usually already resolved. + */ +async function printUpdateNotice(jsonMode: boolean): Promise { + if (jsonMode) return; + const latest = await updatePromise; + if (latest) { + console.log(pc.yellow(`\nUpdate available: ${pkg.version} → ${latest}`)); + console.log(pc.dim(`Run: npm i -g @weavefox/cli`)); + } +} + cli.parse(); diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..f359f21 --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,116 @@ +/** + * Lightweight update notifier. + * + * Caches the npm "latest" version in ~/.weavefox/update-check.json for 1 hour + * so the vast majority of invocations hit the cache and never touch the network. + * When the cache is stale, a single fetch to the npm registry is fired with a + * 3-second timeout — failing silently on timeout or offline so the CLI never + * blocks on update checks. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONFIG_DIR } from './config.js'; + +const CACHE_FILE = join(CONFIG_DIR, 'update-check.json'); +const CACHE_TTL = 60 * 60 * 1000; // 1 hour +const NPM_REGISTRY = 'https://registry.npmjs.org'; +const PACKAGE_NAME = '@weavefox/cli'; +const FETCH_TIMEOUT = 3000; + +interface UpdateCache { + latestVersion?: string; + lastCheck?: number; +} + +function readCache(): UpdateCache { + try { + if (!existsSync(CACHE_FILE)) return {}; + return JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCache; + } catch { + return {}; + } +} + +function writeCache(cache: UpdateCache): void { + try { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(cache) + '\n', 'utf-8'); + } catch { + // Best-effort; skip on permission errors. + } +} + +async function fetchLatestVersion(): Promise { + try { + const res = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}/latest`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + if (!res.ok) return null; + const data = (await res.json()) as { version?: string }; + return data.version ?? null; + } catch { + return null; + } +} + +/** + * Simple semver comparison: returns true when `latest > current`. + * Only compares major.minor.patch (ignores pre-release tags). + */ +function isOutdated(current: string, latest: string): boolean { + const c = current.split('.').map(Number); + const l = latest.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const cv = c[i] ?? 0; + const lv = l[i] ?? 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + return false; +} + +let pendingCheck: Promise | null = null; + +/** + * Starts (or joins) a singleton update-check promise. + * + * Cache-first: if the cached version is fresh (< 1 h) the promise resolves + * synchronously from disk. On a stale cache, fetches the registry in the + * background with a short timeout; stale cache is used as fallback on fetch + * failure. The returned promise resolves with the latest version string when + * the installed version is outdated, or `null` otherwise. + */ +export function startUpdateCheck(currentVersion: string): Promise { + if (pendingCheck) return pendingCheck; + + pendingCheck = (async () => { + const cache = readCache(); + + if ( + cache.latestVersion && + cache.lastCheck && + Date.now() - cache.lastCheck < CACHE_TTL + ) { + return isOutdated(currentVersion, cache.latestVersion) + ? cache.latestVersion + : null; + } + + const latest = await fetchLatestVersion(); + if (latest) { + writeCache({ latestVersion: latest, lastCheck: Date.now() }); + return isOutdated(currentVersion, latest) ? latest : null; + } + + if (cache.latestVersion) { + return isOutdated(currentVersion, cache.latestVersion) + ? cache.latestVersion + : null; + } + + return null; + })(); + + return pendingCheck; +}