toolhome 0.6.12 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +24 -1
  2. package/README.zh.md +24 -1
  3. package/dist/server/cli/adopt.d.ts +43 -0
  4. package/dist/server/cli/adopt.d.ts.map +1 -0
  5. package/dist/server/cli/adopt.js +170 -0
  6. package/dist/server/cli/adopt.js.map +1 -0
  7. package/dist/server/cli/main.js +126 -3
  8. package/dist/server/cli/main.js.map +1 -1
  9. package/dist/server/cli/migrate.d.ts +161 -0
  10. package/dist/server/cli/migrate.d.ts.map +1 -0
  11. package/dist/server/cli/migrate.js +405 -0
  12. package/dist/server/cli/migrate.js.map +1 -0
  13. package/dist/server/cli-plane/cli-service.d.ts +17 -0
  14. package/dist/server/cli-plane/cli-service.d.ts.map +1 -1
  15. package/dist/server/cli-plane/cli-service.js +48 -0
  16. package/dist/server/cli-plane/cli-service.js.map +1 -1
  17. package/dist/server/cli-plane/state.d.ts +29 -0
  18. package/dist/server/cli-plane/state.d.ts.map +1 -0
  19. package/dist/server/cli-plane/state.js +103 -0
  20. package/dist/server/cli-plane/state.js.map +1 -0
  21. package/dist/server/control/client.d.ts +10 -0
  22. package/dist/server/control/client.d.ts.map +1 -1
  23. package/dist/server/control/client.js +40 -2
  24. package/dist/server/control/client.js.map +1 -1
  25. package/dist/server/control/control-api.d.ts.map +1 -1
  26. package/dist/server/control/control-api.js +30 -1
  27. package/dist/server/control/control-api.js.map +1 -1
  28. package/dist/server/control/control-service.d.ts +94 -38
  29. package/dist/server/control/control-service.d.ts.map +1 -1
  30. package/dist/server/control/control-service.js +341 -24
  31. package/dist/server/control/control-service.js.map +1 -1
  32. package/dist/server/control/openapi.d.ts.map +1 -1
  33. package/dist/server/control/openapi.js +14 -1
  34. package/dist/server/control/openapi.js.map +1 -1
  35. package/dist/server/data-plane/gateway-server.d.ts.map +1 -1
  36. package/dist/server/data-plane/gateway-server.js +72 -31
  37. package/dist/server/data-plane/gateway-server.js.map +1 -1
  38. package/dist/server/market/catalog.d.ts +39 -0
  39. package/dist/server/market/catalog.d.ts.map +1 -1
  40. package/dist/server/market/catalog.js +38 -1
  41. package/dist/server/market/catalog.js.map +1 -1
  42. package/dist/server/market/market-service.d.ts +1 -0
  43. package/dist/server/market/market-service.d.ts.map +1 -1
  44. package/dist/server/node/local-gateway.d.ts.map +1 -1
  45. package/dist/server/node/local-gateway.js +20 -1
  46. package/dist/server/node/local-gateway.js.map +1 -1
  47. package/dist/server/upstream/oauth-service.d.ts +22 -0
  48. package/dist/server/upstream/oauth-service.d.ts.map +1 -1
  49. package/dist/server/upstream/oauth-service.js +26 -0
  50. package/dist/server/upstream/oauth-service.js.map +1 -1
  51. package/package.json +1 -1
  52. package/skills/toolhome/SKILL.md +46 -0
  53. package/skills/toolhome/references/cli-reference.md +98 -0
  54. package/skills/toolhome/references/troubleshooting.md +83 -0
package/README.md CHANGED
@@ -64,7 +64,7 @@ The per-server entry proxies losslessly; the aggregate virtualizes conflicting n
64
64
 
65
65
  Downstream 2026 requests stay stateless; 2025-era clients use a persistent session bound to the authenticated principal, preserving initialize capability declarations and bidirectional request semantics. Parts of the Final Tasks extension not yet registered by SDK 2.0 are filled in by an isolated compatibility layer that still speaks the official `tasks/*` wire contract.
66
66
 
