agent-trellis 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/adapters/claude-code.d.ts +23 -0
  4. package/dist/adapters/claude-code.js +86 -0
  5. package/dist/adapters/codex.d.ts +27 -0
  6. package/dist/adapters/codex.js +119 -0
  7. package/dist/adapters/jsonMcp.d.ts +24 -0
  8. package/dist/adapters/jsonMcp.js +84 -0
  9. package/dist/adapters/kiro.d.ts +34 -0
  10. package/dist/adapters/kiro.js +175 -0
  11. package/dist/adapters/mcpPlan.d.ts +28 -0
  12. package/dist/adapters/mcpPlan.js +83 -0
  13. package/dist/adapters/pi.d.ts +23 -0
  14. package/dist/adapters/pi.js +108 -0
  15. package/dist/adapters/symlinkPlan.d.ts +33 -0
  16. package/dist/adapters/symlinkPlan.js +120 -0
  17. package/dist/cli.d.ts +7 -0
  18. package/dist/cli.js +135 -0
  19. package/dist/commands/doctor.d.ts +88 -0
  20. package/dist/commands/doctor.js +269 -0
  21. package/dist/commands/init.d.ts +44 -0
  22. package/dist/commands/init.js +150 -0
  23. package/dist/commands/mcp.d.ts +28 -0
  24. package/dist/commands/mcp.js +70 -0
  25. package/dist/commands/migrate.d.ts +38 -0
  26. package/dist/commands/migrate.js +132 -0
  27. package/dist/commands/onboard.d.ts +50 -0
  28. package/dist/commands/onboard.js +155 -0
  29. package/dist/commands/secretsAudit.d.ts +35 -0
  30. package/dist/commands/secretsAudit.js +115 -0
  31. package/dist/commands/sync.d.ts +40 -0
  32. package/dist/commands/sync.js +91 -0
  33. package/dist/core/adapter.d.ts +133 -0
  34. package/dist/core/adapter.js +16 -0
  35. package/dist/core/canonical.d.ts +16 -0
  36. package/dist/core/canonical.js +148 -0
  37. package/dist/core/types.d.ts +201 -0
  38. package/dist/core/types.js +15 -0
  39. package/dist/lib/dirEquals.d.ts +7 -0
  40. package/dist/lib/dirEquals.js +39 -0
  41. package/dist/lib/envVarNames.d.ts +35 -0
  42. package/dist/lib/envVarNames.js +79 -0
  43. package/dist/lib/fsIdentity.d.ts +16 -0
  44. package/dist/lib/fsIdentity.js +53 -0
  45. package/dist/lib/mcpProbe.d.ts +14 -0
  46. package/dist/lib/mcpProbe.js +96 -0
  47. package/dist/lib/probeCommon.d.ts +24 -0
  48. package/dist/lib/probeCommon.js +108 -0
  49. package/dist/lib/secretEnv.d.ts +19 -0
  50. package/dist/lib/secretEnv.js +46 -0
  51. package/dist/lib/skillFile.d.ts +12 -0
  52. package/dist/lib/skillFile.js +26 -0
  53. package/dist/lib/syncArgs.d.ts +16 -0
  54. package/dist/lib/syncArgs.js +17 -0
  55. package/dist/lib/tomlSection.d.ts +57 -0
  56. package/dist/lib/tomlSection.js +162 -0
  57. package/dist/pi-bridge/bundle.js +32074 -0
  58. package/dist/pi-bridge/index.d.ts +48 -0
  59. package/dist/pi-bridge/index.js +188 -0
  60. package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
  61. package/dist/pi-bridge/schemaTranslate.js +40 -0
  62. package/dist/probes/claude-code.d.ts +13 -0
  63. package/dist/probes/claude-code.js +48 -0
  64. package/dist/probes/codex.d.ts +24 -0
  65. package/dist/probes/codex.js +78 -0
  66. package/dist/probes/kiro.d.ts +12 -0
  67. package/dist/probes/kiro.js +48 -0
  68. package/dist/probes/pi.d.ts +14 -0
  69. package/dist/probes/pi.js +53 -0
  70. package/dist/sdk.d.ts +14 -0
  71. package/dist/sdk.js +13 -0
  72. package/docs/architecture.md +367 -0
  73. package/docs/getting-started.md +235 -0
  74. package/docs/implementation-plan.md +341 -0
  75. package/docs/research.md +175 -0
  76. package/docs/roadmap.md +484 -0
  77. package/package.json +59 -0
  78. package/schema/scope.example.yaml +33 -0
  79. package/schema/secrets.policy.example.yaml +43 -0
  80. package/schema/servers.example.yaml +87 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Kiro adapter: same shape as Claude Code — `~/.kiro/skills/<name>`
