agent-trellis 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -17
- package/dist/adapters/claude-code.d.ts +5 -3
- package/dist/adapters/claude-code.js +27 -14
- package/dist/adapters/codex.d.ts +8 -4
- package/dist/adapters/codex.js +47 -16
- package/dist/adapters/jsonMcp.d.ts +16 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +5 -3
- package/dist/adapters/kiro.js +29 -16
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +40 -7
- package/dist/adapters/pi.d.ts +2 -1
- package/dist/adapters/pi.js +4 -4
- package/dist/adapters/symlinkPlan.d.ts +7 -3
- package/dist/adapters/symlinkPlan.js +42 -16
- package/dist/cli.js +161 -18
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +114 -7
- package/dist/commands/mcp.js +258 -17
- 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 +52 -7
- package/dist/commands/onboard.js +318 -35
- package/dist/commands/rollback.d.ts +44 -0
- package/dist/commands/rollback.js +201 -0
- package/dist/commands/secretsAudit.d.ts +7 -0
- package/dist/commands/secretsAudit.js +14 -7
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +28 -11
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +103 -3
- package/dist/core/types.d.ts +29 -1
- package/dist/core/types.js +11 -2
- package/dist/lib/backup.d.ts +56 -0
- package/dist/lib/backup.js +98 -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/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- 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 +100 -51
- package/dist/pi-bridge/index.js +14 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +267 -33
- package/docs/roadmap.md +444 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
package/dist/core/canonical.js
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { basename, join } from "node:path";
|
|
10
|
-
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
11
11
|
import { ALL_AGENTS } from "./types.js";
|
|
12
|
+
function fromServerDefYaml(def) {
|
|
13
|
+
const { static_env, ...rest } = def;
|
|
14
|
+
return static_env ? { ...rest, staticEnv: static_env } : rest;
|
|
15
|
+
}
|
|
16
|
+
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
17
|
+
* `undefined` fields so the written YAML never gets a literal `null`
|
|
18
|
+
* for an omitted optional. */
|
|
19
|
+
export function toServerDefYaml(def) {
|
|
20
|
+
const { staticEnv, ...rest } = def;
|
|
21
|
+
const out = { ...rest };
|
|
22
|
+
if (staticEnv)
|
|
23
|
+
out.static_env = staticEnv;
|
|
24
|
+
for (const key of Object.keys(out)) {
|
|
25
|
+
if (out[key] === undefined)
|
|
26
|
+
delete out[key];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
12
30
|
function trellisRoot(homeDir) {
|
|
13
31
|
return join(homeDir, ".trellis");
|
|
14
32
|
}
|
|
@@ -58,12 +76,72 @@ function loadServersYaml(path) {
|
|
|
58
76
|
return { servers: {}, knownHostInjected: [] };
|
|
59
77
|
}
|
|
60
78
|
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
79
|
+
const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
|
|
61
80
|
return {
|
|
62
|
-
servers
|
|
81
|
+
servers,
|
|
63
82
|
knownHostInjected: parsed.known_host_injected ?? [],
|
|
64
83
|
hub: parsed.hub,
|
|
65
84
|
};
|
|
66
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Writes or replaces one server entry in `servers.yaml`, preserving
|
|
88
|
+
* every other entry's and comment's exact formatting (trellis-
|
|
89
|
+
* canonical-cli-crud design.md D3) — a `Document`-based edit
|
|
90
|
+
* (`setIn`), never `parse` + rebuild + `stringify`, which would
|
|
91
|
+
* re-serialize the whole file and lose anything hand-authored outside
|
|
92
|
+
* the touched entry. Refuses (no write) if the file doesn't exist yet
|
|
93
|
+
* (run `trellis init` first) or fails to parse.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* `trellis init`'s starter `servers.yaml` writes `servers: {}` — an empty
|
|
97
|
+
* flow-style map, since there's nothing to indent yet. `Document#setIn`
|
|
98
|
+
* inserting into an *existing* flow map keeps rendering it as flow, so
|
|
99
|
+
* the first `mcp add`/`migrate --only mcp` onto a fresh file — and every
|
|
100
|
+
* one after it — would render as one unreadable line (found via actually
|
|
101
|
+
* running the CLI against a fresh `init`, not by inspection). Forcing the
|
|
102
|
+
* touched map, and the newly-set entry's own map, to block style is a
|
|
103
|
+
* one-line, local fix — it never touches any sibling entry's own style,
|
|
104
|
+
* so a file someone deliberately kept flow-style elsewhere is untouched.
|
|
105
|
+
*/
|
|
106
|
+
function forceBlockStyle(node) {
|
|
107
|
+
if (isMap(node)) {
|
|
108
|
+
node.flow = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function upsertServerYaml(path, name, def) {
|
|
112
|
+
if (!existsSync(path)) {
|
|
113
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
114
|
+
}
|
|
115
|
+
let doc;
|
|
116
|
+
try {
|
|
117
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
121
|
+
}
|
|
122
|
+
doc.setIn(["servers", name], toServerDefYaml(def));
|
|
123
|
+
forceBlockStyle(doc.get("servers", true));
|
|
124
|
+
forceBlockStyle(doc.getIn(["servers", name], true));
|
|
125
|
+
writeFileSync(path, doc.toString());
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
129
|
+
* refusal posture on a missing/unparseable file. */
|
|
130
|
+
export function removeServerYaml(path, name) {
|
|
131
|
+
if (!existsSync(path)) {
|
|
132
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
133
|
+
}
|
|
134
|
+
let doc;
|
|
135
|
+
try {
|
|
136
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
140
|
+
}
|
|
141
|
+
doc.deleteIn(["servers", name]);
|
|
142
|
+
writeFileSync(path, doc.toString());
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
67
145
|
function loadSecretsPolicyYaml(path, homeDir) {
|
|
68
146
|
if (!existsSync(path)) {
|
|
69
147
|
return { allowedVars: [], rejectPatterns: [] };
|
|
@@ -78,6 +156,26 @@ function loadSecretsPolicyYaml(path, homeDir) {
|
|
|
78
156
|
/** `undefined` if the name has no entry in scope.yaml's map — "shared with
|
|
79
157
|
* all four agents," the default. A recognized but empty list is left as
|
|
80
158
|
* authored (an explicitly agent-less scope), not coerced to "all". */
|
|
159
|
+
/** Missing file and `agents: []` both resolve to `[]` — zero managed
|
|
160
|
+
* agents (D1), never "everyone." An unrecognized id is dropped with a
|
|
161
|
+
* diagnostic, same posture as an unrecognized scope.yaml agent id. */
|
|
162
|
+
function loadManagedYaml(path, diagnostics) {
|
|
163
|
+
if (!existsSync(path)) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
167
|
+
const raw = parsed.agents ?? [];
|
|
168
|
+
const valid = [];
|
|
169
|
+
for (const id of raw) {
|
|
170
|
+
if (ALL_AGENTS.includes(id)) {
|
|
171
|
+
valid.push(id);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
diagnostics.push(`managed.yaml: "${id}" is not a recognized agent id — ignored`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return valid;
|
|
178
|
+
}
|
|
81
179
|
function scopeFor(map, name) {
|
|
82
180
|
return map?.[name];
|
|
83
181
|
}
|
|
@@ -100,6 +198,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
|
|
|
100
198
|
throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
|
|
101
199
|
}
|
|
102
200
|
const diagnostics = [];
|
|
201
|
+
const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
|
|
103
202
|
const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
|
|
104
203
|
const skillDirs = listSkillDirs(join(root, "skills"));
|
|
105
204
|
const knownSkillNames = new Set(skillDirs.map((s) => s.name));
|
|
@@ -135,6 +234,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
|
|
|
135
234
|
}
|
|
136
235
|
return {
|
|
137
236
|
instructionsFile: join(root, "agents.md"),
|
|
237
|
+
managedAgents,
|
|
138
238
|
skills,
|
|
139
239
|
agents,
|
|
140
240
|
memories,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -15,7 +15,15 @@ export declare const ALL_AGENTS: readonly AgentId[];
|
|
|
15
15
|
* "Private / agent-specific capabilities".
|
|
16
16
|
*/
|
|
17
17
|
export type Scope = AgentId[] | undefined;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `managedAgents` is the hard outer boundary (trellis-managed-agents
|
|
20
|
+
* design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
|
|
21
|
+
* an item WITH an explicit scope is intersected with it, never returned
|
|
22
|
+
* verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
|
|
23
|
+
* isn't in the managed set. Presence on the machine is irrelevant here;
|
|
24
|
+
* only membership in `managedAgents` is.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveScope(scope: Scope, managedAgents: readonly AgentId[]): readonly AgentId[];
|
|
19
27
|
export interface McpServerDef {
|
|
20
28
|
transport: Transport;
|
|
21
29
|
/** stdio only */
|
|
@@ -36,6 +44,23 @@ export interface McpServerDef {
|
|
|
36
44
|
* docs/research.md "Secrets" and schema/secrets.policy.example.yaml.
|
|
37
45
|
*/
|
|
38
46
|
env?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Literal, non-secret values written into the agent's config verbatim
|
|
49
|
+
* — distinct from `env`'s names-only, resolved-at-runtime contract.
|
|
50
|
+
* Still scanned against `reject_patterns` like every other literal
|
|
51
|
+
* field (trellis-mcp-static-env-and-disabled-servers design.md D2):
|
|
52
|
+
* this is for values that were never secrets (an email address, an
|
|
53
|
+
* environment tag), not an escape hatch for real credentials.
|
|
54
|
+
*/
|
|
55
|
+
staticEnv?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Defaults to `true`. `false` keeps the definition in canonical
|
|
58
|
+
* without writing it to any agent — matches a real host config's own
|
|
59
|
+
* "defined but currently off" state (e.g. Codex's `enabled = false`)
|
|
60
|
+
* that omitting the definition entirely can't represent, since that
|
|
61
|
+
* would also throw away the definition itself.
|
|
62
|
+
*/
|
|
63
|
+
enabled?: boolean;
|
|
39
64
|
/** Omit for "all agents" (the default). See `Scope`. */
|
|
40
65
|
agents?: Scope;
|
|
41
66
|
}
|
|
@@ -190,6 +215,9 @@ export interface CanonicalSource {
|
|
|
190
215
|
* that decision.
|
|
191
216
|
*/
|
|
192
217
|
instructionsFile: string;
|
|
218
|
+
/** From `~/.trellis/managed.yaml`. Missing file and `agents: []` both
|
|
219
|
+
* resolve to `[]` — zero managed agents, never "everyone" (D1). */
|
|
220
|
+
managedAgents: readonly AgentId[];
|
|
193
221
|
skills: SkillRef[];
|
|
194
222
|
agents: AgentProfile[];
|
|
195
223
|
memories: MemoryEntry[];
|
package/dist/core/types.js
CHANGED
|
@@ -10,6 +10,15 @@ export const ALL_AGENTS = [
|
|
|
10
10
|
"kiro",
|
|
11
11
|
"pi",
|
|
12
12
|
];
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `managedAgents` is the hard outer boundary (trellis-managed-agents
|
|
15
|
+
* design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
|
|
16
|
+
* an item WITH an explicit scope is intersected with it, never returned
|
|
17
|
+
* verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
|
|
18
|
+
* isn't in the managed set. Presence on the machine is irrelevant here;
|
|
19
|
+
* only membership in `managedAgents` is.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveScope(scope, managedAgents) {
|
|
22
|
+
const candidates = scope ?? managedAgents;
|
|
23
|
+
return candidates.filter((id) => managedAgents.includes(id));
|
|
15
24
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, timestamped backup of every real write `sync`/`mcp sync`
|
|
3
|
+
* perform, and the only path any of them writes disk through — see
|
|
4
|
+
* openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
|
|
5
|
+
* write itself lives here rather than at each call site: a call site
|
|
6
|
+
* that only had to remember to *also* call a record function is exactly
|
|
7
|
+
* the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
|
|
8
|
+
* directory is created lazily on the first recorded operation; a session
|
|
9
|
+
* that never records anything creates nothing on disk.
|
|
10
|
+
*/
|
|
11
|
+
export type BackupOperation = {
|
|
12
|
+
kind: "file-create";
|
|
13
|
+
path: string;
|
|
14
|
+
afterHash: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "file-overwrite";
|
|
17
|
+
path: string;
|
|
18
|
+
beforeFile: string;
|
|
19
|
+
beforeHash: string;
|
|
20
|
+
afterHash: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "symlink-create";
|
|
23
|
+
path: string;
|
|
24
|
+
afterLinkTarget: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "symlink-repair";
|
|
27
|
+
path: string;
|
|
28
|
+
beforeLinkTarget: string;
|
|
29
|
+
afterLinkTarget: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "symlink-remove";
|
|
32
|
+
path: string;
|
|
33
|
+
beforeLinkTarget: string;
|
|
34
|
+
};
|
|
35
|
+
export interface BackupManifest {
|
|
36
|
+
runId: string;
|
|
37
|
+
command: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
operations: BackupOperation[];
|
|
40
|
+
}
|
|
41
|
+
export interface BackupSession {
|
|
42
|
+
writeFile(path: string, content: string): void;
|
|
43
|
+
createSymlink(path: string, linkTarget: string): Promise<void>;
|
|
44
|
+
repairSymlink(path: string, oldLinkTarget: string, newLinkTarget: string): Promise<void>;
|
|
45
|
+
removeSymlink(path: string, oldLinkTarget: string): Promise<void>;
|
|
46
|
+
/** Writes manifest.json. No-op (creates nothing) if zero operations
|
|
47
|
+
* were ever recorded. */
|
|
48
|
+
finalize(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function backupsRoot(homeDir: string): string;
|
|
51
|
+
export declare function openBackupSession(homeDir: string, command: string): BackupSession;
|
|
52
|
+
/** Used by `applySymlinkPlan` and every adapter's own read-before-repair
|
|
53
|
+
* logic — it needs the symlink's current stored target before the
|
|
54
|
+
* session's own repair/remove overwrites it. Not part of `BackupSession`
|
|
55
|
+
* itself: it's a read, not a recorded write. */
|
|
56
|
+
export declare function currentLinkTarget(path: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, timestamped backup of every real write `sync`/`mcp sync`
|
|
3
|
+
* perform, and the only path any of them writes disk through — see
|
|
4
|
+
* openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
|
|
5
|
+
* write itself lives here rather than at each call site: a call site
|
|
6
|
+
* that only had to remember to *also* call a record function is exactly
|
|
7
|
+
* the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
|
|
8
|
+
* directory is created lazily on the first recorded operation; a session
|
|
9
|
+
* that never records anything creates nothing on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { readlink, rm, symlink } from "node:fs/promises";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
function sha256(content) {
|
|
16
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
/** `:` and `.` are legal in POSIX filenames but awkward across shells and
|
|
19
|
+
* some tooling; replaced so a run id is safe to pass around bare. */
|
|
20
|
+
function runIdFor(command) {
|
|
21
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
22
|
+
return `${ts}-${command}`;
|
|
23
|
+
}
|
|
24
|
+
export function backupsRoot(homeDir) {
|
|
25
|
+
return join(homeDir, ".trellis", "backups");
|
|
26
|
+
}
|
|
27
|
+
export function openBackupSession(homeDir, command) {
|
|
28
|
+
const runId = runIdFor(command);
|
|
29
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
30
|
+
const startedAt = new Date().toISOString();
|
|
31
|
+
const operations = [];
|
|
32
|
+
let dirCreated = false;
|
|
33
|
+
let fileIndex = 0;
|
|
34
|
+
function ensureDir() {
|
|
35
|
+
if (dirCreated)
|
|
36
|
+
return;
|
|
37
|
+
mkdirSync(join(runDir, "files"), { recursive: true });
|
|
38
|
+
dirCreated = true;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
writeFile(path, content) {
|
|
42
|
+
ensureDir();
|
|
43
|
+
const afterHash = sha256(content);
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
const before = readFileSync(path, "utf-8");
|
|
46
|
+
const relSnapshot = join("files", `${fileIndex}-${basename(path)}`);
|
|
47
|
+
fileIndex += 1;
|
|
48
|
+
writeFileSync(join(runDir, relSnapshot), before);
|
|
49
|
+
operations.push({
|
|
50
|
+
kind: "file-overwrite",
|
|
51
|
+
path,
|
|
52
|
+
beforeFile: relSnapshot,
|
|
53
|
+
beforeHash: sha256(before),
|
|
54
|
+
afterHash,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
operations.push({ kind: "file-create", path, afterHash });
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(path, content);
|
|
61
|
+
},
|
|
62
|
+
async createSymlink(path, linkTarget) {
|
|
63
|
+
ensureDir();
|
|
64
|
+
await rm(path, { force: true });
|
|
65
|
+
await symlink(linkTarget, path);
|
|
66
|
+
operations.push({ kind: "symlink-create", path, afterLinkTarget: linkTarget });
|
|
67
|
+
},
|
|
68
|
+
async repairSymlink(path, oldLinkTarget, newLinkTarget) {
|
|
69
|
+
ensureDir();
|
|
70
|
+
await rm(path, { force: true });
|
|
71
|
+
await symlink(newLinkTarget, path);
|
|
72
|
+
operations.push({ kind: "symlink-repair", path, beforeLinkTarget: oldLinkTarget, afterLinkTarget: newLinkTarget });
|
|
73
|
+
},
|
|
74
|
+
async removeSymlink(path, oldLinkTarget) {
|
|
75
|
+
ensureDir();
|
|
76
|
+
await rm(path, { force: true });
|
|
77
|
+
operations.push({ kind: "symlink-remove", path, beforeLinkTarget: oldLinkTarget });
|
|
78
|
+
},
|
|
79
|
+
finalize() {
|
|
80
|
+
if (!dirCreated)
|
|
81
|
+
return;
|
|
82
|
+
const manifest = { runId, command, startedAt, operations };
|
|
83
|
+
writeFileSync(join(runDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Used by `applySymlinkPlan` and every adapter's own read-before-repair
|
|
88
|
+
* logic — it needs the symlink's current stored target before the
|
|
89
|
+
* session's own repair/remove overwrites it. Not part of `BackupSession`
|
|
90
|
+
* itself: it's a read, not a recorded write. */
|
|
91
|
+
export async function currentLinkTarget(path) {
|
|
92
|
+
try {
|
|
93
|
+
return await readlink(path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural equality for plain JSON-shaped values — shared by every
|
|
3
|
+
* "does this already match what Trellis would write/has written"
|
|
4
|
+
* comparison (jsonMcp.ts's create/remove decisions, migrate-in's
|
|
5
|
+
* conflict detection) so they can't silently drift apart into two
|
|
6
|
+
* different notions of "equal."
|
|
7
|
+
*/
|
|
8
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural equality for plain JSON-shaped values — shared by every
|
|
3
|
+
* "does this already match what Trellis would write/has written"
|
|
4
|
+
* comparison (jsonMcp.ts's create/remove decisions, migrate-in's
|
|
5
|
+
* conflict detection) so they can't silently drift apart into two
|
|
6
|
+
* different notions of "equal."
|
|
7
|
+
*/
|
|
8
|
+
export function deepEqual(a, b) {
|
|
9
|
+
if (a === b)
|
|
10
|
+
return true;
|
|
11
|
+
if (a === null || b === null || typeof a !== typeof b)
|
|
12
|
+
return false;
|
|
13
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
14
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
15
|
+
return false;
|
|
16
|
+
return a.every((value, index) => deepEqual(value, b[index]));
|
|
17
|
+
}
|
|
18
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
19
|
+
const aKeys = Object.keys(a);
|
|
20
|
+
const bKeys = Object.keys(b);
|
|
21
|
+
if (aKeys.length !== bKeys.length)
|
|
22
|
+
return false;
|
|
23
|
+
return aKeys.every((key) => deepEqual(a[key], b[key]));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
package/dist/lib/dirEquals.d.ts
CHANGED
|
@@ -5,3 +5,12 @@
|
|
|
5
5
|
* paths, same bytes per file; anything else is unequal.
|
|
6
6
|
*/
|
|
7
7
|
export declare function dirContentsEqual(a: string, b: string): boolean;
|
|
8
|
+
export type DirImportDecision = "create" | "already-present" | "conflict";
|
|
9
|
+
/**
|
|
10
|
+
* Shared create/already-present/conflict decision for copying a real
|
|
11
|
+
* directory into a canonical destination — used by both `migrate`'s
|
|
12
|
+
* own skill planning and `trellis skill add` (trellis-canonical-cli-crud
|
|
13
|
+
* design.md D2), so the two never silently drift apart on what counts
|
|
14
|
+
* as "safe to skip" vs. "a real conflict."
|
|
15
|
+
*/
|
|
16
|
+
export declare function decideDirImport(sourceDir: string, canonicalDir: string): DirImportDecision;
|
package/dist/lib/dirEquals.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* by this, not by name/mtime/hash-shortcut. Same set of relative file
|
|
5
5
|
* paths, same bytes per file; anything else is unequal.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
8
|
import { join, relative } from "node:path";
|
|
9
9
|
function listFilesRecursive(dir) {
|
|
10
10
|
const results = [];
|
|
@@ -37,3 +37,17 @@ export function dirContentsEqual(a, b) {
|
|
|
37
37
|
}
|
|
38
38
|
return true;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Shared create/already-present/conflict decision for copying a real
|
|
42
|
+
* directory into a canonical destination — used by both `migrate`'s
|
|
43
|
+
* own skill planning and `trellis skill add` (trellis-canonical-cli-crud
|
|
44
|
+
* design.md D2), so the two never silently drift apart on what counts
|
|
45
|
+
* as "safe to skip" vs. "a real conflict."
|
|
46
|
+
*/
|
|
47
|
+
export function decideDirImport(sourceDir, canonicalDir) {
|
|
48
|
+
if (!existsSync(canonicalDir))
|
|
49
|
+
return "create";
|
|
50
|
+
if (dirContentsEqual(sourceDir, canonicalDir))
|
|
51
|
+
return "already-present";
|
|
52
|
+
return "conflict";
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-then-manage (trellis-managed-agents design.md D5): selecting a
|
|
3
|
+
* not-yet-present agent into the managed set is itself the authorization
|
|
4
|
+
* to install it, but never silently — one confirmation, then a real
|
|
5
|
+
* `npm install -g <package>` child process. Kiro has no npm package (a
|
|
6
|
+
* desktop download) and is refused before this module is ever reached.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentId } from "../core/types.js";
|
|
9
|
+
/** Only agents with a real `npm install -g <pkg>` command — Kiro's own
|
|
10
|
+
* `INSTALL_HINTS` entry is a download URL, not a package, and is never
|
|
11
|
+
* looked up here. */
|
|
12
|
+
export declare const NPM_INSTALLABLE: Record<Exclude<AgentId, "kiro">, string>;
|
|
13
|
+
export interface ConfirmAndInstallOptions {
|
|
14
|
+
/** Test/real seam, same pattern as onboard's `promptForAgent` — a real
|
|
15
|
+
* terminal confirmation by default. */
|
|
16
|
+
confirm?: (agent: AgentId, pkg: string) => Promise<boolean>;
|
|
17
|
+
/** Test/real seam — never a real `npm install` in a unit test. */
|
|
18
|
+
runInstall?: (pkg: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface ConfirmAndInstallResult {
|
|
21
|
+
installed: boolean;
|
|
22
|
+
/** False only when the agent has no npm package at all (Kiro) — the
|
|
23
|
+
* caller refuses this agent with its download URL instead of prompting. */
|
|
24
|
+
installable: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function confirmAndInstall(agent: AgentId, opts?: ConfirmAndInstallOptions): Promise<ConfirmAndInstallResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-then-manage (trellis-managed-agents design.md D5): selecting a
|
|
3
|
+
* not-yet-present agent into the managed set is itself the authorization
|
|
4
|
+
* to install it, but never silently — one confirmation, then a real
|
|
5
|
+
* `npm install -g <package>` child process. Kiro has no npm package (a
|
|
6
|
+
* desktop download) and is refused before this module is ever reached.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
/** Only agents with a real `npm install -g <pkg>` command — Kiro's own
|
|
11
|
+
* `INSTALL_HINTS` entry is a download URL, not a package, and is never
|
|
12
|
+
* looked up here. */
|
|
13
|
+
export const NPM_INSTALLABLE = {
|
|
14
|
+
"claude-code": "@anthropic-ai/claude-code",
|
|
15
|
+
codex: "@openai/codex",
|
|
16
|
+
pi: "@earendil-works/pi-coding-agent",
|
|
17
|
+
};
|
|
18
|
+
async function confirmReal(agent, pkg) {
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
try {
|
|
21
|
+
const answer = (await rl.question(`${agent} is not installed. Install it now (npm install -g ${pkg})? [y/N] `)).trim().toLowerCase();
|
|
22
|
+
return answer === "y" || answer === "yes";
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
rl.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function runInstallReal(pkg) {
|
|
29
|
+
// argv array, never a shell string — the package name is never
|
|
30
|
+
// interpolated into anything a shell parses.
|
|
31
|
+
execFileSync("npm", ["install", "-g", pkg], { stdio: "inherit" });
|
|
32
|
+
}
|
|
33
|
+
export async function confirmAndInstall(agent, opts = {}) {
|
|
34
|
+
if (agent === "kiro") {
|
|
35
|
+
return { installed: false, installable: false };
|
|
36
|
+
}
|
|
37
|
+
const pkg = NPM_INSTALLABLE[agent];
|
|
38
|
+
const confirm = opts.confirm ?? confirmReal;
|
|
39
|
+
const runInstall = opts.runInstall ?? runInstallReal;
|
|
40
|
+
const agreed = await confirm(agent, pkg);
|
|
41
|
+
if (!agreed) {
|
|
42
|
+
return { installed: false, installable: true };
|
|
43
|
+
}
|
|
44
|
+
runInstall(pkg);
|
|
45
|
+
return { installed: true, installable: true };
|
|
46
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an agent's own real, already-configured MCP servers and converts
|
|
3
|
+
* each into canonical's `McpServerDef` shape — `trellis migrate --only
|
|
4
|
+
* mcp`'s read path (trellis-migrate-mcp-servers). Deliberately separate
|
|
5
|
+
* from each probe's own `AgentSnapshotMcpServer` (src/probes/*.ts) and its
|
|
6
|
+
* thin `{name, transport, probe?}` shape used by `doctor` — that shape
|
|
7
|
+
* discards exactly the fields this module needs, and extending it would
|
|
8
|
+
* risk `doctor`'s existing, extensively-tested behavior for no reason
|
|
9
|
+
* (design.md D1). pi has no static MCP config to read at all — no reader
|
|
10
|
+
* is defined for it; `migrate.ts` skips pi for the `mcp` category
|
|
11
|
+
* entirely, the same fact already established for skills/instructions.
|
|
12
|
+
*/
|
|
13
|
+
import type { McpServerDef } from "../core/types.js";
|
|
14
|
+
export interface McpMigrateEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
def: McpServerDef;
|
|
17
|
+
}
|
|
18
|
+
export interface McpMigrateUnsupported {
|
|
19
|
+
name: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface McpMigrateReadResult {
|
|
23
|
+
entries: McpMigrateEntry[];
|
|
24
|
+
unsupported: McpMigrateUnsupported[];
|
|
25
|
+
}
|
|
26
|
+
export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
27
|
+
export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
28
|
+
export interface CodexMcpEntryRich {
|
|
29
|
+
name: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
transport: {
|
|
32
|
+
type: string;
|
|
33
|
+
command?: string;
|
|
34
|
+
args?: string[];
|
|
35
|
+
env_vars?: string[];
|
|
36
|
+
/** Remote-transport fields. Confirmed by running the real,
|
|
37
|
+
* locally-installed `codex-cli 0.154.0` against a hand-written
|
|
38
|
+
* `url`-only server and a `url` + `bearer_token_env_var` server: the
|
|
39
|
+
* type string is `"streamable_http"` (not `"http"`), and the three
|
|
40
|
+
* `*headers*` fields come back `null` when unused. Codex's own
|
|
41
|
+
* config.toml schema (`tomlSection.ts`'s `renderServerSection`) has
|
|
42
|
+
* no way to distinguish `http` from `sse` at all — both render
|
|
43
|
+
* identically — so there is no lossy guess in always reading a
|
|
44
|
+
* remote entry back as `"http"`; that distinction was never stored. */
|
|
45
|
+
url?: string;
|
|
46
|
+
bearer_token_env_var?: string | null;
|
|
47
|
+
http_headers?: unknown;
|
|
48
|
+
env_http_headers?: unknown;
|
|
49
|
+
http_headers_helper?: unknown;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pure conversion half — separated from the subprocess/file-read effects
|
|
54
|
+
* below so the stdio/non-stdio/malformed decision logic is unit-testable
|
|
55
|
+
* without a real `codex` binary on PATH (this codebase has no fake-codex
|
|
56
|
+
* fixture anywhere; codex's subprocess dependency is otherwise entirely
|
|
57
|
+
* untested at the unit level).
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildCodexMcpReadResult(mcpEntries: CodexMcpEntryRich[], tomlContent: string | undefined): McpMigrateReadResult;
|
|
60
|
+
/**
|
|
61
|
+
* Stdio only (design.md D2) — `codex mcp list --json`'s own output never
|
|
62
|
+
* exposes `url`/`bearer_token_env_var` in this codebase's `CodexMcpEntry`
|
|
63
|
+
* (src/probes/codex.ts), and this codebase has no verified evidence for
|
|
64
|
+
* what that subprocess reports for a non-stdio server. Guessing at an
|
|
65
|
+
* external tool's unverified output shape is exactly what this project's
|
|
66
|
+
* "verify, don't assume" discipline exists to prevent — a non-stdio
|
|
67
|
+
* entry is reported as unsupported instead.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readCodexMcpDefs(homeDir: string): McpMigrateReadResult;
|