@agentplate/cli 1.0.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive setup wizard (the Hermes-style onboarding).
|
|
3
|
+
*
|
|
4
|
+
* Walks the user through: provider selection → credentials → model → runtime,
|
|
5
|
+
* then returns the resulting config plus an optional secret for the caller to
|
|
6
|
+
* persist. Pure config construction is delegated to `providers/apply.ts`; this
|
|
7
|
+
* module owns only the interactive I/O.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as p from "@clack/prompts";
|
|
11
|
+
import { applyProviderSelection, buildProviderConfig } from "../providers/apply.ts";
|
|
12
|
+
import { getProviderSpec, listProviders, meetsContextFloor } from "../providers/registry.ts";
|
|
13
|
+
import type { AgentplateConfig, AuthMode } from "../types.ts";
|
|
14
|
+
import { commandOnPath, detectDefaultRuntime } from "../utils/detect.ts";
|
|
15
|
+
|
|
16
|
+
/** Runtimes the wizard can offer. */
|
|
17
|
+
const RUNTIME_CHOICES: ReadonlyArray<{ value: string; label: string; cli: string }> = [
|
|
18
|
+
{ value: "claude", label: "Claude Code", cli: "claude" },
|
|
19
|
+
{ value: "opencode", label: "OpenCode", cli: "opencode" },
|
|
20
|
+
{ value: "codex", label: "OpenAI Codex", cli: "codex" },
|
|
21
|
+
{ value: "gemini", label: "Gemini CLI", cli: "gemini" },
|
|
22
|
+
{ value: "cursor", label: "Cursor", cli: "cursor-agent" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Map a runtime id to the CLI binary that provides its login. */
|
|
26
|
+
function runtimeCli(runtime: string): string {
|
|
27
|
+
return RUNTIME_CHOICES.find((r) => r.value === runtime)?.cli ?? runtime;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WizardResult {
|
|
31
|
+
/** The new config to persist. */
|
|
32
|
+
config: AgentplateConfig;
|
|
33
|
+
/** A secret to store (env-var name → value), if the user provided one. */
|
|
34
|
+
secret?: { key: string; value: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Abort the wizard cleanly if the user cancelled a prompt. */
|
|
38
|
+
function ensure<T>(value: T | symbol): T {
|
|
39
|
+
if (p.isCancel(value)) {
|
|
40
|
+
p.cancel("Setup cancelled. No changes written.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run the interactive wizard. Returns the config + optional secret to persist. */
|
|
47
|
+
export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<WizardResult> {
|
|
48
|
+
p.intro("Agentplate setup");
|
|
49
|
+
|
|
50
|
+
// 1. Provider ----------------------------------------------------------
|
|
51
|
+
const providerId = ensure(
|
|
52
|
+
await p.select({
|
|
53
|
+
message: "Choose your AI provider",
|
|
54
|
+
initialValue: currentConfig.activeProvider,
|
|
55
|
+
options: listProviders().map((spec) => ({
|
|
56
|
+
value: spec.id,
|
|
57
|
+
label: spec.label,
|
|
58
|
+
hint: spec.description,
|
|
59
|
+
})),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
const spec = getProviderSpec(providerId);
|
|
63
|
+
if (!spec) {
|
|
64
|
+
p.cancel(`Unknown provider "${providerId}".`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Base URL (custom endpoints only) ----------------------------------
|
|
69
|
+
let baseUrl: string | undefined;
|
|
70
|
+
if (spec.requiresBaseUrl) {
|
|
71
|
+
baseUrl = ensure(
|
|
72
|
+
await p.text({
|
|
73
|
+
message: "Base URL for the endpoint",
|
|
74
|
+
placeholder: "https://my-endpoint.example.com/v1",
|
|
75
|
+
validate: (v) => (v.trim().length === 0 ? "A base URL is required" : undefined),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
} else {
|
|
79
|
+
baseUrl = spec.defaultBaseUrl;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Authentication method --------------------------------------------
|
|
83
|
+
// Offer the same kind of choice Claude Code shows: use an existing
|
|
84
|
+
// subscription/CLI login, an existing env var, or enter an API key.
|
|
85
|
+
let secret: WizardResult["secret"];
|
|
86
|
+
let authMode: AuthMode;
|
|
87
|
+
|
|
88
|
+
if (spec.keyless) {
|
|
89
|
+
authMode = "none";
|
|
90
|
+
p.note("This provider runs locally and needs no credentials.", spec.label);
|
|
91
|
+
} else {
|
|
92
|
+
const envPresent = Boolean(process.env[spec.authEnvVar]?.length);
|
|
93
|
+
const subInstalled = spec.subscriptionRuntime
|
|
94
|
+
? await commandOnPath(runtimeCli(spec.subscriptionRuntime))
|
|
95
|
+
: false;
|
|
96
|
+
|
|
97
|
+
const options: Array<{ value: AuthMode; label: string; hint?: string }> = [];
|
|
98
|
+
if (spec.supportsSubscription) {
|
|
99
|
+
options.push({
|
|
100
|
+
value: "subscription",
|
|
101
|
+
label: spec.subscriptionLabel ?? `${spec.label} subscription / CLI login`,
|
|
102
|
+
hint: subInstalled ? "CLI detected — uses its login" : "requires the CLI to be logged in",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (envPresent) {
|
|
106
|
+
options.push({
|
|
107
|
+
value: "env",
|
|
108
|
+
label: `Use existing ${spec.authEnvVar} from your environment`,
|
|
109
|
+
hint: "already set — nothing stored",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
options.push({
|
|
113
|
+
value: "api-key",
|
|
114
|
+
label: "Enter an API key (pay-per-token)",
|
|
115
|
+
hint: "stored in .agentplate/secrets.local.yaml (gitignored)",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Default to the most convenient available method.
|
|
119
|
+
const initialAuth: AuthMode = spec.supportsSubscription
|
|
120
|
+
? "subscription"
|
|
121
|
+
: envPresent
|
|
122
|
+
? "env"
|
|
123
|
+
: "api-key";
|
|
124
|
+
|
|
125
|
+
authMode =
|
|
126
|
+
options.length === 1
|
|
127
|
+
? "api-key"
|
|
128
|
+
: ensure(
|
|
129
|
+
await p.select({
|
|
130
|
+
message: `How should Agentplate authenticate with ${spec.label}?`,
|
|
131
|
+
initialValue: initialAuth,
|
|
132
|
+
options,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (authMode === "subscription") {
|
|
137
|
+
const cli = spec.subscriptionRuntime ? runtimeCli(spec.subscriptionRuntime) : spec.id;
|
|
138
|
+
if (!subInstalled) {
|
|
139
|
+
p.note(
|
|
140
|
+
`Agentplate will use your ${spec.subscriptionLabel ?? "CLI"} login.\n` +
|
|
141
|
+
`Make sure \`${cli}\` is installed and logged in (run \`${cli}\` once to sign in).`,
|
|
142
|
+
"Subscription auth",
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
p.note(`Using your existing \`${cli}\` login — no API key stored.`, "Subscription auth");
|
|
146
|
+
}
|
|
147
|
+
} else if (authMode === "api-key") {
|
|
148
|
+
const key = ensure(
|
|
149
|
+
await p.password({
|
|
150
|
+
message: `Enter your ${spec.label} API key (${spec.authEnvVar})`,
|
|
151
|
+
validate: (v) => (v.trim().length === 0 ? "An API key is required" : undefined),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
secret = { key: spec.authEnvVar, value: key.trim() };
|
|
155
|
+
}
|
|
156
|
+
// authMode === "env": nothing to store; resolved from the environment at run time.
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Model -------------------------------------------------------------
|
|
160
|
+
const eligible = spec.models.filter(meetsContextFloor);
|
|
161
|
+
let model: string;
|
|
162
|
+
if (eligible.length > 0) {
|
|
163
|
+
const choice = ensure(
|
|
164
|
+
await p.select({
|
|
165
|
+
message: "Choose a default model",
|
|
166
|
+
options: [
|
|
167
|
+
...eligible.map((m) => ({
|
|
168
|
+
value: m.id,
|
|
169
|
+
label: m.label,
|
|
170
|
+
hint: `${Math.round(m.contextWindow / 1000)}k context`,
|
|
171
|
+
})),
|
|
172
|
+
{ value: "__custom__", label: "Custom model id…", hint: "type your own" },
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
model =
|
|
177
|
+
choice === "__custom__"
|
|
178
|
+
? ensure(
|
|
179
|
+
await p.text({
|
|
180
|
+
message: "Model id",
|
|
181
|
+
validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
|
|
182
|
+
}),
|
|
183
|
+
).trim()
|
|
184
|
+
: choice;
|
|
185
|
+
} else {
|
|
186
|
+
model = ensure(
|
|
187
|
+
await p.text({
|
|
188
|
+
message: "Model id",
|
|
189
|
+
placeholder: "provider/model-name",
|
|
190
|
+
validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
|
|
191
|
+
}),
|
|
192
|
+
).trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. Runtime -----------------------------------------------------------
|
|
196
|
+
const detected = await detectDefaultRuntime();
|
|
197
|
+
const installed = new Set<string>();
|
|
198
|
+
await Promise.all(
|
|
199
|
+
RUNTIME_CHOICES.map(async (r) => {
|
|
200
|
+
if (await commandOnPath(r.cli)) installed.add(r.value);
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
// When the user picked subscription/OAuth auth, the login lives in the
|
|
204
|
+
// provider's own CLI (e.g. a ChatGPT login in `codex`, a Google login in
|
|
205
|
+
// `gemini`). Default the runtime to that CLI so the OAuth credentials are
|
|
206
|
+
// actually reused — otherwise a mismatched runtime (e.g. `claude` driving a
|
|
207
|
+
// GPT model with no key) would silently break the login.
|
|
208
|
+
const initialRuntime =
|
|
209
|
+
authMode === "subscription" && spec.subscriptionRuntime ? spec.subscriptionRuntime : detected;
|
|
210
|
+
const runtime = ensure(
|
|
211
|
+
await p.select({
|
|
212
|
+
message: "Which coding-agent runtime should drive workers?",
|
|
213
|
+
initialValue: initialRuntime,
|
|
214
|
+
options: RUNTIME_CHOICES.map((r) => ({
|
|
215
|
+
value: r.value,
|
|
216
|
+
label: r.label,
|
|
217
|
+
hint: installed.has(r.value) ? "installed" : "not detected on PATH",
|
|
218
|
+
})),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// 6. Summary -----------------------------------------------------------
|
|
223
|
+
const previewProvider = buildProviderConfig(spec, model, authMode, baseUrl);
|
|
224
|
+
const authSummary: Record<AuthMode, string> = {
|
|
225
|
+
subscription: `subscription / ${runtimeCli(spec.subscriptionRuntime ?? runtime)} login (no key stored)`,
|
|
226
|
+
"api-key": `${spec.authEnvVar} → secrets.local.yaml`,
|
|
227
|
+
env: `${spec.authEnvVar} (from environment)`,
|
|
228
|
+
none: "none (local)",
|
|
229
|
+
};
|
|
230
|
+
p.note(
|
|
231
|
+
[
|
|
232
|
+
`provider: ${spec.label} (${providerId})`,
|
|
233
|
+
`model: ${model}`,
|
|
234
|
+
`runtime: ${runtime}`,
|
|
235
|
+
`auth: ${authSummary[authMode]}`,
|
|
236
|
+
previewProvider.baseUrl ? `base URL: ${previewProvider.baseUrl}` : undefined,
|
|
237
|
+
]
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.join("\n"),
|
|
240
|
+
"Configuration",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const config = applyProviderSelection(currentConfig, {
|
|
244
|
+
providerId,
|
|
245
|
+
spec,
|
|
246
|
+
model,
|
|
247
|
+
authMode,
|
|
248
|
+
baseUrl,
|
|
249
|
+
runtime,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
p.outro("Ready to write configuration.");
|
|
253
|
+
return secret ? { config, secret } : { config };
|
|
254
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { WorktreeError } from "../errors.ts";
|
|
7
|
+
import { createWorktree, listWorktrees, removeWorktree, worktreeExists } from "./manager.ts";
|
|
8
|
+
|
|
9
|
+
// We use REAL git repos in temp dirs (no mocks) so the porcelain parsing and
|
|
10
|
+
// `git worktree` semantics are exercised exactly as in production.
|
|
11
|
+
|
|
12
|
+
/** Run git in `cwd`, fail the test helper loudly on non-zero exit. */
|
|
13
|
+
async function runGit(cwd: string, args: string[]): Promise<string> {
|
|
14
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
15
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
16
|
+
new Response(proc.stdout).text(),
|
|
17
|
+
new Response(proc.stderr).text(),
|
|
18
|
+
proc.exited,
|
|
19
|
+
]);
|
|
20
|
+
if (code !== 0) {
|
|
21
|
+
throw new Error(`git ${args.join(" ")} failed (${code}): ${stderr.trim()}`);
|
|
22
|
+
}
|
|
23
|
+
return stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a real git repo in a fresh temp dir with a deterministic identity and
|
|
28
|
+
* one initial commit (so HEAD exists and worktrees have a base to branch from).
|
|
29
|
+
*/
|
|
30
|
+
async function createTempGitRepo(): Promise<string> {
|
|
31
|
+
// realpathSync resolves symlinks in the temp root. On macOS tmpdir() is
|
|
32
|
+
// /tmp -> /private/tmp; `git worktree list --porcelain` reports the resolved
|
|
33
|
+
// form, so we canonicalize here to keep the test's constructed paths in sync
|
|
34
|
+
// with git's output (createWorktree itself is path-agnostic — it just joins).
|
|
35
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "agentplate-wt-")));
|
|
36
|
+
await runGit(dir, ["init"]);
|
|
37
|
+
// Pin a deterministic identity so commits succeed in CI.
|
|
38
|
+
await runGit(dir, ["config", "user.email", "test@agentplate.dev"]);
|
|
39
|
+
await runGit(dir, ["config", "user.name", "Agentplate Test"]);
|
|
40
|
+
writeFileSync(join(dir, "README.md"), "# temp repo\n");
|
|
41
|
+
await runGit(dir, ["add", "README.md"]);
|
|
42
|
+
await runGit(dir, ["commit", "-m", "initial commit"]);
|
|
43
|
+
// Force the initial branch to "main" regardless of the host's
|
|
44
|
+
// init.defaultBranch (master vs main). `branch -M` renames in place and is a
|
|
45
|
+
// no-op-safe rename even if the branch is already called "main", so this is
|
|
46
|
+
// portable across git versions.
|
|
47
|
+
await runGit(dir, ["branch", "-M", "main"]);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("worktree manager", () => {
|
|
52
|
+
let repoRoot: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
repoRoot = await createTempGitRepo();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("createWorktree creates the dir on the right branch and returns its path", async () => {
|
|
63
|
+
const result = await createWorktree(repoRoot, "alice", "agent/alice");
|
|
64
|
+
|
|
65
|
+
const expectedPath = join(repoRoot, ".agentplate", "worktrees", "alice");
|
|
66
|
+
expect(result.path).toBe(expectedPath);
|
|
67
|
+
expect(result.branchName).toBe("agent/alice");
|
|
68
|
+
|
|
69
|
+
// The directory exists on disk.
|
|
70
|
+
expect(await worktreeExists(repoRoot, expectedPath)).toBe(true);
|
|
71
|
+
|
|
72
|
+
// HEAD inside the worktree resolves to the new branch.
|
|
73
|
+
const currentBranch = await runGit(result.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
74
|
+
expect(currentBranch).toBe("agent/alice");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("createWorktree creates the .agentplate/worktrees parent if missing", async () => {
|
|
78
|
+
// No pre-existing .agentplate dir — createWorktree must mkdir -p the parents.
|
|
79
|
+
const result = await createWorktree(repoRoot, "bob", "agent/bob");
|
|
80
|
+
expect(await worktreeExists(repoRoot, result.path)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("createWorktree throws WorktreeError when the path already exists", async () => {
|
|
84
|
+
await createWorktree(repoRoot, "dup", "agent/dup-1");
|
|
85
|
+
|
|
86
|
+
// A second create for the same agent name targets the same dir -> collide.
|
|
87
|
+
await expect(createWorktree(repoRoot, "dup", "agent/dup-2")).rejects.toBeInstanceOf(
|
|
88
|
+
WorktreeError,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("createWorktree branches off an explicit baseBranch", async () => {
|
|
93
|
+
// Make a divergent branch with a marker file, return to main.
|
|
94
|
+
await runGit(repoRoot, ["checkout", "-b", "feature-base"]);
|
|
95
|
+
writeFileSync(join(repoRoot, "marker.txt"), "from base\n");
|
|
96
|
+
await runGit(repoRoot, ["add", "marker.txt"]);
|
|
97
|
+
await runGit(repoRoot, ["commit", "-m", "base-only commit"]);
|
|
98
|
+
await runGit(repoRoot, ["checkout", "main"]);
|
|
99
|
+
|
|
100
|
+
const result = await createWorktree(repoRoot, "carol", "agent/carol", "feature-base");
|
|
101
|
+
|
|
102
|
+
// The worktree should see the marker file that only exists on feature-base.
|
|
103
|
+
expect(await worktreeExists(repoRoot, join(result.path, "marker.txt"))).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("listWorktrees parses porcelain output including the main worktree", async () => {
|
|
107
|
+
await createWorktree(repoRoot, "alice", "agent/alice");
|
|
108
|
+
|
|
109
|
+
const list = await listWorktrees(repoRoot);
|
|
110
|
+
|
|
111
|
+
// Main worktree + the one we created.
|
|
112
|
+
expect(list.length).toBe(2);
|
|
113
|
+
|
|
114
|
+
const alice = list.find((w) => w.branch === "agent/alice");
|
|
115
|
+
expect(alice).toBeDefined();
|
|
116
|
+
expect(alice?.path).toBe(join(repoRoot, ".agentplate", "worktrees", "alice"));
|
|
117
|
+
// HEAD is a 40-char SHA.
|
|
118
|
+
expect(alice?.head).toMatch(/^[0-9a-f]{40}$/);
|
|
119
|
+
|
|
120
|
+
const mainEntry = list.find((w) => w.branch === "main");
|
|
121
|
+
expect(mainEntry).toBeDefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a commit made inside the worktree is visible on its branch", async () => {
|
|
125
|
+
const { path: wtPath } = await createWorktree(repoRoot, "writer", "agent/writer");
|
|
126
|
+
|
|
127
|
+
// Commit a file from INSIDE the worktree.
|
|
128
|
+
writeFileSync(join(wtPath, "work.txt"), "agent output\n");
|
|
129
|
+
await runGit(wtPath, ["add", "work.txt"]);
|
|
130
|
+
await runGit(wtPath, ["commit", "-m", "agent work"]);
|
|
131
|
+
|
|
132
|
+
// The branch tip (queried from the main repo) must contain that commit.
|
|
133
|
+
const subject = await runGit(repoRoot, ["log", "-1", "--format=%s", "agent/writer"]);
|
|
134
|
+
expect(subject).toBe("agent work");
|
|
135
|
+
|
|
136
|
+
// And the file is part of that branch's tree.
|
|
137
|
+
const tree = await runGit(repoRoot, ["ls-tree", "--name-only", "agent/writer"]);
|
|
138
|
+
expect(tree.split("\n")).toContain("work.txt");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("worktreeExists reflects creation and removal", async () => {
|
|
142
|
+
const missing = join(repoRoot, ".agentplate", "worktrees", "ghost");
|
|
143
|
+
expect(await worktreeExists(repoRoot, missing)).toBe(false);
|
|
144
|
+
|
|
145
|
+
const { path } = await createWorktree(repoRoot, "ghost", "agent/ghost");
|
|
146
|
+
expect(await worktreeExists(repoRoot, path)).toBe(true);
|
|
147
|
+
|
|
148
|
+
await removeWorktree(repoRoot, path);
|
|
149
|
+
expect(await worktreeExists(repoRoot, path)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("removeWorktree removes a clean worktree without force", async () => {
|
|
153
|
+
const { path } = await createWorktree(repoRoot, "tmp", "agent/tmp");
|
|
154
|
+
|
|
155
|
+
await removeWorktree(repoRoot, path);
|
|
156
|
+
|
|
157
|
+
// Gone from disk...
|
|
158
|
+
expect(await worktreeExists(repoRoot, path)).toBe(false);
|
|
159
|
+
// ...and de-registered from git's worktree list.
|
|
160
|
+
const list = await listWorktrees(repoRoot);
|
|
161
|
+
expect(list.find((w) => w.path === path)).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("removeWorktree requires force when the worktree is dirty", async () => {
|
|
165
|
+
const { path } = await createWorktree(repoRoot, "dirty", "agent/dirty");
|
|
166
|
+
|
|
167
|
+
// Introduce an uncommitted change; git refuses a non-forced remove.
|
|
168
|
+
writeFileSync(join(path, "scratch.txt"), "uncommitted\n");
|
|
169
|
+
|
|
170
|
+
await expect(removeWorktree(repoRoot, path)).rejects.toBeInstanceOf(WorktreeError);
|
|
171
|
+
|
|
172
|
+
// With force it succeeds.
|
|
173
|
+
await removeWorktree(repoRoot, path, { force: true });
|
|
174
|
+
expect(await worktreeExists(repoRoot, path)).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("removeWorktree throws WorktreeError for an unknown path", async () => {
|
|
178
|
+
const bogus = join(repoRoot, ".agentplate", "worktrees", "does-not-exist");
|
|
179
|
+
await expect(removeWorktree(repoRoot, bogus)).rejects.toBeInstanceOf(WorktreeError);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { SubprocessError, WorktreeError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
// Where every agent's isolated worktree lives, relative to the repo root.
|
|
7
|
+
// Mirrors agentplate's layout (.agentplate/worktrees/<agent>) so the rest of
|
|
8
|
+
// Agentplate can reason about paths deterministically without consulting git.
|
|
9
|
+
const WORKTREES_SUBDIR = join(".agentplate", "worktrees");
|
|
10
|
+
|
|
11
|
+
/** A single entry parsed from `git worktree list --porcelain`. */
|
|
12
|
+
export interface WorktreeEntry {
|
|
13
|
+
/** Absolute path to the worktree directory. */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Branch ref (short name, e.g. "feature/x") or "" for detached HEAD. */
|
|
16
|
+
branch: string;
|
|
17
|
+
/** Commit SHA the worktree currently points at. */
|
|
18
|
+
head: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run a git subcommand in `repoRoot` and return trimmed stdout.
|
|
23
|
+
*
|
|
24
|
+
* Centralised here (rather than per-function) so every git call shares the same
|
|
25
|
+
* argv-array invocation, output capture, and typed-error handling. We never use
|
|
26
|
+
* a shell string: argv arrays avoid quoting/injection issues entirely.
|
|
27
|
+
*
|
|
28
|
+
* On a non-zero exit we throw SubprocessError carrying stderr so callers (and
|
|
29
|
+
* the higher-level WorktreeError translation) have the real git diagnostic.
|
|
30
|
+
*/
|
|
31
|
+
async function git(repoRoot: string, args: string[]): Promise<string> {
|
|
32
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
33
|
+
cwd: repoRoot,
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Drain both streams before awaiting exit. Reading after `await proc.exited`
|
|
39
|
+
// is fine for small outputs, but draining first avoids any chance of a
|
|
40
|
+
// pipe-buffer stall on large `git worktree list` output.
|
|
41
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
42
|
+
new Response(proc.stdout).text(),
|
|
43
|
+
new Response(proc.stderr).text(),
|
|
44
|
+
proc.exited,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
if (exitCode !== 0) {
|
|
48
|
+
const detail = stderr.trim() || stdout.trim() || `exit code ${exitCode}`;
|
|
49
|
+
throw new SubprocessError(`git ${args.join(" ")} failed: ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return stdout.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create an isolated git worktree for an agent on a brand-new branch.
|
|
57
|
+
*
|
|
58
|
+
* Layout: `<repoRoot>/.agentplate/worktrees/<agentName>` checked out on a freshly
|
|
59
|
+
* created `branchName` based off `baseBranch` (default: current HEAD).
|
|
60
|
+
*
|
|
61
|
+
* We mkdir the parent (`.agentplate/worktrees`) up front because git refuses to
|
|
62
|
+
* create a worktree under a non-existent parent directory. If the target path
|
|
63
|
+
* already exists we fail fast with WorktreeError rather than letting git emit a
|
|
64
|
+
* confusing "already exists"/"not a valid object" message — a pre-existing path
|
|
65
|
+
* almost always means a stale or duplicate agent and the caller must clean up.
|
|
66
|
+
*/
|
|
67
|
+
export async function createWorktree(
|
|
68
|
+
repoRoot: string,
|
|
69
|
+
agentName: string,
|
|
70
|
+
branchName: string,
|
|
71
|
+
baseBranch?: string,
|
|
72
|
+
): Promise<{ path: string; branchName: string }> {
|
|
73
|
+
const parentDir = join(repoRoot, WORKTREES_SUBDIR);
|
|
74
|
+
const worktreePath = join(parentDir, agentName);
|
|
75
|
+
|
|
76
|
+
// Guard against clobbering an existing worktree directory. We check before
|
|
77
|
+
// touching git so the error is precise and no partial state is created.
|
|
78
|
+
if (await worktreeExists(repoRoot, worktreePath)) {
|
|
79
|
+
throw new WorktreeError(`Worktree path already exists: ${worktreePath}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ensure `.agentplate/worktrees/` exists (recursive == mkdir -p, no error if
|
|
83
|
+
// the parent chain already exists from a prior agent).
|
|
84
|
+
await mkdir(parentDir, { recursive: true });
|
|
85
|
+
|
|
86
|
+
// `git worktree add -b <branch> <path> [<base>]` creates the branch and the
|
|
87
|
+
// worktree atomically. Omitting <base> makes git use the current HEAD, which
|
|
88
|
+
// matches our documented default.
|
|
89
|
+
const addArgs = ["worktree", "add", "-b", branchName, worktreePath];
|
|
90
|
+
if (baseBranch !== undefined) {
|
|
91
|
+
addArgs.push(baseBranch);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await git(repoRoot, addArgs);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Translate the low-level subprocess failure into the domain error the
|
|
98
|
+
// rest of Agentplate expects, preserving the original git message.
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
throw new WorktreeError(`Failed to create worktree for ${agentName}: ${message}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { path: worktreePath, branchName };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* List all worktrees registered with this repository.
|
|
108
|
+
*
|
|
109
|
+
* Parses `git worktree list --porcelain`, whose stable, machine-readable format
|
|
110
|
+
* emits one record per worktree separated by a blank line. Each record looks
|
|
111
|
+
* like:
|
|
112
|
+
*
|
|
113
|
+
* worktree /abs/path
|
|
114
|
+
* HEAD <sha>
|
|
115
|
+
* branch refs/heads/<name> (omitted/replaced by `detached` if detached)
|
|
116
|
+
*
|
|
117
|
+
* We intentionally parse the porcelain (not the human format) so output is
|
|
118
|
+
* locale- and version-stable.
|
|
119
|
+
*/
|
|
120
|
+
export async function listWorktrees(
|
|
121
|
+
repoRoot: string,
|
|
122
|
+
): Promise<Array<{ path: string; branch: string; head: string }>> {
|
|
123
|
+
const output = await git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
124
|
+
if (output === "") {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const entries: WorktreeEntry[] = [];
|
|
129
|
+
// Records are separated by blank lines. Split on a blank line and process
|
|
130
|
+
// each block independently so a malformed/extra field never bleeds across
|
|
131
|
+
// records.
|
|
132
|
+
const blocks = output.split("\n\n");
|
|
133
|
+
|
|
134
|
+
for (const block of blocks) {
|
|
135
|
+
const trimmedBlock = block.trim();
|
|
136
|
+
if (trimmedBlock === "") {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let path: string | undefined;
|
|
141
|
+
let head = "";
|
|
142
|
+
let branch = "";
|
|
143
|
+
|
|
144
|
+
for (const line of trimmedBlock.split("\n")) {
|
|
145
|
+
if (line.startsWith("worktree ")) {
|
|
146
|
+
path = line.slice("worktree ".length).trim();
|
|
147
|
+
} else if (line.startsWith("HEAD ")) {
|
|
148
|
+
head = line.slice("HEAD ".length).trim();
|
|
149
|
+
} else if (line.startsWith("branch ")) {
|
|
150
|
+
// git prints the full ref ("refs/heads/foo"); expose the short name.
|
|
151
|
+
const ref = line.slice("branch ".length).trim();
|
|
152
|
+
branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
153
|
+
}
|
|
154
|
+
// `detached`, `bare`, `locked`, etc. carry no fields we surface; ignore.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A record without a `worktree` line is not something git emits; skip it
|
|
158
|
+
// defensively so the return type's `path` is always a real string.
|
|
159
|
+
if (path === undefined) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
entries.push({ path, branch, head });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Remove a worktree via `git worktree remove`.
|
|
171
|
+
*
|
|
172
|
+
* `--force` is required when the worktree has uncommitted changes or is locked;
|
|
173
|
+
* callers opt in via `opts.force`. We do NOT delete the branch here — branch
|
|
174
|
+
* lifecycle (merge/cleanup) is a separate concern handled elsewhere.
|
|
175
|
+
*/
|
|
176
|
+
export async function removeWorktree(
|
|
177
|
+
repoRoot: string,
|
|
178
|
+
worktreePath: string,
|
|
179
|
+
opts?: { force?: boolean },
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
const args = ["worktree", "remove"];
|
|
182
|
+
if (opts?.force === true) {
|
|
183
|
+
args.push("--force");
|
|
184
|
+
}
|
|
185
|
+
args.push(worktreePath);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await git(repoRoot, args);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
throw new WorktreeError(`Failed to remove worktree ${worktreePath}: ${message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Force-delete a local branch via `git branch -D`. Used to fully clean up a
|
|
197
|
+
* reaped agent's `agentplate/<name>` branch after its worktree is removed. Uses
|
|
198
|
+
* `-D` (not `-d`) because the branch is typically unmerged. The branch must no
|
|
199
|
+
* longer be checked out by any worktree (remove the worktree first).
|
|
200
|
+
*/
|
|
201
|
+
export async function deleteBranch(repoRoot: string, branchName: string): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await git(repoRoot, ["branch", "-D", branchName]);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
206
|
+
throw new WorktreeError(`Failed to delete branch ${branchName}: ${message}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Report whether a worktree directory currently exists on disk.
|
|
212
|
+
*
|
|
213
|
+
* We answer from the filesystem (not `git worktree list`) on purpose: callers
|
|
214
|
+
* use this to decide whether `createWorktree` would collide, and a leftover
|
|
215
|
+
* directory from an interrupted/aborted run can exist even when git no longer
|
|
216
|
+
* tracks it as a worktree. A filesystem check catches both cases.
|
|
217
|
+
*/
|
|
218
|
+
export async function worktreeExists(_repoRoot: string, worktreePath: string): Promise<boolean> {
|
|
219
|
+
// We stat the path directly (rather than Bun.file().exists(), which reports
|
|
220
|
+
// false for directories) so the check is correct for the common case where a
|
|
221
|
+
// worktree is a directory. Any stat error — ENOENT and friends — means the
|
|
222
|
+
// path is absent for our collision-detection purposes.
|
|
223
|
+
try {
|
|
224
|
+
await stat(worktreePath);
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|