harnery 0.6.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.
- package/README.md +16 -6
- package/dist/commander.d.ts +19 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +51 -4
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +69 -1
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +4 -0
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -18
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +2 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +18 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +14 -9
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +7 -6
- package/src/commander.ts +23 -0
- package/src/commands/agents.ts +50 -3
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +81 -1
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/emit.ts +5 -0
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/config.ts +14 -0
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -17
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +17 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +14 -9
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /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
|
+
}
|
package/src/commands/docs.ts
CHANGED
|
@@ -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,6 +20,7 @@ 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
25
|
initDocsLint({
|
|
20
26
|
...opts,
|
|
@@ -34,7 +40,7 @@ export function registerDocsCommand(
|
|
|
34
40
|
emit = emitParam;
|
|
35
41
|
const docs = program
|
|
36
42
|
.command("docs")
|
|
37
|
-
.description("Documentation tooling: freshness report, lint, sweep, index")
|
|
43
|
+
.description("Documentation tooling: freshness report, metadata, lint, sweep, index")
|
|
38
44
|
// Options on the group itself back the default (no-subcommand) behavior.
|
|
39
45
|
// See handleDocs below.
|
|
40
46
|
.option("--stale <days>", "Only show files not committed in N+ days", Number.parseInt)
|
|
@@ -60,6 +66,37 @@ export function registerDocsCommand(
|
|
|
60
66
|
},
|
|
61
67
|
);
|
|
62
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
|
+
|
|
63
100
|
docs
|
|
64
101
|
.command("lint")
|
|
65
102
|
.description(
|
|
@@ -111,6 +148,49 @@ export function registerDocsCommand(
|
|
|
111
148
|
});
|
|
112
149
|
}
|
|
113
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
|
+
|
|
114
194
|
// --- Default `harn docs` (freshness report) ---
|
|
115
195
|
|
|
116
196
|
async function handleDocs(opts: {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
package/src/commands/env.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (
|
|
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,
|
|
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();
|