Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions llmdoc/cli/argument-contract.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: tb CLI 参数契约:解析/客户端/服务端三层检查、keyword-only search、credential stdin/repeatable field 语义、三入口对等与确认门
description: tb CLI 参数与返回契约:三层检查、健康状态与退出码、传输失败结果未知、分页完整性、配置保存/生效、credential 输入与三入口对等
kind: guide
relations:
requires:
Expand All @@ -20,6 +20,8 @@ code:
- packages/cli/src/confirm.ts
- packages/cli/src/contentType.ts
- packages/cli/src/http.ts
- packages/cli/src/output.ts
- packages/cli/src/deviceOutput.ts
- packages/cli/src/registry.ts
- packages/cli/src/scope.ts
- packages/cli/src/stdin.ts
Expand Down Expand Up @@ -47,6 +49,7 @@ code:
- packages/cli/src/commands/login.ts
- packages/cli/examples/structured-command-profile.json
- packages/cli/test/device.test.ts
- packages/cli/test/status.test.ts
- packages/cli/test/strictParsing.test.ts
- packages/sdk/src/client/client.ts
- packages/sdk/src/client/management.ts
Expand All @@ -57,7 +60,7 @@ code:
- packages/sdk/src/device/storeUpload.ts
---

# CLI 参数契约检查
# CLI 参数与返回契约检查

CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查三层:解析层、客户端语义层、服务端权限层;任一层缺失都会产生管理旁路或误导性帮助。wire 层面的命令契约(builtin 命令表、错误码、Help 模型)以 [protocol/htbp-contract](../protocol/htbp-contract.mdx) 为准,本文只覆盖 CLI 侧的评审纪律。

Expand All @@ -84,8 +87,8 @@ CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查
realtime 失败后执行第二条 enqueue 命令,fallback 是否入队完全由 gateway 的 dispatch certainty 决定。
- `tb device op ls|get|cancel` 是 operation 管理面:ls 按 deviceId 分页并可 repeatable `--state` 过滤,
get/cancel 同时要求 deviceId 与 operationId。人类输出必须区分
`result_unknown = 已开始/结果未知` 与 `expired + executionMayHaveOccurred = 可能执行过`,避免把 expired
误写成安全未执行;`--json` 保留完整固定 wire。
`result_unknown = 可能已开始/结果未知` 与 `expired + executionMayHaveOccurred = 可能执行过`,避免把
journal barrier 当成副作用已发生的证明,或把 expired 误写成安全未执行;`--json` 保留完整固定 wire。
- `store upload` 从文件 stat 得到 size,流式发送,并按 grant 自动选择 relay/direct;relay PUT 返回
descriptor,direct 才调用 capability-only complete。`--idempotency-key` 是 owner-scoped create 重试
key,不是覆盖路径。文件 size 大于当次 `maxBytes` 时在本地早拒,服务端仍做权威限额。
Expand All @@ -100,6 +103,33 @@ CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查
`--path-prefix` 与 repeatable `--effect` 必须原样下发 SDK wire;CLI 只做非法枚举/组合的早拒,不能
自己重排或过滤服务端结果。partial source 状态写 stderr warning,JSON page 保持机器可读。

## 返回语义

CLI 输出只能声明当前证据能证明的结果。人类文本与 JSON 必须同义,区分请求成功、业务终态和结果未知;
不能用 HTTP 成功、operation identity 或本页为空替代业务判断。任意工具结果与既有控制面 JSON 保持原义,
可读性说明放在人类输出中,不为统一外观给业务数据增加通用包装。

- `tb status` 的 `ok` 表示 HTTP 成功且响应明确报告健康,与退出码成功条件一致;`httpOk` 单独表示
HTTP 成功。缺少合法 boolean 健康字段时 `healthy:null`,文本为 unknown,退出失败;不能宣称不健康
或仅凭 HTTP 200 报健康。原始响应保留在 `body` 供判断。
- CLI 传输超时、断连或协议解析失败只证明没有取得可靠结果,不证明请求未执行。统一错误保留 `kind`,
这类结果标记 `outcome:'unknown'`,不自行补 `retryable:true`;服务端 HTTP 错误仍保留其 retryable
声明。文本呈现错误码与明确的重试标记,但不能把可重试机械改写成无条件“再试一次”。
- `tb call` 的 Mailbox 受理结果与 `tb device op get|cancel` 共用状态解释,以 operation 实际 `state`
为准;HTTP 202 也可能返回幂等命中的既有终态,不能固定写 queued。固定 operation 必须复用权威
schema 验证,不从缺失字段猜默认状态。claimed 后的取消仅表示已请求取消,不能报告执行已停止;
claim attempts 也不等于 handler 执行次数。
- 分页输出的空结果仅限定当前页;有 cursor 时即使 items 为空也必须显示后续页信号。搜索的 partial
与分页是独立维度,不能将不完整搜索说成全局无匹配,也不能丢弃 partial source warning。
- `tb config` 文本区分 desired(已保存)与 responding replica 的 effective;validate 不保存,update
不应用,apply 返回后仍按 state/appliedRevision 判断。未确认 applied revision 时不能把报告的
effective 值当成已生效;`--json` 保持服务端原 shape。
- 错误附带的 feedback 只展示实际返回的条目。空反馈不自动追加 submit 邀请,避免将结果诊断变成无关任务。