67
- Aggregate capability lists retain results from healthy upstreams when another upstream fails. Partial responses identify failed server slugs in `_meta["toolhome/failed-servers"]`; when every eligible upstream fails, the list returns a protocol error. Cancelling a client request preserves upstream runtime status. Individual endpoints report their own upstream errors.
67
+ Aggregate capability lists serve an upstream from its last known good snapshot when its live list fails, so a transient upstream failure never changes the catalog between pages and client continuation cursors stay valid. Those upstreams are named in `_meta["toolhome/stale-servers"]`; a live list result always wins over the snapshot. An upstream that has never listed the surface and cannot list it now is reported in `_meta["toolhome/failed-servers"]`, and when no upstream can supply any list data the list returns a protocol error. Cancelling a client request preserves upstream runtime status. Individual endpoints report their own upstream errors.
68
68
 
69
69
  Aggregated tool names use `{server_slug}_{encoded_upstream_name}`. UTF-8 bytes outside ASCII letters and digits are encoded as `-hh` (hex), so `github.search_code` becomes `github_search-5fcode`. Long names are shortened with a stable hash. Prompts retain `{server_slug}.{upstream_name}`. Unknown extension methods use `toolhome/{server_slug}/{upstream_method}` on the aggregate and pass through untouched on per-server entries. MCP Apps that use original tool names are routed by App resource context or globally unique names; use a per-server entry when names collide.
70
70
 
@@ -196,6 +196,8 @@ Discovery reads only definitions already stored on the entry: `search` makes no
196
196
 
197
197
  **Local scope.** Node-placed servers still run only on their owning machine, and the local compact catalog covers only servers placed on that node. At startup the local entry mirrors the control plane's tool visibility; if a server's projection cannot be read it stays out of the compact catalog (fail closed) until the next start or explicit refresh. Changing visibility on the control plane requires the local entry to reconnect before it takes effect.
198
198
 
199
+ **Node label.** `toolhome mcp stdio` binds its local mirror to the node label from `--node` or `nodeId` in the CLI config, falling back to the hostname. Keep that label stable: `auth login` preserves it, and a mirror opened under a different label reports the label it belongs to instead of failing with a bare key error.
200
+
199
201
  **Roll back to full.** Set `TOOLHOME_MCP_TOOL_MODE=full` (or remove it) on the host, or drop `--tool-mode` from `toolhome mcp stdio`, then reconnect the client. No data migration is needed: full mode clears any compact-mirrored visibility rows so every enabled tool is exposed again.
200
202
 
201
203
  **Keep one discovery layer.** Do not nest ToolHome compact behind an upstream that already performs its own search/exec or Code Mode. Pick either ToolHome `full`/individual endpoints or the upstream's full tool list. For Cloudflare API MCP, opt out explicitly yourself with its documented `?codemode=false` URL; ToolHome never rewrites upstream URLs or query parameters, and `truncateToolResult` is a separate switch that is never changed for you.
@@ -275,6 +277,27 @@ npm run cli -- config import backup.json
275
277
 
276
278
  Backups contain plaintext secrets and deserve the same protection as the master key. `--include-secrets` requires an explicit output file to avoid leaking secrets into terminal logs, and the CLI forces `0600` after writing. Omit it for routine review.
277
279
 
280
+ An export covers both planes — MCP servers with their placement, hosted CLIs with their runtime and allow-list, encrypted credentials, and Market provenance. `config import --mode merge` makes a restore idempotent: credentials are reused by name, records the target already holds are skipped, and `--upsert` updates them field by field. `--dry-run` shows the same per-item report without writing, and `create` (the default) still aborts on a conflict and names every clash.
281
+
282
+ ## Migration
283
+
284
+ `toolhome migrate` copies an instance into another one — handy when a laptop instance graduates to a cloud host:
285
+
286
+ ```bash
287
+ npm run cli -- migrate --to https://cloud.example.com --to-key "$TOOLHOME_CONTROL_KEY" --dry-run
288
+ npm run cli -- migrate --to https://cloud.example.com --to-key "$TOOLHOME_CONTROL_KEY" \
289
+ --with-state --issue-access-key cloud
290
+ ```
291
+
292
+ The order is deliberate. Market entries are reinstalled on the target first, at their pinned versions, with values harvested from the exported credentials, so the target keeps its own installation records and version tracking. The configuration import then runs in merge mode and leaves what the Market provisioned alone.
293
+
294
+ Two things cannot cross on their own, and the command reports both rather than pretending otherwise:
295
+
296
+ - **Access keys** are stored as digests only. Re-issue one on the target (`--issue-access-key <name>` does it and prints the harness snippet).
297
+ - **Hosted CLI logins** live in each product's own place — `gh` in the macOS keychain, `az` in a token cache inside its state volume. `--with-state` moves the volumes; `toolhome cli adopt <slug>` extracts a local login with the CLI's own command and writes it into the target's volume. `toolhome cli sync` walks every installed entry.
298
+
299
+ Binaries are not part of a migration and do not need to be: a macOS `gh` is a Mach-O binary while the gateway runs Alpine Linux, so the target rebuilds its own pinned artifact from the catalog. Per-entry outcomes are one of `adopted`, `already-authenticated`, `credential-only` (values travel with the export), `manual` (the entry names the command to run on the target), or `failed`.
300
+
278
301
  ## Configuration