3
+ * symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
4
+ *
5
+ * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
6
+ * (trellis-mcp-sync-p2 design.md D4) — create/repair only, no automatic
7
+ * removal (D7).
8
+ *
9
+ * Kiro's own `${VAR}` substitution (real, found by reading Kiro's
10
+ * installed extension source — trellis-kiro-approved-env-vars design.md
11
+ * Context) is gated by `kiroAgent.mcpApprovedEnvVars`, a completely
12
+ * separate, VS-Code-style global settings.json — not the mcp.json above.
13
+ * A name absent from that list is silently never substituted. This
14
+ * adapter keeps that list a superset of every env name it references,
15
+ * additive only.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { isInScope } from "../core/adapter.js";
21
+ import * as kiroProbe from "../probes/kiro.js";
22
+ import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
23
+ import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
24
+ import { resolveMcpPlan } from "./mcpPlan.js";
25
+ import { declaredEnvNames } from "../lib/envVarNames.js";
26
+ /** VS-Code-family global settings path. macOS only — see
27
+ * trellis-kiro-approved-env-vars proposal.md Non-Goals: Linux/Windows
28
+ * equivalents are the well-known convention but unverified against a
29
+ * real Kiro install on those platforms. */
30
+ function kiroSettingsPath(homeDir) {
31
+ return join(homeDir, "Library", "Application Support", "Kiro", "User", "settings.json");
32
+ }
33
+ const APPROVED_ENV_VARS_KEY = "kiroAgent.mcpApprovedEnvVars";
34
+ export class KiroAdapter {
35
+ homeDir;
36
+ name = "Kiro";
37
+ id = "kiro";
38
+ constructor(homeDir = homedir()) {
39
+ this.homeDir = homeDir;
40
+ }
41
+ async probe() {
42
+ const snapshot = await kiroProbe.probe(this.homeDir);
43
+ return { present: snapshot.present, version: snapshot.version };
44
+ }
45
+ async plan(canonical) {
46
+ const canonicalRoot = dirname(canonical.instructionsFile);
47
+ const skillsRoot = join(this.homeDir, ".kiro", "skills");
48
+ const desiredSkills = canonical.skills
49
+ .filter((skill) => isInScope(this.id, skill.scope))
50
+ .map((skill) => ({ name: skill.name, target: skill.dir }));
51
+ const skillItems = planSymlinks({
52
+ rootDir: skillsRoot,
53
+ desired: desiredSkills,
54
+ canonicalRoot: join(canonicalRoot, "skills"),
55
+ kind: "skill",
56
+ });
57
+ const instructionsItems = planSymlinks({
58
+ rootDir: join(this.homeDir, ".kiro", "steering"),
59
+ desired: [{ name: "CLAUDE.md", target: canonical.instructionsFile }],
60
+ canonicalRoot,
61
+ kind: "instructions",
62
+ });
63
+ const mcpItems = this.planMcp(canonical);
64
+ const approvedEnvVarsItems = this.planApprovedEnvVars(canonical);
65
+ return [...skillItems, ...instructionsItems, ...mcpItems, ...approvedEnvVarsItems];
66
+ }
67
+ planMcp(canonical) {
68
+ const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
69
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
70
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
71
+ }
72
+ /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
73
+ * (design.md D2) — a server scoped away from Kiro, or refused for a
74
+ * known_host_injected collision, never contributes a name here either. */
75
+ desiredApprovedEnvVars(canonical) {
76
+ const { desired } = resolveMcpPlan(this.id, canonical.mcp);
77
+ const names = new Set();
78
+ for (const { def } of desired) {
79
+ for (const name of declaredEnvNames(def))
80
+ names.add(name);
81
+ }
82
+ return [...names];
83
+ }
84
+ planApprovedEnvVars(canonical) {
85
+ const desired = this.desiredApprovedEnvVars(canonical);
86
+ if (desired.length === 0) {
87
+ return [];
88
+ }
89
+ const settingsPath = kiroSettingsPath(this.homeDir);
90
+ let existing = [];
91
+ if (existsSync(settingsPath)) {
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
95
+ }
96
+ catch {
97
+ return [{ action: "conflict", kind: "kiro-approved-env-vars", target: settingsPath, description: `${settingsPath} is not valid JSON — refusing to touch it` }];
98
+ }
99
+ const current = parsed[APPROVED_ENV_VARS_KEY];
100
+ if (Array.isArray(current)) {
101
+ existing = current.filter((v) => typeof v === "string");
102
+ }
103
+ }
104
+ const union = [...new Set([...existing, ...desired])];
105
+ if (union.length === existing.length && union.every((name) => existing.includes(name))) {
106
+ return []; // already a superset — no-op (design.md D4)
107
+ }
108
+ return [
109
+ {
110
+ action: "create",
111
+ kind: "kiro-approved-env-vars",
112
+ target: settingsPath,
113
+ approvedEnvVars: union,
114
+ description: `${settingsPath}: ${APPROVED_ENV_VARS_KEY} updated to include ${desired.filter((n) => !existing.includes(n)).join(", ")}`,
115
+ },
116
+ ];
117
+ }
118
+ async apply(plan) {
119
+ await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"));
120
+ const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
121
+ if (mcpCreates.length > 0) {
122
+ const configPath = mcpCreates[0].target;
123
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
124
+ const merged = applyJsonMcp(parsed, mcpCreates);
125
+ mkdirSync(dirname(configPath), { recursive: true });
126
+ writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`);
127
+ }
128
+ const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
129
+ if (approvedEnvVarsCreate) {
130
+ const settingsPath = approvedEnvVarsCreate.target;
131
+ const parsed = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
132
+ const merged = { ...parsed, [APPROVED_ENV_VARS_KEY]: approvedEnvVarsCreate.approvedEnvVars };
133
+ mkdirSync(dirname(settingsPath), { recursive: true });
134
+ writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
135
+ }
136
+ }
137
+ async verify(canonical) {
138
+ const snapshot = await kiroProbe.probe(this.homeDir);
139
+ if (!snapshot.present) {
140
+ return { ok: false, mismatches: ["kiro is not present on this machine"] };
141
+ }
142
+ const mismatches = [];
143
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
144
+ const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
145
+ for (const name of desiredNames) {
146
+ if (!actualSkills.has(name)) {
147
+ mismatches.push(`skill "${name}" is in canonical scope for kiro but missing on disk`);
148
+ }
149
+ }
150
+ for (const name of actualSkills) {
151
+ if (!desiredNames.has(name)) {
152
+ mismatches.push(`skill "${name}" is present on disk but not in canonical scope for kiro`);
153
+ }
154
+ }
155
+ const settingsPath = kiroSettingsPath(this.homeDir);
156
+ const approved = (() => {
157
+ if (!existsSync(settingsPath))
158
+ return [];
159
+ try {
160
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
161
+ const current = parsed[APPROVED_ENV_VARS_KEY];
162
+ return Array.isArray(current) ? current.filter((v) => typeof v === "string") : [];
163
+ }
164
+ catch {
165
+ return [];
166
+ }
167
+ })();
168
+ for (const name of this.desiredApprovedEnvVars(canonical)) {
169
+ if (!approved.includes(name)) {
170
+ mismatches.push(`env var "${name}" is needed by a canonical MCP server for kiro but missing from ${APPROVED_ENV_VARS_KEY}`);
171
+ }
172
+ }
173
+ return mismatches.length === 0 ? { ok: true } : { ok: false, mismatches };
174
+ }
175
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Storage-agnostic MCP planning shared by all three adapters (Claude
3
+ * Code, Codex, Kiro): given canonical MCP config + an agent id, resolves
4
+ * which (name, def) entries should exist and which are refused
5
+ * (collision or literal-secret conflicts). Never touches a file itself —
6
+ * each adapter turns this into plan items using its own read/write
7
+ * mechanism (JSON merge vs. TOML section splice).
8
+ *
9
+ * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
+ * skill's symlink, a plain key has no ownership marker, so "not in
11
+ * canonical anymore" can't be distinguished from "the user configured
12
+ * this directly." Only create/repair + refuse.
13
+ */
14
+ import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
15
+ export declare const HUB_ENTRY_NAME = "trellis-hub";
16
+ export interface DesiredMcpEntry {
17
+ name: string;
18
+ def: McpServerDef;
19
+ }
20
+ export interface McpConflict {
21
+ name: string;
22
+ message: string;
23
+ }
24
+ export interface McpPlanResult {
25
+ desired: DesiredMcpEntry[];
26
+ conflicts: McpConflict[];
27
+ }
28
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig): McpPlanResult;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Storage-agnostic MCP planning shared by all three adapters (Claude
3
+ * Code, Codex, Kiro): given canonical MCP config + an agent id, resolves
4
+ * which (name, def) entries should exist and which are refused
5
+ * (collision or literal-secret conflicts). Never touches a file itself —
6
+ * each adapter turns this into plan items using its own read/write
7
+ * mechanism (JSON merge vs. TOML section splice).
8
+ *
9
+ * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
+ * skill's symlink, a plain key has no ownership marker, so "not in
11
+ * canonical anymore" can't be distinguished from "the user configured
12
+ * this directly." Only create/repair + refuse.
13
+ */
14
+ import { isInScope } from "../core/adapter.js";
15
+ import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
16
+ export const HUB_ENTRY_NAME = "trellis-hub";
17
+ const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
18
+ function collisionMessage(name, agentId) {
19
+ return `refusing to write MCP server "${name}": also appears in known_host_injected.${agentId === "codex" ? CODEX_STDIO_URL_CRASH_NOTE : ""}`;
20
+ }
21
+ /**
22
+ * Known-dangerous literal patterns (design.md D5) — mirrors
23
+ * schema/secrets.policy.example.yaml's own examples. A narrow, hardcoded
24
+ * floor, not the full configurable policy (P3's job).
25
+ */
26
+ const DANGEROUS_LITERAL_PATTERNS = [
27
+ { label: "GitLab personal access token (glpat-)", pattern: /glpat-/ },
28
+ { label: "OpenAI-style secret key (sk-)", pattern: /\bsk-[A-Za-z0-9]/ },
29
+ { label: "GitHub personal access token (ghp_)", pattern: /ghp_/ },
30
+ { label: "mcp-router token (mcpr_)", pattern: /mcpr_/ },
31
+ ];
32
+ function findLiteralSecret(def) {
33
+ const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {})].filter((v) => typeof v === "string");
34
+ for (const candidate of candidates) {
35
+ for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
36
+ if (pattern.test(candidate)) {
37
+ return label;
38
+ }
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ export function resolveMcpPlan(agentId, mcp) {
44
+ if (mcp.hub) {
45
+ if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
46
+ return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
47
+ }
48
+ return { desired: [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }], conflicts: [] };
49
+ }
50
+ const desired = [];
51
+ const conflicts = [];
52
+ for (const [name, def] of Object.entries(mcp.servers)) {
53
+ if (!isInScope(agentId, def.agents)) {
54
+ continue;
55
+ }
56
+ if (mcp.knownHostInjected.includes(name)) {
57
+ conflicts.push({ name, message: collisionMessage(name, agentId) });
58
+ continue;
59
+ }
60
+ const secretLabel = findLiteralSecret(def);
61
+ if (secretLabel) {
62
+ conflicts.push({
63
+ name,
64
+ message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
65
+ });
66
+ continue;
67
+ }
68
+ // Codex has no generic headers concept — only the single
69
+ // Authorization-bearer-token shape is expressible there
70
+ // (trellis-mcp-transport-auth design.md D4). Any other shape is
71
+ // refused for Codex specifically; every other in-scope agent for
72
+ // the same server is unaffected (this loop runs once per agentId).
73
+ if (agentId === "codex" && def.headers && Object.keys(def.headers).length > 0 && !codexBearerTokenEnvVar(def)) {
74
+ conflicts.push({
75
+ name,
76
+ message: `refusing to write MCP server "${name}" for codex: its "headers" field isn't the single { Authorization: "Bearer \${VAR}" } shape Codex's own config format can express — Codex has no generic headers concept, only \`bearer_token_env_var\`. Still written normally for every other in-scope agent.`,
77
+ });
78
+ continue;
79
+ }
80
+ desired.push({ name, def });
81
+ }
82
+ return { desired, conflicts };
83
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * pi adapter: `~/.pi/agent/skills/<name>` symlinks, `~/.pi/agent/AGENTS.md`
3
+ * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
+ * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
+ * override name first, and Trellis's managed file is the baseline, not an
6
+ * override of something else). pi has no native MCP client (docs/research.md),
7
+ * so MCP access is delivered as a single symlinked bridge extension
8
+ * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
+ * mechanism from every other agent's config-generation adapter, but the
10
+ * same symlink create/repair/remove machinery as skills/instructions.
11
+ */
12
+ import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
13
+ import type { CanonicalSource } from "../core/types.js";
14
+ export declare class PiAdapter implements TrellisAdapter {
15
+ private readonly homeDir;
16
+ readonly name = "pi";
17
+ readonly id: "pi";
18
+ constructor(homeDir?: string);
19
+ probe(): Promise<AdapterProbeResult>;
20
+ plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
21
+ apply(plan: AdapterPlanItem[]): Promise<void>;
22
+ verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
23
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * pi adapter: `~/.pi/agent/skills/<name>` symlinks, `~/.pi/agent/AGENTS.md`
3
+ * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
+ * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
+ * override name first, and Trellis's managed file is the baseline, not an
6
+ * override of something else). pi has no native MCP client (docs/research.md),
7
+ * so MCP access is delivered as a single symlinked bridge extension
8
+ * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
+ * mechanism from every other agent's config-generation adapter, but the
10
+ * same symlink create/repair/remove machinery as skills/instructions.
11
+ */
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { homedir } from "node:os";
15
+ import { isInScope } from "../core/adapter.js";
16
+ import * as piProbe from "../probes/pi.js";
17
+ import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
18
+ const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
19
+ /**
20
+ * The bundled bridge file's location — always `<repo-root>/dist/pi-bridge/
21
+ * bundle.js`, never `~/.trellis/` (it's Trellis's own packaged code, not a
22
+ * user-authored capability — design.md D2). Resolved relative to *this
23
+ * currently executing module* rather than assumed: both `src/adapters/
24
+ * pi.ts` (dev, via tsx) and `dist/adapters/pi.js` (published) sit exactly
25
+ * two directories below the repo root, so `../../dist/pi-bridge/bundle.js`
26
+ * resolves correctly from either.
27
+ *
28
+ * Always the *bundled* output, in dev too — never the raw
29
+ * `src/pi-bridge/index.ts` source. Node's loader resolves a symlinked
30
+ * file's own bare-specifier imports relative to the *symlink's path*
31
+ * (`~/.pi/agent/extensions/...`), not its realpath, so an unbundled file
32
+ * can never resolve `typebox`/`@modelcontextprotocol/sdk` once symlinked
33
+ * into an arbitrary user's home directory — confirmed empirically in the
34
+ * real sandbox (design.md D6). `scripts/build-pi-bridge.mjs` must have run
35
+ * (part of `npm run build`) before this symlink is of any use to pi.
36
+ */
37
+ function resolveBridgeFile() {
38
+ const currentFile = fileURLToPath(import.meta.url);
39
+ return {
40
+ file: join(dirname(currentFile), "..", "..", "dist", "pi-bridge", "bundle.js"),
41
+ symlinkName: BRIDGE_SYMLINK_NAME,
42
+ };
43
+ }
44
+ export class PiAdapter {
45
+ homeDir;
46
+ name = "pi";
47
+ id = "pi";
48
+ constructor(homeDir = homedir()) {
49
+ this.homeDir = homeDir;
50
+ }
51
+ async probe() {
52
+ const snapshot = await piProbe.probe(this.homeDir);
53
+ return { present: snapshot.present, version: snapshot.version };
54
+ }
55
+ async plan(canonical) {
56
+ const canonicalRoot = dirname(canonical.instructionsFile);
57
+ const agentDir = join(this.homeDir, ".pi", "agent");
58
+ const desiredSkills = canonical.skills
59
+ .filter((skill) => isInScope(this.id, skill.scope))
60
+ .map((skill) => ({ name: skill.name, target: skill.dir }));
61
+ const skillItems = planSymlinks({
62
+ rootDir: join(agentDir, "skills"),
63
+ desired: desiredSkills,
64
+ canonicalRoot: join(canonicalRoot, "skills"),
65
+ kind: "skill",
66
+ });
67
+ const instructionsItems = planSymlinks({
68
+ rootDir: agentDir,
69
+ desired: [{ name: "AGENTS.md", target: canonical.instructionsFile }],
70
+ canonicalRoot,
71
+ kind: "instructions",
72
+ });
73
+ const { file: bridgeFile, symlinkName } = resolveBridgeFile();
74
+ const extensionItems = planSymlinks({
75
+ rootDir: join(agentDir, "extensions"),
76
+ desired: [{ name: symlinkName, target: bridgeFile }],
77
+ // The bridge file's own parent directory, not `~/.trellis/` —
78
+ // proves ownership for removal against Trellis's own install path
79
+ // (design.md D2), same role `canonicalRoot` plays for skills.
80
+ canonicalRoot: dirname(bridgeFile),
81
+ kind: "extension",
82
+ });
83
+ return [...skillItems, ...instructionsItems, ...extensionItems];
84
+ }
85
+ async apply(plan) {
86
+ await applySymlinkPlan(plan);
87
+ }
88
+ async verify(canonical) {
89
+ const snapshot = await piProbe.probe(this.homeDir);
90
+ if (!snapshot.present) {
91
+ return { ok: false, mismatches: ["pi is not present on this machine"] };
92
+ }
93
+ const mismatches = [];
94
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
95
+ const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
96
+ for (const name of desiredNames) {
97
+ if (!actualSkills.has(name)) {
98
+ mismatches.push(`skill "${name}" is in canonical scope for pi but missing on disk`);
99
+ }
100
+ }
101
+ for (const name of actualSkills) {
102
+ if (!desiredNames.has(name)) {
103
+ mismatches.push(`skill "${name}" is present on disk but not in canonical scope for pi`);
104
+ }
105
+ }
106
+ return mismatches.length === 0 ? { ok: true } : { ok: false, mismatches };
107
+ }
108
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared create/repair/remove/refuse decision logic every adapter needs
3
+ * for both skills and the instructions file (openspec/changes/
4
+ * trellis-sync-p1/specs/skill-instructions-sync/spec.md) — built once,
5
+ * reused by every adapter rather than reimplemented per agent.
6
+ */
7
+ import type { AdapterPlanItem } from "../core/adapter.js";
8
+ export interface DesiredSymlink {
9
+ /** Basename this entry should have under `rootDir`. */
10
+ name: string;
11
+ /** Absolute path the symlink should point at (inside `canonicalRoot`). */
12
+ target: string;
13
+ }
14
+ /**
15
+ * `rootDir` is the directory entries are named directly under (e.g.
16
+ * `~/.claude/skills`, or the instructions file's own parent directory when
17
+ * `desired` has exactly one entry). `canonicalRoot` is what proves
18
+ * ownership for removal — an existing symlink's stored (readlink) target
19
+ * must resolve inside it before this function will ever plan removing it.
20
+ * Deliberately not realpath-based here (unlike `isSymlinkTo` above, used
21
+ * for create/repair): a symlink whose canonical target was just deleted
22
+ * is broken by construction, and realpath throws on those — exactly the
23
+ * case this removal path exists to catch.
24
+ */
25
+ export declare function planSymlinks(opts: {
26
+ rootDir: string;
27
+ desired: DesiredSymlink[];
28
+ canonicalRoot: string;
29
+ kind: "skill" | "instructions" | "extension";
30
+ }): AdapterPlanItem[];
31
+ /** Executes a `planSymlinks` result. "conflict" is report-only — see
32
+ * src/core/adapter.ts's `apply()` doc for why this never throws. */
33
+ export declare function applySymlinkPlan(plan: AdapterPlanItem[]): Promise<void>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared create/repair/remove/refuse decision logic every adapter needs
3
+ * for both skills and the instructions file (openspec/changes/
4
+ * trellis-sync-p1/specs/skill-instructions-sync/spec.md) — built once,
5
+ * reused by every adapter rather than reimplemented per agent.
6
+ */
7
+ import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
8
+ import { mkdir, rm, symlink } from "node:fs/promises";
9
+ import { join, resolve, sep } from "node:path";
10
+ import { isSymlinkTo } from "../lib/fsIdentity.js";
11
+ function isUnderRoot(path, root) {
12
+ const normalizedRoot = resolve(root);
13
+ const normalizedPath = resolve(path);
14
+ if (normalizedPath === normalizedRoot)
15
+ return true;
16
+ const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
17
+ return normalizedPath.startsWith(prefix);
18
+ }
19
+ /**
20
+ * `rootDir` is the directory entries are named directly under (e.g.
21
+ * `~/.claude/skills`, or the instructions file's own parent directory when
22
+ * `desired` has exactly one entry). `canonicalRoot` is what proves
23
+ * ownership for removal — an existing symlink's stored (readlink) target
24
+ * must resolve inside it before this function will ever plan removing it.
25
+ * Deliberately not realpath-based here (unlike `isSymlinkTo` above, used
26
+ * for create/repair): a symlink whose canonical target was just deleted
27
+ * is broken by construction, and realpath throws on those — exactly the
28
+ * case this removal path exists to catch.
29
+ */
30
+ export function planSymlinks(opts) {
31
+ const { rootDir, desired, kind } = opts;
32
+ const items = [];
33
+ const desiredNames = new Set(desired.map((d) => d.name));
34
+ const canonicalRootResolved = resolve(opts.canonicalRoot);
35
+ for (const { name, target } of desired) {
36
+ const path = join(rootDir, name);
37
+ if (isSymlinkTo(path, target)) {
38
+ continue; // already correct — no-op
39
+ }
40
+ if (existsSync(path) && !lstatSync(path).isSymbolicLink()) {
41
+ items.push({
42
+ action: "conflict",
43
+ kind,
44
+ target: path,
45
+ description: `${path} exists and is not a Trellis-managed symlink — left untouched`,
46
+ });
47
+ continue;
48
+ }
49
+ items.push({
50
+ action: "create",
51
+ kind,
52
+ target: path,
53
+ linkTarget: target,
54
+ description: `symlink ${path} -> ${target}`,
55
+ });
56
+ }
57
+ let existingEntries = [];
58
+ try {
59
+ existingEntries = readdirSync(rootDir);
60
+ }
61
+ catch {
62
+ existingEntries = [];
63
+ }
64
+ for (const name of existingEntries) {
65
+ if (desiredNames.has(name))
66
+ continue;
67
+ const path = join(rootDir, name);
68
+ let isSymlink;
69
+ try {
70
+ isSymlink = lstatSync(path).isSymbolicLink();
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (!isSymlink)
76
+ continue; // real content, not Trellis's — never touched, never even reported
77
+ // Ownership proof uses the symlink's raw stored target (readlink), not
78
+ // realpath: the whole point of this branch is deleted-canonical-entry
79
+ // symlinks, which are BROKEN by construction (their target no longer
80
+ // exists) — realpath throws on those, which would silently skip
81
+ // exactly the case "remove a deleted skill's stale symlink" exists to
82
+ // catch. Trellis always writes an absolute path as the link target
83
+ // (see applySymlinkPlan), so comparing the raw stored string against
84
+ // canonicalRoot needs no filesystem resolution on either side.
85
+ let linkTarget;
86
+ try {
87
+ linkTarget = readlinkSync(path);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (!isUnderRoot(linkTarget, canonicalRootResolved))
93
+ continue; // points somewhere else — not ours
94
+ items.push({
95
+ action: "remove",
96
+ kind,
97
+ target: path,
98
+ description: `remove stale symlink ${path} (canonical entry gone or scoped away)`,
99
+ });
100
+ }
101
+ return items;
102
+ }
103
+ /** Executes a `planSymlinks` result. "conflict" is report-only — see
104
+ * src/core/adapter.ts's `apply()` doc for why this never throws. */
105
+ export async function applySymlinkPlan(plan) {
106
+ for (const item of plan) {
107
+ if (item.action === "conflict")
108
+ continue;
109
+ if (item.action === "remove") {
110
+ await rm(item.target, { force: true });
111
+ continue;
112
+ }
113
+ // "create": rootDir may not exist yet (first sync ever for this agent)
114
+ if (!item.linkTarget)
115
+ continue;
116
+ await mkdir(resolve(item.target, ".."), { recursive: true });
117
+ await rm(item.target, { force: true }); // clear a wrong-target symlink before repointing
118
+ await symlink(item.linkTarget, item.target);
119
+ }
120
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point. Stays a thin dispatcher; real logic lives in
4
+ * src/commands/*.ts so it stays testable without going through argv. See
5
+ * docs/roadmap.md for what's implemented.
6
+ */
7
+ export {};