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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis secrets audit` — reads every present, MCP-capable agent's real
|
|
3
|
+
* config file (never the canonical source) and checks it against
|
|
4
|
+
* `CanonicalSource.secretsPolicy`: a literal-value scan against
|
|
5
|
+
* `rejectPatterns`, and a declared-env-var-name check against
|
|
6
|
+
* `allowedVars`. Also checks, agent-agnostically, whether every env var
|
|
7
|
+
* name declared across canonical `mcp.servers[*].env` actually resolves
|
|
8
|
+
* to a value via the same `resolveSecretEnv` the pi bridge uses
|
|
9
|
+
* (trellis-secrets-env-management) — authoritative for pi, a best-effort
|
|
10
|
+
* proxy for the other three (design.md D3 in that change). Read-only —
|
|
11
|
+
* never writes anything. Fails non-zero on any finding
|
|
12
|
+
* (trellis-secrets-audit-p3).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
18
|
+
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
19
|
+
import { CodexAdapter } from "../adapters/codex.js";
|
|
20
|
+
import { KiroAdapter } from "../adapters/kiro.js";
|
|
21
|
+
import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
|
|
22
|
+
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
23
|
+
function auditedAgents(homeDir) {
|
|
24
|
+
return [
|
|
25
|
+
{ id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
|
|
26
|
+
{ id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
|
|
27
|
+
{ id: "kiro", probe: () => new KiroAdapter(homeDir).probe(), configPath: (h) => join(h, ".kiro", "settings", "mcp.json"), extractNames: extractJsonEnvVarNames },
|
|
28
|
+
// pi has no static, generated MCP config file to audit — its bridge
|
|
29
|
+
// reads mcp/servers.yaml directly at its own runtime (P4). See
|
|
30
|
+
// design.md Non-Goals in trellis-secrets-audit-p3.
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Not scoped to any agent, present or not — a name that can't resolve is
|
|
35
|
+
* a problem regardless of which agents' `servers.yaml` `agents:` field
|
|
36
|
+
* would route it to (trellis-secrets-env-management design.md D3).
|
|
37
|
+
*/
|
|
38
|
+
function findMissingEnvValues(servers, policy) {
|
|
39
|
+
const names = new Set();
|
|
40
|
+
for (const def of Object.values(servers)) {
|
|
41
|
+
for (const name of declaredEnvNames(def))
|
|
42
|
+
names.add(name);
|
|
43
|
+
}
|
|
44
|
+
if (names.size === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const resolved = resolveSecretEnv([...names], policy);
|
|
47
|
+
const source = policy.envFile ?? "process environment";
|
|
48
|
+
return [...names]
|
|
49
|
+
.filter((name) => !resolved[name])
|
|
50
|
+
.map((name) => ({
|
|
51
|
+
agent: "environment",
|
|
52
|
+
file: source,
|
|
53
|
+
kind: "missing-env-value",
|
|
54
|
+
detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function auditFile(agent, file, content, policy, extractNames) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
for (const pattern of policy.rejectPatterns) {
|
|
60
|
+
if (pattern.test(content)) {
|
|
61
|
+
findings.push({ agent, file, kind: "literal-secret", detail: `matches reject pattern ${pattern}` });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const allowed = new Set(policy.allowedVars);
|
|
65
|
+
for (const name of new Set(extractNames(content))) {
|
|
66
|
+
if (!allowed.has(name)) {
|
|
67
|
+
findings.push({ agent, file, kind: "unexpected-var-name", detail: `"${name}" is not in secrets.policy.yaml's allowed_vars` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
export async function collectSecretsAuditReport(opts = {}) {
|
|
73
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
74
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const agent of auditedAgents(homeDir)) {
|
|
77
|
+
const probeResult = await agent.probe();
|
|
78
|
+
if (!probeResult.present)
|
|
79
|
+
continue;
|
|
80
|
+
const file = agent.configPath(homeDir);
|
|
81
|
+
if (!existsSync(file))
|
|
82
|
+
continue;
|
|
83
|
+
const content = readFileSync(file, "utf-8");
|
|
84
|
+
findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
|
|
85
|
+
}
|
|
86
|
+
findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
|
|
87
|
+
return { findings };
|
|
88
|
+
}
|
|
89
|
+
export async function runSecretsAudit(opts = {}) {
|
|
90
|
+
let report;
|
|
91
|
+
try {
|
|
92
|
+
report = await collectSecretsAuditReport(opts);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
96
|
+
return { exitCode: 1 };
|
|
97
|
+
}
|
|
98
|
+
if (opts.json) {
|
|
99
|
+
console.log(JSON.stringify(report, null, 2));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
printReport(report);
|
|
103
|
+
}
|
|
104
|
+
return { exitCode: report.findings.length > 0 ? 1 : 0 };
|
|
105
|
+
}
|
|
106
|
+
function printReport(report) {
|
|
107
|
+
if (report.findings.length === 0) {
|
|
108
|
+
console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
console.log(`⚠️ ${report.findings.length} finding(s):`);
|
|
112
|
+
for (const finding of report.findings) {
|
|
113
|
+
console.log(` - [${finding.kind}] ${finding.agent} — ${finding.file}: ${finding.detail}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis sync` / `trellis sync skills` / `trellis sync instructions` —
|
|
3
|
+
* loads canonical once, runs every present agent's adapter, applies the
|
|
4
|
+
* plan, and reports what happened. Conflicts are report-only (never
|
|
5
|
+
* thrown) — see src/core/adapter.ts's `apply()` doc — so one conflicting
|
|
6
|
+
* item never blocks every other, unrelated item in the same run.
|
|
7
|
+
*/
|
|
8
|
+
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
9
|
+
import type { AgentId } from "../core/types.js";
|
|
10
|
+
export interface RunSyncOptions {
|
|
11
|
+
/** Omit to sync both. */
|
|
12
|
+
target?: "skills" | "instructions";
|
|
13
|
+
json?: boolean;
|
|
14
|
+
/** Defaults to the real `~`; overridable for tests and
|
|
15
|
+
* `scripts/sandbox.sh` only — same seam as every probe and
|
|
16
|
+
* `loadCanonicalSource` uses, and for the same reason: this is the first
|
|
17
|
+
* command in the repo that writes, and it must never be exercised
|
|
18
|
+
* against a developer's real dotfiles (docs/architecture.md's testing
|
|
19
|
+
* philosophy). Not a CLI flag — there is no product reason for an end
|
|
20
|
+
* user to ever point `trellis sync` at a fake home. */
|
|
21
|
+
homeDir?: string;
|
|
22
|
+
/** Compute and report the plan without calling adapter.apply(). */
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface AgentSyncReport {
|
|
26
|
+
agent: AgentId;
|
|
27
|
+
present: boolean;
|
|
28
|
+
items: AdapterPlanItem[];
|
|
29
|
+
}
|
|
30
|
+
export interface SyncReport {
|
|
31
|
+
reports: AgentSyncReport[];
|
|
32
|
+
}
|
|
33
|
+
export declare function collectSyncReport(opts?: RunSyncOptions): Promise<SyncReport>;
|
|
34
|
+
export declare function runSync(opts?: RunSyncOptions): Promise<{
|
|
35
|
+
exitCode: number;
|
|
36
|
+
}>;
|
|
37
|
+
/** Exported so `onboard` prints a sync report identically to running
|
|
38
|
+
* `sync` standalone, instead of a second, easily-drifting copy of this
|
|
39
|
+
* formatting. */
|
|
40
|
+
export declare function printReport(report: SyncReport, dryRun: boolean): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis sync` / `trellis sync skills` / `trellis sync instructions` —
|
|
3
|
+
* loads canonical once, runs every present agent's adapter, applies the
|
|
4
|
+
* plan, and reports what happened. Conflicts are report-only (never
|
|
5
|
+
* thrown) — see src/core/adapter.ts's `apply()` doc — so one conflicting
|
|
6
|
+
* item never blocks every other, unrelated item in the same run.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
10
|
+
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
11
|
+
import { CodexAdapter } from "../adapters/codex.js";
|
|
12
|
+
import { KiroAdapter } from "../adapters/kiro.js";
|
|
13
|
+
import { PiAdapter } from "../adapters/pi.js";
|
|
14
|
+
function buildAdapters(homeDir) {
|
|
15
|
+
return [new ClaudeCodeAdapter(homeDir), new CodexAdapter(homeDir), new KiroAdapter(homeDir), new PiAdapter(homeDir)];
|
|
16
|
+
}
|
|
17
|
+
export async function collectSyncReport(opts = {}) {
|
|
18
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
19
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
20
|
+
const reports = [];
|
|
21
|
+
for (const adapter of buildAdapters(homeDir)) {
|
|
22
|
+
const probeResult = await adapter.probe();
|
|
23
|
+
if (!probeResult.present) {
|
|
24
|
+
reports.push({ agent: adapter.id, present: false, items: [] });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// adapter.plan() also returns "mcp" items now (trellis-mcp-sync-p2) —
|
|
28
|
+
// `trellis sync` never touches MCP, that's `trellis mcp sync`'s own
|
|
29
|
+
// command, so exclude it unconditionally before any target filter.
|
|
30
|
+
let items = (await adapter.plan(canonical)).filter((item) => item.kind !== "mcp");
|
|
31
|
+
if (opts.target) {
|
|
32
|
+
// CLI/option vocabulary is plural ("skills"/"instructions",
|
|
33
|
+
// matching `trellis sync skills`); AdapterPlanItem.kind is singular
|
|
34
|
+
// ("skill"/"instructions") since it describes one item. Map
|
|
35
|
+
// explicitly rather than assume the strings line up — they don't,
|
|
36
|
+
// and a caught-nowhere mismatch here silently filters everything
|
|
37
|
+
// out (found via the sandbox: every agent reported "already in
|
|
38
|
+
// sync" even with real create items pending).
|
|
39
|
+
const kind = opts.target === "skills" ? "skill" : "instructions";
|
|
40
|
+
items = items.filter((item) => item.kind === kind);
|
|
41
|
+
}
|
|
42
|
+
if (!opts.dryRun) {
|
|
43
|
+
await adapter.apply(items);
|
|
44
|
+
}
|
|
45
|
+
reports.push({ agent: adapter.id, present: true, items });
|
|
46
|
+
}
|
|
47
|
+
return { reports };
|
|
48
|
+
}
|
|
49
|
+
export async function runSync(opts = {}) {
|
|
50
|
+
let report;
|
|
51
|
+
try {
|
|
52
|
+
report = await collectSyncReport(opts);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
56
|
+
return { exitCode: 1 };
|
|
57
|
+
}
|
|
58
|
+
if (opts.json) {
|
|
59
|
+
console.log(JSON.stringify(report, null, 2));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
printReport(report, opts.dryRun ?? false);
|
|
63
|
+
}
|
|
64
|
+
const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
|
|
65
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
66
|
+
}
|
|
67
|
+
/** Exported so `onboard` prints a sync report identically to running
|
|
68
|
+
* `sync` standalone, instead of a second, easily-drifting copy of this
|
|
69
|
+
* formatting. */
|
|
70
|
+
export function printReport(report, dryRun) {
|
|
71
|
+
if (dryRun)
|
|
72
|
+
console.log("[dry run]");
|
|
73
|
+
for (const { agent, present, items } of report.reports) {
|
|
74
|
+
if (!present) {
|
|
75
|
+
console.log(`— ${agent} (not installed)`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const created = items.filter((i) => i.action === "create");
|
|
79
|
+
const removed = items.filter((i) => i.action === "remove");
|
|
80
|
+
const conflicts = items.filter((i) => i.action === "conflict");
|
|
81
|
+
if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
|
|
82
|
+
console.log(`✅ ${agent} — already in sync`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const icon = conflicts.length > 0 ? "⚠️ " : "✅";
|
|
86
|
+
console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
|
|
87
|
+
for (const item of [...created, ...removed, ...conflicts]) {
|
|
88
|
+
console.log(` - [${item.action}] ${item.description}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter contract every agent integration (Claude Code, Codex, Kiro,
|
|
3
|
+
* pi) must implement. See docs/architecture.md "Adapter contract" for the
|
|
4
|
+
* rules each method must follow — in particular: `apply` must never
|
|
5
|
+
* overwrite fields it doesn't own, and `verify` must re-read the agent's
|
|
6
|
+
* own state rather than trust that `apply` succeeded.
|
|
7
|
+
*
|
|
8
|
+
* No adapter exists yet (see docs/roadmap.md P1/P2/P4). This interface is
|
|
9
|
+
* the contract they'll be built against.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
12
|
+
import { resolveScope } from "./types.js";
|
|
13
|
+
export interface AdapterProbeResult {
|
|
14
|
+
present: boolean;
|
|
15
|
+
version?: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface AdapterPlanItem {
|
|
19
|
+
/**
|
|
20
|
+
* "create" also covers repair (wrong symlink target, or an MCP server
|
|
21
|
+
* definition that differs from canonical); "remove" is the delete half
|
|
22
|
+
* for skills/instructions — a Trellis-managed symlink whose canonical
|
|
23
|
+
* entry is gone or was just scoped away from this agent. MCP server
|
|
24
|
+
* items never use "remove" (see `kind: "mcp"` below — no ownership
|
|
25
|
+
* marker exists yet to make that provably safe, trellis-mcp-sync-p2
|
|
26
|
+
* design.md D7). "conflict" is either a real, non-symlink path
|
|
27
|
+
* occupying a spot Trellis would otherwise touch, or an MCP server
|
|
28
|
+
* refused for a collision/secrets-guard reason — reported, never acted
|
|
29
|
+
* on. See `plan()`'s doc below.
|
|
30
|
+
*/
|
|
31
|
+
action: "create" | "remove" | "conflict";
|
|
32
|
+
/** Lets `trellis sync skills` / `trellis sync instructions` /
|
|
33
|
+
* `trellis mcp sync` filter a full plan without changing `plan()`'s
|
|
34
|
+
* signature — every adapter produces every kind it's responsible for in
|
|
35
|
+
* one pass; the CLI subcommand decides which to apply, not the adapter.
|
|
36
|
+
* `"extension"` is pi-only: the MCP bridge file itself
|
|
37
|
+
* (trellis-pi-mcp-bridge-p4) — Trellis's own packaged code, not a
|
|
38
|
+
* user-authored capability from `~/.trellis/`, delivered via the same
|
|
39
|
+
* symlink create/repair/remove semantics as skills/instructions.
|
|
40
|
+
* `"kiro-approved-env-vars"` is Kiro-only (trellis-kiro-approved-env-vars):
|
|
41
|
+
* a completely different target file and merge mechanism (see
|
|
42
|
+
* `approvedEnvVars` below) from `"mcp"`, not something the other
|
|
43
|
+
* three agents have an equivalent of. */
|
|
44
|
+
kind: "skill" | "instructions" | "mcp" | "extension" | "kiro-approved-env-vars";
|
|
45
|
+
/** Human-readable description of one change this adapter would make
|
|
46
|
+
* ("create" / "remove") or why it refused to ("conflict"). */
|
|
47
|
+
description: string;
|
|
48
|
+
/** What's being touched, for the collision/audit checks to reason
|
|
49
|
+
* about. For `kind: "mcp"`, the config file being modified (e.g.
|
|
50
|
+
* `~/.codex/config.toml`), not a per-server path — there isn't one. */
|
|
51
|
+
target: string;
|
|
52
|
+
/** Only set (and only meaningful) when `kind` is `"skill"` or
|
|
53
|
+
* `"instructions"` and `action === "create"`: the absolute path
|
|
54
|
+
* `target` should be symlinked to. Kept as a real field rather than
|
|
55
|
+
* embedded in `description` — `apply()` must never have to parse prose
|
|
56
|
+
* back into structured data. */
|
|
57
|
+
linkTarget?: string;
|
|
58
|
+
/** Only set (and only meaningful) when `kind === "mcp"` and
|
|
59
|
+
* `action === "create"`: the server name and definition to write into
|
|
60
|
+
* `target` (the config file) via that agent's own mechanism (JSON
|
|
61
|
+
* merge or, for Codex, `src/lib/tomlSection.ts`'s splice). */
|
|
62
|
+
mcpWrite?: {
|
|
63
|
+
name: string;
|
|
64
|
+
def: McpServerDef;
|
|
65
|
+
};
|
|
66
|
+
/** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
|
|
67
|
+
* and `action === "create"`: the full, already-deduplicated array to
|
|
68
|
+
* write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was
|
|
69
|
+
* already there plus every name Trellis's canonical MCP config needs
|
|
70
|
+
* for Kiro, never a subtraction (trellis-kiro-approved-env-vars
|
|
71
|
+
* design.md D3/D4). */
|
|
72
|
+
approvedEnvVars?: string[];
|
|
73
|
+
}
|
|
74
|
+
export interface AdapterVerifyResult {
|
|
75
|
+
ok: boolean;
|
|
76
|
+
/** Present only when ok is false — what didn't match canonical. */
|
|
77
|
+
mismatches?: string[];
|
|
78
|
+
}
|
|
79
|
+
export interface TrellisAdapter {
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly id: AgentId;
|
|
82
|
+
/** Does this agent exist on this machine, and what version. No side effects. */
|
|
83
|
+
probe(): Promise<AdapterProbeResult>;
|
|
84
|
+
/**
|
|
85
|
+
* Diffs canonical state against this agent's current on-disk state to
|
|
86
|
+
* produce a plan. Read-only against that state — it must never write —
|
|
87
|
+
* but it does read (e.g. `lstat` each target) because "create" vs.
|
|
88
|
+
* "remove" vs. no-op vs. conflict can't be decided from canonical alone.
|
|
89
|
+
*
|
|
90
|
+
* MUST filter every scopable item (skills, subagent profiles, memory
|
|
91
|
+
* entries, MCP servers) through its `scope` field before planning any
|
|
92
|
+
* change for it — an item scoped away from `this.id` must produce no
|
|
93
|
+
* plan item at all, not a plan item that's later skipped. Use
|
|
94
|
+
* `resolveScope(item.scope).includes(this.id)`. See docs/architecture.md
|
|
95
|
+
* "Private / agent-specific capabilities" — the default (`scope`
|
|
96
|
+
* omitted) is "all agents," so this filter is a no-op for the common
|
|
97
|
+
* case and only actually excludes anything when a capability was
|
|
98
|
+
* explicitly restricted.
|
|
99
|
+
*
|
|
100
|
+
* MUST also produce "remove" items: an on-disk symlink whose realpath
|
|
101
|
+
* resolves inside the canonical source, but whose corresponding entry no
|
|
102
|
+
* longer exists in `canonical` (or was just scoped away from `this.id`)
|
|
103
|
+
* is stale and belongs in the plan as a removal — not silently left
|
|
104
|
+
* behind. This is the delete half of "add once, remove once, reaches
|
|
105
|
+
* every agent"; a plan() that only ever emits "create" items has an
|
|
106
|
+
* add-only implementation regardless of what the roadmap says.
|
|
107
|
+
*
|
|
108
|
+
* MUST NOT plan removal of a path that isn't a symlink Trellis can prove
|
|
109
|
+
* it created (realpath outside the canonical source) — that's a real
|
|
110
|
+
* conflict, not a stale entry, and MUST instead produce a "conflict"
|
|
111
|
+
* plan item so it's visible in the same report as everything else,
|
|
112
|
+
* rather than only surfacing when `apply()` gets to it.
|
|
113
|
+
*/
|
|
114
|
+
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
115
|
+
/**
|
|
116
|
+
* Perform the diff. Must be idempotent and safely re-runnable: applying
|
|
117
|
+
* a "create" item against an already-correct symlink is a no-op, and
|
|
118
|
+
* applying a "remove" item against an already-gone path is a no-op.
|
|
119
|
+
*
|
|
120
|
+
* MUST treat every "conflict" item as report-only: no filesystem
|
|
121
|
+
* operation, and MUST NOT throw or abort the rest of the plan because
|
|
122
|
+
* of it — a conflict on one item must never prevent every other, unrelated
|
|
123
|
+
* item in the same plan from being applied. The caller (e.g. `trellis
|
|
124
|
+
* sync`) is responsible for surfacing conflicts and failing the overall
|
|
125
|
+
* command (non-zero exit), not `apply()` per item.
|
|
126
|
+
*/
|
|
127
|
+
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
128
|
+
/** Re-read the agent's own state and confirm it matches canonical. */
|
|
129
|
+
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
130
|
+
}
|
|
131
|
+
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
132
|
+
* obligation documented above. */
|
|
133
|
+
export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter contract every agent integration (Claude Code, Codex, Kiro,
|
|
3
|
+
* pi) must implement. See docs/architecture.md "Adapter contract" for the
|
|
4
|
+
* rules each method must follow — in particular: `apply` must never
|
|
5
|
+
* overwrite fields it doesn't own, and `verify` must re-read the agent's
|
|
6
|
+
* own state rather than trust that `apply` succeeded.
|
|
7
|
+
*
|
|
8
|
+
* No adapter exists yet (see docs/roadmap.md P1/P2/P4). This interface is
|
|
9
|
+
* the contract they'll be built against.
|
|
10
|
+
*/
|
|
11
|
+
import { resolveScope } from "./types.js";
|
|
12
|
+
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
13
|
+
* obligation documented above. */
|
|
14
|
+
export function isInScope(id, scope) {
|
|
15
|
+
return resolveScope(scope).includes(id);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
|
|
3
|
+
* parameter, no workspace merge (docs/architecture.md "Global vs.
|
|
4
|
+
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
|
+
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
|
+
*/
|
|
7
|
+
import type { CanonicalSource } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
10
|
+
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
|
11
|
+
* (src/probes/*.ts) and for the same reason: never touch a developer's
|
|
12
|
+
* real dotfiles from a test. It is not a workspace/project root — see
|
|
13
|
+
* specs/canonical-source-loading's global-only requirement, which this
|
|
14
|
+
* parameter does not weaken.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
|
|
3
|
+
* parameter, no workspace merge (docs/architecture.md "Global vs.
|
|
4
|
+
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
|
+
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, join } from "node:path";
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
import { ALL_AGENTS } from "./types.js";
|
|
12
|
+
function trellisRoot(homeDir) {
|
|
13
|
+
return join(homeDir, ".trellis");
|
|
14
|
+
}
|
|
15
|
+
function listMarkdownFiles(dir) {
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = readdirSync(dir);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return entries
|
|
24
|
+
.filter((name) => name.endsWith(".md"))
|
|
25
|
+
.map((name) => ({ name: basename(name, ".md"), file: join(dir, name) }));
|
|
26
|
+
}
|
|
27
|
+
function listSkillDirs(dir) {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const skills = [];
|
|
36
|
+
for (const name of entries) {
|
|
37
|
+
const skillDir = join(dir, name);
|
|
38
|
+
try {
|
|
39
|
+
if (statSync(skillDir).isDirectory()) {
|
|
40
|
+
skills.push({ name, dir: skillDir });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// unreadable/broken entry — skip, not a load failure
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return skills;
|
|
48
|
+
}
|
|
49
|
+
function loadScopeYaml(path) {
|
|
50
|
+
if (!existsSync(path)) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
const parsed = parseYaml(readFileSync(path, "utf-8"));
|
|
54
|
+
return (parsed ?? {});
|
|
55
|
+
}
|
|
56
|
+
function loadServersYaml(path) {
|
|
57
|
+
if (!existsSync(path)) {
|
|
58
|
+
return { servers: {}, knownHostInjected: [] };
|
|
59
|
+
}
|
|
60
|
+
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
61
|
+
return {
|
|
62
|
+
servers: parsed.servers ?? {},
|
|
63
|
+
knownHostInjected: parsed.known_host_injected ?? [],
|
|
64
|
+
hub: parsed.hub,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function loadSecretsPolicyYaml(path, homeDir) {
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
return { allowedVars: [], rejectPatterns: [] };
|
|
70
|
+
}
|
|
71
|
+
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
72
|
+
return {
|
|
73
|
+
allowedVars: parsed.allowed_vars ?? [],
|
|
74
|
+
rejectPatterns: (parsed.reject_patterns ?? []).map((pattern) => new RegExp(pattern)),
|
|
75
|
+
envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** `undefined` if the name has no entry in scope.yaml's map — "shared with
|
|
79
|
+
* all four agents," the default. A recognized but empty list is left as
|
|
80
|
+
* authored (an explicitly agent-less scope), not coerced to "all". */
|
|
81
|
+
function scopeFor(map, name) {
|
|
82
|
+
return map?.[name];
|
|
83
|
+
}
|
|
84
|
+
function validAgentIds(scope) {
|
|
85
|
+
if (!scope)
|
|
86
|
+
return true;
|
|
87
|
+
return scope.every((id) => ALL_AGENTS.includes(id));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
91
|
+
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
|
92
|
+
* (src/probes/*.ts) and for the same reason: never touch a developer's
|
|
93
|
+
* real dotfiles from a test. It is not a workspace/project root — see
|
|
94
|
+
* specs/canonical-source-loading's global-only requirement, which this
|
|
95
|
+
* parameter does not weaken.
|
|
96
|
+
*/
|
|
97
|
+
export function loadCanonicalSource(homeDir = homedir()) {
|
|
98
|
+
const root = trellisRoot(homeDir);
|
|
99
|
+
if (!existsSync(root)) {
|
|
100
|
+
throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
|
|
101
|
+
}
|
|
102
|
+
const diagnostics = [];
|
|
103
|
+
const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
|
|
104
|
+
const skillDirs = listSkillDirs(join(root, "skills"));
|
|
105
|
+
const knownSkillNames = new Set(skillDirs.map((s) => s.name));
|
|
106
|
+
const skills = skillDirs.map(({ name, dir }) => ({ name, dir, scope: scopeFor(scopeYaml.skills, name) }));
|
|
107
|
+
const agentFiles = listMarkdownFiles(join(root, "agents"));
|
|
108
|
+
const knownAgentProfileNames = new Set(agentFiles.map((a) => a.name));
|
|
109
|
+
const agents = agentFiles.map(({ name, file }) => ({
|
|
110
|
+
name,
|
|
111
|
+
file,
|
|
112
|
+
scope: scopeFor(scopeYaml.agents, name),
|
|
113
|
+
}));
|
|
114
|
+
const memoryFiles = listMarkdownFiles(join(root, "memories"));
|
|
115
|
+
const knownMemoryNames = new Set(memoryFiles.map((m) => m.name));
|
|
116
|
+
const memories = memoryFiles.map(({ name, file }) => ({
|
|
117
|
+
name,
|
|
118
|
+
file,
|
|
119
|
+
scope: scopeFor(scopeYaml.memories, name),
|
|
120
|
+
}));
|
|
121
|
+
for (const [section, known] of [
|
|
122
|
+
["skills", knownSkillNames],
|
|
123
|
+
["agents", knownAgentProfileNames],
|
|
124
|
+
["memories", knownMemoryNames],
|
|
125
|
+
]) {
|
|
126
|
+
const map = scopeYaml[section];
|
|
127
|
+
for (const name of Object.keys(map ?? {})) {
|
|
128
|
+
if (!known.has(name)) {
|
|
129
|
+
diagnostics.push(`scope.yaml: "${section}.${name}" does not match any known ${section.slice(0, -1)} — ignored`);
|
|
130
|
+
}
|
|
131
|
+
else if (!validAgentIds(map?.[name])) {
|
|
132
|
+
diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id — ignored`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
instructionsFile: join(root, "agents.md"),
|
|
138
|
+
skills,
|
|
139
|
+
agents,
|
|
140
|
+
memories,
|
|
141
|
+
mcp: loadServersYaml(join(root, "mcp", "servers.yaml")),
|
|
142
|
+
// P2's pre-write guard (src/adapters/mcpPlan.ts) uses its own narrow,
|
|
143
|
+
// hardcoded floor instead of this field — see design.md D1 in
|
|
144
|
+
// trellis-secrets-audit-p3.
|
|
145
|
+
secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
|
|
146
|
+
diagnostics,
|
|
147
|
+
};
|
|
148
|
+
}
|