@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `agentplate merge` — fold agent branches into the canonical (or chosen) branch.
3
+ *
4
+ * Supports a single `--branch`, `--all` completed agent branches, and a
5
+ * side-effect-free `--dry-run` that predicts the resolution tier and conflicts.
6
+ * Merges run under a sentinel lock so two `merge` invocations never race on the
7
+ * same target.
8
+ */
9
+
10
+ import { Command } from "commander";
11
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
12
+ import { ValidationError } from "../errors.ts";
13
+ import { jsonOutput } from "../json.ts";
14
+ import { printError, printInfo, printSuccess } from "../logging/color.ts";
15
+ import { withMergeLock } from "../merge/lock.ts";
16
+ import { createMergeQueue } from "../merge/queue.ts";
17
+ import { mergeBranch, predictMerge } from "../merge/resolver.ts";
18
+ import { mergeDbPath, sessionsDbPath } from "../paths.ts";
19
+ import { createSessionStore } from "../sessions/store.ts";
20
+ import type { AgentSession, MergeResult } from "../types.ts";
21
+
22
+ export function createMergeCommand(): Command {
23
+ return new Command("merge")
24
+ .description("Merge agent branches into the canonical branch")
25
+ .option("--branch <name>", "merge a specific branch")
26
+ .option("--all", "merge all completed agent branches")
27
+ .option("--into <branch>", "target branch (default: project canonical branch)")
28
+ .option("--dry-run", "predict conflicts/tier without merging")
29
+ .option("--json", "output JSON")
30
+ .action(
31
+ async (
32
+ opts: {
33
+ branch?: string;
34
+ all?: boolean;
35
+ into?: string;
36
+ dryRun?: boolean;
37
+ json?: boolean;
38
+ },
39
+ command: Command,
40
+ ) => {
41
+ const useJson = command.optsWithGlobals().json === true;
42
+ const root = findProjectRoot();
43
+ if (!isInitialized(root)) {
44
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
45
+ }
46
+ const config = loadConfig(root);
47
+ const target = opts.into ?? config.project.canonicalBranch;
48
+
49
+ const store = createSessionStore(sessionsDbPath(root));
50
+ let branches: Array<{ branch: string; session?: AgentSession }> = [];
51
+ try {
52
+ if (opts.branch) {
53
+ const session = store.listSessions().find((s) => s.branchName === opts.branch);
54
+ branches = [{ branch: opts.branch, session }];
55
+ } else if (opts.all) {
56
+ branches = store
57
+ .listSessions({ state: "completed" })
58
+ .map((s) => ({ branch: s.branchName, session: s }));
59
+ } else {
60
+ throw new ValidationError("Pass --branch <name> or --all.");
61
+ }
62
+ } finally {
63
+ store.close();
64
+ }
65
+
66
+ if (branches.length === 0) {
67
+ if (useJson) jsonOutput({ merged: [], message: "no branches to merge" });
68
+ else printInfo("No branches to merge.");
69
+ return;
70
+ }
71
+
72
+ // Dry-run: predict only, no mutation, no lock needed.
73
+ if (opts.dryRun) {
74
+ const predictions: MergeResult[] = [];
75
+ for (const { branch } of branches) {
76
+ predictions.push(await predictMerge(root, branch, target));
77
+ }
78
+ if (useJson) {
79
+ jsonOutput({ dryRun: true, target, predictions });
80
+ return;
81
+ }
82
+ for (const p of predictions) {
83
+ printInfo(
84
+ `${p.branchName} → ${target}: predicted ${p.tier ?? "conflict"}` +
85
+ (p.conflictFiles.length ? ` (${p.conflictFiles.length} conflict file(s))` : ""),
86
+ );
87
+ }
88
+ return;
89
+ }
90
+
91
+ const queue = createMergeQueue(mergeDbPath(root));
92
+ const results: MergeResult[] = [];
93
+ try {
94
+ for (const { branch, session } of branches) {
95
+ const entry = queue.enqueue({
96
+ branchName: branch,
97
+ agentName: session?.agentName ?? "unknown",
98
+ taskId: session?.taskId ?? "unknown",
99
+ targetBranch: target,
100
+ });
101
+ const result = await withMergeLock(root, () =>
102
+ mergeBranch(root, branch, target, { autoResolve: config.merge.aiResolveEnabled }),
103
+ );
104
+ queue.markStatus(entry.id, result.status);
105
+ results.push(result);
106
+ }
107
+ } finally {
108
+ queue.close();
109
+ }
110
+
111
+ if (useJson) {
112
+ jsonOutput({ target, results });
113
+ return;
114
+ }
115
+ for (const r of results) {
116
+ if (r.status === "merged") {
117
+ printSuccess(`${r.branchName} → ${target}: ${r.tier}`);
118
+ } else {
119
+ printError(
120
+ `${r.branchName} → ${target}: ${r.status}` +
121
+ (r.conflictFiles.length ? ` (${r.conflictFiles.join(", ")})` : ""),
122
+ );
123
+ }
124
+ }
125
+ },
126
+ );
127
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `agentplate model` — switch the active provider/model after initial setup.
3
+ *
4
+ * Reuses the same wizard as `agentplate setup` (à la Hermes' `hermes model`), so
5
+ * provider/model/runtime changes go through one consistent flow.
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
10
+ import { ValidationError } from "../errors.ts";
11
+ import { jsonOutput } from "../json.ts";
12
+ import { brand, printHint, printInfo, printSuccess } from "../logging/color.ts";
13
+ import { getProviderSpec } from "../providers/registry.ts";
14
+ import { scaffoldAgentplateDir } from "../scaffold.ts";
15
+ import { setSecret } from "../secrets.ts";
16
+ import { runSetupWizard } from "../wizard/setup.ts";
17
+
18
+ export function createModelCommand(): Command {
19
+ return new Command("model")
20
+ .description("Change the active AI provider and model")
21
+ .option("--json", "print the current provider/model and exit")
22
+ .action(async (_opts: { json?: boolean }, command: Command) => {
23
+ const useJson = command.optsWithGlobals().json === true;
24
+ const root = findProjectRoot();
25
+ if (!isInitialized(root)) {
26
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
27
+ }
28
+ const config = loadConfig(root);
29
+
30
+ // `--json` is a read-only query of the current selection.
31
+ if (useJson) {
32
+ const active = config.providers[config.activeProvider];
33
+ jsonOutput({
34
+ activeProvider: config.activeProvider,
35
+ model: active?.model ?? null,
36
+ runtime: config.runtime.default,
37
+ });
38
+ return;
39
+ }
40
+
41
+ if (!process.stdin.isTTY) {
42
+ const active = config.providers[config.activeProvider];
43
+ const spec = getProviderSpec(config.activeProvider);
44
+ printInfo(`Active provider: ${spec?.label ?? config.activeProvider}`);
45
+ printInfo(`Model: ${active?.model ?? "(unset)"}`);
46
+ printInfo(`Runtime: ${config.runtime.default}`);
47
+ printHint("Run in a terminal to change these interactively.");
48
+ return;
49
+ }
50
+
51
+ const result = await runSetupWizard(config);
52
+ scaffoldAgentplateDir(root, result.config);
53
+ if (result.secret) {
54
+ setSecret(root, result.secret.key, result.secret.value);
55
+ }
56
+ printSuccess(`${brand("Agentplate")} provider updated to ${result.config.activeProvider}.`);
57
+ });
58
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `agentplate prime` — load orchestration context (SessionStart hook target).
3
+ *
4
+ * Emits a concise snapshot of the project: provider/runtime, the current run,
5
+ * and active sessions — so a fresh Claude Code session starts oriented.
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
10
+ import { ValidationError } from "../errors.ts";
11
+ import { jsonOutput } from "../json.ts";
12
+ import { brand, muted, printInfo } from "../logging/color.ts";
13
+ import { sessionsDbPath } from "../paths.ts";
14
+ import { createSessionStore } from "../sessions/store.ts";
15
+
16
+ export function createPrimeCommand(): Command {
17
+ return new Command("prime")
18
+ .description("Load orchestration context")
19
+ .option("--json", "output JSON")
20
+ .action((_opts: { json?: boolean }, command: Command) => {
21
+ const useJson = command.optsWithGlobals().json === true;
22
+ const root = findProjectRoot();
23
+ if (!isInitialized(root)) {
24
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
25
+ }
26
+ const config = loadConfig(root);
27
+ const store = createSessionStore(sessionsDbPath(root));
28
+ try {
29
+ const runs = store.listRuns(1);
30
+ const currentRun = runs[0] ?? null;
31
+ const active = currentRun
32
+ ? store.listSessions({ runId: currentRun.id }).filter((s) => s.state === "working")
33
+ : [];
34
+ const provider = config.providers[config.activeProvider];
35
+
36
+ if (useJson) {
37
+ jsonOutput({
38
+ project: config.project.name,
39
+ runtime: config.runtime.default,
40
+ provider: config.activeProvider,
41
+ model: provider?.model ?? null,
42
+ currentRun,
43
+ activeAgents: active.map((s) => s.agentName),
44
+ });
45
+ return;
46
+ }
47
+ printInfo(brand(`Agentplate — ${config.project.name}`));
48
+ printInfo(
49
+ `runtime: ${config.runtime.default} provider: ${config.activeProvider} model: ${provider?.model ?? "(unset)"}`,
50
+ );
51
+ printInfo(
52
+ currentRun ? `run: ${currentRun.id} (${currentRun.status})` : muted("no active run"),
53
+ );
54
+ printInfo(
55
+ `active agents: ${active.length ? active.map((s) => s.agentName).join(", ") : muted("none")}`,
56
+ );
57
+ } finally {
58
+ store.close();
59
+ }
60
+ });
61
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `agentplate reap` — terminate agents idle past the timeout.
3
+ *
4
+ * Sweeps the session store for workers with no activity for the idle window
5
+ * (default `config.agents.idleTimeoutMinutes`, 10), marks each `stopped`, kills any
6
+ * live process, and removes its worktree + branch. The coordinator is never
7
+ * reaped. Run it manually or on a cron; `agentplate serve` also reaps on its own
8
+ * loop. Use `--dry-run` to preview and `--keep-worktrees` to leave worktrees.
9
+ */
10
+
11
+ import { Command } from "commander";
12
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
13
+ import { ValidationError } from "../errors.ts";
14
+ import { jsonOutput } from "../json.ts";
15
+ import { muted, printInfo, printSuccess } from "../logging/color.ts";
16
+ import { sessionsDbPath } from "../paths.ts";
17
+ import { reapIdleSessions, selectIdleSessions } from "../sessions/reaper.ts";
18
+ import { createSessionStore } from "../sessions/store.ts";
19
+
20
+ export function createReapCommand(): Command {
21
+ return new Command("reap")
22
+ .description("Terminate agents idle past the timeout (stop + kill + remove worktree)")
23
+ .option("--minutes <n>", "idle timeout in minutes (default: config.agents.idleTimeoutMinutes)")
24
+ .option("--keep-worktrees", "mark stopped + kill process but keep worktrees/branches")
25
+ .option("--dry-run", "list which agents would be reaped without changing anything")
26
+ .option("--json", "output JSON")
27
+ .action(
28
+ async (
29
+ opts: { minutes?: string; keepWorktrees?: boolean; dryRun?: boolean; json?: boolean },
30
+ command: Command,
31
+ ) => {
32
+ const useJson = command.optsWithGlobals().json === true;
33
+ const root = findProjectRoot();
34
+ if (!isInitialized(root)) {
35
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
36
+ }
37
+ const config = loadConfig(root);
38
+
39
+ const minutes =
40
+ opts.minutes !== undefined ? Number(opts.minutes) : config.agents.idleTimeoutMinutes;
41
+ if (!Number.isFinite(minutes) || minutes < 0) {
42
+ throw new ValidationError("--minutes must be a number >= 0");
43
+ }
44
+ const idleMs = minutes * 60_000;
45
+
46
+ const store = createSessionStore(sessionsDbPath(root));
47
+ try {
48
+ if (opts.dryRun) {
49
+ const candidates = selectIdleSessions(store.listSessions(), {
50
+ idleMs,
51
+ now: Date.now(),
52
+ }).map((s) => ({ agent: s.agentName, capability: s.capability, state: s.state }));
53
+ if (useJson) {
54
+ jsonOutput({ dryRun: true, minutes, candidates });
55
+ } else if (candidates.length === 0) {
56
+ printInfo(`No agents idle longer than ${minutes}m.`);
57
+ } else {
58
+ printInfo(`Would reap ${candidates.length} agent(s) idle >${minutes}m:`);
59
+ for (const c of candidates) {
60
+ process.stdout.write(` ${c.agent} ${muted(`(${c.capability}, ${c.state})`)}\n`);
61
+ }
62
+ }
63
+ return;
64
+ }
65
+
66
+ const reaped = await reapIdleSessions(store, root, {
67
+ idleMs,
68
+ removeWorktrees: opts.keepWorktrees !== true,
69
+ });
70
+
71
+ if (useJson) {
72
+ jsonOutput({ minutes, reapedCount: reaped.length, reaped });
73
+ } else if (reaped.length === 0) {
74
+ printInfo(`No agents idle longer than ${minutes}m.`);
75
+ } else {
76
+ printSuccess(`Reaped ${reaped.length} idle agent(s) (>${minutes}m):`);
77
+ for (const r of reaped) {
78
+ const wt = r.worktreeRemoved ? "worktree removed" : "worktree kept";
79
+ process.stdout.write(` ${r.agentName} ${muted(`(${r.capability}, ${wt})`)}\n`);
80
+ }
81
+ }
82
+ } finally {
83
+ store.close();
84
+ }
85
+ },
86
+ );
87
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `agentplate serve` — HTTP + WebSocket surface for the web UI.
3
+ *
4
+ * Serves the REST API, a live WebSocket snapshot feed, and the built SPA from
5
+ * `ui/dist`. All data is read from the same SQLite stores the CLI uses.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { Command } from "commander";
10
+ import { findProjectRoot, isInitialized } from "../config.ts";
11
+ import { ValidationError } from "../errors.ts";
12
+ import { jsonOutput } from "../json.ts";
13
+ import { brand, printHint, printInfo, printSuccess } from "../logging/color.ts";
14
+ import { packageRootDir } from "../paths.ts";
15
+ import { startServer } from "../serve/server.ts";
16
+
17
+ export function createServeCommand(): Command {
18
+ return new Command("serve")
19
+ .description("Serve the web UI (HTTP + WebSocket) over the project's state")
20
+ .option("--port <n>", "port", "7551")
21
+ .option("--host <addr>", "bind host", "127.0.0.1")
22
+ .option("--ui-dir <path>", "directory of the built SPA (default: bundled ui/dist)")
23
+ .option("--json", "output JSON (prints the URL and exits non-blocking info)")
24
+ .action(
25
+ async (
26
+ opts: { port: string; host: string; uiDir?: string; json?: boolean },
27
+ command: Command,
28
+ ) => {
29
+ const useJson = command.optsWithGlobals().json === true;
30
+ const root = findProjectRoot();
31
+ if (!isInitialized(root)) {
32
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
33
+ }
34
+ const uiDir = opts.uiDir ?? join(packageRootDir(), "ui", "dist");
35
+ const handle = startServer({
36
+ root,
37
+ port: Number(opts.port),
38
+ host: opts.host,
39
+ uiDir,
40
+ });
41
+
42
+ if (useJson) {
43
+ jsonOutput({ url: handle.url, healthz: `${handle.url}/healthz`, uiDir });
44
+ } else {
45
+ printSuccess(`${brand("agentplate serve")} → ${handle.url}`);
46
+ printInfo(` REST: ${handle.url}/api/overview`);
47
+ printInfo(` health: ${handle.url}/healthz`);
48
+ printHint(" Press Ctrl+C to stop.");
49
+ }
50
+
51
+ // Keep the process alive until interrupted.
52
+ const shutdown = () => {
53
+ handle.stop();
54
+ process.exit(0);
55
+ };
56
+ process.on("SIGINT", shutdown);
57
+ process.on("SIGTERM", shutdown);
58
+ await new Promise<never>(() => {});
59
+ },
60
+ );
61
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `agentplate setup` — interactive onboarding.
3
+ *
4
+ * Ensures `.agentplate/` exists, then runs the provider/runtime/model wizard and
5
+ * persists the resulting config and (optionally) an API key into the gitignored
6
+ * secrets store.
7
+ */
8
+
9
+ import { Command } from "commander";
10
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
11
+ import { ValidationError } from "../errors.ts";
12
+ import { brand, printHint, printSuccess } from "../logging/color.ts";
13
+ import { ensureAgentplateDirs, scaffoldAgentplateDir } from "../scaffold.ts";
14
+ import { setSecret } from "../secrets.ts";
15
+ import { runSetupWizard } from "../wizard/setup.ts";
16
+ import { buildInitialConfig } from "./init.ts";
17
+
18
+ export function createSetupCommand(): Command {
19
+ return new Command("setup")
20
+ .description("Interactive setup — choose an AI provider, add your API key, pick a runtime")
21
+ .option("--name <name>", "set the project name (default: auto-detect)")
22
+ .action(async (opts: { name?: string }) => {
23
+ if (!process.stdin.isTTY) {
24
+ throw new ValidationError(
25
+ "`agentplate setup` is interactive and needs a terminal. Use `agentplate init` for non-interactive scaffolding.",
26
+ );
27
+ }
28
+
29
+ const root = findProjectRoot();
30
+ ensureAgentplateDirs(root);
31
+
32
+ const currentConfig = isInitialized(root)
33
+ ? loadConfig(root)
34
+ : await buildInitialConfig(root, opts.name);
35
+
36
+ const result = await runSetupWizard(currentConfig);
37
+
38
+ // Write config (+ supporting files), then the secret (after .gitignore exists).
39
+ scaffoldAgentplateDir(root, result.config);
40
+ if (result.secret) {
41
+ setSecret(root, result.secret.key, result.secret.value);
42
+ }
43
+
44
+ printSuccess(`${brand("Agentplate")} is configured.`);
45
+ printHint("Verify with `agentplate doctor`. Your API key (if entered) is in");
46
+ printHint(" .agentplate/secrets.local.yaml (gitignored — never committed).");
47
+ });
48
+ }
@@ -0,0 +1,106 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_CONFIG, serializeConfig, setProjectRootOverride } from "../config.ts";
6
+ import { createShipCommand, runShip } from "./ship.ts";
7
+
8
+ let root: string;
9
+
10
+ /** Stand up a minimal initialized project with a Node app and docker-gha default. */
11
+ function initProject(): void {
12
+ mkdirSync(join(root, ".agentplate"), { recursive: true });
13
+ const config = structuredClone(DEFAULT_CONFIG);
14
+ config.project.name = "ship-test";
15
+ config.project.root = root;
16
+ config.deploy.default = "docker-gha";
17
+ config.deploy.gates = { preview: "auto", production: "confirm", staging: "auto" };
18
+ writeFileSync(join(root, ".agentplate", "config.yaml"), serializeConfig(config), "utf8");
19
+ writeFileSync(
20
+ join(root, "package.json"),
21
+ JSON.stringify(
22
+ { name: "demo", scripts: { build: "echo build", start: "echo start" } },
23
+ null,
24
+ 2,
25
+ ),
26
+ "utf8",
27
+ );
28
+ }
29
+
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), "agentplate-ship-"));
32
+ setProjectRootOverride(root);
33
+ initProject();
34
+ });
35
+
36
+ afterEach(() => {
37
+ setProjectRootOverride(null);
38
+ rmSync(root, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("createShipCommand", () => {
42
+ test("builds without throwing and exposes options", () => {
43
+ const cmd = createShipCommand();
44
+ expect(cmd.name()).toBe("ship");
45
+ const flags = cmd.options.map((o) => o.long);
46
+ expect(flags).toContain("--target");
47
+ expect(flags).toContain("--dry-run");
48
+ expect(flags).toContain("--no-build");
49
+ });
50
+ });
51
+
52
+ describe("runShip (dry-run)", () => {
53
+ test("plans a deploy without mutation and writes artifacts", async () => {
54
+ const result = await runShip(root, "a tiny web app", {
55
+ target: "docker-gha",
56
+ env: "preview",
57
+ dryRun: true,
58
+ yes: false,
59
+ build: true,
60
+ });
61
+ expect(result.target).toBe("docker-gha");
62
+ expect(result.dryRun).toBe(true);
63
+ // Build stages ran (architect/builder/devops), deploy planned, verify skipped.
64
+ const names = result.stages.map((s) => s.name);
65
+ expect(names).toContain("architect");
66
+ expect(names).toContain("deploy");
67
+ const deployStage = result.stages.find((s) => s.name === "deploy");
68
+ expect(deployStage?.status).toBe("ok");
69
+ // A dry-run records a dryRun audit row, never a success deploy.
70
+ expect(result.deploy?.dryRun).toBe(true);
71
+ // docker-gha generateConfig wrote a Dockerfile to the project root.
72
+ expect(existsSync(join(root, "Dockerfile"))).toBe(true);
73
+ expect(existsSync(join(root, ".github", "workflows", "deploy.yml"))).toBe(true);
74
+ // No real deployment URL in dry-run.
75
+ expect(result.urls).toEqual([]);
76
+ });
77
+
78
+ test("--no-build skips build stages", async () => {
79
+ const result = await runShip(root, "current tree", {
80
+ target: "docker-gha",
81
+ env: "preview",
82
+ dryRun: true,
83
+ yes: false,
84
+ build: false,
85
+ });
86
+ const names = result.stages.map((s) => s.name);
87
+ expect(names).toContain("build");
88
+ expect(names).not.toContain("architect");
89
+ });
90
+ });
91
+
92
+ describe("runShip (gate)", () => {
93
+ test("production gate refuses a real deploy without --yes", async () => {
94
+ const result = await runShip(root, "prod app", {
95
+ target: "docker-gha",
96
+ env: "production",
97
+ dryRun: false,
98
+ yes: false,
99
+ build: false,
100
+ });
101
+ expect(result.deploy?.refused).toBe(true);
102
+ expect(result.deploy?.gateDecision).toBe("denied");
103
+ const deployStage = result.stages.find((s) => s.name === "deploy");
104
+ expect(deployStage?.status).toBe("refused");
105
+ });
106
+ });