harnery 0.5.0 → 0.7.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 (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,284 @@
1
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { Command } from "commander";
4
+ import type { EmitContext } from "../commander.ts";
5
+ import {
6
+ cursorApiKeyPath,
7
+ type DevtoolName,
8
+ enrichFromApi,
9
+ type ProbeResult,
10
+ probeEndpoints,
11
+ readDevtools,
12
+ resolveCursorApiKey,
13
+ type ToolStatus,
14
+ } from "../lib/devtools.ts";
15
+
16
+ /**
17
+ * `devtools`: report the local state of the AI coding agents harnery supports
18
+ * — Claude Code, Codex, and Cursor — in one place: logged-in status, plan/seat
19
+ * tier, auth expiry, session counts, and (where the tool keeps them locally)
20
+ * rate-limit / quota windows.
21
+ *
22
+ * Reads files on disk by default (no network). When a Cursor API key is
23
+ * configured (`devtools cursor-key set`), it additionally verifies the key and
24
+ * pulls Cloud Agent activity. `--usage` adds an opt-in windowed scan of local
25
+ * transcripts for approximate token totals.
26
+ */
27
+
28
+ const VALID: readonly DevtoolName[] = ["claude-code", "codex", "cursor"];
29
+
30
+ interface DevtoolsOpts {
31
+ format: string;
32
+ usage?: boolean;
33
+ windowDays: number;
34
+ tool?: string[];
35
+ noApi?: boolean;
36
+ }
37
+
38
+ export function registerDevtoolsCommand(program: Command, emit: EmitContext): void {
39
+ const cmd = program
40
+ .command("devtools")
41
+ .description("Local status of the AI coding agents (Claude Code, Codex, Cursor)")
42
+ .option(
43
+ "--tool <name>",
44
+ "Restrict to a tool (repeatable): claude-code | codex | cursor",
45
+ collect,
46
+ [],
47
+ )
48
+ .option("--usage", "Also scan local transcripts for approximate token totals (slower)")
49
+ .option(
50
+ "--window-days <n>",
51
+ "With --usage: only count transcripts modified within N days",
52
+ (v) => Number.parseInt(v, 10),
53
+ 7,
54
+ )
55
+ .option("--no-api", "Skip the Cursor API enrichment even when a key is configured")
56
+ .option("--format <type>", "Output format: table, json", "table")
57
+ .action(async (opts: DevtoolsOpts) => {
58
+ const only = (opts.tool ?? []).filter((t): t is DevtoolName =>
59
+ VALID.includes(t as DevtoolName),
60
+ );
61
+ const bad = (opts.tool ?? []).filter((t) => !VALID.includes(t as DevtoolName));
62
+ if (bad.length) {
63
+ emit.error({
64
+ code: "bad_tool",
65
+ message: `unknown --tool: ${bad.join(", ")}`,
66
+ hint: `valid: ${VALID.join(", ")}`,
67
+ });
68
+ return;
69
+ }
70
+
71
+ const report = readDevtools({
72
+ usage: opts.usage,
73
+ windowDays: opts.windowDays,
74
+ only: only.length ? only : undefined,
75
+ });
76
+
77
+ // Auto-enrich when a Cursor key is configured (the user opted in by
78
+ // storing one); --no-api forces pure-local.
79
+ if (opts.noApi !== true) await enrichFromApi(report);
80
+
81
+ if (opts.format === "json") {
82
+ emit.config({ format: "json" });
83
+ emit.data({ ok: true, ...report });
84
+ return;
85
+ }
86
+
87
+ emit.text(renderTable(report.tools));
88
+ });
89
+
90
+ registerCursorKeyCommand(cmd, emit);
91
+ registerDoctorCommand(cmd, emit);
92
+ }
93
+
94
+ /**
95
+ * `devtools doctor` — one live call per usage endpoint to check the integrations
96
+ * still work. Surfaces `auth_rejected` (our headers/token stopped being
97
+ * accepted) and `shape_changed` (response schema drifted) distinctly from a
98
+ * mere rate limit. Bypasses the cache; run occasionally, by hand.
99
+ */
100
+ function registerDoctorCommand(parent: Command, emit: EmitContext): void {
101
+ parent
102
+ .command("doctor")
103
+ .description("Probe the usage endpoints once each to detect header/schema drift")
104
+ .option("--tool <name>", "Restrict to a tool (repeatable): claude-code | cursor", collect, [])
105
+ .option("--format <type>", "Output format: table, json", "table")
106
+ .action(async (opts: { tool: string[]; format: string }) => {
107
+ const only = (opts.tool ?? []).filter((t): t is DevtoolName =>
108
+ VALID.includes(t as DevtoolName),
109
+ );
110
+ const results = await probeEndpoints({ only: only.length ? only : undefined });
111
+ if (opts.format === "json") {
112
+ emit.config({ format: "json" });
113
+ emit.data({
114
+ ok: results.every((r) => r.outcome === "ok" || r.outcome === "rate_limited"),
115
+ results,
116
+ });
117
+ return;
118
+ }
119
+ emit.text(renderDoctor(results));
120
+ });
121
+ }
122
+
123
+ const DOCTOR_MARK: Record<ProbeResult["outcome"], string> = {
124
+ ok: "✓",
125
+ rate_limited: "~",
126
+ no_credential: "-",
127
+ auth_rejected: "✗",
128
+ shape_changed: "✗",
129
+ unreachable: "✗",
130
+ };
131
+
132
+ function renderDoctor(results: ProbeResult[]): string {
133
+ if (!results.length) return "no probeable endpoints (Codex is local-only)";
134
+ const lines: string[] = [];
135
+ for (const r of results) {
136
+ lines.push(
137
+ `${DOCTOR_MARK[r.outcome]} ${r.tool.padEnd(12)} ${r.outcome.padEnd(14)} ${r.detail}`,
138
+ );
139
+ lines.push(` ${r.endpoint}${r.clientVersion ? ` (client ${r.clientVersion})` : ""}`);
140
+ }
141
+ lines.push("");
142
+ lines.push(
143
+ "✗ auth_rejected / shape_changed = the client's request contract likely changed — review the headers/parser.",
144
+ );
145
+ return lines.join("\n");
146
+ }
147
+
148
+ /** `devtools cursor-key set|clear|status` — store the machine-local Cursor API key. */
149
+ function registerCursorKeyCommand(parent: Command, emit: EmitContext): void {
150
+ const key = parent
151
+ .command("cursor-key")
152
+ .description("Manage the Cursor API key used for the Cloud Agent enrichment");
153
+
154
+ key
155
+ .command("set [value]")
156
+ .description("Store a Cursor API key (reads stdin when [value] is omitted)")
157
+ .action(async (value: string | undefined) => {
158
+ const raw = value ?? (await readStdin());
159
+ const trimmed = raw.trim();
160
+ if (!trimmed) {
161
+ emit.error({ code: "empty_key", message: "no key provided (arg or stdin)" });
162
+ return;
163
+ }
164
+ const path = cursorApiKeyPath();
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ writeFileSync(path, trimmed, { mode: 0o600 });
167
+ chmodSync(path, 0o600);
168
+ emit.file(path, { stored: true, length: trimmed.length });
169
+ });
170
+
171
+ key
172
+ .command("clear")
173
+ .description("Remove the stored Cursor API key")
174
+ .action(() => {
175
+ try {
176
+ rmSync(cursorApiKeyPath());
177
+ } catch {
178
+ // already absent
179
+ }
180
+ emit.data({ ok: true, cleared: true });
181
+ });
182
+
183
+ key
184
+ .command("status")
185
+ .description("Report whether a Cursor key is configured (env or file)")
186
+ .action(() => {
187
+ const fromEnv = Boolean(process.env.CURSOR_API_KEY?.trim());
188
+ const resolved = resolveCursorApiKey();
189
+ emit.data({
190
+ ok: true,
191
+ configured: Boolean(resolved),
192
+ source: fromEnv ? "env" : resolved ? "file" : null,
193
+ path: cursorApiKeyPath(),
194
+ });
195
+ });
196
+ }
197
+
198
+ function readStdin(): Promise<string> {
199
+ return new Promise((resolve) => {
200
+ let data = "";
201
+ if (process.stdin.isTTY) {
202
+ resolve("");
203
+ return;
204
+ }
205
+ process.stdin.setEncoding("utf8");
206
+ process.stdin.on("data", (c) => {
207
+ data += c;
208
+ });
209
+ process.stdin.on("end", () => resolve(data));
210
+ });
211
+ }
212
+
213
+ function collect(value: string, prev: string[]): string[] {
214
+ return [...prev, value];
215
+ }
216
+
217
+ function renderTable(tools: ToolStatus[]): string {
218
+ const lines: string[] = [];
219
+ for (const t of tools) {
220
+ lines.push(`── ${t.tool} ${"─".repeat(Math.max(0, 40 - t.tool.length))}`);
221
+ if (!t.installed) {
222
+ lines.push(" not installed");
223
+ lines.push("");
224
+ continue;
225
+ }
226
+ lines.push(` logged in ${fmtBool(t.loggedIn)}`);
227
+ if (t.account) lines.push(` account ${t.account}`);
228
+ if (t.plan) lines.push(` plan ${t.plan}`);
229
+ if (t.rateLimitTier) lines.push(` rate tier ${t.rateLimitTier}`);
230
+ if (t.authExpiresAt) lines.push(` auth expires ${fmtDate(t.authExpiresAt)}`);
231
+ if (t.sessions != null) lines.push(` sessions ${t.sessions}`);
232
+ if (t.lastActivity) lines.push(` last active ${fmtDate(t.lastActivity)}`);
233
+ if (t.quota?.length) {
234
+ for (const q of t.quota) {
235
+ const pct = q.usedPercent != null ? `${q.usedPercent}% used` : "usage unknown";
236
+ const reset = q.resetsAt ? `resets ${fmtDate(q.resetsAt)}` : "";
237
+ lines.push(` quota (${q.window}) ${pct}${reset ? ` · ${reset}` : ""}`);
238
+ }
239
+ }
240
+ if (t.tokensUsed != null) {
241
+ lines.push(` tokens ${t.tokensUsed.toLocaleString()}`);
242
+ }
243
+ if (t.usage) {
244
+ const u = t.usage;
245
+ if (u.cycleEnd) {
246
+ const days = Math.round((Date.parse(u.cycleEnd) - Date.now()) / 86_400_000);
247
+ lines.push(` plan resets ${fmtDate(u.cycleEnd)} (${days}d)`);
248
+ }
249
+ const pct = (label: string, v: number | null) => (v != null ? `${label} ${v}%` : null);
250
+ const bars = [
251
+ pct("total", u.totalPercentUsed),
252
+ pct("api", u.apiPercentUsed),
253
+ pct("first-party", u.firstPartyPercentUsed),
254
+ ].filter((s): s is string => s != null);
255
+ if (bars.length) lines.push(` usage ${bars.join(" · ")}`);
256
+ }
257
+ if (t.spend?.limitCents != null) {
258
+ const label = t.spend.label.toLowerCase().padEnd(12).slice(0, 12);
259
+ lines.push(` ${label} ${fmtUsd(t.spend.usedCents ?? 0)} / ${fmtUsd(t.spend.limitCents)}`);
260
+ }
261
+ if (t.api?.ok && t.api.cloudAgents) {
262
+ lines.push(` cloud agents ${t.api.cloudAgents.total} (${t.api.cloudAgents.active} active)`);
263
+ }
264
+ for (const n of t.notes) lines.push(` · ${n}`);
265
+ lines.push("");
266
+ }
267
+ return lines.join("\n").trimEnd();
268
+ }
269
+
270
+ function fmtBool(v: boolean | null): string {
271
+ if (v === true) return "yes";
272
+ if (v === false) return "no";
273
+ return "unknown";
274
+ }
275
+
276
+ function fmtDate(iso: string): string {
277
+ // Keep the machine-readable ISO but drop milliseconds for readability.
278
+ return iso.replace(/\.\d{3}Z$/, "Z");
279
+ }
280
+
281
+ /** Cents → "$X.XX". */
282
+ function fmtUsd(cents: number): string {
283
+ return `$${(cents / 100).toFixed(2)}`;
284
+ }
@@ -1,8 +1,13 @@
1
1
  import type { Command } from "commander";
2
2
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
3
3
  import { initDocsContext as initDocs, scanDocs } from "../lib/docs.ts";
4
+ import {
5
+ initDocsMigrationContext,
6
+ runFrontmatterMigration,
7
+ } from "../lib/docs-frontmatter-migrate.ts";
4
8
  import { initDocsContext as initDocsIndex, runIndex } from "../lib/docs-index.ts";
5
9
  import { initDocsContext as initDocsLint, runLint } from "../lib/docs-lint.ts";
10
+ import { readDocsMetadata, readDocsMetadataKey } from "../lib/docs-meta.ts";
6
11
  import {
7
12
  countColdHandoffs,
8
13
  initDocsContext as initDocsSweep,
@@ -15,8 +20,13 @@ function ensureContext(context: HarneryProgramContext | undefined): void {
15
20
  }
16
21
  const opts = { repoRoot: context.repoRoot, submodules: context.submodules };
17
22
  initDocs(opts);
23
+ initDocsMigrationContext(opts);
18
24
  initDocsIndex(opts);
19
- initDocsLint({ ...opts, extraExcludedPrefixes: context.extraDocsExcludedPrefixes });
25
+ initDocsLint({
26
+ ...opts,
27
+ extraExcludedPrefixes: context.extraDocsExcludedPrefixes,
28
+ docsRootAllowlist: context.docsRootAllowlist,
29
+ });
20
30
  initDocsSweep(opts);
21
31
  }
22
32
 
@@ -30,7 +40,7 @@ export function registerDocsCommand(
30
40
  emit = emitParam;
31
41
  const docs = program
32
42
  .command("docs")
33
- .description("Documentation tooling: freshness report, lint, sweep, index")
43
+ .description("Documentation tooling: freshness report, metadata, lint, sweep, index")
34
44
  // Options on the group itself back the default (no-subcommand) behavior.
35
45
  // See handleDocs below.
36
46
  .option("--stale <days>", "Only show files not committed in N+ days", Number.parseInt)
@@ -56,6 +66,37 @@ export function registerDocsCommand(
56
66
  },
57
67
  );
58
68
 
69
+ docs
70
+ .command("meta")
71
+ .description("Read YAML frontmatter from a documentation file")
72
+ .argument("<path>", "Markdown file path, relative to the project root or absolute")
73
+ .argument("[key]", "Optional top-level frontmatter key")
74
+ .option("--json", "Emit a requested key as JSON even in an interactive terminal")
75
+ .action(async (path: string, key: string | undefined, opts: { json?: boolean }) => {
76
+ try {
77
+ ensureContext(context);
78
+ handleMeta(context!.repoRoot!, path, key, opts);
79
+ } catch (err: unknown) {
80
+ const msg = err instanceof Error ? err.message : String(err);
81
+ emit.error({ code: "docs_error", message: msg });
82
+ }
83
+ });
84
+
85
+ docs
86
+ .command("frontmatter-migrate")
87
+ .description("Convert lifecycle docs from bold metadata headers to YAML frontmatter")
88
+ .option("--repo <name>", "Limit to one submodule or '.' for parent")
89
+ .option("--yes", "Apply changes; without this flag the command is a dry-run")
90
+ .action(async (opts: { repo?: string; yes?: boolean }) => {
91
+ try {
92
+ ensureContext(context);
93
+ handleFrontmatterMigration(opts);
94
+ } catch (err: unknown) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ emit.error({ code: "docs_error", message: msg });
97
+ }
98
+ });
99
+
59
100
  docs
