agent-trellis 0.1.0 → 0.3.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 (67) hide show
  1. package/README.md +69 -17
  2. package/dist/adapters/claude-code.d.ts +5 -3
  3. package/dist/adapters/claude-code.js +27 -14
  4. package/dist/adapters/codex.d.ts +8 -4
  5. package/dist/adapters/codex.js +47 -16
  6. package/dist/adapters/jsonMcp.d.ts +16 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +5 -3
  9. package/dist/adapters/kiro.js +29 -16
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +40 -7
  12. package/dist/adapters/pi.d.ts +2 -1
  13. package/dist/adapters/pi.js +4 -4
  14. package/dist/adapters/symlinkPlan.d.ts +7 -3
  15. package/dist/adapters/symlinkPlan.js +42 -16
  16. package/dist/cli.js +161 -18
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +114 -7
  19. package/dist/commands/mcp.js +258 -17
  20. package/dist/commands/memory.d.ts +39 -0
  21. package/dist/commands/memory.js +78 -0
  22. package/dist/commands/migrate.d.ts +30 -4
  23. package/dist/commands/migrate.js +83 -16
  24. package/dist/commands/onboard.d.ts +52 -7
  25. package/dist/commands/onboard.js +318 -35
  26. package/dist/commands/rollback.d.ts +44 -0
  27. package/dist/commands/rollback.js +201 -0
  28. package/dist/commands/secretsAudit.d.ts +7 -0
  29. package/dist/commands/secretsAudit.js +14 -7
  30. package/dist/commands/skill.d.ts +51 -0
  31. package/dist/commands/skill.js +104 -0
  32. package/dist/commands/sync.d.ts +13 -0
  33. package/dist/commands/sync.js +31 -5
  34. package/dist/core/adapter.d.ts +28 -11
  35. package/dist/core/adapter.js +2 -2
  36. package/dist/core/canonical.d.ts +26 -1
  37. package/dist/core/canonical.js +103 -3
  38. package/dist/core/types.d.ts +29 -1
  39. package/dist/core/types.js +11 -2
  40. package/dist/lib/backup.d.ts +56 -0
  41. package/dist/lib/backup.js +98 -0
  42. package/dist/lib/deepEqual.d.ts +8 -0
  43. package/dist/lib/deepEqual.js +26 -0
  44. package/dist/lib/dirEquals.d.ts +9 -0
  45. package/dist/lib/dirEquals.js +15 -1
  46. package/dist/lib/installAgent.d.ts +26 -0
  47. package/dist/lib/installAgent.js +46 -0
  48. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  49. package/dist/lib/mcpMigrateRead.js +188 -0
  50. package/dist/lib/mcpOwnership.d.ts +25 -0
  51. package/dist/lib/mcpOwnership.js +50 -0
  52. package/dist/lib/memoryGraph.d.ts +60 -0
  53. package/dist/lib/memoryGraph.js +101 -0
  54. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  55. package/dist/lib/realHomeSnapshot.js +77 -0
  56. package/dist/lib/terminalPicker.d.ts +45 -0
  57. package/dist/lib/terminalPicker.js +193 -0
  58. package/dist/lib/tomlSection.d.ts +20 -6
  59. package/dist/lib/tomlSection.js +78 -12
  60. package/dist/pi-bridge/bundle.js +100 -51
  61. package/dist/pi-bridge/index.js +14 -2
  62. package/dist/probes/codex.js +10 -2
  63. package/docs/architecture.md +7 -4
  64. package/docs/getting-started.md +267 -33
  65. package/docs/roadmap.md +444 -0
  66. package/package.json +1 -1
  67. package/schema/servers.example.yaml +39 -2
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `trellis rollback` — restores exactly what one recorded backup run
3
+ * changed (trellis-backup-rollback). Every operation is checked against
4
+ * its target path's *current* state before touching anything: if the
5
+ * path still matches what the run itself left behind, it's restored; if
6
+ * something else has touched it since, that's a conflict, reported and
7
+ * left untouched — same "verify, never guess" posture `sync`/`mcp sync`
8
+ * already hold themselves to for every other kind of conflict.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { readlink, rm, symlink } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { backupsRoot } from "../lib/backup.js";
16
+ function sha256(content) {
17
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
18
+ }
19
+ /** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
20
+ * chronological sort. */
21
+ export function listBackups(homeDir = homedir()) {
22
+ const root = backupsRoot(homeDir);
23
+ if (!existsSync(root))
24
+ return [];
25
+ const runIds = readdirSync(root).sort().reverse();
26
+ const summaries = [];
27
+ for (const runId of runIds) {
28
+ const manifest = tryLoadManifest(homeDir, runId);
29
+ if (!manifest)
30
+ continue;
31
+ summaries.push({ runId, command: manifest.command, startedAt: manifest.startedAt, operationCount: manifest.operations.length });
32
+ }
33
+ return summaries;
34
+ }
35
+ function tryLoadManifest(homeDir, runId) {
36
+ const manifestPath = join(backupsRoot(homeDir), runId, "manifest.json");
37
+ if (!existsSync(manifestPath))
38
+ return undefined;
39
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
40
+ }
41
+ export function loadManifest(homeDir, runId) {
42
+ const manifest = tryLoadManifest(homeDir, runId);
43
+ if (!manifest) {
44
+ throw new Error(`No backup run "${runId}" found under ${backupsRoot(homeDir)}`);
45
+ }
46
+ return manifest;
47
+ }
48
+ function resolveRunId(homeDir, requested) {
49
+ if (requested)
50
+ return requested;
51
+ const [mostRecent] = listBackups(homeDir);
52
+ if (!mostRecent) {
53
+ throw new Error(`No backup runs found under ${backupsRoot(homeDir)} — nothing to roll back`);
54
+ }
55
+ return mostRecent.runId;
56
+ }
57
+ async function currentLinkTarget(path) {
58
+ try {
59
+ return await readlink(path);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ async function planOperation(homeDir, runId, op) {
66
+ const runDir = join(backupsRoot(homeDir), runId);
67
+ switch (op.kind) {
68
+ case "file-create":
69
+ case "file-overwrite": {
70
+ if (!existsSync(op.path)) {
71
+ return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
72
+ }
73
+ const currentHash = sha256(readFileSync(op.path, "utf-8"));
74
+ if (currentHash !== op.afterHash) {
75
+ return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
76
+ }
77
+ if (op.kind === "file-create") {
78
+ return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
79
+ }
80
+ return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
81
+ }
82
+ case "symlink-create":
83
+ case "symlink-repair": {
84
+ const current = await currentLinkTarget(op.path);
85
+ if (current === undefined) {
86
+ return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
87
+ }
88
+ if (current !== op.afterLinkTarget) {
89
+ return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
90
+ }
91
+ if (op.kind === "symlink-create") {
92
+ return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
93
+ }
94
+ return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
95
+ }
96
+ case "symlink-remove": {
97
+ if (existsSync(op.path)) {
98
+ return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
99
+ }
100
+ return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
101
+ }
102
+ }
103
+ }
104
+ export async function collectRollbackPlan(homeDirInput, runIdInput) {
105
+ const homeDir = homeDirInput ?? homedir();
106
+ const runId = resolveRunId(homeDir, runIdInput);
107
+ const manifest = loadManifest(homeDir, runId);
108
+ const items = [];
109
+ for (const op of manifest.operations) {
110
+ items.push(await planOperation(homeDir, runId, op));
111
+ }
112
+ return { runId, items };
113
+ }
114
+ async function restoreOperation(homeDir, runId, op) {
115
+ const runDir = join(backupsRoot(homeDir), runId);
116
+ switch (op.kind) {
117
+ case "file-create":
118
+ rmSync(op.path, { force: true });
119
+ return;
120
+ case "file-overwrite":
121
+ writeFileSync(op.path, readFileSync(join(runDir, op.beforeFile), "utf-8"));
122
+ return;
123
+ case "symlink-create":
124
+ await rm(op.path, { force: true });
125
+ return;
126
+ case "symlink-repair":
127
+ await rm(op.path, { force: true });
128
+ await symlink(op.beforeLinkTarget, op.path);
129
+ return;
130
+ case "symlink-remove":
131
+ await symlink(op.beforeLinkTarget, op.path);
132
+ return;
133
+ }
134
+ }
135
+ export async function applyRollbackPlan(homeDir, runId, manifest, items) {
136
+ const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
137
+ for (const op of manifest.operations) {
138
+ if (!restorePaths.has(op.path))
139
+ continue;
140
+ await restoreOperation(homeDir, runId, op);
141
+ }
142
+ }
143
+ export async function runRollback(opts = {}) {
144
+ const homeDir = opts.homeDir ?? homedir();
145
+ if (opts.list) {
146
+ const runs = listBackups(homeDir);
147
+ if (opts.json) {
148
+ console.log(JSON.stringify(runs, null, 2));
149
+ }
150
+ else {
151
+ printList(runs);
152
+ }
153
+ return { exitCode: 0 };
154
+ }
155
+ let report;
156
+ try {
157
+ report = await collectRollbackPlan(homeDir, opts.runId);
158
+ }
159
+ catch (err) {
160
+ console.error(err instanceof Error ? err.message : String(err));
161
+ return { exitCode: 1 };
162
+ }
163
+ if (!opts.dryRun) {
164
+ const manifest = loadManifest(homeDir, report.runId);
165
+ await applyRollbackPlan(homeDir, report.runId, manifest, report.items);
166
+ }
167
+ if (opts.json) {
168
+ console.log(JSON.stringify(report, null, 2));
169
+ }
170
+ else {
171
+ printReport(report, opts.dryRun ?? false);
172
+ }
173
+ const hasConflict = report.items.some((i) => i.action === "conflict");
174
+ return { exitCode: hasConflict ? 1 : 0 };
175
+ }
176
+ function printList(runs) {
177
+ if (runs.length === 0) {
178
+ console.log("No backup runs found under ~/.trellis/backups/.");
179
+ return;
180
+ }
181
+ for (const run of runs) {
182
+ console.log(`${run.runId} — ${run.command}, ${run.operationCount} operation(s), ${run.startedAt}`);
183
+ }
184
+ }
185
+ export function printReport(report, dryRun) {
186
+ if (dryRun)
187
+ console.log("[dry run]");
188
+ console.log(`rollback ${report.runId}`);
189
+ const restored = report.items.filter((i) => i.action === "restore");
190
+ const conflicts = report.items.filter((i) => i.action === "conflict");
191
+ const alreadyReverted = report.items.filter((i) => i.action === "already-reverted");
192
+ if (report.items.length === 0) {
193
+ console.log(" nothing recorded in this run");
194
+ return;
195
+ }
196
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
197
+ console.log(`${icon} ${restored.length} restored, ${conflicts.length} conflict(s), ${alreadyReverted.length} already reverted`);
198
+ for (const item of [...restored, ...conflicts, ...alreadyReverted]) {
199
+ console.log(` - [${item.action}] ${item.description}`);
200
+ }
201
+ }
@@ -17,6 +17,9 @@ export interface RunSecretsAuditOptions {
17
17
  /** Same test/sandbox-only seam as every other command — never a CLI
18
18
  * flag. See docs/architecture.md's testing philosophy. */
19
19
  homeDir?: string;
20
+ /** Onboard-only seam — see RunSyncOptions.managedAgents. Never a CLI
21
+ * flag. */
22
+ managedAgents?: readonly AgentId[];
20
23
  }
21
24
  export interface SecretsFinding {
22
25
  /** "environment" for `missing-env-value` — that check isn't scoped to
@@ -33,3 +36,7 @@ export declare function collectSecretsAuditReport(opts?: RunSecretsAuditOptions)
33
36
  export declare function runSecretsAudit(opts?: RunSecretsAuditOptions): Promise<{
34
37
  exitCode: number;
35
38
  }>;
39
+ /** Exported so `onboard` prints a secrets-audit report identically to
40
+ * running `secrets audit` standalone, instead of a second, easily-drifting
41
+ * copy of this formatting. */
42
+ export declare function printReport(report: SecretsAuditReport): void;
@@ -20,15 +20,18 @@ import { CodexAdapter } from "../adapters/codex.js";
20
20
  import { KiroAdapter } from "../adapters/kiro.js";
21
21
  import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
22
22
  import { resolveSecretEnv } from "../lib/secretEnv.js";
23
- function auditedAgents(homeDir) {
24
- return [
23
+ /** Only agents in `managedAgents` — a present-but-unmanaged agent's config
24
+ * file is never even read (trellis-managed-agents), same restriction as
25
+ * `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
26
+ * regardless of management — its bridge reads mcp/servers.yaml directly at
27
+ * its own runtime (P4). See design.md Non-Goals in trellis-secrets-audit-p3. */
28
+ function auditedAgents(homeDir, managedAgents) {
29
+ const all = [
25
30
  { id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
26
31
  { id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
27
32
  { id: "kiro", probe: () => new KiroAdapter(homeDir).probe(), configPath: (h) => join(h, ".kiro", "settings", "mcp.json"), extractNames: extractJsonEnvVarNames },
28
- // pi has no static, generated MCP config file to audit — its bridge
29
- // reads mcp/servers.yaml directly at its own runtime (P4). See
30
- // design.md Non-Goals in trellis-secrets-audit-p3.
31
33
  ];
34
+ return all.filter((a) => managedAgents.includes(a.id));
32
35
  }
33
36
  /**
34
37
  * Not scoped to any agent, present or not — a name that can't resolve is
@@ -72,8 +75,9 @@ function auditFile(agent, file, content, policy, extractNames) {
72
75
  export async function collectSecretsAuditReport(opts = {}) {
73
76
  const homeDir = opts.homeDir ?? homedir();
74
77
  const canonical = loadCanonicalSource(homeDir);
78
+ const managedAgents = opts.managedAgents ?? canonical.managedAgents;
75
79
  const findings = [];
76
- for (const agent of auditedAgents(homeDir)) {
80
+ for (const agent of auditedAgents(homeDir, managedAgents)) {
77
81
  const probeResult = await agent.probe();
78
82
  if (!probeResult.present)
79
83
  continue;
@@ -103,7 +107,10 @@ export async function runSecretsAudit(opts = {}) {
103
107
  }
104
108
  return { exitCode: report.findings.length > 0 ? 1 : 0 };
105
109
  }
106
- function printReport(report) {
110
+ /** Exported so `onboard` prints a secrets-audit report identically to
111
+ * running `secrets audit` standalone, instead of a second, easily-drifting
112
+ * copy of this formatting. */
113
+ export function printReport(report) {
107
114
  if (report.findings.length === 0) {
108
115
  console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
109
116
  return;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `trellis skill list|add|remove` — command-line CRUD for canonical
3
+ * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
+ * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
+ * decision and `remove`'s "sync will un-sync it" behavior both reuse
6
+ * existing, already-shipped logic rather than reimplementing it — see
7
+ * `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
8
+ * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
+ */
10
+ import type { AgentId } from "../core/types.js";
11
+ export interface SkillListEntry {
12
+ name: string;
13
+ scope: readonly AgentId[];
14
+ }
15
+ export declare function collectSkillList(homeDir?: string): SkillListEntry[];
16
+ export declare function runSkillList(opts?: {
17
+ homeDir?: string;
18
+ json?: boolean;
19
+ }): {
20
+ exitCode: number;
21
+ };
22
+ export type SkillAddAction = "create" | "already-present" | "conflict" | "invalid-source";
23
+ export interface SkillAddPlan {
24
+ name: string;
25
+ action: SkillAddAction;
26
+ detail: string;
27
+ sourceDir?: string;
28
+ }
29
+ export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
30
+ export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string): void;
31
+ export declare function runSkillAdd(name: string, fromPath: string, opts?: {
32
+ homeDir?: string;
33
+ json?: boolean;
34
+ dryRun?: boolean;
35
+ }): {
36
+ exitCode: number;
37
+ };
38
+ export type SkillRemoveAction = "removed" | "not-found";
39
+ export interface SkillRemovePlan {
40
+ name: string;
41
+ action: SkillRemoveAction;
42
+ }
43
+ export declare function collectSkillRemovePlan(name: string, homeDir?: string): SkillRemovePlan;
44
+ export declare function applySkillRemovePlan(plan: SkillRemovePlan, homeDir?: string): void;
45
+ export declare function runSkillRemove(name: string, opts?: {
46
+ homeDir?: string;
47
+ json?: boolean;
48
+ dryRun?: boolean;
49
+ }): {
50
+ exitCode: number;
51
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `trellis skill list|add|remove` — command-line CRUD for canonical
3
+ * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
+ * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
+ * decision and `remove`'s "sync will un-sync it" behavior both reuse
6
+ * existing, already-shipped logic rather than reimplementing it — see
7
+ * `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
8
+ * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
+ */
10
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { decideDirImport } from "../lib/dirEquals.js";
14
+ import { findSkillFile } from "../lib/skillFile.js";
15
+ import { loadCanonicalSource } from "../core/canonical.js";
16
+ import { resolveScope } from "../core/types.js";
17
+ export function collectSkillList(homeDir = homedir()) {
18
+ const canonical = loadCanonicalSource(homeDir);
19
+ return canonical.skills.map((skill) => ({
20
+ name: skill.name,
21
+ scope: resolveScope(skill.scope, canonical.managedAgents),
22
+ }));
23
+ }
24
+ export function runSkillList(opts = {}) {
25
+ const entries = collectSkillList(opts.homeDir ?? homedir());
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(entries, null, 2));
28
+ }
29
+ else if (entries.length === 0) {
30
+ console.log("No skills in canonical source yet.");
31
+ }
32
+ else {
33
+ for (const { name, scope } of entries) {
34
+ console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}`);
35
+ }
36
+ }
37
+ return { exitCode: 0 };
38
+ }
39
+ export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
40
+ const skillFile = findSkillFile(fromPath);
41
+ if (!skillFile) {
42
+ return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
43
+ }
44
+ if (!skillFile.caseCorrect) {
45
+ return { name, action: "invalid-source", detail: `${fromPath} has skill.md, not case-correct SKILL.md — fix the case first` };
46
+ }
47
+ const canonicalDir = join(homeDir, ".trellis", "skills", name);
48
+ switch (decideDirImport(fromPath, canonicalDir)) {
49
+ case "create":
50
+ return { name, action: "create", detail: `will copy from ${fromPath}`, sourceDir: fromPath };
51
+ case "already-present":
52
+ return { name, action: "already-present", detail: "canonical content is already byte-identical" };
53
+ case "conflict":
54
+ return { name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
55
+ }
56
+ }
57
+ export function applySkillAddPlan(plan, homeDir = homedir()) {
58
+ if (plan.action !== "create" || !plan.sourceDir)
59
+ return;
60
+ const dest = join(homeDir, ".trellis", "skills", plan.name);
61
+ mkdirSync(dest, { recursive: true });
62
+ cpSync(plan.sourceDir, dest, { recursive: true });
63
+ }
64
+ export function runSkillAdd(name, fromPath, opts = {}) {
65
+ const homeDir = opts.homeDir ?? homedir();
66
+ const plan = collectSkillAddPlan(name, fromPath, homeDir);
67
+ if (!opts.dryRun && plan.action === "create") {
68
+ applySkillAddPlan(plan, homeDir);
69
+ }
70
+ if (opts.json) {
71
+ console.log(JSON.stringify(plan, null, 2));
72
+ }
73
+ else {
74
+ console.log(`${opts.dryRun ? "[dry run] " : ""}skill add ${name}`);
75
+ console.log(` [${plan.action}] ${plan.detail}`);
76
+ }
77
+ return { exitCode: plan.action === "conflict" || plan.action === "invalid-source" ? 1 : 0 };
78
+ }
79
+ export function collectSkillRemovePlan(name, homeDir = homedir()) {
80
+ const dir = join(homeDir, ".trellis", "skills", name);
81
+ return { name, action: existsSync(dir) ? "removed" : "not-found" };
82
+ }
83
+ export function applySkillRemovePlan(plan, homeDir = homedir()) {
84
+ if (plan.action !== "removed")
85
+ return;
86
+ rmSync(join(homeDir, ".trellis", "skills", plan.name), { recursive: true, force: true });
87
+ }
88
+ export function runSkillRemove(name, opts = {}) {
89
+ const homeDir = opts.homeDir ?? homedir();
90
+ const plan = collectSkillRemovePlan(name, homeDir);
91
+ if (!opts.dryRun) {
92
+ applySkillRemovePlan(plan, homeDir);
93
+ }
94
+ if (opts.json) {
95
+ console.log(JSON.stringify(plan, null, 2));
96
+ }
97
+ else if (plan.action === "not-found") {
98
+ console.error(`"${name}" is not a canonical skill — nothing to remove.`);
99
+ }
100
+ else {
101
+ console.log(`${opts.dryRun ? "[dry run] " : ""}removed skill "${name}" from canonical source.`);
102
+ }
103
+ return { exitCode: plan.action === "not-found" ? 1 : 0 };
104
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { AdapterPlanItem } from "../core/adapter.js";
9
9
  import type { AgentId } from "../core/types.js";
10
+ import { type BackupSession } from "../lib/backup.js";
10
11
  export interface RunSyncOptions {
11
12
  /** Omit to sync both. */
12
13
  target?: "skills" | "instructions";
@@ -21,6 +22,18 @@ export interface RunSyncOptions {
21
22
  homeDir?: string;
22
23
  /** Compute and report the plan without calling adapter.apply(). */
23
24
  dryRun?: boolean;
25
+ /** Onboard-only seam: preview/act against a managed-agent set that
26
+ * hasn't been written to `~/.trellis/managed.yaml` yet (its own
27
+ * `--dry-run` still needs a real plan against the set the user is
28
+ * about to select, not the one already on disk). Never a CLI flag —
29
+ * standalone `trellis sync` always reads canonical's own. */
30
+ managedAgents?: readonly AgentId[];
31
+ /** Onboard-only seam (trellis-backup-rollback): a session already open
32
+ * for the whole onboard run, so one `trellis rollback` undoes every
33
+ * stage together instead of one per stage. When given, this function
34
+ * does NOT finalize it — only whoever opened it does. Never a CLI
35
+ * flag — standalone `trellis sync` always opens and finalizes its own. */
36
+ backupSession?: BackupSession;
24
37
  }
25
38
  export interface AgentSyncReport {
26
39
  agent: AgentId;
@@ -11,14 +11,35 @@ 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
- function buildAdapters(homeDir) {
15
- return [new ClaudeCodeAdapter(homeDir), new CodexAdapter(homeDir), new KiroAdapter(homeDir), new PiAdapter(homeDir)];
14
+ import { openBackupSession } from "../lib/backup.js";
15
+ const ADAPTER_FACTORY = {
16
+ "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
17
+ codex: (homeDir) => new CodexAdapter(homeDir),
18
+ kiro: (homeDir) => new KiroAdapter(homeDir),
19
+ pi: (homeDir) => new PiAdapter(homeDir),
20
+ };
21
+ /** Only agents in `canonical.managedAgents` — an agent present on this
22
+ * machine but not managed gets no adapter at all, not a zero-item plan
23
+ * (trellis-managed-agents). */
24
+ function buildAdapters(homeDir, managedAgents) {
25
+ return managedAgents.map((id) => ADAPTER_FACTORY[id](homeDir));
16
26
  }
17
27
  export async function collectSyncReport(opts = {}) {
18
28
  const homeDir = opts.homeDir ?? homedir();
19
- const canonical = loadCanonicalSource(homeDir);
29
+ const loaded = loadCanonicalSource(homeDir);
30
+ // A single override point, not two: every downstream read of "who's
31
+ // managed" — the adapter list built here AND each adapter's own
32
+ // in-scope filtering via `canonical.managedAgents` — must agree, or a
33
+ // dry-run preview computed against a not-yet-written managed set would
34
+ // silently diverge from what onboard's own outer adapter list acted on.
35
+ const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
20
36
  const reports = [];
21
- for (const adapter of buildAdapters(homeDir)) {
37
+ // Own session only when the caller didn't share one (trellis-backup-
38
+ // rollback D2) — dry-run never opens or writes one, there's nothing to
39
+ // record.
40
+ const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
41
+ const backup = opts.backupSession ?? ownSession;
42
+ for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
22
43
  const probeResult = await adapter.probe();
23
44
  if (!probeResult.present) {
24
45
  reports.push({ agent: adapter.id, present: false, items: [] });
@@ -40,10 +61,11 @@ export async function collectSyncReport(opts = {}) {
40
61
  items = items.filter((item) => item.kind === kind);
41
62
  }
42
63
  if (!opts.dryRun) {
43
- await adapter.apply(items);
64
+ await adapter.apply(items, backup);
44
65
  }
45
66
  reports.push({ agent: adapter.id, present: true, items });
46
67
  }
68
+ ownSession?.finalize();
47
69
  return { reports };
48
70
  }
49
71
  export async function runSync(opts = {}) {
@@ -70,6 +92,10 @@ export async function runSync(opts = {}) {
70
92
  export function printReport(report, dryRun) {
71
93
  if (dryRun)
72
94
  console.log("[dry run]");
95
+ if (report.reports.length === 0) {
96
+ console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
97
+ return;
98
+ }
73
99
  for (const { agent, present, items } of report.reports) {
74
100
  if (!present) {
75
101
  console.log(`— ${agent} (not installed)`);
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
12
12
  import { resolveScope } from "./types.js";
13
+ import type { BackupSession } from "../lib/backup.js";
13
14
  export interface AdapterProbeResult {
14
15
  present: boolean;
15
16
  version?: string;
@@ -19,14 +20,18 @@ export interface AdapterPlanItem {
19
20
  /**
20
21
  * "create" also covers repair (wrong symlink target, or an MCP server
21
22
  * definition that differs from canonical); "remove" is the delete half
22
- * for skills/instructions a Trellis-managed symlink whose canonical
23
- * entry is gone or was just scoped away from this agent. MCP server
24
- * items never use "remove" (see `kind: "mcp"` below — no ownership
25
- * marker exists yet to make that provably safe, trellis-mcp-sync-p2
26
- * design.md D7). "conflict" is either a real, non-symlink path
27
- * occupying a spot Trellis would otherwise touch, or an MCP server
28
- * refused for a collision/secrets-guard reason reported, never acted
29
- * on. See `plan()`'s doc below.
23
+ * for skills/instructions (a Trellis-managed symlink whose canonical
24
+ * entry is gone or was just scoped away from this agent) and, since
25
+ * trellis-mcp-lifecycle-parity, for MCP servers too but only when
26
+ * `src/lib/mcpOwnership.ts`'s ledger proves the agent's current native
27
+ * entry is still exactly what Trellis itself last wrote there
28
+ * (mcpPlan.ts's own D7 reasoning: a bare TOML/JSON key has no ownership
29
+ * marker on its own, so this ledger is what makes removal provably
30
+ * safe instead of guessing). A name whose native content has since
31
+ * been hand-edited is left alone, never removed. "conflict" is either
32
+ * a real, non-symlink path occupying a spot Trellis would otherwise
33
+ * touch, or an MCP server refused for a collision/secrets-guard
34
+ * reason — reported, never acted on. See `plan()`'s doc below.
30
35
  */
31
36
  action: "create" | "remove" | "conflict";
32
37
  /** Lets `trellis sync skills` / `trellis sync instructions` /
@@ -63,6 +68,12 @@ export interface AdapterPlanItem {
63
68
  name: string;
64
69
  def: McpServerDef;
65
70
  };
71
+ /** Only set (and only meaningful) when `kind === "mcp"` and
72
+ * `action === "remove"`: the server name to delete from `target` via
73
+ * that agent's own mechanism. */
74
+ mcpRemove?: {
75
+ name: string;
76
+ };
66
77
  /** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
67
78
  * and `action === "create"`: the full, already-deduplicated array to
68
79
  * write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was
@@ -91,7 +102,7 @@ export interface TrellisAdapter {
91
102
  * entries, MCP servers) through its `scope` field before planning any
92
103
  * change for it — an item scoped away from `this.id` must produce no
93
104
  * plan item at all, not a plan item that's later skipped. Use
94
- * `resolveScope(item.scope).includes(this.id)`. See docs/architecture.md
105
+ * `isInScope(this.id, item.scope, canonical.managedAgents)`. See docs/architecture.md
95
106
  * "Private / agent-specific capabilities" — the default (`scope`
96
107
  * omitted) is "all agents," so this filter is a no-op for the common
97
108
  * case and only actually excludes anything when a capability was
@@ -123,11 +134,17 @@ export interface TrellisAdapter {
123
134
  * item in the same plan from being applied. The caller (e.g. `trellis
124
135
  * sync`) is responsible for surfacing conflicts and failing the overall
125
136
  * command (non-zero exit), not `apply()` per item.
137
+ *
138
+ * MUST perform every real write through `backup` (`writeFile`/
139
+ * `createSymlink`/`repairSymlink`/`removeSymlink`), never directly —
140
+ * `backup` is the only thing that may call `fs`/`fs/promises` to
141
+ * mutate a path this method touches (trellis-backup-rollback design.md
142
+ * D3/D5). Mandatory, not optional: the caller always has one open.
126
143
  */
127
- apply(plan: AdapterPlanItem[]): Promise<void>;
144
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
128
145
  /** Re-read the agent's own state and confirm it matches canonical. */
129
146
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
130
147
  }
131
148
  /** Convenience used by every adapter's `plan()` — see the scope-filtering
132
149
  * obligation documented above. */
133
- export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
150
+ export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0], managedAgents: readonly AgentId[]): boolean;
@@ -11,6 +11,6 @@
11
11
  import { resolveScope } from "./types.js";
12
12
  /** Convenience used by every adapter's `plan()` — see the scope-filtering
13
13
  * obligation documented above. */
14
- export function isInScope(id, scope) {
15
- return resolveScope(scope).includes(id);
14
+ export function isInScope(id, scope, managedAgents) {
15
+ return resolveScope(scope, managedAgents).includes(id);
16
16
  }
@@ -4,7 +4,31 @@
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 { CanonicalSource } from "./types.js";
7
+ import type { CanonicalSource, McpServerDef } from "./types.js";
8
+ /**
9
+ * The on-disk shape for one server entry — `static_env` (snake_case, like
10
+ * every other multi-word key across `.trellis/*.yaml`) is translated to
11
+ * `McpServerDef.staticEnv` (camelCase) below; every other field happens
12
+ * to already be a single word, so no server-def field needed this
13
+ * treatment before (trellis-mcp-static-env-and-disabled-servers).
14
+ */
15
+ type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
16
+ static_env?: Record<string, string>;
17
+ };
18
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
19
+ * `undefined` fields so the written YAML never gets a literal `null`
20
+ * for an omitted optional. */
21
+ export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
22
+ export type ServersYamlWriteResult = {
23
+ ok: true;
24
+ } | {
25
+ ok: false;
26
+ error: string;
27
+ };
28
+ export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
29
+ /** Inverse of `upsertServerYaml` — same preservation guarantee, same
30
+ * refusal posture on a missing/unparseable file. */
31
+ export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
8
32
  /**
9
33
  * `homeDir` defaults to the real `~` and is only ever overridden for tests
10
34
  * and `scripts/sandbox.sh` — the same seam P0's probes use
@@ -14,3 +38,4 @@ import type { CanonicalSource } from "./types.js";
14
38
  * parameter does not weaken.
15
39
  */
16
40
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
41
+ export {};