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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis rollback` — restores exactly what one recorded backup run
|
|
3
|
+
* changed (trellis-backup-rollback). Every operation is checked against
|
|
4
|
+
* its target path's *current* state before touching anything: if the
|
|
5
|
+
* path still matches what the run itself left behind, it's restored; if
|
|
6
|
+
* something else has touched it since, that's a conflict, reported and
|
|
7
|
+
* left untouched — same "verify, never guess" posture `sync`/`mcp sync`
|
|
8
|
+
* already hold themselves to for every other kind of conflict.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { readlink, rm, symlink } from "node:fs/promises";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { backupsRoot } from "../lib/backup.js";
|
|
16
|
+
function sha256(content) {
|
|
17
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
18
|
+
}
|
|
19
|
+
/** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
|
|
20
|
+
* chronological sort. */
|
|
21
|
+
export function listBackups(homeDir = homedir()) {
|
|
22
|
+
const root = backupsRoot(homeDir);
|
|
23
|
+
if (!existsSync(root))
|
|
24
|
+
return [];
|
|
25
|
+
const runIds = readdirSync(root).sort().reverse();
|
|
26
|
+
const summaries = [];
|
|
27
|
+
for (const runId of runIds) {
|
|
28
|
+
const manifest = tryLoadManifest(homeDir, runId);
|
|
29
|
+
if (!manifest)
|
|
30
|
+
continue;
|
|
31
|
+
summaries.push({ runId, command: manifest.command, startedAt: manifest.startedAt, operationCount: manifest.operations.length });
|
|
32
|
+
}
|
|
33
|
+
return summaries;
|
|
34
|
+
}
|
|
35
|
+
function tryLoadManifest(homeDir, runId) {
|
|
36
|
+
const manifestPath = join(backupsRoot(homeDir), runId, "manifest.json");
|
|
37
|
+
if (!existsSync(manifestPath))
|
|
38
|
+
return undefined;
|
|
39
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
40
|
+
}
|
|
41
|
+
export function loadManifest(homeDir, runId) {
|
|
42
|
+
const manifest = tryLoadManifest(homeDir, runId);
|
|
43
|
+
if (!manifest) {
|
|
44
|
+
throw new Error(`No backup run "${runId}" found under ${backupsRoot(homeDir)}`);
|
|
45
|
+
}
|
|
46
|
+
return manifest;
|
|
47
|
+
}
|
|
48
|
+
function resolveRunId(homeDir, requested) {
|
|
49
|
+
if (requested)
|
|
50
|
+
return requested;
|
|
51
|
+
const [mostRecent] = listBackups(homeDir);
|
|
52
|
+
if (!mostRecent) {
|
|
53
|
+
throw new Error(`No backup runs found under ${backupsRoot(homeDir)} — nothing to roll back`);
|
|
54
|
+
}
|
|
55
|
+
return mostRecent.runId;
|
|
56
|
+
}
|
|
57
|
+
async function currentLinkTarget(path) {
|
|
58
|
+
try {
|
|
59
|
+
return await readlink(path);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function planOperation(homeDir, runId, op) {
|
|
66
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
67
|
+
switch (op.kind) {
|
|
68
|
+
case "file-create":
|
|
69
|
+
case "file-overwrite": {
|
|
70
|
+
if (!existsSync(op.path)) {
|
|
71
|
+
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
72
|
+
}
|
|
73
|
+
const currentHash = sha256(readFileSync(op.path, "utf-8"));
|
|
74
|
+
if (currentHash !== op.afterHash) {
|
|
75
|
+
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
76
|
+
}
|
|
77
|
+
if (op.kind === "file-create") {
|
|
78
|
+
return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
|
|
79
|
+
}
|
|
80
|
+
return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
|
|
81
|
+
}
|
|
82
|
+
case "symlink-create":
|
|
83
|
+
case "symlink-repair": {
|
|
84
|
+
const current = await currentLinkTarget(op.path);
|
|
85
|
+
if (current === undefined) {
|
|
86
|
+
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
87
|
+
}
|
|
88
|
+
if (current !== op.afterLinkTarget) {
|
|
89
|
+
return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
|
|
90
|
+
}
|
|
91
|
+
if (op.kind === "symlink-create") {
|
|
92
|
+
return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
|
|
93
|
+
}
|
|
94
|
+
return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
|
|
95
|
+
}
|
|
96
|
+
case "symlink-remove": {
|
|
97
|
+
if (existsSync(op.path)) {
|
|
98
|
+
return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
|
|
99
|
+
}
|
|
100
|
+
return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function collectRollbackPlan(homeDirInput, runIdInput) {
|
|
105
|
+
const homeDir = homeDirInput ?? homedir();
|
|
106
|
+
const runId = resolveRunId(homeDir, runIdInput);
|
|
107
|
+
const manifest = loadManifest(homeDir, runId);
|
|
108
|
+
const items = [];
|
|
109
|
+
for (const op of manifest.operations) {
|
|
110
|
+
items.push(await planOperation(homeDir, runId, op));
|
|
111
|
+
}
|
|
112
|
+
return { runId, items };
|
|
113
|
+
}
|
|
114
|
+
async function restoreOperation(homeDir, runId, op) {
|
|
115
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
116
|
+
switch (op.kind) {
|
|
117
|
+
case "file-create":
|
|
118
|
+
rmSync(op.path, { force: true });
|
|
119
|
+
return;
|
|
120
|
+
case "file-overwrite":
|
|
121
|
+
writeFileSync(op.path, readFileSync(join(runDir, op.beforeFile), "utf-8"));
|
|
122
|
+
return;
|
|
123
|
+
case "symlink-create":
|
|
124
|
+
await rm(op.path, { force: true });
|
|
125
|
+
return;
|
|
126
|
+
case "symlink-repair":
|
|
127
|
+
await rm(op.path, { force: true });
|
|
128
|
+
await symlink(op.beforeLinkTarget, op.path);
|
|
129
|
+
return;
|
|
130
|
+
case "symlink-remove":
|
|
131
|
+
await symlink(op.beforeLinkTarget, op.path);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function applyRollbackPlan(homeDir, runId, manifest, items) {
|
|
136
|
+
const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
|
|
137
|
+
for (const op of manifest.operations) {
|
|
138
|
+
if (!restorePaths.has(op.path))
|
|
139
|
+
continue;
|
|
140
|
+
await restoreOperation(homeDir, runId, op);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function runRollback(opts = {}) {
|
|
144
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
145
|
+
if (opts.list) {
|
|
146
|
+
const runs = listBackups(homeDir);
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
printList(runs);
|
|
152
|
+
}
|
|
153
|
+
return { exitCode: 0 };
|
|
154
|
+
}
|
|
155
|
+
let report;
|
|
156
|
+
try {
|
|
157
|
+
report = await collectRollbackPlan(homeDir, opts.runId);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
161
|
+
return { exitCode: 1 };
|
|
162
|
+
}
|
|
163
|
+
if (!opts.dryRun) {
|
|
164
|
+
const manifest = loadManifest(homeDir, report.runId);
|
|
165
|
+
await applyRollbackPlan(homeDir, report.runId, manifest, report.items);
|
|
166
|
+
}
|
|
167
|
+
if (opts.json) {
|
|
168
|
+
console.log(JSON.stringify(report, null, 2));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
printReport(report, opts.dryRun ?? false);
|
|
172
|
+
}
|
|
173
|
+
const hasConflict = report.items.some((i) => i.action === "conflict");
|
|
174
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
175
|
+
}
|
|
176
|
+
function printList(runs) {
|
|
177
|
+
if (runs.length === 0) {
|
|
178
|
+
console.log("No backup runs found under ~/.trellis/backups/.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const run of runs) {
|
|
182
|
+
console.log(`${run.runId} — ${run.command}, ${run.operationCount} operation(s), ${run.startedAt}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function printReport(report, dryRun) {
|
|
186
|
+
if (dryRun)
|
|
187
|
+
console.log("[dry run]");
|
|
188
|
+
console.log(`rollback ${report.runId}`);
|
|
189
|
+
const restored = report.items.filter((i) => i.action === "restore");
|
|
190
|
+
const conflicts = report.items.filter((i) => i.action === "conflict");
|
|
191
|
+
const alreadyReverted = report.items.filter((i) => i.action === "already-reverted");
|
|
192
|
+
if (report.items.length === 0) {
|
|
193
|
+
console.log(" nothing recorded in this run");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const icon = conflicts.length > 0 ? "⚠️ " : "✅";
|
|
197
|
+
console.log(`${icon} ${restored.length} restored, ${conflicts.length} conflict(s), ${alreadyReverted.length} already reverted`);
|
|
198
|
+
for (const item of [...restored, ...conflicts, ...alreadyReverted]) {
|
|
199
|
+
console.log(` - [${item.action}] ${item.description}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -17,6 +17,9 @@ export interface RunSecretsAuditOptions {
|
|
|
17
17
|
/** Same test/sandbox-only seam as every other command — never a CLI
|
|
18
18
|
* flag. See docs/architecture.md's testing philosophy. */
|
|
19
19
|
homeDir?: string;
|
|
20
|
+
/** Onboard-only seam — see RunSyncOptions.managedAgents. Never a CLI
|
|
21
|
+
* flag. */
|
|
22
|
+
managedAgents?: readonly AgentId[];
|
|
20
23
|
}
|
|
21
24
|
export interface SecretsFinding {
|
|
22
25
|
/** "environment" for `missing-env-value` — that check isn't scoped to
|
|
@@ -33,3 +36,7 @@ export declare function collectSecretsAuditReport(opts?: RunSecretsAuditOptions)
|
|
|
33
36
|
export declare function runSecretsAudit(opts?: RunSecretsAuditOptions): Promise<{
|
|
34
37
|
exitCode: number;
|
|
35
38
|
}>;
|
|
39
|
+
/** Exported so `onboard` prints a secrets-audit report identically to
|
|
40
|
+
* running `secrets audit` standalone, instead of a second, easily-drifting
|
|
41
|
+
* copy of this formatting. */
|
|
42
|
+
export declare function printReport(report: SecretsAuditReport): void;
|
|
@@ -20,15 +20,18 @@ import { CodexAdapter } from "../adapters/codex.js";
|
|
|
20
20
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
21
21
|
import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
|
|
22
22
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Only agents in `managedAgents` — a present-but-unmanaged agent's config
|
|
24
|
+
* file is never even read (trellis-managed-agents), same restriction as
|
|
25
|
+
* `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
|
|
26
|
+
* regardless of management — its bridge reads mcp/servers.yaml directly at
|
|
27
|
+
* its own runtime (P4). See design.md Non-Goals in trellis-secrets-audit-p3. */
|
|
28
|
+
function auditedAgents(homeDir, managedAgents) {
|
|
29
|
+
const all = [
|
|
25
30
|
{ id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
|
|
26
31
|
{ id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
|
|
27
32
|
{ 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
33
|
];
|
|
34
|
+
return all.filter((a) => managedAgents.includes(a.id));
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Not scoped to any agent, present or not — a name that can't resolve is
|
|
@@ -72,8 +75,9 @@ function auditFile(agent, file, content, policy, extractNames) {
|
|
|
72
75
|
export async function collectSecretsAuditReport(opts = {}) {
|
|
73
76
|
const homeDir = opts.homeDir ?? homedir();
|
|
74
77
|
const canonical = loadCanonicalSource(homeDir);
|
|
78
|
+
const managedAgents = opts.managedAgents ?? canonical.managedAgents;
|
|
75
79
|
const findings = [];
|
|
76
|
-
for (const agent of auditedAgents(homeDir)) {
|
|
80
|
+
for (const agent of auditedAgents(homeDir, managedAgents)) {
|
|
77
81
|
const probeResult = await agent.probe();
|
|
78
82
|
if (!probeResult.present)
|
|
79
83
|
continue;
|
|
@@ -103,7 +107,10 @@ export async function runSecretsAudit(opts = {}) {
|
|
|
103
107
|
}
|
|
104
108
|
return { exitCode: report.findings.length > 0 ? 1 : 0 };
|
|
105
109
|
}
|
|
106
|
-
|
|
110
|
+
/** Exported so `onboard` prints a secrets-audit report identically to
|
|
111
|
+
* running `secrets audit` standalone, instead of a second, easily-drifting
|
|
112
|
+
* copy of this formatting. */
|
|
113
|
+
export function printReport(report) {
|
|
107
114
|
if (report.findings.length === 0) {
|
|
108
115
|
console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
|
|
109
116
|
return;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
3
|
+
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
|
+
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
|
+
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
6
|
+
* existing, already-shipped logic rather than reimplementing it — see
|
|
7
|
+
* `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
|
|
8
|
+
* symlinkPlan.ts`'s pre-existing stale-symlink removal.
|
|
9
|
+
*/
|
|
10
|
+
import type { AgentId } from "../core/types.js";
|
|
11
|
+
export interface SkillListEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
scope: readonly AgentId[];
|
|
14
|
+
}
|
|
15
|
+
export declare function collectSkillList(homeDir?: string): SkillListEntry[];
|
|
16
|
+
export declare function runSkillList(opts?: {
|
|
17
|
+
homeDir?: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
}): {
|
|
20
|
+
exitCode: number;
|
|
21
|
+
};
|
|
22
|
+
export type SkillAddAction = "create" | "already-present" | "conflict" | "invalid-source";
|
|
23
|
+
export interface SkillAddPlan {
|
|
24
|
+
name: string;
|
|
25
|
+
action: SkillAddAction;
|
|
26
|
+
detail: string;
|
|
27
|
+
sourceDir?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
|
|
30
|
+
export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string): void;
|
|
31
|
+
export declare function runSkillAdd(name: string, fromPath: string, opts?: {
|
|
32
|
+
homeDir?: string;
|
|
33
|
+
json?: boolean;
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
}): {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
};
|
|
38
|
+
export type SkillRemoveAction = "removed" | "not-found";
|
|
39
|
+
export interface SkillRemovePlan {
|
|
40
|
+
name: string;
|
|
41
|
+
action: SkillRemoveAction;
|
|
42
|
+
}
|
|
43
|
+
export declare function collectSkillRemovePlan(name: string, homeDir?: string): SkillRemovePlan;
|
|
44
|
+
export declare function applySkillRemovePlan(plan: SkillRemovePlan, homeDir?: string): void;
|
|
45
|
+
export declare function runSkillRemove(name: string, opts?: {
|
|
46
|
+
homeDir?: string;
|
|
47
|
+
json?: boolean;
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
}): {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
3
|
+
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
|
+
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
|
+
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
6
|
+
* existing, already-shipped logic rather than reimplementing it — see
|
|
7
|
+
* `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
|
|
8
|
+
* symlinkPlan.ts`'s pre-existing stale-symlink removal.
|
|
9
|
+
*/
|
|
10
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { decideDirImport } from "../lib/dirEquals.js";
|
|
14
|
+
import { findSkillFile } from "../lib/skillFile.js";
|
|
15
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
16
|
+
import { resolveScope } from "../core/types.js";
|
|
17
|
+
export function collectSkillList(homeDir = homedir()) {
|
|
18
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
19
|
+
return canonical.skills.map((skill) => ({
|
|
20
|
+
name: skill.name,
|
|
21
|
+
scope: resolveScope(skill.scope, canonical.managedAgents),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
export function runSkillList(opts = {}) {
|
|
25
|
+
const entries = collectSkillList(opts.homeDir ?? homedir());
|
|
26
|
+
if (opts.json) {
|
|
27
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
28
|
+
}
|
|
29
|
+
else if (entries.length === 0) {
|
|
30
|
+
console.log("No skills in canonical source yet.");
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
for (const { name, scope } of entries) {
|
|
34
|
+
console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { exitCode: 0 };
|
|
38
|
+
}
|
|
39
|
+
export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
|
|
40
|
+
const skillFile = findSkillFile(fromPath);
|
|
41
|
+
if (!skillFile) {
|
|
42
|
+
return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
|
|
43
|
+
}
|
|
44
|
+
if (!skillFile.caseCorrect) {
|
|
45
|
+
return { name, action: "invalid-source", detail: `${fromPath} has skill.md, not case-correct SKILL.md — fix the case first` };
|
|
46
|
+
}
|
|
47
|
+
const canonicalDir = join(homeDir, ".trellis", "skills", name);
|
|
48
|
+
switch (decideDirImport(fromPath, canonicalDir)) {
|
|
49
|
+
case "create":
|
|
50
|
+
return { name, action: "create", detail: `will copy from ${fromPath}`, sourceDir: fromPath };
|
|
51
|
+
case "already-present":
|
|
52
|
+
return { name, action: "already-present", detail: "canonical content is already byte-identical" };
|
|
53
|
+
case "conflict":
|
|
54
|
+
return { name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function applySkillAddPlan(plan, homeDir = homedir()) {
|
|
58
|
+
if (plan.action !== "create" || !plan.sourceDir)
|
|
59
|
+
return;
|
|
60
|
+
const dest = join(homeDir, ".trellis", "skills", plan.name);
|
|
61
|
+
mkdirSync(dest, { recursive: true });
|
|
62
|
+
cpSync(plan.sourceDir, dest, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
export function runSkillAdd(name, fromPath, opts = {}) {
|
|
65
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
66
|
+
const plan = collectSkillAddPlan(name, fromPath, homeDir);
|
|
67
|
+
if (!opts.dryRun && plan.action === "create") {
|
|
68
|
+
applySkillAddPlan(plan, homeDir);
|
|
69
|
+
}
|
|
70
|
+
if (opts.json) {
|
|
71
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill add ${name}`);
|
|
75
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
76
|
+
}
|
|
77
|
+
return { exitCode: plan.action === "conflict" || plan.action === "invalid-source" ? 1 : 0 };
|
|
78
|
+
}
|
|
79
|
+
export function collectSkillRemovePlan(name, homeDir = homedir()) {
|
|
80
|
+
const dir = join(homeDir, ".trellis", "skills", name);
|
|
81
|
+
return { name, action: existsSync(dir) ? "removed" : "not-found" };
|
|
82
|
+
}
|
|
83
|
+
export function applySkillRemovePlan(plan, homeDir = homedir()) {
|
|
84
|
+
if (plan.action !== "removed")
|
|
85
|
+
return;
|
|
86
|
+
rmSync(join(homeDir, ".trellis", "skills", plan.name), { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
export function runSkillRemove(name, opts = {}) {
|
|
89
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
90
|
+
const plan = collectSkillRemovePlan(name, homeDir);
|
|
91
|
+
if (!opts.dryRun) {
|
|
92
|
+
applySkillRemovePlan(plan, homeDir);
|
|
93
|
+
}
|
|
94
|
+
if (opts.json) {
|
|
95
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
96
|
+
}
|
|
97
|
+
else if (plan.action === "not-found") {
|
|
98
|
+
console.error(`"${name}" is not a canonical skill — nothing to remove.`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}removed skill "${name}" from canonical source.`);
|
|
102
|
+
}
|
|
103
|
+
return { exitCode: plan.action === "not-found" ? 1 : 0 };
|
|
104
|
+
}
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
9
9
|
import type { AgentId } from "../core/types.js";
|
|
10
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
10
11
|
export interface RunSyncOptions {
|
|
11
12
|
/** Omit to sync both. */
|
|
12
13
|
target?: "skills" | "instructions";
|
|
@@ -21,6 +22,18 @@ export interface RunSyncOptions {
|
|
|
21
22
|
homeDir?: string;
|
|
22
23
|
/** Compute and report the plan without calling adapter.apply(). */
|
|
23
24
|
dryRun?: boolean;
|
|
25
|
+
/** Onboard-only seam: preview/act against a managed-agent set that
|
|
26
|
+
* hasn't been written to `~/.trellis/managed.yaml` yet (its own
|
|
27
|
+
* `--dry-run` still needs a real plan against the set the user is
|
|
28
|
+
* about to select, not the one already on disk). Never a CLI flag —
|
|
29
|
+
* standalone `trellis sync` always reads canonical's own. */
|
|
30
|
+
managedAgents?: readonly AgentId[];
|
|
31
|
+
/** Onboard-only seam (trellis-backup-rollback): a session already open
|
|
32
|
+
* for the whole onboard run, so one `trellis rollback` undoes every
|
|
33
|
+
* stage together instead of one per stage. When given, this function
|
|
34
|
+
* does NOT finalize it — only whoever opened it does. Never a CLI
|
|
35
|
+
* flag — standalone `trellis sync` always opens and finalizes its own. */
|
|
36
|
+
backupSession?: BackupSession;
|
|
24
37
|
}
|
|
25
38
|
export interface AgentSyncReport {
|
|
26
39
|
agent: AgentId;
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,14 +11,35 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
|
11
11
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
12
12
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
13
13
|
import { PiAdapter } from "../adapters/pi.js";
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
15
|
+
const ADAPTER_FACTORY = {
|
|
16
|
+
"claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
|
|
17
|
+
codex: (homeDir) => new CodexAdapter(homeDir),
|
|
18
|
+
kiro: (homeDir) => new KiroAdapter(homeDir),
|
|
19
|
+
pi: (homeDir) => new PiAdapter(homeDir),
|
|
20
|
+
};
|
|
21
|
+
/** Only agents in `canonical.managedAgents` — an agent present on this
|
|
22
|
+
* machine but not managed gets no adapter at all, not a zero-item plan
|
|
23
|
+
* (trellis-managed-agents). */
|
|
24
|
+
function buildAdapters(homeDir, managedAgents) {
|
|
25
|
+
return managedAgents.map((id) => ADAPTER_FACTORY[id](homeDir));
|
|
16
26
|
}
|
|
17
27
|
export async function collectSyncReport(opts = {}) {
|
|
18
28
|
const homeDir = opts.homeDir ?? homedir();
|
|
19
|
-
const
|
|
29
|
+
const loaded = loadCanonicalSource(homeDir);
|
|
30
|
+
// A single override point, not two: every downstream read of "who's
|
|
31
|
+
// managed" — the adapter list built here AND each adapter's own
|
|
32
|
+
// in-scope filtering via `canonical.managedAgents` — must agree, or a
|
|
33
|
+
// dry-run preview computed against a not-yet-written managed set would
|
|
34
|
+
// silently diverge from what onboard's own outer adapter list acted on.
|
|
35
|
+
const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
|
|
20
36
|
const reports = [];
|
|
21
|
-
|
|
37
|
+
// Own session only when the caller didn't share one (trellis-backup-
|
|
38
|
+
// rollback D2) — dry-run never opens or writes one, there's nothing to
|
|
39
|
+
// record.
|
|
40
|
+
const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
|
|
41
|
+
const backup = opts.backupSession ?? ownSession;
|
|
42
|
+
for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
|
|
22
43
|
const probeResult = await adapter.probe();
|
|
23
44
|
if (!probeResult.present) {
|
|
24
45
|
reports.push({ agent: adapter.id, present: false, items: [] });
|
|
@@ -40,10 +61,11 @@ export async function collectSyncReport(opts = {}) {
|
|
|
40
61
|
items = items.filter((item) => item.kind === kind);
|
|
41
62
|
}
|
|
42
63
|
if (!opts.dryRun) {
|
|
43
|
-
await adapter.apply(items);
|
|
64
|
+
await adapter.apply(items, backup);
|
|
44
65
|
}
|
|
45
66
|
reports.push({ agent: adapter.id, present: true, items });
|
|
46
67
|
}
|
|
68
|
+
ownSession?.finalize();
|
|
47
69
|
return { reports };
|
|
48
70
|
}
|
|
49
71
|
export async function runSync(opts = {}) {
|
|
@@ -70,6 +92,10 @@ export async function runSync(opts = {}) {
|
|
|
70
92
|
export function printReport(report, dryRun) {
|
|
71
93
|
if (dryRun)
|
|
72
94
|
console.log("[dry run]");
|
|
95
|
+
if (report.reports.length === 0) {
|
|
96
|
+
console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
73
99
|
for (const { agent, present, items } of report.reports) {
|
|
74
100
|
if (!present) {
|
|
75
101
|
console.log(`— ${agent} (not installed)`);
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
12
12
|
import { resolveScope } from "./types.js";
|
|
13
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
13
14
|
export interface AdapterProbeResult {
|
|
14
15
|
present: boolean;
|
|
15
16
|
version?: string;
|
|
@@ -19,14 +20,18 @@ export interface AdapterPlanItem {
|
|
|
19
20
|
/**
|
|
20
21
|
* "create" also covers repair (wrong symlink target, or an MCP server
|
|
21
22
|
* definition that differs from canonical); "remove" is the delete half
|
|
22
|
-
* for skills/instructions
|
|
23
|
-
* entry is gone or was just scoped away from this agent
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* for skills/instructions (a Trellis-managed symlink whose canonical
|
|
24
|
+
* entry is gone or was just scoped away from this agent) and, since
|
|
25
|
+
* trellis-mcp-lifecycle-parity, for MCP servers too — but only when
|
|
26
|
+
* `src/lib/mcpOwnership.ts`'s ledger proves the agent's current native
|
|
27
|
+
* entry is still exactly what Trellis itself last wrote there
|
|
28
|
+
* (mcpPlan.ts's own D7 reasoning: a bare TOML/JSON key has no ownership
|
|
29
|
+
* marker on its own, so this ledger is what makes removal provably
|
|
30
|
+
* safe instead of guessing). A name whose native content has since
|
|
31
|
+
* been hand-edited is left alone, never removed. "conflict" is either
|
|
32
|
+
* a real, non-symlink path occupying a spot Trellis would otherwise
|
|
33
|
+
* touch, or an MCP server refused for a collision/secrets-guard
|
|
34
|
+
* reason — reported, never acted on. See `plan()`'s doc below.
|
|
30
35
|
*/
|
|
31
36
|
action: "create" | "remove" | "conflict";
|
|
32
37
|
/** Lets `trellis sync skills` / `trellis sync instructions` /
|
|
@@ -63,6 +68,12 @@ export interface AdapterPlanItem {
|
|
|
63
68
|
name: string;
|
|
64
69
|
def: McpServerDef;
|
|
65
70
|
};
|
|
71
|
+
/** Only set (and only meaningful) when `kind === "mcp"` and
|
|
72
|
+
* `action === "remove"`: the server name to delete from `target` via
|
|
73
|
+
* that agent's own mechanism. */
|
|
74
|
+
mcpRemove?: {
|
|
75
|
+
name: string;
|
|
76
|
+
};
|
|
66
77
|
/** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
|
|
67
78
|
* and `action === "create"`: the full, already-deduplicated array to
|
|
68
79
|
* write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was
|
|
@@ -91,7 +102,7 @@ export interface TrellisAdapter {
|
|
|
91
102
|
* entries, MCP servers) through its `scope` field before planning any
|
|
92
103
|
* change for it — an item scoped away from `this.id` must produce no
|
|
93
104
|
* plan item at all, not a plan item that's later skipped. Use
|
|
94
|
-
* `
|
|
105
|
+
* `isInScope(this.id, item.scope, canonical.managedAgents)`. See docs/architecture.md
|
|
95
106
|
* "Private / agent-specific capabilities" — the default (`scope`
|
|
96
107
|
* omitted) is "all agents," so this filter is a no-op for the common
|
|
97
108
|
* case and only actually excludes anything when a capability was
|
|
@@ -123,11 +134,17 @@ export interface TrellisAdapter {
|
|
|
123
134
|
* item in the same plan from being applied. The caller (e.g. `trellis
|
|
124
135
|
* sync`) is responsible for surfacing conflicts and failing the overall
|
|
125
136
|
* command (non-zero exit), not `apply()` per item.
|
|
137
|
+
*
|
|
138
|
+
* MUST perform every real write through `backup` (`writeFile`/
|
|
139
|
+
* `createSymlink`/`repairSymlink`/`removeSymlink`), never directly —
|
|
140
|
+
* `backup` is the only thing that may call `fs`/`fs/promises` to
|
|
141
|
+
* mutate a path this method touches (trellis-backup-rollback design.md
|
|
142
|
+
* D3/D5). Mandatory, not optional: the caller always has one open.
|
|
126
143
|
*/
|
|
127
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
144
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
128
145
|
/** Re-read the agent's own state and confirm it matches canonical. */
|
|
129
146
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
130
147
|
}
|
|
131
148
|
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
132
149
|
* obligation documented above. */
|
|
133
|
-
export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
|
|
150
|
+
export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0], managedAgents: readonly AgentId[]): boolean;
|
package/dist/core/adapter.js
CHANGED
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
import { resolveScope } from "./types.js";
|
|
12
12
|
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
13
13
|
* obligation documented above. */
|
|
14
|
-
export function isInScope(id, scope) {
|
|
15
|
-
return resolveScope(scope).includes(id);
|
|
14
|
+
export function isInScope(id, scope, managedAgents) {
|
|
15
|
+
return resolveScope(scope, managedAgents).includes(id);
|
|
16
16
|
}
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,31 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { CanonicalSource } from "./types.js";
|
|
7
|
+
import type { CanonicalSource, McpServerDef } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* The on-disk shape for one server entry — `static_env` (snake_case, like
|
|
10
|
+
* every other multi-word key across `.trellis/*.yaml`) is translated to
|
|
11
|
+
* `McpServerDef.staticEnv` (camelCase) below; every other field happens
|
|
12
|
+
* to already be a single word, so no server-def field needed this
|
|
13
|
+
* treatment before (trellis-mcp-static-env-and-disabled-servers).
|
|
14
|
+
*/
|
|
15
|
+
type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
|
|
16
|
+
static_env?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
19
|
+
* `undefined` fields so the written YAML never gets a literal `null`
|
|
20
|
+
* for an omitted optional. */
|
|
21
|
+
export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
|
|
22
|
+
export type ServersYamlWriteResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
|
|
29
|
+
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
30
|
+
* refusal posture on a missing/unparseable file. */
|
|
31
|
+
export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
|
|
8
32
|
/**
|
|
9
33
|
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
10
34
|
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
|
@@ -14,3 +38,4 @@ import type { CanonicalSource } from "./types.js";
|
|
|
14
38
|
* parameter does not weaken.
|
|
15
39
|
*/
|
|
16
40
|
export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
|
|
41
|
+
export {};
|