@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,63 @@
1
+ /**
2
+ * Runtime registry — the single place that knows about concrete adapter classes.
3
+ *
4
+ * Everything else resolves a runtime by name through {@link getRuntime} and then
5
+ * talks only to the {@link AgentRuntime} interface, so adding a new runtime is a
6
+ * one-line registration here plus its adapter file. Resolution is deliberately
7
+ * tiny: an explicit name, else a caller-supplied fallback, else `"claude"`.
8
+ */
9
+
10
+ import { ValidationError } from "../errors.ts";
11
+ import { ClaudeRuntime } from "./claude.ts";
12
+ import { CodexRuntime } from "./codex.ts";
13
+ import { CursorRuntime } from "./cursor.ts";
14
+ import { GeminiRuntime } from "./gemini.ts";
15
+ import { MockRuntime } from "./mock.ts";
16
+ import { OpenCodeRuntime } from "./opencode.ts";
17
+ import type { AgentRuntime } from "./types.ts";
18
+
19
+ /**
20
+ * Name → factory map. Factories return a *fresh* instance per call so adapters
21
+ * can never accidentally share mutable state between resolutions. Insertion
22
+ * order here defines the order reported by {@link getRuntimeNames} and in error
23
+ * messages.
24
+ */
25
+ const runtimes = new Map<string, () => AgentRuntime>([
26
+ ["claude", () => new ClaudeRuntime()],
27
+ ["codex", () => new CodexRuntime()],
28
+ ["gemini", () => new GeminiRuntime()],
29
+ ["cursor", () => new CursorRuntime()],
30
+ ["opencode", () => new OpenCodeRuntime()],
31
+ ["mock", () => new MockRuntime()],
32
+ ]);
33
+
34
+ /**
35
+ * Resolve a runtime adapter by name.
36
+ *
37
+ * Lookup order:
38
+ * 1. explicit `name` (e.g. from `--runtime`),
39
+ * 2. `fallback` (typically `config.runtime.default`),
40
+ * 3. `"claude"` (the built-in default).
41
+ *
42
+ * Throws {@link ValidationError} listing the valid names when the resolved name
43
+ * is unknown, so a typo at the CLI yields an actionable message rather than a
44
+ * bare `undefined`.
45
+ */
46
+ export function getRuntime(name?: string, fallback?: string): AgentRuntime {
47
+ const resolved = name ?? fallback ?? "claude";
48
+ const factory = runtimes.get(resolved);
49
+ if (!factory) {
50
+ throw new ValidationError(
51
+ `Unknown runtime: "${resolved}". Valid runtimes: ${getRuntimeNames().join(", ")}`,
52
+ );
53
+ }
54
+ return factory();
55
+ }
56
+
57
+ /**
58
+ * Names of all registered runtimes, in registration order. Used to validate a
59
+ * user-supplied runtime name and to render the choices in help / errors.
60
+ */
61
+ export function getRuntimeNames(): string[] {
62
+ return [...runtimes.keys()];
63
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Model resolution — bridge between the agent manifest (which names models by
3
+ * alias) and a concrete {@link ResolvedModel} the runtime can spawn.
4
+ *
5
+ * Phase 2 keeps this deliberately simple: the active provider's configured
6
+ * `model` (set by `agentplate setup`) is the concrete model, and the provider's
7
+ * `authTokenEnv` secret is injected as an env var. Per-capability model tiering
8
+ * (opus/sonnet/haiku) is a later refinement; for now the manifest alias is the
9
+ * fallback when no provider model is configured.
10
+ *
11
+ * Auth mode matters here: only `api-key`/`env` providers inject a credential env
12
+ * var. `subscription` providers delegate to the runtime CLI's own login (e.g. a
13
+ * Claude Pro/Max OAuth session), so injecting an empty/leftover key would
14
+ * actively break that login — we deliberately inject nothing.
15
+ */
16
+
17
+ import { getSecret } from "../secrets.ts";
18
+ import type { AgentplateConfig, ResolvedModel } from "../types.ts";
19
+
20
+ /**
21
+ * Resolve the concrete model + provider env for a manifest model alias.
22
+ *
23
+ * @param config loaded Agentplate config
24
+ * @param root project root (to read the gitignored secret value)
25
+ * @param manifestModel the model alias/id from the agent definition
26
+ */
27
+ export function resolveModel(
28
+ config: AgentplateConfig,
29
+ root: string,
30
+ manifestModel: string,
31
+ ): ResolvedModel {
32
+ const provider = config.providers[config.activeProvider];
33
+ const model = provider?.model ?? manifestModel;
34
+ const env: Record<string, string> = {};
35
+
36
+ // Default to "api-key" for legacy configs written before authMode existed.
37
+ const authMode = provider?.authMode ?? (provider?.authTokenEnv ? "api-key" : "none");
38
+ const usesKey = authMode === "api-key" || authMode === "env";
39
+
40
+ if (usesKey && provider?.authTokenEnv) {
41
+ const secret = getSecret(root, provider.authTokenEnv);
42
+ if (secret) env[provider.authTokenEnv] = secret;
43
+ }
44
+ return { model, env };
45
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Runtime adapter contract.
3
+ *
4
+ * A *runtime* is the coding-agent CLI that drives a worker (Claude Code, Codex,
5
+ * …). Agentplate is headless-first: workers run as spawn-per-turn subprocesses, so
6
+ * the core method is {@link AgentRuntime.buildDirectSpawn}, which returns the
7
+ * argv for a single turn. Adapters are stateless; one file per CLI, resolved by
8
+ * the registry. Auth flows through {@link AgentRuntime.buildEnv} — never
9
+ * hardcoded.
10
+ */
11
+
12
+ import type { ResolvedModel } from "../types.ts";
13
+
14
+ /** A normalized event parsed from a runtime's headless event stream. */
15
+ export interface AgentEvent {
16
+ /** Coarse event kind: "assistant" | "tool_use" | "tool_result" | "result" | "session" | "error". */
17
+ type: string;
18
+ /** Tool name for tool events. */
19
+ tool?: string;
20
+ /** Runtime session id (emitted once near the start; used for --resume). */
21
+ sessionId?: string;
22
+ /**
23
+ * Token usage + USD cost, when the runtime reports it (e.g. a Claude Code
24
+ * `result` event carries `usage` + `total_cost_usd`). Recorded into the event
25
+ * store so the Costs page can aggregate real per-agent spend.
26
+ */
27
+ usage?: { tokens: number; costUsd: number };
28
+ /**
29
+ * Human-readable error message when this is an error event (e.g. OpenCode's
30
+ * `error.data.message`, a Claude error result). Recorded into the event store
31
+ * so a failed agent's reason is visible in the feed/logs instead of a blank
32
+ * "error" with no detail.
33
+ */
34
+ error?: string;
35
+ /** The raw parsed JSON line, for callers that need more detail. */
36
+ raw: unknown;
37
+ }
38
+
39
+ /** Options for a single headless turn. */
40
+ export interface DirectSpawnOpts {
41
+ /** Working directory (the worktree path). */
42
+ cwd: string;
43
+ /** Concrete model id. */
44
+ model: string;
45
+ /** Relative path to the instruction file within the worktree. */
46
+ instructionPath: string;
47
+ /** Resume a prior turn's session (omit for the first turn). */
48
+ resumeSessionId?: string;
49
+ /** Extra env vars (merged over process.env by the caller). */
50
+ env?: Record<string, string>;
51
+ /** Initial user-turn text (the prompt for this turn). */
52
+ prompt?: string;
53
+ }
54
+
55
+ /** Options for an attended (foreground, stdio-inherited) interactive session. */
56
+ export interface InteractiveSpawnOpts {
57
+ /** Concrete model id. */
58
+ model: string;
59
+ /** System prompt text to append (the agent's role definition). */
60
+ systemPrompt?: string;
61
+ /** Permission posture: "default" (prompts) | "bypass" (unattended). */
62
+ permissionMode?: "default" | "bypass";
63
+ /** A first user message to seed the session with (non-empty → seeded). */
64
+ initialMessage?: string;
65
+ }
66
+
67
+ /** The contract every runtime adapter implements. */
68
+ export interface AgentRuntime {
69
+ /** Unique adapter id (e.g. "claude"). */
70
+ id: string;
71
+ /** Stability tier. */
72
+ readonly stability: "stable" | "beta" | "experimental";
73
+ /** Relative path where the overlay instructions are written in the worktree. */
74
+ readonly instructionPath: string;
75
+
76
+ /** Build argv for a single headless turn (run via Bun.spawn). */
77
+ buildDirectSpawn(opts: DirectSpawnOpts): string[];
78
+
79
+ /**
80
+ * Build argv for an ATTENDED interactive session (foreground, stdio inherited).
81
+ * Used by `coordinator start` to hand the terminal to a live agent chat.
82
+ * Optional: runtimes that cannot run interactively omit it.
83
+ */
84
+ buildInteractiveSpawn?(opts: InteractiveSpawnOpts): string[];
85
+
86
+ /** Build provider env vars (API keys, base URLs) for the resolved model. */
87
+ buildEnv(model: ResolvedModel): Record<string, string>;
88
+
89
+ /** Build argv for a one-shot non-interactive call (used by AI merge/distill later). */
90
+ buildPrintCommand(prompt: string, model?: string): string[];
91
+
92
+ /** Parse a headless stdout byte stream into normalized events. */
93
+ parseEvents?(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent>;
94
+
95
+ /** Deploy any runtime-specific guards/config into the worktree before spawn. */
96
+ deployConfig?(worktreePath: string): Promise<void>;
97
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Project scaffolding — create the `.agentplate/` directory structure and write the
3
+ * initial config. Shared by `agentplate init` (non-interactive) and `agentplate setup`
4
+ * (interactive).
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { AGENTPLATE_DIR, CONFIG_FILE, serializeConfig } from "./config.ts";
10
+ import type { AgentplateConfig } from "./types.ts";
11
+
12
+ /** Subdirectories created under `.agentplate/`. */
13
+ const SUBDIRS = ["agents", "agent-defs", "worktrees", "specs", "logs", "skills"] as const;
14
+
15
+ /** The `.agentplate/.gitignore` that keeps runtime state and secrets out of git. */
16
+ const AGENTPLATE_GITIGNORE = `# Agentplate runtime state — do not commit.
17
+ worktrees/
18
+ logs/
19
+ *.db
20
+ *.db-shm
21
+ *.db-wal
22
+ config.local.yaml
23
+ secrets.local.yaml
24
+ agents/*/checkpoint.json
25
+ `;
26
+
27
+ const AGENTPLATE_README = `# .agentplate/
28
+
29
+ This directory holds Agentplate's per-project state: configuration, agent
30
+ definitions, git worktrees for agent workers, task specs, logs, distilled
31
+ skills, and SQLite databases (mail, sessions, events, skills, deploys).
32
+
33
+ Committed: \`config.yaml\`, \`agent-manifest.json\`, \`agent-defs/\`.
34
+ Gitignored (see \`.gitignore\` here): \`config.local.yaml\`, \`secrets.local.yaml\`,
35
+ \`worktrees/\`, \`logs/\`, and all \`*.db\` files.
36
+
37
+ Managed by the \`agentplate\` CLI — you generally don't edit these by hand.
38
+ `;
39
+
40
+ /** Create the `.agentplate/` directory tree (idempotent). */
41
+ export function ensureAgentplateDirs(root: string): void {
42
+ const base = join(root, AGENTPLATE_DIR);
43
+ mkdirSync(base, { recursive: true });
44
+ for (const sub of SUBDIRS) {
45
+ mkdirSync(join(base, sub), { recursive: true });
46
+ }
47
+ }
48
+
49
+ /** Write `.agentplate/config.yaml`. */
50
+ export function writeConfig(root: string, config: AgentplateConfig): void {
51
+ writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
52
+ }
53
+
54
+ /**
55
+ * Scaffold `.agentplate/` and write the config + supporting files. Idempotent: safe
56
+ * to run on an already-initialized project (it overwrites config.yaml with the
57
+ * provided config and (re)writes the .gitignore/README).
58
+ */
59
+ export function scaffoldAgentplateDir(root: string, config: AgentplateConfig): void {
60
+ ensureAgentplateDirs(root);
61
+ writeConfig(root, config);
62
+ const base = join(root, AGENTPLATE_DIR);
63
+ writeFileSync(join(base, ".gitignore"), AGENTPLATE_GITIGNORE, "utf8");
64
+ const readmePath = join(base, "README.md");
65
+ if (!existsSync(readmePath)) {
66
+ writeFileSync(readmePath, AGENTPLATE_README, "utf8");
67
+ }
68
+ }
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getSecret, hasSecret, loadSecrets, secretsPath, setSecret } from "./secrets.ts";
6
+
7
+ let root: string;
8
+
9
+ beforeEach(() => {
10
+ root = mkdtempSync(join(tmpdir(), "agentplate-sec-"));
11
+ mkdirSync(join(root, ".agentplate"), { recursive: true });
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(root, { recursive: true, force: true });
16
+ delete process.env.AGENTPLATE_TEST_KEY;
17
+ });
18
+
19
+ describe("secrets store", () => {
20
+ test("loadSecrets is empty when no file exists", () => {
21
+ expect(loadSecrets(root)).toEqual({});
22
+ });
23
+
24
+ test("setSecret then loadSecrets round-trips", () => {
25
+ setSecret(root, "ANTHROPIC_API_KEY", "sk-test-value");
26
+ expect(loadSecrets(root).ANTHROPIC_API_KEY).toBe("sk-test-value");
27
+ });
28
+
29
+ test("secrets file is written with 0600 permissions", () => {
30
+ setSecret(root, "TOKEN", "abc");
31
+ const mode = statSync(secretsPath(root)).mode & 0o777;
32
+ expect(mode).toBe(0o600);
33
+ });
34
+
35
+ test("getSecret prefers the file over the environment", () => {
36
+ process.env.AGENTPLATE_TEST_KEY = "from-env";
37
+ setSecret(root, "AGENTPLATE_TEST_KEY", "from-file");
38
+ expect(getSecret(root, "AGENTPLATE_TEST_KEY")).toBe("from-file");
39
+ });
40
+
41
+ test("getSecret falls back to the environment", () => {
42
+ process.env.AGENTPLATE_TEST_KEY = "from-env";
43
+ expect(getSecret(root, "AGENTPLATE_TEST_KEY")).toBe("from-env");
44
+ });
45
+
46
+ test("hasSecret reflects availability", () => {
47
+ expect(hasSecret(root, "MISSING")).toBe(false);
48
+ setSecret(root, "PRESENT", "x");
49
+ expect(hasSecret(root, "PRESENT")).toBe(true);
50
+ });
51
+ });
package/src/secrets.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Secret storage.
3
+ *
4
+ * Secrets (API keys, deploy tokens) are stored as `ENV_VAR_NAME: value` pairs in
5
+ * `.agentplate/secrets.local.yaml`, which is gitignored and never committed. The
6
+ * rest of Agentplate references secrets only by their env-var *name*; values are
7
+ * read here at the moment they are needed and injected into a child process env.
8
+ *
9
+ * Resolution order for {@link getSecret}: the secrets file first, then
10
+ * `process.env` (so CI can supply credentials via the environment without a
11
+ * file).
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import yaml from "js-yaml";
17
+ import { AGENTPLATE_DIR } from "./config.ts";
18
+ import { ConfigError } from "./errors.ts";
19
+
20
+ /** Filename of the gitignored secrets store. */
21
+ export const SECRETS_FILE = "secrets.local.yaml";
22
+
23
+ /** Absolute path to the secrets file for a project root. */
24
+ export function secretsPath(root: string): string {
25
+ return join(root, AGENTPLATE_DIR, SECRETS_FILE);
26
+ }
27
+
28
+ /** Load all stored secrets for a project root (empty object if none). */
29
+ export function loadSecrets(root: string): Record<string, string> {
30
+ const path = secretsPath(root);
31
+ if (!existsSync(path)) return {};
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = yaml.load(readFileSync(path, "utf8"));
35
+ } catch (error) {
36
+ throw new ConfigError(`Invalid YAML in ${path}: ${(error as Error).message}`);
37
+ }
38
+ if (parsed === null || parsed === undefined) return {};
39
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
40
+ throw new ConfigError(`Expected a mapping in ${path}`);
41
+ }
42
+ const result: Record<string, string> = {};
43
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
44
+ if (typeof value === "string") result[key] = value;
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * Persist a secret under an env-var name. Writes the gitignored secrets file
51
+ * with restrictive (0600) permissions.
52
+ */
53
+ export function setSecret(root: string, key: string, value: string): void {
54
+ const path = secretsPath(root);
55
+ const current = loadSecrets(root);
56
+ current[key] = value;
57
+ const header =
58
+ "# Agentplate secrets — gitignored, never commit this file.\n" +
59
+ "# Maps ENV_VAR_NAME: value. Values are injected into agent/deploy processes on demand.\n";
60
+ writeFileSync(path, header + yaml.dump(current, { indent: 2 }), { mode: 0o600 });
61
+ }
62
+
63
+ /**
64
+ * Read a secret by env-var name. Checks the secrets file, then `process.env`.
65
+ * Returns `undefined` if neither has it.
66
+ */
67
+ export function getSecret(root: string, key: string): string | undefined {
68
+ const fromFile = loadSecrets(root)[key];
69
+ if (fromFile !== undefined && fromFile !== "") return fromFile;
70
+ const fromEnv = process.env[key];
71
+ if (fromEnv !== undefined && fromEnv !== "") return fromEnv;
72
+ return undefined;
73
+ }
74
+
75
+ /** True if a secret is available (file or env) for the given env-var name. */
76
+ export function hasSecret(root: string, key: string): boolean {
77
+ return getSecret(root, key) !== undefined;
78
+ }