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/dist/commands/init.js
CHANGED
|
@@ -71,6 +71,16 @@ reject_patterns:
|
|
|
71
71
|
${patternLines}
|
|
72
72
|
`;
|
|
73
73
|
}
|
|
74
|
+
/** Zero managed agents is the correct starting point (trellis-managed-agents
|
|
75
|
+
* design.md D1) — `trellis onboard`'s managed-set selection is what
|
|
76
|
+
* populates this, never `init` guessing on its behalf. */
|
|
77
|
+
function managedYamlTemplate() {
|
|
78
|
+
return `# Agents Trellis is authorized to write to. Empty means none yet —
|
|
79
|
+
# run \`trellis onboard\` or list agent ids here yourself, e.g.:
|
|
80
|
+
# agents: [pi, codex]
|
|
81
|
+
agents: []
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
74
84
|
/**
|
|
75
85
|
* Trellis never spawns an installer itself (global package installs are
|
|
76
86
|
* exactly the kind of irreversible, system-wide action that needs the
|
|
@@ -102,6 +112,7 @@ export async function collectInitReport(homeDir = homedir()) {
|
|
|
102
112
|
ensureFile(join(root, "agents.md"), AGENTS_MD_TEMPLATE),
|
|
103
113
|
ensureFile(join(root, "mcp", "servers.yaml"), serversYamlTemplate()),
|
|
104
114
|
ensureFile(join(root, "secrets.policy.yaml"), secretsPolicyYamlTemplate()),
|
|
115
|
+
ensureFile(join(root, "managed.yaml"), managedYamlTemplate()),
|
|
105
116
|
];
|
|
106
117
|
const probes = [
|
|
107
118
|
{ agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir) },
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -8,11 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
11
12
|
export interface RunMcpSyncOptions {
|
|
12
13
|
json?: boolean;
|
|
13
14
|
/** Same test/sandbox-only seam as `RunSyncOptions.homeDir` — never a CLI
|
|
14
15
|
* flag. See docs/architecture.md's testing philosophy. */
|
|
15
16
|
homeDir?: string;
|
|
17
|
+
/** Compute and report the plan without calling adapter.apply(). */
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
/** Onboard-only seam — see RunSyncOptions.managedAgents. Never a CLI
|
|
20
|
+
* flag. */
|
|
21
|
+
managedAgents?: readonly AgentId[];
|
|
22
|
+
/** Onboard-only seam — see RunSyncOptions.backupSession. Never a CLI
|
|
23
|
+
* flag. */
|
|
24
|
+
backupSession?: BackupSession;
|
|
16
25
|
}
|
|
17
26
|
export interface AgentMcpSyncReport {
|
|
18
27
|
agent: AgentId;
|
|
@@ -26,3 +35,7 @@ export declare function collectMcpSyncReport(opts?: RunMcpSyncOptions): Promise<
|
|
|
26
35
|
export declare function runMcpSync(opts?: RunMcpSyncOptions): Promise<{
|
|
27
36
|
exitCode: number;
|
|
28
37
|
}>;
|
|
38
|
+
/** Exported so `onboard` prints an mcp-sync report identically to running
|
|
39
|
+
* `mcp sync` standalone, instead of a second, easily-drifting copy of this
|
|
40
|
+
* formatting. */
|
|
41
|
+
export declare function printReport(report: McpSyncReport, dryRun: boolean): void;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -12,23 +12,38 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
|
12
12
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
13
13
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
14
14
|
import { PiAdapter } from "../adapters/pi.js";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
16
|
+
const ADAPTER_FACTORY = {
|
|
17
|
+
"claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
|
|
18
|
+
codex: (homeDir) => new CodexAdapter(homeDir),
|
|
19
|
+
kiro: (homeDir) => new KiroAdapter(homeDir),
|
|
20
|
+
pi: (homeDir) => new PiAdapter(homeDir),
|
|
21
|
+
};
|
|
22
|
+
/** Only agents in `canonical.managedAgents` — see src/commands/sync.ts's
|
|
23
|
+
* own copy of this same restriction (trellis-managed-agents). */
|
|
24
|
+
function buildAdapters(homeDir, managedAgents) {
|
|
25
|
+
return managedAgents.map((id) => ADAPTER_FACTORY[id](homeDir));
|
|
17
26
|
}
|
|
18
27
|
export async function collectMcpSyncReport(opts = {}) {
|
|
19
28
|
const homeDir = opts.homeDir ?? homedir();
|
|
20
|
-
const
|
|
29
|
+
const loaded = loadCanonicalSource(homeDir);
|
|
30
|
+
const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
|
|
21
31
|
const reports = [];
|
|
22
|
-
|
|
32
|
+
const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "mcp-sync") : undefined;
|
|
33
|
+
const backup = opts.backupSession ?? ownSession;
|
|
34
|
+
for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
|
|
23
35
|
const probeResult = await adapter.probe();
|
|
24
36
|
if (!probeResult.present) {
|
|
25
37
|
reports.push({ agent: adapter.id, present: false, items: [] });
|
|
26
38
|
continue;
|
|
27
39
|
}
|
|
28
40
|
const items = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
|
|
29
|
-
|
|
41
|
+
if (!opts.dryRun) {
|
|
42
|
+
await adapter.apply(items, backup);
|
|
43
|
+
}
|
|
30
44
|
reports.push({ agent: adapter.id, present: true, items });
|
|
31
45
|
}
|
|
46
|
+
ownSession?.finalize();
|
|
32
47
|
return { reports };
|
|
33
48
|
}
|
|
34
49
|
export async function runMcpSync(opts = {}) {
|
|
@@ -44,12 +59,21 @@ export async function runMcpSync(opts = {}) {
|
|
|
44
59
|
console.log(JSON.stringify(report, null, 2));
|
|
45
60
|
}
|
|
46
61
|
else {
|
|
47
|
-
printReport(report);
|
|
62
|
+
printReport(report, opts.dryRun ?? false);
|
|
48
63
|
}
|
|
49
64
|
const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
|
|
50
65
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
51
66
|
}
|
|
52
|
-
|
|
67
|
+
/** Exported so `onboard` prints an mcp-sync report identically to running
|
|
68
|
+
* `mcp sync` standalone, instead of a second, easily-drifting copy of this
|
|
69
|
+
* formatting. */
|
|
70
|
+
export function printReport(report, dryRun) {
|
|
71
|
+
if (dryRun)
|
|
72
|
+
console.log("[dry run]");
|
|
73
|
+
if (report.reports.length === 0) {
|
|
74
|
+
console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
53
77
|
for (const { agent, present, items } of report.reports) {
|
|
54
78
|
if (!present) {
|
|
55
79
|
console.log(`— ${agent} (not installed)`);
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis onboard` — chains `init` → agent detection →
|
|
3
|
-
* resolution →
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `trellis onboard` — chains `init` → agent detection → migration-source
|
|
3
|
+
* resolution → managed-agent-set selection (install-then-manage for a
|
|
4
|
+
* selected, not-yet-present agent) → `migrate` → `sync` → `mcp sync` →
|
|
5
|
+
* `secrets audit` into one guided flow (trellis-cli-onboard,
|
|
6
|
+
* trellis-managed-agents) — a user should never have to type a second
|
|
7
|
+
* command by hand to finish onboarding. Source and managed set are
|
|
8
|
+
* independent choices (design.md D2): importing from a source never
|
|
9
|
+
* writes back to it, and it is not implicitly added to the managed set.
|
|
10
|
+
* Orchestrates existing commands' own plan/apply logic; no new
|
|
11
|
+
* skill-copy, symlink, conflict-detection, or secrets-scanning judgment
|
|
12
|
+
* is made here.
|
|
7
13
|
*/
|
|
8
14
|
import type { AgentId } from "../core/types.js";
|
|
9
15
|
import type { MigratePlan } from "./migrate.js";
|
|
10
16
|
import type { SyncReport } from "./sync.js";
|
|
17
|
+
import type { McpSyncReport } from "./mcp.js";
|
|
18
|
+
import type { SecretsAuditReport } from "./secretsAudit.js";
|
|
19
|
+
import type { ConfirmAndInstallOptions } from "../lib/installAgent.js";
|
|
11
20
|
export interface OnboardAgentSummary {
|
|
12
21
|
agent: AgentId;
|
|
13
22
|
present: boolean;
|
|
@@ -20,7 +29,12 @@ export interface OnboardAgentSummary {
|
|
|
20
29
|
}
|
|
21
30
|
export declare function collectOnboardSummary(homeDir?: string): Promise<OnboardAgentSummary[]>;
|
|
22
31
|
export interface RunOnboardOptions {
|
|
32
|
+
/** Non-interactive migration-source choice. */
|
|
23
33
|
agent?: string;
|
|
34
|
+
/** Non-interactive managed-set choice: comma-separated agent ids, or
|
|
35
|
+
* the literal string "none" for "add nothing new this run" — distinct
|
|
36
|
+
* from omitting the flag, which requires a prompt or refuses. */
|
|
37
|
+
manage?: string;
|
|
24
38
|
dryRun?: boolean;
|
|
25
39
|
json?: boolean;
|
|
26
40
|
/** Defaults to the real `~`; overridable for tests only. */
|
|
@@ -31,13 +45,34 @@ export interface RunOnboardOptions {
|
|
|
31
45
|
/** Test-only: replaces the real readline prompt with a scripted
|
|
32
46
|
* answer, so the prompt path is exercisable without a real terminal. */
|
|
33
47
|
promptForAgent?: (present: OnboardAgentSummary[]) => Promise<string>;
|
|
48
|
+
/** Test-only: replaces the real readline multi-select prompt. Returns
|
|
49
|
+
* the raw answer string (same grammar as `--manage`'s value), not a
|
|
50
|
+
* pre-parsed list — so the same parsing/validation code path is
|
|
51
|
+
* exercised whether the answer came from a flag or a prompt. */
|
|
52
|
+
promptForManagedAgents?: (candidates: OnboardAgentSummary[], alreadyManaged: readonly AgentId[]) => Promise<string>;
|
|
53
|
+
/** Test-only: injected into every `confirmAndInstall` call for a
|
|
54
|
+
* selected, not-yet-present agent. Never a real terminal prompt or a
|
|
55
|
+
* real `npm install` in a unit test. */
|
|
56
|
+
install?: ConfirmAndInstallOptions;
|
|
57
|
+
}
|
|
58
|
+
export interface OnboardInstallResult {
|
|
59
|
+
agent: AgentId;
|
|
60
|
+
installed: boolean;
|
|
61
|
+
installable: boolean;
|
|
34
62
|
}
|
|
35
63
|
export interface OnboardResult {
|
|
36
64
|
summary: OnboardAgentSummary[];
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
source?: AgentId;
|
|
66
|
+
sourceReason?: "auto-selected" | "flag" | "prompt";
|
|
67
|
+
/** The full managed set this run acted against — the union of whatever
|
|
68
|
+
* was already in `managed.yaml` plus this run's own new selections
|
|
69
|
+
* that actually resolved (D3: never a subtraction). */
|
|
70
|
+
managedAgents?: AgentId[];
|
|
71
|
+
installResults?: OnboardInstallResult[];
|
|
39
72
|
migratePlan?: MigratePlan;
|
|
40
73
|
syncReport?: SyncReport;
|
|
74
|
+
mcpSyncReport?: McpSyncReport;
|
|
75
|
+
secretsAuditReport?: SecretsAuditReport;
|
|
41
76
|
refusal?: string;
|
|
42
77
|
/** Only set when no agent is present — the same values `--json` and
|
|
43
78
|
* text output both surface, so a machine caller doesn't have to
|
package/dist/commands/onboard.js
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis onboard` — chains `init` → agent detection →
|
|
3
|
-
* resolution →
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `trellis onboard` — chains `init` → agent detection → migration-source
|
|
3
|
+
* resolution → managed-agent-set selection (install-then-manage for a
|
|
4
|
+
* selected, not-yet-present agent) → `migrate` → `sync` → `mcp sync` →
|
|
5
|
+
* `secrets audit` into one guided flow (trellis-cli-onboard,
|
|
6
|
+
* trellis-managed-agents) — a user should never have to type a second
|
|
7
|
+
* command by hand to finish onboarding. Source and managed set are
|
|
8
|
+
* independent choices (design.md D2): importing from a source never
|
|
9
|
+
* writes back to it, and it is not implicitly added to the managed set.
|
|
10
|
+
* Orchestrates existing commands' own plan/apply logic; no new
|
|
11
|
+
* skill-copy, symlink, conflict-detection, or secrets-scanning judgment
|
|
12
|
+
* is made here.
|
|
7
13
|
*/
|
|
8
14
|
import { createInterface } from "node:readline/promises";
|
|
9
15
|
import { homedir } from "node:os";
|
|
16
|
+
import { writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
10
18
|
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
11
19
|
import * as codexProbe from "../probes/codex.js";
|
|
12
20
|
import * as kiroProbe from "../probes/kiro.js";
|
|
13
21
|
import * as piProbe from "../probes/pi.js";
|
|
14
22
|
import { ALL_AGENTS } from "../core/types.js";
|
|
23
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
15
24
|
import { INSTALL_HINTS, collectInitReport } from "./init.js";
|
|
16
25
|
import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
|
|
17
26
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
27
|
+
import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
|
|
28
|
+
import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
|
|
29
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
30
|
+
import { confirmAndInstall } from "../lib/installAgent.js";
|
|
18
31
|
const PROBES = {
|
|
19
32
|
"claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
|
|
20
33
|
codex: (homeDir) => codexProbe.probe(homeDir),
|
|
@@ -32,26 +45,100 @@ export async function collectOnboardSummary(homeDir = homedir()) {
|
|
|
32
45
|
return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions };
|
|
33
46
|
}));
|
|
34
47
|
}
|
|
48
|
+
function hasContent(s) {
|
|
49
|
+
return s.skillCount > 0 || s.hasRealInstructions;
|
|
50
|
+
}
|
|
35
51
|
async function promptForAgentReal(present) {
|
|
36
52
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
37
53
|
try {
|
|
38
54
|
console.log("Multiple agents detected:");
|
|
39
|
-
|
|
55
|
+
present.forEach((s, i) => {
|
|
40
56
|
const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
|
|
41
|
-
console.log(` ${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`);
|
|
42
|
-
}
|
|
57
|
+
console.log(` ${i + 1}) ${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`);
|
|
58
|
+
});
|
|
43
59
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
|
-
const answer = (await rl.question(
|
|
60
|
+
const answer = (await rl.question(`Choose a migration source [1-${present.length}]: `)).trim();
|
|
61
|
+
// Accepts either the number shown or the literal agent id — the
|
|
62
|
+
// latter kept so `--agent`-equivalent scripted callers piping a
|
|
63
|
+
// canned answer in don't have to know the numbering.
|
|
64
|
+
const byIndex = present[Number(answer) - 1];
|
|
65
|
+
if (byIndex)
|
|
66
|
+
return byIndex.agent;
|
|
45
67
|
if (present.some((s) => s.agent === answer))
|
|
46
68
|
return answer;
|
|
47
|
-
console.log(`Not
|
|
69
|
+
console.log(`Not a valid choice: enter a number from 1-${present.length}, or one of ${present.map((s) => s.agent).join(", ")}`);
|
|
48
70
|
}
|
|
49
|
-
throw new Error("no valid
|
|
71
|
+
throw new Error("no valid migration source chosen after 2 attempts");
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
rl.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function promptForManagedAgentsReal(candidates, alreadyManaged) {
|
|
78
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
79
|
+
try {
|
|
80
|
+
console.log("Which agents should Trellis manage? (comma-separated numbers; enter for none new)");
|
|
81
|
+
candidates.forEach((s, i) => {
|
|
82
|
+
const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
|
|
83
|
+
const tag = alreadyManaged.includes(s.agent) ? " [already managed]" : "";
|
|
84
|
+
console.log(` ${i + 1}) ${s.agent} — ${status}${tag}`);
|
|
85
|
+
});
|
|
86
|
+
const answer = (await rl.question("Select: ")).trim();
|
|
87
|
+
return answer;
|
|
50
88
|
}
|
|
51
89
|
finally {
|
|
52
90
|
rl.close();
|
|
53
91
|
}
|
|
54
92
|
}
|
|
93
|
+
/** Shared by `--manage` and the interactive prompt's answer — same
|
|
94
|
+
* grammar either way (design.md D4): a real agent id list, comma- or
|
|
95
|
+
* whitespace-separated numbers referring to `candidates`' own order, or
|
|
96
|
+
* the literal `none`. Never guesses on an unparseable token. */
|
|
97
|
+
function parseManagedSelection(raw, candidates) {
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
if (trimmed === "" || trimmed.toLowerCase() === "none") {
|
|
100
|
+
return { agents: [] };
|
|
101
|
+
}
|
|
102
|
+
const tokens = trimmed.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
103
|
+
const agents = [];
|
|
104
|
+
for (const token of tokens) {
|
|
105
|
+
const byIndex = candidates[Number(token) - 1];
|
|
106
|
+
if (byIndex) {
|
|
107
|
+
agents.push(byIndex.agent);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ALL_AGENTS.includes(token)) {
|
|
111
|
+
agents.push(token);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
return { error: `"${token}" is not a valid choice — use a number from 1-${candidates.length} or an agent id` };
|
|
115
|
+
}
|
|
116
|
+
return { agents: [...new Set(agents)] };
|
|
117
|
+
}
|
|
118
|
+
async function resolveManagedAgents(opts, summary, alreadyManaged) {
|
|
119
|
+
if (opts.manage !== undefined) {
|
|
120
|
+
const parsed = parseManagedSelection(opts.manage, summary);
|
|
121
|
+
if ("error" in parsed)
|
|
122
|
+
return { refusal: parsed.error };
|
|
123
|
+
return { newlySelected: parsed.agents };
|
|
124
|
+
}
|
|
125
|
+
const canPrompt = !opts.json && (opts.isTTY ?? process.stdin.isTTY === true);
|
|
126
|
+
if (!canPrompt) {
|
|
127
|
+
return { refusal: "no managed-agent selection given and no terminal to prompt in — pass --manage <ids> or --manage none" };
|
|
128
|
+
}
|
|
129
|
+
const prompt = opts.promptForManagedAgents ?? promptForManagedAgentsReal;
|
|
130
|
+
const answer = await prompt(summary, alreadyManaged);
|
|
131
|
+
const parsed = parseManagedSelection(answer, summary);
|
|
132
|
+
if ("error" in parsed)
|
|
133
|
+
return { refusal: parsed.error };
|
|
134
|
+
return { newlySelected: parsed.agents };
|
|
135
|
+
}
|
|
136
|
+
function readManagedYaml(homeDir) {
|
|
137
|
+
return loadCanonicalSource(homeDir).managedAgents;
|
|
138
|
+
}
|
|
139
|
+
function writeManagedYaml(homeDir, agents) {
|
|
140
|
+
writeFileSync(join(homeDir, ".trellis", "managed.yaml"), `agents: [${agents.join(", ")}]\n`);
|
|
141
|
+
}
|
|
55
142
|
export async function collectOnboardPlan(opts = {}) {
|
|
56
143
|
const homeDir = opts.homeDir ?? homedir();
|
|
57
144
|
await collectInitReport(homeDir);
|
|
@@ -60,8 +147,9 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
60
147
|
if (present.length === 0) {
|
|
61
148
|
return { summary, installHints: { ...INSTALL_HINTS } };
|
|
62
149
|
}
|
|
63
|
-
|
|
64
|
-
let
|
|
150
|
+
const sourceCandidates = present.filter(hasContent);
|
|
151
|
+
let source;
|
|
152
|
+
let sourceReason;
|
|
65
153
|
if (opts.agent) {
|
|
66
154
|
const match = present.find((s) => s.agent === opts.agent);
|
|
67
155
|
if (!match) {
|
|
@@ -70,36 +158,101 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
70
158
|
refusal: `"${opts.agent}" is not one of the present agents (${present.map((s) => s.agent).join(", ")})`,
|
|
71
159
|
};
|
|
72
160
|
}
|
|
73
|
-
|
|
74
|
-
|
|
161
|
+
source = match.agent;
|
|
162
|
+
sourceReason = "flag";
|
|
75
163
|
}
|
|
76
|
-
else if (
|
|
77
|
-
|
|
78
|
-
|
|
164
|
+
else if (sourceCandidates.length === 1) {
|
|
165
|
+
source = sourceCandidates[0].agent;
|
|
166
|
+
sourceReason = "auto-selected";
|
|
79
167
|
}
|
|
80
|
-
else {
|
|
168
|
+
else if (sourceCandidates.length > 1) {
|
|
81
169
|
const canPrompt = !opts.json && (opts.isTTY ?? process.stdin.isTTY === true);
|
|
82
170
|
if (!canPrompt) {
|
|
83
171
|
return {
|
|
84
172
|
summary,
|
|
85
|
-
refusal: `multiple agents detected (${
|
|
173
|
+
refusal: `multiple agents detected (${sourceCandidates.map((s) => s.agent).join(", ")}) and no terminal to prompt in — pass --agent <id>`,
|
|
86
174
|
};
|
|
87
175
|
}
|
|
88
176
|
const prompt = opts.promptForAgent ?? promptForAgentReal;
|
|
89
177
|
try {
|
|
90
|
-
|
|
178
|
+
source = (await prompt(sourceCandidates));
|
|
91
179
|
}
|
|
92
180
|
catch (err) {
|
|
93
181
|
return { summary, refusal: err instanceof Error ? err.message : String(err) };
|
|
94
182
|
}
|
|
95
|
-
|
|
183
|
+
sourceReason = "prompt";
|
|
184
|
+
}
|
|
185
|
+
// sourceCandidates.length === 0: nothing with real content to migrate
|
|
186
|
+
// from — source stays undefined, managed-set selection still proceeds.
|
|
187
|
+
const alreadyManaged = readManagedYaml(homeDir);
|
|
188
|
+
const managedResult = await resolveManagedAgents(opts, summary, alreadyManaged);
|
|
189
|
+
if ("refusal" in managedResult) {
|
|
190
|
+
return { summary, source, sourceReason, refusal: managedResult.refusal };
|
|
96
191
|
}
|
|
97
|
-
const
|
|
192
|
+
const installResults = [];
|
|
193
|
+
const resolvedNew = [];
|
|
194
|
+
for (const agent of managedResult.newlySelected) {
|
|
195
|
+
const alreadyPresent = summary.find((s) => s.agent === agent)?.present ?? false;
|
|
196
|
+
if (alreadyPresent) {
|
|
197
|
+
resolvedNew.push(agent);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// A selected, not-yet-present agent: install-then-manage (design.md
|
|
201
|
+
// D5). `--json`/non-interactive callers still get a real confirm
|
|
202
|
+
// step here (never silently installed) — with no injected `confirm`
|
|
203
|
+
// and no TTY, the real readline prompt itself will simply never
|
|
204
|
+
// resolve to "yes" in a non-interactive run, so nothing installs;
|
|
205
|
+
// callers that want this path automated must inject `opts.install`.
|
|
206
|
+
if (opts.json && !opts.install?.confirm) {
|
|
207
|
+
return {
|
|
208
|
+
summary,
|
|
209
|
+
source,
|
|
210
|
+
sourceReason,
|
|
211
|
+
refusal: `"${agent}" is not installed — installing it requires a confirmation, which --json never prompts for. Inject a confirm handler or install ${agent} first.`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const result = await confirmAndInstall(agent, opts.install);
|
|
215
|
+
installResults.push({ agent, installed: result.installed, installable: result.installable });
|
|
216
|
+
if (result.installed) {
|
|
217
|
+
resolvedNew.push(agent);
|
|
218
|
+
}
|
|
219
|
+
// Declined or (Kiro) not installable: excluded from this run's
|
|
220
|
+
// managed set, not an abort of the rest of the flow.
|
|
221
|
+
}
|
|
222
|
+
const managedAgents = [...new Set([...alreadyManaged, ...resolvedNew])];
|
|
98
223
|
if (!opts.dryRun) {
|
|
99
|
-
|
|
224
|
+
writeManagedYaml(homeDir, managedAgents);
|
|
225
|
+
}
|
|
226
|
+
let migratePlan;
|
|
227
|
+
if (source) {
|
|
228
|
+
migratePlan = await collectMigratePlan(source, homeDir);
|
|
229
|
+
if (!opts.dryRun) {
|
|
230
|
+
applyMigratePlan(migratePlan, homeDir);
|
|
231
|
+
}
|
|
100
232
|
}
|
|
101
|
-
|
|
102
|
-
|
|
233
|
+
// One session for the whole chained run (trellis-backup-rollback) —
|
|
234
|
+
// `--dry-run` opens none, there's nothing either stage will write.
|
|
235
|
+
// Neither collectSyncReport nor collectMcpSyncReport finalizes a
|
|
236
|
+
// session they were handed; only this caller does, once, after both.
|
|
237
|
+
const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
|
|
238
|
+
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
239
|
+
const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
240
|
+
backupSession?.finalize();
|
|
241
|
+
// Read-only, no dryRun concept — same report either way, run last since
|
|
242
|
+
// it audits the config mcp sync just wrote (or, on --dry-run, whatever
|
|
243
|
+
// was already there before this run).
|
|
244
|
+
const secretsAuditReport = await collectSecretsAuditReport({ homeDir, managedAgents });
|
|
245
|
+
return {
|
|
246
|
+
summary,
|
|
247
|
+
source,
|
|
248
|
+
sourceReason,
|
|
249
|
+
managedAgents,
|
|
250
|
+
installResults: installResults.length > 0 ? installResults : undefined,
|
|
251
|
+
migratePlan,
|
|
252
|
+
syncReport,
|
|
253
|
+
mcpSyncReport,
|
|
254
|
+
secretsAuditReport,
|
|
255
|
+
};
|
|
103
256
|
}
|
|
104
257
|
export async function runOnboard(opts = {}) {
|
|
105
258
|
const result = await collectOnboardPlan(opts);
|
|
@@ -112,7 +265,9 @@ export async function runOnboard(opts = {}) {
|
|
|
112
265
|
if (result.refusal)
|
|
113
266
|
return { exitCode: 1 };
|
|
114
267
|
const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
|
|
115
|
-
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false)
|
|
268
|
+
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
269
|
+
(result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
270
|
+
(result.secretsAuditReport?.findings.length ?? 0) > 0;
|
|
116
271
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
117
272
|
}
|
|
118
273
|
function printResult(result, dryRun) {
|
|
@@ -130,15 +285,26 @@ function printResult(result, dryRun) {
|
|
|
130
285
|
console.error(result.refusal);
|
|
131
286
|
return;
|
|
132
287
|
}
|
|
133
|
-
if (result.
|
|
134
|
-
console.log(`Only ${result.
|
|
288
|
+
if (result.sourceReason === "auto-selected") {
|
|
289
|
+
console.log(`Only ${result.source} has real content — using it as the migration source.`);
|
|
290
|
+
}
|
|
291
|
+
else if (result.sourceReason === "flag") {
|
|
292
|
+
console.log(`Using ${result.source} as the migration source (--agent).`);
|
|
135
293
|
}
|
|
136
|
-
else if (result.
|
|
137
|
-
console.log(`Using ${result.
|
|
294
|
+
else if (result.sourceReason === "prompt") {
|
|
295
|
+
console.log(`Using ${result.source} as the migration source.`);
|
|
138
296
|
}
|
|
139
|
-
else
|
|
140
|
-
console.log(
|
|
297
|
+
else {
|
|
298
|
+
console.log("No agent has real content to migrate from — starting from canonical's placeholder.");
|
|
299
|
+
}
|
|
300
|
+
for (const install of result.installResults ?? []) {
|
|
301
|
+
console.log(install.installed
|
|
302
|
+
? `Installed ${install.agent}.`
|
|
303
|
+
: install.installable
|
|
304
|
+
? `${install.agent} was not installed and the install was declined — left out of this run's managed set.`
|
|
305
|
+
: `${install.agent} has no npm package to install (see \`trellis init\`'s install hint) — left out of this run's managed set.`);
|
|
141
306
|
}
|
|
307
|
+
console.log(`Managed agents: ${result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)"}`);
|
|
142
308
|
// Reuse `migrate`/`sync`'s own printing verbatim (including the
|
|
143
309
|
// "nothing to migrate" / "already in sync" cases) rather than a second,
|
|
144
310
|
// easily-drifting copy of this formatting. `dryRun: false` here since
|
|
@@ -151,5 +317,12 @@ function printResult(result, dryRun) {
|
|
|
151
317
|
console.log("\nsync");
|
|
152
318
|
printSyncReport(result.syncReport, false);
|
|
153
319
|
}
|
|
154
|
-
|
|
320
|
+
if (result.mcpSyncReport) {
|
|
321
|
+
console.log("\nmcp sync");
|
|
322
|
+
printMcpSyncReport(result.mcpSyncReport, false);
|
|
323
|
+
}
|
|
324
|
+
if (result.secretsAuditReport) {
|
|
325
|
+
console.log("\nsecrets audit");
|
|
326
|
+
printSecretsAuditReport(result.secretsAuditReport);
|
|
327
|
+
}
|
|
155
328
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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 type { BackupManifest } from "../lib/backup.js";
|
|
11
|
+
export interface RunRollbackOptions {
|
|
12
|
+
runId?: string;
|
|
13
|
+
list?: boolean;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
/** Test/sandbox-only seam, same as every other command. Never a CLI
|
|
17
|
+
* flag. */
|
|
18
|
+
homeDir?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface BackupRunSummary {
|
|
21
|
+
runId: string;
|
|
22
|
+
command: string;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
operationCount: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RollbackPlanItem {
|
|
27
|
+
action: "restore" | "conflict" | "already-reverted";
|
|
28
|
+
path: string;
|
|
29
|
+
description: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RollbackReport {
|
|
32
|
+
runId: string;
|
|
33
|
+
items: RollbackPlanItem[];
|
|
34
|
+
}
|
|
35
|
+
/** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
|
|
36
|
+
* chronological sort. */
|
|
37
|
+
export declare function listBackups(homeDir?: string): BackupRunSummary[];
|
|
38
|
+
export declare function loadManifest(homeDir: string, runId: string): BackupManifest;
|
|
39
|
+
export declare function collectRollbackPlan(homeDirInput: string | undefined, runIdInput: string | undefined): Promise<RollbackReport>;
|
|
40
|
+
export declare function applyRollbackPlan(homeDir: string, runId: string, manifest: BackupManifest, items: RollbackPlanItem[]): Promise<void>;
|
|
41
|
+
export declare function runRollback(opts?: RunRollbackOptions): Promise<{
|
|
42
|
+
exitCode: number;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function printReport(report: RollbackReport, dryRun: boolean): void;
|