agent-trellis 0.2.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 (50) hide show
  1. package/README.md +12 -0
  2. package/dist/adapters/claude-code.d.ts +3 -2
  3. package/dist/adapters/claude-code.js +21 -8
  4. package/dist/adapters/codex.d.ts +6 -3
  5. package/dist/adapters/codex.js +41 -10
  6. package/dist/adapters/jsonMcp.d.ts +15 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +3 -2
  9. package/dist/adapters/kiro.js +22 -9
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +39 -6
  12. package/dist/cli.js +123 -10
  13. package/dist/commands/mcp.d.ts +101 -7
  14. package/dist/commands/mcp.js +227 -10
  15. package/dist/commands/memory.d.ts +39 -0
  16. package/dist/commands/memory.js +78 -0
  17. package/dist/commands/migrate.d.ts +30 -4
  18. package/dist/commands/migrate.js +83 -16
  19. package/dist/commands/onboard.d.ts +18 -8
  20. package/dist/commands/onboard.js +124 -14
  21. package/dist/commands/skill.d.ts +51 -0
  22. package/dist/commands/skill.js +104 -0
  23. package/dist/core/adapter.d.ts +18 -8
  24. package/dist/core/canonical.d.ts +26 -1
  25. package/dist/core/canonical.js +81 -3
  26. package/dist/core/types.d.ts +17 -0
  27. package/dist/lib/deepEqual.d.ts +8 -0
  28. package/dist/lib/deepEqual.js +26 -0
  29. package/dist/lib/dirEquals.d.ts +9 -0
  30. package/dist/lib/dirEquals.js +15 -1
  31. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  32. package/dist/lib/mcpMigrateRead.js +188 -0
  33. package/dist/lib/mcpOwnership.d.ts +25 -0
  34. package/dist/lib/mcpOwnership.js +50 -0
  35. package/dist/lib/memoryGraph.d.ts +60 -0
  36. package/dist/lib/memoryGraph.js +101 -0
  37. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  38. package/dist/lib/realHomeSnapshot.js +77 -0
  39. package/dist/lib/terminalPicker.d.ts +45 -0
  40. package/dist/lib/terminalPicker.js +193 -0
  41. package/dist/lib/tomlSection.d.ts +20 -6
  42. package/dist/lib/tomlSection.js +78 -12
  43. package/dist/pi-bridge/bundle.js +76 -46
  44. package/dist/pi-bridge/index.js +7 -2
  45. package/dist/probes/codex.js +10 -2
  46. package/docs/architecture.md +7 -4
  47. package/docs/getting-started.md +166 -10
  48. package/docs/roadmap.md +311 -0
  49. package/package.json +1 -1
  50. package/schema/servers.example.yaml +39 -2
@@ -4,26 +4,52 @@
4
4
  * Pure `collectMigratePlan` / effectful `applyMigratePlan`, same
5
5
  * plan-then-apply split every adapter already uses.
6
6
  */
7
- import type { AgentId } from "../core/types.js";
8
- export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "already-migrated" | "conflict";
7
+ import type { AgentId, McpServerDef } from "../core/types.js";
8
+ export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "conflict";
9
+ /** Internal kind naming, unchanged since before `--only` existed
10
+ * (trellis-migrate-category-selection design.md D2) — the CLI-facing
11
+ * flag value is the plural `"skills"`, mapped to this singular `"skill"`
12
+ * in `runMigrate`, not renamed here to avoid touching every existing
13
+ * `MigratePlanItem.kind` comparison for no functional reason. */
14
+ export type MigrateKind = "skill" | "instructions" | "mcp";
9
15
  export interface MigratePlanItem {
10
- kind: "skill" | "instructions";
16
+ kind: MigrateKind;
11
17
  name: string;
12
18
  action: MigrateAction;
13
19
  detail: string;
14
20
  /** Only set when action === "create"; consumed by applyMigratePlan. */
15
21
  sourceDir?: string;
16
22
  sourceContent?: string;
23
+ /** Only set when kind === "mcp" && action === "create". */
24
+ mcpDef?: McpServerDef;
17
25
  }
18
26
  export interface MigratePlan {
19
27
  agent: AgentId;
20
28
  present: boolean;
21
29
  items: MigratePlanItem[];
22
30
  }
