agent-trellis 0.6.3 → 0.8.0

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 (107) hide show
  1. package/README.md +97 -38
  2. package/dist/adapters/claude-code.js +3 -2
  3. package/dist/adapters/codex.js +3 -2
  4. package/dist/adapters/jsonMcp.d.ts +3 -1
  5. package/dist/adapters/jsonMcp.js +18 -4
  6. package/dist/adapters/kimi-code.d.ts +21 -0
  7. package/dist/adapters/kimi-code.js +107 -0
  8. package/dist/adapters/kiro.js +3 -2
  9. package/dist/adapters/mcpPlan.d.ts +26 -2
  10. package/dist/adapters/mcpPlan.js +135 -15
  11. package/dist/adapters/pi.js +22 -3
  12. package/dist/adapters/symlinkPlan.d.ts +7 -0
  13. package/dist/adapters/symlinkPlan.js +16 -7
  14. package/dist/cli.js +129 -20
  15. package/dist/commands/doctor.d.ts +7 -2
  16. package/dist/commands/doctor.js +38 -2
  17. package/dist/commands/init.d.ts +4 -2
  18. package/dist/commands/init.js +26 -13
  19. package/dist/commands/kimi.d.ts +16 -0
  20. package/dist/commands/kimi.js +64 -0
  21. package/dist/commands/manage.d.ts +39 -0
  22. package/dist/commands/manage.js +126 -0
  23. package/dist/commands/mcp.d.ts +22 -1
  24. package/dist/commands/mcp.js +65 -2
  25. package/dist/commands/mcpGateway.d.ts +6 -0
  26. package/dist/commands/mcpGateway.js +34 -14
  27. package/dist/commands/mcpImport.d.ts +27 -0
  28. package/dist/commands/mcpImport.js +336 -0
  29. package/dist/commands/memory.d.ts +5 -3
  30. package/dist/commands/memory.js +12 -8
  31. package/dist/commands/migrate.d.ts +37 -7
  32. package/dist/commands/migrate.js +253 -27
  33. package/dist/commands/onboard.d.ts +74 -2
  34. package/dist/commands/onboard.js +463 -72
  35. package/dist/commands/onboardVerdict.js +20 -7
  36. package/dist/commands/rollback.js +4 -1
  37. package/dist/commands/secretsAudit.d.ts +21 -12
  38. package/dist/commands/secretsAudit.js +39 -10
  39. package/dist/commands/skill.d.ts +17 -0
  40. package/dist/commands/skill.js +47 -2
  41. package/dist/commands/sync.js +2 -0
  42. package/dist/core/canonical.d.ts +58 -4
  43. package/dist/core/canonical.js +183 -11
  44. package/dist/core/capabilitySelection.d.ts +45 -0
  45. package/dist/core/capabilitySelection.js +124 -0
  46. package/dist/core/types.d.ts +25 -2
  47. package/dist/core/types.js +7 -0
  48. package/dist/lib/backup.d.ts +2 -0
  49. package/dist/lib/backup.js +4 -0
  50. package/dist/lib/builtinSkills.d.ts +2 -0
  51. package/dist/lib/builtinSkills.js +4 -0
  52. package/dist/lib/cliMetadata.d.ts +1 -0
  53. package/dist/lib/cliMetadata.js +14 -0
  54. package/dist/lib/gatewayBackend.d.ts +28 -0
  55. package/dist/lib/gatewayBackend.js +72 -1
  56. package/dist/lib/installAgent.d.ts +7 -5
  57. package/dist/lib/installAgent.js +4 -3
  58. package/dist/lib/mcpConnect.js +5 -1
  59. package/dist/lib/mcpMigrateRead.d.ts +1 -0
  60. package/dist/lib/mcpMigrateRead.js +3 -0
  61. package/dist/lib/mcpRuntime.d.ts +46 -0
  62. package/dist/lib/mcpRuntime.js +185 -0
  63. package/dist/lib/mcpStatusProvider.d.ts +10 -0
  64. package/dist/lib/mcpStatusProvider.js +25 -0
  65. package/dist/lib/mcpToolRegistry.d.ts +18 -0
  66. package/dist/lib/mcpToolRegistry.js +107 -14
  67. package/dist/lib/memoryGraph.d.ts +1 -1
  68. package/dist/lib/memoryGraph.js +9 -3
  69. package/dist/lib/memoryProvider.d.ts +58 -0
  70. package/dist/lib/memoryProvider.js +175 -0
  71. package/dist/lib/memoryStatus.d.ts +17 -0
  72. package/dist/lib/memoryStatus.js +88 -0
  73. package/dist/lib/nativeMemory.d.ts +19 -0
  74. package/dist/lib/nativeMemory.js +129 -0
  75. package/dist/lib/runtimeControlProvider.d.ts +16 -0
  76. package/dist/lib/runtimeControlProvider.js +168 -0
  77. package/dist/lib/secretEnv.d.ts +17 -0
  78. package/dist/lib/secretEnv.js +31 -1
  79. package/dist/lib/skillProvider.d.ts +13 -0
  80. package/dist/lib/skillProvider.js +210 -0
  81. package/dist/lib/taskProvider.d.ts +7 -0
  82. package/dist/lib/taskProvider.js +43 -0
  83. package/dist/lib/taskStore.d.ts +46 -0
  84. package/dist/lib/taskStore.js +156 -0
  85. package/dist/lib/terminalPicker.d.ts +23 -28
  86. package/dist/lib/terminalPicker.js +54 -164
  87. package/dist/pi-bridge/bundle.js +493 -51
  88. package/dist/pi-bridge/index.js +42 -21
  89. package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
  90. package/dist/pi-bridge/schemaTranslate.js +4 -8
  91. package/dist/probes/kimi-code.d.ts +12 -0
  92. package/dist/probes/kimi-code.js +55 -0
  93. package/dist/sdk.d.ts +1 -1
  94. package/docs/architecture.md +224 -26
  95. package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
  96. package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
  97. package/docs/assets/trellis-mark.svg +12 -0
  98. package/docs/getting-started.md +403 -35
  99. package/docs/overview.md +131 -0
  100. package/docs/overview.zh-CN.md +100 -0
  101. package/docs/research.md +59 -0
  102. package/docs/roadmap.md +10 -5
  103. package/package.json +5 -3
  104. package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
  105. package/schema/capability-selection.example.yaml +23 -0
  106. package/schema/scope.example.yaml +1 -1
  107. package/schema/servers.example.yaml +15 -1