279
302
 
280
303
  | Environment variable | Description | Default |
package/README.zh.md CHANGED
@@ -64,7 +64,7 @@ ToolHome 有两个一等平面:
64
64
 
65
65
  下游 2026 请求保持无状态;2025-era 使用与认证 principal 绑定的持久 Session,保留 initialize 能力声明和双向请求语义。最终 Tasks extension 在 SDK 2.0 尚未注册的部分由隔离兼容层补齐,对外仍是官方 `tasks/*` wire contract。
66
66
 
67
- 聚合能力列表在部分上游失败时保留健康上游的结果,并在 `_meta["toolhome/failed-servers"]` 中列出失败的服务器 slug。所有具备相应能力的上游均失败时,列表返回协议错误。客户端取消请求时保持上游运行状态。独立入口返回自身上游的错误。
67
+ 聚合能力列表在某个上游实时列举失败时改用该 Server 上次成功发现的快照,因此瞬时上游失败不会改变分页期间的目录组成,客户端的续页游标保持有效。这些上游会出现在 `_meta["toolhome/stale-servers"]` 中;实时列举结果始终优先于快照。从未成功列举过该能力、现在又失败的 Server 会出现在 `_meta["toolhome/failed-servers"]` 中;当没有任何上游能提供列表数据时,列表返回协议错误。客户端取消请求时保持上游运行状态。独立入口返回自身上游的错误。
68
68
 
69
69
  聚合工具名为 `{server_slug}_{encoded_upstream_name}`,上游名称中除 ASCII 字母与数字外的 UTF-8 字节编码为 `-hh`(十六进制),例如 `github.search_code` 映射为 `github_search-5fcode`;超长名称使用稳定哈希缩短。Prompt 名称仍为 `{server_slug}.{upstream_name}`。未知扩展方法在聚合入口使用 `toolhome/{server_slug}/{upstream_method}`;独立入口原样透传。MCP App 若使用原始工具名,ToolHome 会根据 App 资源上下文或全局唯一名称路由;存在同名歧义时应使用独立入口。
70
70
 
@@ -194,6 +194,8 @@ ToolHome 的数据面也实现 OAuth 2.1:Harness 可通过 RFC 9728 元数据
194
194
 
195
195
  **本机范围。** Node-hosted Server 仍然只在所属机器上运行,本机 compact 目录只包含放置在该 node 的 Server。启动时本机入口会镜像控制面的工具可见性;若某个 Server 的投影读取失败,它会一直排除在 compact 目录之外(fail closed),直到下次启动或显式刷新。在控制面修改可见性后,本机入口需要重新连接才会生效。
196
196
 
197
+ **节点标识。** `toolhome mcp stdio` 会把本地镜像绑定到 `--node` 或 CLI 配置里的 `nodeId`,都没有时退回主机名。请保持该标识稳定:`auth login` 会保留它;若镜像是用别的标识建立的,启动时会直接说明它属于哪个节点标识,而不是只报一个密钥错误。
198
+
197
199
  **回退到 full。** 在 Host 上把 `TOOLHOME_MCP_TOOL_MODE` 设回 `full`(或删除该变量),或去掉 `toolhome mcp stdio --tool-mode`,然后重新连接客户端。无需数据迁移:full 会清除 compact 镜像写入的可见性行,重新暴露全部启用工具。
198
200
 
199
201
  **定义稳定。** 聚合 `tools/list` 输出规范化后的对象键顺序,数组保持原顺序,因此仅等价 schema 对象的键顺序变化时,序列化字节完全一致。把工具定义放在模型请求前部的客户端可以复用前缀;真实的定义变化仍会改变字节。独立入口保持上游原始键顺序。
