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
@@ -6,12 +6,17 @@
6
6
  * each adapter turns this into plan items using its own read/write
7
7
  * mechanism (JSON merge vs. TOML section splice).
8
8
  *
9
- * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
- * skill's symlink, a plain key has no ownership marker, so "not in
11
- * canonical anymore" can't be distinguished from "the user configured
12
- * this directly." Only create/repair + refuse.
9
+ * Removal is NOT decided here (design.md D7, trellis-mcp-sync-p2): unlike
10
+ * a skill's symlink, a plain key has no ownership marker of its own, so
11
+ * "not in canonical anymore" can't be distinguished from "the user
12
+ * configured this directly" from this function's storage-agnostic view
13
+ * alone. Each format-specific caller (`jsonMcp.ts`'s `planJsonMcp`,
14
+ * `codex.ts`'s own `planMcp`) computes removal candidates itself, against
15
+ * `src/lib/mcpOwnership.ts`'s ledger and that format's own current
16
+ * on-disk content (trellis-mcp-lifecycle-parity) — this function only
17
+ * ever returns create/repair + refuse.
13
18
  */
14
- import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
19
+ import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
15
20
  export declare const HUB_ENTRY_NAME = "trellis-hub";
16
21
  export interface DesiredMcpEntry {
17
22
  name: string;
@@ -25,4 +30,4 @@ export interface McpPlanResult {
25
30
  desired: DesiredMcpEntry[];
26
31
  conflicts: McpConflict[];
27
32
  }
28
- export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig): McpPlanResult;
33
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
@@ -6,13 +6,19 @@
6
6
  * each adapter turns this into plan items using its own read/write
7
7
  * mechanism (JSON merge vs. TOML section splice).
8
8
  *
9
- * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
- * skill's symlink, a plain key has no ownership marker, so "not in
11
- * canonical anymore" can't be distinguished from "the user configured
12
- * this directly." Only create/repair + refuse.
9
+ * Removal is NOT decided here (design.md D7, trellis-mcp-sync-p2): unlike
10
+ * a skill's symlink, a plain key has no ownership marker of its own, so
11
+ * "not in canonical anymore" can't be distinguished from "the user
12
+ * configured this directly" from this function's storage-agnostic view
13
+ * alone. Each format-specific caller (`jsonMcp.ts`'s `planJsonMcp`,
14
+ * `codex.ts`'s own `planMcp`) computes removal candidates itself, against
15
+ * `src/lib/mcpOwnership.ts`'s ledger and that format's own current
16
+ * on-disk content (trellis-mcp-lifecycle-parity) — this function only
17
+ * ever returns create/repair + refuse.
13
18
  */
14
19
  import { isInScope } from "../core/adapter.js";
15
20
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
21
+ import { resolveSecretEnv } from "../lib/secretEnv.js";
16
22
  export const HUB_ENTRY_NAME = "trellis-hub";
17
23
  const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
18
24
  function collisionMessage(name, agentId) {
@@ -30,7 +36,7 @@ const DANGEROUS_LITERAL_PATTERNS = [
30
36
  { label: "mcp-router token (mcpr_)", pattern: /mcpr_/ },
31
37
  ];
32
38
  function findLiteralSecret(def) {
33
- const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {})].filter((v) => typeof v === "string");
39
+ const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
34
40
  for (const candidate of candidates) {
35
41
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
36
42
  if (pattern.test(candidate)) {
@@ -40,7 +46,22 @@ function findLiteralSecret(def) {
40
46
  }
41
47
  return undefined;
42
48
  }
43
- export function resolveMcpPlan(agentId, mcp) {
49
+ /**
50
+ * Name-only `env` entries that don't resolve to anything would otherwise
51
+ * be written and silently break the agent's connection to that server —
52
+ * found via real-machine dogfooding (trellis-mcp-static-env-and-disabled-servers
53
+ * proposal.md "Why"). Uses the identical `resolveSecretEnv` `secrets
54
+ * audit`/the pi bridge already call, so this and a later `secrets audit`
55
+ * run can never disagree about what resolves.
56
+ */
57
+ function findUnresolvedEnvName(def, policy) {
58
+ const names = def.env ?? [];
59
+ if (names.length === 0)
60
+ return undefined;
61
+ const resolved = resolveSecretEnv(names, policy);
62
+ return names.find((name) => !resolved[name]);
63
+ }
64
+ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
44
65
  if (mcp.hub) {
45
66
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
46
67
  return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
@@ -50,7 +71,10 @@ export function resolveMcpPlan(agentId, mcp) {
50
71
  const desired = [];
51
72
  const conflicts = [];
52
73
  for (const [name, def] of Object.entries(mcp.servers)) {
53
- if (!isInScope(agentId, def.agents)) {
74
+ if (def.enabled === false) {
75
+ continue;
76
+ }
77
+ if (!isInScope(agentId, def.agents, managedAgents)) {
54
78
  continue;
55
79
  }
56
80
  if (mcp.knownHostInjected.includes(name)) {
@@ -77,6 +101,15 @@ export function resolveMcpPlan(agentId, mcp) {
77
101
  });
78
102
  continue;
79
103
  }
104
+ const unresolvedName = findUnresolvedEnvName(def, policy);
105
+ if (unresolvedName) {
106
+ const source = policy.envFile ?? "process environment";
107
+ conflicts.push({
108
+ name,
109
+ message: `refusing to write MCP server "${name}": its declared env var "${unresolvedName}" has no resolvable value in ${source} — writing it now would silently break this server's connection once the agent starts it (trellis-mcp-static-env-and-disabled-servers)`,
110
+ });
111
+ continue;
112
+ }
80
113
  desired.push({ name, def });
81
114
  }
82
115
  return { desired, conflicts };
@@ -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
@@ -9,10 +9,13 @@ import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
- import { runMcpSync } from "./commands/mcp.js";
12
+ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from "./commands/mcp.js";
13
13
  import { runSecretsAudit } from "./commands/secretsAudit.js";
14
+ import { runRollback } from "./commands/rollback.js";
15
+ import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
16
+ import { runMemorySync } from "./commands/memory.js";
14
17
  import { parseSyncArgs } from "./lib/syncArgs.js";
15
- const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets"];
18
+ const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "skill", "memory", "secrets", "rollback"];
16
19
  function printUsage() {
17
20
  console.log(`trellis - a single source of capability for every coding agent
18
21
 
@@ -20,21 +23,32 @@ Usage:
20
23
  trellis <command>
21
24
 
22
25
  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
26
+ onboard Guided flow: init -> detect agents -> pick a migration source ->
27
+ pick which agents to manage -> migrate -> sync -> mcp sync ->
28
+ secrets audit, in one command. Source (read from) and managed
29
+ set (written to) are independent; the source is not managed
30
+ by default.
31
+ --agent <agent> non-interactive migration-source
32
+ choice (required with 2+ candidates
33
+ and no terminal to prompt in)
34
+ --manage <ids|none> non-interactive managed-set choice,
35
+ e.g. --manage pi,codex ; --manage none
36
+ means "add nothing new this run"
37
+ (required with no terminal to prompt
38
+ in, e.g. --json)
39
+ --dry-run preview the whole flow, write nothing
40
+ --json machine-readable output, no report text
30
41
  init Create ~/.trellis/ with a minimal valid skeleton if missing
31
42
  (never overwrites an existing file — fills in only what's
32
43
  missing) and prints which agents are present
33
44
  --json machine-readable output, no report text
34
45
  migrate --from <agent>
35
- Import an existing agent's real skills/instructions into
36
- canonical source (claude-code | codex | kiro | pi). Never
37
- overwrites differing content reports a conflict instead.
46
+ Import an existing agent's real skills/instructions/MCP
47
+ servers into canonical source (claude-code | codex | kiro |
48
+ pi pi has no static MCP config, mcp migrate-in is a no-op
49
+ for it). Never overwrites differing content — reports a
50
+ conflict instead.
51
+ --only skills|instructions|mcp restrict to one category
38
52
  --dry-run preview the plan, write nothing
39
53
  --json machine-readable output, no report text
40
54
  doctor Scan Claude Code / Codex / Kiro / pi for drift
@@ -47,12 +61,62 @@ Commands:
47
61
  --dry-run preview the plan, write nothing
48
62
  --json machine-readable output, no report text
49
63
  mcp sync Distribute MCP servers to each agent's native config
50
- (create/repair only no automatic removal, see docs/roadmap.md)
64
+ (create/repair, plus ownership-ledger-gated removal an
65
+ entry is only ever removed when it's still exactly what
66
+ Trellis itself last wrote there; see docs/roadmap.md)
67
+ --dry-run preview the plan, write nothing
68
+ --json machine-readable output, no report text
69
+ mcp list List canonical MCP servers (transport, scope, enabled;
70
+ never prints resolved secret values)
71
+ --json machine-readable output, no report text
72
+ mcp add <name> --transport stdio|http|sse ...
73
+ Add a canonical MCP server (refuses on an existing name,
74
+ no overwrite): --command <cmd> [--args a,b] (stdio) or
75
+ --url <url> (http/sse); [--headers k=v,...]
76
+ [--env NAME,...] [--static-env k=v,...] [--agents id,...]
77
+ [--enabled true|false]
78
+ --dry-run preview the plan, write nothing
79
+ --json machine-readable output, no report text
80
+ mcp remove <name>
81
+ Remove a canonical MCP server (canonical-side only — does
82
+ not touch any agent's already-synced native config)
83
+ --dry-run preview the plan, write nothing
84
+ --json machine-readable output, no report text
85
+ skill list
86
+ List canonical skills with resolved scope
51
87
  --json machine-readable output, no report text
88
+ skill add <name> --from <path>
89
+ Import a real skill directory into canonical (refuses on
90
+ an existing name with different content, no overwrite)
91
+ --dry-run preview the plan, write nothing
92
+ --json machine-readable output, no report text
93
+ skill remove <name>
94
+ Remove a canonical skill (the next sync auto-removes the
95
+ now-stale symlink on every managed agent)
96
+ --dry-run preview the plan, write nothing
97
+ --json machine-readable output, no report text
98
+ memory sync
99
+ Ingest canonical memories/*.md into the shared-memory MCP
100
+ server's own on-disk knowledge-graph file (requires a
101
+ "memory" server with static_env.MEMORY_FILE_PATH set in
102
+ servers.yaml — see schema/servers.example.yaml; a no-op,
103
+ not an error, if unconfigured). Never touches an entity
104
+ or relation this didn't create.
105
+ --dry-run preview the plan, write nothing
106
+ --json machine-readable output, no report text
52
107
  secrets audit
53
108
  Scan each present agent's real MCP config for leaked
54
109
  credentials and unexpected env var names
55
110
  --json machine-readable output, no report text
111
+ rollback [<run-id>]
112
+ Undo one recorded sync/mcp-sync/onboard run (every real write
113
+ it performed is backed up first, under
114
+ ~/.trellis/backups/) — omit <run-id> for the most recent
115
+ run. Refuses per-path (a conflict, not overwritten) if the
116
+ path changed since that run.
117
+ --list show available backup runs, don't restore
118
+ --dry-run preview what would be restored, write nothing
119
+ --json machine-readable output, no report text
56
120
 
57
121
  See docs/roadmap.md for what's built vs. planned.`);
58
122
  }
@@ -71,7 +135,9 @@ async function main(argv) {
71
135
  if (command === "onboard") {
72
136
  const agentIndex = rest.indexOf("--agent");
73
137
  const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
74
- const { exitCode } = await runOnboard({ agent, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
138
+ const manageIndex = rest.indexOf("--manage");
139
+ const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
140
+ const { exitCode } = await runOnboard({ agent, manage, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
75
141
  process.exitCode = exitCode;
76
142
  return;
77
143
  }
@@ -83,7 +149,9 @@ async function main(argv) {
83
149
  if (command === "migrate") {
84
150
  const fromIndex = rest.indexOf("--from");
85
151
  const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
86
- const { exitCode } = await runMigrate({ from, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
152
+ const onlyIndex = rest.indexOf("--only");
153
+ const only = onlyIndex >= 0 ? rest[onlyIndex + 1] : undefined;
154
+ const { exitCode } = await runMigrate({ from, only, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
87
155
  process.exitCode = exitCode;
88
156
  return;
89
157
  }
@@ -105,13 +173,79 @@ async function main(argv) {
105
173
  return;
106
174
  }
107
175
  if (command === "mcp") {
108
- const [subcommand] = rest;
176
+ const [subcommand, ...mcpRest] = rest;
177
+ const json = mcpRest.includes("--json");
178
+ const dryRun = mcpRest.includes("--dry-run");
179
+ if (subcommand === "sync") {
180
+ const { exitCode } = await runMcpSync({ json, dryRun });
181
+ process.exitCode = exitCode;
182
+ return;
183
+ }
184
+ if (subcommand === "list") {
185
+ process.exitCode = runMcpList({ json }).exitCode;
186
+ return;
187
+ }
188
+ if (subcommand === "add") {
189
+ const [name] = mcpRest;
190
+ process.exitCode = runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun }).exitCode;
191
+ return;
192
+ }
193
+ if (subcommand === "remove") {
194
+ const [name] = mcpRest;
195
+ if (!name) {
196
+ console.error("Usage: trellis mcp remove <name>");
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+ process.exitCode = runMcpRemove(name, { json, dryRun }).exitCode;
201
+ return;
202
+ }
203
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>\n`);
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+ if (command === "skill") {
208
+ const [subcommand, ...skillRest] = rest;
209
+ const json = skillRest.includes("--json");
210
+ const dryRun = skillRest.includes("--dry-run");
211
+ if (subcommand === "list") {
212
+ process.exitCode = runSkillList({ json }).exitCode;
213
+ return;
214
+ }
215
+ if (subcommand === "add") {
216
+ const [name] = skillRest;
217
+ const fromIndex = skillRest.indexOf("--from");
218
+ const from = fromIndex >= 0 ? skillRest[fromIndex + 1] : undefined;
219
+ if (!name || !from) {
220
+ console.error("Usage: trellis skill add <name> --from <path>");
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ process.exitCode = runSkillAdd(name, from, { json, dryRun }).exitCode;
225
+ return;
226
+ }
227
+ if (subcommand === "remove") {
228
+ const [name] = skillRest;
229
+ if (!name) {
230
+ console.error("Usage: trellis skill remove <name>");
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ process.exitCode = runSkillRemove(name, { json, dryRun }).exitCode;
235
+ return;
236
+ }
237
+ console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>\n`);
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ if (command === "memory") {
242
+ const [subcommand, ...memoryRest] = rest;
109
243
  if (subcommand !== "sync") {
110
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync\n`);
244
+ console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync\n`);
111
245
  process.exitCode = 1;
112
246
  return;
113
247
  }
114
- const { exitCode } = await runMcpSync({ json: rest.includes("--json") });
248
+ const { exitCode } = runMemorySync({ json: memoryRest.includes("--json"), dryRun: memoryRest.includes("--dry-run") });
115
249
  process.exitCode = exitCode;
116
250
  return;
117
251
  }
@@ -126,6 +260,15 @@ async function main(argv) {
126
260
  process.exitCode = exitCode;
127
261
  return;
128
262
  }
263
+ if (command === "rollback") {
264
+ const list = rest.includes("--list");
265
+ const dryRun = rest.includes("--dry-run");
266
+ const json = rest.includes("--json");
267
+ const runId = rest.find((arg) => !arg.startsWith("--"));
268
+ const { exitCode } = await runRollback({ runId, list, dryRun, json });
269
+ process.exitCode = exitCode;
270
+ return;
271
+ }
129
272
  console.error(`\`trellis ${command}\` is not implemented yet — this is a pre-alpha scaffold.\nSee docs/roadmap.md for status.`);
130
273
  process.exitCode = 1;
131
274
  }
@@ -71,6 +71,16 @@ reject_patterns:
71
71
  ${patternLines}
72
72
  `;
73
73
  }
74
+ /** Zero managed agents is the correct starting point (trellis-managed-agents
75
+ * design.md D1) — `trellis onboard`'s managed-set selection is what
76
+ * populates this, never `init` guessing on its behalf. */
77
+ function managedYamlTemplate() {
78
+ return `# Agents Trellis is authorized to write to. Empty means none yet —
79
+ # run \`trellis onboard\` or list agent ids here yourself, e.g.:
80
+ # agents: [pi, codex]
81
+ agents: []
82
+ `;
83
+ }
74
84
  /**
75
85
  * Trellis never spawns an installer itself (global package installs are
76
86
  * exactly the kind of irreversible, system-wide action that needs the
@@ -102,6 +112,7 @@ export async function collectInitReport(homeDir = homedir()) {
102
112
  ensureFile(join(root, "agents.md"), AGENTS_MD_TEMPLATE),
103
113
  ensureFile(join(root, "mcp", "servers.yaml"), serversYamlTemplate()),
104
114
  ensureFile(join(root, "secrets.policy.yaml"), secretsPolicyYamlTemplate()),
115
+ ensureFile(join(root, "managed.yaml"), managedYamlTemplate()),
105
116
  ];
106
117
  const probes = [
107
118
  { agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir) },