Spark CLI 与桌面联动
Spark CLI(包名 @spark/agent,命令 spark)完整参考:18 个子命令与参数解析的真实边界(所有 flag 共用一张解析表,与本命令无关的 flag 会被静默忽略)、三种输出格式的区别(--json 给事件流、--output-format json 才给一行结果对象)、完整退出码表(取消是 130、用法错误是 2、update 有专属 0/1/2/3/4)、会话账本的组织方式与 -c/-r 的差别、TUI 的七个准入条件与 9 条公开斜杠命令、图片附件的格式与限额。桌面联动部分讲清本地回环桥的发现与选主、bridge 描述文件协议、三个真实端点、哪些渠道会被桥接(只有 Anthropic 与显式标记 responses 的 OpenAI 渠道),以及桥的安全模型与凭据链路。最后是 ~/.spark 与项目级 .spark 的数据落点、配置分层合并规则、各子命令的真实行为与 28 行排查对照。
Spark CLI 是桌面应用之外的第二入口:一个终端程序,名叫spark,包名 @spark/agent。 它自带交互式 TUI、一次性任务模式与一组维护命令,并且与桌面应用共用~/.spark数据根—— 你在桌面里配好的渠道,CLI 能直接用,靠的是桌面进程里一个只监听回环地址的本地桥。
这一页讲清三件事:CLI 自己的命令面(参数、输出格式、退出码、会话、TUI), 桌面联动那条链路的真实协议与边界,以及~/.spark与项目级.spark 里到底存了什么。 凡是容易被想当然写错的地方都单独标出来了。
1. 这是什么
1.1 包与入口
| 项 | 实际值 |
|---|---|
| 包名 | @spark/agent |
| 安装后的命令 | spark(bin 映射到./dist/cli/main.js) |
| Node 要求 | engines.node >= 22.14.0 |
| 源码根 | 仓库里的spark-engine/ |
| 自带依赖 | Ink + React(TUI)、zod、ajv、smol-toml、@modelcontextprotocol/sdk |
它不是桌面应用的包装脚本,而是一套独立的引擎:SDK、事件账本、工具运行时、MCP 客户端、 TUI 都在这个包里,桌面应用的 Spark 执行器复用的是同一套内核。
1.2 与桌面端的关系
两边通过三个共享面协作,理解这三个面基本就理解了「桌面联动」:
| 共享面 | 路径 / 形式 | 作用 |
|---|---|---|
| 数据根 | ~/.spark(SPARK_HOME可覆盖) | 配置、会话账本、账号凭据、桥描述文件、更新状态 |
| 本地桥 | ~/.spark/hosts/sparkwork/bridge-<id>.json | 让 CLI 用桌面已配置的渠道与凭据发请求 |
| 宿主技能目录 | ~/.claude/skills、~/.codex/skills、~/.agents/skills、~/.spark/skills | 两边都扫描这四处,CLI 侧同一份技能可见 |
注意一个反直觉的例外:长期记忆不在~/.spark 下,而在~/.spark-agent(见第 12 章)。所以「两边共用数据根」这句话对配置和会话成立,对记忆不成立。
1.3 三套容易混淆的「CLI」
仓库里还有两个名字里带 CLI 的东西,它们是不同的对象,不要混写:
| 名称 | 是什么 | 怎么区分 |
|---|---|---|
sparkCLI | 本文档的主角,独立安装的终端程序 | 命令是spark,包名@spark/agent |
| 「本机 AI 工具」渠道 | 桌面里的两个内置 Provider(local-cli/local-codex-cli),把模型调用转给你机器上已登录的 Claude Code / Codex CLI | 没有 Key、没有 Endpoint,属于 Provider 而非程序 |
spark serve | 只是存根,不启动任何服务 | 固定打印一句「M1 kernel slice / M3 才有」,退出码 2 |
spark serve # spark serve is not part of the M1 kernel slice; the versioned App Server lands in M3. # 退出码 2
另外spark-engine这个目录名指的是引擎源码包,不是命令名——命令始终是 spark。
2. 安装、卸载与 PATH
2.1 三种安装路径的真实差别
这三条路经常被当成同一件事,其实职责完全不同:
| 方式 | 下载 | 做什么 |
|---|---|---|
install.sh/install.ps1 | 会 | 下载 tarball → 校验 sha256 →npm install -g;只有 npm 全局 bin 不在 PATH 时才回落去链~/.spark/bin |
npm install -g @spark/agent | 会 | 标准 npm 安装,shim 落在 npm 全局 bin |
spark install | 不会 | 只把当前正在运行的这份代码的入口链到--bin目录 |
最后一条最容易被误解。它从运行中的代码向上找包根(最多 8 层),入口常量是 dist/cli/main.js; 在源码树里直接跑会因为没构建而报 is missing dist/cli/main.js; build it with npm run build。
2.2spark install做了什么
- launcher 形态分平台:POSIX 建符号链接指向
<包>/dist/cli/main.js;Windows 写一个文本 shim,内容固定为三行(@echo off、rem @spark/agent launcher、node "<entry>" %*)。 - 写入是原子的:先写
.spark-launcher-<pid>.tmp,Windows 上把旧文件改名成.bak再换新,失败回滚。 - 不校验 PATH,只警告:成功后提示
Verify with: spark doctor,PATH 不在只给一条 NOTE。 - 报错一律退出码 2,不是「安装失败=3」。
一个容易踩的细节:
--bin的默认值是join(defaultSparkHome(), 'bin'), 而defaultSparkHome()读SPARK_HOME。帮助文本只写default ~/.spark/bin, 没提环境变量——所以你改过SPARK_HOME时,默认 launcher 目录会跟着变。
2.3 卸载的三种粒度
| 命令 | 删除范围 | 保留 |
|---|---|---|
spark uninstall | 只删 launcher 文件 | npm 包、~/.spark全部保留 |
spark uninstall --package | launcher + npm 全局 shim + 包目录本身 | ~/.spark的配置 / 会话 / 缓存永不删除 |
npm uninstall -g @spark/agent | npm 侧 | PATH 上的 launcher 会变成悬空链接 |
--package的所有权校验是保守的:只会删「能证明属于 spark」的文件——POSIX 要求符号链接且目标落在包目录内, Windows 要读文件内容匹配包目录或标记。无法证明的条目会打印exists but does not provably belong to spark; left untouched并原样留着。 成功恒定返回 0,包括什么都没删到的情况。
但它卸不掉npm link装的版本:更新路径在发现目标目录本身是符号链接时会明确拒绝, 需要先 npm unlink -g @spark/agent。
2.4 PATH 与「外来 launcher」
「外来 launcher」指的是 PATH 上那个spark 不是我们装的:判定要求它要么不是符号链接, 要么指向的包名不是@spark/agent。此时 spark install会拒绝并提示加--force;spark uninstall 同样拒绝,让你手工处理。
PATH 探测按 PATH 顺序逐目录找,悬空符号链接也算候选(会明确提示 broken link)。 多份 launcher 同时存在时,靠前者会遮蔽靠后者,doctor 会给出WARNING: <path> shadows a spark launcher later on PATH.
3. 命令面与参数解析
3.1 子命令清单
子命令是一张硬编码白名单,共 18 个:
serve models doctor update upgrade install uninstall init login logout whoami config mcp memory plan skills todo sessions
分发不是框架,而是main()里一串手写分支。这带来一个必须知道的行为:positionals[0]一旦命中这些字面量就直接走子命令, 所以spark update永远不会被当成任务名;反过来spark hello update 会被拼成任务提示"hello update"。
3.2 「全局 flag」是文档概念,不是解析概念
所有 flag 都定义在同一张parseArgs.options 里,任何子命令在语法上都能接受任何 flag。strict: true只拒绝未定义的 flag(比如--nope 会报错并退出 2), 不拒绝「定义了但与本命令无关」的 flag——后者被静默忽略。
# 这两个都不会报错: spark -p "分析日志" --check # --check 被静默丢弃,任务照常跑 spark todo list --scope user # --scope 只映射给 memory,这里无效
同一个 flag 复用到多个子命令的地方,是最容易记错的一类:
| flag | 实际接收者 |
|---|---|
--limit | memory(1–100)、todo(1–100)、skills(1–200)——三处区间不同 |
--body | memory save与plan set|append |
--global/--project | config与mcp(帮助文本只写了 config) |
--scope | 只有memory |
--session | 只有plan |
3.3 任务提示词的三种来源
优先级是-p/--prompt> 位置参数 > 管道 stdin:
spark "重构这个文件" # 位置参数按空格拼成完整提示词 spark -p "重构这个文件" # 与上一行等价 cat notes.md | spark # stdin 非 TTY 时才读管道
-p一旦给出,位置参数被静默忽略。没有提示词、又不是交互 TTY 时报No task was provided. Pass a prompt, pipe stdin, or run spark in an interactive TTY. 并退出 2。
3.4--resume的空值哨兵
node:util的parseArgs拒绝无值 flag,所以裸--resume 会被预处理: 在它后面插入一个空串''当哨兵,下游把哨兵解释成「打开 TUI 会话选择器」。
spark --resume # TUI 里弹选择器(需要 TTY) spark -r session_... # 直接恢复指定会话 spark -c # 继续当前目录最近一次会话
副作用值得一提:预处理是「后面跟任何以-开头的 token 就插空串」, 所以 spark -r -p "x"会被改写成-r '' -p x——-r吃掉了哨兵,-p正常生效。
4. 输出格式
4.1 三态语义
这里有两个长得像但语义不同的开关,混用是这一块最常见的错误:
| 写法 | 输出什么 | 含流式 delta? |
|---|---|---|
| (默认) | 人类可读文本 | 是,直接写 stdout |
--json | 事实事件 NDJSON(无 delta) | 否 |
--output-format stream-json | 事实事件 +{"type":"delta",…}记录 | 是 |
--output-format json | 恰好一行最终结果对象 | 否 |
所以「--json就是机器可读的最终结果」是错的——它给的是事件流。 想要一行结果对象,只能用--output-format json。
--json与--output-format text/json冲突报错, 但与 --output-format stream-json是合法组合,且此时行为等同纯 stream-json。
4.2 text 模式的 stdout / stderr 分工
这是脚本化调用时最实用的一条:
- stdout:只有模型的正文输出。
- stderr:工具调用、权限请求、失败、取消等全部「过程事实」。
如果模型一个字面 delta 都没给,会在assistant.completed之后补打整段文本。 这意味着spark -p "..." > answer.txt拿到的就是干净的答案。
4.3--output-format json的字段
{"type":"result","sessionId":"session_…","turnId":"turn_…", "status":"completed","message":"…","terminal":{"type":"turn.completed",…}}
status取值与终态事件一致,message在没有文本时是null。 该模式下 delta 被显式关闭,内容只来自assistant.completed。
4.4 一个容易踩的副作用
--output-format会顺手打开所有子命令的 JSON 输出:
spark models --output-format json # 输出 JSON spark models --output-format text # 输出表格
不需要 JSON 的子命令上带了这个 flag,输出形态会被意外改掉。
5. 退出码
退出码是脚本化调用最依赖的东西,也是文档里最容易写错的部分。分三组记。
5.1 一次性任务
| 码 | 条件 |
|---|---|
0 | 终态事件是turn.completed |
1 | 其它终态(含turn.failed) |
130 | 终态是turn.cancelled(例如你按了 Ctrl+C) |
取消是 130 而不是 1,与 Unix 的 SIGINT 约定一致; 另有EPIPE 被单独吞成 0(写管道时下游提前关闭不算失败)。
5.2 用法错误统一是 2
参数解析失败会打印错误 + 整篇帮助并退出 2。触发条件包括:未知 flag、--output-format非法、--json冲突、--permission-mode 非法、--effort非法、--continue与--resume同给、--global与--project同给、--dangerously-skip-permissions与显式非 bypass 模式冲突。
除此之外,下面这些也走 2:配置加载失败、模型无法解析、图片读取失败、裸--resume 但没有 TTY。
5.3 子命令自带的分支
「用法错误是 2、找不到/未登录是 1」是大致规律,但有真实的不一致:
| 命令 | 退出 1 的情况 | 退出 2 的情况 |
|---|---|---|
config get | 键未设置 | 用法错误、未知子命令 |
config unset | — | 键不存在也是 2(与get相反) |
mcp remove | 名字不存在 | 用法错误 |
mcp status | 任一服务器启动失败 | 用法错误、变量缺失 |
skills read | 找不到技能,或--limit非法 | 未知子命令、参数个数不对 |
todo remove | id 不存在 | id 格式非法、用法错误 |
plan | PlanStoreError(非法 sessionId、超限) | 无会话、缺--body |
whoami | 未登录、会话过期 | 未知子命令 |
plan show/plan clear在没有计划时仍返回 0, 只打印No plan exists for session …;logout在未登录时也返回 0。
6. 会话与账本
6.1 会话存在哪里
会话不是散落的文件,而是一份按项目目录归档的事件账本:
~/.spark/projects/<编码后的项目目录>/<sessionId>/events.jsonl
目录名由两段拼成,这个规则值得记住,因为你去磁盘找文件时只能靠它:
- 把绝对路径里所有非
[a-zA-Z0-9._-]的字符换成-,去掉首尾-,取最后 96 个字符(空了就用root); - 再接一个
-和绝对路径 sha256 的前 12 位十六进制。
事件按行追加,每行一个 JSON。同目录下可并存多个会话,互不干扰。
6.2 三个入口的差别
| 命令 | 选中的会话 | 找不到时 |
|---|---|---|
spark -c | 当前目录最近更新的主会话(排除 subagent) | 不报错:打印提示并开新会话,退出 0 |
spark -r <id> | 按完整 id 精确匹配(包含subagent 会话) | 报Session not found: …+ 最近 5 条提示,退出 2 |
spark --resume | TUI 里手动选 | 没有 TTY 时报错退出 2 |
最容易踩的坑:
spark sessions列表里显示的是8 位短 id(去掉session_前缀后取前 8 个字符), 而--resume只接受完整 id。直接把列表里的短 id 粘进去一定失败。 完整 id 要用spark sessions --json拿,或在 TUI 里用/status看。
6.3 恢复时的权限语义
会话会把当时的权限模式记进账本。恢复时:
- 显式给了
--permission-mode→ 覆盖账本里的模式; - 没给 →沿用会话原有的模式,而不是当前配置里的默认值。
另外两条硬约束:恢复 subagent 会话会被明确拒绝; 恢复一个带未终止 turn 的会话时,会先补写一条turn.failed,把断掉的轮次收尾。
7. TUI 与 plain REPL
7.1 什么情况下会进 TUI
进入 TUI 需要同时满足七个条件,并且没有提示词:
--plain 未给 且 --json 未给 且 --output-format 是 text 且 stdin 与 stdout 都是 TTY 且 CI != 'true' 且 TERM != 'dumb'
注意--output-format json/stream-json会让内部json 标志为真, 从而永久关闭 TUI——这是「加了输出格式就不进界面了」的原因。
还有一类输入永远不会进 TUI:-i/--image只对一次性任务有效, 带子命令或没有提示词时都会先被拦掉(见第 8 章)。
7.2 斜杠命令
公开的是这 9 条(/help显示的就是它们):
| 命令 | 作用 |
|---|---|
/help | 显示命令与快捷键 |
/status | 会话 id、排队 turn、事件数 |
/model | 切换模型或配置本地渠道 |
/perm | 选择权限策略 |
/effort | 选择推理强度 |
/update | 检查并安装新版本(--check仅检查) |
/sessions | 选择并切换到历史会话 |
/clear | 开启全新会话 |
/exit | 退出(Ctrl+C 两次同效) |
还有一条未公开的/quit:它和/exit走同一个分支, 但不在命令表里——不出现在 Tab 补全和 /help中,也不占用保留名, 所以项目里的.spark/commands/quit.md 可以把/quit抢注成自定义命令。 未知命令会提示 未知命令: … · 输入 /help 查看命令。
/model、/perm、/effort、/clear、/sessions在有 turn 正在跑时只提示不执行,不会打断进行中的工作。
7.3 快捷键
| 按键 | 行为 |
|---|---|
Esc | 有草稿时先清空草稿;草稿为空则中断当前 turn |
Shift+Tab | 在 manual ↔ auto 之间循环(永远不会切到 bypass) |
Ctrl+O | 开关思考流展示 |
Ctrl+E | 展开/折叠折叠区 |
Ctrl+U/Ctrl+W | 行编辑快捷删除 |
Shift+Enter/\+Enter | 换行而不提交 |
↑/↓ | 历史输入 |
Tab | 命令补全 |
Ctrl+C | 按两次退出 |
Shift+Tab到不了bypass是刻意的:完全访问权限必须在 /perm里 经过二次确认才能开启。另外Ctrl+V 粘贴图片只出现在欢迎框提示里,不在/help的快捷键表中。
7.4 权限模式与推理强度
权限模式只有三个值:
| 值 | 含义 |
|---|---|
manual | 每次工具调用按策略询问 |
auto | 按规则自动放行低风险操作 |
bypass | 完全放行,仅用于受控环境 |
默认值不是帮助文本写的manual。实际链是 「显式 flag →[permissions].mode→/perm持久化过的 agent.permission_mode→manual」。 TUI 里/perm 的选择会写回全局配置,所以下一次启动仍然生效。
spark --permission-mode bypass "跑一遍测试" # stderr 会打印: # DANGER: permission bypass is active; registered tools may execute without approval.
这条警告只在一次性任务与 plain REPL路径打印,TUI 路径不打印。--dangerously-skip-permissions是--permission-mode bypass 的别名, 但和显式给出的非 bypass 模式同时出现会报错。
推理强度五值off | low | medium | high | max,默认链同样是 「flag → 持久化偏好 → high」。off关闭思考;max在 Anthropic 上是 65536 token 的思考预算。
7.5--plain到底是什么
帮助文本写的是「Disable color and terminal redraw」,但实现里它选的是行式 REPL: stdin/stdout 都是 TTY 且没有提示词时进入,横幅是 spark plain REPL · /exit 退出。
- 只识别
/exit与/quit,其它/xxx一律当任务文本发给模型; - 该路径完全忽略
--json与--output-format; - 配一次性提示词或配子命令时,
--plain没有任何效果。
8. 图片附件
-i/--image把图片附加到一次性任务的提示词上,可重复出现:
spark -p "分析这张截图的问题" -i shot.png -i arch.jpg
8.1 格式与限额
| 项 | 实际值 |
|---|---|
| 支持格式 | PNG / JPEG / WEBP / GIF |
| 判定依据 | 文件头魔数,不是扩展名 |
| 单轮张数 | 最多 20 张 |
| 单张大小 | 最多 20 MiB |
| 合计大小 | 最多 50 MiB |
校验在读取阶段完成,整批失败就整体拒绝(不会只跳过坏的那张)。 因为按魔数判断,把 PNG 改名成notes.bin也能通过;反过来把一个文本文件改名成 .png会被拒。
8.2 两条约束
- 只能配一次性任务:图片在模型解析之前就读取校验,而 TUI 分支在此之前就已经被排除。
- 不能配子命令:
spark models -i a.png会报--image only applies to a task prompt, not to models.并退出 2。
图片不会以 base64 形式写进事件账本——账本里只记制品引用,请求体里才是 data URL。 这也意味着账本可以安全地按行传阅,不会夹带图片内容。
9. 桌面联动:本地桥
「CLI 与桌面共用~/.spark,所以能看到同一批渠道」这句话只说对了一半。 真正让 CLI 用上桌面渠道的,是桌面主进程里跑的一个只监听回环地址的本地 HTTP 服务。 CLI 从不直接读桌面的数据库,也不直接读系统钥匙串——它只和这个桥说话。
9.1 桥是什么
| 项 | 实际值 |
|---|---|
| 实现位置 | Electron 主进程内的node:http服务器 |
| 绑定地址 | 127.0.0.1+系统分配的临时端口(listen(0)) |
| 端口 | 每次启动都变,没有固定端口 |
| 发现方式 | 只靠描述文件里的endpoint字段 |
| 是否可关闭 | 不能:没有设置项、没有 IPC 开关,随应用启动无条件尝试创建 |
端口不固定意味着你无法靠curl 127.0.0.1:某端口直接调试——必须先读描述文件拿到 endpoint。 如果启动时绑定失败,只记一条log.warn,整进程不会重试。
9.2 描述文件
~/.spark/hosts/sparkwork/bridge-<instanceId>.json
目录权限0700、文件0600,用「同目录临时文件 + fsync + rename」原子写入。 内容共 7 个字段:
{ "schemaVersion": 1, "host": "sparkwork", "instanceId": "<uuid>", "endpoint": "http://127.0.0.1:<port>", "token": "<32 字节随机数的 base64url>", "pid": 12345, "startedAt": "2026-09-19T…Z" }
token在每次进程启动时重新生成,不做轮换,只存在于描述文件与内存里。 CLI 侧用严格的 zod schema 校验同一组字段——多一个字段整份描述会被判无效。
两侧的文件名正则不等价,这是最容易写错的一处。桌面端接受
bridge.json(单实例时代的遗留名,会被 GC 清理), 而 CLI 侧的正则要求必须有-<id>段, 所以bridge.json对 CLI 来说等于不存在。
9.3 发现与选主
CLI 每次开始任务时都重新发现一遍,流程是:
- 扫
~/.spark/hosts/sparkwork/下匹配bridge-<id>.json的普通文件; - 按文件mtime 倒序取前 8 个作为候选;
- 逐个校验权限(非 Windows 要求
0600或更严)、大小(≤ 64 KiB)、endpoint 必须是回环 HTTP且不带凭据/路径/查询/fragment; - 带 token 请求
/v1/catalog; - 成功的候选里,取
startedAt最大的那个作为胜出者。
探测超时分两档:pid 存活给 2000 ms,pid 已消失只给 250 ms—— 后者是防「端口复用巧合应答」拖慢启动。
多开桌面应用是支持的:每个实例写自己的描述文件,退出时只删自己那份; 启动时还会清理「能证明已死」的残留(判定依据是kill(pid, 0)返回 ESRCH, EPERM 视为存活)。解析失败的垃圾文件不会阻塞启动,交给 CLI 侧跳过。
9.4 桥的全部端点
只有三条有效路由,没有「凭据端点」这种设计:
| 方法 | 路径 | 返回 |
|---|---|---|
GET | /v1/health | {"ok":true,"schemaVersion":1,"host":"sparkwork"} |
GET | /v1/catalog | 模型目录:revision/generatedAt/defaultRoute? /routes[] |
POST | /v1/proxy/<providerId>/v1/messages | 代理到上游 Anthropic Messages |
POST | /v1/proxy/<providerId>/v1/responses | 代理到上游 OpenAI Responses |
revision是sha256(JSON.stringify({defaultRoute, routes})),routes 里含渠道显示名,所以改一个渠道的名字也会换 revision。
/v1/health也要 token(鉴权在路由分发之前), 而且它在整个仓库里没有任何调用方——是一条定义了但没人用的路由。
鉴权头有两种写法,两种都必须支持:authorization: Bearer <token>或x-api-key: <token>。原因是 CLI 的 Anthropic 路由把 token 放进x-api-key、 OpenAI 路由放进 authorization。只实现一种会出现「Anthropic 渠道全部 401」。
10. 哪些渠道会被桥接
这是本章最容易写错的部分。桥不会把桌面里的全部渠道暴露给 CLI, 筛选规则相当窄。
10.1 协议判定规则
渠道必须命中下面两条之一,否则根本不出现在目录里:
| 渠道类型 | 条件 | 桥协议 |
|---|---|---|
| Anthropic | provider === 'anthropic' | anthropic-messages |
| OpenAI | provider === 'openai'且 codexApiKind === 'responses' | openai-responses |
也就是说:OpenAI 渠道必须显式标记为 Responses API 才会进桥。 普通走 /chat/completions的渠道在桌面里能用,但在 CLI 里彻底看不到。 这一点很反直觉,因为桌面端自己两种协议都支持。
按内置预设统计:Anthropic 系全部可桥接(约 16 条),OpenAI 加 codexApiKind: 'responses'的只有 5 条, 其余约 32 条 OpenAI 兼容渠道(含通义千问、Google Gemini、DeepSeek 这类未标记codexApiKind的预设) 都不进桥。想知道某个渠道能不能用,看它有没有显式的codexApiKind: 'responses' 即可。
10.2 排除集
除协议外,还有三类过滤:
enabled === false的渠道不出现;modelType === 'image'的渠道不出现;- 下面四个 id 被硬编码排除。
local-cli local-codex-cli claude-auto-router codex-auto-router
这个排除集是承重的,不是冗余: 前两个是「本机 AI 工具」渠道,没有 Key 也没有 Endpoint, 但它们有非空的模型名——如果不排除,桥会真的生成sparkwork:local-cli:claude cli这样的路由,然后在调用时返回 503。 后两个是运行时合成的「自动路由」profile,本身模型名为空。
另外,被时段策略临时禁用的渠道/模型也会从目录里消失——目录是每次请求现算的,不是启动时快照。
10.3 上游地址如何推导(含一处不一致)
桥拿到 providerId 后,按下表推导真正的上游地址:
| 协议 | 规则 |
|---|---|
anthropic-messages | 已以/v1/messages结尾 → 原样;以/v1结尾 → 补 /messages;否则补/v1/messages |
openai-responses | 已以/responses结尾 → 原样;以/chat/completions结尾 → 换成 /responses;最后一段是/vN形式 → 补/responses;以/v1结尾 → 补/responses;否则补 /v1/responses |
/vN规则这条曾有一处真实缺陷:桌面端一直有这条规则, 而桥的实现里此前没有,导致因为地址以/vN结尾而被自动标成responses的编程端点(如…/api/coding/paas/v4)会被拼成…/paas/v4/v1/responses——同一个渠道「桌面里能用、CLI 里报错」。 该缺陷已在桥侧修复:桥的推导与桌面端getOpenAiResponsesEndpoint 逐分支对齐,并用回归测试锁定(覆盖…/paas/v4与…/coding/v3 两类端点)。如果你还在旧版本上遇到这个症状,升级桌面端后重启 CLI 即可。
10.4 在spark models里长什么样
每行格式固定是「选中标记 + 模型名 + 渠道名 + 协议 + 来源」:
* gpt-5.5 OpenAI openai-responses [sparkwork] claude-sonnet-4-5 Claude anthropic-messages [sparkwork] my-local local anthropic-messages [local]
来源只有local与sparkwork两个值, 终端里不出现「bridge」这个词——看到[sparkwork]就是指它来自桌面桥。 路由 id 的内部格式是sparkwork:<providerId>:<model>, 但列表和 TUI 显示时会还原成裸模型名。--model两种都接受: 既可以直接给路由 id,也可以给唯一的模型名;名字重复时报Model X exists in multiple Spark Work providers; use one of: …。
目录里桥路由排在本地配置的模型前面,所以打开/model时 光标默认落在桥路由上。但默认选中项另有优先级链:
--model > SPARK_MODEL > 项目层 [agent].model > 全局 [agent].model > 桌面的 defaultRoute
只要 CLI 侧存过任何本地默认,桌面的defaultRoute 就轮不到——这是刻意的「sticky」设计。
最后一个必须知道的后果:桌面没开时没有回退,只有报错。 如果你之前用 TUI 选过一个桥路由,它会被写进~/.spark/config.toml的[agent].model; 关掉桌面再启动 CLI,就会撞上Model <id> is not defined locally or available from Spark Work。 解法是重开桌面,或在 TUI 里换回本地模型。
11. 桥的安全模型
这部分的边界值得单独讲,因为它决定了「谁能读到你的 Key」。
11.1 三道约束
| 约束 | 实现 |
|---|---|
| 只监听回环 | 服务端只 bind127.0.0.1;客户端还会拒绝任何非回环 endpoint |
| 随机 token | 32 字节随机数,每次启动重新生成,随描述文件传递 |
| 文件权限 | 目录0700、文件0600;CLI 侧反向校验,权限过松直接拒绝 |
没有 CORS / Origin 检查,也没有 Host 头校验—— 防护完全建立在「回环 + 随机 token + 文件权限」之上。这意味着同一台机器上的其他进程 只要能读到描述文件,就能用你的渠道额度。
11.2 请求头白名单
桥转发到上游的客户端头是一张 6 项白名单,其余一律丢弃:
anthropic-version anthropic-beta openai-organization openai-project x-opencode-session user-agent
x-opencode-session的存在有个具体原因:某些网关要求每个会话带稳定的会话头, 缺了直接返回 400。桥生成不出这个值(它必须跨请求稳定且与会话一一对应),只能原样透传。 桥对头值从不记录,日志里只有「这个头在不在」的布尔值。
客户端没带 User-Agent 时,兜底值是sparkwork-cli-bridge, 这样上游不会看到 Node 的默认 UA。
11.3 体积与流式
| 项 | 上限 |
|---|---|
| 请求体 | 32 MiB(超出返回413 request_too_large) |
| 未完成的 SSE 累积缓冲 | 8 MiB |
请求体是全量缓冲成字符串再转发,即使stream:true 也不做背压转发。
流式响应按完整 SSE 帧转发,帧边界取\n\n与 \r\n\r\n中较早的一个。 上游中途断流时,半截 JSON 会被丢弃,然后追加一个协议原生形状的错误帧—— Anthropic 用 {type:"error", error:{type:"bridge_stream_error"…}}, OpenAI 用{code:"bridge_stream_error"…}——这样下游解析器不会看到两个事件被粘在一起。 连接 EOF 处那个「天然完整」的尾帧还要再验一次能否解析成合法 JSON 或[DONE]。
11.4 凭据是怎么用的
凭据链路是:URL 里的 providerId → 桌面 SQLite 里的渠道行 → 系统钥匙串。 桥每次代理请求现取一次凭据,不缓存;取到空串就返回 503。
**但那个 503 的错误码永远到不了调用方。**桥的通用错误处理规则是「状态码 < 500 时回传原始消息,否则统一返回
bridge_request_failed」,而凭据缺失恰好是 503。 所以 CLI 侧只会看到{"error":"bridge_request_failed"}; 想知道到底是「凭据没了」还是别的,得去翻桌面端日志。
凭据只写进上游请求的x-api-key(Anthropic)或authorization: Bearer(OpenAI), 且会覆盖客户端传来的同名头——调用方无法用自己的值顶替。 回传给调用方的响应头也只是一张固定白名单(content-type、retry-after、request-id及两个厂商的 request-id)。 目录响应里不含任何密钥。
另外,桥本身不做 sk- 之类的密钥模式脱敏,只做控制字符清洗与 1024 字符截断。 正常路径上密钥不会出现在响应体里,但如果上游把密钥回显到错误文本里,桥不会主动擦掉。
12. 数据落点与配置分层
12.1~/.spark里到底有什么
全局根的解析是SPARK_HOME ?? ~/.spark。真实落盘项:
| 路径 | 内容 | 权限 |
|---|---|---|
config.toml | 全局配置层 | 文件 0600 / 目录 0700 |
credentials.json | 账号会话(含 token / refreshToken) | 0600 |
projects/<编码目录>/<sessionId>/events.jsonl | 会话账本 | — |
artifacts/<sha256 前 2 位>/<sha256> | 工具结果制品 | — |
settings.json | Hooks 的 user 作用域配置 | — |
hosts/sparkwork/ | 桌面桥描述文件目录 | 目录 0700 |
update-check.json | 更新提示的最近检查时间与版本 | — |
update.lock | 更新并发锁 | — |
bin/ | spark install的默认 launcher 目录 | — |
12.2 项目级.spark
以process.cwd()为根,不会向上查找父目录:
| 路径 | 内容 |
|---|---|
.spark/config.toml | 项目配置层,覆盖全局 |
.spark/todos.json | spark todo的任务清单 |
.spark/plans/<sessionId>.md | spark plan的会话计划 |
.spark/settings.json/settings.local.json | 项目级 Hooks 配置 |
.spark/skills | 项目级技能 |
12.3 记忆是那个例外
长期记忆不在~/.spark下,而在:
~/.spark-agent/memory/<scope>/
默认根是SPARK_AGENT_HOME ?? ~/.spark-agent, 而 CLI 构造记忆存储时不传自定义 home, 所以设置SPARK_HOME对记忆完全无效。这是一个非常容易踩的坑: 你以为把SPARK_HOME 指到别处就隔离了全部状态,实际记忆还留在原处。
项目作用域的记忆目录名是.spark-agent/memory(注意是.spark-agent,不是.spark), 而且它会逐级向上查找第一个已存在的目录——这一点与 todo / plan 只认当前目录的行为不同。
12.4 配置分层与合并
两层合并规则是「全局在下、项目在上,项目赢」,实现为对象递归合并、数组与标量整体替换。 真实的 section 与 key 只有这些:
| section | 键 |
|---|---|
[agent] | model、permission_mode、reasoning_effort、failover、max_retries、retry_initial_delay_ms、retry_max_delay_ms、retry_jitter_ratio |
[providers.<id>] | protocol、base_url、api_key_env |
[models.<id>] | provider、model、context_window、max_tokens、capabilities |
[permissions] | mode、allow、deny、ask |
[tools] | enabled、disabled(二者互斥) |
[mcp]/[mcp.servers.<name>] | startup_timeout_ms;enabled、command、args、env、cwd、url、headers |
[memory] | enabled、max_inject_tokens、agent_id |
[platform] | server_url、web_login_url |
[update] | base_url、version、enabled |
根 schema 是 strict 的:写一个不存在的键不是被静默丢弃,而是报错退出 2。 所以 spark config set foo bar会失败。
表级递归合并有一个隐蔽的坑。如果全局写了
mcp.servers.x.url、项目层写了mcp.servers.x.command, 合并结果是两者共存,而 schema 明确禁止command与url同时出现。 于是所有读配置的命令都会退出 2。修复只能用spark config unset(写前校验的是合并后的结果)。
13. 子命令逐个说
13.1login/logout/whoami
登录走的是浏览器 + PKCE + 轮询,不是设备码,也不是本地回调:
- 生成
state、codeVerifier(各 32 字节随机数的 hex),codeChallenge = sha256(verifier); - 拼出
?desktop=1&state=…&challenge=…的登录页并打开浏览器; - 轮询服务端
GET /auth/desktop/poll?state=,间隔 2 秒、总超时 300 秒; - 成功后一次性
POST /auth/desktop/exchange换回会话。
state与 verifier只存在进程内存里,交换成功前不写盘。--no-browser只打印登录链接,适合远程或容器环境。 轮询间隔与超时没有对应的命令行开关(它们只是测试注入口)。
spark login --no-browser # 打印链接,自己在浏览器打开 spark whoami # 显示当前账号与服务器 spark logout # 删除本地凭据
credentials.json的结构是{version, serverUrl, session:{token, refreshToken, userId}, account?, updatedAt}, 写入时 0600;如果加载时发现权限被放宽,会自动 chmod 回 0600。 文件损坏或字段不符会直接报错,不会静默登出。
**
spark logout只删本地文件,不调用任何服务端吊销接口。**CLI 使用的服务端端点里根本没有 logout / revoke, 所以「登出后旧 token 在服务端是否还有效」在代码层面无法确认——按仍然有效来处理更安全。
whoami用的是存储里的serverUrl(不是当前配置里的), 两者不一致只在 stderr 提示。会话过期时会清空凭据并退出 1。
13.2config
| 子命令 | 行为 |
|---|---|
spark config/list | 列出生效配置,每行key = value [scope] |
get <key> | text 模式打印原始值;--json给完整条目 |
set <key> <value> | 默认写全局层;--project写项目层 |
unset <key> | 从默认(全局)层删除;键不存在退出 2 |
path | 打印全局与项目的配置文件路径,并标注是否已创建 |
两个必须注意的口径:
config list只反映显式写过的键,schema 默认值不出现;config get读的是合并结果,看不到 schema 默认值—— 比如配置文件里没写agent.max_retries,get会说「not set」,尽管运行时默认是 2。
取值解析规则:以[/{开头按 JSON 解析成数组或对象;true/false转布尔;纯数字转 number;带引号会剥掉引号。
13.3mcp
stdio 与 http 两种服务器的配置形状:
[mcp.servers.filesystem] command = "npx" args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] env = { TOKEN = "abc" } cwd = "/tmp" [mcp.servers.remote] url = "https://example.com/mcp" headers = { Authorization = "Bearer xyz" }
schema 强制几条约束,违反会直接退出 2:
command与url必须二选一;args/env/cwd只能配command;headers只能配url;- 服务名要求
^[A-Za-z0-9._:-]{1,96}$。
spark mcp add写的是整表替换:同名服务器的原有字段会被清掉,不是字段级合并。 要临时关闭某个服务器,只能spark config set mcp.servers.<name>.enabled false,add 本身不提供关闭选项。
spark mcp status会真的把每个启用的服务器拉起来: 串行启动、每个 15 秒超时,读注册表里mcp__<name>__前缀的工具,然后关闭。 任一失败即退出 1。所以它是真实连接测试,不是读配置文件。 因为要展开${VAR},环境变量缺失会报错退出 2,而mcp list不展开变量所以不会。
13.4memory
| 子命令 | 作用 |
|---|---|
list | 列出记忆摘要(不含正文) |
search <q> | 按摘要检索 |
recall <id> | 读取一条完整记忆(含正文);会写盘——命中计数 +1 |
save | 写入一条记忆,需要--scope |
--type与--scope是两个不同域的枚举,这点最容易搞混:
| 枚举 | 取值 |
|---|---|
--scope | user|project|agent |
--type | user|feedback|project| reference——没有agent |
不指定--type时按 scope 推导:projectscope 落成 type: project,其余(含 agent)都落成type: user。 所以「agent 作用域的记忆类型是 agent」是错的。
几条实用口径:
--confidence取值范围 0–1(含端点);--limit是 1–100;- 同名条目是覆盖而非新增(按 name 在目标作用域内查,命中就沿用原 id 重写);
list的排序不是user→project→agent,而是按类型优先级feedback<user<project<reference,再按更新时间倒序;- 手写
.md放进目录不会被识别——id 必须匹配^(usr|prj|agt)_…,否则整条跳过; [memory].enabled = false不阻止 CLI 读写,它只影响提示词注入。
13.5plan
会话计划是会话私有的 Markdown,落在 <cwd>/.spark/plans/<sessionId>.md, 与项目级的 todo 刻意分开。CLI 与模型工具共用同一个 store, 所以spark plan show 读到的就是模型写下的计划。
| 子命令 | 行为 |
|---|---|
show(别名read) | 打印当前计划;没有则打印提示并退出 0 |
set(别名write) | 整体覆写,需要--body |
append | 读出现有计划,用空行拼接后整体写回 |
clear | 删除该文件;文件不存在仍退出 0 |
不写--session时默认用该项目下更新时间最新的会话; 一条会话都没有才报错退出 2。sessionId 有格式校验(只允许[A-Za-z0-9._-]、≤128、不含..), 单文件上限 256 KB。
有个校验缺口:plan set <任意合法 id>不校验该会话是否存在, 会直接给你创建一个.spark/plans/<id>.md。
13.6skills
技能从四个宿主根发现,再加上每个祖先目录下的同名四兄弟(越深越优先):
~/.claude/skills ~/.codex/skills ~/.agents/skills ~/.spark/skills
spark skills list [query]按 name / description / id 做子串匹配, 默认上限 200条(超出会被截断且不提示);--limit允许 1–200。
spark skills read <id-or-name>打印完整的SKILL.md正文,--json时正文字段名是 instructions。 目录扫描时每个技能只读前 16 KB 解析 frontmatter,整文件上限 256 KB; 再次加载会校验 name 与目录清单一致,不一致报changed_document。
桌面里装的技能在 CLI 里看不到。桌面的托管技能目录是应用数据目录下的
skills/, CLI 侧完全不引用它;两边只共享上面那四个宿主目录。
13.7todo
任务是单个 JSON 文件<cwd>/.spark/todos.json, 结构是 {version:1, items:[{id,title,status,priority,notes,createdAt,updatedAt}]}, 最多 1000 条。id 形如todo_<uuid>。
| 枚举 | 取值 |
|---|---|
--status | pending|in_progress|completed| cancelled |
--priority | low|normal|high |
两条容易错的:
todo list不传--limit是「全量」,不是默认 10 或 20;传了则必须在 1–100 之间。todo clear只删已完成与已取消;要连待办一起清掉必须加--all。
列表排序是按状态分组(进行中 → 待办 → 已完成 → 已取消),再按优先级降序、更新时间降序。add的--title与位置参数互斥;标题禁止换行、最长 240 字符。
13.8 未公开的别名
下面这些等价写法不在--help里,但确实是同一分支:
| 写法 | 等价于 |
|---|---|
spark config(无子命令) | spark config list |
spark mcp(无子命令) | spark mcp list |
spark memory add | spark memory save |
spark plan read/write | spark plan show/set |
spark todo delete | spark todo remove |
另一类不一致是「位置参数被静默忽略」:spark mcp list <x>、spark config path <x>、spark memory list <x>都不会报错;而spark todo list <x>反而会报用法错误退出 2。
14. 版本与更新
14.1 从哪里取版本
发布源与版本的解析顺序是两条独立的链:
| 项 | 优先级(高 → 低) |
|---|---|
| 发布源 | --base→SPARK_RELEASE_BASE→SPARK_INSTALL_BASE → 全局[update] base_url→ 内置地址 |
| 固定版本 | --target→SPARK_INSTALL_VERSION→ [update] version |
两个容易踩的坑。第一,
install.sh只认SPARK_INSTALL_BASE(不认SPARK_RELEASE_BASE), 所以设了后者的机器上,spark update与install.sh会走两个不同的源。 第二,SPARK_INSTALL_VERSION/[update] version一旦设置就会永久钉死后续每次更新, 而且钉死状态下比当前更旧的版本会被当成「有更新」并执行降级。
项目级.spark/config.toml里的[update] 只能改enabled, 它的base_url与version会被忽略。
14.2spark update的退出码
| 码 | 含义 |
|---|---|
0 | 有可用更新(--check)或更新已应用 |
1 | 已是最新 / 远端更旧 / 预发布被门槛挡住 |
2 | 用法错误(由参数解析产生,不是 update 模块) |
3 | 检查或升级失败 |
4 | 另一个更新正持有锁 |
「--check发现新版本」是 0,不是 1—— 「什么都没做」才是 1。这一点与大多数工具的直觉相反。
分类顺序也值得注意:先相等 →up_to_date,再比较大小 →remote_older, 然后是预发布门槛,最后才是update_available。 所以「远端是个更旧的预发布版」得到的是remote_older,不是「有预发布可用」。
14.3 锁、校验与原子交换
- 并发锁在
~/.spark/update.lock,内容是{pid, token, acquiredAt},用open(…, 'wx')独占创建。 抢不到时:owner 进程被内核证实已消失(ESRCH)就立刻抢占;否则按 mtime 超过 15 分钟判为陈旧。 总共只尝试 2 次,第二次仍失败就返回码 4。 - 锁目录刻意不受
SPARK_HOME影响——把SPARK_HOME指到别处并不能换锁位置, 这是为了防止两个更新器竞争同一份安装。 - 校验是双重的:下载后比对 tarball 的 sha256 与清单,再校验包内
package.json的name/version以及 Node engines。--target路径不读latest.json,改读版本化 tarball 旁边的.sha256sidecar。 - 交换用两次 rename(不是符号链接):旧包改名到
.spark-agent-backup-<pid>/package,暂存目录改名到正式位置。 换完会重链 npm bin 与$SPARK_HOME/bin两处 launcher,再跑一次健康检查; 任何一步抛错都会把备份 rename 回去。
两个限制:更新只作用于@spark/agent的 npm 全局树, 并且只重链默认的两处 launcher——如果你当初用spark install --bin /usr/local/bin装的,更新后那个 launcher 不会被刷新。
被 kill 掉的更新会留下.spark-agent-stage-*与备份目录, 下次更新时会自动修复或清理。
14.4 更新提示
TUI 启动时会顺手做一次更新检查,规则如下:
| 项 | 值 |
|---|---|
| 触发条件 | 非--plain、非--json、输出格式是 text、stdin/stdout 都是 TTY、CI != 'true'、TERM != 'dumb' |
| 检查频率 | 24 小时一次(缓存在~/.spark/update-check.json) |
| 硬超时 | 4 秒 |
| 打印位置 | stderr,且在 TUI 退出之后 |
| 关闭开关 | SPARK_UPDATE_CHECK=0(只有字符串0有效)或[update] enabled = false |
SPARK_UPDATE_CHECK=false不生效,必须是0。 另外--json模式下这次检查根本不会发起(不是「查了但不打印」), 因为--json会让内部json标志为真。 提示只针对「非预发布且严格更新」;抓取失败也会写缓存时间,避免每次调用都重试。
15. doctor 与排查
15.1spark doctor看什么
models与doctor走的是同一个函数, 区别只是 doctor额外做两件事:检查配置是否可加载、生成安装报告。 输出分几块:
Spark Work bridge: connected | not connected Diagnostic: … # 桥不可达时的原因 Stale bridge descriptors: N … # 陈旧描述文件数量 Selected model: … # 当前生效模型 Available models: N Configuration: ready | error — … # 配置是否可加载 spark on PATH: … # 安装报告 Node <版本>: …
退出码是「有模型且配置能加载 → 0,否则 1」——一个模型都没有时 doctor 也返回 1。
「Stale bridge descriptors」这个数字与它的文案不符。实现里它统计的是所有探测失败的候选(包括「进程活着但没应答」「权限读不了」「401」的活实例), 但文案写成了「left by Spark Work instances that are no longer running」。 而且这个数字受「最多探测 8 个候选」的截断影响。看到非零不必惊慌,先用
spark doctor的Diagnostic行判断真实原因。
15.2 排查对照表
| 现象 | 最可能的原因 | 怎么确认 / 怎么办 |
|---|---|---|
Model … is not defined locally or available from Spark Work | 之前选过的桥路由被写进[agent].model,而桌面没开 | 重开桌面应用,或在 TUI 里/model换回本地模型 |
spark models里看不到某个桌面渠道 | 该渠道不是 Anthropic,也不是codexApiKind: 'responses'的 OpenAI | 去桌面 Provider 设置里确认协议标记;普通 chat 渠道不会进桥 |
| 渠道在桌面能用、在 CLI 报错 | 旧版本桥的地址拼接缺陷:上游地址多了一段/v1(/vN 规则缺失,已在 10.3 所述版本修复) | 升级桌面端并重启 CLI;若仍复现,比对拼出的上游 URL |
调用返回bridge_request_failed | 桥侧 5xx,多半是凭据缺失(原始码 503 被通用规则掩盖) | 去桌面日志找真实原因;检查该渠道的 Key 是否还在 |
| 所有 Anthropic 渠道都 401 | 鉴权只实现了authorization头,没实现x-api-key | 两种头都要接受(Anthropic 协议走x-api-key) |
| 描述文件明明在却探测不到 | 文件名不是bridge-<id>.json,或权限宽于 0600,或 endpoint 不是回环 | CLI 侧会分别报 invalid / unreadable;对照 9.2 的约束 |
spark sessions里的 id 喂给--resume失败 | 列表显示的是 8 位短 id,匹配用的是完整 id | 用spark sessions --json取完整 id |
裸--resume报错 | 它需要 TUI;管道或非 TTY 环境下用不了 | 改用--resume <完整id> |
--continue没接上历史 | 当前目录还没有会话,此时会静默开新会话 | 确认spark sessions非空;注意会话按项目目录归档 |
| 恢复会话后权限模式变了 | 给了显式--permission-mode,它会覆盖账本里的模式 | 不传该 flag 即沿用会话原模式 |
-i加了却没生效 | 图片只用于一次性任务;配子命令或无提示词都会退出 2 | 写成spark -p "…" -i a.png |
| 图片被判格式不支持 | 判定按魔数不按扩展名 | 确认文件本身真的是 PNG/JPEG/WEBP/GIF |
| 图片批次被整体拒绝 | 超过 20 张 / 单张 20 MiB / 合计 50 MiB 中的任一条 | 拆成多轮,或先压缩 |
加了--json却拿不到最终结果 | --json给的是事件流,不是结果对象 | 改用--output-format json |
--json报冲突 | 与--output-format text/json互斥 | 只保留一个,或显式用stream-json |
| 子命令输出突然变成 JSON | --output-format会顺手打开所有子命令的 JSON | 去掉该 flag,或用--output-format text |
| 任务被取消想区分于失败 | 取消是 130,失败是 1 | 按退出码分支处理 |
脚本里spark update --check判定反了 | 有更新是 0,无更新是 1 | 按此语义写判断,别按常规直觉 |
| 更新一直报「另一个更新持有锁」(4) | 上次更新被中断,锁文件还在且未满 15 分钟 | 确认没有更新的进程后删除~/.spark/update.lock |
| 更新完 PATH 上的 launcher 没刷新 | 更新只重链 npm bin 与$SPARK_HOME/bin | 用非默认--bin装的,更新后需要重跑一次spark install |
SPARK_UPDATE_CHECK=false没关掉提示 | 只有字符串0被识别为关闭 | 改成SPARK_UPDATE_CHECK=0 |
改过SPARK_HOME但记忆还在原处 | 记忆根是独立的SPARK_AGENT_HOME ?? ~/.spark-agent | 要隔离记忆需同时设SPARK_AGENT_HOME |
| 放了手写的记忆文件但读不到 | id 不符合(usr|prj|agt)_…格式会被整条跳过 | 用spark memory save生成,而不是手写 |
| 所有读配置的命令突然退出 2 | 全局与项目层各写了url/command,合并后冲突 | 用spark config unset删掉冲突的一侧 |
spark mcp status很慢 | 它会串行拉起每个启用的服务器,每个 15 秒超时 | 这是预期行为;临时禁用不需要的服务器可提速 |
桌面里装的技能skills list看不到 | CLI 只扫四个宿主目录,不读桌面托管目录 | 把技能放到~/.claude/skills等宿主目录,两边都能用 |
| 登出后担心 token 还在服务端有效 | logout只删本地文件,没有吊销端点 | 按「仍然有效」处理;需要真吊销得走服务端渠道 |
15.3 已知边界
下面这些是代码层面能确认的限制或缺口,写在这里避免你按「应该有」去排查:
spark serve没有实现,只打印一句说明并退出 2。- 桥没有开关,无法通过设置或 IPC 关闭;启动失败也不会重试。
- 桥没有 CORS / Origin / Host 校验,同一台机器上能读到描述文件的进程即可使用你的渠道。
- 桥不缓存凭据,每个代理请求都重新取一次;也不做密钥模式脱敏。
/v1/health在仓库里没有任何调用方。- 桥与 CLI 的目录 schema 是两份手写实现,没有共享类型也没有跨端契约测试; 任一侧加字段或改名,另一侧只会报「catalog 无效」。
- 桥暴露的
descriptorPath与endpoint生产代码不使用,只有测试读取。 - 进程存活判定只靠
kill(pid, 0);pid 被系统复用后,残留描述文件将不会被回收。 - 没有任何命令行开关能改登录轮询间隔与超时(2 秒 / 300 秒是固定值)。
plan set不校验会话是否存在,可以给不存在的会话创建计划文件。- 更新提示的 base 解析不含
--base,所以用自定义源时提示可能指向另一个源。 install.ps1的真机行为未验证:脚本头部自述仅做静态评审,且没有清理临时文件的 trap。