@pimmesz/afterburner 1.0.1 → 1.0.7
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 +99 -20
- package/assets/afterburner-logo-round.png +0 -0
- package/dist/cli/index.js +250 -128
- package/dist/core/index.d.ts +2 -2
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="assets/afterburner-logo.png" alt="Afterburner logo" width="
|
|
2
|
+
<img src="assets/afterburner-logo-round.png" alt="Afterburner logo" width="144" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Afterburner</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
8
|
+
<strong>Idle Claude quota, turned into small reviewed pull requests.</strong>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
|
+
<a href="https://github.com/pimmesz/afterburner/stargazers">
|
|
13
|
+
<img src="https://img.shields.io/github/stars/pimmesz/afterburner?style=flat&color=yellow" alt="Stars" />
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/@pimmesz/afterburner">
|
|
16
|
+
<img src="https://img.shields.io/npm/v/%40pimmesz%2Fafterburner?label=npm" alt="npm version" />
|
|
17
|
+
</a>
|
|
12
18
|
<a href="https://github.com/pimmesz/afterburner/actions/workflows/ci.yml">
|
|
13
19
|
<img src="https://github.com/pimmesz/afterburner/actions/workflows/ci.yml/badge.svg" alt="CI status" />
|
|
14
20
|
</a>
|
|
@@ -20,26 +26,47 @@
|
|
|
20
26
|
</p>
|
|
21
27
|
|
|
22
28
|
<p align="center">
|
|
23
|
-
<
|
|
24
|
-
<
|
|
29
|
+
<a href="#quickstart-60-seconds">Quickstart</a> ·
|
|
30
|
+
<a href="#scheduling">Scheduling</a> ·
|
|
31
|
+
<a href="#updating">Updating</a> ·
|
|
32
|
+
<a href="#configuration">Configuration</a> ·
|
|
33
|
+
<a href="#safety-model">Safety</a>
|
|
25
34
|
</p>
|
|
26
35
|
|
|
27
|
-
Afterburner
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
Afterburner is a budget-aware decision layer for coding agents. It watches your configured
|
|
37
|
+
Claude headroom, chooses one bounded task from an allowlisted repo, and opens a review-first
|
|
38
|
+
path toward a PR.
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
It stays conservative by default: dry-run first, one task per ignition cycle, no telemetry,
|
|
41
|
+
and live work only after a two-part opt-in.
|
|
33
42
|
|
|
34
43
|
> Current status: the dry-run path works end to end. Live Claude Code/API execution and real
|
|
35
44
|
> PR creation are still interface stubs in this version.
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
|
42
|
-
|
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Before / After
|
|
49
|
+
|
|
50
|
+
| Without Afterburner | With Afterburner |
|
|
51
|
+
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
52
|
+
| Your weekly Claude quota resets on a flat-rate plan. Whatever you don't spend by the reset is gone, with no rollover. | It watches the headroom you configured, picks one bounded task from an allowlisted repo, and previews the exact `claude/` branch and PR it would open. You review everything, nothing merges itself. Dry-run today, live next. |
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
┌─ at a glance ─────────────────────────────────────────────
|
|
56
|
+
│ Safety dry-run by default, two-part live opt-in
|
|
57
|
+
│ Output PR-only, on a claude/ branch
|
|
58
|
+
│ Scope one bounded task per run, hard token ceiling
|
|
59
|
+
│ Budget both-caps gate + safety margin
|
|
60
|
+
│ Privacy no telemetry, no phone-home
|
|
61
|
+
└────────────────────────────────────────────────────────────
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Feature | Default behavior |
|
|
65
|
+
| ----------------- | ----------------------------------------------------------------------- |
|
|
66
|
+
| Budget gate | Checks weekly headroom and 5-hour session availability before spending. |
|
|
67
|
+
| Bounded work | Picks one small task per run and enforces a per-task token ceiling. |
|
|
68
|
+
| Review-first flow | Plans a `claude/` branch and PR; never pushes to default. |
|
|
69
|
+
| Safe install path | Starts in dry-run mode; native scheduling is opt-in. |
|
|
43
70
|
|
|
44
71
|
> Fair use: Afterburner is for spending your own subscription quota on your own repos. It
|
|
45
72
|
> doesn't get around provider limits; it keeps capacity you've already paid for from going to
|
|
@@ -103,13 +130,11 @@ These rules live in the code, not just in this README:
|
|
|
103
130
|
|
|
104
131
|
Needs Node.js 22.12 or newer and `git` on your PATH.
|
|
105
132
|
|
|
106
|
-
Heads up before you copy-paste: the
|
|
107
|
-
|
|
108
|
-
install line below works; the unscoped `afterburner` name on npm is a different, unrelated
|
|
109
|
-
package, so always use the scoped `@pimmesz/afterburner`.
|
|
133
|
+
Heads up before you copy-paste: the unscoped `afterburner` name on npm is a different,
|
|
134
|
+
unrelated package, so always use the scoped `@pimmesz/afterburner`.
|
|
110
135
|
|
|
111
136
|
```sh
|
|
112
|
-
npm install -g @pimmesz/afterburner
|
|
137
|
+
npm install -g @pimmesz/afterburner
|
|
113
138
|
afterburner init # interactive setup, writes afterburner.config.mjs
|
|
114
139
|
afterburner doctor # checks prerequisites and prints fixes
|
|
115
140
|
afterburner run-once --dry-run
|
|
@@ -143,6 +168,50 @@ afterburner skill install # copies the skill to ~/.claude/skills/afterburner/
|
|
|
143
168
|
Then you can ask Claude Code to "run an afterburner dry run", or use `/afterburner`. Both
|
|
144
169
|
drive the same CLI.
|
|
145
170
|
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Commands
|
|
174
|
+
|
|
175
|
+
| Command | What it does |
|
|
176
|
+
| -------------------------------- | ------------------------------------------------------------------------- |
|
|
177
|
+
| `afterburner init` | Interactive setup; writes `afterburner.config.mjs` (alias: `setup`) |
|
|
178
|
+
| `afterburner doctor` | Check prerequisites and config; every failure prints an actionable fix |
|
|
179
|
+
| `afterburner run-once --dry-run` | Preview the next task: branch, PR title, est cost (no changes made) |
|
|
180
|
+
| `afterburner run-once --live` | Arm a live run (needs a live engine in config; stubbed this version) |
|
|
181
|
+
| `afterburner watch` | Foreground scheduler daemon on your configured cron |
|
|
182
|
+
| `afterburner schedule install` | Native launchd / systemd / schtasks entry (recommended for unattended) |
|
|
183
|
+
| `afterburner log` | Append-only history of what ran (`--json` for machine output) |
|
|
184
|
+
| `afterburner update` | Update to the latest release (`--print` to just show the command) |
|
|
185
|
+
| `afterburner statusline install` | Hook that caches your real `/usage` numbers for the `claude-usage` budget |
|
|
186
|
+
| `afterburner skill install` | Install the Claude Code skill into `~/.claude/skills` |
|
|
187
|
+
| `afterburner mcp` | MCP server front-end (stub) |
|
|
188
|
+
|
|
189
|
+
## Updating
|
|
190
|
+
|
|
191
|
+
Afterburner checks npm at most once per day before interactive commands and prints an update
|
|
192
|
+
nudge when a newer version is available. The notice goes to stderr, so JSON output from
|
|
193
|
+
commands like `afterburner log --json` stays parseable. Machine-invoked commands such as
|
|
194
|
+
`statusline` and `mcp` stay quiet; `update` skips the pre-check because it performs the
|
|
195
|
+
update itself.
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
afterburner update # installs the latest release
|
|
199
|
+
afterburner update --print # only print the command
|
|
200
|
+
afterburner update --registry=https://registry.npmjs.org/
|
|
201
|
+
afterburner doctor --check-updates # verify
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
For an npm install, `update` runs `npm install -g @pimmesz/afterburner@latest`. For a source
|
|
205
|
+
checkout it rebuilds and reinstalls that checkout in place; it does not run git, so `git pull`
|
|
206
|
+
first if you want new code.
|
|
207
|
+
|
|
208
|
+
If a company npm proxy shadows public packages, point the install at the public registry with
|
|
209
|
+
`afterburner update --registry https://registry.npmjs.org/`, or set it once globally:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
npm config set @pimmesz:registry https://registry.npmjs.org/
|
|
213
|
+
```
|
|
214
|
+
|
|
146
215
|
### MCP server (stub)
|
|
147
216
|
|
|
148
217
|
`afterburner mcp` is a placeholder for exposing run-once, log, and doctor as MCP tools. It's
|
|
@@ -287,6 +356,16 @@ provider, prebuilt single binaries and a Homebrew tap (the build is set up so th
|
|
|
287
356
|
to add later), the MCP server beyond its stub, and any web dashboard or vendor notification
|
|
288
357
|
integration. The `Notifier` interface is the seam for that last one.
|
|
289
358
|
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Star history
|
|
362
|
+
|
|
363
|
+
If Afterburner turned some idle quota into something useful, a star helps other people find it.
|
|
364
|
+
|
|
365
|
+
[](https://star-history.com/#pimmesz/afterburner&Date)
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
290
369
|
## Contributing and license
|
|
291
370
|
|
|
292
371
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) (and [DECISIONS.md](./DECISIONS.md) for the reasoning
|
|
Binary file
|
package/dist/cli/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import { Command } from "commander";
|
|
|
31
31
|
// package.json
|
|
32
32
|
var package_default = {
|
|
33
33
|
name: "@pimmesz/afterburner",
|
|
34
|
-
version: "1.0.
|
|
34
|
+
version: "1.0.7",
|
|
35
35
|
description: "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
|
|
36
36
|
license: "Apache-2.0",
|
|
37
37
|
publishConfig: {
|
|
@@ -43,8 +43,8 @@ var package_default = {
|
|
|
43
43
|
node: ">=22.12"
|
|
44
44
|
},
|
|
45
45
|
bin: {
|
|
46
|
-
afterburner: "
|
|
47
|
-
abr: "
|
|
46
|
+
afterburner: "dist/cli/index.js",
|
|
47
|
+
abr: "dist/cli/index.js"
|
|
48
48
|
},
|
|
49
49
|
main: "./dist/core/index.js",
|
|
50
50
|
types: "./dist/core/index.d.ts",
|
|
@@ -59,6 +59,7 @@ var package_default = {
|
|
|
59
59
|
"dist",
|
|
60
60
|
"skill",
|
|
61
61
|
"assets/afterburner-logo.png",
|
|
62
|
+
"assets/afterburner-logo-round.png",
|
|
62
63
|
"afterburner.config.example.ts",
|
|
63
64
|
".env.example"
|
|
64
65
|
],
|
|
@@ -74,7 +75,7 @@ var package_default = {
|
|
|
74
75
|
},
|
|
75
76
|
repository: {
|
|
76
77
|
type: "git",
|
|
77
|
-
url: "https://github.com/pimmesz/afterburner.git"
|
|
78
|
+
url: "git+https://github.com/pimmesz/afterburner.git"
|
|
78
79
|
},
|
|
79
80
|
keywords: [
|
|
80
81
|
"claude",
|
|
@@ -336,8 +337,10 @@ async function runDoctor(opts) {
|
|
|
336
337
|
${green(`${deco(emoji.rocket)}All checks passed.`)}` : `
|
|
337
338
|
${red(`${failures} check(s) failed.`)}`
|
|
338
339
|
);
|
|
339
|
-
console.log(
|
|
340
|
-
|
|
340
|
+
console.log(
|
|
341
|
+
`
|
|
342
|
+
${renderDoctorNextSteps({ config, configPath, failed: results.filter((r) => !r.ok) })}`
|
|
343
|
+
);
|
|
341
344
|
process.exitCode = failures === 0 ? 0 : 1;
|
|
342
345
|
}
|
|
343
346
|
function renderDoctorNextSteps(opts) {
|
|
@@ -355,9 +358,13 @@ ${nextCmd("afterburner init", "three questions; writes the config and takes it f
|
|
|
355
358
|
then re-run:
|
|
356
359
|
${nextCmd(cmd("doctor"))}`;
|
|
357
360
|
}
|
|
358
|
-
if (opts.
|
|
361
|
+
if (opts.failed.length > 0) {
|
|
362
|
+
const steps = opts.failed.map(
|
|
363
|
+
(check, i) => ` ${i + 1}. ${check.fix ?? `Fix the \u2717 ${check.name} check above.`}`
|
|
364
|
+
);
|
|
359
365
|
return `${next}
|
|
360
|
-
|
|
366
|
+
${steps.join("\n")}
|
|
367
|
+
${opts.failed.length + 1}. Re-run the health check; all green ends with your first run command:
|
|
361
368
|
${nextCmd(cmd("doctor"))}`;
|
|
362
369
|
}
|
|
363
370
|
const repoNames = config.repos.map((r) => r.url);
|
|
@@ -377,7 +384,7 @@ ${next}
|
|
|
377
384
|
${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
|
|
378
385
|
}
|
|
379
386
|
function checkVersion(opts) {
|
|
380
|
-
const localUpdateCommand = "pnpm build && npm install -g .";
|
|
387
|
+
const localUpdateCommand = "pnpm build && npm install -g . --force";
|
|
381
388
|
const npmUpdateCommand = `npm install -g ${opts.packageName}@latest`;
|
|
382
389
|
const isSourceRun = isPathInside(opts.cliEntryPath, opts.cwd);
|
|
383
390
|
if (opts.latest.status === "found" && opts.latest.latestVersion) {
|
|
@@ -700,12 +707,15 @@ function registerInit(program2, packageInfo2) {
|
|
|
700
707
|
async function runInit(opts, packageInfo2) {
|
|
701
708
|
console.log(`${banner()}
|
|
702
709
|
`);
|
|
703
|
-
let
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
710
|
+
let answers = {
|
|
711
|
+
backend: "dry-run",
|
|
712
|
+
budgetProvider: "manual",
|
|
713
|
+
repoUrl: "",
|
|
714
|
+
verifyNow: false,
|
|
715
|
+
targetDir: process.cwd(),
|
|
716
|
+
target: join2(process.cwd(), "afterburner.config.mjs"),
|
|
717
|
+
overwriteConsented: false
|
|
718
|
+
};
|
|
709
719
|
if (!opts.yes) {
|
|
710
720
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
711
721
|
fail(
|
|
@@ -716,52 +726,7 @@ async function runInit(opts, packageInfo2) {
|
|
|
716
726
|
const aborted = () => fail("\nAborted. No config written.");
|
|
717
727
|
rl.on("SIGINT", aborted);
|
|
718
728
|
try {
|
|
719
|
-
|
|
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";
|
|
729
|
+
answers = await collectAnswers(rl, opts);
|
|
765
730
|
} catch (error) {
|
|
766
731
|
if (error?.code === "ABORT_ERR") aborted();
|
|
767
732
|
throw error;
|
|
@@ -769,7 +734,8 @@ ${bold("Step 3 of 3 \u2014 Budget tracking (how it knows how much quota is left)
|
|
|
769
734
|
rl.close();
|
|
770
735
|
}
|
|
771
736
|
}
|
|
772
|
-
|
|
737
|
+
const { backend, budgetProvider, repoUrl, verifyNow, targetDir, target } = answers;
|
|
738
|
+
assertCanWriteConfig(targetDir, target, opts.force === true || answers.overwriteConsented);
|
|
773
739
|
await writeFile2(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
|
|
774
740
|
console.log(`
|
|
775
741
|
${step("Config", target)}`);
|
|
@@ -789,19 +755,95 @@ ${renderOnboardingSummary({
|
|
|
789
755
|
})}`
|
|
790
756
|
);
|
|
791
757
|
}
|
|
758
|
+
async function collectAnswers(rl, opts, cwd = process.cwd()) {
|
|
759
|
+
let targetDir = cwd;
|
|
760
|
+
let target = join2(targetDir, "afterburner.config.mjs");
|
|
761
|
+
console.log(
|
|
762
|
+
"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"
|
|
763
|
+
);
|
|
764
|
+
console.log(bold("Step 1 of 3 \u2014 Engine (who does the work, and what it can spend)"));
|
|
765
|
+
BACKENDS.forEach((name, i) => {
|
|
766
|
+
console.log(` ${i + 1}. ${name}: ${ENGINE_CHOICES[name]}`);
|
|
767
|
+
});
|
|
768
|
+
const backend = BACKENDS[await askChoice(rl, "Engine [1]: ", BACKENDS.length)] ?? "dry-run";
|
|
769
|
+
console.log(step("Engine", backend));
|
|
770
|
+
console.log(`
|
|
771
|
+
${bold("Step 2 of 3 \u2014 Repository (the allowlist of what it may touch)")}`);
|
|
772
|
+
console.log(' - a local path (e.g. ~/code/my-project, or "." for this folder)');
|
|
773
|
+
console.log(" \u2192 a dry run can scan it for candidate tasks right away");
|
|
774
|
+
console.log(" - a GitHub URL (e.g. https://github.com/you/repo)");
|
|
775
|
+
console.log(" \u2192 saved to your allowlist; used once live runs are enabled");
|
|
776
|
+
console.log(" - leave empty to skip for now (the health check flags it until a repo is added)");
|
|
777
|
+
const repoAnswer = (await rl.question("Repo path or URL: ")).trim();
|
|
778
|
+
const repoPath = localRepoPath(repoAnswer, cwd);
|
|
779
|
+
const repoUrl = repoPath ?? repoAnswer;
|
|
780
|
+
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;
|
|
781
|
+
console.log(step("Repo", repoEcho));
|
|
782
|
+
if (repoPath && repoPath !== targetDir && !hasConfig(repoPath)) {
|
|
783
|
+
console.log("\nWhere should the config live?");
|
|
784
|
+
console.log(` 1. ${repoPath} (next to the repo)`);
|
|
785
|
+
console.log(` 2. ${targetDir} (current directory)`);
|
|
786
|
+
if (await askChoice(rl, "Config location [1]: ", 2) === 0) {
|
|
787
|
+
targetDir = repoPath;
|
|
788
|
+
target = join2(targetDir, "afterburner.config.mjs");
|
|
789
|
+
}
|
|
790
|
+
} else if (repoPath && repoPath !== targetDir) {
|
|
791
|
+
console.log(
|
|
792
|
+
dim(
|
|
793
|
+
`Note: ${repoPath} already has an afterburner config; this one is written to the current directory.`
|
|
794
|
+
)
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const overwriteConsented = await resolveConfigConflict(
|
|
798
|
+
rl,
|
|
799
|
+
targetDir,
|
|
800
|
+
target,
|
|
801
|
+
opts.force === true
|
|
802
|
+
);
|
|
803
|
+
console.log(
|
|
804
|
+
`
|
|
805
|
+
${bold("Step 3 of 3 \u2014 Budget tracking (how it knows how much quota is left)")}`
|
|
806
|
+
);
|
|
807
|
+
console.log(" 1. manual: trust the numbers in the config; tweak them anytime. Easy start.");
|
|
808
|
+
console.log(" 2. claude-usage: read your real usage from Claude Code. Most accurate; needs");
|
|
809
|
+
console.log(" `afterburner statusline install` after setup (the health check reminds you).");
|
|
810
|
+
console.log(" 3. claude-code-transcripts: estimate from local Claude Code session logs.");
|
|
811
|
+
const budgetProvider = BUDGET_PROVIDERS[await askChoice(rl, "Budget tracking [1]: ", BUDGET_PROVIDERS.length)] ?? "manual";
|
|
812
|
+
console.log(step("Budget", budgetProvider));
|
|
813
|
+
const verifyAnswer = (await rl.question("\nRun the health check now (afterburner doctor)? [Y/n]: ")).trim().toLowerCase();
|
|
814
|
+
const verifyNow = verifyAnswer === "" || verifyAnswer === "y" || verifyAnswer === "yes";
|
|
815
|
+
return { backend, budgetProvider, repoUrl, verifyNow, targetDir, target, overwriteConsented };
|
|
816
|
+
}
|
|
817
|
+
async function resolveConfigConflict(rl, targetDir, target, force) {
|
|
818
|
+
const { exact, shadowing } = configConflict(targetDir, target);
|
|
819
|
+
if (shadowing.length > 0) failShadowing(shadowing, target);
|
|
820
|
+
if (!exact || force) return false;
|
|
821
|
+
if (!await confirmOverwrite(rl, exact)) fail("Aborted. Existing config kept.");
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
792
824
|
function assertCanWriteConfig(targetDir, target, force) {
|
|
825
|
+
const { exact, shadowing } = configConflict(targetDir, target);
|
|
826
|
+
if (shadowing.length > 0) failShadowing(shadowing, target);
|
|
827
|
+
if (exact && !force) fail(`${exact} already exists. Pass --force to overwrite it.`);
|
|
828
|
+
}
|
|
829
|
+
function configConflict(targetDir, target) {
|
|
793
830
|
const existing = CONFIG_FILENAMES.map((name) => join2(targetDir, name)).filter(
|
|
794
831
|
(p) => existsSync3(p)
|
|
795
832
|
);
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
)
|
|
804
|
-
|
|
833
|
+
return {
|
|
834
|
+
exact: existing.includes(target) ? target : null,
|
|
835
|
+
shadowing: existing.filter((p) => p !== target)
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function failShadowing(shadowing, target) {
|
|
839
|
+
fail(
|
|
840
|
+
`Remove ${shadowing.join(", ")} first, or cosmiconfig will load it instead of the generated ${target}.`
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
async function confirmOverwrite(rl, target) {
|
|
844
|
+
const answer = (await rl.question(`
|
|
845
|
+
${target} already exists. Overwrite it? [y/N]: `)).trim().toLowerCase();
|
|
846
|
+
return answer === "y" || answer === "yes";
|
|
805
847
|
}
|
|
806
848
|
function hasConfig(dir) {
|
|
807
849
|
return CONFIG_FILENAMES.some((name) => existsSync3(join2(dir, name)));
|
|
@@ -1177,11 +1219,11 @@ function renderDefaultStatusLine(data) {
|
|
|
1177
1219
|
}
|
|
1178
1220
|
function readStdin() {
|
|
1179
1221
|
if (process.stdin.isTTY) return Promise.resolve("");
|
|
1180
|
-
return new Promise((
|
|
1222
|
+
return new Promise((resolve7, reject) => {
|
|
1181
1223
|
let buf = "";
|
|
1182
1224
|
process.stdin.setEncoding("utf8");
|
|
1183
1225
|
process.stdin.on("data", (c) => buf += c);
|
|
1184
|
-
process.stdin.on("end", () =>
|
|
1226
|
+
process.stdin.on("end", () => resolve7(buf));
|
|
1185
1227
|
process.stdin.on("error", reject);
|
|
1186
1228
|
});
|
|
1187
1229
|
}
|
|
@@ -1194,10 +1236,10 @@ async function readWrappedState() {
|
|
|
1194
1236
|
}
|
|
1195
1237
|
}
|
|
1196
1238
|
function passThrough(command, input) {
|
|
1197
|
-
return new Promise((
|
|
1239
|
+
return new Promise((resolve7) => {
|
|
1198
1240
|
const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
|
|
1199
|
-
child.on("error", () =>
|
|
1200
|
-
child.on("close", () =>
|
|
1241
|
+
child.on("error", () => resolve7());
|
|
1242
|
+
child.on("close", () => resolve7());
|
|
1201
1243
|
child.stdin.on("error", () => {
|
|
1202
1244
|
});
|
|
1203
1245
|
child.stdin.end(input);
|
|
@@ -1284,53 +1326,9 @@ async function uninstall() {
|
|
|
1284
1326
|
);
|
|
1285
1327
|
}
|
|
1286
1328
|
|
|
1287
|
-
// src/cli/commands/
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
}
|
|
1329
|
+
// src/cli/commands/update.ts
|
|
1330
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1331
|
+
import { dirname as dirname5, resolve as resolve6 } from "path";
|
|
1334
1332
|
|
|
1335
1333
|
// src/cli/update-check.ts
|
|
1336
1334
|
import { mkdir as mkdir5, readFile as readFile2, writeFile as writeFile5 } from "fs/promises";
|
|
@@ -1349,7 +1347,7 @@ async function maybeNotifyUpdate(opts) {
|
|
|
1349
1347
|
packageName: opts.packageName,
|
|
1350
1348
|
currentVersion: opts.currentVersion,
|
|
1351
1349
|
latestVersion: cached.latestVersion,
|
|
1352
|
-
updateCommand:
|
|
1350
|
+
updateCommand: resolveUpdateCommand(opts)
|
|
1353
1351
|
}) : null;
|
|
1354
1352
|
if (cachedNotice) process.stderr.write(`${cachedNotice}
|
|
1355
1353
|
`);
|
|
@@ -1368,13 +1366,13 @@ async function maybeNotifyUpdate(opts) {
|
|
|
1368
1366
|
packageName: opts.packageName,
|
|
1369
1367
|
currentVersion: opts.currentVersion,
|
|
1370
1368
|
latestVersion,
|
|
1371
|
-
updateCommand:
|
|
1369
|
+
updateCommand: resolveUpdateCommand(opts)
|
|
1372
1370
|
});
|
|
1373
1371
|
if (freshNotice) process.stderr.write(`${freshNotice}
|
|
1374
1372
|
`);
|
|
1375
1373
|
}
|
|
1376
1374
|
}
|
|
1377
|
-
var UPDATE_NOTIFY_EXCLUDED = /* @__PURE__ */ new Set(["statusline", "mcp"]);
|
|
1375
|
+
var UPDATE_NOTIFY_EXCLUDED = /* @__PURE__ */ new Set(["statusline", "mcp", "update"]);
|
|
1378
1376
|
function shouldNotifyForCommand(commandName) {
|
|
1379
1377
|
return !UPDATE_NOTIFY_EXCLUDED.has(commandName);
|
|
1380
1378
|
}
|
|
@@ -1429,8 +1427,25 @@ async function fetchLatestVersion(opts) {
|
|
|
1429
1427
|
clearTimeout(timeout);
|
|
1430
1428
|
}
|
|
1431
1429
|
}
|
|
1432
|
-
function
|
|
1433
|
-
return isPathInside2(opts.cliEntryPath, opts.cwd) ? "
|
|
1430
|
+
function detectInstallKind(opts) {
|
|
1431
|
+
return isPathInside2(opts.cliEntryPath, opts.cwd) ? "source" : "global";
|
|
1432
|
+
}
|
|
1433
|
+
function detectGlobalPackageManager(cliEntryPath) {
|
|
1434
|
+
const path = (cliEntryPath ?? "").replaceAll("\\", "/").toLowerCase();
|
|
1435
|
+
if (path.includes("/pnpm/")) return "pnpm";
|
|
1436
|
+
if (path.includes("/.bun/")) return "bun";
|
|
1437
|
+
if (path.includes("/yarn/") || path.includes("/.yarn/")) return "yarn";
|
|
1438
|
+
return "npm";
|
|
1439
|
+
}
|
|
1440
|
+
var GLOBAL_UPDATE_COMMANDS = {
|
|
1441
|
+
npm: (packageName) => `npm install -g ${packageName}@latest`,
|
|
1442
|
+
pnpm: (packageName) => `pnpm add -g ${packageName}@latest`,
|
|
1443
|
+
yarn: (packageName) => `yarn global add ${packageName}@latest`,
|
|
1444
|
+
bun: (packageName) => `bun add -g ${packageName}@latest`
|
|
1445
|
+
};
|
|
1446
|
+
function resolveUpdateCommand(opts) {
|
|
1447
|
+
if (detectInstallKind(opts) === "source") return "pnpm build && npm install -g . --force";
|
|
1448
|
+
return GLOBAL_UPDATE_COMMANDS[detectGlobalPackageManager(opts.cliEntryPath)](opts.packageName);
|
|
1434
1449
|
}
|
|
1435
1450
|
function isPathInside2(candidate, parent) {
|
|
1436
1451
|
if (!candidate) return false;
|
|
@@ -1453,6 +1468,112 @@ function parseSemver2(version) {
|
|
|
1453
1468
|
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
1454
1469
|
}
|
|
1455
1470
|
|
|
1471
|
+
// src/cli/commands/update.ts
|
|
1472
|
+
var DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
1473
|
+
function planUpdate(opts) {
|
|
1474
|
+
const kind = detectInstallKind(opts);
|
|
1475
|
+
const registry = opts.registry ?? (kind === "source" ? void 0 : DEFAULT_REGISTRY);
|
|
1476
|
+
const display = withRegistry(resolveUpdateCommand(opts), registry);
|
|
1477
|
+
if (opts.print) return { mode: "print", display };
|
|
1478
|
+
if (kind === "source") {
|
|
1479
|
+
const root = opts.cliEntryPath ? resolve6(dirname5(opts.cliEntryPath), "..", "..") : opts.cwd;
|
|
1480
|
+
return {
|
|
1481
|
+
mode: "execute",
|
|
1482
|
+
display,
|
|
1483
|
+
step: { run: display, cwd: root },
|
|
1484
|
+
note: `Rebuilds the checkout at ${root}; it does not fetch new code. Run \`git pull\` there first for the latest.`
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return { mode: "execute", display, step: { run: display } };
|
|
1488
|
+
}
|
|
1489
|
+
function registerUpdate(program2, packageInfo2) {
|
|
1490
|
+
program2.command("update").description("Update Afterburner to the latest release").option("--print", "only print the update command, do not run it").option(
|
|
1491
|
+
"--registry <url>",
|
|
1492
|
+
"registry for the install (default: registry.npmjs.org, matching the update check)"
|
|
1493
|
+
).action(async (opts) => {
|
|
1494
|
+
const plan = planUpdate({
|
|
1495
|
+
...packageInfo2,
|
|
1496
|
+
cliEntryPath: process.argv[1],
|
|
1497
|
+
cwd: process.cwd(),
|
|
1498
|
+
print: opts.print === true,
|
|
1499
|
+
registry: opts.registry
|
|
1500
|
+
});
|
|
1501
|
+
console.log(section(emoji.rocket, "Update Afterburner"));
|
|
1502
|
+
if (plan.mode === "print" || !plan.step) {
|
|
1503
|
+
console.log(nextCmd(plan.display, "run this to update"));
|
|
1504
|
+
console.log(nextCmd("afterburner doctor --check-updates", "verify afterwards"));
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
console.log(` ${dim("will run:")} ${plan.display}`);
|
|
1508
|
+
if (plan.note) console.log(` ${yellow(plan.note)}`);
|
|
1509
|
+
console.log(`
|
|
1510
|
+
${dim(`$ ${plan.step.run}`)}`);
|
|
1511
|
+
const result = spawnSync2(plan.step.run, {
|
|
1512
|
+
shell: true,
|
|
1513
|
+
stdio: "inherit",
|
|
1514
|
+
cwd: plan.step.cwd
|
|
1515
|
+
});
|
|
1516
|
+
if (result.error || result.status !== 0) {
|
|
1517
|
+
const why = result.error ? result.error.message : `exit code ${result.status}`;
|
|
1518
|
+
fail(`
|
|
1519
|
+
Update failed (${why}). Your existing install is unchanged.`);
|
|
1520
|
+
}
|
|
1521
|
+
console.log(`
|
|
1522
|
+
${green("Updated. Run `afterburner doctor --check-updates` to confirm.")}`);
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
function withRegistry(command, registry) {
|
|
1526
|
+
return registry ? `${command} --registry=${registry}` : command;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/cli/commands/watch.ts
|
|
1530
|
+
function registerWatch(program2) {
|
|
1531
|
+
program2.command("watch").description("Foreground scheduler daemon; prefer schedule install for unattended runs").option("--config <path>", "path to a config file").option(
|
|
1532
|
+
"--live",
|
|
1533
|
+
"arm live execution for every tick; only takes effect when agent.backend is a live engine (two-part opt-in)"
|
|
1534
|
+
).action(async (opts) => {
|
|
1535
|
+
const { config, filepath } = await loadConfigOrExit(opts.config);
|
|
1536
|
+
const live = opts.live === true;
|
|
1537
|
+
const runner = createRunner(config, live);
|
|
1538
|
+
const downgrade = liveDowngradeReason(config, live, filepath);
|
|
1539
|
+
if (downgrade) console.error(errYellow(downgrade));
|
|
1540
|
+
const { provider, source } = createBudgetProvider(config, {
|
|
1541
|
+
onNote: (m) => console.error(`[afterburner] ${m}`)
|
|
1542
|
+
});
|
|
1543
|
+
console.log(banner());
|
|
1544
|
+
console.log(
|
|
1545
|
+
`${deco(emoji.jet)}${bold("watching")} ${dim(
|
|
1546
|
+
`cron="${config.schedule.cron}" tz=${config.schedule.timezone} mode=${runner.backend === "dry-run" ? "dry-run" : `LIVE (${runner.backend})`} budget=${source} config=${filepath}`
|
|
1547
|
+
)}`
|
|
1548
|
+
);
|
|
1549
|
+
const handle = startWatch({
|
|
1550
|
+
cron: config.schedule.cron,
|
|
1551
|
+
timezone: config.schedule.timezone,
|
|
1552
|
+
onTick: async () => {
|
|
1553
|
+
console.log(`[afterburner] tick ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1554
|
+
const outcomes = await runOnce({
|
|
1555
|
+
config,
|
|
1556
|
+
budgetProvider: provider,
|
|
1557
|
+
selector: createSelector(config),
|
|
1558
|
+
runner,
|
|
1559
|
+
store: new JsonlRunStore(),
|
|
1560
|
+
notifier: new ConsoleNotifier()
|
|
1561
|
+
});
|
|
1562
|
+
for (const outcome of outcomes) {
|
|
1563
|
+
console.log(`[afterburner] ${outcome.repoUrl}: ${outcome.status}, ${outcome.reason}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
const stop = () => {
|
|
1568
|
+
console.log("\n[afterburner] stopping watcher");
|
|
1569
|
+
handle.stop();
|
|
1570
|
+
process.exit(0);
|
|
1571
|
+
};
|
|
1572
|
+
process.on("SIGINT", stop);
|
|
1573
|
+
process.on("SIGTERM", stop);
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1456
1577
|
// src/cli/welcome.ts
|
|
1457
1578
|
function renderWelcome(configPath) {
|
|
1458
1579
|
const lines = [banner(), ""];
|
|
@@ -1510,6 +1631,7 @@ registerWatch(program);
|
|
|
1510
1631
|
registerSchedule(program);
|
|
1511
1632
|
registerLog(program);
|
|
1512
1633
|
registerSkill(program);
|
|
1634
|
+
registerUpdate(program, packageInfo);
|
|
1513
1635
|
registerStatusline(program);
|
|
1514
1636
|
registerMcp(program);
|
|
1515
1637
|
if (process.argv.length <= 2) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -53,8 +53,8 @@ declare const budgetConfigSchema: z.ZodPrefault<z.ZodObject<{
|
|
|
53
53
|
}, z.core.$strip>>;
|
|
54
54
|
declare const agentConfigSchema: z.ZodPrefault<z.ZodObject<{
|
|
55
55
|
backend: z.ZodDefault<z.ZodEnum<{
|
|
56
|
-
"dry-run": "dry-run";
|
|
57
56
|
"claude-code": "claude-code";
|
|
57
|
+
"dry-run": "dry-run";
|
|
58
58
|
"api-key": "api-key";
|
|
59
59
|
}>>;
|
|
60
60
|
modelByCategory: z.ZodPipe<z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
@@ -115,8 +115,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
115
115
|
}, z.core.$strip>>;
|
|
116
116
|
agent: z.ZodPrefault<z.ZodObject<{
|
|
117
117
|
backend: z.ZodDefault<z.ZodEnum<{
|
|
118
|
-
"dry-run": "dry-run";
|
|
119
118
|
"claude-code": "claude-code";
|
|
119
|
+
"dry-run": "dry-run";
|
|
120
120
|
"api-key": "api-key";
|
|
121
121
|
}>>;
|
|
122
122
|
modelByCategory: z.ZodPipe<z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimmesz/afterburner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"node": ">=22.12"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"afterburner": "
|
|
16
|
-
"abr": "
|
|
15
|
+
"afterburner": "dist/cli/index.js",
|
|
16
|
+
"abr": "dist/cli/index.js"
|
|
17
17
|
},
|
|
18
18
|
"main": "./dist/core/index.js",
|
|
19
19
|
"types": "./dist/core/index.d.ts",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"skill",
|
|
30
30
|
"assets/afterburner-logo.png",
|
|
31
|
+
"assets/afterburner-logo-round.png",
|
|
31
32
|
"afterburner.config.example.ts",
|
|
32
33
|
".env.example"
|
|
33
34
|
],
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
},
|
|
44
45
|
"repository": {
|
|
45
46
|
"type": "git",
|
|
46
|
-
"url": "https://github.com/pimmesz/afterburner.git"
|
|
47
|
+
"url": "git+https://github.com/pimmesz/afterburner.git"
|
|
47
48
|
},
|
|
48
49
|
"keywords": [
|
|
49
50
|
"claude",
|