@@ -261,6 +263,27 @@ npm run cli -- config import backup.json
261
263
 
262
264
  备份文件包含明文 Secret,应使用与主密钥同等级别的保护。为避免 Secret 意外进入终端日志,`--include-secrets` 必须同时提供目标文件;CLI 会在写入后强制设置 `0600`。日常审阅可省略 `--include-secrets`。
263
265
 
266
+ 导出覆盖两个平面:MCP 服务器(含放置位置)、Hosted CLI(含运行方式与允许列表)、加密凭据,以及 Market 台账。`config import --mode merge` 让恢复变得幂等:凭据按名称复用,目标已有的记录跳过,加 `--upsert` 才逐字段更新;`--dry-run` 返回同样的逐条报告但不写入;默认的 `create` 模式仍然在冲突时中止并列出全部冲突项。
267
+
268
+ ## 迁移
269
+
270
+ `toolhome migrate` 把一个实例复制到另一个实例,适合把笔记本上的实例搬到云端主机:
271
+
272
+ ```bash
273
+ npm run cli -- migrate --to https://cloud.example.com --to-key "$TOOLHOME_CONTROL_KEY" --dry-run
274
+ npm run cli -- migrate --to https://cloud.example.com --to-key "$TOOLHOME_CONTROL_KEY" \
275
+ --with-state --issue-access-key cloud
276
+ ```
277
+
278
+ 步骤顺序是刻意的。先在目标实例按 pin 住的版本重装 Market 条目,所需的取值从导出的凭据里提取,因此目标实例保留自己的安装台账和版本追踪;随后配置导入以 merge 模式执行,不动 Market 已经建好的记录。
279
+
280
+ 有两样东西无法自行跨实例,命令会如实列出而不是假装完成:
281
+
282
+ - **access key** 只存摘要。在目标实例重新签发(`--issue-access-key <name>` 会签发并打印 harness 片段)。
283
+ - **Hosted CLI 的登录态**存放在各产品自己的位置——`gh` 在 macOS 钥匙串,`az` 在其状态卷内的令牌缓存。`--with-state` 搬运状态卷;`toolhome cli adopt <slug>` 用该 CLI 自己的命令在本机提取登录态并写进目标实例的卷;`toolhome cli sync` 遍历所有已安装条目。
284
+
285
+ 二进制不参与迁移,也不需要参与:macOS 上的 `gh` 是 Mach-O 可执行文件,而 gateway 运行 Alpine Linux,目标实例会按 catalog 重建自己的 pin 版本制品。逐条结果是 `adopted`、`already-authenticated`、`credential-only`(取值随导出一起走)、`manual`(条目会给出目标端要执行的命令)或 `failed`。
286
+
264
287
  ## 配置
265
288
 
