agent-trellis 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -17
- package/dist/adapters/claude-code.d.ts +5 -3
- package/dist/adapters/claude-code.js +27 -14
- package/dist/adapters/codex.d.ts +8 -4
- package/dist/adapters/codex.js +47 -16
- package/dist/adapters/jsonMcp.d.ts +16 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +5 -3
- package/dist/adapters/kiro.js +29 -16
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +40 -7
- package/dist/adapters/pi.d.ts +2 -1
- package/dist/adapters/pi.js +4 -4
- package/dist/adapters/symlinkPlan.d.ts +7 -3
- package/dist/adapters/symlinkPlan.js +42 -16
- package/dist/cli.js +161 -18
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +114 -7
- package/dist/commands/mcp.js +258 -17
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +52 -7
- package/dist/commands/onboard.js +318 -35
- package/dist/commands/rollback.d.ts +44 -0
- package/dist/commands/rollback.js +201 -0
- package/dist/commands/secretsAudit.d.ts +7 -0
- package/dist/commands/secretsAudit.js +14 -7
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +28 -11
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +103 -3
- package/dist/core/types.d.ts +29 -1
- package/dist/core/types.js +11 -2
- package/dist/lib/backup.d.ts +56 -0
- package/dist/lib/backup.js +98 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +100 -51
- package/dist/pi-bridge/index.js +14 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +267 -33
- package/docs/roadmap.md +444 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
package/README.md
CHANGED
|
@@ -45,14 +45,29 @@ trellis onboard
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Creates canonical source, detects which of Claude Code/Codex/Kiro/pi are on
|
|
48
|
-
this machine,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
this machine, then resolves two independent choices: a **migration source**
|
|
49
|
+
(read from — auto-selected if only one present agent has real content,
|
|
50
|
+
prompted with a numbered choice if more than one, skippable if starting
|
|
51
|
+
fresh; pass `--agent <id>` to skip the prompt) and a **managed set** (written
|
|
52
|
+
to — zero or more agents, chosen explicitly; pass `--manage <ids>` or
|
|
53
|
+
`--manage none` to skip the prompt). The source is **not** managed by
|
|
54
|
+
default — importing from Claude Code doesn't mean Trellis starts writing to
|
|
55
|
+
Claude Code too, unless you explicitly include it. Selecting an agent that
|
|
56
|
+
isn't installed yet (e.g. pi) is itself the authorization to install it —
|
|
57
|
+
one confirmation, then a real `npm install -g <package>`; Kiro has no CLI
|
|
58
|
+
package and is refused with its download URL instead. Once resolved, it
|
|
59
|
+
runs `migrate`, `sync`, `mcp sync`, and `secrets audit` against the managed
|
|
60
|
+
set — the whole onboarding path in one command, no follow-up commands to
|
|
61
|
+
type by hand. If no agent is detected, it prints each supported agent's
|
|
62
|
+
real install command/URL and stops — it never installs anything on its own
|
|
63
|
+
initiative. Add `--dry-run` to preview the whole thing with zero writes.
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
```
|
|
66
|
+
trellis onboard --agent claude-code --manage pi
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Or step by step** (what `onboard` is actually doing under the hood, if you
|
|
70
|
+
want to run any one stage on its own):
|
|
56
71
|
|
|
57
72
|
**Already using Claude Code, Codex, Kiro, or pi and want to migrate what you
|
|
58
73
|
already have?**
|
|
@@ -65,24 +80,46 @@ already have?**
|
|
|
65
80
|
overwrites: an already-identical skill is reported and skipped, a genuine
|
|
66
81
|
conflict is reported and left for you to resolve by hand. Add `--dry-run`
|
|
67
82
|
to preview first.
|
|
68
|
-
3. `trellis sync` — distributes canonical skills/instructions to every
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
3. `trellis sync` — distributes canonical skills/instructions to every
|
|
84
|
+
**managed** agent (`~/.trellis/managed.yaml` — empty by default; edit it
|
|
85
|
+
by hand or let `trellis onboard` write it). An agent that's merely present
|
|
86
|
+
but not managed is never touched. Add `--dry-run` to preview first.
|
|
71
87
|
4. `trellis mcp sync` — distributes `~/.trellis/mcp/servers.yaml` (see
|
|
72
88
|
[`schema/servers.example.yaml`](schema/servers.example.yaml)) to every
|
|
73
|
-
agent's native MCP config (create/repair only — see Known
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
managed agent's native MCP config (create/repair only — see Known
|
|
90
|
+
limitations).
|
|
91
|
+
5. `trellis secrets audit` — fails non-zero if any managed agent's real
|
|
92
|
+
config holds a literal credential or an unexpected env var name.
|
|
93
|
+
6. `trellis doctor` — read-only scan of every present agent's current state
|
|
94
|
+
(managed or not); run any time to check for drift.
|
|
78
95
|
|
|
79
96
|
**Starting from nothing?** Skip step 2 — `trellis init`'s placeholder
|
|
80
97
|
`agents.md` and empty `skills/` are a fine starting point; edit them by hand.
|
|
81
98
|
|
|
99
|
+
**Every real write `sync`/`mcp sync`/`onboard` perform is backed up
|
|
100
|
+
first, automatically — no flag to opt in.** Before creating, repairing,
|
|
101
|
+
or removing anything, each is recorded to a structured, timestamped run
|
|
102
|
+
under `~/.trellis/backups/`, with enough information to invert it exactly.
|
|
103
|
+
`trellis rollback` undoes one recorded run — omit a run id for the most
|
|
104
|
+
recent one:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
$ trellis rollback
|
|
108
|
+
rollback 2026-09-13T04-52-18-727Z-mcp-sync
|
|
109
|
+
✅ 3 restored, 0 conflict(s), 0 already reverted
|
|
110
|
+
- [restore] restore /Users/you/.claude.json to its content before this run (...)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If something else touched a path since the backed-up run, that one path
|
|
114
|
+
is a `conflict` — reported, left untouched, never force-restored over —
|
|
115
|
+
while every other path in the same rollback still restores. `--list`
|
|
116
|
+
shows available runs without touching anything; `--dry-run` previews a
|
|
117
|
+
restore with zero writes.
|
|
118
|
+
|
|
82
119
|
## Status
|
|
83
120
|
|
|
84
|
-
**Early, pre-1.0.** All
|
|
85
|
-
`migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`) are implemented,
|
|
121
|
+
**Early, pre-1.0.** All eight CLI commands above (`onboard`, `init`,
|
|
122
|
+
`migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`, `rollback`) are implemented,
|
|
86
123
|
unit-tested, and verified end-to-end against real Docker containers (never a
|
|
87
124
|
developer's own dotfiles during development — see
|
|
88
125
|
[`docs/architecture.md`](docs/architecture.md)'s testing philosophy). See
|
|
@@ -107,6 +144,21 @@ result is named future work, not built yet.
|
|
|
107
144
|
- Automatic removal of an MCP server is deliberately unsupported (create/
|
|
108
145
|
repair only) until an ownership-tracking mechanism exists — see
|
|
109
146
|
`docs/roadmap.md`'s P2 note.
|
|
147
|
+
- `~/.trellis/backups/` has no automatic pruning yet — every `sync`/
|
|
148
|
+
`mcp sync`/`onboard` run that performs a real write adds one more run
|
|
149
|
+
directory, with no cap. Delete old ones by hand for now.
|
|
150
|
+
|
|
151
|
+
A canonical MCP server can also declare `enabled: false` (kept defined,
|
|
152
|
+
never written to any agent) and `static_env` (a value that was never a
|
|
153
|
+
secret — an email, an environment tag — written verbatim instead of
|
|
154
|
+
resolved by name; still scanned against `reject_patterns` like every
|
|
155
|
+
other literal field). Before ever writing a name-only `env` entry,
|
|
156
|
+
`mcp sync` now checks it actually resolves through the same source
|
|
157
|
+
`secrets audit` and the pi bridge use — an unresolvable name is refused
|
|
158
|
+
as a conflict, not silently written and left to break that server's
|
|
159
|
+
connection once the agent starts it. Both found via real-machine
|
|
160
|
+
dogfooding, not a synthetic fixture — see
|
|
161
|
+
[`schema/servers.example.yaml`](schema/servers.example.yaml).
|
|
110
162
|
|
|
111
163
|
## Design principles
|
|
112
164
|
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* before the other three adapters commit to reusing `symlinkPlan.ts`.
|
|
6
6
|
*
|
|
7
7
|
* MCP servers: plain JSON parse → merge under `mcpServers` → stringify
|
|
8
|
-
* (trellis-mcp-sync-p2 design.md D4)
|
|
9
|
-
*
|
|
8
|
+
* (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
|
|
9
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
10
|
+
* the current entry is still exactly what Trellis itself last wrote.
|
|
10
11
|
*/
|
|
11
12
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
12
13
|
import type { CanonicalSource } from "../core/types.js";
|
|
14
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
13
15
|
export declare class ClaudeCodeAdapter implements TrellisAdapter {
|
|
14
16
|
private readonly homeDir;
|
|
15
17
|
readonly name = "Claude Code";
|
|
@@ -18,6 +20,6 @@ export declare class ClaudeCodeAdapter implements TrellisAdapter {
|
|
|
18
20
|
probe(): Promise<AdapterProbeResult>;
|
|
19
21
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
20
22
|
private planMcp;
|
|
21
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
23
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
22
24
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
23
25
|
}
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
* before the other three adapters commit to reusing `symlinkPlan.ts`.
|
|
6
6
|
*
|
|
7
7
|
* MCP servers: plain JSON parse → merge under `mcpServers` → stringify
|
|
8
|
-
* (trellis-mcp-sync-p2 design.md D4)
|
|
9
|
-
*
|
|
8
|
+
* (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
|
|
9
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
10
|
+
* the current entry is still exactly what Trellis itself last wrote.
|
|
10
11
|
*/
|
|
11
|
-
import { existsSync, readFileSync
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
13
|
import { dirname, join } from "node:path";
|
|
13
14
|
import { homedir } from "node:os";
|
|
14
15
|
import { isInScope } from "../core/adapter.js";
|
|
15
16
|
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
16
17
|
import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
|
|
17
|
-
import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
|
|
18
|
+
import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
|
|
19
|
+
import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
|
|
18
20
|
export class ClaudeCodeAdapter {
|
|
19
21
|
homeDir;
|
|
20
22
|
name = "Claude Code";
|
|
@@ -30,7 +32,7 @@ export class ClaudeCodeAdapter {
|
|
|
30
32
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
31
33
|
const skillsRoot = join(this.homeDir, ".claude", "skills");
|
|
32
34
|
const desiredSkills = canonical.skills
|
|
33
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
35
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
34
36
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
35
37
|
const skillItems = planSymlinks({
|
|
36
38
|
rootDir: skillsRoot,
|
|
@@ -50,18 +52,29 @@ export class ClaudeCodeAdapter {
|
|
|
50
52
|
planMcp(canonical) {
|
|
51
53
|
const configPath = join(this.homeDir, ".claude.json");
|
|
52
54
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
53
|
-
|
|
55
|
+
const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
|
|
56
|
+
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
|
|
54
57
|
}
|
|
55
|
-
async apply(plan) {
|
|
56
|
-
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
58
|
+
async apply(plan, backup) {
|
|
59
|
+
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
|
|
60
|
+
const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
|
|
61
|
+
if (mcpWrites.length === 0) {
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
|
-
const configPath =
|
|
64
|
+
const configPath = mcpWrites[0].target;
|
|
62
65
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
63
|
-
const merged = applyJsonMcp(parsed,
|
|
64
|
-
|
|
66
|
+
const merged = applyJsonMcp(parsed, mcpWrites);
|
|
67
|
+
backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
68
|
+
let ownership = loadMcpOwnership(this.homeDir);
|
|
69
|
+
for (const item of mcpWrites) {
|
|
70
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
71
|
+
ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
|
|
72
|
+
}
|
|
73
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
74
|
+
ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
saveMcpOwnership(this.homeDir, ownership);
|
|
65
78
|
}
|
|
66
79
|
async verify(canonical) {
|
|
67
80
|
const snapshot = await claudeCodeProbe.probe(this.homeDir);
|
|
@@ -69,7 +82,7 @@ export class ClaudeCodeAdapter {
|
|
|
69
82
|
return { ok: false, mismatches: ["claude-code is not present on this machine"] };
|
|
70
83
|
}
|
|
71
84
|
const mismatches = [];
|
|
72
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
85
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
73
86
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
74
87
|
for (const name of desiredNames) {
|
|
75
88
|
if (!actualSkills.has(name)) {
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
*
|
|
9
9
|
* MCP servers are written via `src/lib/tomlSection.ts`'s line-based
|
|
10
10
|
* section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
|
|
11
|
-
* D1/D2 — both rejected with evidence)
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
11
|
+
* D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
|
|
12
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
13
|
+
* the current section text is still exactly what Trellis itself last
|
|
14
|
+
* wrote — a bare TOML key has no ownership marker of its own the way a
|
|
15
|
+
* symlink's realpath provides for skills, so this ledger is what makes
|
|
16
|
+
* it provably safe.
|
|
14
17
|
*/
|
|
15
18
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
16
19
|
import type { CanonicalSource } from "../core/types.js";
|
|
20
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
17
21
|
export declare class CodexAdapter implements TrellisAdapter {
|
|
18
22
|
private readonly homeDir;
|
|
19
23
|
readonly name = "Codex";
|
|
@@ -22,6 +26,6 @@ export declare class CodexAdapter implements TrellisAdapter {
|
|
|
22
26
|
probe(): Promise<AdapterProbeResult>;
|
|
23
27
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
24
28
|
private planMcp;
|
|
25
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
29
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
26
30
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
27
31
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* MCP servers are written via `src/lib/tomlSection.ts`'s line-based
|
|
10
10
|
* section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
|
|
11
|
-
* D1/D2 — both rejected with evidence)
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
11
|
+
* D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
|
|
12
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
13
|
+
* the current section text is still exactly what Trellis itself last
|
|
14
|
+
* wrote — a bare TOML key has no ownership marker of its own the way a
|
|
15
|
+
* symlink's realpath provides for skills, so this ledger is what makes
|
|
16
|
+
* it provably safe.
|
|
14
17
|
*/
|
|
15
|
-
import { existsSync, readFileSync
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
19
|
import { basename, dirname, join } from "node:path";
|
|
17
20
|
import { homedir } from "node:os";
|
|
18
21
|
import { isInScope } from "../core/adapter.js";
|
|
@@ -20,7 +23,8 @@ import * as codexProbe from "../probes/codex.js";
|
|
|
20
23
|
import { readInstructionsPath } from "../probes/codex.js";
|
|
21
24
|
import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
|
|
22
25
|
import { resolveMcpPlan } from "./mcpPlan.js";
|
|
23
|
-
import { currentServerSectionText, renderServerSection, upsertSection } from "../lib/tomlSection.js";
|
|
26
|
+
import { currentServerSectionText, removeSection, renderServerSection, upsertSection } from "../lib/tomlSection.js";
|
|
27
|
+
import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
|
|
24
28
|
export class CodexAdapter {
|
|
25
29
|
homeDir;
|
|
26
30
|
name = "Codex";
|
|
@@ -36,7 +40,7 @@ export class CodexAdapter {
|
|
|
36
40
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
37
41
|
const skillsRoot = join(this.homeDir, ".agents", "skills");
|
|
38
42
|
const desiredSkills = canonical.skills
|
|
39
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
43
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
40
44
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
41
45
|
const skillItems = planSymlinks({
|
|
42
46
|
rootDir: skillsRoot,
|
|
@@ -62,7 +66,7 @@ export class CodexAdapter {
|
|
|
62
66
|
planMcp(canonical) {
|
|
63
67
|
const configTomlPath = join(this.homeDir, ".codex", "config.toml");
|
|
64
68
|
const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
|
|
65
|
-
const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp);
|
|
69
|
+
const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
|
|
66
70
|
const items = [];
|
|
67
71
|
for (const { name, def } of desired) {
|
|
68
72
|
const current = currentServerSectionText(content, name);
|
|
@@ -81,20 +85,47 @@ export class CodexAdapter {
|
|
|
81
85
|
for (const conflict of conflicts) {
|
|
82
86
|
items.push({ action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message });
|
|
83
87
|
}
|
|
88
|
+
const desiredNames = new Set(desired.map((d) => d.name));
|
|
89
|
+
const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
|
|
90
|
+
for (const [name, expected] of Object.entries(ownership)) {
|
|
91
|
+
if (desiredNames.has(name))
|
|
92
|
+
continue; // still wanted — not a removal candidate
|
|
93
|
+
const current = currentServerSectionText(content, name);
|
|
94
|
+
if (current === null)
|
|
95
|
+
continue; // already gone
|
|
96
|
+
if (current !== expected)
|
|
97
|
+
continue; // hand-edited since Trellis wrote it — no longer ours to touch
|
|
98
|
+
items.push({
|
|
99
|
+
action: "remove",
|
|
100
|
+
kind: "mcp",
|
|
101
|
+
target: configTomlPath,
|
|
102
|
+
mcpRemove: { name },
|
|
103
|
+
description: `MCP server "${name}" removed from ${configTomlPath} — no longer in canonical`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
84
106
|
return items;
|
|
85
107
|
}
|
|
86
|
-
async apply(plan) {
|
|
87
|
-
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
108
|
+
async apply(plan, backup) {
|
|
109
|
+
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
|
|
110
|
+
const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
|
|
111
|
+
if (mcpWrites.length === 0) {
|
|
90
112
|
return;
|
|
91
113
|
}
|
|
92
|
-
const configTomlPath =
|
|
114
|
+
const configTomlPath = mcpWrites[0].target;
|
|
93
115
|
let content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
let ownership = loadMcpOwnership(this.homeDir);
|
|
117
|
+
for (const item of mcpWrites) {
|
|
118
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
119
|
+
content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
|
|
120
|
+
ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderServerSection(item.mcpWrite.name, item.mcpWrite.def));
|
|
121
|
+
}
|
|
122
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
123
|
+
content = removeSection(content, item.mcpRemove.name);
|
|
124
|
+
ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
|
|
125
|
+
}
|
|
96
126
|
}
|
|
97
|
-
|
|
127
|
+
backup.writeFile(configTomlPath, content);
|
|
128
|
+
saveMcpOwnership(this.homeDir, ownership);
|
|
98
129
|
}
|
|
99
130
|
async verify(canonical) {
|
|
100
131
|
const snapshot = await codexProbe.probe(this.homeDir);
|
|
@@ -102,7 +133,7 @@ export class CodexAdapter {
|
|
|
102
133
|
return { ok: false, mismatches: ["codex is not present on this machine"] };
|
|
103
134
|
}
|
|
104
135
|
const mismatches = [];
|
|
105
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
136
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
106
137
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
107
138
|
for (const name of desiredNames) {
|
|
108
139
|
if (!actualSkills.has(name)) {
|
|
@@ -6,19 +6,30 @@
|
|
|
6
6
|
* round-trip is safe as long as every sibling top-level key is carried
|
|
7
7
|
* through untouched.
|
|
8
8
|
*/
|
|
9
|
-
import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
|
|
9
|
+
import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
10
10
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
11
11
|
/** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
|
|
12
|
-
* expects — `env`
|
|
13
|
-
*
|
|
12
|
+
* expects — `env` names render as `${VAR}` references, `staticEnv`
|
|
13
|
+
* entries render as their literal values, both in the same map (JSON's
|
|
14
|
+
* `env` object has no structural distinction between the two the way
|
|
15
|
+
* Codex's TOML does — see tomlSection.ts's two-table rendering)
|
|
16
|
+
* (trellis-mcp-static-env-and-disabled-servers design.md D4). */
|
|
14
17
|
export declare function renderJsonServerEntry(def: McpServerDef): Record<string, unknown>;
|
|
15
18
|
export declare function planJsonMcp(opts: {
|
|
16
19
|
configPath: string;
|
|
17
20
|
parsed: Record<string, unknown> | undefined;
|
|
18
21
|
mcp: McpConfig;
|
|
19
22
|
agentId: AgentId;
|
|
23
|
+
managedAgents: readonly AgentId[];
|
|
24
|
+
policy: SecretsPolicy;
|
|
25
|
+
/** This agent's own slice of `src/lib/mcpOwnership.ts`'s ledger — what
|
|
26
|
+
* Trellis itself last wrote for each name, keyed by name. Omit (or
|
|
27
|
+
* pass `{}`) to get today's create/repair/conflict-only behavior with
|
|
28
|
+
* zero removal candidates. */
|
|
29
|
+
ownership?: Record<string, unknown>;
|
|
20
30
|
}): AdapterPlanItem[];
|
|
21
31
|
/** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
|
|
22
|
-
* file didn't exist)
|
|
23
|
-
* top-level key on `parsed` is
|
|
32
|
+
* file didn't exist) and deletes every `"remove"` item's key, returning
|
|
33
|
+
* the object to stringify — every sibling top-level key on `parsed` is
|
|
34
|
+
* spread through untouched. */
|
|
24
35
|
export declare function applyJsonMcp(parsed: Record<string, unknown> | undefined, items: AdapterPlanItem[]): Record<string, unknown>;
|
package/dist/adapters/jsonMcp.js
CHANGED
|
@@ -6,29 +6,14 @@
|
|
|
6
6
|
* round-trip is safe as long as every sibling top-level key is carried
|
|
7
7
|
* through untouched.
|
|
8
8
|
*/
|
|
9
|
+
import { deepEqual } from "../lib/deepEqual.js";
|
|
9
10
|
import { resolveMcpPlan } from "./mcpPlan.js";
|
|
10
|
-
function deepEqual(a, b) {
|
|
11
|
-
if (a === b)
|
|
12
|
-
return true;
|
|
13
|
-
if (a === null || b === null || typeof a !== typeof b)
|
|
14
|
-
return false;
|
|
15
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
16
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
17
|
-
return false;
|
|
18
|
-
return a.every((value, index) => deepEqual(value, b[index]));
|
|
19
|
-
}
|
|
20
|
-
if (typeof a === "object" && typeof b === "object") {
|
|
21
|
-
const aKeys = Object.keys(a);
|
|
22
|
-
const bKeys = Object.keys(b);
|
|
23
|
-
if (aKeys.length !== bKeys.length)
|
|
24
|
-
return false;
|
|
25
|
-
return aKeys.every((key) => deepEqual(a[key], b[key]));
|
|
26
|
-
}
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
11
|
/** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
|
|
30
|
-
* expects — `env`
|
|
31
|
-
*
|
|
12
|
+
* expects — `env` names render as `${VAR}` references, `staticEnv`
|
|
13
|
+
* entries render as their literal values, both in the same map (JSON's
|
|
14
|
+
* `env` object has no structural distinction between the two the way
|
|
15
|
+
* Codex's TOML does — see tomlSection.ts's two-table rendering)
|
|
16
|
+
* (trellis-mcp-static-env-and-disabled-servers design.md D4). */
|
|
32
17
|
export function renderJsonServerEntry(def) {
|
|
33
18
|
if (def.transport === "http" || def.transport === "sse") {
|
|
34
19
|
const entry = { type: def.transport, url: def.url };
|
|
@@ -38,14 +23,16 @@ export function renderJsonServerEntry(def) {
|
|
|
38
23
|
return entry;
|
|
39
24
|
}
|
|
40
25
|
const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
|
|
41
|
-
|
|
42
|
-
|
|
26
|
+
const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
|
|
27
|
+
const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
|
|
28
|
+
if (Object.keys(env).length > 0) {
|
|
29
|
+
entry.env = env;
|
|
43
30
|
}
|
|
44
31
|
return entry;
|
|
45
32
|
}
|
|
46
33
|
export function planJsonMcp(opts) {
|
|
47
|
-
const { configPath, parsed, mcp, agentId } = opts;
|
|
48
|
-
const { desired, conflicts } = resolveMcpPlan(agentId, mcp);
|
|
34
|
+
const { configPath, parsed, mcp, agentId, managedAgents, policy, ownership } = opts;
|
|
35
|
+
const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents, policy);
|
|
49
36
|
const existingServers = parsed?.mcpServers ?? {};
|
|
50
37
|
const items = [];
|
|
51
38
|
for (const { name, def } of desired) {
|
|
@@ -65,20 +52,42 @@ export function planJsonMcp(opts) {
|
|
|
65
52
|
for (const conflict of conflicts) {
|
|
66
53
|
items.push({ action: "conflict", kind: "mcp", target: configPath, description: conflict.message });
|
|
67
54
|
}
|
|
55
|
+
const desiredNames = new Set(desired.map((d) => d.name));
|
|
56
|
+
for (const [name, expected] of Object.entries(ownership ?? {})) {
|
|
57
|
+
if (desiredNames.has(name))
|
|
58
|
+
continue; // still wanted — not a removal candidate at all
|
|
59
|
+
const current = existingServers[name];
|
|
60
|
+
if (current === undefined)
|
|
61
|
+
continue; // already gone — nothing to remove, ledger is pruned separately
|
|
62
|
+
if (!deepEqual(current, expected))
|
|
63
|
+
continue; // hand-edited since Trellis wrote it — no longer ours to touch
|
|
64
|
+
items.push({
|
|
65
|
+
action: "remove",
|
|
66
|
+
kind: "mcp",
|
|
67
|
+
target: configPath,
|
|
68
|
+
mcpRemove: { name },
|
|
69
|
+
description: `MCP server "${name}" removed from ${configPath} — no longer in canonical`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
68
72
|
return items;
|
|
69
73
|
}
|
|
70
74
|
/** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
|
|
71
|
-
* file didn't exist)
|
|
72
|
-
* top-level key on `parsed` is
|
|
75
|
+
* file didn't exist) and deletes every `"remove"` item's key, returning
|
|
76
|
+
* the object to stringify — every sibling top-level key on `parsed` is
|
|
77
|
+
* spread through untouched. */
|
|
73
78
|
export function applyJsonMcp(parsed, items) {
|
|
74
79
|
const base = parsed ?? {};
|
|
75
80
|
const existingServers = base.mcpServers ?? {};
|
|
76
81
|
const mergedServers = { ...existingServers };
|
|
77
82
|
for (const item of items) {
|
|
78
|
-
if (item.kind !== "mcp"
|
|
83
|
+
if (item.kind !== "mcp")
|
|
79
84
|
continue;
|
|
85
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
86
|
+
mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
|
|
87
|
+
}
|
|
88
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
89
|
+
delete mergedServers[item.mcpRemove.name];
|
|
80
90
|
}
|
|
81
|
-
mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
|
|
82
91
|
}
|
|
83
92
|
return { ...base, mcpServers: mergedServers };
|
|
84
93
|
}
|
package/dist/adapters/kiro.d.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
|
|
4
4
|
*
|
|
5
5
|
* MCP servers: plain JSON parse → merge under `mcpServers` → stringify
|
|
6
|
-
* (trellis-mcp-sync-p2 design.md D4)
|
|
7
|
-
*
|
|
6
|
+
* (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
|
|
7
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
8
|
+
* the current entry is still exactly what Trellis itself last wrote.
|
|
8
9
|
*
|
|
9
10
|
* Kiro's own `${VAR}` substitution (real, found by reading Kiro's
|
|
10
11
|
* installed extension source — trellis-kiro-approved-env-vars design.md
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
*/
|
|
17
18
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
18
19
|
import type { CanonicalSource } from "../core/types.js";
|
|
20
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
19
21
|
export declare class KiroAdapter implements TrellisAdapter {
|
|
20
22
|
private readonly homeDir;
|
|
21
23
|
readonly name = "Kiro";
|
|
@@ -29,6 +31,6 @@ export declare class KiroAdapter implements TrellisAdapter {
|
|
|
29
31
|
* known_host_injected collision, never contributes a name here either. */
|
|
30
32
|
private desiredApprovedEnvVars;
|
|
31
33
|
private planApprovedEnvVars;
|
|
32
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
34
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
33
35
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
34
36
|
}
|
package/dist/adapters/kiro.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
|
|
4
4
|
*
|
|
5
5
|
* MCP servers: plain JSON parse → merge under `mcpServers` → stringify
|
|
6
|
-
* (trellis-mcp-sync-p2 design.md D4)
|
|
7
|
-
*
|
|
6
|
+
* (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
|
|
7
|
+
* parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
|
|
8
|
+
* the current entry is still exactly what Trellis itself last wrote.
|
|
8
9
|
*
|
|
9
10
|
* Kiro's own `${VAR}` substitution (real, found by reading Kiro's
|
|
10
11
|
* installed extension source — trellis-kiro-approved-env-vars design.md
|
|
@@ -14,15 +15,16 @@
|
|
|
14
15
|
* adapter keeps that list a superset of every env name it references,
|
|
15
16
|
* additive only.
|
|
16
17
|
*/
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import { isInScope } from "../core/adapter.js";
|
|
21
22
|
import * as kiroProbe from "../probes/kiro.js";
|
|
22
23
|
import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
|
|
23
|
-
import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
|
|
24
|
+
import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
|
|
24
25
|
import { resolveMcpPlan } from "./mcpPlan.js";
|
|
25
26
|
import { declaredEnvNames } from "../lib/envVarNames.js";
|
|
27
|
+
import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
|
|
26
28
|
/** VS-Code-family global settings path. macOS only — see
|
|
27
29
|
* trellis-kiro-approved-env-vars proposal.md Non-Goals: Linux/Windows
|
|
28
30
|
* equivalents are the well-known convention but unverified against a
|
|
@@ -46,7 +48,7 @@ export class KiroAdapter {
|
|
|
46
48
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
47
49
|
const skillsRoot = join(this.homeDir, ".kiro", "skills");
|
|
48
50
|
const desiredSkills = canonical.skills
|
|
49
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
51
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
50
52
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
51
53
|
const skillItems = planSymlinks({
|
|
52
54
|
rootDir: skillsRoot,
|
|
@@ -67,13 +69,14 @@ export class KiroAdapter {
|
|
|
67
69
|
planMcp(canonical) {
|
|
68
70
|
const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
|
|
69
71
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
70
|
-
|
|
72
|
+
const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
|
|
73
|
+
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
|
|
71
74
|
}
|
|
72
75
|
/** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
|
|
73
76
|
* (design.md D2) — a server scoped away from Kiro, or refused for a
|
|
74
77
|
* known_host_injected collision, never contributes a name here either. */
|
|
75
78
|
desiredApprovedEnvVars(canonical) {
|
|
76
|
-
const { desired } = resolveMcpPlan(this.id, canonical.mcp);
|
|
79
|
+
const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
|
|
77
80
|
const names = new Set();
|
|
78
81
|
for (const { def } of desired) {
|
|
79
82
|
for (const name of declaredEnvNames(def))
|
|
@@ -115,15 +118,25 @@ export class KiroAdapter {
|
|
|
115
118
|
},
|
|
116
119
|
];
|
|
117
120
|
}
|
|
118
|
-
async apply(plan) {
|
|
119
|
-
await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"));
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
const configPath =
|
|
121
|
+
async apply(plan, backup) {
|
|
122
|
+
await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"), backup);
|
|
123
|
+
const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
|
|
124
|
+
if (mcpWrites.length > 0) {
|
|
125
|
+
const configPath = mcpWrites[0].target;
|
|
123
126
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
124
|
-
const merged = applyJsonMcp(parsed,
|
|
127
|
+
const merged = applyJsonMcp(parsed, mcpWrites);
|
|
125
128
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
126
|
-
|
|
129
|
+
backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
130
|
+
let ownership = loadMcpOwnership(this.homeDir);
|
|
131
|
+
for (const item of mcpWrites) {
|
|
132
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
133
|
+
ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
|
|
134
|
+
}
|
|
135
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
136
|
+
ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
saveMcpOwnership(this.homeDir, ownership);
|
|
127
140
|
}
|
|
128
141
|
const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
|
|
129
142
|
if (approvedEnvVarsCreate) {
|
|
@@ -131,7 +144,7 @@ export class KiroAdapter {
|
|
|
131
144
|
const parsed = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
|
|
132
145
|
const merged = { ...parsed, [APPROVED_ENV_VARS_KEY]: approvedEnvVarsCreate.approvedEnvVars };
|
|
133
146
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
134
|
-
|
|
147
|
+
backup.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
150
|
async verify(canonical) {
|
|
@@ -140,7 +153,7 @@ export class KiroAdapter {
|
|
|
140
153
|
return { ok: false, mismatches: ["kiro is not present on this machine"] };
|
|
141
154
|
}
|
|
142
155
|
const mismatches = [];
|
|
143
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
156
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
144
157
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
145
158
|
for (const name of desiredNames) {
|
|
146
159
|
if (!actualSkills.has(name)) {
|