agent-trellis 0.1.0 → 0.2.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 (41) hide show
  1. package/README.md +57 -17
  2. package/dist/adapters/claude-code.d.ts +2 -1
  3. package/dist/adapters/claude-code.js +7 -7
  4. package/dist/adapters/codex.d.ts +2 -1
  5. package/dist/adapters/codex.js +7 -7
  6. package/dist/adapters/jsonMcp.d.ts +1 -0
  7. package/dist/adapters/jsonMcp.js +2 -2
  8. package/dist/adapters/kiro.d.ts +2 -1
  9. package/dist/adapters/kiro.js +9 -9
  10. package/dist/adapters/mcpPlan.d.ts +1 -1
  11. package/dist/adapters/mcpPlan.js +2 -2
  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 +41 -11
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +13 -0
  19. package/dist/commands/mcp.js +31 -7
  20. package/dist/commands/onboard.d.ts +42 -7
  21. package/dist/commands/onboard.js +207 -34
  22. package/dist/commands/rollback.d.ts +44 -0
  23. package/dist/commands/rollback.js +201 -0
  24. package/dist/commands/secretsAudit.d.ts +7 -0
  25. package/dist/commands/secretsAudit.js +14 -7
  26. package/dist/commands/sync.d.ts +13 -0
  27. package/dist/commands/sync.js +31 -5
  28. package/dist/core/adapter.d.ts +10 -3
  29. package/dist/core/adapter.js +2 -2
  30. package/dist/core/canonical.js +22 -0
  31. package/dist/core/types.d.ts +12 -1
  32. package/dist/core/types.js +11 -2
  33. package/dist/lib/backup.d.ts +56 -0
  34. package/dist/lib/backup.js +98 -0
  35. package/dist/lib/installAgent.d.ts +26 -0
  36. package/dist/lib/installAgent.js +46 -0
  37. package/dist/pi-bridge/bundle.js +26 -7
  38. package/dist/pi-bridge/index.js +8 -1
  39. package/docs/getting-started.md +104 -26
  40. package/docs/roadmap.md +133 -0
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -45,14 +45,29 @@ trellis onboard
45
45
  ```
46
46
 
47
47
  Creates canonical source, detects which of Claude Code/Codex/Kiro/pi are on
48
- this machine, picks a base agent to migrate from (auto-selected if only one
49
- is present; prompts if more than one pass `--agent <id>` to skip the
50
- prompt), then runs `migrate` and `sync`. If no agent is detected, it prints
51
- each supported agent's real install command/URL and stops it never
52
- installs anything itself. Add `--dry-run` to preview the whole thing with
53
- zero writes.
48
+ this machine, then resolves two independent choices: a **migration source**
49
+ (read from auto-selected if only one present agent has real content,
50
+ prompted with a numbered choice if more than one, skippable if starting
51
+ fresh; pass `--agent <id>` to skip the prompt) and a **managed set** (written
52
+ to zero or more agents, chosen explicitly; pass `--manage <ids>` or
53
+ `--manage none` to skip the prompt). The source is **not** managed by
54
+ default — importing from Claude Code doesn't mean Trellis starts writing to
55
+ Claude Code too, unless you explicitly include it. Selecting an agent that
56
+ isn't installed yet (e.g. pi) is itself the authorization to install it —
57
+ one confirmation, then a real `npm install -g <package>`; Kiro has no CLI
58
+ package and is refused with its download URL instead. Once resolved, it
59
+ runs `migrate`, `sync`, `mcp sync`, and `secrets audit` against the managed
60
+ set — the whole onboarding path in one command, no follow-up commands to
61
+ type by hand. If no agent is detected, it prints each supported agent's
62
+ real install command/URL and stops — it never installs anything on its own
63
+ initiative. Add `--dry-run` to preview the whole thing with zero writes.
54
64
 
55
- **Or step by step** (what `onboard` is actually doing under the hood):
65
+ ```
66
+ trellis onboard --agent claude-code --manage pi
67
+ ```
68
+
69
+ **Or step by step** (what `onboard` is actually doing under the hood, if you
70
+ want to run any one stage on its own):
56
71
 
57
72
  **Already using Claude Code, Codex, Kiro, or pi and want to migrate what you
58
73
  already have?**
@@ -65,24 +80,46 @@ already have?**
65
80
  overwrites: an already-identical skill is reported and skipped, a genuine
66
81
  conflict is reported and left for you to resolve by hand. Add `--dry-run`
67
82
  to preview first.
68
- 3. `trellis sync` — distributes canonical skills/instructions to every agent
69
- present on this machine (including the ones you didn't migrate from).
70
- Add `--dry-run` to preview first.
83
+ 3. `trellis sync` — distributes canonical skills/instructions to every
84
+ **managed** agent (`~/.trellis/managed.yaml` empty by default; edit it
85
+ by hand or let `trellis onboard` write it). An agent that's merely present
86
+ but not managed is never touched. Add `--dry-run` to preview first.
71
87
  4. `trellis mcp sync` — distributes `~/.trellis/mcp/servers.yaml` (see
72
88
  [`schema/servers.example.yaml`](schema/servers.example.yaml)) to every
73
- agent's native MCP config (create/repair only — see Known limitations).
74
- 5. `trellis secrets audit` — fails non-zero if any agent's real config holds
75
- a literal credential or an unexpected env var name.
76
- 6. `trellis doctor` read-only scan of every present agent's current state;
77
- run any time to check for drift.
89
+ managed agent's native MCP config (create/repair only — see Known
90
+ limitations).
91
+ 5. `trellis secrets audit` fails non-zero if any managed agent's real
92
+ config holds a literal credential or an unexpected env var name.
93
+ 6. `trellis doctor` read-only scan of every present agent's current state
94
+ (managed or not); run any time to check for drift.
78
95
 
79
96
  **Starting from nothing?** Skip step 2 — `trellis init`'s placeholder
80
97
  `agents.md` and empty `skills/` are a fine starting point; edit them by hand.
81
98
 
99
+ **Every real write `sync`/`mcp sync`/`onboard` perform is backed up
100
+ first, automatically — no flag to opt in.** Before creating, repairing,
101
+ or removing anything, each is recorded to a structured, timestamped run
102
+ under `~/.trellis/backups/`, with enough information to invert it exactly.
103
+ `trellis rollback` undoes one recorded run — omit a run id for the most
104
+ recent one:
105
+
106
+ ```
107
+ $ trellis rollback
108
+ rollback 2026-09-13T04-52-18-727Z-mcp-sync
109
+ ✅ 3 restored, 0 conflict(s), 0 already reverted
110
+ - [restore] restore /Users/you/.claude.json to its content before this run (...)
111
+ ```
112
+
113
+ If something else touched a path since the backed-up run, that one path
114
+ is a `conflict` — reported, left untouched, never force-restored over —
115
+ while every other path in the same rollback still restores. `--list`
116
+ shows available runs without touching anything; `--dry-run` previews a
117
+ restore with zero writes.
118
+
82
119
  ## Status
83
120
 
84
- **Early, pre-1.0.** All seven CLI commands above (`onboard`, `init`,
85
- `migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`) are implemented,
121
+ **Early, pre-1.0.** All eight CLI commands above (`onboard`, `init`,
122
+ `migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`, `rollback`) are implemented,
86
123
  unit-tested, and verified end-to-end against real Docker containers (never a
87
124
  developer's own dotfiles during development — see
88
125
  [`docs/architecture.md`](docs/architecture.md)'s testing philosophy). See
@@ -107,6 +144,9 @@ result is named future work, not built yet.
107
144
  - Automatic removal of an MCP server is deliberately unsupported (create/
108
145
  repair only) until an ownership-tracking mechanism exists — see
109
146
  `docs/roadmap.md`'s P2 note.
147
+ - `~/.trellis/backups/` has no automatic pruning yet — every `sync`/
148
+ `mcp sync`/`onboard` run that performs a real write adds one more run
149
+ directory, with no cap. Delete old ones by hand for now.
110
150
 
111
151
  ## Design principles
112
152
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
12
12
  import type { CanonicalSource } from "../core/types.js";
13
+ import type { BackupSession } from "../lib/backup.js";
13
14
  export declare class ClaudeCodeAdapter implements TrellisAdapter {
14
15
  private readonly homeDir;
15
16
  readonly name = "Claude Code";
@@ -18,6 +19,6 @@ export declare class ClaudeCodeAdapter implements TrellisAdapter {
18
19
  probe(): Promise<AdapterProbeResult>;
19
20
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
20
21
  private planMcp;
21
- apply(plan: AdapterPlanItem[]): Promise<void>;
22
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
22
23
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
23
24
  }
@@ -8,7 +8,7 @@
8
8
  * (trellis-mcp-sync-p2 design.md D4) — create/repair only, no automatic
9
9
  * removal (D7).
10
10
  */
11
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, readFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { isInScope } from "../core/adapter.js";
@@ -30,7 +30,7 @@ export class ClaudeCodeAdapter {
30
30
  const canonicalRoot = dirname(canonical.instructionsFile);
31
31
  const skillsRoot = join(this.homeDir, ".claude", "skills");
32
32
  const desiredSkills = canonical.skills
33
- .filter((skill) => isInScope(this.id, skill.scope))
33
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
34
34
  .map((skill) => ({ name: skill.name, target: skill.dir }));
35
35
  const skillItems = planSymlinks({
36
36
  rootDir: skillsRoot,
@@ -50,10 +50,10 @@ export class ClaudeCodeAdapter {
50
50
  planMcp(canonical) {
51
51
  const configPath = join(this.homeDir, ".claude.json");
52
52
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
53
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
53
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
54
54
  }
55
- async apply(plan) {
56
- await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
55
+ async apply(plan, backup) {
56
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
57
57
  const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
58
58
  if (mcpCreates.length === 0) {
59
59
  return;
@@ -61,7 +61,7 @@ export class ClaudeCodeAdapter {
61
61
  const configPath = mcpCreates[0].target;
62
62
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
63
63
  const merged = applyJsonMcp(parsed, mcpCreates);
64
- writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`);
64
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
65
65
  }