60
101
  .command("lint")
61
102
  .description(
@@ -107,6 +148,49 @@ export function registerDocsCommand(
107
148
  });
108
149
  }
109
150
 
151
+ // --- `harn docs frontmatter-migrate` ---
152
+
153
+ function handleFrontmatterMigration(opts: { repo?: string; yes?: boolean }): void {
154
+ const rows = runFrontmatterMigration({ repo: opts.repo, apply: !!opts.yes });
155
+ const counts = {
156
+ would_update: rows.filter((row) => row.status === "would-update").length,
157
+ updated: rows.filter((row) => row.status === "updated").length,
158
+ skipped: rows.filter((row) => row.status === "skipped").length,
159
+ errors: rows.filter((row) => row.status === "error").length,
160
+ };
161
+ emit.data({
162
+ dry_run: !opts.yes,
163
+ applied: !!opts.yes && counts.errors === 0,
164
+ aborted: !!opts.yes && counts.errors > 0,
165
+ repo: opts.repo ?? null,
166
+ counts,
167
+ rows,
168
+ });
169
+ if (counts.errors > 0) emit.setExitCode(1);
170
+ }
171
+
172
+ // --- `harn docs meta` ---
173
+
174
+ function handleMeta(
175
+ repoRoot: string,
176
+ path: string,
177
+ key: string | undefined,
178
+ opts: { json?: boolean },
179
+ ): void {
180
+ const metadata = readDocsMetadata(repoRoot, path).data;
181
+ if (!key) {
182
+ emit.data(metadata);
183
+ return;
184
+ }
185
+
186
+ const value = readDocsMetadataKey(metadata, key, path);
187
+ if (opts.json || !process.stdout.isTTY || typeof value === "object") {
188
+ emit.data(value);
189
+ return;
190
+ }
191
+ emit.text(String(value));
192
+ }
193
+
110
194
  // --- Default `harn docs` (freshness report) ---