@@ -17,21 +17,32 @@
17
17
  * ever returns create/repair + refuse.
18
18
  */
19
19
  import { isInScope } from "../core/adapter.js";
20
+ import { capabilityDeliveryForAgent } from "../core/types.js";
20
21
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
21
22
  import { resolveSecretEnv } from "../lib/secretEnv.js";
22
23
  export const HUB_ENTRY_NAME = "trellis-hub";
23
- export const GATEWAY_ENTRY_NAME = "trellis-gateway";
24
+ export const GATEWAY_ENTRY_NAME = "trellis";
25
+ export const LEGACY_GATEWAY_ENTRY_NAME = "trellis-gateway";
26
+ export const RUNTIME_ENTRY_NAME = "trellis";
27
+ export const LEGACY_RUNTIME_ENTRY_NAME = "trellis-runtime";
24
28
  /** The command an agent spawns in gateway mode. Bare `trellis` rather than
25
29
  * an absolute path: the entry has to keep working across reinstalls and
26
30
  * version bumps, and every agent resolves it through the same PATH the
27
31
  * user installed the CLI onto. */
28
32
  export const GATEWAY_COMMAND = "trellis";
33
+ export const RUNTIME_COMMAND = "trellis";
34
+ export function isOAuthMcpServer(def) {
35
+ return def.auth === "oauth";
36
+ }
29
37
  /**
30
38
  * Gateway mode applies to every managed agent when `agents` is omitted —
31
39
  * the expected normal case (design.md D14). An explicit list narrows it,
32
40
  * letting some agents stay in direct or hub mode.
33
41
  */