23
- export declare function collectMigratePlan(agent: AgentId, homeDir?: string): Promise<MigratePlan>;
31
+ /**
32
+ * `only` restricts which kind(s) are even considered — not a post-hoc
33
+ * filter on a fully-computed plan (trellis-migrate-category-selection
34
+ * design.md D3): the excluded kind's canonical path is never read for
35
+ * comparison and never appears in the plan, not even as a suppressed
36
+ * conflict. Omitting `only` (or passing both kinds) is exactly today's
37
+ * behavior.
38
+ */
39
+ export declare function collectMigratePlan(agent: AgentId, homeDir?: string, only?: readonly MigrateKind[]): Promise<MigratePlan>;
24
40
  export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string): void;
41
+ /** CLI-facing spelling: `"skills"` (plural — a run usually touches more
42
+ * than one), `"instructions"` (already singular-shaped), or `"mcp"`
43
+ * (already the CLI's own convention, matching `trellis mcp`'s own
44
+ * command name). Mapped to `MigrateKind` in `runMigrate`, the one place
45
+ * this translation lives. */
46
+ export type MigrateOnlyValue = "skills" | "instructions" | "mcp";
25
47
  export interface RunMigrateOptions {
26
48
  from?: string;
49
+ /** Restricts the run to one category (`"skills"`, `"instructions"`, or
50
+ * `"mcp"`, raw and unvalidated same as `from` — `runMigrate` checks
51
+ * it). `undefined` means all three, exactly as before `"mcp"` existed. */
52
+ only?: string;
27
53
  dryRun?: boolean;
28
54
  json?: boolean;
29
55
  /** Defaults to the real `~`; overridable for tests only. */
@@ -12,7 +12,10 @@ import * as codexProbe from "../probes/codex.js";
12
12
  import * as kiroProbe from "../probes/kiro.js";
13
13
  import * as piProbe from "../probes/pi.js";
14
14
  import { AGENTS_MD_TEMPLATE } from "./init.js";
15
- import { dirContentsEqual } from "../lib/dirEquals.js";
15
+ import { decideDirImport } from "../lib/dirEquals.js";
16
+ import { deepEqual } from "../lib/deepEqual.js";
17
+ import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs } from "../lib/mcpMigrateRead.js";
18
+ import { loadCanonicalSource, upsertServerYaml } from "../core/canonical.js";
16
19
  import { ALL_AGENTS } from "../core/types.js";
