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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/adapters/claude-code.d.ts +23 -0
- package/dist/adapters/claude-code.js +86 -0
- package/dist/adapters/codex.d.ts +27 -0
- package/dist/adapters/codex.js +119 -0
- package/dist/adapters/jsonMcp.d.ts +24 -0
- package/dist/adapters/jsonMcp.js +84 -0
- package/dist/adapters/kiro.d.ts +34 -0
- package/dist/adapters/kiro.js +175 -0
- package/dist/adapters/mcpPlan.d.ts +28 -0
- package/dist/adapters/mcpPlan.js +83 -0
- package/dist/adapters/pi.d.ts +23 -0
- package/dist/adapters/pi.js +108 -0
- package/dist/adapters/symlinkPlan.d.ts +33 -0
- package/dist/adapters/symlinkPlan.js +120 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +135 -0
- package/dist/commands/doctor.d.ts +88 -0
- package/dist/commands/doctor.js +269 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +150 -0
- package/dist/commands/mcp.d.ts +28 -0
- package/dist/commands/mcp.js +70 -0
- package/dist/commands/migrate.d.ts +38 -0
- package/dist/commands/migrate.js +132 -0
- package/dist/commands/onboard.d.ts +50 -0
- package/dist/commands/onboard.js +155 -0
- package/dist/commands/secretsAudit.d.ts +35 -0
- package/dist/commands/secretsAudit.js +115 -0
- package/dist/commands/sync.d.ts +40 -0
- package/dist/commands/sync.js +91 -0
- package/dist/core/adapter.d.ts +133 -0
- package/dist/core/adapter.js +16 -0
- package/dist/core/canonical.d.ts +16 -0
- package/dist/core/canonical.js +148 -0
- package/dist/core/types.d.ts +201 -0
- package/dist/core/types.js +15 -0
- package/dist/lib/dirEquals.d.ts +7 -0
- package/dist/lib/dirEquals.js +39 -0
- package/dist/lib/envVarNames.d.ts +35 -0
- package/dist/lib/envVarNames.js +79 -0
- package/dist/lib/fsIdentity.d.ts +16 -0
- package/dist/lib/fsIdentity.js +53 -0
- package/dist/lib/mcpProbe.d.ts +14 -0
- package/dist/lib/mcpProbe.js +96 -0
- package/dist/lib/probeCommon.d.ts +24 -0
- package/dist/lib/probeCommon.js +108 -0
- package/dist/lib/secretEnv.d.ts +19 -0
- package/dist/lib/secretEnv.js +46 -0
- package/dist/lib/skillFile.d.ts +12 -0
- package/dist/lib/skillFile.js +26 -0
- package/dist/lib/syncArgs.d.ts +16 -0
- package/dist/lib/syncArgs.js +17 -0
- package/dist/lib/tomlSection.d.ts +57 -0
- package/dist/lib/tomlSection.js +162 -0
- package/dist/pi-bridge/bundle.js +32074 -0
- package/dist/pi-bridge/index.d.ts +48 -0
- package/dist/pi-bridge/index.js +188 -0
- package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
- package/dist/pi-bridge/schemaTranslate.js +40 -0
- package/dist/probes/claude-code.d.ts +13 -0
- package/dist/probes/claude-code.js +48 -0
- package/dist/probes/codex.d.ts +24 -0
- package/dist/probes/codex.js +78 -0
- package/dist/probes/kiro.d.ts +12 -0
- package/dist/probes/kiro.js +48 -0
- package/dist/probes/pi.d.ts +14 -0
- package/dist/probes/pi.js +53 -0
- package/dist/sdk.d.ts +14 -0
- package/dist/sdk.js +13 -0
- package/docs/architecture.md +367 -0
- package/docs/getting-started.md +235 -0
- package/docs/implementation-plan.md +341 -0
- package/docs/research.md +175 -0
- package/docs/roadmap.md +484 -0
- package/package.json +59 -0
- package/schema/scope.example.yaml +33 -0
- package/schema/secrets.policy.example.yaml +43 -0
- 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
|
+
}
|