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
@@ -42,12 +42,25 @@ export function normalizeMigrateVerdict(plan) {
42
42
  return [];
43
43
  const items = [];
44
44
  for (const item of plan.items) {
45
- if (item.action !== "conflict")
46
- continue;
47
- const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
48
- if (item.remediation)
49
- verdict.remediation = item.remediation;
50
- items.push(verdict);
45
+ if (item.action === "conflict") {
46
+ const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
47
+ if (item.remediation)
48
+ verdict.remediation = item.remediation;
49
+ items.push(verdict);
50
+ }
51
+ else if (item.action === "extract-secret") {
52
+ // The run succeeded — never `blocked` — but stands as a standing
53
+ // reminder, not a silent success: migrate never writes to the
54
+ // source agent's own file (design.md D2), so it still holds the
55
+ // real value in plaintext forever, independent of this extraction
56
+ // (trellis-migrate-extract-static-env-secrets design.md D8).
57
+ items.push({
58
+ stage: "migrate",
59
+ severity: "warning",
60
+ message: item.detail,
61
+ remediation: `the source agent's own configuration for "${item.name}" was not modified and may still hold "${item.extractVarName}"'s real value in plaintext — clean it up by hand if you want it gone from there too`,
62
+ });
63
+ }
51
64
  }
52
65
  return items;
53
66
  }