66
66
  async verify(canonical) {
67
67
  const snapshot = await claudeCodeProbe.probe(this.homeDir);
@@ -69,7 +69,7 @@ export class ClaudeCodeAdapter {
69
69
  return { ok: false, mismatches: ["claude-code is not present on this machine"] };
70
70
  }
71
71
  const mismatches = [];
72
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
72
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
73
73
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
74
74
  for (const name of desiredNames) {
75
75
  if (!actualSkills.has(name)) {
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
16
16
  import type { CanonicalSource } from "../core/types.js";
17
+ import type { BackupSession } from "../lib/backup.js";
17
18
  export declare class CodexAdapter implements TrellisAdapter {
18
19
  private readonly homeDir;
19
20
  readonly name = "Codex";
@@ -22,6 +23,6 @@ export declare class CodexAdapter implements TrellisAdapter {
22
23
  probe(): Promise<AdapterProbeResult>;
23
24
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
24
25
  private planMcp;
25
- apply(plan: AdapterPlanItem[]): Promise<void>;
26
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
26
27
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
27
28
  }
@@ -12,7 +12,7 @@
12
12
  * automatic removal (D7: no ownership marker exists for a bare TOML key
13
13
  * the way a symlink's realpath provides for skills).
14
14
  */
15
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { existsSync, readFileSync } from "node:fs";
16
16
  import { basename, dirname, join } from "node:path";
17
17
  import { homedir } from "node:os";
18
18
  import { isInScope } from "../core/adapter.js";
@@ -36,7 +36,7 @@ export class CodexAdapter {
36
36
  const canonicalRoot = dirname(canonical.instructionsFile);
37
37
  const skillsRoot = join(this.homeDir, ".agents", "skills");
38
38
  const desiredSkills = canonical.skills
39
- .filter((skill) => isInScope(this.id, skill.scope))
39
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
40
40
  .map((skill) => ({ name: skill.name, target: skill.dir }));
41
41
  const skillItems = planSymlinks({
42
42
  rootDir: skillsRoot,
@@ -62,7 +62,7 @@ export class CodexAdapter {
62
62
  planMcp(canonical) {
63
63
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
64
64
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
65
- const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp);
65
+ const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
66
66
  const items = [];
67
67
  for (const { name, def } of desired) {
68
68
  const current = currentServerSectionText(content, name);
@@ -83,8 +83,8 @@ export class CodexAdapter {
83
83
  }
84
84
  return items;
85
85
  }
86
- async apply(plan) {
87
- await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
86
+ async apply(plan, backup) {
87
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
88
88
  const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
89
89
  if (mcpCreates.length === 0) {
90
90
  return;
@@ -94,7 +94,7 @@ export class CodexAdapter {
94
94
  for (const item of mcpCreates) {
95
95
  content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
96
96
  }
97
- writeFileSync(configTomlPath, content);
97
+ backup.writeFile(configTomlPath, content);
98
98
  }
99
99
  async verify(canonical) {
100
100
  const snapshot = await codexProbe.probe(this.homeDir);
@@ -102,7 +102,7 @@ export class CodexAdapter {
102
102
  return { ok: false, mismatches: ["codex is not present on this machine"] };
103
103
  }
104
104
  const mismatches = [];
105
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
105
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
106
106
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
107
107
  for (const name of desiredNames) {
108
108
  if (!actualSkills.has(name)) {
@@ -17,6 +17,7 @@ export declare function planJsonMcp(opts: {
17
17
  parsed: Record<string, unknown> | undefined;
18
18
  mcp: McpConfig;
19
19
  agentId: AgentId;
20
+ managedAgents: readonly AgentId[];
20
21
  }): AdapterPlanItem[];
21
22
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
22
23
  * file didn't exist), returning the object to stringify — every sibling
@@ -44,8 +44,8 @@ export function renderJsonServerEntry(def) {
44
44
  return entry;
45
45
  }
46
46
  export function planJsonMcp(opts) {
47
- const { configPath, parsed, mcp, agentId } = opts;
48
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp);
47
+ const { configPath, parsed, mcp, agentId, managedAgents } = opts;
48
+ const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents);
49
49
  const existingServers = parsed?.mcpServers ?? {};
50
50
  const items = [];
51
51
  for (const { name, def } of desired) {
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
18
18
  import type { CanonicalSource } from "../core/types.js";
19
+ import type { BackupSession } from "../lib/backup.js";
19
20
  export declare class KiroAdapter implements TrellisAdapter {
20
21
  private readonly homeDir;
21
22
  readonly name = "Kiro";
@@ -29,6 +30,6 @@ export declare class KiroAdapter implements TrellisAdapter {
29
30
  * known_host_injected collision, never contributes a name here either. */
30
31
  private desiredApprovedEnvVars;
31
32
  private planApprovedEnvVars;
32
- apply(plan: AdapterPlanItem[]): Promise<void>;
33
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
33
34
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
34
35
  }
@@ -14,7 +14,7 @@
14
14
  * adapter keeps that list a superset of every env name it references,
15
15
  * additive only.
16
16
  */
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  import { isInScope } from "../core/adapter.js";
@@ -46,7 +46,7 @@ export class KiroAdapter {
46
46
  const canonicalRoot = dirname(canonical.instructionsFile);
47
47
  const skillsRoot = join(this.homeDir, ".kiro", "skills");
48
48
  const desiredSkills = canonical.skills
49
- .filter((skill) => isInScope(this.id, skill.scope))
49
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
50
50
  .map((skill) => ({ name: skill.name, target: skill.dir }));
51
51
  const skillItems = planSymlinks({
52
52
  rootDir: skillsRoot,
@@ -67,13 +67,13 @@ export class KiroAdapter {
67
67
  planMcp(canonical) {
68
68
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
69
69
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
70
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
70
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
71
71
  }
72
72
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
73
73
  * (design.md D2) — a server scoped away from Kiro, or refused for a
74
74
  * known_host_injected collision, never contributes a name here either. */
75
75
  desiredApprovedEnvVars(canonical) {
76
- const { desired } = resolveMcpPlan(this.id, canonical.mcp);
76
+ const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
77
77
  const names = new Set();
78
78
  for (const { def } of desired) {
79
79
  for (const name of declaredEnvNames(def))
@@ -115,15 +115,15 @@ export class KiroAdapter {
115
115
  },
116
116
  ];
117
117
  }
118
- async apply(plan) {
119
- await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"));
118
+ async apply(plan, backup) {
119
+ await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"), backup);
120
120
  const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
121
121
  if (mcpCreates.length > 0) {
122
122
  const configPath = mcpCreates[0].target;
123
123
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
124
124
  const merged = applyJsonMcp(parsed, mcpCreates);
125
125
  mkdirSync(dirname(configPath), { recursive: true });
126
- writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`);
126
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
127
127
  }
128
128
  const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
129
129
  if (approvedEnvVarsCreate) {
@@ -131,7 +131,7 @@ export class KiroAdapter {
131
131
  const parsed = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
132
132
  const merged = { ...parsed, [APPROVED_ENV_VARS_KEY]: approvedEnvVarsCreate.approvedEnvVars };
133
133
  mkdirSync(dirname(settingsPath), { recursive: true });
134
- writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
134
+ backup.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
135
135
  }
136
136
  }
137
137
  async verify(canonical) {
@@ -140,7 +140,7 @@ export class KiroAdapter {
140
140
  return { ok: false, mismatches: ["kiro is not present on this machine"] };
141
141
  }
142
142
  const mismatches = [];
143
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
143
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
144
144
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
145
145
  for (const name of desiredNames) {
146
146
  if (!actualSkills.has(name)) {
@@ -25,4 +25,4 @@ export interface McpPlanResult {
25
25
  desired: DesiredMcpEntry[];
26
26
  conflicts: McpConflict[];
27
27
  }
28
- export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig): McpPlanResult;
28
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[]): McpPlanResult;
@@ -40,7 +40,7 @@ function findLiteralSecret(def) {
40
40
  }
41
41
  return undefined;
42
42
  }
43
- export function resolveMcpPlan(agentId, mcp) {
43
+ export function resolveMcpPlan(agentId, mcp, managedAgents) {
44
44
  if (mcp.hub) {
45
45
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
46
46
  return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
@@ -50,7 +50,7 @@ export function resolveMcpPlan(agentId, mcp) {
50
50
  const desired = [];
51
51
  const conflicts = [];
52
52
  for (const [name, def] of Object.entries(mcp.servers)) {
53
- if (!isInScope(agentId, def.agents)) {
53
+ if (!isInScope(agentId, def.agents, managedAgents)) {
54
54
  continue;
55
55
  }
56
56
  if (mcp.knownHostInjected.includes(name)) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
13
13
  import type { CanonicalSource } from "../core/types.js";
14
+ import type { BackupSession } from "../lib/backup.js";
14
15
  export declare class PiAdapter implements TrellisAdapter {
15
16
  private readonly homeDir;
16
17
  readonly name = "pi";
@@ -18,6 +19,6 @@ export declare class PiAdapter implements TrellisAdapter {
18
19
  constructor(homeDir?: string);
19
20
  probe(): Promise<AdapterProbeResult>;
20
21
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
21
- apply(plan: AdapterPlanItem[]): Promise<void>;
22
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
22
23
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
23
24
  }
@@ -56,7 +56,7 @@ export class PiAdapter {
56
56
  const canonicalRoot = dirname(canonical.instructionsFile);
57
57
  const agentDir = join(this.homeDir, ".pi", "agent");
58
58
  const desiredSkills = canonical.skills
59
- .filter((skill) => isInScope(this.id, skill.scope))
59
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
60
60
  .map((skill) => ({ name: skill.name, target: skill.dir }));
61
61
  const skillItems = planSymlinks({
62
62
  rootDir: join(agentDir, "skills"),
@@ -82,8 +82,8 @@ export class PiAdapter {
82
82
  });
83
83
  return [...skillItems, ...instructionsItems, ...extensionItems];
84
84
  }
85
- async apply(plan) {
86
- await applySymlinkPlan(plan);
85
+ async apply(plan, backup) {
86
+ await applySymlinkPlan(plan, backup);
87
87
  }
88
88
  async verify(canonical) {
89
89
  const snapshot = await piProbe.probe(this.homeDir);
@@ -91,7 +91,7 @@ export class PiAdapter {
91
91
  return { ok: false, mismatches: ["pi is not present on this machine"] };
92
92
  }
93
93
  const mismatches = [];
94
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
94
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
95
95
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
96
96
  for (const name of desiredNames) {
97
97
  if (!actualSkills.has(name)) {
@@ -4,6 +4,7 @@
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 { type BackupSession } from "../lib/backup.js";
7
8
  import type { AdapterPlanItem } from "../core/adapter.js";
8
9
  export interface DesiredSymlink {
9
10
  /** Basename this entry should have under `rootDir`. */
@@ -28,6 +29,9 @@ export declare function planSymlinks(opts: {
28
29
  canonicalRoot: string;
29
30
  kind: "skill" | "instructions" | "extension";
30
31
  }): AdapterPlanItem[];
31
- /** Executes a `planSymlinks` result. "conflict" is report-only see
32
- * src/core/adapter.ts's `apply()` doc for why this never throws. */
33
- export declare function applySymlinkPlan(plan: AdapterPlanItem[]): Promise<void>;
32
+ /** Executes a `planSymlinks` result through the run's backup session
33
+ * (trellis-backup-rollback) every create/repair/remove is recorded
34
+ * before it happens; `backup` performs the actual filesystem write, this
35
+ * function never calls `fs/promises` itself. "conflict" is report-only —
36
+ * see src/core/adapter.ts's `apply()` doc for why this never throws. */
37
+ export declare function applySymlinkPlan(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
@@ -5,9 +5,10 @@
5
5
  * reused by every adapter rather than reimplemented per agent.
6
6
  */
7
7
  import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
8
- import { mkdir, rm, symlink } from "node:fs/promises";
8
+ import { mkdir } from "node:fs/promises";
9
9
  import { join, resolve, sep } from "node:path";
10
10
  import { isSymlinkTo } from "../lib/fsIdentity.js";
11
+ import { currentLinkTarget } from "../lib/backup.js";
11
12
  function isUnderRoot(path, root) {
12
13
  const normalizedRoot = resolve(root);
13
14
  const normalizedPath = resolve(path);
@@ -37,14 +38,27 @@ export function planSymlinks(opts) {
37
38
  if (isSymlinkTo(path, target)) {
38
39
  continue; // already correct — no-op
39
40
  }
40
- if (existsSync(path) && !lstatSync(path).isSymbolicLink()) {
41
- items.push({
42
- action: "conflict",
43
- kind,
44
- target: path,
45
- description: `${path} exists and is not a Trellis-managed symlink — left untouched`,
46
- });
47
- continue;
41
+ if (existsSync(path)) {
42
+ const isSymlink = lstatSync(path).isSymbolicLink();
43
+ // A symlink whose stored target resolves outside canonicalRoot is
44
+ // owned by something else (e.g. a user's own dotfile-management
45
+ // setup) — repairing it as if it were a stale Trellis entry would
46
+ // silently steal that ownership. Raw readlink, not realpath: a
47
+ // symlink Trellis itself left pointing at a since-removed canonical
48
+ // entry is broken by construction and must still be treated as
49
+ // ours to repair, not thrown out to a conflict by a realpath error.
50
+ const isForeign = isSymlink && !isUnderRoot(readlinkSync(path), canonicalRootResolved);
51
+ if (!isSymlink || isForeign) {
52
+ items.push({
53
+ action: "conflict",
54
+ kind,
55
+ target: path,
56
+ description: isForeign
57
+ ? `${path} exists as a symlink to ${readlinkSync(path)}, not owned by Trellis — left untouched`
58
+ : `${path} exists and is not a Trellis-managed symlink — left untouched`,
59
+ });
60
+ continue;
61
+ }
48
62
  }
49
63
  items.push({
50
64
  action: "create",
@@ -100,21 +114,33 @@ export function planSymlinks(opts) {
100
114
  }
101
115
  return items;
102
116
  }
103
- /** Executes a `planSymlinks` result. "conflict" is report-only see
104
- * src/core/adapter.ts's `apply()` doc for why this never throws. */
105
- export async function applySymlinkPlan(plan) {
117
+ /** Executes a `planSymlinks` result through the run's backup session
118
+ * (trellis-backup-rollback) every create/repair/remove is recorded
119
+ * before it happens; `backup` performs the actual filesystem write, this
120
+ * function never calls `fs/promises` itself. "conflict" is report-only —
121
+ * see src/core/adapter.ts's `apply()` doc for why this never throws. */
122
+ export async function applySymlinkPlan(plan, backup) {
106
123
  for (const item of plan) {
107
124
  if (item.action === "conflict")
108
125
  continue;
109
126
  if (item.action === "remove") {
110
- await rm(item.target, { force: true });
127
+ const oldTarget = await currentLinkTarget(item.target);
128
+ if (oldTarget === undefined)
129
+ continue; // already gone — no-op, nothing to record
130
+ await backup.removeSymlink(item.target, oldTarget);
111
131
  continue;
112
132
  }
113
- // "create": rootDir may not exist yet (first sync ever for this agent)
133
+ // "create" also covers repair — rootDir may not exist yet (first
134
+ // sync ever for this agent).
114
135
  if (!item.linkTarget)
115
136
  continue;
116
137
  await mkdir(resolve(item.target, ".."), { recursive: true });
117
- await rm(item.target, { force: true }); // clear a wrong-target symlink before repointing
118
- await symlink(item.linkTarget, item.target);
138
+ const oldTarget = await currentLinkTarget(item.target);
139
+ if (oldTarget === undefined) {
140
+ await backup.createSymlink(item.target, item.linkTarget);
141
+ }
142
+ else {
143
+ await backup.repairSymlink(item.target, oldTarget, item.linkTarget);
144
+ }
119
145
  }
120
146
  }
package/dist/cli.js CHANGED
@@ -11,8 +11,9 @@ import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
12
  import { runMcpSync } from "./commands/mcp.js";
13
13
  import { runSecretsAudit } from "./commands/secretsAudit.js";
14
+ import { runRollback } from "./commands/rollback.js";
14
15
  import { parseSyncArgs } from "./lib/syncArgs.js";
15
- const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets"];
16
+ const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets", "rollback"];
16
17
  function printUsage() {
17
18
  console.log(`trellis - a single source of capability for every coding agent
18
19
 
@@ -20,13 +21,21 @@ Usage:
20
21
  trellis <command>
21
22
 
22
23
  Commands:
23
- onboard Guided flow: init -> detect agents -> pick a base agent ->
24
- migrate -> sync, in one command
25
- --agent <agent> non-interactive base-agent choice
26
- (required with 2+ agents present and
27
- no terminal to prompt in, e.g. --json)
28
- --dry-run preview the whole flow, write nothing
29
- --json machine-readable output, no report text
24
+ onboard Guided flow: init -> detect agents -> pick a migration source ->
25
+ pick which agents to manage -> migrate -> sync -> mcp sync ->
26
+ secrets audit, in one command. Source (read from) and managed
27
+ set (written to) are independent; the source is not managed
28
+ by default.
29
+ --agent <agent> non-interactive migration-source
30
+ choice (required with 2+ candidates
31
+ and no terminal to prompt in)
32
+ --manage <ids|none> non-interactive managed-set choice,
33
+ e.g. --manage pi,codex ; --manage none
34
+ means "add nothing new this run"
35
+ (required with no terminal to prompt
36
+ in, e.g. --json)
37
+ --dry-run preview the whole flow, write nothing
38
+ --json machine-readable output, no report text
30
39
  init Create ~/.trellis/ with a minimal valid skeleton if missing
31
40
  (never overwrites an existing file — fills in only what's
32
41
  missing) and prints which agents are present
@@ -48,11 +57,21 @@ Commands:
48
57
  --json machine-readable output, no report text
49
58
  mcp sync Distribute MCP servers to each agent's native config
50
59
  (create/repair only — no automatic removal, see docs/roadmap.md)
51
- --json machine-readable output, no report text
60
+ --dry-run preview the plan, write nothing
61
+ --json machine-readable output, no report text
52
62
  secrets audit
53
63
  Scan each present agent's real MCP config for leaked
54
64
  credentials and unexpected env var names
55
65
  --json machine-readable output, no report text
66
+ rollback [<run-id>]
67
+ Undo one recorded sync/mcp-sync/onboard run (every real write
68
+ it performed is backed up first, under
69
+ ~/.trellis/backups/) — omit <run-id> for the most recent
70
+ run. Refuses per-path (a conflict, not overwritten) if the
71
+ path changed since that run.
72
+ --list show available backup runs, don't restore
73
+ --dry-run preview what would be restored, write nothing
74
+ --json machine-readable output, no report text
56
75
 
57
76
  See docs/roadmap.md for what's built vs. planned.`);
58
77
  }
@@ -71,7 +90,9 @@ async function main(argv) {
71
90
  if (command === "onboard") {
72
91
  const agentIndex = rest.indexOf("--agent");
73
92
  const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
74
- const { exitCode } = await runOnboard({ agent, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
93
+ const manageIndex = rest.indexOf("--manage");
94
+ const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
95
+ const { exitCode } = await runOnboard({ agent, manage, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
75
96
  process.exitCode = exitCode;
76
97
  return;
77
98
  }
@@ -111,7 +132,7 @@ async function main(argv) {
111
132
  process.exitCode = 1;
112
133
  return;
113
134
  }
114
- const { exitCode } = await runMcpSync({ json: rest.includes("--json") });
135
+ const { exitCode } = await runMcpSync({ json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
115
136
  process.exitCode = exitCode;
116
137
  return;
117
138
  }
@@ -126,6 +147,15 @@ async function main(argv) {
126
147
  process.exitCode = exitCode;
127
148
  return;
128
149
  }
150
+ if (command === "rollback") {
151
+ const list = rest.includes("--list");
152
+ const dryRun = rest.includes("--dry-run");
153
+ const json = rest.includes("--json");
154
+ const runId = rest.find((arg) => !arg.startsWith("--"));
155
+ const { exitCode } = await runRollback({ runId, list, dryRun, json });
156
+ process.exitCode = exitCode;
157
+ return;
158
+ }
129
159
  console.error(`\`trellis ${command}\` is not implemented yet — this is a pre-alpha scaffold.\nSee docs/roadmap.md for status.`);
130
160
  process.exitCode = 1;
131
161
  }