266
289
  | 环境变量 | 说明 | 默认值 |
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hand a locally authenticated platform CLI over to a ToolHome instance.
3
+ *
4
+ * A hosted CLI's login state belongs to the product, not to ToolHome: `gh` keeps
5
+ * its token in the macOS keychain, `az` in a token cache, others in a container
6
+ * volume on the target. Each catalog entry therefore declares how its state
7
+ * moves, and this walks that declaration once per entry. The extracted secret is
8
+ * written to the target's stdin and never printed, logged or returned.
9
+ */
10
+ export interface ControlApi {
11
+ request(method: string, path: string, body?: unknown, timeoutMs?: number): Promise<unknown>;
12
+ execStream(slug: string, input: {
13
+ argv: string[];
14
+ stdin?: string | null;
15
+ timeoutMs?: number;
16
+ }, onFrame: (frame: {
17
+ type: string;
18
+ data?: string;
19
+ }) => void, signal?: AbortSignal): Promise<{
20
+ code: number | null;
21
+ result: string;
22
+ }>;
23
+ }
24
+ export interface LocalCommandResult {
25
+ code: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ }
29
+ export type LocalRunner = (command: string, args: string[]) => Promise<LocalCommandResult>;
30
+ export interface AdoptOutcome {
31
+ slug: string;
32
+ status: 'adopted' | 'already-authenticated' | 'credential-only' | 'manual' | 'undeclared' | 'failed';
33
+ detail?: string;
34
+ }
35
+ export interface AdoptOptions {
36
+ slugs?: string[];
37
+ dryRun?: boolean;
38
+ force?: boolean;
39
+ runLocal?: LocalRunner;
40
+ log?: (line: string) => void;
41
+ }
42
+ export declare function adoptCliCredentials(target: ControlApi, options?: AdoptOptions): Promise<AdoptOutcome[]>;
43
+ //# sourceMappingURL=adopt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adopt.d.ts","sourceRoot":"","sources":["../../../src/cli/adopt.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5F,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,EACpE,OAAO,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,EACzD,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;AAE3F,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,GAAG,uBAAuB,GAAG,iBAAiB,GAAG,QAAQ,GAAG,YAAY,GAAG,QAAQ,CAAC;IACrG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAyBD,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,UAAU,EAClB,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,YAAY,EAAE,CAAC,CAkHzB"}
@@ -0,0 +1,170 @@
1
+ import { z } from 'zod';
2
+ const localStateSchema = z
3
+ .object({
4
+ extract: z.object({ command: z.string(), args: z.array(z.string()) }).optional(),
5
+ import: z.object({ args: z.array(z.string()) }).optional(),
6
+ verify: z.object({ args: z.array(z.string()), expect: z.string() }).optional(),
7
+ target: z.string().optional(),
8
+ credentialOnly: z.boolean().optional(),
9
+ manual: z.string().optional(),
10
+ })
11
+ .passthrough();
12
+ const entrySchema = z
13
+ .object({
14
+ id: z.string(),
15
+ name: z.string(),
16
+ plane: z.enum(['mcp', 'cli']),
17
+ installed: z.boolean().default(false),
18
+ localState: localStateSchema.optional(),
19
+ })
20
+ .passthrough();
21
+ const statusSchema = z.object({ loggedIn: z.boolean() }).passthrough();
22
+ export async function adoptCliCredentials(target, options = {}) {
23
+ const runLocal = options.runLocal ?? defaultRunner;
24
+ const log = options.log ?? (() => { });
25
+ const catalog = z
26
+ .array(entrySchema)
27
+ .parse(await target.request('GET', '/api/v1/market'))
28
+ .filter((entry) => entry.plane === 'cli' && entry.installed);
29
+ const wanted = options.slugs === undefined ? catalog : catalog.filter((entry) => options.slugs.includes(entry.id));
30
+ const outcomes = [];
31
+ for (const entry of wanted) {
32
+ const state = entry.localState;
33
+ if (state === undefined) {
34
+ outcomes.push({
35
+ slug: entry.id,
36
+ status: 'undeclared',
37
+ detail: 'this catalog entry declares no local-state policy',
38
+ });
39
+ continue;
40
+ }
41
+ if (state.manual !== undefined) {
42
+ outcomes.push({ slug: entry.id, status: 'manual', detail: state.manual });
43
+ continue;
44
+ }
45
+ if (state.credentialOnly === true || state.extract === undefined || state.import === undefined) {
46
+ outcomes.push({
47
+ slug: entry.id,
48
+ status: 'credential-only',
49
+ detail: 'authenticates from credential values, which already travel with the configuration',
50
+ });
51
+ continue;
52
+ }
53
+ if (!options.force && (await authenticationState(target, entry.id, state.verify)) === 'yes') {
54
+ outcomes.push({ slug: entry.id, status: 'already-authenticated' });
55
+ continue;
56
+ }
57
+ if (options.dryRun === true) {
58
+ outcomes.push({
59
+ slug: entry.id,
60
+ status: 'adopted',
61
+ detail: `would run \`${state.extract.command} ${state.extract.args.join(' ')}\` and send the result to ${state.target ?? entry.id}`,
62
+ });
63
+ continue;
64
+ }
65
+ let secret;
66
+ try {
67
+ const extracted = await runLocal(state.extract.command, state.extract.args);
68
+ if (extracted.code !== 0 || extracted.stdout.trim() === '') {
69
+ outcomes.push({
70
+ slug: entry.id,
71
+ status: 'failed',
72
+ detail: extracted.stderr.trim() !== ''
73
+ ? `local extraction failed: ${extracted.stderr.trim().split('\n')[0]}`
74
+ : `local extraction returned nothing (exit ${String(extracted.code)})`,
75
+ });
76
+ continue;
77
+ }
78
+ secret = extracted.stdout.trim();
79
+ }
80
+ catch (error) {
81
+ outcomes.push({
82
+ slug: entry.id,
83
+ status: 'failed',
84
+ detail: `local extraction failed: ${error instanceof Error ? error.message : String(error)}`,
85
+ });
86
+ continue;
87
+ }
88
+ let stderr = '';
89
+ try {
90
+ await target.execStream(entry.id, { argv: state.import.args, stdin: `${secret}\n` }, (frame) => {
91
+ if (frame.type === 'stderr' && frame.data !== undefined)
92
+ stderr += frame.data;
93
+ });
94
+ }
95
+ catch (error) {
96
+ outcomes.push({
97
+ slug: entry.id,
98
+ status: 'failed',
99
+ detail: error instanceof Error ? error.message : String(error),
100
+ });
101
+ continue;
102
+ }
103
+ const confirmed = await authenticationState(target, entry.id, state.verify);
104
+ if (confirmed === 'no') {
105
+ outcomes.push({
106
+ slug: entry.id,
107
+ status: 'failed',
108
+ detail: stderr.trim() !== ''
109
+ ? `the target rejected the credential: ${stderr.trim().split('\n')[0]}`
110
+ : 'the target still reports no login state',
111
+ });
112
+ continue;
113
+ }
114
+ log(`${entry.id}: credential accepted`);
115
+ outcomes.push({
116
+ slug: entry.id,
117
+ status: 'adopted',
118
+ detail: confirmed === 'unknown'
119
+ ? 'handed over, but this entry reports no login state to confirm it'
120
+ : state.verify === undefined
121
+ ? 'reported by the status probe'
122
+ : `verified with \`${state.verify.args.join(' ')}\``,
123
+ });
124
+ }
125
+ return outcomes;
126
+ }
127
+ /**
128
+ * Ask the target whether the credential works: yes, no, or it cannot say.
129
+ *
130
+ * A `status` probe answers `loggedIn` only when the product prints that marker,
131
+ * and most platform CLIs do not (`gh --version` says nothing about login), so an
132
+ * entry can declare a `verify` command instead. A probe that reports no markers
133
+ * is inconclusive — never evidence of failure, and never a reason to skip an
134
+ * upload that is still needed.
135
+ */
136
+ async function authenticationState(target, slug, verify) {
137
+ if (verify !== undefined) {
138
+ let stdout = '';
139
+ await target.execStream(slug, { argv: verify.args }, (frame) => {
140
+ if (frame.type === 'stdout' && frame.data !== undefined)
141
+ stdout += frame.data;
142
+ });
143
+ return stdout.includes(verify.expect) ? 'yes' : 'no';
144
+ }
145
+ const status = statusSchema.parse(await target.request('GET', `/cli/${slug}/status`));
146
+ if (status.loggedIn)
147
+ return 'yes';
148
+ return status.installed === true && status.version === null ? 'unknown' : 'no';
149
+ }
150
+ const defaultRunner = async (command, args) => {
151
+ const { spawn } = await import('node:child_process');
152
+ return new Promise((resolve) => {
153
+ const child = spawn(command, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
154
+ let stdout = '';
155
+ let stderr = '';
156
+ child.stdout.on('data', (chunk) => {
157
+ stdout += chunk.toString('utf8');
158
+ });
159
+ child.stderr.on('data', (chunk) => {
160
+ stderr += chunk.toString('utf8');
161
+ });
162
+ child.on('error', (error) => {
163
+ resolve({ code: null, stdout, stderr: error.message });
164
+ });
165
+ child.on('close', (code) => {
166
+ resolve({ code, stdout, stderr });
167
+ });
168
+ });
169
+ };
170
+ //# sourceMappingURL=adopt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adopt.js","sourceRoot":"","sources":["../../../src/cli/adopt.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,8 @@ import { stdioTransportSchema } from '../domain/models.js';
12
12
  import { launchEnvironment, spawnStdioServer } from '../node/launch.js';
13
13
  import { prepareLocalGateway, startLocalGateway } from '../node/local-gateway.js';
14
14
  import { readNodeStatus } from '../node/status.js';
15
+ import { adoptCliCredentials } from './adopt.js';
16
+ import { migrateConfiguration } from './migrate.js';
15
17
  function packageVersion() {
16
18
  let directory = dirname(fileURLToPath(import.meta.url));
17
19
  for (;;) {
@@ -90,6 +92,62 @@ cli
90
92
  .command('add <file>')
91
93
  .description('Register a hosted CLI from a JSON file or - for stdin')
92
94
  .action(run((client, file) => client.request('POST', '/api/v1/clis', readJson(file))));
95
+ cli
96
+ .command('adopt [slug]')
97
+ .description('Move a locally authenticated CLI login into this ToolHome instance')
98
+ .option('--to <url>', 'target instance URL (default: the configured instance)')
99
+ .option('--to-key <key>', 'target Control Key')
100
+ .option('--dry-run', 'report what would be handed over without doing it')
101
+ .option('--force', 'hand the credential over even when the instance already has a login')
102
+ .action(run(async (client, slug, options) => {
103
+ const target = targetClient(client, options);
104
+ return adoptCliCredentials(target, {
105
+ ...(slug === undefined ? {} : { slugs: [slug] }),
106
+ dryRun: options.dryRun === true,
107
+ force: options.force === true,
108
+ log: (line) => process.stderr.write(`${line}\n`),
109
+ });
110
+ }));
111
+ cli
112
+ .command('export-state <slug>')
113
+ .description('Write a hosted CLI container volume to a file (or - for stdout)')
114
+ .requiredOption('--volume <name>', 'declared volume name, e.g. toolhome-gh-cli-state')
115
+ .option('--to <url>', 'instance URL (default: the configured instance)')
116
+ .option('--to-key <key>', 'Control Key for --to')
117
+ .action(run(async (client, slug, options) => {
118
+ const exported = z
119
+ .object({ archive: z.string(), bytes: z.number() })
120
+ .parse(await targetClient(client, options).request('GET', `/api/v1/clis/${encodeURIComponent(slug)}/state?volume=${encodeURIComponent(options.volume)}`, undefined, 120_000));
121
+ return exported;
122
+ }));
123
+ cli
124
+ .command('import-state <slug> <file>')
125
+ .description('Replace a hosted CLI container volume from a file written by export-state')
126
+ .requiredOption('--volume <name>', 'declared volume name, e.g. toolhome-gh-cli-state')
127
+ .option('--to <url>', 'instance URL (default: the configured instance)')
128
+ .option('--to-key <key>', 'Control Key for --to')
129
+ .action(run(async (client, slug, file, options) => {
130
+ const body = readJson(file);
131
+ if (typeof body.archive !== 'string') {
132
+ throw new Error('The file must be the output of `toolhome cli export-state`');
133
+ }
134
+ return targetClient(client, options).request('POST', `/api/v1/clis/${encodeURIComponent(slug)}/state?volume=${encodeURIComponent(options.volume)}`, { archive: body.archive }, 120_000);
135
+ }));
136
+ cli
137
+ .command('sync')
138
+ .description('Adopt every locally authenticated CLI this instance has installed')
139
+ .option('--to <url>', 'target instance URL (default: the configured instance)')
140
+ .option('--to-key <key>', 'target Control Key')
141
+ .option('--dry-run', 'report what would be handed over without doing it')
142
+ .option('--force', 'hand credentials over even when the instance already has a login')
143
+ .action(run(async (client, options) => {
144
+ const target = targetClient(client, options);
145
+ return adoptCliCredentials(target, {
146
+ dryRun: options.dryRun === true,
147
+ force: options.force === true,
148
+ log: (line) => process.stderr.write(`${line}\n`),
149
+ });
150
+ }));
93
151
  cli
94
152
  .command('update <id> <file>')
95
153
  .description('Update a hosted CLI from a JSON file or - for stdin')
@@ -232,9 +290,15 @@ mcp
232
290
  if (stopped)
233
291
  return;
234
292
  stopped = true;
293
+ // Closing waits on children, the recorder and the local mirror; bound it,
294
+ // because a shutdown that hangs leaves a process holding node.sqlite that
295
+ // only SIGKILL can remove — the client has already given up by then.
296
+ const deadline = setTimeout(() => process.exit(0), 5_000);
297
+ deadline.unref();
235
298
  // Children first: they leak if the process exits before they are reaped.
236
299
  await gateway.close().catch(() => undefined);
237
300
  await handle.close().catch(() => undefined);
301
+ clearTimeout(deadline);
238
302
  process.exit(0);
239
303
  };
240
304
  process.stdin.on('end', () => void stop());
@@ -391,7 +455,16 @@ config
391
455
  }));
