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.
- package/README.md +12 -0
- package/dist/adapters/claude-code.d.ts +3 -2
- package/dist/adapters/claude-code.js +21 -8
- package/dist/adapters/codex.d.ts +6 -3
- package/dist/adapters/codex.js +41 -10
- package/dist/adapters/jsonMcp.d.ts +15 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +3 -2
- package/dist/adapters/kiro.js +22 -9
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +39 -6
- package/dist/cli.js +123 -10
- package/dist/commands/mcp.d.ts +101 -7
- package/dist/commands/mcp.js +227 -10
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +18 -8
- package/dist/commands/onboard.js +124 -14
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/core/adapter.d.ts +18 -8
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +81 -3
- package/dist/core/types.d.ts +17 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +76 -46
- package/dist/pi-bridge/index.js +7 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +166 -10
- package/docs/roadmap.md +311 -0
- package/package.json +1 -1
- 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:
|
|
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
|
-
|
|
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. */
|
package/dist/commands/migrate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
|
8
|
-
* independent choices (design.md D2): importing from a
|
|
9
|
-
* writes back to it, and it is not implicitly added to the
|
|
10
|
-
* Orchestrates existing commands' own plan/apply logic; no
|
|
11
|
-
* skill-copy, symlink, conflict-detection, or secrets-scanning
|
|
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;
|
package/dist/commands/onboard.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `trellis onboard` — chains `init` → agent detection → migration-source
|
|
3
|
-
* resolution →
|
|
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
|
|
8
|
-
* independent choices (design.md D2): importing from a
|
|
9
|
-
* writes back to it, and it is not implicitly added to the
|
|
10
|
-
* Orchestrates existing commands' own plan/apply logic; no
|
|
11
|
-
* skill-copy, symlink, conflict-detection, or secrets-scanning
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -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
|
|
24
|
-
* entry is gone or was just scoped away from this agent
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|