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.
Files changed (50) hide show
  1. package/README.md +12 -0
  2. package/dist/adapters/claude-code.d.ts +3 -2
  3. package/dist/adapters/claude-code.js +21 -8
  4. package/dist/adapters/codex.d.ts +6 -3
  5. package/dist/adapters/codex.js +41 -10
  6. package/dist/adapters/jsonMcp.d.ts +15 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +3 -2
  9. package/dist/adapters/kiro.js +22 -9
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +39 -6
  12. package/dist/cli.js +123 -10
  13. package/dist/commands/mcp.d.ts +101 -7
  14. package/dist/commands/mcp.js +227 -10
  15. package/dist/commands/memory.d.ts +47 -0
  16. package/dist/commands/memory.js +89 -0
  17. package/dist/commands/migrate.d.ts +30 -4
  18. package/dist/commands/migrate.js +83 -16
  19. package/dist/commands/onboard.d.ts +27 -8
  20. package/dist/commands/onboard.js +144 -17
  21. package/dist/commands/skill.d.ts +51 -0
  22. package/dist/commands/skill.js +104 -0
  23. package/dist/core/adapter.d.ts +18 -8
  24. package/dist/core/canonical.d.ts +26 -1
  25. package/dist/core/canonical.js +81 -3
  26. package/dist/core/types.d.ts +17 -0
  27. package/dist/lib/deepEqual.d.ts +8 -0
  28. package/dist/lib/deepEqual.js +26 -0
  29. package/dist/lib/dirEquals.d.ts +9 -0
  30. package/dist/lib/dirEquals.js +15 -1
  31. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  32. package/dist/lib/mcpMigrateRead.js +188 -0
  33. package/dist/lib/mcpOwnership.d.ts +25 -0
  34. package/dist/lib/mcpOwnership.js +50 -0
  35. package/dist/lib/memoryGraph.d.ts +60 -0
  36. package/dist/lib/memoryGraph.js +101 -0
  37. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  38. package/dist/lib/realHomeSnapshot.js +77 -0
  39. package/dist/lib/terminalPicker.d.ts +45 -0
  40. package/dist/lib/terminalPicker.js +193 -0
  41. package/dist/lib/tomlSection.d.ts +20 -6
  42. package/dist/lib/tomlSection.js +78 -12
  43. package/dist/pi-bridge/bundle.js +76 -46
  44. package/dist/pi-bridge/index.js +7 -2
  45. package/dist/probes/codex.js +10 -2
  46. package/docs/architecture.md +7 -4
  47. package/docs/getting-started.md +180 -15
  48. package/docs/roadmap.md +341 -0
  49. package/package.json +1 -1
  50. package/schema/servers.example.yaml +39 -2
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `trellis skill list|add|remove` — command-line CRUD for canonical
3
+ * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
+ * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
+ * decision and `remove`'s "sync will un-sync it" behavior both reuse
6
+ * existing, already-shipped logic rather than reimplementing it — see
7
+ * `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
8
+ * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
+ */
10
+ import type { AgentId } from "../core/types.js";
11
+ export interface SkillListEntry {
12
+ name: string;
13
+ scope: readonly AgentId[];
14
+ }
15
+ export declare function collectSkillList(homeDir?: string): SkillListEntry[];
16
+ export declare function runSkillList(opts?: {
17
+ homeDir?: string;
18
+ json?: boolean;
19
+ }): {
20
+ exitCode: number;
21
+ };
22
+ export type SkillAddAction = "create" | "already-present" | "conflict" | "invalid-source";
23
+ export interface SkillAddPlan {
24
+ name: string;
25
+ action: SkillAddAction;
26
+ detail: string;
27
+ sourceDir?: string;
28
+ }
29
+ export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
30
+ export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string): void;
31
+ export declare function runSkillAdd(name: string, fromPath: string, opts?: {
32
+ homeDir?: string;
33
+ json?: boolean;
34
+ dryRun?: boolean;
35
+ }): {
36
+ exitCode: number;
37
+ };
38
+ export type SkillRemoveAction = "removed" | "not-found";
39
+ export interface SkillRemovePlan {
40
+ name: string;
41
+ action: SkillRemoveAction;
42
+ }
43
+ export declare function collectSkillRemovePlan(name: string, homeDir?: string): SkillRemovePlan;
44
+ export declare function applySkillRemovePlan(plan: SkillRemovePlan, homeDir?: string): void;
45
+ export declare function runSkillRemove(name: string, opts?: {
46
+ homeDir?: string;
47
+ json?: boolean;
48
+ dryRun?: boolean;
49
+ }): {
50
+ exitCode: number;
51
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `trellis skill list|add|remove` — command-line CRUD for canonical
3
+ * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
+ * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
+ * decision and `remove`'s "sync will un-sync it" behavior both reuse
6
+ * existing, already-shipped logic rather than reimplementing it — see
7
+ * `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
8
+ * symlinkPlan.ts`'s pre-existing stale-symlink removal.
9
+ */
10
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { decideDirImport } from "../lib/dirEquals.js";
14
+ import { findSkillFile } from "../lib/skillFile.js";
15
+ import { loadCanonicalSource } from "../core/canonical.js";
16
+ import { resolveScope } from "../core/types.js";
17
+ export function collectSkillList(homeDir = homedir()) {
18
+ const canonical = loadCanonicalSource(homeDir);
19
+ return canonical.skills.map((skill) => ({
20
+ name: skill.name,
21
+ scope: resolveScope(skill.scope, canonical.managedAgents),
22
+ }));
23
+ }
24
+ export function runSkillList(opts = {}) {
25
+ const entries = collectSkillList(opts.homeDir ?? homedir());
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(entries, null, 2));
28
+ }
29
+ else if (entries.length === 0) {
30
+ console.log("No skills in canonical source yet.");
31
+ }
32
+ else {
33
+ for (const { name, scope } of entries) {
34
+ console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}`);
35
+ }
36
+ }
37
+ return { exitCode: 0 };
38
+ }
39
+ export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
40
+ const skillFile = findSkillFile(fromPath);
41
+ if (!skillFile) {
42
+ return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
43
+ }
44
+ if (!skillFile.caseCorrect) {
45
+ return { name, action: "invalid-source", detail: `${fromPath} has skill.md, not case-correct SKILL.md — fix the case first` };
46
+ }
47
+ const canonicalDir = join(homeDir, ".trellis", "skills", name);
48
+ switch (decideDirImport(fromPath, canonicalDir)) {
49
+ case "create":
50
+ return { name, action: "create", detail: `will copy from ${fromPath}`, sourceDir: fromPath };
51
+ case "already-present":
52
+ return { name, action: "already-present", detail: "canonical content is already byte-identical" };
53
+ case "conflict":
54
+ return { name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
55
+ }
56
+ }
57
+ export function applySkillAddPlan(plan, homeDir = homedir()) {
58
+ if (plan.action !== "create" || !plan.sourceDir)
59
+ return;
60
+ const dest = join(homeDir, ".trellis", "skills", plan.name);
61
+ mkdirSync(dest, { recursive: true });
62
+ cpSync(plan.sourceDir, dest, { recursive: true });
63
+ }
64
+ export function runSkillAdd(name, fromPath, opts = {}) {
65
+ const homeDir = opts.homeDir ?? homedir();
66
+ const plan = collectSkillAddPlan(name, fromPath, homeDir);
67
+ if (!opts.dryRun && plan.action === "create") {
68
+ applySkillAddPlan(plan, homeDir);
69
+ }
70
+ if (opts.json) {
71
+ console.log(JSON.stringify(plan, null, 2));
72
+ }
73
+ else {
74
+ console.log(`${opts.dryRun ? "[dry run] " : ""}skill add ${name}`);
75
+ console.log(` [${plan.action}] ${plan.detail}`);
76
+ }
77
+ return { exitCode: plan.action === "conflict" || plan.action === "invalid-source" ? 1 : 0 };
78
+ }
79
+ export function collectSkillRemovePlan(name, homeDir = homedir()) {
80
+ const dir = join(homeDir, ".trellis", "skills", name);
81
+ return { name, action: existsSync(dir) ? "removed" : "not-found" };
82
+ }
83
+ export function applySkillRemovePlan(plan, homeDir = homedir()) {
84
+ if (plan.action !== "removed")
85
+ return;
86
+ rmSync(join(homeDir, ".trellis", "skills", plan.name), { recursive: true, force: true });
87
+ }
88
+ export function runSkillRemove(name, opts = {}) {
89
+ const homeDir = opts.homeDir ?? homedir();
90
+ const plan = collectSkillRemovePlan(name, homeDir);
91
+ if (!opts.dryRun) {
92
+ applySkillRemovePlan(plan, homeDir);
93
+ }
94
+ if (opts.json) {
95
+ console.log(JSON.stringify(plan, null, 2));
96
+ }
97
+ else if (plan.action === "not-found") {
98
+ console.error(`"${name}" is not a canonical skill — nothing to remove.`);
99
+ }
100
+ else {
101
+ console.log(`${opts.dryRun ? "[dry run] " : ""}removed skill "${name}" from canonical source.`);
102
+ }
103
+ return { exitCode: plan.action === "not-found" ? 1 : 0 };
104
+ }
@@ -20,14 +20,18 @@ export interface AdapterPlanItem {
20
20
  /**
21
21
  * "create" also covers repair (wrong symlink target, or an MCP server
22
22
  * definition that differs from canonical); "remove" is the delete half
23
- * for skills/instructions — a Trellis-managed symlink whose canonical
24
- * entry is gone or was just scoped away from this agent. MCP server
25
- * items never use "remove" (see `kind: "mcp"` below — no ownership
26
- * marker exists yet to make that provably safe, trellis-mcp-sync-p2
27
- * design.md D7). "conflict" is either a real, non-symlink path
28
- * occupying a spot Trellis would otherwise touch, or an MCP server
29
- * refused for a collision/secrets-guard reason — reported, never acted
30
- * on. See `plan()`'s doc below.
23
+ * for skills/instructions (a Trellis-managed symlink whose canonical
24
+ * entry is gone or was just scoped away from this agent) and, since
25
+ * trellis-mcp-lifecycle-parity, for MCP servers too — but only when
26
+ * `src/lib/mcpOwnership.ts`'s ledger proves the agent's current native
27
+ * entry is still exactly what Trellis itself last wrote there
28
+ * (mcpPlan.ts's own D7 reasoning: a bare TOML/JSON key has no ownership
29
+ * marker on its own, so this ledger is what makes removal provably
30
+ * safe instead of guessing). A name whose native content has since
31
+ * been hand-edited is left alone, never removed. "conflict" is either
32
+ * a real, non-symlink path occupying a spot Trellis would otherwise
33
+ * touch, or an MCP server refused for a collision/secrets-guard
34
+ * reason — reported, never acted on. See `plan()`'s doc below.
31
35
  */
32
36
  action: "create" | "remove" | "conflict";
33
37
  /** Lets `trellis sync skills` / `trellis sync instructions` /
@@ -64,6 +68,12 @@ export interface AdapterPlanItem {
64
68
  name: string;
65
69
  def: McpServerDef;
66
70
  };
71
+ /** Only set (and only meaningful) when `kind === "mcp"` and
72
+ * `action === "remove"`: the server name to delete from `target` via
73
+ * that agent's own mechanism. */
74
+ mcpRemove?: {
75
+ name: string;
76
+ };
67
77
  /** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
68
78
  * and `action === "create"`: the full, already-deduplicated array to
69
79
  * write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was
@@ -4,7 +4,31 @@
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 type { CanonicalSource } from "./types.js";
7
+ import type { CanonicalSource, McpServerDef } from "./types.js";
8
+ /**
9
+ * The on-disk shape for one server entry — `static_env` (snake_case, like
10
+ * every other multi-word key across `.trellis/*.yaml`) is translated to
11
+ * `McpServerDef.staticEnv` (camelCase) below; every other field happens
12
+ * to already be a single word, so no server-def field needed this
13
+ * treatment before (trellis-mcp-static-env-and-disabled-servers).
14
+ */
15
+ type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
16
+ static_env?: Record<string, string>;
17
+ };
18
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
19
+ * `undefined` fields so the written YAML never gets a literal `null`
20
+ * for an omitted optional. */
21
+ export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
22
+ export type ServersYamlWriteResult = {
23
+ ok: true;
24
+ } | {
25
+ ok: false;
26
+ error: string;
27
+ };
28
+ export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
29
+ /** Inverse of `upsertServerYaml` — same preservation guarantee, same
30
+ * refusal posture on a missing/unparseable file. */
31
+ export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
8
32
  /**
9
33
  * `homeDir` defaults to the real `~` and is only ever overridden for tests
10
34
  * and `scripts/sandbox.sh` — the same seam P0's probes use
@@ -14,3 +38,4 @@ import type { CanonicalSource } from "./types.js";
14
38
  * parameter does not weaken.
15
39
  */
16
40
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
41
+ export {};
@@ -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: parsed.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: [] };
@@ -44,6 +44,23 @@ export interface McpServerDef {
44
44
  * docs/research.md "Secrets" and schema/secrets.policy.example.yaml.
45
45
  */
46
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;
47
64
  /** Omit for "all agents" (the default). See `Scope`. */
48
65
  agents?: Scope;
49
66
  }
@@ -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
+ }
@@ -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;
@@ -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,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;