392
456
  config
393
457
  .command('import <file>')
394
- .action(run((client, file) => client.request('POST', '/api/v1/config/import', readJson(file))));
458
+ .description('Apply a configuration export; --mode merge keeps records the target already has')
459
+ .option('--mode <mode>', 'create or merge', 'create')
460
+ .option('--upsert', 'update records the target already has (merge mode)')
461
+ .option('--dry-run', 'report what would change without writing anything')
462
+ .action(run((client, file, options) => client.request('POST', '/api/v1/config/import', {
463
+ ...readJson(file),
464
+ mode: options.mode,
465
+ upsert: options.upsert === true,
466
+ dryRun: options.dryRun === true,
467
+ })));
395
468
  config
396
469
  .command('import-harness <file>')
397
470
  .description('Import a harness mcpServers config (Claude Desktop / Cursor JSON); secrets become encrypted credentials')
@@ -402,6 +475,41 @@ config
402
475
  preview: options.preview ?? false,
403
476
  ...(options.upsert ? { mode: 'upsert' } : {}),
404
477
  })));
478
+ program
479
+ .command('migrate')
480
+ .description('Copy this instance into another ToolHome instance (both planes)')
481
+ .requiredOption('--to <url>', 'target instance URL')
482
+ .requiredOption('--to-key <key>', 'target Control Key')
483
+ .option('--planes <planes>', 'mcp, cli or both', 'mcp,cli')
484
+ .option('--dry-run', 'report what would change without writing to the target')
485
+ .option('--with-state', 'move each hosted CLI state volume, including its login')
486
+ .option('--reset-oauth-clients', 'clear OAuth client registrations bound to this origin')
487
+ .option('--issue-access-key <name>', 'issue an MCP Access Key on the target and print it')
488
+ .action(run(async (client, options) => {
489
+ const planes = options.planes
490
+ .split(',')
491
+ .map((plane) => plane.trim())
492
+ .filter((plane) => plane !== '');
493
+ for (const plane of planes) {
494
+ if (plane !== 'mcp' && plane !== 'cli') {
495
+ throw new Error(`Unknown plane "${plane}"; use mcp, cli or both`);
496
+ }
497
+ }
498
+ const connection = resolveConnection(program.opts());
499
+ const target = new ControlClient(new URL(options.to), options.toKey);
500
+ const report = await migrateConfiguration(client, target, {
501
+ planes: { mcp: planes.includes('mcp'), cli: planes.includes('cli') },
502
+ dryRun: options.dryRun === true,
503
+ withState: options.withState === true,
504
+ resetOauthClients: options.resetOauthClients === true,
505
+ ...(options.issueAccessKey === undefined
506
+ ? {}
507
+ : { accessKeyName: options.issueAccessKey }),
508
+ log: (line) => process.stderr.write(`${line}\n`),
509
+ });
510
+ process.stderr.write(`source ${connection.url} → target ${options.to}\n`);
511
+ return report;
512
+ }));
405
513
  const endpoint = program.command('endpoint').description('Print standard MCP endpoints');