评审须覆盖同一响应在文本、JSON、退出码上的含义,尤其是 HTTP 成功但健康未知、结果未知、取消未完成、
空页带 cursor 与配置保存未生效。普通工具业务失败仍属于其自身返回契约,不能由 CLI 猜测任意 payload
的业务状态来重写退出码。

## 类型契约

Commander 的 `.argument()`/`.option()` 链同时是运行时解析、help 与 action callback 类型的真源。命令文件
Expand Down
6 changes: 3 additions & 3 deletions llmdoc/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
"validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"
},
"cli/agent-skill-integration.mdx": {
"validatedRevision": "02fca70d0d4ccca088fc4c44a908243b96eda5aa"
"validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2"
},
"cli/argument-contract.mdx": {
"validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"
"validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2"
},
"dashboard/canvas-architecture.mdx": {
"validatedRevision": "41c39bccb8055f2d3bffa904b5cc2e30c0b43c5b"
Expand Down Expand Up @@ -57,7 +57,7 @@
"validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"
},
"device/durable-mailbox.mdx": {
"validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"
"validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2"
},
"hosts-deploy/managed-configuration.mdx": {
"validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tool-bridge/cli",
"version": "0.31.0",
"version": "0.32.0",
"description": "tb \u2014 Tool Bridge CLI: one gateway for HTBP/MCP/HTTP tools, contexts, and device shells",
"type": "module",
"license": "MIT",
Expand Down
18 changes: 13 additions & 5 deletions packages/cli/src/commands/call.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deviceOperationDetailSchema } from '@tool-bridge/core/protocol'
import { readFileSync } from 'node:fs'
import { Command } from 'commander'
import { collect, parseKeyValueSpecs, parsePositiveInt, resolveTarget, withGlobalOpts } from '../args'
Expand All @@ -8,6 +9,7 @@ import {
type Target,
withClient,
} from '../http'
import { printDeviceOperation } from '../deviceOutput'
import { printJson, printLine } from '../output'
import { printMarkdown } from '../markdown'
import { readStdinRaw } from '../stdin'
Expand Down Expand Up @@ -121,8 +123,6 @@ export async function attachFeedbackHint(
`hint: known pitfalls from other agents — details: tb feedback get ${cleanPath} <id>`,
...items.map(f => ` - ${f.id} (${f.score >= 0 ? '+' : ''}${f.score}) "${f.title}"`),
].join('\n')
} else {
err.hint = `hint: no known pitfalls recorded for this path yet — if you figure this out, help the next agent:\n tb feedback submit ${cleanPath} --title "<short summary>" --detail "<how to avoid>"`
}
} catch {
// hint 拉取失败不影响主错误报告
Expand All @@ -136,7 +136,8 @@ export async function attachFeedbackHint(
* `tb call system/status/get`、`tb call docs/context7/resolve-library-id`。
* arguments 四种给法互斥:第二 positional(裸 JSON)/ `--args` / `--args-file`(`-` = stdin)/
* 可重复 `--arg k=v`(扁平标量,见 parseArgScalar)。
* 默认人类模式:markdown 原样打印;`--json`:输出原始 JSON。TBError → stderr + exit 1。
* 默认人类模式呈现 Markdown/operation;`--json` 保留原结果或显式 delivery 的 SDK 返回。
* 错误经根边界落地:文本到 stderr,JSON 到 stdout,均 exit 1。
*/
export function callCommand() {
return withGlobalOpts(new Command('call'))
Expand Down Expand Up @@ -233,8 +234,15 @@ Examples:
}),
)
if (response.status === 202) {
const operation = JSON.parse(response.text) as { operationId?: string, state?: string }
printLine(`queued ${operation.operationId ?? 'operation'} (${operation.state ?? 'queued'})`)
const operation = deviceOperationDetailSchema.safeParse(response.json)
if (!operation.success) {
const error = new CliError('gateway returned an invalid device operation; the request outcome is unknown', 'internal')
error.kind = 'protocol'
error.outcome = 'unknown'
throw error
}
printLine('delivery: mailbox')
printDeviceOperation(operation.data)
} else {
printMarkdown(response.text)
}
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ function readBinaryFile(file: string): Buffer {
function printEntries(page: Page<ContextEntryMeta>): void {
const items = page.items ?? []
if (items.length === 0) {
printLine('(no entries)')
printLine('(no entries on this page)')
if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`)
return
}
const rows = items.map(m => [
Expand All @@ -78,7 +79,7 @@ function printEntries(page: Page<ContextEntryMeta>): void {
m.updatedAt ?? '',
])
printLine(table(['URI', 'SIZE', 'UPDATED'], rows))
if (page.cursor) printLine(`next cursor: ${page.cursor}`)
if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`)
}

/** `tb ctx ls <ns> [prefix]` —— 浅层列表(ContextProvider.List)。 */
Expand Down
45 changes: 6 additions & 39 deletions packages/cli/src/commands/device.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type {
DeviceOperationDetail,
DeviceOperationState,
DeviceOperationSummary,
} from '@tool-bridge/sdk/client'
import { Command } from 'commander'
import type { Node, Page } from '../types'
import { collect, parsePageOpts, resolveTarget, withGlobalOpts, withPageOpts } from '../args'
import { operationMeaning, printDeviceOperation } from '../deviceOutput'
import { callDirect, CliError, withClient } from '../http'
import { printJson, printLine, table } from '../output'

Expand Down Expand Up @@ -56,12 +56,12 @@ export function deviceLsCommand() {
// 能被看出来。
printLine(
table(
['DEVICE_ID', 'PATH', 'ONLINE', 'LAST_SEEN', 'DESCRIPTION'],
['DEVICE_ID', 'PATH', 'RECORDED_ONLINE', 'LAST_SEEN', 'DESCRIPTION'],
devices.map(n => [
deviceIdFromPath(n.path),
n.path,
n.online ? 'yes' : 'no',
n.lastSeenAt ? new Date(n.lastSeenAt).toLocaleString() : '-',
n.lastSeenAt ?? '-',
n.description ?? '',
]),
),
Expand All @@ -81,58 +81,25 @@ function states(values: readonly string[]): DeviceOperationState[] | undefined {
return unique as DeviceOperationState[]
}

function executionMeaning(operation: DeviceOperationSummary): string {
if (operation.state === 'expired' && operation.executionMayHaveOccurred) {
return 'may-have-run'
}
if (operation.state === 'result_unknown') return 'started/result-unknown'
return '-'
}

function printOperationList(page: { cursor?: string, items: DeviceOperationSummary[] }): void {
if (page.items.length === 0) {
printLine(page.cursor ? '(no visible operations on this page)' : '(no device operations)')
} else {
printLine(table(
['OPERATION_ID', 'STATE', 'TARGET', 'ATTEMPT', 'UPDATED', 'EXECUTION'],
['OPERATION_ID', 'STATE', 'TARGET', 'CLAIM_ATTEMPTS', 'UPDATED', 'MEANING'],
page.items.map(operation => [
operation.operationId,
operation.state,
operation.targetPath,
String(operation.attempt),
operation.updatedAt,
executionMeaning(operation),
operationMeaning(operation),
]),
))
}
if (page.cursor) printLine(`next cursor: ${page.cursor}`)
}

function printOperation(operation: DeviceOperationDetail): void {
printLine(table(
['FIELD', 'VALUE'],
[
['operationId', operation.operationId],
['deviceId', operation.deviceId],
['state', operation.state],
['target', operation.targetPath],
['attempt', String(operation.attempt)],
['createdAt', operation.createdAt],
['expiresAt', operation.expiresAt],
['updatedAt', operation.updatedAt],
['execution', executionMeaning(operation)],
...(operation.cancelRequestedAt === undefined
? []
: [['cancelRequestedAt', operation.cancelRequestedAt]]),
],
))
if (operation.state === 'expired' && operation.executionMayHaveOccurred) {
printLine('warning: this operation was claimed before expiry and may have executed')
}
if (operation.error !== undefined) printLine(`error: ${operation.error.code}: ${operation.error.message}`)
if (operation.result !== undefined) printLine(`result: ${JSON.stringify(operation.result)}`)
}

export function deviceOperationListCommand() {
return withPageOpts(withGlobalOpts(new Command('ls')))
.alias('list')
Expand Down Expand Up @@ -171,7 +138,7 @@ function deviceOperationReadCommand(command: 'get' | 'cancel') {
? await client.deviceOperations.get(deviceId, operationId)
: await client.deviceOperations.cancel(deviceId, operationId))
if (opts.json) printJson(operation)
else printOperation(operation)
else printDeviceOperation(operation)
})
}

Expand Down
60 changes: 54 additions & 6 deletions packages/cli/src/commands/management.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {
type ConfigStatus,
createSetupClient,
parseConfigUpdate,
parseRuntimeConfig,
parseStorageRotate,
parseStorageWrite,
type RuntimeConfig,
} from '@tool-bridge/sdk/client'
import { readFile } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { Command } from 'commander'
import { callDirect, CliError, getFetch, requireTarget } from '../http'
import { resolveTarget, withGlobalOpts } from '../args'
import { printJson, printLine, table } from '../output'
import { readStdinRaw } from '../stdin'
import { printJson } from '../output'

async function readInput(file: string): Promise<unknown> {
if (file === '-' && process.stdin.isTTY) throw new CliError('provide JSON through stdin or --file <path>')
Expand All @@ -33,29 +35,75 @@ function revision(value: string): number {
return Number(value)
}

function printConfigStatus(status: ConfigStatus): void {
const descriptions: Record<ConfigStatus['state'], string> = {
applied: 'saved settings are effective on the responding replica',
pending: 'saved revision is not yet effective on the responding replica',
applying: 'configuration application is in progress',
failed: 'configuration application has an error',
}
printLine(`State: ${status.state} — ${descriptions[status.state]}`)
printLine(`Desired revision (saved): ${status.revision}`)
printLine(`Effective revision (responding replica): ${status.appliedRevision === 0 ? 'none confirmed' : status.appliedRevision}`)
if (status.lastError) printLine(`Last application error: ${status.lastError}`)
if (status.appliedRevision === 0) printLine('Reported effective settings below are not confirmed as applied on this replica.')
const keys = [...new Set([...Object.keys(status.desired), ...Object.keys(status.effective)])] as Array<keyof RuntimeConfig>
printLine(table(
['Setting', 'Desired (saved)', 'Reported effective'],
keys.map(key => [key, JSON.stringify(status.desired[key]) ?? '(not reported)', JSON.stringify(status.effective[key]) ?? '(not reported)']),
))
}

export function configCommand() {
const command = new Command('config').description('Manage instance settings and their applied revision (admin)')
for (const name of ['schema', 'get', 'status'] as const) {
command.addCommand(withGlobalOpts(new Command('schema'))
.description('Read the runtime settings JSON Schema')
.action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/schema'))))
for (const name of ['get', 'status'] as const) {
command.addCommand(withGlobalOpts(new Command(name))
.description(`${name} instance configuration`)
.action(async opts => printJson(await callDirect(resolveTarget(opts), `/system/config/${name}`))))
.action(async (opts) => {
const result = await callDirect<ConfigStatus>(resolveTarget(opts), `/system/config/${name}`)
if (opts.json) printJson(result)
else printConfigStatus(result)
}))
}
command.addCommand(withGlobalOpts(new Command('validate'))
.description('Validate runtime settings without saving')
.option('--file <path>', 'JSON file; - reads stdin', '-')
.action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/validate', parseRuntimeConfig(await readInput(opts.file))))))
.action(async (opts) => {
const settings = await callDirect<RuntimeConfig>(resolveTarget(opts), '/system/config/validate', parseRuntimeConfig(await readInput(opts.file)))
if (opts.json) printJson(settings)
else {
printLine('Configuration is valid; no settings were saved or applied.')
printLine('Validated settings (including defaults):')
printJson(settings)
}
}))
command.addCommand(withGlobalOpts(new Command('update'))
.description('Save desired runtime settings; run apply separately')
.requiredOption('--revision <number>', 'Current configuration revision', revision)
.option('--file <path>', 'Runtime settings JSON file; - reads stdin', '-')
.action(async (opts) => {
const payload = parseConfigUpdate({ expectedRevision: opts.revision, settings: await readInput(opts.file) })
printJson(await callDirect(resolveTarget(opts), '/system/config/update', payload))
const result = await callDirect<ConfigStatus>(resolveTarget(opts), '/system/config/update', payload)
if (opts.json) printJson(result)
else {
printLine('Configuration update saved; this command does not apply settings.')
printConfigStatus(result)
}
}))
command.addCommand(withGlobalOpts(new Command('apply'))
.description('Apply the saved revision and report effective settings')
.requiredOption('--revision <number>', 'Saved configuration revision', revision)
.action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/apply', { expectedRevision: opts.revision }))))
.action(async (opts) => {
const result = await callDirect<ConfigStatus>(resolveTarget(opts), '/system/config/apply', { expectedRevision: opts.revision })
if (opts.json) printJson(result)
else {
printLine(`Apply result for requested revision ${opts.revision}:`)
printConfigStatus(result)
}
}))
return command
}

Expand Down
Loading
Loading