@@ -103,7 +116,7 @@ export function normalizeSecretsAuditVerdict(report) {
103
116
  return [];
104
117
  return report.findings.map((finding) => {
105
118
  const item = { stage: "secrets audit", severity: "blocked", message: `${finding.file}: ${finding.detail}` };
106
- if (finding.agent !== "environment")
119
+ if (finding.agent !== "environment" && finding.agent !== "canonical")
107
120
  item.agent = finding.agent;
108
121
  return item;
109
122
  });
@@ -134,7 +134,10 @@ async function restoreOperation(homeDir, runId, op) {
134
134
  }
135
135
  export async function applyRollbackPlan(homeDir, runId, manifest, items) {
136
136
  const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
137
- for (const op of manifest.operations) {
137
+ // Restore in reverse write order. A single onboarding transaction may
138
+ // update servers.yaml several times (mode, memory, routes); replaying
139
+ // restores forward would leave the file at an intermediate snapshot.
140
+ for (const op of [...manifest.operations].reverse()) {
138
141
  if (!restorePaths.has(op.path))
139
142
  continue;
140
143
  await restoreOperation(homeDir, runId, op);
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * `trellis secrets audit` — reads every present, MCP-capable agent's real
3
- * config file (never the canonical source) and checks it against
4
- * `CanonicalSource.secretsPolicy`: a literal-value scan against
5
- * `rejectPatterns`, and a declared-env-var-name check against
6
- * `allowedVars`. Also checks, agent-agnostically, whether every env var
7
- * name declared across canonical `mcp.servers[*].env` actually resolves
8
- * to a value via the same `resolveSecretEnv` the pi bridge uses
9
- * (trellis-secrets-env-management) — authoritative for pi, a best-effort
10
- * proxy for the other three (design.md D3 in that change). Read-only —
11
- * never writes anything. Fails non-zero on any finding
12
- * (trellis-secrets-audit-p3).
3
+ * config file and checks it against `CanonicalSource.secretsPolicy`: a
4
+ * literal-value scan against `rejectPatterns`, and a declared-env-var-name
5
+ * check against `allowedVars`. Also checks, agent-agnostically, whether
6
+ * every env var name declared across canonical `mcp.servers[*].env`
7
+ * actually resolves to a value via the same `resolveSecretEnv` the pi
8
+ * bridge uses (trellis-secrets-env-management) — authoritative for pi, a
9
+ * best-effort proxy for the other three (design.md D3 in that change).
10
+ *
11
+ * Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
12
+ * literal-value check (trellis-migrate-extract-static-env-secrets design.md
13
+ * D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
14
+ * canonical by `migrate` rather than refused (no natural name to extract
15
+ * to, or no `${VAR}` resolution proven for every consumer), so canonical
16
+ * is no longer guaranteed clean by construction the way it used to be —
17
+ * this keeps that acceptance from being silent. Never the
18
+ * unexpected-var-name check, which is about agent-native serialized
19
+ * `env`/`env_vars` syntax specifically. Read-only — never writes
20
+ * anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
13
21
  */
14
22
  import type { AgentId } from "../core/types.js";
15
23
  export interface RunSecretsAuditOptions {
@@ -23,8 +31,9 @@ export interface RunSecretsAuditOptions {
23
31
  }
24
32
  export interface SecretsFinding {
25
33
  /** "environment" for `missing-env-value` — that check isn't scoped to
26
- * any single agent's config file (see module doc comment). */
27
- agent: AgentId | "environment";
34
+ * any single agent's config file. "canonical" for a literal found in
35
+ * `~/.trellis/mcp/servers.yaml` itself (see module doc comment). */
36
+ agent: AgentId | "environment" | "canonical";
28
37
  file: string;
29
38
  kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
30
39
  detail: string;
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * `trellis secrets audit` — reads every present, MCP-capable agent's real
3
- * config file (never the canonical source) and checks it against
4
- * `CanonicalSource.secretsPolicy`: a literal-value scan against
5
- * `rejectPatterns`, and a declared-env-var-name check against
6
- * `allowedVars`. Also checks, agent-agnostically, whether every env var
7
- * name declared across canonical `mcp.servers[*].env` actually resolves
8
- * to a value via the same `resolveSecretEnv` the pi bridge uses
9
- * (trellis-secrets-env-management) — authoritative for pi, a best-effort
10
- * proxy for the other three (design.md D3 in that change). Read-only —
11
- * never writes anything. Fails non-zero on any finding
12
- * (trellis-secrets-audit-p3).
3
+ * config file and checks it against `CanonicalSource.secretsPolicy`: a
4
+ * literal-value scan against `rejectPatterns`, and a declared-env-var-name
5
+ * check against `allowedVars`. Also checks, agent-agnostically, whether
6
+ * every env var name declared across canonical `mcp.servers[*].env`
7
+ * actually resolves to a value via the same `resolveSecretEnv` the pi
8
+ * bridge uses (trellis-secrets-env-management) — authoritative for pi, a
9
+ * best-effort proxy for the other three (design.md D3 in that change).
10
+ *
11
+ * Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
12
+ * literal-value check (trellis-migrate-extract-static-env-secrets design.md
13
+ * D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
14
+ * canonical by `migrate` rather than refused (no natural name to extract
15
+ * to, or no `${VAR}` resolution proven for every consumer), so canonical
16
+ * is no longer guaranteed clean by construction the way it used to be —
17
+ * this keeps that acceptance from being silent. Never the
18
+ * unexpected-var-name check, which is about agent-native serialized
19
+ * `env`/`env_vars` syntax specifically. Read-only — never writes
20
+ * anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
13
21
  */
14
22
  import { existsSync, readFileSync } from "node:fs";
15
23
  import { homedir } from "node:os";
@@ -57,6 +65,26 @@ function findMissingEnvValues(servers, policy) {
57
65
  detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
58
66
  }));
59
67
  }
68
+ /**
69
+ * Reject-pattern-only scan of canonical's own `mcp/servers.yaml` (design.md
70
+ * D11) — no `unexpected-var-name` check here, that one is specifically
71
+ * about names extracted from an agent's own serialized `env`/`env_vars`
72
+ * syntax, not canonical's `env:` name list (which holding a name matching
73
+ * a reject pattern would be an absurd false positive, not a real finding).
74
+ */
75
+ function auditCanonicalServersYaml(homeDir, policy) {
76
+ const file = join(homeDir, ".trellis", "mcp", "servers.yaml");
77
+ if (!existsSync(file))
78
+ return [];
79
+ const content = readFileSync(file, "utf-8");
80
+ const findings = [];
81
+ for (const pattern of policy.rejectPatterns) {
82
+ if (pattern.test(content)) {
83
+ findings.push({ agent: "canonical", file, kind: "literal-secret", detail: `matches reject pattern ${pattern}` });
84
+ }
85
+ }
86
+ return findings;
87
+ }
60
88
  function auditFile(agent, file, content, policy, extractNames) {
61
89
  const findings = [];
62
90
  for (const pattern of policy.rejectPatterns) {
@@ -88,6 +116,7 @@ export async function collectSecretsAuditReport(opts = {}) {
88
116
  findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
89
117
  }
90
118
  findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
119
+ findings.push(...auditCanonicalServersYaml(homeDir, canonical.secretsPolicy));
91
120
  return { findings };
92
121
  }
93
122
  export async function runSecretsAudit(opts = {}) {
@@ -8,6 +8,23 @@
8
8
  * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
9
  */
10
10
  import type { AgentId } from "../core/types.js";
11
+ import { type BackupSession } from "../lib/backup.js";
12
+ export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
13
+ export interface BuiltinSkillUpdatePlan {
14
+ name: "trellis-runtime";
15
+ action: BuiltinSkillUpdateAction;
16
+ detail: string;
17
+ path: string;
18
+ }
19
+ export declare function collectBuiltinSkillUpdatePlan(homeDir?: string): BuiltinSkillUpdatePlan;
20
+ export declare function applyBuiltinSkillUpdatePlan(plan: BuiltinSkillUpdatePlan, backup?: BackupSession): void;
21
+ export declare function runSkillUpdateBuiltin(opts?: {
22
+ homeDir?: string;
23
+ json?: boolean;
24
+ dryRun?: boolean;
25
+ }): {
26
+ exitCode: number;
27
+ };
11
28
  export interface SkillListEntry {
12
29
  name: string;
13
30
  scope: readonly AgentId[];
@@ -7,16 +7,56 @@
7
7
  * `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
8
8
  * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
9
  */
10
- import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
10
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
13
15
  import { decideDirImport } from "../lib/dirEquals.js";
14
16
  import { findSkillFile } from "../lib/skillFile.js";
15
17
  import { loadCanonicalSource } from "../core/canonical.js";
16
18
  import { resolveScope } from "../core/types.js";
19
+ import { isBuiltinSkillName } from "../lib/builtinSkills.js";
20
+ import { openBackupSession } from "../lib/backup.js";
21
+ function builtinSkillTemplate() {
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
24
+ }
25
+ export function collectBuiltinSkillUpdatePlan(homeDir = homedir()) {
26
+ const path = join(homeDir, ".trellis", "skills", "trellis-runtime", "SKILL.md");
27
+ if (!existsSync(path))
28
+ return { name: "trellis-runtime", action: "create", detail: "will install the package-owned Runtime Skill", path };
29
+ if (readFileSync(path, "utf8") === builtinSkillTemplate())
30
+ return { name: "trellis-runtime", action: "already-current", detail: "package-owned Runtime Skill is already current", path };
31
+ return { name: "trellis-runtime", action: "update", detail: "will refresh the package-owned Runtime Skill; existing Agent symlinks will see the update", path };
32
+ }
33
+ export function applyBuiltinSkillUpdatePlan(plan, backup) {
34
+ if (plan.action === "already-current")
35
+ return;
36
+ const content = builtinSkillTemplate();
37
+ mkdirSync(dirname(plan.path), { recursive: true });
38
+ if (backup)
39
+ backup.writeFile(plan.path, content);
40
+ else
41
+ writeFileSync(plan.path, content);
42
+ }
43
+ export function runSkillUpdateBuiltin(opts = {}) {
44
+ const homeDir = opts.homeDir ?? homedir();
45
+ const plan = collectBuiltinSkillUpdatePlan(homeDir);
46
+ if (!opts.dryRun && plan.action !== "already-current") {
47
+ const backup = openBackupSession(homeDir, "skill-update-builtin");
48
+ applyBuiltinSkillUpdatePlan(plan, backup);
49
+ backup.finalize();
50
+ }
51
+ if (opts.json)
52
+ console.log(JSON.stringify(plan, null, 2));
53
+ else
54
+ console.log(`${opts.dryRun ? "[dry run] " : ""}skill update-builtin\n [${plan.action}] ${plan.detail}`);
55
+ return { exitCode: 0 };
56
+ }
17
57
  export function collectSkillList(homeDir = homedir()) {
18
58
  const canonical = loadCanonicalSource(homeDir);
19
- return canonical.skills.map((skill) => ({
59
+ return canonical.skills.filter((skill) => !isBuiltinSkillName(skill.name)).map((skill) => ({
20
60
  name: skill.name,
21
61
  scope: resolveScope(skill.scope, canonical.managedAgents),
22
62
  }));
@@ -37,6 +77,9 @@ export function runSkillList(opts = {}) {
37
77
  return { exitCode: 0 };
38
78
  }
39
79
  export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
80
+ if (isBuiltinSkillName(name)) {
81
+ return { name, action: "conflict", detail: `"${name}" is a Trellis package-owned Skill and cannot be replaced with skill add` };
82
+ }
40
83
  const skillFile = findSkillFile(fromPath);
41
84
  if (!skillFile) {
42
85
  return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
@@ -77,6 +120,8 @@ export function runSkillAdd(name, fromPath, opts = {}) {
77
120
  return { exitCode: plan.action === "conflict" || plan.action === "invalid-source" ? 1 : 0 };
78
121
  }
79
122
  export function collectSkillRemovePlan(name, homeDir = homedir()) {
123
+ if (isBuiltinSkillName(name))
124
+ return { name, action: "not-found" };
80
125
  const dir = join(homeDir, ".trellis", "skills", name);
81
126
  return { name, action: existsSync(dir) ? "removed" : "not-found" };
82
127
  }
@@ -11,12 +11,14 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
11
11
  import { CodexAdapter } from "../adapters/codex.js";
12
12
  import { KiroAdapter } from "../adapters/kiro.js";
13
13
  import { PiAdapter } from "../adapters/pi.js";
14
+ import { KimiCodeAdapter } from "../adapters/kimi-code.js";
14
15
  import { openBackupSession } from "../lib/backup.js";
15
16
  const ADAPTER_FACTORY = {
16
17
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
17
18
  codex: (homeDir) => new CodexAdapter(homeDir),
18
19
  kiro: (homeDir) => new KiroAdapter(homeDir),
19
20
  pi: (homeDir) => new PiAdapter(homeDir),
21
+ "kimi-code": (homeDir) => new KimiCodeAdapter(homeDir),
20
22
  };
21
23
  /** Only agents in `canonical.managedAgents` — an agent present on this
22
24
  * machine but not managed gets no adapter at all, not a zero-item plan
@@ -4,7 +4,8 @@
4
4
  * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
5
  * canonical-source-loading/spec.md for the exact contract this implements.
6
6
  */
7
- import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
7
+ import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef } from "./types.js";
8
+ import type { BackupSession } from "../lib/backup.js";
8
9
  /**
9
10
  * The on-disk shape for one server entry — `static_env`/`env_aliases`
10
11
  * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
@@ -27,10 +28,10 @@ export type ServersYamlWriteResult = {
27
28
  ok: false;
28
29
  error: string;
29
30
  };
30
- export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
31
+ export declare function upsertServerYaml(path: string, name: string, def: McpServerDef, backup?: BackupSession): ServersYamlWriteResult;
31
32
  /** Inverse of `upsertServerYaml` — same preservation guarantee, same
32
33
  * refusal posture on a missing/unparseable file. */
33
- export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
34
+ export declare function removeServerYaml(path: string, name: string, backup?: BackupSession): ServersYamlWriteResult;
34
35
  /**
35
36
  * `trellis onboard --mcp-mode <value>` (trellis-onboard-mcp-mode) —
36
37
  * everything needed to select or clear the top-level `hub`/`gateway`
@@ -56,7 +57,60 @@ export type McpMode = {
56
57
  * starter file). Selecting one mode always clears the other two
57
58
  * (design.md D3) — never left for the caller to remember.
58
59
  */
59
- export declare function writeMcpModeYaml(path: string, mode: McpMode): ServersYamlWriteResult;
60
+ export declare function writeMcpModeYaml(path: string, mode: McpMode, backup?: BackupSession): ServersYamlWriteResult;
61
+ export type McpRoutesWriteResult = ServersYamlWriteResult;
62
+ /** Writes explicit per-agent routes while preserving legacy hub/gateway
63
+ * shorthand. An empty route map removes the optional key. */
64
+ export declare function writeMcpRoutesYaml(path: string, routes: Partial<Record<AgentId, McpRoute>>, backup?: BackupSession): McpRoutesWriteResult;
65
+ /** Writes per-agent capability delivery preferences into servers.yaml.
66
+ * Omitted entries continue to use the native default. */
67
+ export declare function writeMcpRuntimeDeliveryYaml(path: string, delivery: Partial<Record<AgentId, CapabilityDelivery>>, backup?: BackupSession): ServersYamlWriteResult;
68
+ export type SecretsPolicyWriteResult = {
69
+ ok: true;
70
+ } | {
71
+ ok: false;
72
+ error: string;
73
+ };
74
+ /**
75
+ * `trellis migrate`'s static-env secret extraction
76
+ * (trellis-migrate-extract-static-env-secrets design.md D6) — the
77
+ * missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
78
+ * `Document`-based, comment-preserving mechanism. Sets `env_file` only
79
+ * when it is not already set — an already-configured value is a
80
+ * deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
81
+ * `env_file` as the sole source once set, so silently repointing it
82
+ * would orphan every name already resolving from the old file. Appends
83
+ * `varName` to `allowed_vars` only if not already present (dedup, same
84
+ * "already there is a no-op" rule every other Trellis writer follows).
85
+ */
86
+ export declare function writeSecretsPolicyExtraction(path: string, extraction: {
87
+ varName: string;
88
+ envFilePath: string;
89
+ }, backup?: BackupSession): SecretsPolicyWriteResult;
90
+ /**
91
+ * Ensures one exact line is present in a `.gitignore` file, creating the
92
+ * file if it doesn't exist yet — idempotent (a no-op if the line is
93
+ * already there), never disturbing any other line
94
+ * (trellis-migrate-extract-static-env-secrets design.md D5). Called
95
+ * lazily, immediately before the first real write to the local secrets
96
+ * file migrate's static-env extraction produces — never from `trellis
97
+ * init`'s own bootstrap, so a machine that never extracts a secret never
98
+ * gains this file at all.
99
+ */
100
+ export declare function ensureGitignoreEntry(gitignorePath: string, line: string, backup?: BackupSession): void;
101
+ /**
102
+ * Ensures a shell rc file sources the local secrets env file on every new
103
+ * shell — one generic pointer block, appended once, idempotent (detected
104
+ * by its own marker comment, never duplicated). Deliberately never writes
105
+ * a literal secret value into the rc file: the block only names the path
106
+ * to the real `NAME=value` file migrate's extraction already writes and
107
+ * protects — adding a new variable later means editing that one file, the
108
+ * rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
109
+ * design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
110
+ * lines `parseDotenv` already expects, so that format never needs an
111
+ * `export` prefix of its own.
112
+ */
113
+ export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
60
114
  /**
61
115
  * `homeDir` defaults to the real `~` and is only ever overridden for tests
62
116
  * and `scripts/sandbox.sh` — the same seam P0's probes use
@@ -4,10 +4,10 @@
4
4
  * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
5
  * canonical-source-loading/spec.md for the exact contract this implements.
6
6
  */
7
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { basename, join } from "node:path";
10
- import { isMap, parse as parseYaml, parseDocument } from "yaml";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
11
  import { ALL_AGENTS } from "./types.js";
12
12
  function fromServerDefYaml(def) {
13
13
  const { static_env, env_aliases, ...rest } = def;
@@ -89,8 +89,39 @@ function loadServersYaml(path) {
89
89
  knownHostInjected: parsed.known_host_injected ?? [],
90
90
  hub: parsed.hub,
91
91
  gateway: fromGatewayYaml(parsed.gateway),
92
+ routes: fromRoutesYaml(parsed.routes),
93
+ runtime: fromRuntimeYaml(parsed.runtime),
92
94
  };
93
95
  }
96
+ function fromRuntimeYaml(runtime) {
97
+ if (!runtime?.delivery)
98
+ return undefined;
99
+ const delivery = {};
100
+ for (const [rawAgent, rawDelivery] of Object.entries(runtime.delivery)) {
101
+ if (!ALL_AGENTS.includes(rawAgent))
102
+ continue;
103
+ if (rawDelivery !== "native" && rawDelivery !== "mcp" && rawDelivery !== "both")
104
+ continue;
105
+ delivery[rawAgent] = rawDelivery;
106
+ }
107
+ return Object.keys(delivery).length > 0 ? { delivery } : undefined;
108
+ }
109
+ function fromRoutesYaml(routes) {
110
+ if (!routes)
111
+ return undefined;
112
+ const out = {};
113
+ for (const [rawAgent, rawRoute] of Object.entries(routes)) {
114
+ if (!ALL_AGENTS.includes(rawAgent))
115
+ continue;
116
+ if (!rawRoute?.mode || !["direct", "gateway", "hub"].includes(rawRoute.mode))
117
+ continue;
118
+ out[rawAgent] = {
119
+ mode: rawRoute.mode,
120
+ ...(rawRoute.servers ? { servers: rawRoute.servers } : {}),
121
+ };
122
+ }
123
+ return Object.keys(out).length > 0 ? out : undefined;
124
+ }
94
125
  /**
95
126
  * A `gateway:` block with no `enabled` key reads as off, not as on — the
96
127
  * only safe default for a field whose whole effect is to stop writing the
@@ -126,12 +157,26 @@ function fromGatewayYaml(gateway) {
126
157
  * one-line, local fix — it never touches any sibling entry's own style,
127
158
  * so a file someone deliberately kept flow-style elsewhere is untouched.
128
159
  */
160
+ /** Handles both a map (`servers: {}`) and a sequence (`allowed_vars: []`)
161
+ * — `trellis init`'s starter files use flow style for both kinds of
162
+ * still-empty collection, and inserting into either one via `Document`
163
+ * methods keeps rendering it as flow otherwise (same real bug this
164
+ * function was first written to fix, just a second collection type
165
+ * hitting it — trellis-migrate-extract-static-env-secrets). */
129
166
  function forceBlockStyle(node) {
130
- if (isMap(node)) {
167
+ if (isMap(node) || isSeq(node)) {
131
168
  node.flow = false;
132
169
  }
133
170
  }
134
- export function upsertServerYaml(path, name, def) {
171
+ function persistText(path, content, backup) {
172
+ if (existsSync(path) && readFileSync(path, "utf-8") === content)
173
+ return;
174
+ if (backup)
175
+ backup.writeFile(path, content);
176
+ else
177
+ writeFileSync(path, content);
178
+ }
179
+ export function upsertServerYaml(path, name, def, backup) {
135
180
  if (!existsSync(path)) {
136
181
  return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
137
182
  }
@@ -145,12 +190,12 @@ export function upsertServerYaml(path, name, def) {
145
190
  doc.setIn(["servers", name], toServerDefYaml(def));
146
191
  forceBlockStyle(doc.get("servers", true));
147
192
  forceBlockStyle(doc.getIn(["servers", name], true));
148
- writeFileSync(path, doc.toString());
193
+ persistText(path, doc.toString(), backup);
149
194
  return { ok: true };
150
195
  }
151
196
  /** Inverse of `upsertServerYaml` — same preservation guarantee, same
152
197
  * refusal posture on a missing/unparseable file. */
153
- export function removeServerYaml(path, name) {
198
+ export function removeServerYaml(path, name, backup) {
154
199
  if (!existsSync(path)) {
155
200
  return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
156
201
  }
@@ -162,7 +207,7 @@ export function removeServerYaml(path, name) {
162
207
  return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
163
208
  }
164
209
  doc.deleteIn(["servers", name]);
165
- writeFileSync(path, doc.toString());
210
+ persistText(path, doc.toString(), backup);
166
211
  return { ok: true };
167
212
  }
168
213
  /**
@@ -175,7 +220,7 @@ export function removeServerYaml(path, name) {
175
220
  * starter file). Selecting one mode always clears the other two
176
221
  * (design.md D3) — never left for the caller to remember.
177
222
  */
178
- export function writeMcpModeYaml(path, mode) {
223
+ export function writeMcpModeYaml(path, mode, backup) {
179
224
  if (!existsSync(path)) {
180
225
  return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
181
226
  }
@@ -208,7 +253,56 @@ export function writeMcpModeYaml(path, mode) {
208
253
  forceBlockStyle(doc.get("gateway", true));
209
254
  break;
210
255
  }
211
- writeFileSync(path, doc.toString());
256
+ persistText(path, doc.toString(), backup);
257
+ return { ok: true };
258
+ }
259
+ /** Writes explicit per-agent routes while preserving legacy hub/gateway
260
+ * shorthand. An empty route map removes the optional key. */
261
+ export function writeMcpRoutesYaml(path, routes, backup) {
262
+ if (!existsSync(path)) {
263
+ return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
264
+ }
265
+ let doc;
266
+ try {
267
+ doc = parseDocument(readFileSync(path, "utf-8"));
268
+ }
269
+ catch (err) {
270
+ return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
271
+ }
272
+ const serialized = Object.fromEntries(Object.entries(routes).map(([agent, route]) => [agent, { mode: route.mode, ...(route.servers ? { servers: route.servers } : {}) }]));
273
+ if (Object.keys(serialized).length === 0) {
274
+ doc.delete("routes");
275
+ }
276
+ else {
277
+ doc.set("routes", serialized);
278
+ forceBlockStyle(doc.get("routes", true));
279
+ }
280
+ persistText(path, doc.toString(), backup);
281
+ return { ok: true };
282
+ }
283
+ /** Writes per-agent capability delivery preferences into servers.yaml.
284
+ * Omitted entries continue to use the native default. */
285
+ export function writeMcpRuntimeDeliveryYaml(path, delivery, backup) {
286
+ if (!existsSync(path)) {
287
+ return { ok: false, error: path + " does not exist — run trellis init first" };
288
+ }
289
+ let doc;
290
+ try {
291
+ doc = parseDocument(readFileSync(path, "utf-8"));
292
+ }
293
+ catch (err) {
294
+ return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
295
+ }
296
+ const serialized = Object.fromEntries(Object.entries(delivery).map(([agent, mode]) => [agent, mode]));
297
+ if (Object.keys(serialized).length === 0) {
298
+ doc.delete("runtime");
299
+ }
300
+ else {
301
+ doc.setIn(["runtime", "delivery"], serialized);
302
+ forceBlockStyle(doc.get("runtime", true));
303
+ forceBlockStyle(doc.getIn(["runtime", "delivery"], true));
304
+ }
305
+ persistText(path, doc.toString(), backup);
212
306
  return { ok: true };
213
307
  }
214
308
  function loadSecretsPolicyYaml(path, homeDir) {
@@ -222,8 +316,86 @@ function loadSecretsPolicyYaml(path, homeDir) {
222
316
  envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : undefined,
223
317
  };
224
318
  }
319
+ /**
320
+ * `trellis migrate`'s static-env secret extraction
321
+ * (trellis-migrate-extract-static-env-secrets design.md D6) — the
322
+ * missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
323
+ * `Document`-based, comment-preserving mechanism. Sets `env_file` only
324
+ * when it is not already set — an already-configured value is a
325
+ * deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
326
+ * `env_file` as the sole source once set, so silently repointing it
327
+ * would orphan every name already resolving from the old file. Appends
328
+ * `varName` to `allowed_vars` only if not already present (dedup, same
329
+ * "already there is a no-op" rule every other Trellis writer follows).
330
+ */
331
+ export function writeSecretsPolicyExtraction(path, extraction, backup) {
332
+ if (!existsSync(path)) {
333
+ return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
334
+ }
335
+ let doc;
336
+ try {
337
+ doc = parseDocument(readFileSync(path, "utf-8"));
338
+ }
339
+ catch (err) {
340
+ return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
341
+ }
342
+ if (doc.get("env_file") === undefined) {
343
+ doc.set("env_file", extraction.envFilePath);
344
+ }
345
+ // `.get()` returns the raw Seq node for a collection, not a plain
346
+ // array — `.toJS()` on the whole document is the reliable way to read
347
+ // a fully-unwrapped value back out before deciding whether to append.
348
+ const currentAllowedVars = (doc.toJS().allowed_vars ?? []);
349
+ if (!currentAllowedVars.includes(extraction.varName)) {
350
+ doc.set("allowed_vars", [...currentAllowedVars, extraction.varName]);
351
+ forceBlockStyle(doc.get("allowed_vars", true));
352
+ }
353
+ persistText(path, doc.toString(), backup);
354
+ return { ok: true };
355
+ }
356
+ /**
357
+ * Ensures one exact line is present in a `.gitignore` file, creating the
358
+ * file if it doesn't exist yet — idempotent (a no-op if the line is
359
+ * already there), never disturbing any other line
360
+ * (trellis-migrate-extract-static-env-secrets design.md D5). Called
361
+ * lazily, immediately before the first real write to the local secrets
362
+ * file migrate's static-env extraction produces — never from `trellis
363
+ * init`'s own bootstrap, so a machine that never extracts a secret never
364
+ * gains this file at all.
365
+ */
366
+ export function ensureGitignoreEntry(gitignorePath, line, backup) {
367
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
368
+ const lines = existing.split("\n").map((l) => l.trim());
369
+ if (lines.includes(line))
370
+ return;
371
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
372
+ persistText(gitignorePath, `${existing}${separator}${line}\n`, backup);
373
+ }
374
+ const SHELL_ENV_SOURCE_MARKER = "# >>> trellis mcp secrets >>>";
375
+ const SHELL_ENV_SOURCE_END_MARKER = "# <<< trellis mcp secrets <<<";
376
+ /**
377
+ * Ensures a shell rc file sources the local secrets env file on every new
378
+ * shell — one generic pointer block, appended once, idempotent (detected
379
+ * by its own marker comment, never duplicated). Deliberately never writes
380
+ * a literal secret value into the rc file: the block only names the path
381
+ * to the real `NAME=value` file migrate's extraction already writes and
382
+ * protects — adding a new variable later means editing that one file, the
383
+ * rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
384
+ * design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
385
+ * lines `parseDotenv` already expects, so that format never needs an
386
+ * `export` prefix of its own.
387
+ */
388
+ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
389
+ const existing = existsSync(rcPath) ? readFileSync(rcPath, "utf-8") : "";
390
+ if (existing.includes(SHELL_ENV_SOURCE_MARKER))
391
+ return;
392
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
393
+ const block = `${SHELL_ENV_SOURCE_MARKER}\nif [ -f "${envFilePath}" ]; then\n set -a\n source "${envFilePath}"\n set +a\nfi\n${SHELL_ENV_SOURCE_END_MARKER}\n`;
394
+ mkdirSync(dirname(rcPath), { recursive: true });
395
+ persistText(rcPath, `${existing}${separator}${block}`, backup);
396
+ }
225
397
  /** `undefined` if the name has no entry in scope.yaml's map — "shared with
226
- * all four agents," the default. A recognized but empty list is left as
398
+ * all managed agents by default. A recognized but empty list is left as
227
399
  * authored (an explicitly agent-less scope), not coerced to "all". */
228
400
  /** Missing file and `agents: []` both resolve to `[]` — zero managed
229
401
  * agents (D1), never "everyone." An unrecognized id is dropped with a