406
514
  endpoint
407
515
  .command('aggregate')
@@ -531,8 +639,15 @@ auth
531
639
  .action((options) => {
532
640
  const url = options.url ?? program.opts().url;
533
641
  if (!url)
534
- throw new Error('required option \'--url <url>\' not specified');
535
- const value = localConfigSchema.parse({ url, controlKey: options.controlKey });
642
+ throw new Error("required option '--url <url>' not specified");
643
+ // Logging in replaces the connection settings only: the node label decides
644
+ // the local mirror's key, and dropping it silently broke `mcp stdio`.
645
+ const existing = loadLocalConfig();
646
+ const value = localConfigSchema.parse({
647
+ url,
648
+ controlKey: options.controlKey,
649
+ ...(existing?.nodeId === undefined ? {} : { nodeId: existing.nodeId }),
650
+ });
536
651
  const path = configPath();
537
652
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
538
653
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
@@ -572,6 +687,14 @@ function run(action) {
572
687
  }
573
688
  };
574
689
  }
690
+ /** Reuse the connection the command already resolved unless the caller names another instance. */
691
+ function targetClient(client, options) {
692
+ if (options.to === undefined)
693
+ return client;
694
+ if (options.toKey === undefined)
695
+ throw new Error('--to-key is required with --to');
696
+ return new ControlClient(new URL(options.to), options.toKey);
697
+ }
575
698
  function resolveConnection(options) {
576
699
  const stored = loadLocalConfig();
577
700
  return localConfigSchema.parse({