agent-trellis 0.1.0 → 0.2.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 +57 -17
- package/dist/adapters/claude-code.d.ts +2 -1
- package/dist/adapters/claude-code.js +7 -7
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +7 -7
- package/dist/adapters/jsonMcp.d.ts +1 -0
- package/dist/adapters/jsonMcp.js +2 -2
- package/dist/adapters/kiro.d.ts +2 -1
- package/dist/adapters/kiro.js +9 -9
- package/dist/adapters/mcpPlan.d.ts +1 -1
- package/dist/adapters/mcpPlan.js +2 -2
- 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 +41 -11
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +31 -7
- package/dist/commands/onboard.d.ts +42 -7
- package/dist/commands/onboard.js +207 -34
- 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/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +10 -3
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.js +22 -0
- package/dist/core/types.d.ts +12 -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/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- package/dist/pi-bridge/bundle.js +26 -7
- package/dist/pi-bridge/index.js +8 -1
- package/docs/getting-started.md +104 -26
- package/docs/roadmap.md +133 -0
- package/package.json +1 -1
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,9 @@ 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.
|
|
110
150
|
|
|
111
151
|
## Design principles
|
|
112
152
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
12
12
|
import type { CanonicalSource } from "../core/types.js";
|
|
13
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
13
14
|
export declare class ClaudeCodeAdapter implements TrellisAdapter {
|
|
14
15
|
private readonly homeDir;
|
|
15
16
|
readonly name = "Claude Code";
|
|
@@ -18,6 +19,6 @@ export declare class ClaudeCodeAdapter implements TrellisAdapter {
|
|
|
18
19
|
probe(): Promise<AdapterProbeResult>;
|
|
19
20
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
20
21
|
private planMcp;
|
|
21
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
22
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
22
23
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
23
24
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (trellis-mcp-sync-p2 design.md D4) — create/repair only, no automatic
|
|
9
9
|
* removal (D7).
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, readFileSync
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { isInScope } from "../core/adapter.js";
|
|
@@ -30,7 +30,7 @@ export class ClaudeCodeAdapter {
|
|
|
30
30
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
31
31
|
const skillsRoot = join(this.homeDir, ".claude", "skills");
|
|
32
32
|
const desiredSkills = canonical.skills
|
|
33
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
33
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
34
34
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
35
35
|
const skillItems = planSymlinks({
|
|
36
36
|
rootDir: skillsRoot,
|
|
@@ -50,10 +50,10 @@ export class ClaudeCodeAdapter {
|
|
|
50
50
|
planMcp(canonical) {
|
|
51
51
|
const configPath = join(this.homeDir, ".claude.json");
|
|
52
52
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
53
|
-
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
|
|
53
|
+
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
|
|
54
54
|
}
|
|
55
|
-
async apply(plan) {
|
|
56
|
-
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
|
|
55
|
+
async apply(plan, backup) {
|
|
56
|
+
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
|
|
57
57
|
const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
|
|
58
58
|
if (mcpCreates.length === 0) {
|
|
59
59
|
return;
|
|
@@ -61,7 +61,7 @@ export class ClaudeCodeAdapter {
|
|
|
61
61
|
const configPath = mcpCreates[0].target;
|
|
62
62
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
63
63
|
const merged = applyJsonMcp(parsed, mcpCreates);
|
|
64
|
-
|
|
64
|
+
backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
65
65
|
}
|
|
66
66
|
async verify(canonical) {
|
|
67
67
|
const snapshot = await claudeCodeProbe.probe(this.homeDir);
|
|
@@ -69,7 +69,7 @@ export class ClaudeCodeAdapter {
|
|
|
69
69
|
return { ok: false, mismatches: ["claude-code is not present on this machine"] };
|
|
70
70
|
}
|
|
71
71
|
const mismatches = [];
|
|
72
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
72
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
73
73
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
74
74
|
for (const name of desiredNames) {
|
|
75
75
|
if (!actualSkills.has(name)) {
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
16
16
|
import type { CanonicalSource } from "../core/types.js";
|
|
17
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
17
18
|
export declare class CodexAdapter implements TrellisAdapter {
|
|
18
19
|
private readonly homeDir;
|
|
19
20
|
readonly name = "Codex";
|
|
@@ -22,6 +23,6 @@ export declare class CodexAdapter implements TrellisAdapter {
|
|
|
22
23
|
probe(): Promise<AdapterProbeResult>;
|
|
23
24
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
24
25
|
private planMcp;
|
|
25
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
26
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
26
27
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
27
28
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* automatic removal (D7: no ownership marker exists for a bare TOML key
|
|
13
13
|
* the way a symlink's realpath provides for skills).
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, readFileSync
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
16
|
import { basename, dirname, join } from "node:path";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { isInScope } from "../core/adapter.js";
|
|
@@ -36,7 +36,7 @@ export class CodexAdapter {
|
|
|
36
36
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
37
37
|
const skillsRoot = join(this.homeDir, ".agents", "skills");
|
|
38
38
|
const desiredSkills = canonical.skills
|
|
39
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
39
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
40
40
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
41
41
|
const skillItems = planSymlinks({
|
|
42
42
|
rootDir: skillsRoot,
|
|
@@ -62,7 +62,7 @@ export class CodexAdapter {
|
|
|
62
62
|
planMcp(canonical) {
|
|
63
63
|
const configTomlPath = join(this.homeDir, ".codex", "config.toml");
|
|
64
64
|
const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
|
|
65
|
-
const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp);
|
|
65
|
+
const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
|
|
66
66
|
const items = [];
|
|
67
67
|
for (const { name, def } of desired) {
|
|
68
68
|
const current = currentServerSectionText(content, name);
|
|
@@ -83,8 +83,8 @@ export class CodexAdapter {
|
|
|
83
83
|
}
|
|
84
84
|
return items;
|
|
85
85
|
}
|
|
86
|
-
async apply(plan) {
|
|
87
|
-
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
|
|
86
|
+
async apply(plan, backup) {
|
|
87
|
+
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
|
|
88
88
|
const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
|
|
89
89
|
if (mcpCreates.length === 0) {
|
|
90
90
|
return;
|
|
@@ -94,7 +94,7 @@ export class CodexAdapter {
|
|
|
94
94
|
for (const item of mcpCreates) {
|
|
95
95
|
content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
backup.writeFile(configTomlPath, content);
|
|
98
98
|
}
|
|
99
99
|
async verify(canonical) {
|
|
100
100
|
const snapshot = await codexProbe.probe(this.homeDir);
|
|
@@ -102,7 +102,7 @@ export class CodexAdapter {
|
|
|
102
102
|
return { ok: false, mismatches: ["codex is not present on this machine"] };
|
|
103
103
|
}
|
|
104
104
|
const mismatches = [];
|
|
105
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
105
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
106
106
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
107
107
|
for (const name of desiredNames) {
|
|
108
108
|
if (!actualSkills.has(name)) {
|
|
@@ -17,6 +17,7 @@ export declare function planJsonMcp(opts: {
|
|
|
17
17
|
parsed: Record<string, unknown> | undefined;
|
|
18
18
|
mcp: McpConfig;
|
|
19
19
|
agentId: AgentId;
|
|
20
|
+
managedAgents: readonly AgentId[];
|
|
20
21
|
}): AdapterPlanItem[];
|
|
21
22
|
/** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
|
|
22
23
|
* file didn't exist), returning the object to stringify — every sibling
|
package/dist/adapters/jsonMcp.js
CHANGED
|
@@ -44,8 +44,8 @@ export function renderJsonServerEntry(def) {
|
|
|
44
44
|
return entry;
|
|
45
45
|
}
|
|
46
46
|
export function planJsonMcp(opts) {
|
|
47
|
-
const { configPath, parsed, mcp, agentId } = opts;
|
|
48
|
-
const { desired, conflicts } = resolveMcpPlan(agentId, mcp);
|
|
47
|
+
const { configPath, parsed, mcp, agentId, managedAgents } = opts;
|
|
48
|
+
const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents);
|
|
49
49
|
const existingServers = parsed?.mcpServers ?? {};
|
|
50
50
|
const items = [];
|
|
51
51
|
for (const { name, def } of desired) {
|
package/dist/adapters/kiro.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
18
18
|
import type { CanonicalSource } from "../core/types.js";
|
|
19
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
19
20
|
export declare class KiroAdapter implements TrellisAdapter {
|
|
20
21
|
private readonly homeDir;
|
|
21
22
|
readonly name = "Kiro";
|
|
@@ -29,6 +30,6 @@ export declare class KiroAdapter implements TrellisAdapter {
|
|
|
29
30
|
* known_host_injected collision, never contributes a name here either. */
|
|
30
31
|
private desiredApprovedEnvVars;
|
|
31
32
|
private planApprovedEnvVars;
|
|
32
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
33
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
33
34
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
34
35
|
}
|
package/dist/adapters/kiro.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* adapter keeps that list a superset of every env name it references,
|
|
15
15
|
* additive only.
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { isInScope } from "../core/adapter.js";
|
|
@@ -46,7 +46,7 @@ export class KiroAdapter {
|
|
|
46
46
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
47
47
|
const skillsRoot = join(this.homeDir, ".kiro", "skills");
|
|
48
48
|
const desiredSkills = canonical.skills
|
|
49
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
49
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
50
50
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
51
51
|
const skillItems = planSymlinks({
|
|
52
52
|
rootDir: skillsRoot,
|
|
@@ -67,13 +67,13 @@ export class KiroAdapter {
|
|
|
67
67
|
planMcp(canonical) {
|
|
68
68
|
const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
|
|
69
69
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
70
|
-
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
|
|
70
|
+
return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
|
|
71
71
|
}
|
|
72
72
|
/** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
|
|
73
73
|
* (design.md D2) — a server scoped away from Kiro, or refused for a
|
|
74
74
|
* known_host_injected collision, never contributes a name here either. */
|
|
75
75
|
desiredApprovedEnvVars(canonical) {
|
|
76
|
-
const { desired } = resolveMcpPlan(this.id, canonical.mcp);
|
|
76
|
+
const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
|
|
77
77
|
const names = new Set();
|
|
78
78
|
for (const { def } of desired) {
|
|
79
79
|
for (const name of declaredEnvNames(def))
|
|
@@ -115,15 +115,15 @@ export class KiroAdapter {
|
|
|
115
115
|
},
|
|
116
116
|
];
|
|
117
117
|
}
|
|
118
|
-
async apply(plan) {
|
|
119
|
-
await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"));
|
|
118
|
+
async apply(plan, backup) {
|
|
119
|
+
await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"), backup);
|
|
120
120
|
const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
|
|
121
121
|
if (mcpCreates.length > 0) {
|
|
122
122
|
const configPath = mcpCreates[0].target;
|
|
123
123
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
124
124
|
const merged = applyJsonMcp(parsed, mcpCreates);
|
|
125
125
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
126
|
-
|
|
126
|
+
backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
127
127
|
}
|
|
128
128
|
const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
|
|
129
129
|
if (approvedEnvVarsCreate) {
|
|
@@ -131,7 +131,7 @@ export class KiroAdapter {
|
|
|
131
131
|
const parsed = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
|
|
132
132
|
const merged = { ...parsed, [APPROVED_ENV_VARS_KEY]: approvedEnvVarsCreate.approvedEnvVars };
|
|
133
133
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
134
|
-
|
|
134
|
+
backup.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
async verify(canonical) {
|
|
@@ -140,7 +140,7 @@ export class KiroAdapter {
|
|
|
140
140
|
return { ok: false, mismatches: ["kiro is not present on this machine"] };
|
|
141
141
|
}
|
|
142
142
|
const mismatches = [];
|
|
143
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
143
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
144
144
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
145
145
|
for (const name of desiredNames) {
|
|
146
146
|
if (!actualSkills.has(name)) {
|
|
@@ -25,4 +25,4 @@ export interface McpPlanResult {
|
|
|
25
25
|
desired: DesiredMcpEntry[];
|
|
26
26
|
conflicts: McpConflict[];
|
|
27
27
|
}
|
|
28
|
-
export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig): McpPlanResult;
|
|
28
|
+
export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[]): McpPlanResult;
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -40,7 +40,7 @@ function findLiteralSecret(def) {
|
|
|
40
40
|
}
|
|
41
41
|
return undefined;
|
|
42
42
|
}
|
|
43
|
-
export function resolveMcpPlan(agentId, mcp) {
|
|
43
|
+
export function resolveMcpPlan(agentId, mcp, managedAgents) {
|
|
44
44
|
if (mcp.hub) {
|
|
45
45
|
if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
|
|
46
46
|
return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
|
|
@@ -50,7 +50,7 @@ export function resolveMcpPlan(agentId, mcp) {
|
|
|
50
50
|
const desired = [];
|
|
51
51
|
const conflicts = [];
|
|
52
52
|
for (const [name, def] of Object.entries(mcp.servers)) {
|
|
53
|
-
if (!isInScope(agentId, def.agents)) {
|
|
53
|
+
if (!isInScope(agentId, def.agents, managedAgents)) {
|
|
54
54
|
continue;
|
|
55
55
|
}
|
|
56
56
|
if (mcp.knownHostInjected.includes(name)) {
|
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
13
13
|
import type { CanonicalSource } from "../core/types.js";
|
|
14
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
14
15
|
export declare class PiAdapter implements TrellisAdapter {
|
|
15
16
|
private readonly homeDir;
|
|
16
17
|
readonly name = "pi";
|
|
@@ -18,6 +19,6 @@ export declare class PiAdapter implements TrellisAdapter {
|
|
|
18
19
|
constructor(homeDir?: string);
|
|
19
20
|
probe(): Promise<AdapterProbeResult>;
|
|
20
21
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
21
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
22
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
22
23
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
23
24
|
}
|
package/dist/adapters/pi.js
CHANGED
|
@@ -56,7 +56,7 @@ export class PiAdapter {
|
|
|
56
56
|
const canonicalRoot = dirname(canonical.instructionsFile);
|
|
57
57
|
const agentDir = join(this.homeDir, ".pi", "agent");
|
|
58
58
|
const desiredSkills = canonical.skills
|
|
59
|
-
.filter((skill) => isInScope(this.id, skill.scope))
|
|
59
|
+
.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
|
|
60
60
|
.map((skill) => ({ name: skill.name, target: skill.dir }));
|
|
61
61
|
const skillItems = planSymlinks({
|
|
62
62
|
rootDir: join(agentDir, "skills"),
|
|
@@ -82,8 +82,8 @@ export class PiAdapter {
|
|
|
82
82
|
});
|
|
83
83
|
return [...skillItems, ...instructionsItems, ...extensionItems];
|
|
84
84
|
}
|
|
85
|
-
async apply(plan) {
|
|
86
|
-
await applySymlinkPlan(plan);
|
|
85
|
+
async apply(plan, backup) {
|
|
86
|
+
await applySymlinkPlan(plan, backup);
|
|
87
87
|
}
|
|
88
88
|
async verify(canonical) {
|
|
89
89
|
const snapshot = await piProbe.probe(this.homeDir);
|
|
@@ -91,7 +91,7 @@ export class PiAdapter {
|
|
|
91
91
|
return { ok: false, mismatches: ["pi is not present on this machine"] };
|
|
92
92
|
}
|
|
93
93
|
const mismatches = [];
|
|
94
|
-
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
|
|
94
|
+
const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
|
|
95
95
|
const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
|
|
96
96
|
for (const name of desiredNames) {
|
|
97
97
|
if (!actualSkills.has(name)) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* trellis-sync-p1/specs/skill-instructions-sync/spec.md) — built once,
|
|
5
5
|
* reused by every adapter rather than reimplemented per agent.
|
|
6
6
|
*/
|
|
7
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
7
8
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
8
9
|
export interface DesiredSymlink {
|
|
9
10
|
/** Basename this entry should have under `rootDir`. */
|
|
@@ -28,6 +29,9 @@ export declare function planSymlinks(opts: {
|
|
|
28
29
|
canonicalRoot: string;
|
|
29
30
|
kind: "skill" | "instructions" | "extension";
|
|
30
31
|
}): AdapterPlanItem[];
|
|
31
|
-
/** Executes a `planSymlinks` result
|
|
32
|
-
*
|
|
33
|
-
|
|
32
|
+
/** Executes a `planSymlinks` result through the run's backup session
|
|
33
|
+
* (trellis-backup-rollback) — every create/repair/remove is recorded
|
|
34
|
+
* before it happens; `backup` performs the actual filesystem write, this
|
|
35
|
+
* function never calls `fs/promises` itself. "conflict" is report-only —
|
|
36
|
+
* see src/core/adapter.ts's `apply()` doc for why this never throws. */
|
|
37
|
+
export declare function applySymlinkPlan(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* reused by every adapter rather than reimplemented per agent.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
|
|
8
|
-
import { mkdir
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
9
|
import { join, resolve, sep } from "node:path";
|
|
10
10
|
import { isSymlinkTo } from "../lib/fsIdentity.js";
|
|
11
|
+
import { currentLinkTarget } from "../lib/backup.js";
|
|
11
12
|
function isUnderRoot(path, root) {
|
|
12
13
|
const normalizedRoot = resolve(root);
|
|
13
14
|
const normalizedPath = resolve(path);
|
|
@@ -37,14 +38,27 @@ export function planSymlinks(opts) {
|
|
|
37
38
|
if (isSymlinkTo(path, target)) {
|
|
38
39
|
continue; // already correct — no-op
|
|
39
40
|
}
|
|
40
|
-
if (existsSync(path)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
if (existsSync(path)) {
|
|
42
|
+
const isSymlink = lstatSync(path).isSymbolicLink();
|
|
43
|
+
// A symlink whose stored target resolves outside canonicalRoot is
|
|
44
|
+
// owned by something else (e.g. a user's own dotfile-management
|
|
45
|
+
// setup) — repairing it as if it were a stale Trellis entry would
|
|
46
|
+
// silently steal that ownership. Raw readlink, not realpath: a
|
|
47
|
+
// symlink Trellis itself left pointing at a since-removed canonical
|
|
48
|
+
// entry is broken by construction and must still be treated as
|
|
49
|
+
// ours to repair, not thrown out to a conflict by a realpath error.
|
|
50
|
+
const isForeign = isSymlink && !isUnderRoot(readlinkSync(path), canonicalRootResolved);
|
|
51
|
+
if (!isSymlink || isForeign) {
|
|
52
|
+
items.push({
|
|
53
|
+
action: "conflict",
|
|
54
|
+
kind,
|
|
55
|
+
target: path,
|
|
56
|
+
description: isForeign
|
|
57
|
+
? `${path} exists as a symlink to ${readlinkSync(path)}, not owned by Trellis — left untouched`
|
|
58
|
+
: `${path} exists and is not a Trellis-managed symlink — left untouched`,
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
48
62
|
}
|
|
49
63
|
items.push({
|
|
50
64
|
action: "create",
|
|
@@ -100,21 +114,33 @@ export function planSymlinks(opts) {
|
|
|
100
114
|
}
|
|
101
115
|
return items;
|
|
102
116
|
}
|
|
103
|
-
/** Executes a `planSymlinks` result
|
|
104
|
-
*
|
|
105
|
-
|
|
117
|
+
/** Executes a `planSymlinks` result through the run's backup session
|
|
118
|
+
* (trellis-backup-rollback) — every create/repair/remove is recorded
|
|
119
|
+
* before it happens; `backup` performs the actual filesystem write, this
|
|
120
|
+
* function never calls `fs/promises` itself. "conflict" is report-only —
|
|
121
|
+
* see src/core/adapter.ts's `apply()` doc for why this never throws. */
|
|
122
|
+
export async function applySymlinkPlan(plan, backup) {
|
|
106
123
|
for (const item of plan) {
|
|
107
124
|
if (item.action === "conflict")
|
|
108
125
|
continue;
|
|
109
126
|
if (item.action === "remove") {
|
|
110
|
-
await
|
|
127
|
+
const oldTarget = await currentLinkTarget(item.target);
|
|
128
|
+
if (oldTarget === undefined)
|
|
129
|
+
continue; // already gone — no-op, nothing to record
|
|
130
|
+
await backup.removeSymlink(item.target, oldTarget);
|
|
111
131
|
continue;
|
|
112
132
|
}
|
|
113
|
-
// "create"
|
|
133
|
+
// "create" also covers repair — rootDir may not exist yet (first
|
|
134
|
+
// sync ever for this agent).
|
|
114
135
|
if (!item.linkTarget)
|
|
115
136
|
continue;
|
|
116
137
|
await mkdir(resolve(item.target, ".."), { recursive: true });
|
|
117
|
-
await
|
|
118
|
-
|
|
138
|
+
const oldTarget = await currentLinkTarget(item.target);
|
|
139
|
+
if (oldTarget === undefined) {
|
|
140
|
+
await backup.createSymlink(item.target, item.linkTarget);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await backup.repairSymlink(item.target, oldTarget, item.linkTarget);
|
|
144
|
+
}
|
|
119
145
|
}
|
|
120
146
|
}
|
package/dist/cli.js
CHANGED
|
@@ -11,8 +11,9 @@ import { runOnboard } from "./commands/onboard.js";
|
|
|
11
11
|
import { runSync } from "./commands/sync.js";
|
|
12
12
|
import { runMcpSync } from "./commands/mcp.js";
|
|
13
13
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
14
|
+
import { runRollback } from "./commands/rollback.js";
|
|
14
15
|
import { parseSyncArgs } from "./lib/syncArgs.js";
|
|
15
|
-
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets"];
|
|
16
|
+
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets", "rollback"];
|
|
16
17
|
function printUsage() {
|
|
17
18
|
console.log(`trellis - a single source of capability for every coding agent
|
|
18
19
|
|
|
@@ -20,13 +21,21 @@ Usage:
|
|
|
20
21
|
trellis <command>
|
|
21
22
|
|
|
22
23
|
Commands:
|
|
23
|
-
onboard Guided flow: init -> detect agents -> pick a
|
|
24
|
-
migrate -> sync
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
--
|
|
29
|
-
|
|
24
|
+
onboard Guided flow: init -> detect agents -> pick a migration source ->
|
|
25
|
+
pick which agents to manage -> migrate -> sync -> mcp sync ->
|
|
26
|
+
secrets audit, in one command. Source (read from) and managed
|
|
27
|
+
set (written to) are independent; the source is not managed
|
|
28
|
+
by default.
|
|
29
|
+
--agent <agent> non-interactive migration-source
|
|
30
|
+
choice (required with 2+ candidates
|
|
31
|
+
and no terminal to prompt in)
|
|
32
|
+
--manage <ids|none> non-interactive managed-set choice,
|
|
33
|
+
e.g. --manage pi,codex ; --manage none
|
|
34
|
+
means "add nothing new this run"
|
|
35
|
+
(required with no terminal to prompt
|
|
36
|
+
in, e.g. --json)
|
|
37
|
+
--dry-run preview the whole flow, write nothing
|
|
38
|
+
--json machine-readable output, no report text
|
|
30
39
|
init Create ~/.trellis/ with a minimal valid skeleton if missing
|
|
31
40
|
(never overwrites an existing file — fills in only what's
|
|
32
41
|
missing) and prints which agents are present
|
|
@@ -48,11 +57,21 @@ Commands:
|
|
|
48
57
|
--json machine-readable output, no report text
|
|
49
58
|
mcp sync Distribute MCP servers to each agent's native config
|
|
50
59
|
(create/repair only — no automatic removal, see docs/roadmap.md)
|
|
51
|
-
--
|
|
60
|
+
--dry-run preview the plan, write nothing
|
|
61
|
+
--json machine-readable output, no report text
|
|
52
62
|
secrets audit
|
|
53
63
|
Scan each present agent's real MCP config for leaked
|
|
54
64
|
credentials and unexpected env var names
|
|
55
65
|
--json machine-readable output, no report text
|
|
66
|
+
rollback [<run-id>]
|
|
67
|
+
Undo one recorded sync/mcp-sync/onboard run (every real write
|
|
68
|
+
it performed is backed up first, under
|
|
69
|
+
~/.trellis/backups/) — omit <run-id> for the most recent
|
|
70
|
+
run. Refuses per-path (a conflict, not overwritten) if the
|
|
71
|
+
path changed since that run.
|
|
72
|
+
--list show available backup runs, don't restore
|
|
73
|
+
--dry-run preview what would be restored, write nothing
|
|
74
|
+
--json machine-readable output, no report text
|
|
56
75
|
|
|
57
76
|
See docs/roadmap.md for what's built vs. planned.`);
|
|
58
77
|
}
|
|
@@ -71,7 +90,9 @@ async function main(argv) {
|
|
|
71
90
|
if (command === "onboard") {
|
|
72
91
|
const agentIndex = rest.indexOf("--agent");
|
|
73
92
|
const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
|
|
74
|
-
const
|
|
93
|
+
const manageIndex = rest.indexOf("--manage");
|
|
94
|
+
const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
|
|
95
|
+
const { exitCode } = await runOnboard({ agent, manage, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
|
|
75
96
|
process.exitCode = exitCode;
|
|
76
97
|
return;
|
|
77
98
|
}
|
|
@@ -111,7 +132,7 @@ async function main(argv) {
|
|
|
111
132
|
process.exitCode = 1;
|
|
112
133
|
return;
|
|
113
134
|
}
|
|
114
|
-
const { exitCode } = await runMcpSync({ json: rest.includes("--json") });
|
|
135
|
+
const { exitCode } = await runMcpSync({ json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
|
|
115
136
|
process.exitCode = exitCode;
|
|
116
137
|
return;
|
|
117
138
|
}
|
|
@@ -126,6 +147,15 @@ async function main(argv) {
|
|
|
126
147
|
process.exitCode = exitCode;
|
|
127
148
|
return;
|
|
128
149
|
}
|
|
150
|
+
if (command === "rollback") {
|
|
151
|
+
const list = rest.includes("--list");
|
|
152
|
+
const dryRun = rest.includes("--dry-run");
|
|
153
|
+
const json = rest.includes("--json");
|
|
154
|
+
const runId = rest.find((arg) => !arg.startsWith("--"));
|
|
155
|
+
const { exitCode } = await runRollback({ runId, list, dryRun, json });
|
|
156
|
+
process.exitCode = exitCode;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
129
159
|
console.error(`\`trellis ${command}\` is not implemented yet — this is a pre-alpha scaffold.\nSee docs/roadmap.md for status.`);
|
|
130
160
|
process.exitCode = 1;
|
|
131
161
|
}
|