17
20
  const PROBES = {
18
21
  "claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
@@ -20,6 +23,14 @@ const PROBES = {
20
23
  kiro: (homeDir) => kiroProbe.probe(homeDir),
21
24
  pi: (homeDir) => piProbe.probe(homeDir),
22
25
  };
26
+ /** pi has no static MCP config to read at all (roadmap.md P14/
27
+ * trellis-migrate-mcp-servers) — deliberately absent, not an oversight;
28
+ * `collectMigratePlan` skips the `mcp` category entirely for pi. */
29
+ const MCP_READERS = {
30
+ "claude-code": readClaudeCodeMcpDefs,
31
+ kiro: readKiroMcpDefs,
32
+ codex: readCodexMcpDefs,
33
+ };
23
34
  function planSkill(name, sourceDir, isSymlink, caseCorrect, canonicalDir) {
24
35
  if (isSymlink) {
25
36
  return { kind: "skill", name, action: "skip-symlink", detail: "shared in from elsewhere, not this agent's own content" };
@@ -27,13 +38,14 @@ function planSkill(name, sourceDir, isSymlink, caseCorrect, canonicalDir) {
27
38
  if (!caseCorrect) {
28
39
  return { kind: "skill", name, action: "skip-case-broken", detail: "already undiscoverable on at least one other agent — fix on the source before migrating" };
29
40
  }
30
- if (!existsSync(canonicalDir)) {
31
- return { kind: "skill", name, action: "create", detail: `will copy from ${sourceDir}`, sourceDir };
32
- }
33
- if (dirContentsEqual(sourceDir, canonicalDir)) {
34
- return { kind: "skill", name, action: "already-migrated", detail: "canonical content is byte-identical" };
41
+ switch (decideDirImport(sourceDir, canonicalDir)) {
42
+ case "create":
43
+ return { kind: "skill", name, action: "create", detail: `will copy from ${sourceDir}`, sourceDir };
44
+ case "already-present":
45
+ return { kind: "skill", name, action: "already-migrated", detail: "canonical content is byte-identical" };
46
+ case "conflict":
47
+ return { kind: "skill", name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
35
48
  }
36
- return { kind: "skill", name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
37
49
  }
38
50
  function planInstructions(snapshot, canonicalAgentsMd) {
39
51
  if (!snapshot.instructionsFile)
@@ -60,21 +72,56 @@ function planInstructions(snapshot, canonicalAgentsMd) {
60
72
  }
61
73
  return { kind: "instructions", name: "agents.md", action: "conflict", detail: "canonical agents.md already has different real content — resolve by hand" };
62
74
  }
63
- export async function collectMigratePlan(agent, homeDir = homedir()) {
75
+ function planMcpServer(name, def, existing) {
76
+ if (existing === undefined) {
77
+ return { kind: "mcp", name, action: "create", detail: "will add to servers.yaml", mcpDef: def };
78
+ }
79
+ if (deepEqual(existing, def)) {
80
+ return { kind: "mcp", name, action: "already-migrated", detail: "canonical definition is already identical" };
81
+ }
82
+ return { kind: "mcp", name, action: "conflict", detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand` };
83
+ }
84
+ /**
85
+ * `only` restricts which kind(s) are even considered — not a post-hoc
86
+ * filter on a fully-computed plan (trellis-migrate-category-selection
87
+ * design.md D3): the excluded kind's canonical path is never read for
88
+ * comparison and never appears in the plan, not even as a suppressed
89
+ * conflict. Omitting `only` (or passing both kinds) is exactly today's
90
+ * behavior.
91
+ */
92
+ export async function collectMigratePlan(agent, homeDir = homedir(), only) {
64
93
  const snapshot = await PROBES[agent](homeDir);
65
94
  if (!snapshot.present) {
66
95
  return { agent, present: false, items: [] };
67
96
  }
68
97
  const canonicalRoot = join(homeDir, ".trellis");
69
98
  const items = [];
70
- for (const root of snapshot.skillRoots) {
71
- for (const skill of root.skills) {
72
- items.push(planSkill(skill.name, skill.dir, skill.isSymlink, skill.caseCorrect, join(canonicalRoot, "skills", skill.name)));
99
+ const wants = (kind) => !only || only.includes(kind);
100
+ if (wants("skill")) {
101
+ for (const root of snapshot.skillRoots) {
102
+ for (const skill of root.skills) {
103
+ items.push(planSkill(skill.name, skill.dir, skill.isSymlink, skill.caseCorrect, join(canonicalRoot, "skills", skill.name)));
104
+ }
105
+ }
106
+ }
107
+ if (wants("instructions")) {
108
+ const instructionsItem = planInstructions(snapshot, join(canonicalRoot, "agents.md"));
109
+ if (instructionsItem)
110
+ items.push(instructionsItem);
111
+ }
112
+ if (wants("mcp")) {
113
+ const reader = MCP_READERS[agent];
114
+ if (reader) {
115
+ const canonicalServers = loadCanonicalSource(homeDir).mcp.servers;
116
+ const { entries, unsupported } = reader(homeDir);
117
+ for (const { name, def } of entries) {
118
+ items.push(planMcpServer(name, def, canonicalServers[name]));
119
+ }
120
+ for (const { name, reason } of unsupported) {
121
+ items.push({ kind: "mcp", name, action: "skip-unsupported", detail: reason });
122
+ }
73
123
  }
74
124
  }
75
- const instructionsItem = planInstructions(snapshot, join(canonicalRoot, "agents.md"));
76
- if (instructionsItem)
77
- items.push(instructionsItem);
78
125
  return { agent, present: true, items };
79
126
  }
80
127
  export function applyMigratePlan(plan, homeDir = homedir()) {
@@ -91,8 +138,22 @@ export function applyMigratePlan(plan, homeDir = homedir()) {
91
138
  mkdirSync(canonicalRoot, { recursive: true });
92
139
  writeFileSync(join(canonicalRoot, "agents.md"), item.sourceContent);
93
140
  }
141
+ else if (item.kind === "mcp" && item.mcpDef) {
142
+ upsertServerYaml(join(canonicalRoot, "mcp", "servers.yaml"), item.name, item.mcpDef);
143
+ }
94
144
  }
95
145
  }
146
+ const ONLY_VALUES = ["skills", "instructions", "mcp"];
147
+ function isMigrateOnlyValue(value) {
148
+ return ONLY_VALUES.includes(value);
149
+ }
150
+ function toMigrateKinds(only) {
151
+ if (only === "skills")
152
+ return ["skill"];
153
+ if (only === "instructions")
154
+ return ["instructions"];
155
+ return ["mcp"];
156
+ }
96
157
  export async function runMigrate(opts = {}) {
97
158
  const homeDir = opts.homeDir ?? homedir();
98
159
  if (!opts.from || !ALL_AGENTS.includes(opts.from)) {
@@ -100,7 +161,12 @@ export async function runMigrate(opts = {}) {
100
161
  return { exitCode: 1 };
101
162
  }
102
163
  const agent = opts.from;
103
- const plan = await collectMigratePlan(agent, homeDir);
164
+ if (opts.only !== undefined && !isMigrateOnlyValue(opts.only)) {
165
+ console.error(`--only must be one of: ${ONLY_VALUES.join(", ")} (got ${opts.only})`);
166
+ return { exitCode: 1 };
167
+ }
168
+ const only = opts.only && isMigrateOnlyValue(opts.only) ? toMigrateKinds(opts.only) : undefined;
169
+ const plan = await collectMigratePlan(agent, homeDir, only);
104
170
  if (!plan.present) {
105
171
  console.error(`${agent} is not present on this machine — nothing to migrate.`);
106
172
  return { exitCode: 1 };
@@ -127,6 +193,7 @@ export function printPlan(plan, dryRun) {
127
193
  return;
128
194
  }
129
195
  for (const item of plan.items) {
130
- console.log(` [${item.action}] ${item.kind === "skill" ? `skill "${item.name}"` : "instructions"} ${item.detail}`);
196
+ const label = item.kind === "skill" ? `skill "${item.name}"` : item.kind === "mcp" ? `mcp server "${item.name}"` : "instructions";
197
+ console.log(` [${item.action}] ${label} — ${item.detail}`);
131
198
  }
132
199
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * `trellis onboard` — chains `init` → agent detection → migration-source
3
- * resolution → managed-agent-set selection (install-then-manage for a
3
+ * resolution → migrate-category selection (trellis-migrate-category-
4
+ * selection) → managed-agent-set selection (install-then-manage for a
4
5
  * selected, not-yet-present agent) → `migrate` → `sync` → `mcp sync` →
5
6
  * `secrets audit` into one guided flow (trellis-cli-onboard,
6
7
  * trellis-managed-agents) — a user should never have to type a second
7
- * command by hand to finish onboarding. Source and managed set are
8
- * independent choices (design.md D2): importing from a source never
9
- * writes back to it, and it is not implicitly added to the managed set.
10
- * Orchestrates existing commands' own plan/apply logic; no new
11
- * skill-copy, symlink, conflict-detection, or secrets-scanning judgment
12
- * is made here.
8
+ * command by hand to finish onboarding. Source, categories, and managed
9
+ * set are three independent choices (design.md D2): importing from a
10
+ * source never writes back to it, and it is not implicitly added to the
11
+ * managed set. Orchestrates existing commands' own plan/apply logic; no
12
+ * new skill-copy, symlink, conflict-detection, or secrets-scanning
13
+ * judgment is made here.
13
14
  */
14
15
  import type { AgentId } from "../core/types.js";
15
- import type { MigratePlan } from "./migrate.js";
16
+ import type { MigrateKind, MigratePlan } from "./migrate.js";
16
17
  import type { SyncReport } from "./sync.js";
17
18
  import type { McpSyncReport } from "./mcp.js";
18
19
  import type { SecretsAuditReport } from "./secretsAudit.js";
@@ -50,6 +51,11 @@ export interface RunOnboardOptions {
50
51
  * pre-parsed list — so the same parsing/validation code path is
51
52
  * exercised whether the answer came from a flag or a prompt. */
52
53
  promptForManagedAgents?: (candidates: OnboardAgentSummary[], alreadyManaged: readonly AgentId[]) => Promise<string>;
54
+ /** Test-only: replaces the real migrate-category picker/default logic
55
+ * (trellis-migrate-category-selection). An empty array is a valid
56
+ * answer — "skip migrate for this run" (design.md D6) — distinct from
57
+ * `source` being unresolved at all. */
58
+ promptForMigrateCategories?: (source: OnboardAgentSummary) => Promise<MigrateKind[]>;
53
59
  /** Test-only: injected into every `confirmAndInstall` call for a
54
60
  * selected, not-yet-present agent. Never a real terminal prompt or a
55
61
  * real `npm install` in a unit test. */
@@ -70,6 +76,10 @@ export interface OnboardResult {
70
76
  managedAgents?: AgentId[];
71
77
  installResults?: OnboardInstallResult[];
72
78
  migratePlan?: MigratePlan;
79
+ /** Set when a source was resolved but zero migrate categories were
80
+ * selected (design.md D6) — distinct from `migratePlan` being absent
81
+ * because no source existed at all, which prints nothing here. */
82
+ migrateSkipped?: string;
73
83
  syncReport?: SyncReport;
74
84
  mcpSyncReport?: McpSyncReport;
75
85
  secretsAuditReport?: SecretsAuditReport;
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * `trellis onboard` — chains `init` → agent detection → migration-source
3
- * resolution → managed-agent-set selection (install-then-manage for a
3
+ * resolution → migrate-category selection (trellis-migrate-category-
4
+ * selection) → managed-agent-set selection (install-then-manage for a
4
5
  * selected, not-yet-present agent) → `migrate` → `sync` → `mcp sync` →
5
6
  * `secrets audit` into one guided flow (trellis-cli-onboard,
6
7
  * trellis-managed-agents) — a user should never have to type a second
7
- * command by hand to finish onboarding. Source and managed set are
8
- * independent choices (design.md D2): importing from a source never
9
- * writes back to it, and it is not implicitly added to the managed set.
10
- * Orchestrates existing commands' own plan/apply logic; no new
11
- * skill-copy, symlink, conflict-detection, or secrets-scanning judgment
12
- * is made here.
8
+ * command by hand to finish onboarding. Source, categories, and managed
9
+ * set are three independent choices (design.md D2): importing from a
10
+ * source never writes back to it, and it is not implicitly added to the
11
+ * managed set. Orchestrates existing commands' own plan/apply logic; no
12
+ * new skill-copy, symlink, conflict-detection, or secrets-scanning
13
+ * judgment is made here.
13
14
  */
14
15
  import { createInterface } from "node:readline/promises";
15
16
  import { homedir } from "node:os";
16
17
  import { writeFileSync } from "node:fs";
17
18
  import { join } from "node:path";
19
+ import { canUseInteractivePicker, runMultiSelectPicker, runSingleSelectPicker } from "../lib/terminalPicker.js";
18
20
  import * as claudeCodeProbe from "../probes/claude-code.js";
19
21
  import * as codexProbe from "../probes/codex.js";
20
22
  import * as kiroProbe from "../probes/kiro.js";
@@ -48,13 +50,19 @@ export async function collectOnboardSummary(homeDir = homedir()) {
48
50
  function hasContent(s) {
49
51
  return s.skillCount > 0 || s.hasRealInstructions;
50
52
  }
51
- async function promptForAgentReal(present) {
53
+ function agentSummaryLabel(s) {
54
+ const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
55
+ return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`;
56
+ }
57
+ /** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
58
+ * D2) — used only when the terminal can't support the raw-mode picker
59
+ * (`canUseInteractivePicker()` false). Unchanged from before that change. */
60
+ async function promptForAgentNumbered(present) {
52
61
  const rl = createInterface({ input: process.stdin, output: process.stdout });
53
62
  try {
54
63
  console.log("Multiple agents detected:");
55
64
  present.forEach((s, i) => {
56
- const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
57
- console.log(` ${i + 1}) ${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`);
65
+ console.log(` ${i + 1}) ${agentSummaryLabel(s)}`);
58
66
  });
59
67
  for (let attempt = 0; attempt < 2; attempt++) {
60
68
  const answer = (await rl.question(`Choose a migration source [1-${present.length}]: `)).trim();
@@ -74,7 +82,28 @@ async function promptForAgentReal(present) {
74
82
  rl.close();
75
83
  }
76
84
  }
77
- async function promptForManagedAgentsReal(candidates, alreadyManaged) {
85
+ /** Arrow-key single-select on a real, raw-mode-capable terminal; falls
86
+ * back to `promptForAgentNumbered` otherwise. Resolves to the exact same
87
+ * string contract either way — a real agent id — so `resolveMigrationSource`
88
+ * and every test injecting `RunOnboardOptions.promptForAgent` need no
89
+ * changes (design.md D5). Cancel (Ctrl+C) prints a message and exits
90
+ * directly, rather than threading a new "cancelled" state through the
91
+ * rest of onboard's return-based refusal plumbing. */
92
+ async function promptForAgentReal(present) {
93
+ if (!canUseInteractivePicker()) {
94
+ return promptForAgentNumbered(present);
95
+ }
96
+ console.log("Multiple agents detected — use Up/Down (or j/k) and Enter to choose a migration source:");
97
+ const index = await runSingleSelectPicker(present.map(agentSummaryLabel));
98
+ if (index === null) {
99
+ console.log("cancelled, no changes made");
100
+ process.exit(1);
101
+ }
102
+ return present[index].agent;
103
+ }
104
+ /** Numbered-typing fallback — unchanged from before this change (see
105
+ * `promptForAgentNumbered`'s doc comment). */
106
+ async function promptForManagedAgentsNumbered(candidates, alreadyManaged) {
78
107
  const rl = createInterface({ input: process.stdin, output: process.stdout });
79
108
  try {
80
109
  console.log("Which agents should Trellis manage? (comma-separated numbers; enter for none new)");
@@ -90,6 +119,74 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
90
119
  rl.close();
91
120
  }
92
121
  }
122
+ /** Checkbox multi-select on a real, raw-mode-capable terminal; falls
123
+ * back to `promptForManagedAgentsNumbered` otherwise. Resolves to the
124
+ * same comma-separated-agent-id string contract `parseManagedSelection`
125
+ * already parses — an empty selection resolves to `""` (comma-separated
126
+ * join of zero items), which `parseManagedSelection` already treats as
127
+ * "none" (design.md D5). Cancel behaves like `promptForAgentReal`'s. */
128
+ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
129
+ if (!canUseInteractivePicker()) {
130
+ return promptForManagedAgentsNumbered(candidates, alreadyManaged);
131
+ }
132
+ console.log("Which agents should Trellis manage? Up/Down (or j/k) to move, Space to toggle, Enter to confirm:");
133
+ const labels = candidates.map((s) => {
134
+ const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
135
+ return `${s.agent} — ${status}`;
136
+ });
137
+ const initiallyChecked = candidates.map((s) => alreadyManaged.includes(s.agent));
138
+ const indices = await runMultiSelectPicker(labels, initiallyChecked);
139
+ if (indices === null) {
140
+ console.log("cancelled, no changes made");
141
+ process.exit(1);
142
+ }
143
+ return indices.map((i) => candidates[i].agent).join(",");
144
+ }
145
+ /**
146
+ * Resolves which categories (skills, instructions) to migrate from a
147
+ * resolved source (trellis-migrate-category-selection). Unlike the two
148
+ * pickers above, there is no numbered-text fallback to preserve parity
149
+ * with — this concept never existed before this change, so "can't
150
+ * prompt" simply means "default to whichever kind(s) actually have real
151
+ * content, silently" (design.md D4). The picker itself is only offered
152
+ * when the choice is meaningful — both kinds present, a capable
153
+ * terminal, and not a `--json` run (design.md D5). An empty result is a
154
+ * valid answer: "skip migrate for this run" (design.md D6), left for
155
+ * the caller to act on.
156
+ */
157
+ async function resolveMigrateCategories(source, opts) {
158
+ const wantsSkills = source.skillCount > 0;
159
+ const wantsInstructions = source.hasRealInstructions;
160
+ // The choice is only meaningful when both kinds are real — same gate
161
+ // for the injected test seam as for the real picker, mirroring how
162
+ // `promptForAgent`/`promptForManagedAgents` are only ever consulted
163
+ // when their own real prompt would actually apply.
164
+ if (wantsSkills && wantsInstructions && !opts.json) {
165
+ if (opts.promptForMigrateCategories) {
166
+ return opts.promptForMigrateCategories(source);
167
+ }
168
+ if (canUseInteractivePicker()) {
169
+ console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
170
+ const indices = await runMultiSelectPicker(["skills", "instructions"], [true, true]);
171
+ if (indices === null) {
172
+ console.log("cancelled, no changes made");
173
+ process.exit(1);
174
+ }
175
+ const kinds = [];
176
+ if (indices.includes(0))
177
+ kinds.push("skill");
178
+ if (indices.includes(1))
179
+ kinds.push("instructions");
180
+ return kinds;
181
+ }
182
+ }
183
+ const kinds = [];
184
+ if (wantsSkills)
185
+ kinds.push("skill");
186
+ if (wantsInstructions)
187
+ kinds.push("instructions");
188
+ return kinds;
189
+ }
93
190
  /** Shared by `--manage` and the interactive prompt's answer — same
94
191
  * grammar either way (design.md D4): a real agent id list, comma- or
95
192
  * whitespace-separated numbers referring to `candidates`' own order, or
@@ -224,10 +321,18 @@ export async function collectOnboardPlan(opts = {}) {
224
321
  writeManagedYaml(homeDir, managedAgents);
225
322
  }
226
323
  let migratePlan;
324
+ let migrateSkipped;
227
325
  if (source) {
228
- migratePlan = await collectMigratePlan(source, homeDir);
229
- if (!opts.dryRun) {
230
- applyMigratePlan(migratePlan, homeDir);
326
+ const sourceSummary = summary.find((s) => s.agent === source);
327
+ const categories = await resolveMigrateCategories(sourceSummary, opts);
328
+ if (categories.length > 0) {
329
+ migratePlan = await collectMigratePlan(source, homeDir, categories);
330
+ if (!opts.dryRun) {
331
+ applyMigratePlan(migratePlan, homeDir);
332
+ }
333
+ }
334
+ else {
335
+ migrateSkipped = "migrate skipped — no categories selected";
231
336
  }
232
337
  }
233
338
  // One session for the whole chained run (trellis-backup-rollback) —
@@ -249,6 +354,7 @@ export async function collectOnboardPlan(opts = {}) {
249
354
  managedAgents,
250
355
  installResults: installResults.length > 0 ? installResults : undefined,
251
356
  migratePlan,
357
+ migrateSkipped,
252
358
  syncReport,
253
359
  mcpSyncReport,
254
360
  secretsAuditReport,
@@ -313,6 +419,10 @@ function printResult(result, dryRun) {
313
419
  console.log("");
314
420
  printMigratePlan(result.migratePlan, false);
315
421
  }
422
+ else if (result.migrateSkipped) {
423
+ console.log("");
424
+ console.log(result.migrateSkipped);
425
+ }
316
426
  if (result.syncReport) {
317
427
  console.log("\nsync");
318
428
  printSyncReport(result.syncReport, false);
@@ -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
+ }
@@ -20,14 +20,18 @@ export interface AdapterPlanItem {
20
20
  /**
21
21
  * "create" also covers repair (wrong symlink target, or an MCP server
22
22
  * definition that differs from canonical); "remove" is the delete half
23
- * for skills/instructions a Trellis-managed symlink whose canonical
24
- * entry is gone or was just scoped away from this agent. MCP server
25
- * items never use "remove" (see `kind: "mcp"` below — no ownership
26
- * marker exists yet to make that provably safe, trellis-mcp-sync-p2
27
- * design.md D7). "conflict" is either a real, non-symlink path
28
- * occupying a spot Trellis would otherwise touch, or an MCP server
29
- * refused for a collision/secrets-guard reason reported, never acted
30
- * 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.
31
35
  */
32
36
  action: "create" | "remove" | "conflict";
33
37
  /** Lets `trellis sync skills` / `trellis sync instructions` /
@@ -64,6 +68,12 @@ export interface AdapterPlanItem {
64
68
  name: string;
65
69
  def: McpServerDef;
66
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
+ };
67
77
  /** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
68
78
  * and `action === "create"`: the full, already-deduplicated array to
69
79
  * write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was