111
195
 
112
196
  async function handleDocs(opts: {
@@ -251,16 +251,25 @@ function checkHarnessHooks(): Check {
251
251
  const bin = resolveBinName(root);
252
252
  const parts = drift.map((d) => {
253
253
  const bits: string[] = [];
254
+ if (d.parseError) bits.push(`invalid JSON (${d.parseError})`);
254
255
  if (d.missing.length > 0) {
255
256
  bits.push(`${d.missing.length} missing (${d.missing.map((m) => m.subcommand).join(", ")})`);
256
257
  }
257
258
  if (d.orphans.length > 0) bits.push(`${d.orphans.length} orphaned (${d.orphans.join(", ")})`);
259
+ if (d.invalidTopLevelKeys.length > 0) {
260
+ bits.push(`invalid fields (${d.invalidTopLevelKeys.join(", ")})`);
261
+ }
262
+ if (d.invalidEventKeys.length > 0) {
263
+ bits.push(`unsupported events (${d.invalidEventKeys.join(", ")})`);
264
+ }
258
265
  return `${d.settingsFile}: ${bits.join("; ")}`;
259
266
  });
260
- const hasOrphans = drift.some((d) => d.orphans.length > 0);
261
- const hint = hasOrphans
262
- ? `run \`${bin} init\` to wire missing hooks; remove orphaned entries (renamed/dropped events) with \`${bin} deinit\` then \`${bin} init\``
263
- : `run \`${bin} init\` to wire the new hook(s) (idempotent)`;
267
+ const needsManualRepair = drift.some(
268
+ (d) => d.parseError || d.invalidTopLevelKeys.length > 0 || d.invalidEventKeys.length > 0,
269
+ );
270
+ const hint = needsManualRepair
271
+ ? `repair the invalid harness settings, then run \`${bin} init\` to migrate harnery hooks`
272
+ : `run \`${bin} init\` to migrate the hook set (idempotent)`;
264
273
  return { name: "harness hooks", severity: "warn", detail: parts.join(" | "), hint };
265
274
  }
266
275
 
@@ -1,11 +1,13 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { Command } from "commander";
4
- import type { EmitContext, HarneryProgramContext } from "../commander.ts";
4
+ import type { EmitContext, EnvCheck, EnvSection, HarneryProgramContext } from "../commander.ts";
5
5
  import { exec, sh } from "../lib/exec.ts";
6
6
 
7
7
  /**
8
- * `env`: show environment status across runtimes, docker, gcp, bq, git.
8
+ * `env`: show environment status across the generic sections (runtimes,
9
+ * docker, git) plus any sections the host registers via `context.envSections`.
10
+ * harnery core carries no provider-specific checks (a host adds e.g. gcp/bq).
9
11
  *
10
12
  * Monorepo state (repoRoot, submodules) flows in via HarneryProgramContext.
11
13
  * When neither is provided (harn invoked outside a monorepo), the git section
@@ -16,11 +18,9 @@ import { exec, sh } from "../lib/exec.ts";
16
18
  * metadata; defaultEmit just JSON-stringifies.
17
19
  */
18
20
 
19
- interface Check {
20
- label: string;
21
- value: string;
22
- status?: "ok" | "missing" | "warn" | "info";
23
- }
21
+ // Internal alias for the shared row type (exported from commander.ts so hosts
22
+ // can type their own `envSections`).
23
+ type Check = EnvCheck;
24
24
 
25
25
  export function registerEnvCommand(
26
26
  program: Command,
@@ -29,7 +29,7 @@ export function registerEnvCommand(
29
29
  ): void {
30
30
  program
31
31
  .command("env [section]")
32
- .description("Show environment status (docker, gcp, bq, node, python, git)")
32
+ .description("Show environment status (runtimes, docker, git; hosts can add sections)")
33
33
  .action(async (section?: string) => {
34
34
  try {
35
35
  await handleEnv(section, emit, context);
@@ -46,12 +46,12 @@ async function handleEnv(
46
46
  emit: EmitContext,
47
47
  context: HarneryProgramContext | undefined,
48
48
  ): Promise<void> {
49
- const sections: Record<string, () => Promise<Check[]>> = {
49
+ const sections: Record<string, EnvSection> = {
50
50
  runtimes: checkRuntimes,
51
51
  docker: checkDocker,
52
- gcp: checkGcp,
53
- bq: checkBigQuery,
54
52
  git: () => checkGit(context),
53
+ // Host-registered sections (e.g. gcp/bq) merge in after the generic ones.
54
+ ...(context?.envSections ?? {}),
55
55
  };
56
56
 
57
57
  if (section) {
@@ -153,72 +153,6 @@ async function checkDocker(): Promise<Check[]> {
153
153
  return checks;
154
154
  }
155
155
 
156
- async function checkGcp(): Promise<Check[]> {
157
- const checks: Check[] = [];
158
-
159
- const account = await sh("gcloud config get-value account 2>/dev/null").catch(() => ({
160
- stdout: "",
161
- exitCode: 1,
162
- stderr: "",
163
- }));
164
- const project = await sh("gcloud config get-value project 2>/dev/null").catch(() => ({
165
- stdout: "",
166
- exitCode: 1,
167
- stderr: "",
168
- }));
169
-
170
- if (account.exitCode === 0 && account.stdout) {
171
- checks.push({ label: "GCP Account", value: account.stdout, status: "ok" });
172
- } else {
173
- checks.push({ label: "GCP Account", value: "not authenticated", status: "missing" });
174
- }
175
-
176
- if (project.exitCode === 0 && project.stdout) {
177
- checks.push({ label: "GCP Project", value: project.stdout, status: "ok" });
178
- } else {
179
- checks.push({ label: "GCP Project", value: "not set", status: "missing" });
180
- }
181
-
182
- return checks;
183
- }
184
-
185
- async function checkBigQuery(): Promise<Check[]> {
186
- const checks: Check[] = [];
187
-
188
- const result = await sh("bq ls --max_results=1 2>/dev/null").catch(() => ({
189
- stdout: "",
190
- exitCode: 1,
191
- stderr: "",
192
- }));
193
-
194
- if (result.exitCode === 0) {
195
- checks.push({ label: "BigQuery", value: "connected", status: "ok" });
196
- } else {
197
- checks.push({ label: "BigQuery", value: "not connected (check GCP auth)", status: "missing" });
198
- }
199
-
200
- const datasets = await sh("bq ls --format=json --max_results=20 2>/dev/null").catch(() => ({
201
- stdout: "",
202
- exitCode: 1,
203
- stderr: "",
204
- }));
205
-
206
- if (datasets.exitCode === 0 && datasets.stdout) {
207
- try {
208
- const ds = JSON.parse(datasets.stdout) as { datasetReference?: { datasetId?: string } }[];
209
- const names = ds
210
- .map((d) => d.datasetReference?.datasetId)
211
- .filter(Boolean)
212
- .join(", ");
213
- if (names) checks.push({ label: "Datasets", value: names, status: "info" });
214
- } catch {
215
- // Ignore parse failures
216
- }
217
- }
218
-
219
- return checks;
220
- }
221
-
222
156
  async function checkGit(context: HarneryProgramContext | undefined): Promise<Check[]> {
223
157
  const checks: Check[] = [];
224
158
  const cwd = context?.repoRoot ?? process.cwd();
@@ -80,7 +80,7 @@ async function runFetch(
80
80
 
81
81
  const jar =
82
82
  opts.cookies !== false
83
- ? new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "bp-fetch" })
83
+ ? new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-fetch" })
84
84
  : null;
85
85
 
86
86
  const timeoutMs = Number.parseInt(opts.timeout, 10);