34
42
  export function isGatewayAgent(agentId, mcp, managedAgents) {
43
+ const route = mcp.routes?.[agentId];
44
+ if (route)
45
+ return route.mode === "gateway";
35
46
  const gateway = mcp.gateway;
36
47
  if (!gateway?.enabled)
37
48
  return false;
@@ -39,6 +50,16 @@ export function isGatewayAgent(agentId, mcp, managedAgents) {
39
50
  return gateway.agents.includes(agentId);
40
51
  return managedAgents.includes(agentId);
41
52
  }
53
+ function routeForAgent(agentId, mcp, managedAgents) {
54
+ const explicit = mcp.routes?.[agentId];
55
+ if (explicit)
56
+ return explicit;
57
+ if (isGatewayAgent(agentId, mcp, managedAgents))
58
+ return { mode: "gateway" };
59
+ if (mcp.hub)
60
+ return { mode: "hub" };
61
+ return { mode: "direct" };
62
+ }
42
63
  const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
43
64
  function collisionMessage(name, agentId) {
44
65
  return `refusing to write MCP server "${name}": also appears in known_host_injected.${agentId === "codex" ? CODEX_STDIO_URL_CRASH_NOTE : ""}`;
@@ -71,13 +92,23 @@ const DANGEROUS_LITERAL_PATTERNS = [
71
92
  * read, diffed, and committed as plain text. One shared implementation,
72
93
  * not two that can drift, for the same reason `mcpConnect.ts`/
73
94
  * `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
95
+ *
96
+ * Scanning order is `command, url, args, headers, staticEnv` — a def
97
+ * matching in more than one field returns whichever is found first, same
98
+ * as before this function reported a field at all.
74
99
  */
75
100
  export function findLiteralSecret(def) {
76
- const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
77
- for (const candidate of candidates) {
101
+ const candidates = [
102
+ ...(def.command !== undefined ? [{ value: def.command, field: "command" }] : []),
103
+ ...(def.url !== undefined ? [{ value: def.url, field: "url" }] : []),
104
+ ...(def.args ?? []).map((value) => ({ value, field: "args" })),
105
+ ...Object.entries(def.headers ?? {}).map(([key, value]) => ({ value, field: "headers", key })),
106
+ ...Object.entries(def.staticEnv ?? {}).map(([key, value]) => ({ value, field: "staticEnv", key })),
107
+ ];
108
+ for (const { value, field, key } of candidates) {
78
109
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
79
- if (pattern.test(candidate)) {
80
- return label;
110
+ if (pattern.test(value)) {
111
+ return { label, field, key };
81
112
  }
82
113
  }
83
114
  }
@@ -106,54 +137,143 @@ function findUnresolvedEnvName(def, policy) {
106
137
  return names.find((name) => !resolved[name]);
107
138
  }
108
139
  export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
140
+ const route = routeForAgent(agentId, mcp, managedAgents);
141
+ const runtimeEnabled = capabilityDeliveryForAgent(agentId, mcp) !== "native";
109
142
  // Checked before `hub`: only one entry can be "the" MCP entry for an
110
143
  // agent, and gateway is the newer, purpose-built path (design.md D6).
111
144
  // `hub` stays available, unchanged, for anyone pointing an agent at an
112
145
  // externally-operated endpoint — including a different agent in the
113
146
  // same canonical source.
114
- if (isGatewayAgent(agentId, mcp, managedAgents)) {
115
- if (mcp.knownHostInjected.includes(GATEWAY_ENTRY_NAME)) {
147
+ if (route.mode === "gateway") {
148
+ const entryName = runtimeEnabled ? RUNTIME_ENTRY_NAME : GATEWAY_ENTRY_NAME;
149
+ if (mcp.knownHostInjected.includes(entryName)) {
116
150
  return {
117
151
  desired: [],
118
- conflicts: [{ name: GATEWAY_ENTRY_NAME, message: collisionMessage(GATEWAY_ENTRY_NAME, agentId), remediation: collisionRemediation(GATEWAY_ENTRY_NAME) }],
152
+ conflicts: [{ name: entryName, message: collisionMessage(entryName, agentId), remediation: collisionRemediation(entryName) }],
119
153
  };
120
154
  }
155
+ const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isOAuthMcpServer);
156
+ // Every ordinary per-server check (enabled, scope, literal secrets,
157
+ // unresolved env names, Codex's header shape) still runs at gateway
158
+ // startup. OAuth servers are the deliberate exception: they are written
159
+ // as direct entries so the gateway never owns their client connection.
121
160
  // Every per-server check (enabled, scope, literal secrets, unresolved
122
161
  // env names, Codex's header shape) still runs — but at gateway
123
162
  // *startup*, inside the subcommand, since no adapter sees an
124
163
  // individual server in this mode (design.md D6).
125
164
  return {
126
- desired: [{ name: GATEWAY_ENTRY_NAME, def: { transport: "stdio", command: GATEWAY_COMMAND, args: ["mcp-gateway", "--agent", agentId] } }],
127
- conflicts: [],
165
+ desired: [{
166
+ name: entryName,
167
+ def: {
168
+ transport: "stdio",
169
+ command: runtimeEnabled ? RUNTIME_COMMAND : GATEWAY_COMMAND,
170
+ args: [runtimeEnabled ? "mcp-runtime" : "mcp-gateway", "--agent", agentId],
171
+ },
172
+ }, ...oauth.desired],
173
+ conflicts: oauth.conflicts,
128
174
  };
129
175
  }
130
- if (mcp.hub) {
176
+ if (route.mode === "hub") {
177
+ if (!mcp.hub) {
178
+ return {
179
+ desired: [],
180
+ conflicts: [{
181
+ name: HUB_ENTRY_NAME,
182
+ message: `cannot resolve hub route "${agentId}": canonical mcp.hub.url is not configured`,
183
+ remediation: `configure mcp.hub.url or choose direct/gateway mode for ${agentId}`,
184
+ }],
185
+ };
186
+ }
131
187
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
132
188
  return {
133
189
  desired: [],
134
190
  conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId), remediation: collisionRemediation(HUB_ENTRY_NAME) }],
135
191
  };
136
192
  }
137
- return { desired: [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }], conflicts: [] };
193
+ if (route.servers) {
194
+ return {
195
+ desired: [],
196
+ conflicts: [{
197
+ name: HUB_ENTRY_NAME,
198
+ message: `cannot restrict external hub route "${agentId}" to individual MCP servers: the hub owns its upstream set`,
199
+ remediation: `remove the servers list from the ${agentId} hub route, or use direct/gateway mode for per-server selection`,
200
+ }],
201
+ };
202
+ }
203
+ const desired = [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }];
204
+ if (runtimeEnabled) {
205
+ if (mcp.knownHostInjected.includes(RUNTIME_ENTRY_NAME)) {
206
+ return {
207
+ desired: [],
208
+ conflicts: [{ name: RUNTIME_ENTRY_NAME, message: collisionMessage(RUNTIME_ENTRY_NAME, agentId), remediation: collisionRemediation(RUNTIME_ENTRY_NAME) }],
209
+ };
210
+ }
211
+ // The runtime provides Trellis-owned capabilities here. The external
212
+ // hub remains the upstream MCP edge; the runtime does not connect to
213
+ // canonical upstreams a second time for this route.
214
+ desired.push({
215
+ name: RUNTIME_ENTRY_NAME,
216
+ def: { transport: "stdio", command: RUNTIME_COMMAND, args: ["mcp-runtime", "--agent", agentId] },
217
+ });
218
+ }
219
+ return { desired, conflicts: [] };
138
220
  }
221
+ if (runtimeEnabled) {
222
+ if (mcp.knownHostInjected.includes(RUNTIME_ENTRY_NAME)) {
223
+ return {
224
+ desired: [],
225
+ conflicts: [{ name: RUNTIME_ENTRY_NAME, message: collisionMessage(RUNTIME_ENTRY_NAME, agentId), remediation: collisionRemediation(RUNTIME_ENTRY_NAME) }],
226
+ };
227
+ }
228
+ // Runtime mode owns the agent-facing MCP edge. It resolves the same
229
+ // direct/gateway-compatible upstream route at startup, so native
230
+ // per-server entries are not duplicated here.
231
+ return {
232
+ desired: [{
233
+ name: RUNTIME_ENTRY_NAME,
234
+ def: { transport: "stdio", command: RUNTIME_COMMAND, args: ["mcp-runtime", "--agent", agentId] },
235
+ }],
236
+ conflicts: [],
237
+ };
238
+ }
239
+ return planDirectServers(agentId, mcp, managedAgents, policy, route);
240
+ }
241
+ function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
139
242
  const desired = [];
140
243
  const conflicts = [];
141
244
  for (const [name, def] of Object.entries(mcp.servers)) {
142
245
  if (def.enabled === false) {
143
246
  continue;
144
247
  }
248
+ if (filter && !filter(def))
249
+ continue;
145
250
  if (!isInScope(agentId, def.agents, managedAgents)) {
146
251
  continue;
147
252
  }
253
+ if (route.servers && !route.servers.includes(name)) {
254
+ continue;
255
+ }
148
256
  if (mcp.knownHostInjected.includes(name)) {
149
257
  conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
150
258
  continue;
151
259
  }
152
- const secretLabel = findLiteralSecret(def);
153
- if (secretLabel) {
260
+ // Only `staticEnv` is refused here: it's the one shape a real
261
+ // credential is meant to leave via `env`/`env_vars`, proven to
262
+ // resolve back to a real value for every consumer (pi-bridge,
263
+ // claude-code, codex, kiro) — a literal surviving there means
264
+ // something bypassed `migrate`'s own extraction (a hand-edited
265
+ // servers.yaml, most likely) and must still be refused outright. A
266
+ // match in `command`/`url`/`args`/`headers` is accepted and written
267
+ // as ordinary literal config instead (trellis-migrate-extract-static-env-secrets
268
+ // design.md D11) — none of those fields has a `${VAR}` resolution
269
+ // mechanism proven across every consumer, so a fake reference there
270
+ // would be strictly worse than the literal it replaced; `secrets
271
+ // audit` keeps flagging canonical itself so this isn't silent.
272
+ const secretMatch = findLiteralSecret(def);
273
+ if (secretMatch?.field === "staticEnv") {
154
274
  conflicts.push({
155
275
  name,
156
- message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
276
+ message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretMatch.label}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
157
277
  remediation: `replace the literal value in servers.yaml with a \`\${VAR_NAME}\` reference and put the real value wherever secrets.policy.yaml resolves it from`,
158
278
  });
159
279
  continue;
@@ -9,13 +9,31 @@
9
9
  * mechanism from every other agent's config-generation adapter, but the
10
10
  * same symlink create/repair/remove machinery as skills/instructions.
11
11
  */
12
- import { dirname, join } from "node:path";
12
+ import { dirname, join, sep } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { homedir } from "node:os";
15
+ import { readFileSync } from "node:fs";
15
16
  import { isInScope } from "../core/adapter.js";
17
+ import { usesNativeCapabilityDelivery } from "../core/types.js";
16
18
  import * as piProbe from "../probes/pi.js";
17
19
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
18
20
  const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
21
+ /**
22
+ * A source-checkout bridge can survive a switch to the packaged CLI. Adopt it
23
+ * only when it has the Trellis bridge artifact shape and is byte-identical
24
+ * to the bundle this installation would write. This is intentionally not a
25
+ * general symlink ownership rule.
26
+ */
27
+ function isEquivalentTrellisBridge(existingTarget, desiredTarget) {
28
+ if (!existingTarget.endsWith(`${sep}${join("dist", "pi-bridge", "bundle.js")}`))
29
+ return false;
30
+ try {
31
+ return readFileSync(existingTarget).equals(readFileSync(desiredTarget));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
19
37
  /**
20
38
  * The bundled bridge file's location — always `<repo-root>/dist/pi-bridge/
21
39
  * bundle.js`, never `~/.trellis/` (it's Trellis's own packaged code, not a
@@ -56,7 +74,7 @@ export class PiAdapter {
56
74
  const canonicalRoot = dirname(canonical.instructionsFile);
57
75
  const agentDir = join(this.homeDir, ".pi", "agent");
58
76
  const desiredSkills = canonical.skills
59
- .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
77
+ .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
60
78
  .map((skill) => ({ name: skill.name, target: skill.dir }));
61
79
  const skillItems = planSymlinks({
62
80
  rootDir: join(agentDir, "skills"),
@@ -79,6 +97,7 @@ export class PiAdapter {
79
97
  // (design.md D2), same role `canonicalRoot` plays for skills.
80
98
  canonicalRoot: dirname(bridgeFile),
81
99
  kind: "extension",
100
+ adoptExistingSymlink: isEquivalentTrellisBridge,
82
101
  });
83
102
  return [...skillItems, ...instructionsItems, ...extensionItems];
84
103
  }
@@ -91,7 +110,7 @@ export class PiAdapter {
91
110
  return { ok: false, mismatches: ["pi is not present on this machine"] };
92
111
  }
93
112
  const mismatches = [];
94
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
113
+ const desiredNames = new Set(canonical.skills.filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
95
114
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
96
115
  for (const name of desiredNames) {
97
116
  if (!actualSkills.has(name)) {
@@ -12,6 +12,12 @@ export interface DesiredSymlink {
12
12
  /** Absolute path the symlink should point at (inside `canonicalRoot`). */
13
13
  target: string;
14
14
  }
15
+ /**
16
+ * A narrowly-scoped escape hatch for artifacts that Trellis can identify
17
+ * across installation roots. The default ownership rule remains path-based;
18
+ * callers must opt in explicitly for a particular artifact kind.
19
+ */
20
+ export type ExistingSymlinkAdoption = (existingTarget: string, desiredTarget: string) => boolean;
15
21
  /**
16
22
  * `rootDir` is the directory entries are named directly under (e.g.
17
23
  * `~/.claude/skills`, or the instructions file's own parent directory when
@@ -28,6 +34,7 @@ export declare function planSymlinks(opts: {
28
34
  desired: DesiredSymlink[];
29
35
  canonicalRoot: string;
30
36
  kind: "skill" | "instructions" | "extension";
37
+ adoptExistingSymlink?: ExistingSymlinkAdoption;
31
38
  }): AdapterPlanItem[];
32
39
  /** Executes a `planSymlinks` result through the run's backup session
33
40
  * (trellis-backup-rollback) — every create/repair/remove is recorded
@@ -4,9 +4,9 @@
4
4
  * trellis-sync-p1/specs/skill-instructions-sync/spec.md) — built once,
5
5
  * reused by every adapter rather than reimplemented per agent.
6
6
  */
7
- import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
7
+ import { lstatSync, readdirSync, readlinkSync } from "node:fs";
8
8
  import { mkdir } from "node:fs/promises";
9
- import { join, resolve, sep } from "node:path";
9
+ import { dirname, join, resolve, sep } from "node:path";
10
10
  import { isSymlinkTo } from "../lib/fsIdentity.js";
11
11
  import { currentLinkTarget } from "../lib/backup.js";
12
12
  function isUnderRoot(path, root) {
@@ -29,7 +29,7 @@ function isUnderRoot(path, root) {
29
29
  * case this removal path exists to catch.
30
30
  */
31
31
  export function planSymlinks(opts) {
32
- const { rootDir, desired, kind } = opts;
32
+ const { rootDir, desired, kind, adoptExistingSymlink } = opts;
33
33
  const items = [];
34
34
  const desiredNames = new Set(desired.map((d) => d.name));
35
35
  const canonicalRootResolved = resolve(opts.canonicalRoot);
@@ -38,8 +38,15 @@ export function planSymlinks(opts) {
38
38
  if (isSymlinkTo(path, target)) {
39
39
  continue; // already correct — no-op
40
40
  }
41
- if (existsSync(path)) {
42
- const isSymlink = lstatSync(path).isSymbolicLink();
41
+ // lstat sees dangling links too; existsSync would treat them as absent
42
+ // and bypass the foreign-link protection below.
43
+ const existing = lstatSync(path, { throwIfNoEntry: false });
44
+ if (existing) {
45
+ const isSymlink = existing.isSymbolicLink();
46
+ const rawExistingTarget = isSymlink ? readlinkSync(path) : undefined;
47
+ const resolvedExistingTarget = rawExistingTarget === undefined
48
+ ? undefined
49
+ : resolve(dirname(path), rawExistingTarget);
43
50
  // A symlink whose stored target resolves outside canonicalRoot is
44
51
  // owned by something else (e.g. a user's own dotfile-management
45
52
  // setup) — repairing it as if it were a stale Trellis entry would
@@ -47,14 +54,16 @@ export function planSymlinks(opts) {
47
54
  // symlink Trellis itself left pointing at a since-removed canonical
48
55
  // entry is broken by construction and must still be treated as
49
56
  // ours to repair, not thrown out to a conflict by a realpath error.
50
- const isForeign = isSymlink && !isUnderRoot(readlinkSync(path), canonicalRootResolved);
57
+ const isForeign = resolvedExistingTarget !== undefined
58
+ && !isUnderRoot(resolvedExistingTarget, canonicalRootResolved)
59
+ && adoptExistingSymlink?.(resolvedExistingTarget, target) !== true;
51
60
  if (!isSymlink || isForeign) {
52
61
  items.push({
53
62
  action: "conflict",
54
63
  kind,
55
64
  target: path,
56
65
  description: isForeign
57
- ? `${path} exists as a symlink to ${readlinkSync(path)}, not owned by Trellis — left untouched`
66
+ ? `${path} exists as a symlink to ${rawExistingTarget}, not owned by Trellis — left untouched`
58
67
  : `${path} exists and is not a Trellis-managed symlink — left untouched`,
59
68
  remediation: isForeign
60
69
  ? `remove the existing symlink at ${path} if you want Trellis to manage it, then re-run sync — otherwise leave it, Trellis will not touch it`
package/dist/cli.js CHANGED
@@ -9,22 +9,33 @@ import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
- import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from "./commands/mcp.js";
13
- import { parseMcpGatewayArgs, runMcpGateway } from "./commands/mcpGateway.js";
12
+ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, parseMcpAddArgs } from "./commands/mcp.js";
13
+ import { runMcpImport } from "./commands/mcpImport.js";
14
+ import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
14
15
  import { runMcpAuth } from "./commands/mcpAuth.js";
15
16
  import { runSecretsAudit } from "./commands/secretsAudit.js";
16
17
  import { runRollback } from "./commands/rollback.js";
17
- import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
18
+ import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
18
19
  import { runMemoryExtraction, runMemorySync } from "./commands/memory.js";
20
+ import { parseManageArgs, runManage } from "./commands/manage.js";
21
+ import { runKimi } from "./commands/kimi.js";
22
+ import { TRELLIS_VERSION } from "./lib/cliMetadata.js";
19
23
  import { parseSyncArgs } from "./lib/syncArgs.js";
20
- const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "skill", "memory", "secrets", "rollback"];
24
+ const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "mcp-runtime", "skill", "memory", "manage", "kimi", "secrets", "rollback"];
21
25
  function printUsage() {
22
26
  console.log(`trellis - a single source of capability for every coding agent
23
27
 
24
28
  Usage:
25
29
  trellis <command>
30
+ trellis --version
31
+ trellis --help
32
+
33
+ Options:
34
+ -v, --version print the installed Trellis version
35
+ -h, --help show this help
26
36
 
27
37
  Commands:
38
+ help Show this help
28
39
  onboard Guided flow: init -> detect agents -> pick a migration source ->
29
40
  pick which agents to manage -> migrate -> sync -> mcp sync ->
30
41
  secrets audit, in one command. Source (read from) and managed
@@ -39,15 +50,21 @@ Commands:
39
50
  (required with no terminal to prompt
40
51
  in, e.g. --json)
41
52
  --mcp-mode <direct|hub|gateway>
42
- change the MCP mode; omit to leave
43
- whatever's already configured
44
- untouched (never prompted)
45
- --hub-url <url> required with --mcp-mode hub
53
+ direct = Agent 分别直连;gateway =
54
+ Trellis 本机统一托管(推荐);hub =
55
+ 连接已有的外部 MCP Hub
56
+ --hub-url <url> required with --mcp-mode hub; Trellis
57
+ does not deploy an external Hub
46
58
  --gateway-agents <ids>
47
59
  optional with --mcp-mode gateway;
48
60
  omit for every managed agent
49
61
  --memory <on|off> enable/disable the shared memory MCP
50
62
  server; omit to leave it untouched
63
+ --memory-migrate <on|off>
64
+ import supported native Agent memory into
65
+ canonical; omit to leave it untouched
66
+ --selection <file> item-level YAML/JSON selection for skills,
67
+ MCP servers, memories, and per-agent routes
51
68
  --dry-run preview the whole flow, write nothing
52
69
  --json machine-readable output, no report text
53
70
  init Create ~/.trellis/ with a minimal valid skeleton if missing
@@ -55,12 +72,13 @@ Commands:
55
72
  missing) and prints which agents are present
56
73
  --json machine-readable output, no report text
57
74
  migrate --from <agent>
58
- Import an existing agent's real skills/instructions/MCP
75
+ Import an existing agent's real skills/instructions/MCP/memory
59
76
  servers into canonical source (claude-code | codex | kiro |
60
77
  pi — pi has no static MCP config, mcp migrate-in is a no-op
61
78
  for it). Never overwrites differing content — reports a
62
79
  conflict instead.
63
- --only skills|instructions|mcp restrict to one category
80
+ --only skills|instructions|mcp|memory
81
+ restrict to one category
64
82
  --dry-run preview the plan, write nothing
65
83
  --json machine-readable output, no report text
66
84
  doctor Scan Claude Code / Codex / Kiro / pi for drift
@@ -83,12 +101,22 @@ Commands:
83
101
  --json machine-readable output, no report text
84
102
  mcp add <name> --transport stdio|http|sse ...
85
103
  Add a canonical MCP server (refuses on an existing name,
86
- no overwrite): --command <cmd> [--args a,b] (stdio) or
87
- --url <url> (http/sse); [--headers k=v,...]
88
- [--env NAME,...] [--static-env k=v,...] [--agents id,...]
89
- [--enabled true|false]
90
- --dry-run preview the plan, write nothing
91
- --json machine-readable output, no report text
104
+ no overwrite): --command <cmd> [--args a,b] (stdio) or
105
+ --url <url> (http/sse); [--headers k=v,...]
106
+ [--auth oauth] [--env NAME,...] [--static-env k=v,...] [--agents id,...]
107
+ [--enabled true|false]
108
+ --dry-run preview the plan, write nothing
109
+ --json machine-readable output, no report text
110
+ mcp import <json-file>
111
+ Import a standard mcpServers JSON export without changing the
112
+ source; extracts credential env values into the ignored local
113
+ secret file, de-duplicates, and reports conflicts
114
+ --dry-run preview without writing
115
+ --json machine-readable output, no report text
116
+ mcp set <name> --auth oauth|none
117
+ Set or clear explicit OAuth classification; canonical only
118
+ --dry-run preview the plan, write nothing
119
+ --json machine-readable output, no report text
92
120
  mcp auth <server-name>
93
121
  Authorize a remote (http/sse) MCP server that uses OAuth —
94
122
  opens a browser once, stores the result 0600 under
@@ -97,6 +125,10 @@ Commands:
97
125
  refreshes silently on its own and never prompts.
98
126
  --force re-authorize even if the stored token is valid
99
127
  --json machine-readable output, no report text
128
+ mcp-runtime --agent <agent>
129
+ Internal MCP edge exposing Trellis providers and selected
130
+ upstream capabilities; existing mcp-gateway configs remain valid
131
+ as a compatibility alias
100
132
  mcp remove <name>
101
133
  Remove a canonical MCP server (canonical-side only — does
102
134
  not touch any agent's already-synced native config)
@@ -111,6 +143,11 @@ Commands:
111
143
  --dry-run preview the plan, write nothing
112
144
  --json machine-readable output, no report text
113
145
  skill remove <name>
146
+ skill update-builtin
147
+ Refresh the package-owned trellis-runtime Skill in canonical
148
+ source; existing native symlinks see the update
149
+ --dry-run preview without writing
150
+ --json machine-readable output, no report text
114
151
  Remove a canonical skill (the next sync auto-removes the
115
152
  now-stale symlink on every managed agent)
116
153
  --dry-run preview the plan, write nothing
@@ -132,6 +169,20 @@ Commands:
132
169
  round-trip byte-for-byte back through memory sync.
133
170
  --dry-run preview the plan, write nothing
134
171
  --json machine-readable output, no report text
172
+ manage list
173
+ Show the canonical managed-agent write boundary.
174
+ manage set <ids|none>
175
+ manage add <ids>
176
+ manage remove <ids>
177
+ Explicitly replace, extend, or narrow the managed set. Removing
178
+ an agent preserves its existing native state; future Trellis
179
+ writes stop reaching it. Mutations are backup/rollback-enabled.
180
+ --dry-run preview current -> desired, write nothing
181
+ --json machine-readable output
182
+ kimi [args...]
183
+ Launch Kimi Code. When kimi-code Runtime delivery is \`mcp\`,
184
+ starts Kimi with an empty --skills-dir so Skills come from
185
+ trellis-runtime instead of native discovery.
135
186
  secrets audit
136
187
  Scan each present agent's real MCP config for leaked
137
188
  credentials and unexpected env var names
@@ -148,18 +199,38 @@ Commands:
148
199
 
149
200
  See docs/roadmap.md for what's built vs. planned.`);
150
201
  }
202
+ function printVersion() {
203
+ console.log(TRELLIS_VERSION);
204
+ }
151
205
  async function main(argv) {
152
206
  const [command, ...rest] = argv;
153
207
  if (!command || command === "--help" || command === "-h") {
154
208
  printUsage();
155
209
  return;
156
210
  }
211
+ if (command === "--version" || command === "-v") {
212
+ printVersion();
213
+ return;
214
+ }
215
+ if (command === "help") {
216
+ printUsage();
217
+ return;
218
+ }
157
219
  if (!KNOWN_COMMANDS.includes(command)) {
158
220
  console.error(`Unknown command: ${command}\n`);
159
221
  printUsage();
160
222
  process.exitCode = 1;
161
223
  return;
162
224
  }
225
+ // `--help`/`-h` anywhere in a subcommand's own args short-circuits before
226
+ // any parsing or real action — a typo'd or exploratory flag (e.g. `mcp
227
+ // sync --help`) must never fall through to actually running the command
228
+ // it was asking about (real incident: `--help` isn't a flag any branch
229
+ // recognizes, so it silently ran a real, non-dry-run `mcp sync`).
230
+ if (command !== "kimi" && (rest.includes("--help") || rest.includes("-h"))) {
231
+ printUsage();
232
+ return;
233
+ }
163
234
  if (command === "onboard") {
164
235
  const agentIndex = rest.indexOf("--agent");
165
236
  const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
@@ -173,6 +244,10 @@ async function main(argv) {
173
244
  const gatewayAgents = gatewayAgentsIndex >= 0 ? rest[gatewayAgentsIndex + 1] : undefined;
174
245
  const memoryIndex = rest.indexOf("--memory");
175
246
  const memory = memoryIndex >= 0 ? rest[memoryIndex + 1] : undefined;
247
+ const memoryMigrateIndex = rest.indexOf("--memory-migrate");
248
+ const memoryMigrate = memoryMigrateIndex >= 0 ? rest[memoryMigrateIndex + 1] : undefined;
249
+ const selectionIndex = rest.indexOf("--selection");
250
+ const selectionFile = selectionIndex >= 0 ? rest[selectionIndex + 1] : undefined;
176
251
  const { exitCode } = await runOnboard({
177
252
  agent,
178
253
  manage,
@@ -180,6 +255,8 @@ async function main(argv) {
180
255
  hubUrl,
181
256
  gatewayAgents,
182
257
  memory,
258
+ memoryMigrate,
259
+ selectionFile,
183
260
  dryRun: rest.includes("--dry-run"),
184
261
  json: rest.includes("--json"),
185
262
  });
@@ -208,14 +285,16 @@ async function main(argv) {
208
285
  // Spawned by an agent, never typed by a person: stdout is the MCP
209
286
  // protocol stream for the rest of this process's life, so nothing here
210
287
  // may print to it (trellis-mcp-gateway-hosting design.md D2).
211
- if (command === "mcp-gateway") {
288
+ if (command === "mcp-gateway" || command === "mcp-runtime") {
212
289
  const parsed = parseMcpGatewayArgs(rest);
213
290
  if ("error" in parsed) {
214
291
  console.error(parsed.error);
215
292
  process.exitCode = 1;
216
293
  return;
217
294
  }
218
- const { exitCode } = await runMcpGateway({ agentId: parsed.agentId });
295
+ const { exitCode } = command === "mcp-runtime"
296
+ ? await runMcpRuntime({ agentId: parsed.agentId })
297
+ : await runMcpGateway({ agentId: parsed.agentId });
219
298
  process.exitCode = exitCode;
220
299
  return;
221
300
  }
@@ -249,6 +328,18 @@ async function main(argv) {
249
328
  process.exitCode = runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun }).exitCode;
250
329
  return;
251
330
  }
331
+ if (subcommand === "import") {
332
+ const [file] = mcpRest;
333
+ process.exitCode = runMcpImport(file, { json, dryRun }).exitCode;
334
+ return;
335
+ }
336
+ if (subcommand === "set") {
337
+ const [name] = mcpRest;
338
+ const authIndex = mcpRest.indexOf("--auth");
339
+ const auth = authIndex >= 0 ? mcpRest[authIndex + 1] : undefined;
340
+ process.exitCode = runMcpSetAuth(name, auth, { json, dryRun }).exitCode;
341
+ return;
342
+ }
252
343
  if (subcommand === "remove") {
253
344
  const [name] = mcpRest;
254
345
  if (!name) {
@@ -270,7 +361,7 @@ async function main(argv) {
270
361
  process.exitCode = exitCode;
271
362
  return;
272
363
  }
273
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>|auth <name>\n`);
364
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|remove <name>|auth <name>\n`);
274
365
  process.exitCode = 1;
275
366
  return;
276
367
  }
@@ -304,7 +395,11 @@ async function main(argv) {
304
395
  process.exitCode = runSkillRemove(name, { json, dryRun }).exitCode;
305
396
  return;
306
397
  }
307
- console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>\n`);
398
+ if (subcommand === "update-builtin") {
399
+ process.exitCode = runSkillUpdateBuiltin({ json, dryRun }).exitCode;
400
+ return;
401
+ }
402
+ console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|update-builtin\n`);
308
403
  process.exitCode = 1;
309
404
  return;
310
405
  }
@@ -324,6 +419,20 @@ async function main(argv) {
324
419
  process.exitCode = 1;
325
420
  return;
326
421
  }
422
+ if (command === "manage") {
423
+ const parsed = parseManageArgs(rest);
424
+ if ("error" in parsed) {
425
+ console.error(`${parsed.error}\nUsage: trellis manage list|set <ids|none>|add <ids>|remove <ids> [--dry-run] [--json]\n`);
426
+ process.exitCode = 1;
427
+ return;
428
+ }
429
+ process.exitCode = runManage(parsed).exitCode;
430
+ return;
431
+ }
432
+ if (command === "kimi") {
433
+ process.exitCode = (await runKimi(rest)).exitCode;
434
+ return;
435
+ }
327
436
  if (command === "secrets") {
328
437
  const [subcommand] = rest;
329
438
  if (subcommand !== "audit") {