agent-trellis 0.2.0 → 0.4.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 +47 -0
- package/dist/commands/memory.js +89 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +27 -8
- package/dist/commands/onboard.js +144 -17
- 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 +180 -15
- package/docs/roadmap.md +341 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis memory sync` — ingests canonical `memories/*.md` into the
|
|
3
|
+
* on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
|
|
4
|
+
* itself reads at startup, closing P6's explicitly-left-open "auto-
|
|
5
|
+
* ingesting memories/*.md content into the running memory server's
|
|
6
|
+
* store" gap (trellis-memory-sync). Never spawns or talks to a running
|
|
7
|
+
* server process — this is a plain file write, same posture as every
|
|
8
|
+
* other Trellis write.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
14
|
+
import { planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
|
|
15
|
+
const MEMORY_SERVER_NAME = "memory";
|
|
16
|
+
/**
|
|
17
|
+
* Looks up `mcp.servers["memory"]` specifically (matching `schema/
|
|
18
|
+
* servers.example.yaml`'s own naming convention for the default shared-
|
|
19
|
+
* memory backend) — not a heuristic scan for any server whose command
|
|
20
|
+
* happens to mention `@modelcontextprotocol/server-memory`, since a
|
|
21
|
+
* user could reasonably name or configure it differently and this
|
|
22
|
+
* would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
|
|
23
|
+
* set explicitly: the server's own unset-env default resolves relative
|
|
24
|
+
* to wherever `npx` cached the package, a location Trellis has no
|
|
25
|
+
* reliable way to predict.
|
|
26
|
+
*/
|
|
27
|
+
export function collectMemorySyncResult(homeDir = homedir()) {
|
|
28
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
29
|
+
const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
|
|
30
|
+
const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
|
|
31
|
+
if (!server || !rawPath) {
|
|
32
|
+
return {
|
|
33
|
+
configured: false,
|
|
34
|
+
reason: `no "${MEMORY_SERVER_NAME}" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const graphPath = rawPath.replace(/^~(?=$|\/)/, homeDir);
|
|
38
|
+
const currentContent = existsSync(graphPath) ? readFileSync(graphPath, "utf-8") : undefined;
|
|
39
|
+
const plan = planMemorySync(canonical, currentContent);
|
|
40
|
+
return { configured: true, graphPath, plan };
|
|
41
|
+
}
|
|
42
|
+
export function applyMemorySync(result) {
|
|
43
|
+
if (!result.configured || !result.plan.nextGraph)
|
|
44
|
+
return;
|
|
45
|
+
mkdirSync(dirname(result.graphPath), { recursive: true });
|
|
46
|
+
writeFileSync(result.graphPath, renderMemoryGraph(result.plan.nextGraph));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
|
|
50
|
+
* `onboard`'s own chained output can print this stage in the exact same
|
|
51
|
+
* format without a second, drifting copy — mirrors the
|
|
52
|
+
* `collect...Report`/`print...Report` export pair every other chained
|
|
53
|
+
* stage (`sync`, `mcp sync`, `secrets audit`) already has.
|
|
54
|
+
*/
|
|
55
|
+
export function printMemorySyncResult(result, dryRun) {
|
|
56
|
+
if (!result.configured) {
|
|
57
|
+
console.log(result.reason);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
console.log(`${dryRun ? "[dry run] " : ""}memory sync — ${result.graphPath}`);
|
|
61
|
+
if (result.plan.items.length === 0) {
|
|
62
|
+
console.log(" nothing in canonical memories/ yet");
|
|
63
|
+
}
|
|
64
|
+
for (const item of result.plan.items) {
|
|
65
|
+
console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function runMemorySync(opts = {}) {
|
|
69
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = collectMemorySyncResult(homeDir);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
76
|
+
return { exitCode: 1 };
|
|
77
|
+
}
|
|
78
|
+
if (!opts.dryRun) {
|
|
79
|
+
applyMemorySync(result);
|
|
80
|
+
}
|
|
81
|
+
if (opts.json) {
|
|
82
|
+
console.log(JSON.stringify(result, null, 2));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
printMemorySyncResult(result, opts.dryRun ?? false);
|
|
86
|
+
}
|
|
87
|
+
const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
|
|
88
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
89
|
+
}
|
|
@@ -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,21 +1,23 @@
|
|
|
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";
|
|
20
|
+
import type { MemorySyncResult } from "./memory.js";
|
|
19
21
|
import type { ConfirmAndInstallOptions } from "../lib/installAgent.js";
|
|
20
22
|
export interface OnboardAgentSummary {
|
|
21
23
|
agent: AgentId;
|
|
@@ -26,6 +28,13 @@ export interface OnboardAgentSummary {
|
|
|
26
28
|
* of that agent's own to potentially migrate, not a judgment about
|
|
27
29
|
* its quality or length. */
|
|
28
30
|
hasRealInstructions: boolean;
|
|
31
|
+
/** How many real MCP servers `migrate --only mcp` would find for this
|
|
32
|
+
* agent — computed via `collectMigratePlan(agent, homeDir, ["mcp"])`
|
|
33
|
+
* rather than a second reader dispatch, so this always agrees with
|
|
34
|
+
* what migrate itself will do (design.md D1, trellis-onboard-mcp-
|
|
35
|
+
* memory). 0 for pi, which has no MCP reader at all — not a gap here,
|
|
36
|
+
* the same fact `migrate.ts` already establishes. */
|
|
37
|
+
mcpServerCount: number;
|
|
29
38
|
}
|
|
30
39
|
export declare function collectOnboardSummary(homeDir?: string): Promise<OnboardAgentSummary[]>;
|
|
31
40
|
export interface RunOnboardOptions {
|
|
@@ -50,6 +59,11 @@ export interface RunOnboardOptions {
|
|
|
50
59
|
* pre-parsed list — so the same parsing/validation code path is
|
|
51
60
|
* exercised whether the answer came from a flag or a prompt. */
|
|
52
61
|
promptForManagedAgents?: (candidates: OnboardAgentSummary[], alreadyManaged: readonly AgentId[]) => Promise<string>;
|
|
62
|
+
/** Test-only: replaces the real migrate-category picker/default logic
|
|
63
|
+
* (trellis-migrate-category-selection). An empty array is a valid
|
|
64
|
+
* answer — "skip migrate for this run" (design.md D6) — distinct from
|
|
65
|
+
* `source` being unresolved at all. */
|
|
66
|
+
promptForMigrateCategories?: (source: OnboardAgentSummary) => Promise<MigrateKind[]>;
|
|
53
67
|
/** Test-only: injected into every `confirmAndInstall` call for a
|
|
54
68
|
* selected, not-yet-present agent. Never a real terminal prompt or a
|
|
55
69
|
* real `npm install` in a unit test. */
|
|
@@ -70,8 +84,13 @@ export interface OnboardResult {
|
|
|
70
84
|
managedAgents?: AgentId[];
|
|
71
85
|
installResults?: OnboardInstallResult[];
|
|
72
86
|
migratePlan?: MigratePlan;
|
|
87
|
+
/** Set when a source was resolved but zero migrate categories were
|
|
88
|
+
* selected (design.md D6) — distinct from `migratePlan` being absent
|
|
89
|
+
* because no source existed at all, which prints nothing here. */
|
|
90
|
+
migrateSkipped?: string;
|
|
73
91
|
syncReport?: SyncReport;
|
|
74
92
|
mcpSyncReport?: McpSyncReport;
|
|
93
|
+
memorySyncResult?: MemorySyncResult;
|
|
75
94
|
secretsAuditReport?: SecretsAuditReport;
|
|
76
95
|
refusal?: string;
|
|
77
96
|
/** Only set when no agent is present — the same values `--json` and
|
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";
|
|
@@ -26,6 +28,7 @@ import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } f
|
|
|
26
28
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
27
29
|
import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
|
|
28
30
|
import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
|
|
31
|
+
import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult } from "./memory.js";
|
|
29
32
|
import { openBackupSession } from "../lib/backup.js";
|
|
30
33
|
import { confirmAndInstall } from "../lib/installAgent.js";
|
|
31
34
|
const PROBES = {
|
|
@@ -38,23 +41,30 @@ export async function collectOnboardSummary(homeDir = homedir()) {
|
|
|
38
41
|
return Promise.all(ALL_AGENTS.map(async (agent) => {
|
|
39
42
|
const snapshot = await PROBES[agent](homeDir);
|
|
40
43
|
if (!snapshot.present) {
|
|
41
|
-
return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false };
|
|
44
|
+
return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false, mcpServerCount: 0 };
|
|
42
45
|
}
|
|
43
46
|
const skillNames = snapshot.skillRoots.flatMap((root) => root.skills.map((s) => s.name));
|
|
44
47
|
const hasRealInstructions = snapshot.instructionsFile !== undefined && !snapshot.instructionsFile.isSymlink;
|
|
45
|
-
|
|
48
|
+
const mcpPlan = await collectMigratePlan(agent, homeDir, ["mcp"]);
|
|
49
|
+
return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions, mcpServerCount: mcpPlan.items.length };
|
|
46
50
|
}));
|
|
47
51
|
}
|
|
48
52
|
function hasContent(s) {
|
|
49
|
-
return s.skillCount > 0 || s.hasRealInstructions;
|
|
53
|
+
return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0;
|
|
50
54
|
}
|
|
51
|
-
|
|
55
|
+
function agentSummaryLabel(s) {
|
|
56
|
+
const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
|
|
57
|
+
return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
|
|
58
|
+
}
|
|
59
|
+
/** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
|
|
60
|
+
* D2) — used only when the terminal can't support the raw-mode picker
|
|
61
|
+
* (`canUseInteractivePicker()` false). Unchanged from before that change. */
|
|
62
|
+
async function promptForAgentNumbered(present) {
|
|
52
63
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
53
64
|
try {
|
|
54
65
|
console.log("Multiple agents detected:");
|
|
55
66
|
present.forEach((s, i) => {
|
|
56
|
-
|
|
57
|
-
console.log(` ${i + 1}) ${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`);
|
|
67
|
+
console.log(` ${i + 1}) ${agentSummaryLabel(s)}`);
|
|
58
68
|
});
|
|
59
69
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
60
70
|
const answer = (await rl.question(`Choose a migration source [1-${present.length}]: `)).trim();
|
|
@@ -74,7 +84,28 @@ async function promptForAgentReal(present) {
|
|
|
74
84
|
rl.close();
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
|
-
|
|
87
|
+
/** Arrow-key single-select on a real, raw-mode-capable terminal; falls
|
|
88
|
+
* back to `promptForAgentNumbered` otherwise. Resolves to the exact same
|
|
89
|
+
* string contract either way — a real agent id — so `resolveMigrationSource`
|
|
90
|
+
* and every test injecting `RunOnboardOptions.promptForAgent` need no
|
|
91
|
+
* changes (design.md D5). Cancel (Ctrl+C) prints a message and exits
|
|
92
|
+
* directly, rather than threading a new "cancelled" state through the
|
|
93
|
+
* rest of onboard's return-based refusal plumbing. */
|
|
94
|
+
async function promptForAgentReal(present) {
|
|
95
|
+
if (!canUseInteractivePicker()) {
|
|
96
|
+
return promptForAgentNumbered(present);
|
|
97
|
+
}
|
|
98
|
+
console.log("Multiple agents detected — use Up/Down (or j/k) and Enter to choose a migration source:");
|
|
99
|
+
const index = await runSingleSelectPicker(present.map(agentSummaryLabel));
|
|
100
|
+
if (index === null) {
|
|
101
|
+
console.log("cancelled, no changes made");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
return present[index].agent;
|
|
105
|
+
}
|
|
106
|
+
/** Numbered-typing fallback — unchanged from before this change (see
|
|
107
|
+
* `promptForAgentNumbered`'s doc comment). */
|
|
108
|
+
async function promptForManagedAgentsNumbered(candidates, alreadyManaged) {
|
|
78
109
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
79
110
|
try {
|
|
80
111
|
console.log("Which agents should Trellis manage? (comma-separated numbers; enter for none new)");
|
|
@@ -90,6 +121,73 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
|
|
|
90
121
|
rl.close();
|
|
91
122
|
}
|
|
92
123
|
}
|
|
124
|
+
/** Checkbox multi-select on a real, raw-mode-capable terminal; falls
|
|
125
|
+
* back to `promptForManagedAgentsNumbered` otherwise. Resolves to the
|
|
126
|
+
* same comma-separated-agent-id string contract `parseManagedSelection`
|
|
127
|
+
* already parses — an empty selection resolves to `""` (comma-separated
|
|
128
|
+
* join of zero items), which `parseManagedSelection` already treats as
|
|
129
|
+
* "none" (design.md D5). Cancel behaves like `promptForAgentReal`'s. */
|
|
130
|
+
async function promptForManagedAgentsReal(candidates, alreadyManaged) {
|
|
131
|
+
if (!canUseInteractivePicker()) {
|
|
132
|
+
return promptForManagedAgentsNumbered(candidates, alreadyManaged);
|
|
133
|
+
}
|
|
134
|
+
console.log("Which agents should Trellis manage? Up/Down (or j/k) to move, Space to toggle, Enter to confirm:");
|
|
135
|
+
const labels = candidates.map((s) => {
|
|
136
|
+
const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
|
|
137
|
+
return `${s.agent} — ${status}`;
|
|
138
|
+
});
|
|
139
|
+
const initiallyChecked = candidates.map((s) => alreadyManaged.includes(s.agent));
|
|
140
|
+
const indices = await runMultiSelectPicker(labels, initiallyChecked);
|
|
141
|
+
if (indices === null) {
|
|
142
|
+
console.log("cancelled, no changes made");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
return indices.map((i) => candidates[i].agent).join(",");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolves which categories (skills, instructions) to migrate from a
|
|
149
|
+
* resolved source (trellis-migrate-category-selection, extended to a
|
|
150
|
+
* third category by trellis-onboard-mcp-memory design.md D2). Unlike the
|
|
151
|
+
* two pickers above, there is no numbered-text fallback to preserve
|
|
152
|
+
* parity with — this concept never existed before this change, so
|
|
153
|
+
* "can't prompt" simply means "default to whichever kind(s) actually
|
|
154
|
+
* have real content, silently" (design.md D4). The picker itself is
|
|
155
|
+
* only offered when the choice is meaningful — two or more kinds
|
|
156
|
+
* present, a capable terminal, and not a `--json` run (design.md D5).
|
|
157
|
+
* An empty result is a valid answer: "skip migrate for this run"
|
|
158
|
+
* (design.md D6), left for the caller to act on.
|
|
159
|
+
*/
|
|
160
|
+
async function resolveMigrateCategories(source, opts) {
|
|
161
|
+
// Built dynamically, in skill/instructions/mcp display order — not a
|
|
162
|
+
// fixed two- or three-slot structure, so a fourth category some day
|
|
163
|
+
// would only need an entry here, not a rewritten branch (design.md D2).
|
|
164
|
+
const candidates = [];
|
|
165
|
+
if (source.skillCount > 0)
|
|
166
|
+
candidates.push({ kind: "skill", label: "skills" });
|
|
167
|
+
if (source.hasRealInstructions)
|
|
168
|
+
candidates.push({ kind: "instructions", label: "instructions" });
|
|
169
|
+
if (source.mcpServerCount > 0)
|
|
170
|
+
candidates.push({ kind: "mcp", label: "mcp" });
|
|
171
|
+
// The choice is only meaningful when two or more kinds are real — same
|
|
172
|
+
// gate for the injected test seam as for the real picker, mirroring
|
|
173
|
+
// how `promptForAgent`/`promptForManagedAgents` are only ever
|
|
174
|
+
// consulted when their own real prompt would actually apply.
|
|
175
|
+
if (candidates.length > 1 && !opts.json) {
|
|
176
|
+
if (opts.promptForMigrateCategories) {
|
|
177
|
+
return opts.promptForMigrateCategories(source);
|
|
178
|
+
}
|
|
179
|
+
if (canUseInteractivePicker()) {
|
|
180
|
+
console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
|
|
181
|
+
const indices = await runMultiSelectPicker(candidates.map((c) => c.label), candidates.map(() => true));
|
|
182
|
+
if (indices === null) {
|
|
183
|
+
console.log("cancelled, no changes made");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
return indices.map((i) => candidates[i].kind);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return candidates.map((c) => c.kind);
|
|
190
|
+
}
|
|
93
191
|
/** Shared by `--manage` and the interactive prompt's answer — same
|
|
94
192
|
* grammar either way (design.md D4): a real agent id list, comma- or
|
|
95
193
|
* whitespace-separated numbers referring to `candidates`' own order, or
|
|
@@ -224,10 +322,18 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
224
322
|
writeManagedYaml(homeDir, managedAgents);
|
|
225
323
|
}
|
|
226
324
|
let migratePlan;
|
|
325
|
+
let migrateSkipped;
|
|
227
326
|
if (source) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
327
|
+
const sourceSummary = summary.find((s) => s.agent === source);
|
|
328
|
+
const categories = await resolveMigrateCategories(sourceSummary, opts);
|
|
329
|
+
if (categories.length > 0) {
|
|
330
|
+
migratePlan = await collectMigratePlan(source, homeDir, categories);
|
|
331
|
+
if (!opts.dryRun) {
|
|
332
|
+
applyMigratePlan(migratePlan, homeDir);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
migrateSkipped = "migrate skipped — no categories selected";
|
|
231
337
|
}
|
|
232
338
|
}
|
|
233
339
|
// One session for the whole chained run (trellis-backup-rollback) —
|
|
@@ -238,6 +344,15 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
238
344
|
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
239
345
|
const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
240
346
|
backupSession?.finalize();
|
|
347
|
+
// Independent of `managedAgents` — the shared memory server is not
|
|
348
|
+
// per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
|
|
349
|
+
// sync/mcp-sync above. Runs unconditionally once reached; an
|
|
350
|
+
// unconfigured `memory` server is a legitimate "nothing to do yet"
|
|
351
|
+
// state (design.md D5), not gated on any prior step here.
|
|
352
|
+
const memorySyncResult = collectMemorySyncResult(homeDir);
|
|
353
|
+
if (!opts.dryRun) {
|
|
354
|
+
applyMemorySync(memorySyncResult);
|
|
355
|
+
}
|
|
241
356
|
// Read-only, no dryRun concept — same report either way, run last since
|
|
242
357
|
// it audits the config mcp sync just wrote (or, on --dry-run, whatever
|
|
243
358
|
// was already there before this run).
|
|
@@ -249,8 +364,10 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
249
364
|
managedAgents,
|
|
250
365
|
installResults: installResults.length > 0 ? installResults : undefined,
|
|
251
366
|
migratePlan,
|
|
367
|
+
migrateSkipped,
|
|
252
368
|
syncReport,
|
|
253
369
|
mcpSyncReport,
|
|
370
|
+
memorySyncResult,
|
|
254
371
|
secretsAuditReport,
|
|
255
372
|
};
|
|
256
373
|
}
|
|
@@ -264,9 +381,11 @@ export async function runOnboard(opts = {}) {
|
|
|
264
381
|
}
|
|
265
382
|
if (result.refusal)
|
|
266
383
|
return { exitCode: 1 };
|
|
384
|
+
const memorySyncConflict = result.memorySyncResult?.configured === true && result.memorySyncResult.plan.items.some((i) => i.action === "conflict");
|
|
267
385
|
const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
|
|
268
386
|
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
269
387
|
(result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
388
|
+
memorySyncConflict ||
|
|
270
389
|
(result.secretsAuditReport?.findings.length ?? 0) > 0;
|
|
271
390
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
272
391
|
}
|
|
@@ -313,6 +432,10 @@ function printResult(result, dryRun) {
|
|
|
313
432
|
console.log("");
|
|
314
433
|
printMigratePlan(result.migratePlan, false);
|
|
315
434
|
}
|
|
435
|
+
else if (result.migrateSkipped) {
|
|
436
|
+
console.log("");
|
|
437
|
+
console.log(result.migrateSkipped);
|
|
438
|
+
}
|
|
316
439
|
if (result.syncReport) {
|
|
317
440
|
console.log("\nsync");
|
|
318
441
|
printSyncReport(result.syncReport, false);
|
|
@@ -321,6 +444,10 @@ function printResult(result, dryRun) {
|
|
|
321
444
|
console.log("\nmcp sync");
|
|
322
445
|
printMcpSyncReport(result.mcpSyncReport, false);
|
|
323
446
|
}
|
|
447
|
+
if (result.memorySyncResult) {
|
|
448
|
+
console.log("\nmemory sync");
|
|
449
|
+
printMemorySyncResult(result.memorySyncResult, false);
|
|
450
|
+
}
|
|
324
451
|
if (result.secretsAuditReport) {
|
|
325
452
|
console.log("\nsecrets audit");
|
|
326
453
|
printSecretsAuditReport(result.secretsAuditReport);
|