@pimmesz/afterburner 1.0.1
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/.env.example +16 -0
- package/LICENSE +201 -0
- package/README.md +294 -0
- package/afterburner.config.example.ts +94 -0
- package/assets/afterburner-logo.png +0 -0
- package/dist/chunk-2NSOEZWY.js +11 -0
- package/dist/chunk-OZAFLQDP.js +1098 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1524 -0
- package/dist/core/index.d.ts +781 -0
- package/dist/core/index.js +96 -0
- package/dist/mcp/server.d.ts +13 -0
- package/dist/mcp/server.js +8 -0
- package/package.json +75 -0
- package/skill/SKILL.md +38 -0
|
@@ -0,0 +1,1524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ConsoleNotifier,
|
|
4
|
+
JsonlRunStore,
|
|
5
|
+
ManualBudgetProvider,
|
|
6
|
+
budgetFromUsageCache,
|
|
7
|
+
claudeConfigDir,
|
|
8
|
+
createBudgetProvider,
|
|
9
|
+
createRunner,
|
|
10
|
+
createSelector,
|
|
11
|
+
dataDir,
|
|
12
|
+
defaultClaudeProjectsDir,
|
|
13
|
+
defaultUsageCachePath,
|
|
14
|
+
findConfigPath,
|
|
15
|
+
generateScheduleArtifacts,
|
|
16
|
+
liveDowngradeReason,
|
|
17
|
+
loadConfig,
|
|
18
|
+
looksRemoteRepoUrl,
|
|
19
|
+
readUsageCache,
|
|
20
|
+
runOnce,
|
|
21
|
+
startWatch,
|
|
22
|
+
writeUsageCache
|
|
23
|
+
} from "../chunk-OZAFLQDP.js";
|
|
24
|
+
import {
|
|
25
|
+
MCP_STUB_MESSAGE
|
|
26
|
+
} from "../chunk-2NSOEZWY.js";
|
|
27
|
+
|
|
28
|
+
// src/cli/index.ts
|
|
29
|
+
import { Command } from "commander";
|
|
30
|
+
|
|
31
|
+
// package.json
|
|
32
|
+
var package_default = {
|
|
33
|
+
name: "@pimmesz/afterburner",
|
|
34
|
+
version: "1.0.1",
|
|
35
|
+
description: "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
|
|
36
|
+
license: "Apache-2.0",
|
|
37
|
+
publishConfig: {
|
|
38
|
+
access: "public"
|
|
39
|
+
},
|
|
40
|
+
type: "module",
|
|
41
|
+
packageManager: "pnpm@11.5.3",
|
|
42
|
+
engines: {
|
|
43
|
+
node: ">=22.12"
|
|
44
|
+
},
|
|
45
|
+
bin: {
|
|
46
|
+
afterburner: "./dist/cli/index.js",
|
|
47
|
+
abr: "./dist/cli/index.js"
|
|
48
|
+
},
|
|
49
|
+
main: "./dist/core/index.js",
|
|
50
|
+
types: "./dist/core/index.d.ts",
|
|
51
|
+
exports: {
|
|
52
|
+
".": {
|
|
53
|
+
types: "./dist/core/index.d.ts",
|
|
54
|
+
import: "./dist/core/index.js",
|
|
55
|
+
default: "./dist/core/index.js"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
files: [
|
|
59
|
+
"dist",
|
|
60
|
+
"skill",
|
|
61
|
+
"assets/afterburner-logo.png",
|
|
62
|
+
"afterburner.config.example.ts",
|
|
63
|
+
".env.example"
|
|
64
|
+
],
|
|
65
|
+
scripts: {
|
|
66
|
+
build: "tsup",
|
|
67
|
+
prepack: "pnpm build",
|
|
68
|
+
dev: "tsx src/cli/index.ts",
|
|
69
|
+
lint: "eslint . && prettier --check .",
|
|
70
|
+
format: "prettier --write .",
|
|
71
|
+
typecheck: "tsc --noEmit",
|
|
72
|
+
test: "vitest run",
|
|
73
|
+
"test:watch": "vitest"
|
|
74
|
+
},
|
|
75
|
+
repository: {
|
|
76
|
+
type: "git",
|
|
77
|
+
url: "https://github.com/pimmesz/afterburner.git"
|
|
78
|
+
},
|
|
79
|
+
keywords: [
|
|
80
|
+
"claude",
|
|
81
|
+
"claude-code",
|
|
82
|
+
"automation",
|
|
83
|
+
"quota",
|
|
84
|
+
"agent",
|
|
85
|
+
"pull-request"
|
|
86
|
+
],
|
|
87
|
+
dependencies: {
|
|
88
|
+
commander: "^15.0.0",
|
|
89
|
+
cosmiconfig: "^9.0.2",
|
|
90
|
+
"env-paths": "^4.0.0",
|
|
91
|
+
"node-cron": "^4.2.1",
|
|
92
|
+
zod: "^4.4.3"
|
|
93
|
+
},
|
|
94
|
+
devDependencies: {
|
|
95
|
+
"@eslint/js": "^10.0.1",
|
|
96
|
+
"@types/node": "^25.9.2",
|
|
97
|
+
eslint: "^10.4.1",
|
|
98
|
+
"eslint-config-prettier": "^10.1.8",
|
|
99
|
+
prettier: "^3.8.4",
|
|
100
|
+
tsup: "^8.5.1",
|
|
101
|
+
tsx: "^4.22.4",
|
|
102
|
+
typescript: "^6.0.3",
|
|
103
|
+
"typescript-eslint": "^8.61.0",
|
|
104
|
+
vitest: "^4.1.8"
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/cli/ui.ts
|
|
109
|
+
var noColor = !!process.env.NO_COLOR || process.env.TERM === "dumb";
|
|
110
|
+
var fancy = process.stdout.isTTY === true && !noColor;
|
|
111
|
+
var fancyErr = process.stderr.isTTY === true && !noColor;
|
|
112
|
+
var canAnimate = fancyErr && !process.env.CI;
|
|
113
|
+
var paint = (enabled) => (open, close) => (s) => enabled ? `\x1B[${open}m${s}\x1B[${close}m` : s;
|
|
114
|
+
var wrap = paint(fancy);
|
|
115
|
+
var bold = wrap(1, 22);
|
|
116
|
+
var dim = wrap(2, 22);
|
|
117
|
+
var red = wrap(31, 39);
|
|
118
|
+
var green = wrap(32, 39);
|
|
119
|
+
var yellow = wrap(33, 39);
|
|
120
|
+
var cyan = wrap(36, 39);
|
|
121
|
+
var flame = (s) => fancy ? `\x1B[38;5;208m${s}\x1B[39m` : s;
|
|
122
|
+
var errRed = paint(fancyErr)(31, 39);
|
|
123
|
+
var errYellow = paint(fancyErr)(33, 39);
|
|
124
|
+
var flameErr = (s) => fancyErr ? `\x1B[38;5;208m${s}\x1B[39m` : s;
|
|
125
|
+
var emoji = {
|
|
126
|
+
jet: "\u2708\uFE0F",
|
|
127
|
+
// ✈️
|
|
128
|
+
rocket: "\u{1F680}",
|
|
129
|
+
// 🚀
|
|
130
|
+
flame: "\u{1F525}",
|
|
131
|
+
// 🔥
|
|
132
|
+
thrust: "\u{1F4A8}",
|
|
133
|
+
// 💨
|
|
134
|
+
fuel: "\u26FD"
|
|
135
|
+
// ⛽
|
|
136
|
+
};
|
|
137
|
+
function deco(glyph) {
|
|
138
|
+
return fancy ? `${glyph} ` : "";
|
|
139
|
+
}
|
|
140
|
+
function section(glyph, title) {
|
|
141
|
+
return `${deco(glyph)}${bold(title)}`;
|
|
142
|
+
}
|
|
143
|
+
function step(label, value) {
|
|
144
|
+
return `${green("\u2713")} ${bold(label)}: ${value}`;
|
|
145
|
+
}
|
|
146
|
+
function nextCmd(cmd, note) {
|
|
147
|
+
const line = ` ${bold(flame(cmd))}`;
|
|
148
|
+
return note ? `${line}
|
|
149
|
+
${dim(note)}` : line;
|
|
150
|
+
}
|
|
151
|
+
function cmdRow(cmd, note, width = 32) {
|
|
152
|
+
return ` ${bold(cmd.padEnd(width))}${dim(note)}`;
|
|
153
|
+
}
|
|
154
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
155
|
+
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
156
|
+
function spinner(text) {
|
|
157
|
+
if (!canAnimate) {
|
|
158
|
+
process.stderr.write(`${text}
|
|
159
|
+
`);
|
|
160
|
+
return { update: () => {
|
|
161
|
+
}, stop: (final) => final && process.stderr.write(`${final}
|
|
162
|
+
`) };
|
|
163
|
+
}
|
|
164
|
+
let current = text;
|
|
165
|
+
let i = 0;
|
|
166
|
+
const render = () => {
|
|
167
|
+
i = (i + 1) % SPINNER_FRAMES.length;
|
|
168
|
+
process.stderr.write(`\r${flameErr(SPINNER_FRAMES[i] ?? "")} ${current}\x1B[K`);
|
|
169
|
+
};
|
|
170
|
+
const id = setInterval(render, 80);
|
|
171
|
+
render();
|
|
172
|
+
return {
|
|
173
|
+
update: (t) => {
|
|
174
|
+
current = t;
|
|
175
|
+
},
|
|
176
|
+
stop: (final) => {
|
|
177
|
+
clearInterval(id);
|
|
178
|
+
process.stderr.write("\r\x1B[K");
|
|
179
|
+
if (final) process.stderr.write(`${final}
|
|
180
|
+
`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function spool() {
|
|
185
|
+
if (!canAnimate) return;
|
|
186
|
+
const trail = ["", "\xB7", "\xB7\xB7", emoji.thrust, `${emoji.thrust}${emoji.flame}`];
|
|
187
|
+
for (const t of trail) {
|
|
188
|
+
process.stderr.write(`\r${emoji.jet} ${flameErr(t)}\x1B[K`);
|
|
189
|
+
await sleep(55);
|
|
190
|
+
}
|
|
191
|
+
process.stderr.write("\r\x1B[K");
|
|
192
|
+
}
|
|
193
|
+
async function ignite() {
|
|
194
|
+
if (!canAnimate) return;
|
|
195
|
+
const burst = [
|
|
196
|
+
`${emoji.jet} ${flameErr(emoji.thrust)}`,
|
|
197
|
+
`${emoji.jet} ${flameErr(emoji.thrust + emoji.flame)}`,
|
|
198
|
+
`${emoji.jet} ${flameErr(emoji.thrust + emoji.flame + emoji.flame)}`,
|
|
199
|
+
`${emoji.jet}${flameErr(emoji.flame + emoji.flame + emoji.flame)}`
|
|
200
|
+
];
|
|
201
|
+
for (const frame of burst) {
|
|
202
|
+
process.stderr.write(`\r${frame}\x1B[K`);
|
|
203
|
+
await sleep(70);
|
|
204
|
+
}
|
|
205
|
+
process.stderr.write("\r\x1B[K");
|
|
206
|
+
}
|
|
207
|
+
function banner() {
|
|
208
|
+
const title = `${deco(emoji.jet)}${flame(bold("afterburner"))}`;
|
|
209
|
+
return `${title}
|
|
210
|
+
${dim(" idle Claude quota, turned into reviewed pull requests")}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/cli/commands/doctor.ts
|
|
214
|
+
import { spawnSync } from "child_process";
|
|
215
|
+
import { existsSync as existsSync2, statSync } from "fs";
|
|
216
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
217
|
+
import { isAbsolute, join, resolve as resolve2 } from "path";
|
|
218
|
+
|
|
219
|
+
// src/cli/helpers.ts
|
|
220
|
+
import { existsSync } from "fs";
|
|
221
|
+
import { basename, dirname, resolve } from "path";
|
|
222
|
+
async function loadConfigOrExit(configPath) {
|
|
223
|
+
try {
|
|
224
|
+
return await loadConfig(configPath);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(errRed(error instanceof Error ? error.message : String(error)));
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function fail(message) {
|
|
231
|
+
console.error(errRed(message));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
var DISCOVERABLE_CONFIG_NAMES = /* @__PURE__ */ new Set([
|
|
235
|
+
"package.json",
|
|
236
|
+
".afterburnerrc",
|
|
237
|
+
".afterburnerrc.json",
|
|
238
|
+
".afterburnerrc.yaml",
|
|
239
|
+
".afterburnerrc.yml",
|
|
240
|
+
".afterburnerrc.js",
|
|
241
|
+
".afterburnerrc.ts",
|
|
242
|
+
".afterburnerrc.mjs",
|
|
243
|
+
".afterburnerrc.cjs",
|
|
244
|
+
"afterburner.config.js",
|
|
245
|
+
"afterburner.config.ts",
|
|
246
|
+
"afterburner.config.mjs",
|
|
247
|
+
"afterburner.config.cjs"
|
|
248
|
+
]);
|
|
249
|
+
function suggestCommand(sub, configPath, cwd = process.cwd()) {
|
|
250
|
+
const absolute = resolve(configPath);
|
|
251
|
+
if (dirname(absolute) === resolve(cwd) && DISCOVERABLE_CONFIG_NAMES.has(basename(absolute))) {
|
|
252
|
+
return `afterburner ${sub}`;
|
|
253
|
+
}
|
|
254
|
+
const quoted = /\s/.test(configPath) ? `"${configPath}"` : configPath;
|
|
255
|
+
return `afterburner ${sub} --config ${quoted}`;
|
|
256
|
+
}
|
|
257
|
+
function formatTokens(tokens) {
|
|
258
|
+
return tokens.toLocaleString("en-US");
|
|
259
|
+
}
|
|
260
|
+
function resolveCliEntry() {
|
|
261
|
+
const entry = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
262
|
+
if (!entry || !existsSync(entry)) {
|
|
263
|
+
fail("Could not resolve the afterburner CLI entry script, is the installation intact?");
|
|
264
|
+
}
|
|
265
|
+
if (entry.endsWith(".ts")) {
|
|
266
|
+
fail(
|
|
267
|
+
"This command must run from the built CLI (it embeds a node-runnable path). Run `pnpm build` and use `node dist/cli/index.js ...`, or install the package."
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return entry;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/cli/commands/doctor.ts
|
|
274
|
+
function registerDoctor(program2, packageInfo2) {
|
|
275
|
+
program2.command("doctor").description("Check prerequisites and configuration; every failure prints an actionable fix").option("--config <path>", "path to a config file").option("--check-updates", "query the npm registry for the latest published version").action(
|
|
276
|
+
(opts) => runDoctor({ packageInfo: packageInfo2, configPath: opts.config, checkUpdates: opts.checkUpdates })
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
async function runDoctor(opts) {
|
|
280
|
+
console.log(`${deco(emoji.fuel)}${bold("afterburner doctor")}`);
|
|
281
|
+
const results = [];
|
|
282
|
+
let latest;
|
|
283
|
+
if (opts.checkUpdates) {
|
|
284
|
+
const spin = spinner("checking npm for the latest version\u2026");
|
|
285
|
+
latest = await fetchLatestNpmVersion(opts.packageInfo.packageName);
|
|
286
|
+
spin.stop();
|
|
287
|
+
} else {
|
|
288
|
+
latest = { status: "skipped" };
|
|
289
|
+
}
|
|
290
|
+
results.push(
|
|
291
|
+
checkVersion({
|
|
292
|
+
...opts.packageInfo,
|
|
293
|
+
latest,
|
|
294
|
+
cliEntryPath: process.argv[1],
|
|
295
|
+
cwd: process.cwd()
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
results.push(checkNodeVersion());
|
|
299
|
+
results.push(checkGit());
|
|
300
|
+
let config;
|
|
301
|
+
let configPath;
|
|
302
|
+
try {
|
|
303
|
+
const loaded = await loadConfig(opts.configPath);
|
|
304
|
+
config = loaded.config;
|
|
305
|
+
configPath = loaded.filepath;
|
|
306
|
+
results.push({ name: "config", ok: true, detail: `valid (${loaded.filepath})` });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
309
|
+
results.push({
|
|
310
|
+
name: "config",
|
|
311
|
+
ok: false,
|
|
312
|
+
detail,
|
|
313
|
+
// Only suggest init when there is no config at all, for load/parse
|
|
314
|
+
// failures the detail above already carries the exact fix, and
|
|
315
|
+
// suggesting init for a config init just wrote would be circular.
|
|
316
|
+
...detail.startsWith("No Afterburner config found") ? { fix: "Run `afterburner init` to create one." } : {}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (config && configPath) {
|
|
320
|
+
results.push(checkRepos(config, configPath));
|
|
321
|
+
results.push(...checkBackend(config));
|
|
322
|
+
results.push(await checkBudgetProvider(config));
|
|
323
|
+
}
|
|
324
|
+
results.push(await checkRunStoreWritable());
|
|
325
|
+
let failures = 0;
|
|
326
|
+
for (const result of results) {
|
|
327
|
+
const mark = result.ok ? green("\u2713") : red("\u2717");
|
|
328
|
+
console.log(`${mark} ${bold(result.name)}: ${result.detail}`);
|
|
329
|
+
if (!result.ok) {
|
|
330
|
+
failures += 1;
|
|
331
|
+
if (result.fix) console.log(` ${dim("fix:")} ${yellow(result.fix)}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
console.log(
|
|
335
|
+
failures === 0 ? `
|
|
336
|
+
${green(`${deco(emoji.rocket)}All checks passed.`)}` : `
|
|
337
|
+
${red(`${failures} check(s) failed.`)}`
|
|
338
|
+
);
|
|
339
|
+
console.log(`
|
|
340
|
+
${renderDoctorNextSteps({ config, configPath, failures })}`);
|
|
341
|
+
process.exitCode = failures === 0 ? 0 : 1;
|
|
342
|
+
}
|
|
343
|
+
function renderDoctorNextSteps(opts) {
|
|
344
|
+
const next = section(emoji.rocket, "Next");
|
|
345
|
+
if (!opts.config || !opts.configPath) {
|
|
346
|
+
return `${next}
|
|
347
|
+
${nextCmd("afterburner init", "three questions; writes the config and takes it from there")}`;
|
|
348
|
+
}
|
|
349
|
+
const { config, configPath } = opts;
|
|
350
|
+
const cmd = (sub) => suggestCommand(sub, configPath, opts.cwd);
|
|
351
|
+
if (config.repos.length === 0) {
|
|
352
|
+
return `${next}
|
|
353
|
+
Add a repo to "repos" in ${configPath}, e.g.
|
|
354
|
+
{ url: '/absolute/path/to/repo', enabledTaskCategories: ['docs', 'tests'] },
|
|
355
|
+
then re-run:
|
|
356
|
+
${nextCmd(cmd("doctor"))}`;
|
|
357
|
+
}
|
|
358
|
+
if (opts.failures > 0) {
|
|
359
|
+
return `${next}
|
|
360
|
+
Fix the failed checks above (each prints its fix), then re-run:
|
|
361
|
+
${nextCmd(cmd("doctor"))}`;
|
|
362
|
+
}
|
|
363
|
+
const repoNames = config.repos.map((r) => r.url);
|
|
364
|
+
const repoLine = repoNames.length <= 2 ? repoNames.join(", ") : `${repoNames.slice(0, 2).join(", ")} (and ${repoNames.length - 2} more)`;
|
|
365
|
+
const engineLine = config.agent.backend === "dry-run" ? "dry-run \u2014 simulation only; set agent.backend: 'claude-code' in the config to do real work" : config.agent.backend === "claude-code" ? "claude-code \u2014 spends subscription quota you already pay for; PRs only with --live" : "api-key \u2014 bills your Anthropic API account per token (real money); PRs only with --live";
|
|
366
|
+
const budgetLine = config.budget.provider === "manual" ? "manual \u2014 trusts budget.manual (automatic option: `afterburner statusline install`, then budget.provider: 'claude-usage')" : config.budget.provider === "claude-usage" ? "claude-usage \u2014 reads your real usage via the status line hook" : "claude-code-transcripts \u2014 estimates from local Claude Code session logs";
|
|
367
|
+
const nextNote = config.agent.backend === "dry-run" ? "previews the next task; nothing is executed or spent" : `previews the next task (live execution ships in a future release; ${cmd("run-once --live")} currently validates and refuses)`;
|
|
368
|
+
return `${section(emoji.flame, "Ready")}
|
|
369
|
+
Config: ${configPath}
|
|
370
|
+
Repos: ${repoLine}
|
|
371
|
+
Engine: ${engineLine}
|
|
372
|
+
Budget: ${budgetLine}
|
|
373
|
+
Safety: live PRs need BOTH a live engine in the config AND --live (two-part opt-in)
|
|
374
|
+
Later: \`afterburner schedule install\` (recommended OS scheduler) or \`afterburner watch\` (foreground)
|
|
375
|
+
|
|
376
|
+
${next}
|
|
377
|
+
${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
|
|
378
|
+
}
|
|
379
|
+
function checkVersion(opts) {
|
|
380
|
+
const localUpdateCommand = "pnpm build && npm install -g .";
|
|
381
|
+
const npmUpdateCommand = `npm install -g ${opts.packageName}@latest`;
|
|
382
|
+
const isSourceRun = isPathInside(opts.cliEntryPath, opts.cwd);
|
|
383
|
+
if (opts.latest.status === "found" && opts.latest.latestVersion) {
|
|
384
|
+
const comparison = compareSemver(opts.latest.latestVersion, opts.currentVersion);
|
|
385
|
+
if (comparison > 0) {
|
|
386
|
+
return {
|
|
387
|
+
name: "version",
|
|
388
|
+
ok: false,
|
|
389
|
+
detail: `${opts.currentVersion} installed, latest is ${opts.latest.latestVersion}`,
|
|
390
|
+
fix: isSourceRun ? localUpdateCommand : npmUpdateCommand
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
name: "version",
|
|
395
|
+
ok: true,
|
|
396
|
+
detail: comparison === 0 ? `${opts.currentVersion} (latest)` : `${opts.currentVersion} (newer than npm latest ${opts.latest.latestVersion})`
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (opts.latest.status === "not-published") {
|
|
400
|
+
return {
|
|
401
|
+
name: "version",
|
|
402
|
+
ok: true,
|
|
403
|
+
detail: `${opts.currentVersion} (${opts.packageName} is not published to npm yet; source installs update with \`${localUpdateCommand}\`)`
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (opts.latest.status === "failed") {
|
|
407
|
+
return {
|
|
408
|
+
name: "version",
|
|
409
|
+
ok: true,
|
|
410
|
+
detail: `${opts.currentVersion} (could not check npm latest: ${opts.latest.detail ?? "unknown error"})`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
name: "version",
|
|
415
|
+
ok: true,
|
|
416
|
+
detail: isSourceRun ? `${opts.currentVersion} (source run; update with \`${localUpdateCommand}\`)` : `${opts.currentVersion} (check npm latest with \`afterburner doctor --check-updates\`)`
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async function fetchLatestNpmVersion(packageName) {
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
422
|
+
try {
|
|
423
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
|
|
424
|
+
signal: controller.signal,
|
|
425
|
+
headers: { accept: "application/json" }
|
|
426
|
+
});
|
|
427
|
+
if (response.status === 404) return { status: "not-published" };
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
return { status: "failed", detail: `npm registry returned HTTP ${response.status}` };
|
|
430
|
+
}
|
|
431
|
+
const body = await response.json();
|
|
432
|
+
const latestVersion = body["dist-tags"]?.latest;
|
|
433
|
+
return typeof latestVersion === "string" ? { status: "found", latestVersion } : { status: "failed", detail: "npm registry response did not include dist-tags.latest" };
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return {
|
|
436
|
+
status: "failed",
|
|
437
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
438
|
+
};
|
|
439
|
+
} finally {
|
|
440
|
+
clearTimeout(timeout);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function compareSemver(a, b) {
|
|
444
|
+
const [aMajor, aMinor, aPatch] = parseSemver(a);
|
|
445
|
+
const [bMajor, bMinor, bPatch] = parseSemver(b);
|
|
446
|
+
const differences = [aMajor - bMajor, aMinor - bMinor, aPatch - bPatch];
|
|
447
|
+
for (const difference of differences) {
|
|
448
|
+
if (difference !== 0) return difference;
|
|
449
|
+
}
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
function parseSemver(version) {
|
|
453
|
+
const stableVersion = version.split("-", 1)[0] ?? "0";
|
|
454
|
+
const [major = "0", minor = "0", patch = "0"] = stableVersion.split(".");
|
|
455
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
456
|
+
}
|
|
457
|
+
function isPathInside(candidate, parent) {
|
|
458
|
+
if (!candidate) return false;
|
|
459
|
+
const absoluteCandidate = resolve2(candidate);
|
|
460
|
+
const absoluteParent = resolve2(parent);
|
|
461
|
+
return absoluteCandidate === absoluteParent || absoluteCandidate.startsWith(`${absoluteParent}/`);
|
|
462
|
+
}
|
|
463
|
+
function checkNodeVersion() {
|
|
464
|
+
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
465
|
+
const ok = major > 22 || major === 22 && minor >= 12;
|
|
466
|
+
return ok ? { name: "node", ok: true, detail: `v${process.versions.node} (>= 22.12)` } : {
|
|
467
|
+
name: "node",
|
|
468
|
+
ok: false,
|
|
469
|
+
detail: `v${process.versions.node} is too old`,
|
|
470
|
+
fix: "Install Node.js 22.12 or newer (https://nodejs.org or your version manager)."
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function checkGit() {
|
|
474
|
+
const result = spawnSync("git", ["--version"], { encoding: "utf8" });
|
|
475
|
+
return result.status === 0 ? { name: "git", ok: true, detail: result.stdout.trim() } : {
|
|
476
|
+
name: "git",
|
|
477
|
+
ok: false,
|
|
478
|
+
detail: "git not found on PATH",
|
|
479
|
+
fix: "Install git (macOS: `xcode-select --install`, Debian/Ubuntu: `apt install git`, Windows: https://git-scm.com)."
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function checkRepos(config, configPath) {
|
|
483
|
+
if (config.repos.length === 0) {
|
|
484
|
+
return {
|
|
485
|
+
name: "repos",
|
|
486
|
+
ok: false,
|
|
487
|
+
detail: "none configured; run-once refuses to start without an allowlisted repo",
|
|
488
|
+
fix: `Add an entry to "repos" in ${configPath}, e.g. { url: '/absolute/path/to/repo', enabledTaskCategories: ['docs', 'tests'] }.`
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const problems = [];
|
|
492
|
+
for (const repo of config.repos) {
|
|
493
|
+
if (looksRemoteRepoUrl(repo.url)) continue;
|
|
494
|
+
if (!isAbsolute(repo.url)) {
|
|
495
|
+
problems.push(
|
|
496
|
+
`"${repo.url}" is not an absolute path; scheduled runs resolve it against a different working directory`
|
|
497
|
+
);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const stat = statSync(repo.url, { throwIfNoEntry: false });
|
|
501
|
+
if (!stat) {
|
|
502
|
+
problems.push(`"${repo.url}" does not exist on this machine`);
|
|
503
|
+
} else if (!stat.isDirectory()) {
|
|
504
|
+
problems.push(`"${repo.url}" is not a directory`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (problems.length > 0) {
|
|
508
|
+
return {
|
|
509
|
+
name: "repos",
|
|
510
|
+
ok: false,
|
|
511
|
+
detail: problems.join("; "),
|
|
512
|
+
fix: `Use absolute paths for local repos in ${configPath}.`
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
name: "repos",
|
|
517
|
+
ok: true,
|
|
518
|
+
detail: `${config.repos.length} configured (${config.repos.map((r) => r.url).join(", ")})`
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function checkBackend(config) {
|
|
522
|
+
const backend = config.agent.backend;
|
|
523
|
+
if (backend === "dry-run") {
|
|
524
|
+
return [
|
|
525
|
+
{ name: "engine", ok: true, detail: "dry-run (simulation only, no external requirements)" }
|
|
526
|
+
];
|
|
527
|
+
}
|
|
528
|
+
if (backend === "api-key") {
|
|
529
|
+
return [
|
|
530
|
+
process.env.ANTHROPIC_API_KEY ? { name: "engine", ok: true, detail: "api-key: ANTHROPIC_API_KEY is set" } : {
|
|
531
|
+
name: "engine",
|
|
532
|
+
ok: false,
|
|
533
|
+
detail: "api-key engine selected but ANTHROPIC_API_KEY is not set",
|
|
534
|
+
fix: "Export ANTHROPIC_API_KEY in your environment. Note this engine bills per token at API rates."
|
|
535
|
+
}
|
|
536
|
+
];
|
|
537
|
+
}
|
|
538
|
+
const results = [];
|
|
539
|
+
const version = spawnSync("claude", ["--version"], { encoding: "utf8" });
|
|
540
|
+
if (version.status !== 0) {
|
|
541
|
+
results.push({
|
|
542
|
+
name: "engine",
|
|
543
|
+
ok: false,
|
|
544
|
+
detail: "claude-code engine selected but the `claude` CLI is not on PATH",
|
|
545
|
+
fix: "Install Claude Code: `npm install -g @anthropic-ai/claude-code`, then run `claude /login`."
|
|
546
|
+
});
|
|
547
|
+
return results;
|
|
548
|
+
}
|
|
549
|
+
results.push({ name: "engine", ok: true, detail: `claude-code: ${version.stdout.trim()}` });
|
|
550
|
+
const auth = spawnSync("claude", ["auth", "status"], { encoding: "utf8" });
|
|
551
|
+
results.push(classifyClaudeAuth(auth.status, auth.stdout));
|
|
552
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
553
|
+
results.push({
|
|
554
|
+
name: "env",
|
|
555
|
+
ok: false,
|
|
556
|
+
detail: "ANTHROPIC_API_KEY is set: in headless mode it ALWAYS outranks your subscription login, so runs would bill the API instead of spending subscription quota",
|
|
557
|
+
fix: "unset ANTHROPIC_API_KEY (Afterburner also strips it from child processes as a defense, but fix the environment)."
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return results;
|
|
561
|
+
}
|
|
562
|
+
async function checkBudgetProvider(config, deps = {}) {
|
|
563
|
+
const provider = config.budget.provider;
|
|
564
|
+
if (provider === "manual") {
|
|
565
|
+
return {
|
|
566
|
+
name: "budget",
|
|
567
|
+
ok: true,
|
|
568
|
+
detail: "manual provider (values from budget.manual; override per run with --weekly-remaining-pct / --weekly-remaining-tokens / --session-available)"
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const projectsDir = deps.projectsDir ?? defaultClaudeProjectsDir();
|
|
572
|
+
const transcriptsExist = existsSync2(projectsDir);
|
|
573
|
+
if (provider === "claude-code-transcripts") {
|
|
574
|
+
return transcriptsExist ? { name: "budget", ok: true, detail: `transcripts provider (reading ${projectsDir})` } : {
|
|
575
|
+
name: "budget",
|
|
576
|
+
ok: false,
|
|
577
|
+
detail: `claude-code-transcripts provider selected but ${projectsDir} does not exist`,
|
|
578
|
+
fix: "Use Claude Code on this machine at least once (it writes the transcripts), or set budget.provider to 'manual'."
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const cachePath = deps.usageCachePath ?? defaultUsageCachePath();
|
|
582
|
+
const cache = await readUsageCache(cachePath);
|
|
583
|
+
const fallbackNote = transcriptsExist ? "runs currently fall back to the transcript estimate" : `and the transcript fallback is also unavailable (${projectsDir} does not exist)`;
|
|
584
|
+
if (!cache) {
|
|
585
|
+
return {
|
|
586
|
+
name: "budget",
|
|
587
|
+
ok: false,
|
|
588
|
+
detail: `claude-usage provider selected but no usage cache exists yet; ${fallbackNote}`,
|
|
589
|
+
fix: "Run `afterburner statusline install`, then use Claude Code once so it captures your usage limits."
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const nowMs = deps.nowMs ?? Date.now();
|
|
593
|
+
const { budget, fallbackReason } = budgetFromUsageCache(
|
|
594
|
+
cache,
|
|
595
|
+
{
|
|
596
|
+
weeklyAllowanceSonnetTokens: config.budget.weeklyAllowanceSonnetTokens,
|
|
597
|
+
maxAgeMs: config.budget.usageCacheMaxAgeHours * 36e5
|
|
598
|
+
},
|
|
599
|
+
nowMs
|
|
600
|
+
);
|
|
601
|
+
if (!budget) {
|
|
602
|
+
return {
|
|
603
|
+
name: "budget",
|
|
604
|
+
ok: false,
|
|
605
|
+
detail: `claude-usage cache is unusable (${fallbackReason}); ${fallbackNote}`,
|
|
606
|
+
fix: "Use Claude Code once to refresh the cache (the statusLine hook updates it automatically while you work)."
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
name: "budget",
|
|
611
|
+
ok: true,
|
|
612
|
+
detail: `claude-usage provider (cache fresh, captured ${formatAge(nowMs - Date.parse(cache.capturedAt))} ago via the statusLine hook)`
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function formatAge(ms) {
|
|
616
|
+
const minutes = Math.round(ms / 6e4);
|
|
617
|
+
if (minutes < 60) return `${minutes}m`;
|
|
618
|
+
return `${Math.round(minutes / 60)}h`;
|
|
619
|
+
}
|
|
620
|
+
function classifyClaudeAuth(exitStatus, stdout) {
|
|
621
|
+
if (exitStatus !== 0) {
|
|
622
|
+
return {
|
|
623
|
+
name: "claude auth",
|
|
624
|
+
ok: false,
|
|
625
|
+
detail: "not logged in",
|
|
626
|
+
fix: "Run `claude /login` (interactive), or set CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token` for headless machines."
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
let authMethod;
|
|
630
|
+
try {
|
|
631
|
+
authMethod = String(JSON.parse(stdout).authMethod ?? "");
|
|
632
|
+
} catch {
|
|
633
|
+
return { name: "claude auth", ok: true, detail: "logged in (could not parse auth method)" };
|
|
634
|
+
}
|
|
635
|
+
if (authMethod.startsWith("api_key")) {
|
|
636
|
+
return {
|
|
637
|
+
name: "claude auth",
|
|
638
|
+
ok: false,
|
|
639
|
+
detail: `authenticated via ${authMethod}, NOT your subscription; headless runs would bill the API`,
|
|
640
|
+
fix: "Unset ANTHROPIC_API_KEY (or remove the apiKeyHelper from ~/.claude/settings.json) and run `claude /login` to use subscription credentials."
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
name: "claude auth",
|
|
645
|
+
ok: true,
|
|
646
|
+
detail: `logged in via subscription (${authMethod || "unknown method"})`
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async function checkRunStoreWritable() {
|
|
650
|
+
const probePath = join(dataDir, `.doctor-probe-${process.pid}`);
|
|
651
|
+
try {
|
|
652
|
+
await mkdir(dataDir, { recursive: true });
|
|
653
|
+
await writeFile(probePath, "probe", "utf8");
|
|
654
|
+
await rm(probePath, { force: true });
|
|
655
|
+
return { name: "run store", ok: true, detail: `writable (${dataDir})` };
|
|
656
|
+
} catch (error) {
|
|
657
|
+
return {
|
|
658
|
+
name: "run store",
|
|
659
|
+
ok: false,
|
|
660
|
+
detail: `cannot write to ${dataDir}: ${error instanceof Error ? error.message : String(error)}`,
|
|
661
|
+
fix: `Ensure the directory exists and is writable by your user (mkdir -p "${dataDir}").`
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/cli/commands/init.ts
|
|
667
|
+
import { existsSync as existsSync3, statSync as statSync2 } from "fs";
|
|
668
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
669
|
+
import { createInterface } from "readline/promises";
|
|
670
|
+
import { homedir } from "os";
|
|
671
|
+
import { join as join2, resolve as resolve3 } from "path";
|
|
672
|
+
var BACKENDS = ["dry-run", "claude-code", "api-key"];
|
|
673
|
+
var BUDGET_PROVIDERS = ["manual", "claude-usage", "claude-code-transcripts"];
|
|
674
|
+
var ENGINE_CHOICES = {
|
|
675
|
+
"dry-run": "simulates everything, spends nothing. The safe default.",
|
|
676
|
+
"claude-code": "your Claude subscription via the local `claude` login. No extra money; it spends quota you already pay for.",
|
|
677
|
+
"api-key": "bills your Anthropic API account per token. Every run costs real money."
|
|
678
|
+
};
|
|
679
|
+
var ENGINE_NOTES = {
|
|
680
|
+
"dry-run": "dry-run (simulates everything, spends nothing)",
|
|
681
|
+
"claude-code": "claude-code (spends subscription quota you already pay for)",
|
|
682
|
+
"api-key": "api-key (bills your Anthropic API account; real money)"
|
|
683
|
+
};
|
|
684
|
+
var BUDGET_NOTES = {
|
|
685
|
+
manual: "manual (uses the numbers in budget.manual)",
|
|
686
|
+
"claude-usage": "claude-usage (run `afterburner statusline install`, then use Claude Code once to fill the cache)",
|
|
687
|
+
"claude-code-transcripts": "claude-code-transcripts (estimates from local Claude Code logs)"
|
|
688
|
+
};
|
|
689
|
+
var CONFIG_FILENAMES = [
|
|
690
|
+
"afterburner.config.js",
|
|
691
|
+
"afterburner.config.ts",
|
|
692
|
+
"afterburner.config.mjs",
|
|
693
|
+
"afterburner.config.cjs"
|
|
694
|
+
];
|
|
695
|
+
function registerInit(program2, packageInfo2) {
|
|
696
|
+
program2.command("init").alias("setup").description(
|
|
697
|
+
"Interactive setup: 3 questions, writes afterburner.config.mjs (next to your repo, or in the current directory)"
|
|
698
|
+
).option("--yes", "non-interactive: accept all defaults (dry-run engine, no repos)").option("--force", "overwrite an existing config file").action((opts) => runInit(opts, packageInfo2));
|
|
699
|
+
}
|
|
700
|
+
async function runInit(opts, packageInfo2) {
|
|
701
|
+
console.log(`${banner()}
|
|
702
|
+
`);
|
|
703
|
+
let targetDir = process.cwd();
|
|
704
|
+
let target = join2(targetDir, "afterburner.config.mjs");
|
|
705
|
+
let backend = "dry-run";
|
|
706
|
+
let budgetProvider = "manual";
|
|
707
|
+
let repoUrl = "";
|
|
708
|
+
let verifyNow = false;
|
|
709
|
+
if (!opts.yes) {
|
|
710
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
711
|
+
fail(
|
|
712
|
+
"Interactive prompts need a terminal on stdin and stdout. Use `afterburner init --yes` for non-interactive setup."
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
716
|
+
const aborted = () => fail("\nAborted. No config written.");
|
|
717
|
+
rl.on("SIGINT", aborted);
|
|
718
|
+
try {
|
|
719
|
+
console.log(
|
|
720
|
+
"Afterburner turns unused Claude subscription quota into small, reviewed pull requests.\nDry-run first: nothing executes or spends until a live engine is set in the config\nAND you pass --live. Three questions, then an optional health check.\n"
|
|
721
|
+
);
|
|
722
|
+
console.log(bold("Step 1 of 3 \u2014 Engine (who does the work, and what it can spend)"));
|
|
723
|
+
BACKENDS.forEach((name, i) => {
|
|
724
|
+
console.log(` ${i + 1}. ${name}: ${ENGINE_CHOICES[name]}`);
|
|
725
|
+
});
|
|
726
|
+
backend = BACKENDS[await askChoice(rl, "Engine [1]: ", BACKENDS.length)] ?? "dry-run";
|
|
727
|
+
console.log(step("Engine", backend));
|
|
728
|
+
console.log(`
|
|
729
|
+
${bold("Step 2 of 3 \u2014 Repository (the allowlist of what it may touch)")}`);
|
|
730
|
+
console.log(' - a local path (e.g. ~/code/my-project, or "." for this folder)');
|
|
731
|
+
console.log(" \u2192 a dry run can scan it for candidate tasks right away");
|
|
732
|
+
console.log(" - a GitHub URL (e.g. https://github.com/you/repo)");
|
|
733
|
+
console.log(" \u2192 saved to your allowlist; used once live runs are enabled");
|
|
734
|
+
console.log(
|
|
735
|
+
" - leave empty to skip for now (the health check flags it until a repo is added)"
|
|
736
|
+
);
|
|
737
|
+
const repoAnswer = (await rl.question("Repo path or URL: ")).trim();
|
|
738
|
+
const repoPath = localRepoPath(repoAnswer);
|
|
739
|
+
repoUrl = repoPath ?? repoAnswer;
|
|
740
|
+
const repoEcho = repoUrl === "" ? '(skipped \u2014 add one to "repos" later)' : !repoPath && !looksRemoteRepoUrl(repoAnswer) ? `${repoUrl} (not an existing directory \u2014 saved as typed; the health check will flag it)` : repoUrl;
|
|
741
|
+
console.log(step("Repo", repoEcho));
|
|
742
|
+
if (repoPath && repoPath !== targetDir && !hasConfig(repoPath)) {
|
|
743
|
+
console.log("\nWhere should the config live?");
|
|
744
|
+
console.log(` 1. ${repoPath} (next to the repo)`);
|
|
745
|
+
console.log(` 2. ${targetDir} (current directory)`);
|
|
746
|
+
if (await askChoice(rl, "Config location [1]: ", 2) === 0) {
|
|
747
|
+
targetDir = repoPath;
|
|
748
|
+
target = join2(targetDir, "afterburner.config.mjs");
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
console.log(
|
|
752
|
+
`
|
|
753
|
+
${bold("Step 3 of 3 \u2014 Budget tracking (how it knows how much quota is left)")}`
|
|
754
|
+
);
|
|
755
|
+
console.log(" 1. manual: trust the numbers in the config; tweak them anytime. Easy start.");
|
|
756
|
+
console.log(" 2. claude-usage: read your real usage from Claude Code. Most accurate; needs");
|
|
757
|
+
console.log(
|
|
758
|
+
" `afterburner statusline install` after setup (the health check reminds you)."
|
|
759
|
+
);
|
|
760
|
+
console.log(" 3. claude-code-transcripts: estimate from local Claude Code session logs.");
|
|
761
|
+
budgetProvider = BUDGET_PROVIDERS[await askChoice(rl, "Budget tracking [1]: ", BUDGET_PROVIDERS.length)] ?? "manual";
|
|
762
|
+
console.log(step("Budget", budgetProvider));
|
|
763
|
+
const verifyAnswer = (await rl.question("\nRun the health check now (afterburner doctor)? [Y/n]: ")).trim().toLowerCase();
|
|
764
|
+
verifyNow = verifyAnswer === "" || verifyAnswer === "y" || verifyAnswer === "yes";
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (error?.code === "ABORT_ERR") aborted();
|
|
767
|
+
throw error;
|
|
768
|
+
} finally {
|
|
769
|
+
rl.close();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
assertCanWriteConfig(targetDir, target, opts.force === true);
|
|
773
|
+
await writeFile2(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
|
|
774
|
+
console.log(`
|
|
775
|
+
${step("Config", target)}`);
|
|
776
|
+
if (verifyNow) {
|
|
777
|
+
console.log();
|
|
778
|
+
await runDoctor({ packageInfo: packageInfo2, configPath: target });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
console.log(
|
|
782
|
+
`
|
|
783
|
+
${renderOnboardingSummary({
|
|
784
|
+
configPath: target,
|
|
785
|
+
repoUrl,
|
|
786
|
+
backend,
|
|
787
|
+
budgetProvider,
|
|
788
|
+
interactive: !opts.yes
|
|
789
|
+
})}`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
function assertCanWriteConfig(targetDir, target, force) {
|
|
793
|
+
const existing = CONFIG_FILENAMES.map((name) => join2(targetDir, name)).filter(
|
|
794
|
+
(p) => existsSync3(p)
|
|
795
|
+
);
|
|
796
|
+
if (existing.length > 0 && !force) {
|
|
797
|
+
fail(`${existing[0]} already exists. Pass --force to overwrite it.`);
|
|
798
|
+
}
|
|
799
|
+
const shadowing = existing.filter((p) => p !== target);
|
|
800
|
+
if (shadowing.length > 0) {
|
|
801
|
+
fail(
|
|
802
|
+
`Remove ${shadowing.join(", ")} first, or cosmiconfig will load it instead of the generated ${target}.`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function hasConfig(dir) {
|
|
807
|
+
return CONFIG_FILENAMES.some((name) => existsSync3(join2(dir, name)));
|
|
808
|
+
}
|
|
809
|
+
async function askChoice(rl, prompt, count) {
|
|
810
|
+
for (; ; ) {
|
|
811
|
+
const answer = (await rl.question(prompt)).trim();
|
|
812
|
+
const index = answer === "" ? 0 : Number(answer) - 1;
|
|
813
|
+
if (Number.isInteger(index) && index >= 0 && index < count) return index;
|
|
814
|
+
console.log(`Invalid choice "${answer}". Enter a number from 1 to ${count}.`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function localRepoPath(repoUrl, base = process.cwd()) {
|
|
818
|
+
if (repoUrl === "" || looksRemoteRepoUrl(repoUrl)) return null;
|
|
819
|
+
const expanded = repoUrl === "~" ? homedir() : repoUrl.replace(/^~(?=\/)/, homedir());
|
|
820
|
+
const absolute = resolve3(base, expanded);
|
|
821
|
+
return statSync2(absolute, { throwIfNoEntry: false })?.isDirectory() ? absolute : null;
|
|
822
|
+
}
|
|
823
|
+
function renderOnboardingSummary(opts) {
|
|
824
|
+
const lines = [];
|
|
825
|
+
if (!opts.interactive) {
|
|
826
|
+
lines.push(
|
|
827
|
+
step("Engine", ENGINE_NOTES[opts.backend]),
|
|
828
|
+
step("Repo", opts.repoUrl || '(none yet \u2014 add one to "repos" in the config)'),
|
|
829
|
+
step("Budget", BUDGET_NOTES[opts.budgetProvider]),
|
|
830
|
+
""
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
lines.push(
|
|
834
|
+
dim(
|
|
835
|
+
"Safety: every run is a dry run until agent.backend is a live engine AND you pass --live."
|
|
836
|
+
),
|
|
837
|
+
"",
|
|
838
|
+
section(emoji.rocket, "Next"),
|
|
839
|
+
nextCmd(
|
|
840
|
+
suggestCommand("doctor", opts.configPath, opts.cwd),
|
|
841
|
+
"checks the setup end to end and prints the exact next command"
|
|
842
|
+
)
|
|
843
|
+
);
|
|
844
|
+
return lines.join("\n");
|
|
845
|
+
}
|
|
846
|
+
function renderConfig(backend, budgetProvider, repoUrl) {
|
|
847
|
+
const repoBlock = repoUrl ? ` {
|
|
848
|
+
url: ${JSON.stringify(repoUrl)},
|
|
849
|
+
defaultBranch: 'main',
|
|
850
|
+
branchPrefix: 'claude/',
|
|
851
|
+
enabledTaskCategories: ['docs', 'tests', 'dead-code'],
|
|
852
|
+
},` : ` // {
|
|
853
|
+
// url: '/absolute/path/to/your/repo', // or 'https://github.com/you/repo'
|
|
854
|
+
// defaultBranch: 'main',
|
|
855
|
+
// branchPrefix: 'claude/',
|
|
856
|
+
// enabledTaskCategories: ['docs', 'tests', 'dead-code'],
|
|
857
|
+
// },`;
|
|
858
|
+
return `// Afterburner configuration. Safe by default: dry-run, no live execution.
|
|
859
|
+
// Docs: see afterburner.config.example.ts in the package, or the README.
|
|
860
|
+
const config = {
|
|
861
|
+
repos: [
|
|
862
|
+
${repoBlock}
|
|
863
|
+
],
|
|
864
|
+
budget: {
|
|
865
|
+
// 'manual' \u2192 use the manual values below.
|
|
866
|
+
// 'claude-usage' \u2192 read your real 5h/7d usage % + reset times from
|
|
867
|
+
// Claude Code (run \`afterburner statusline install\`
|
|
868
|
+
// first); most accurate, falls back to transcripts.
|
|
869
|
+
// 'claude-code-transcripts' \u2192 estimate spend from your local Claude Code sessions.
|
|
870
|
+
provider: '${budgetProvider}',
|
|
871
|
+
weeklyAllowanceSonnetTokens: 5_000_000,
|
|
872
|
+
minWeeklyHeadroomPct: 20,
|
|
873
|
+
safetyMarginTokens: 200_000,
|
|
874
|
+
requireSessionAvailable: true,
|
|
875
|
+
// Manual estimates: check /usage in Claude Code and keep these roughly
|
|
876
|
+
// current. sessionAvailable is read here by the manual and transcripts
|
|
877
|
+
// providers; claude-usage reads the real 5-hour window from its cache.
|
|
878
|
+
manual: {
|
|
879
|
+
sessionAvailable: true,
|
|
880
|
+
weeklyRemainingPct: 100,
|
|
881
|
+
weeklyRemainingTokensEst: 3_000_000,
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
schedule: { cron: '17 */4 * * *', timezone: 'UTC' },
|
|
885
|
+
agent: {
|
|
886
|
+
// The engine: 'dry-run' simulates, 'claude-code' spends subscription quota,
|
|
887
|
+
// 'api-key' bills your Anthropic API account (real money). Live runs also
|
|
888
|
+
// need the --live flag (two-part opt-in).
|
|
889
|
+
backend: '${backend}',
|
|
890
|
+
// Upper bound for a single task, in Sonnet-equivalent tokens.
|
|
891
|
+
maxTaskTokens: 200_000,
|
|
892
|
+
// Fable/Mythos-tier models are blocked by default: they can bill real
|
|
893
|
+
// money at API rates outside promotional windows.
|
|
894
|
+
allowFable: false,
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
export default config;
|
|
899
|
+
`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/cli/commands/log.ts
|
|
903
|
+
function registerLog(program2) {
|
|
904
|
+
program2.command("log").description("Print the run store (the canonical audit trail of what happened)").option("--json", "output raw JSON records").action(async (opts) => {
|
|
905
|
+
const records = await new JsonlRunStore().list();
|
|
906
|
+
if (records.length === 0) {
|
|
907
|
+
console.log("No runs recorded yet. Try `afterburner run-once --dry-run`.");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (opts.json) {
|
|
911
|
+
console.log(JSON.stringify(records, null, 2));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
for (const r of records) {
|
|
915
|
+
const pr = r.prUrl ? ` pr=${r.prUrl}` : "";
|
|
916
|
+
console.log(
|
|
917
|
+
`${r.timestamp} ${r.outcome.padEnd(9)} [${r.category}] ${r.title}
|
|
918
|
+
repo=${r.repoUrl} branch=${r.branch} fp=${r.fingerprint} est=${formatTokens(r.estCostSonnetTokens)} sonnet-eq${pr}`
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// src/cli/commands/mcp.ts
|
|
925
|
+
function registerMcp(program2) {
|
|
926
|
+
program2.command("mcp").description("Start the MCP server front-end (not implemented yet)").action(() => {
|
|
927
|
+
throw new Error(MCP_STUB_MESSAGE);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// src/cli/commands/run-once.ts
|
|
932
|
+
function registerRunOnce(program2) {
|
|
933
|
+
program2.command("run-once").description(
|
|
934
|
+
"Run one ignition cycle: pick at most one bounded task per configured repo (dry-run by default)"
|
|
935
|
+
).option("--config <path>", "path to a config file").option("--dry-run", "force a dry run (this is the default behavior)").option(
|
|
936
|
+
"--live",
|
|
937
|
+
"arm live execution; only takes effect when agent.backend is also a live engine in the config (two-part opt-in)"
|
|
938
|
+
).option("--weekly-remaining-pct <pct>", "override budget: remaining weekly %", Number).option(
|
|
939
|
+
"--weekly-remaining-tokens <tokens>",
|
|
940
|
+
"override budget: remaining weekly Sonnet-equivalent tokens",
|
|
941
|
+
Number
|
|
942
|
+
).option("--session-available <bool>", "override budget: session window available (true|false)").action(async (opts) => {
|
|
943
|
+
if (opts.live && opts.dryRun) {
|
|
944
|
+
fail("Pass either --live or --dry-run, not both.");
|
|
945
|
+
}
|
|
946
|
+
const live = opts.live === true && opts.dryRun !== true;
|
|
947
|
+
const { config, filepath } = await loadConfigOrExit(opts.config);
|
|
948
|
+
if (config.repos.length === 0) {
|
|
949
|
+
fail(
|
|
950
|
+
`No repos configured in ${filepath}. Add an entry to "repos", e.g. { url: '/absolute/path/to/repo', enabledTaskCategories: ['docs', 'tests'] }.`
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
if (opts.weeklyRemainingPct !== void 0 && !Number.isFinite(opts.weeklyRemainingPct)) {
|
|
954
|
+
fail("--weekly-remaining-pct must be a number.");
|
|
955
|
+
}
|
|
956
|
+
if (opts.weeklyRemainingTokens !== void 0 && !Number.isFinite(opts.weeklyRemainingTokens)) {
|
|
957
|
+
fail("--weekly-remaining-tokens must be a number.");
|
|
958
|
+
}
|
|
959
|
+
if (opts.sessionAvailable !== void 0 && opts.sessionAvailable !== "true" && opts.sessionAvailable !== "false") {
|
|
960
|
+
fail("--session-available must be true or false.");
|
|
961
|
+
}
|
|
962
|
+
const { provider, source } = createBudgetProvider(config, {
|
|
963
|
+
onNote: (m) => console.error(`[afterburner] ${m}`)
|
|
964
|
+
});
|
|
965
|
+
let budget;
|
|
966
|
+
try {
|
|
967
|
+
budget = await provider.getBudget();
|
|
968
|
+
} catch (error) {
|
|
969
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
970
|
+
}
|
|
971
|
+
if (opts.weeklyRemainingPct !== void 0)
|
|
972
|
+
budget.weeklyRemainingPct = opts.weeklyRemainingPct;
|
|
973
|
+
if (opts.weeklyRemainingTokens !== void 0) {
|
|
974
|
+
budget.weeklyRemainingTokensEst = opts.weeklyRemainingTokens;
|
|
975
|
+
}
|
|
976
|
+
if (opts.sessionAvailable !== void 0) {
|
|
977
|
+
budget.sessionAvailable = opts.sessionAvailable === "true";
|
|
978
|
+
}
|
|
979
|
+
await spool();
|
|
980
|
+
const runner = createRunner(config, live);
|
|
981
|
+
const downgrade = liveDowngradeReason(config, live, filepath);
|
|
982
|
+
if (downgrade) console.error(errYellow(downgrade));
|
|
983
|
+
const modeLabel = runner.backend === "dry-run" ? cyan("DRY-RUN (no side effects)") : flame(`LIVE via ${runner.backend}`);
|
|
984
|
+
console.log(`${deco(emoji.jet)}Mode: ${modeLabel}`);
|
|
985
|
+
const sessionLabel = budget.sessionAvailable ? green("available") : yellow("unavailable");
|
|
986
|
+
console.log(
|
|
987
|
+
`${deco(emoji.fuel)}Budget ${dim(`(${source})`)}: session=${sessionLabel} weekly=${budget.weeklyRemainingPct}% (~${formatTokens(budget.weeklyRemainingTokensEst)} Sonnet-eq tokens remaining)`
|
|
988
|
+
);
|
|
989
|
+
const outcomes = await runOnce({
|
|
990
|
+
config,
|
|
991
|
+
budgetProvider: new ManualBudgetProvider(budget),
|
|
992
|
+
selector: createSelector(config),
|
|
993
|
+
runner,
|
|
994
|
+
store: new JsonlRunStore(),
|
|
995
|
+
notifier: new ConsoleNotifier()
|
|
996
|
+
});
|
|
997
|
+
if (outcomes.some((o) => o.status === "completed")) await ignite();
|
|
998
|
+
for (const outcome of outcomes) {
|
|
999
|
+
console.log(`
|
|
1000
|
+
repo ${dim(outcome.repoUrl)}`);
|
|
1001
|
+
if (outcome.status === "skipped") {
|
|
1002
|
+
console.log(` ${dim("skipped:")} ${outcome.reason}`);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const { task, result } = outcome;
|
|
1006
|
+
if (!task || !result) continue;
|
|
1007
|
+
console.log(` ${flame(deco("\u25B2"))}${bold(`[${task.category}] ${task.title}`)}`);
|
|
1008
|
+
console.log(` ${dim("branch ")} ${cyan(result.branch)}`);
|
|
1009
|
+
console.log(` ${dim("PR title")} ${result.prTitle}`);
|
|
1010
|
+
console.log(
|
|
1011
|
+
` ${dim("est cost")} ${flame(formatTokens(task.estCostSonnetTokens))} Sonnet-equivalent tokens`
|
|
1012
|
+
);
|
|
1013
|
+
console.log(` ${dim(result.summary)}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (runner.backend === "dry-run" && outcomes.some((o) => o.status === "completed")) {
|
|
1016
|
+
const liveCmd = suggestCommand("run-once --live", filepath);
|
|
1017
|
+
console.log(`
|
|
1018
|
+
${section(emoji.rocket, "Next")}`);
|
|
1019
|
+
console.log(
|
|
1020
|
+
config.agent.backend === "dry-run" ? ` This was a preview. Live execution ships in a future release; once it does,
|
|
1021
|
+
set agent.backend: 'claude-code' in ${filepath}, then: ${liveCmd}` : ` This was a preview. Live execution ships in a future release;
|
|
1022
|
+
${liveCmd} currently validates the setup and refuses.`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/cli/commands/schedule.ts
|
|
1029
|
+
import { mkdir as mkdir2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
1030
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1031
|
+
import { dirname as dirname2, resolve as resolve4 } from "path";
|
|
1032
|
+
function currentPlatform() {
|
|
1033
|
+
const platform = process.platform;
|
|
1034
|
+
if (platform === "darwin" || platform === "linux" || platform === "win32") return platform;
|
|
1035
|
+
fail(
|
|
1036
|
+
`Unsupported platform "${platform}" for native scheduling. Use \`afterburner watch\` instead.`
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
function registerSchedule(program2) {
|
|
1040
|
+
const schedule = program2.command("schedule").description("Install or remove a native OS scheduler entry (launchd / systemd / schtasks)");
|
|
1041
|
+
schedule.command("install").description("Generate and install the native scheduler entry for this OS").option("--config <path>", "path to a config file (recorded in the scheduled command)").action(async (opts) => {
|
|
1042
|
+
const { config, filepath } = await loadConfigOrExit(opts.config);
|
|
1043
|
+
const configPath = opts.config ? resolve4(opts.config) : filepath;
|
|
1044
|
+
const artifacts = generateScheduleArtifacts(currentPlatform(), {
|
|
1045
|
+
cron: config.schedule.cron,
|
|
1046
|
+
timezone: config.schedule.timezone,
|
|
1047
|
+
nodePath: process.execPath,
|
|
1048
|
+
cliPath: resolveCliEntry(),
|
|
1049
|
+
configPath
|
|
1050
|
+
});
|
|
1051
|
+
for (const file of artifacts.files) {
|
|
1052
|
+
await mkdir2(dirname2(file.path), { recursive: true });
|
|
1053
|
+
await writeFile3(file.path, file.content, "utf8");
|
|
1054
|
+
console.log(`Wrote ${file.path}`);
|
|
1055
|
+
}
|
|
1056
|
+
console.log(`
|
|
1057
|
+
Kind: ${artifacts.kind}`);
|
|
1058
|
+
console.log(`Scheduled runs will use this config: ${configPath}`);
|
|
1059
|
+
console.log(`Activate with:
|
|
1060
|
+
${artifacts.activationHint}`);
|
|
1061
|
+
console.log(`
|
|
1062
|
+
Remove later with:
|
|
1063
|
+
${artifacts.removalHint}`);
|
|
1064
|
+
});
|
|
1065
|
+
schedule.command("uninstall").description("Remove the native scheduler entry files for this OS").option("--config <path>", "path to a config file").action(async (opts) => {
|
|
1066
|
+
const { config } = await loadConfigOrExit(opts.config);
|
|
1067
|
+
const artifacts = generateScheduleArtifacts(currentPlatform(), {
|
|
1068
|
+
cron: config.schedule.cron,
|
|
1069
|
+
timezone: config.schedule.timezone,
|
|
1070
|
+
nodePath: process.execPath,
|
|
1071
|
+
cliPath: resolveCliEntry()
|
|
1072
|
+
});
|
|
1073
|
+
if (artifacts.files.length === 0) {
|
|
1074
|
+
console.log(
|
|
1075
|
+
`Nothing to delete on this platform. Remove the tasks with:
|
|
1076
|
+
${artifacts.removalHint}`
|
|
1077
|
+
);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
for (const file of artifacts.files) {
|
|
1081
|
+
if (existsSync4(file.path)) {
|
|
1082
|
+
await rm2(file.path);
|
|
1083
|
+
console.log(`Removed ${file.path}`);
|
|
1084
|
+
} else {
|
|
1085
|
+
console.log(`Not found (already removed?): ${file.path}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
console.log(`
|
|
1089
|
+
Finish deactivation with:
|
|
1090
|
+
${artifacts.removalHint}`);
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/cli/commands/skill.ts
|
|
1095
|
+
import { copyFile, mkdir as mkdir3 } from "fs/promises";
|
|
1096
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1097
|
+
import { join as join3 } from "path";
|
|
1098
|
+
import { fileURLToPath } from "url";
|
|
1099
|
+
function findBundledSkill() {
|
|
1100
|
+
for (const relative of ["../../skill/SKILL.md", "../../../skill/SKILL.md"]) {
|
|
1101
|
+
const candidate = fileURLToPath(new URL(relative, import.meta.url));
|
|
1102
|
+
if (existsSync5(candidate)) return candidate;
|
|
1103
|
+
}
|
|
1104
|
+
fail("Bundled skill/SKILL.md not found, is the package installation intact?");
|
|
1105
|
+
}
|
|
1106
|
+
function registerSkill(program2) {
|
|
1107
|
+
const skill = program2.command("skill").description("Manage the Claude Code skill front-end for Afterburner");
|
|
1108
|
+
skill.command("install").description("Copy the skill into your personal Claude Code skills directory").option("--force", "overwrite an existing installation").action(async (opts) => {
|
|
1109
|
+
const source = findBundledSkill();
|
|
1110
|
+
const targetDir = join3(claudeConfigDir(), "skills", "afterburner");
|
|
1111
|
+
const target = join3(targetDir, "SKILL.md");
|
|
1112
|
+
if (existsSync5(target) && !opts.force) {
|
|
1113
|
+
fail(`${target} already exists. Pass --force to overwrite it.`);
|
|
1114
|
+
}
|
|
1115
|
+
await mkdir3(targetDir, { recursive: true });
|
|
1116
|
+
await copyFile(source, target);
|
|
1117
|
+
console.log(`Installed ${target}`);
|
|
1118
|
+
console.log("Claude Code now exposes /afterburner (restart running sessions to pick it up).");
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// src/cli/commands/statusline.ts
|
|
1123
|
+
import { spawn } from "child_process";
|
|
1124
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1125
|
+
import { copyFile as copyFile2, mkdir as mkdir4, readFile, rm as rm3, writeFile as writeFile4 } from "fs/promises";
|
|
1126
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
1127
|
+
function wrappedStatePath() {
|
|
1128
|
+
return join4(dataDir, "statusline-wrapped.json");
|
|
1129
|
+
}
|
|
1130
|
+
function claudeSettingsPath() {
|
|
1131
|
+
return join4(claudeConfigDir(), "settings.json");
|
|
1132
|
+
}
|
|
1133
|
+
function registerStatusline(program2) {
|
|
1134
|
+
const statusline = program2.command("statusline").description(
|
|
1135
|
+
"Claude Code statusLine hook: caches your live usage limits so the budget provider can read them"
|
|
1136
|
+
).action(runHook);
|
|
1137
|
+
statusline.command("install").description(
|
|
1138
|
+
"Wire the hook into Claude Code settings.json (non-destructively wraps any existing status line)"
|
|
1139
|
+
).option("--force", "reinstall even if already configured").action(install);
|
|
1140
|
+
statusline.command("uninstall").description("Remove the hook and restore any previous status line").action(uninstall);
|
|
1141
|
+
}
|
|
1142
|
+
async function runHook() {
|
|
1143
|
+
let input = "";
|
|
1144
|
+
try {
|
|
1145
|
+
input = await readStdin();
|
|
1146
|
+
} catch {
|
|
1147
|
+
}
|
|
1148
|
+
let data = {};
|
|
1149
|
+
try {
|
|
1150
|
+
data = JSON.parse(input);
|
|
1151
|
+
} catch {
|
|
1152
|
+
}
|
|
1153
|
+
if (data.rate_limits) {
|
|
1154
|
+
try {
|
|
1155
|
+
await writeUsageCache(defaultUsageCachePath(), {
|
|
1156
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1157
|
+
rateLimits: data.rate_limits,
|
|
1158
|
+
model: data.model?.display_name
|
|
1159
|
+
});
|
|
1160
|
+
} catch {
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const wrapped = await readWrappedState();
|
|
1164
|
+
if (wrapped?.command) {
|
|
1165
|
+
await passThrough(wrapped.command, input);
|
|
1166
|
+
} else {
|
|
1167
|
+
process.stdout.write(renderDefaultStatusLine(data) + "\n");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
function renderDefaultStatusLine(data) {
|
|
1171
|
+
const parts = ["afterburner", data.model?.display_name ?? "claude"];
|
|
1172
|
+
const five = data.rate_limits?.five_hour?.used_percentage;
|
|
1173
|
+
const seven = data.rate_limits?.seven_day?.used_percentage;
|
|
1174
|
+
if (typeof five === "number") parts.push(`5h ${Math.round(five)}%`);
|
|
1175
|
+
if (typeof seven === "number") parts.push(`7d ${Math.round(seven)}%`);
|
|
1176
|
+
return parts.join(" \xB7 ");
|
|
1177
|
+
}
|
|
1178
|
+
function readStdin() {
|
|
1179
|
+
if (process.stdin.isTTY) return Promise.resolve("");
|
|
1180
|
+
return new Promise((resolve6, reject) => {
|
|
1181
|
+
let buf = "";
|
|
1182
|
+
process.stdin.setEncoding("utf8");
|
|
1183
|
+
process.stdin.on("data", (c) => buf += c);
|
|
1184
|
+
process.stdin.on("end", () => resolve6(buf));
|
|
1185
|
+
process.stdin.on("error", reject);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
async function readWrappedState() {
|
|
1189
|
+
try {
|
|
1190
|
+
const saved = JSON.parse(await readFile(wrappedStatePath(), "utf8"));
|
|
1191
|
+
return saved.statusLine ?? null;
|
|
1192
|
+
} catch {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function passThrough(command, input) {
|
|
1197
|
+
return new Promise((resolve6) => {
|
|
1198
|
+
const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
|
|
1199
|
+
child.on("error", () => resolve6());
|
|
1200
|
+
child.on("close", () => resolve6());
|
|
1201
|
+
child.stdin.on("error", () => {
|
|
1202
|
+
});
|
|
1203
|
+
child.stdin.end(input);
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
async function readSettings(path) {
|
|
1207
|
+
if (!existsSync6(path)) return {};
|
|
1208
|
+
try {
|
|
1209
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
1210
|
+
} catch {
|
|
1211
|
+
fail(`Could not parse ${path} as JSON, fix it before installing the status line hook.`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
async function backup(path) {
|
|
1215
|
+
if (!existsSync6(path)) return null;
|
|
1216
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1217
|
+
const dest = `${path}.afterburner-backup.${stamp}`;
|
|
1218
|
+
await copyFile2(path, dest);
|
|
1219
|
+
return dest;
|
|
1220
|
+
}
|
|
1221
|
+
var STATUSLINE_SENTINEL = "# afterburner-statusline-hook";
|
|
1222
|
+
function ourCommand() {
|
|
1223
|
+
return `"${process.execPath}" "${resolveCliEntry()}" statusline ${STATUSLINE_SENTINEL}`;
|
|
1224
|
+
}
|
|
1225
|
+
function isOurCommand(command) {
|
|
1226
|
+
return !!command && command.includes(STATUSLINE_SENTINEL);
|
|
1227
|
+
}
|
|
1228
|
+
async function install(opts) {
|
|
1229
|
+
const settingsPath = claudeSettingsPath();
|
|
1230
|
+
const settings = await readSettings(settingsPath);
|
|
1231
|
+
const existing = settings.statusLine;
|
|
1232
|
+
if (isOurCommand(existing?.command) && !opts.force) {
|
|
1233
|
+
console.log("Afterburner status line hook is already installed. Pass --force to reinstall.");
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (existing && !isOurCommand(existing.command)) {
|
|
1237
|
+
if (existsSync6(wrappedStatePath()) && !opts.force) {
|
|
1238
|
+
fail(
|
|
1239
|
+
`A wrapped status line is already recorded at ${wrappedStatePath()}, but settings.json has a different one. Resolve manually, or pass --force to overwrite the recorded wrapper.`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
await mkdir4(dirname3(wrappedStatePath()), { recursive: true });
|
|
1243
|
+
await writeWrappedState(existing);
|
|
1244
|
+
console.log("Found an existing status line, it will be chained after the afterburner hook.");
|
|
1245
|
+
}
|
|
1246
|
+
const next = { type: "command", command: ourCommand() };
|
|
1247
|
+
if (existing?.padding !== void 0) next.padding = existing.padding;
|
|
1248
|
+
settings.statusLine = next;
|
|
1249
|
+
const backedUp = await backup(settingsPath);
|
|
1250
|
+
await mkdir4(dirname3(settingsPath), { recursive: true });
|
|
1251
|
+
await writeFile4(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
1252
|
+
if (backedUp) console.log(`Backed up ${settingsPath} \u2192 ${backedUp}`);
|
|
1253
|
+
console.log(`Installed the status line hook in ${settingsPath}.`);
|
|
1254
|
+
console.log(
|
|
1255
|
+
"Use Claude Code once (it populates usage after the first API response), then set budget.provider: 'claude-usage' in your afterburner config."
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
async function writeWrappedState(existing) {
|
|
1259
|
+
await writeFile4(wrappedStatePath(), JSON.stringify({ statusLine: existing }, null, 2), "utf8");
|
|
1260
|
+
}
|
|
1261
|
+
async function uninstall() {
|
|
1262
|
+
const settingsPath = claudeSettingsPath();
|
|
1263
|
+
const settings = await readSettings(settingsPath);
|
|
1264
|
+
const existing = settings.statusLine;
|
|
1265
|
+
if (!isOurCommand(existing?.command)) {
|
|
1266
|
+
console.log(
|
|
1267
|
+
"The afterburner status line hook is not the active status line; nothing to remove."
|
|
1268
|
+
);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const saved = await readWrappedState();
|
|
1272
|
+
if (saved) {
|
|
1273
|
+
settings.statusLine = saved;
|
|
1274
|
+
} else {
|
|
1275
|
+
delete settings.statusLine;
|
|
1276
|
+
}
|
|
1277
|
+
await rm3(wrappedStatePath(), { force: true });
|
|
1278
|
+
const restored = saved !== null;
|
|
1279
|
+
const backedUp = await backup(settingsPath);
|
|
1280
|
+
await writeFile4(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
1281
|
+
if (backedUp) console.log(`Backed up ${settingsPath} \u2192 ${backedUp}`);
|
|
1282
|
+
console.log(
|
|
1283
|
+
restored ? `Removed the afterburner hook and restored your previous status line in ${settingsPath}.` : `Removed the afterburner status line hook from ${settingsPath}.`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/cli/commands/watch.ts
|
|
1288
|
+
function registerWatch(program2) {
|
|
1289
|
+
program2.command("watch").description("Foreground scheduler daemon; prefer schedule install for unattended runs").option("--config <path>", "path to a config file").option(
|
|
1290
|
+
"--live",
|
|
1291
|
+
"arm live execution for every tick; only takes effect when agent.backend is a live engine (two-part opt-in)"
|
|
1292
|
+
).action(async (opts) => {
|
|
1293
|
+
const { config, filepath } = await loadConfigOrExit(opts.config);
|
|
1294
|
+
const live = opts.live === true;
|
|
1295
|
+
const runner = createRunner(config, live);
|
|
1296
|
+
const downgrade = liveDowngradeReason(config, live, filepath);
|
|
1297
|
+
if (downgrade) console.error(errYellow(downgrade));
|
|
1298
|
+
const { provider, source } = createBudgetProvider(config, {
|
|
1299
|
+
onNote: (m) => console.error(`[afterburner] ${m}`)
|
|
1300
|
+
});
|
|
1301
|
+
console.log(banner());
|
|
1302
|
+
console.log(
|
|
1303
|
+
`${deco(emoji.jet)}${bold("watching")} ${dim(
|
|
1304
|
+
`cron="${config.schedule.cron}" tz=${config.schedule.timezone} mode=${runner.backend === "dry-run" ? "dry-run" : `LIVE (${runner.backend})`} budget=${source} config=${filepath}`
|
|
1305
|
+
)}`
|
|
1306
|
+
);
|
|
1307
|
+
const handle = startWatch({
|
|
1308
|
+
cron: config.schedule.cron,
|
|
1309
|
+
timezone: config.schedule.timezone,
|
|
1310
|
+
onTick: async () => {
|
|
1311
|
+
console.log(`[afterburner] tick ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1312
|
+
const outcomes = await runOnce({
|
|
1313
|
+
config,
|
|
1314
|
+
budgetProvider: provider,
|
|
1315
|
+
selector: createSelector(config),
|
|
1316
|
+
runner,
|
|
1317
|
+
store: new JsonlRunStore(),
|
|
1318
|
+
notifier: new ConsoleNotifier()
|
|
1319
|
+
});
|
|
1320
|
+
for (const outcome of outcomes) {
|
|
1321
|
+
console.log(`[afterburner] ${outcome.repoUrl}: ${outcome.status}, ${outcome.reason}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
const stop = () => {
|
|
1326
|
+
console.log("\n[afterburner] stopping watcher");
|
|
1327
|
+
handle.stop();
|
|
1328
|
+
process.exit(0);
|
|
1329
|
+
};
|
|
1330
|
+
process.on("SIGINT", stop);
|
|
1331
|
+
process.on("SIGTERM", stop);
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/cli/update-check.ts
|
|
1336
|
+
import { mkdir as mkdir5, readFile as readFile2, writeFile as writeFile5 } from "fs/promises";
|
|
1337
|
+
import { dirname as dirname4, join as join5, resolve as resolve5 } from "path";
|
|
1338
|
+
var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
1339
|
+
var DEFAULT_TIMEOUT_MS = 750;
|
|
1340
|
+
function defaultUpdateCachePath() {
|
|
1341
|
+
return join5(dataDir, "update-check.json");
|
|
1342
|
+
}
|
|
1343
|
+
async function maybeNotifyUpdate(opts) {
|
|
1344
|
+
if (!shouldCheckForUpdates(opts)) return;
|
|
1345
|
+
const cachePath = opts.cachePath ?? defaultUpdateCachePath();
|
|
1346
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
1347
|
+
const cached = await readUpdateCache(cachePath);
|
|
1348
|
+
const cachedNotice = cached ? renderUpdateNotice({
|
|
1349
|
+
packageName: opts.packageName,
|
|
1350
|
+
currentVersion: opts.currentVersion,
|
|
1351
|
+
latestVersion: cached.latestVersion,
|
|
1352
|
+
updateCommand: updateCommand(opts)
|
|
1353
|
+
}) : null;
|
|
1354
|
+
if (cachedNotice) process.stderr.write(`${cachedNotice}
|
|
1355
|
+
`);
|
|
1356
|
+
const checkedAtMs = cached ? Date.parse(cached.checkedAt) : 0;
|
|
1357
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
1358
|
+
const ageMs = nowMs - checkedAtMs;
|
|
1359
|
+
if (Number.isFinite(checkedAtMs) && ageMs >= 0 && ageMs < maxAgeMs) return;
|
|
1360
|
+
const latestVersion = await fetchLatestVersion(opts);
|
|
1361
|
+
await writeUpdateCacheBestEffort(cachePath, {
|
|
1362
|
+
checkedAt: new Date(nowMs).toISOString(),
|
|
1363
|
+
latestVersion: latestVersion === void 0 ? cached?.latestVersion ?? null : latestVersion
|
|
1364
|
+
});
|
|
1365
|
+
if (latestVersion === void 0) return;
|
|
1366
|
+
if (!cachedNotice) {
|
|
1367
|
+
const freshNotice = renderUpdateNotice({
|
|
1368
|
+
packageName: opts.packageName,
|
|
1369
|
+
currentVersion: opts.currentVersion,
|
|
1370
|
+
latestVersion,
|
|
1371
|
+
updateCommand: updateCommand(opts)
|
|
1372
|
+
});
|
|
1373
|
+
if (freshNotice) process.stderr.write(`${freshNotice}
|
|
1374
|
+
`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
var UPDATE_NOTIFY_EXCLUDED = /* @__PURE__ */ new Set(["statusline", "mcp"]);
|
|
1378
|
+
function shouldNotifyForCommand(commandName) {
|
|
1379
|
+
return !UPDATE_NOTIFY_EXCLUDED.has(commandName);
|
|
1380
|
+
}
|
|
1381
|
+
function shouldCheckForUpdates(opts) {
|
|
1382
|
+
const env = opts.env ?? process.env;
|
|
1383
|
+
const stderrIsTty = opts.stderrIsTty ?? process.stderr.isTTY === true;
|
|
1384
|
+
return stderrIsTty && !env.CI && !env.NO_UPDATE_NOTIFIER && env.NODE_ENV !== "test" && opts.packageName.length > 0 && opts.currentVersion.length > 0;
|
|
1385
|
+
}
|
|
1386
|
+
function renderUpdateNotice(opts) {
|
|
1387
|
+
if (!opts.latestVersion) return null;
|
|
1388
|
+
if (compareSemver2(opts.latestVersion, opts.currentVersion) <= 0) return null;
|
|
1389
|
+
return `${section(emoji.rocket, "Update available")}: ${opts.currentVersion} -> ${opts.latestVersion}
|
|
1390
|
+
${nextCmd(opts.updateCommand)}`;
|
|
1391
|
+
}
|
|
1392
|
+
async function readUpdateCache(path) {
|
|
1393
|
+
try {
|
|
1394
|
+
const cache = JSON.parse(await readFile2(path, "utf8"));
|
|
1395
|
+
const valid = typeof cache?.checkedAt === "string" && (cache.latestVersion === null || typeof cache.latestVersion === "string");
|
|
1396
|
+
return valid ? cache : null;
|
|
1397
|
+
} catch {
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
async function writeUpdateCache(path, cache) {
|
|
1402
|
+
await mkdir5(dirname4(path), { recursive: true });
|
|
1403
|
+
await writeFile5(path, JSON.stringify(cache, null, 2) + "\n", "utf8");
|
|
1404
|
+
}
|
|
1405
|
+
async function writeUpdateCacheBestEffort(path, cache) {
|
|
1406
|
+
try {
|
|
1407
|
+
await writeUpdateCache(path, cache);
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
async function fetchLatestVersion(opts) {
|
|
1412
|
+
const controller = new AbortController();
|
|
1413
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1414
|
+
try {
|
|
1415
|
+
const response = await fetch(
|
|
1416
|
+
`https://registry.npmjs.org/${encodeURIComponent(opts.packageName)}/latest`,
|
|
1417
|
+
{
|
|
1418
|
+
signal: controller.signal,
|
|
1419
|
+
headers: { accept: "application/json" }
|
|
1420
|
+
}
|
|
1421
|
+
);
|
|
1422
|
+
if (response.status === 404) return null;
|
|
1423
|
+
if (!response.ok) return void 0;
|
|
1424
|
+
const body = await response.json();
|
|
1425
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
1426
|
+
} catch {
|
|
1427
|
+
return void 0;
|
|
1428
|
+
} finally {
|
|
1429
|
+
clearTimeout(timeout);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
function updateCommand(opts) {
|
|
1433
|
+
return isPathInside2(opts.cliEntryPath, opts.cwd) ? "pnpm build && npm install -g ." : `npm install -g ${opts.packageName}@latest`;
|
|
1434
|
+
}
|
|
1435
|
+
function isPathInside2(candidate, parent) {
|
|
1436
|
+
if (!candidate) return false;
|
|
1437
|
+
const absoluteCandidate = resolve5(candidate);
|
|
1438
|
+
const absoluteParent = resolve5(parent);
|
|
1439
|
+
return absoluteCandidate === absoluteParent || absoluteCandidate.startsWith(`${absoluteParent}/`);
|
|
1440
|
+
}
|
|
1441
|
+
function compareSemver2(a, b) {
|
|
1442
|
+
const [aMajor, aMinor, aPatch] = parseSemver2(a);
|
|
1443
|
+
const [bMajor, bMinor, bPatch] = parseSemver2(b);
|
|
1444
|
+
const differences = [aMajor - bMajor, aMinor - bMinor, aPatch - bPatch];
|
|
1445
|
+
for (const difference of differences) {
|
|
1446
|
+
if (difference !== 0) return difference;
|
|
1447
|
+
}
|
|
1448
|
+
return 0;
|
|
1449
|
+
}
|
|
1450
|
+
function parseSemver2(version) {
|
|
1451
|
+
const stableVersion = version.split("-", 1)[0] ?? "0";
|
|
1452
|
+
const [major = "0", minor = "0", patch = "0"] = stableVersion.split(".");
|
|
1453
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/cli/welcome.ts
|
|
1457
|
+
function renderWelcome(configPath) {
|
|
1458
|
+
const lines = [banner(), ""];
|
|
1459
|
+
const cmd = cmdRow;
|
|
1460
|
+
if (!configPath) {
|
|
1461
|
+
lines.push(
|
|
1462
|
+
"Looks like a first run. Setup takes about a minute, and nothing spends quota or",
|
|
1463
|
+
"touches a repo until you explicitly opt in (dry-run is the default).",
|
|
1464
|
+
"",
|
|
1465
|
+
"Start here:",
|
|
1466
|
+
cmd("afterburner init", "answer 3 quick questions; writes a config")
|
|
1467
|
+
);
|
|
1468
|
+
} else {
|
|
1469
|
+
lines.push(
|
|
1470
|
+
`Config: ${dim(configPath)}`,
|
|
1471
|
+
"",
|
|
1472
|
+
"Everyday commands:",
|
|
1473
|
+
cmd("afterburner doctor", "check prerequisites and config"),
|
|
1474
|
+
cmd("afterburner run-once --dry-run", "preview the next task (no changes made)"),
|
|
1475
|
+
cmd(
|
|
1476
|
+
"afterburner run-once --live",
|
|
1477
|
+
"real PRs (stubbed in this version; validates and refuses)"
|
|
1478
|
+
),
|
|
1479
|
+
cmd("afterburner log", "recent run history")
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
lines.push("", dim("Full command list: afterburner --help"));
|
|
1483
|
+
return lines.join("\n");
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// src/cli/index.ts
|
|
1487
|
+
var program = new Command();
|
|
1488
|
+
var packageInfo = { packageName: package_default.name, currentVersion: package_default.version };
|
|
1489
|
+
program.name("afterburner").description(
|
|
1490
|
+
"Turn unused Claude subscription quota into small, reviewed pull requests. PR-only, dry-run by default."
|
|
1491
|
+
).version(package_default.version).addHelpText("beforeAll", `${banner()}
|
|
1492
|
+
`);
|
|
1493
|
+
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
1494
|
+
let top = actionCommand;
|
|
1495
|
+
while (top.parent && top.parent !== program) top = top.parent;
|
|
1496
|
+
if (!shouldNotifyForCommand(top.name())) return;
|
|
1497
|
+
try {
|
|
1498
|
+
await maybeNotifyUpdate({
|
|
1499
|
+
...packageInfo,
|
|
1500
|
+
cliEntryPath: process.argv[1],
|
|
1501
|
+
cwd: process.cwd()
|
|
1502
|
+
});
|
|
1503
|
+
} catch {
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
registerInit(program, packageInfo);
|
|
1507
|
+
registerDoctor(program, packageInfo);
|
|
1508
|
+
registerRunOnce(program);
|
|
1509
|
+
registerWatch(program);
|
|
1510
|
+
registerSchedule(program);
|
|
1511
|
+
registerLog(program);
|
|
1512
|
+
registerSkill(program);
|
|
1513
|
+
registerStatusline(program);
|
|
1514
|
+
registerMcp(program);
|
|
1515
|
+
if (process.argv.length <= 2) {
|
|
1516
|
+
console.log(renderWelcome(await findConfigPath()));
|
|
1517
|
+
} else {
|
|
1518
|
+
try {
|
|
1519
|
+
await program.parseAsync(process.argv);
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
console.error(errRed(error instanceof Error ? error.message : String(error)));
|
|
1522
|
+
process.exit(1);
|
|
1523
|
+
}
|
|
1524
|
+
}
|