@pimmesz/afterburner 1.0.1 → 1.0.6
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 +240 -124
- 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.6",
|
|
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",
|
|
@@ -377,7 +378,7 @@ ${next}
|
|
|
377
378
|
${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
|
|
378
379
|
}
|
|
379
380
|
function checkVersion(opts) {
|
|
380
|
-
const localUpdateCommand = "pnpm build && npm install -g .";
|
|
381
|
+
const localUpdateCommand = "pnpm build && npm install -g . --force";
|
|
381
382
|
const npmUpdateCommand = `npm install -g ${opts.packageName}@latest`;
|
|
382
383
|
const isSourceRun = isPathInside(opts.cliEntryPath, opts.cwd);
|
|
383
384
|
if (opts.latest.status === "found" && opts.latest.latestVersion) {
|
|
@@ -700,12 +701,15 @@ function registerInit(program2, packageInfo2) {
|
|
|
700
701
|
async function runInit(opts, packageInfo2) {
|
|
701
702
|
console.log(`${banner()}
|
|
702
703
|
`);
|
|
703
|
-
let
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
704
|
+
let answers = {
|
|
705
|
+
backend: "dry-run",
|
|
706
|
+
budgetProvider: "manual",
|
|
707
|
+
repoUrl: "",
|
|
708
|
+
verifyNow: false,
|
|
709
|
+
targetDir: process.cwd(),
|
|
710
|
+
target: join2(process.cwd(), "afterburner.config.mjs"),
|
|
711
|
+
overwriteConsented: false
|
|
712
|
+
};
|
|
709
713
|
if (!opts.yes) {
|
|
710
714
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
711
715
|
fail(
|
|
@@ -716,52 +720,7 @@ async function runInit(opts, packageInfo2) {
|
|
|
716
720
|
const aborted = () => fail("\nAborted. No config written.");
|
|
717
721
|
rl.on("SIGINT", aborted);
|
|
718
722
|
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";
|
|
723
|
+
answers = await collectAnswers(rl, opts);
|
|
765
724
|
} catch (error) {
|
|
766
725
|
if (error?.code === "ABORT_ERR") aborted();
|
|
767
726
|
throw error;
|
|
@@ -769,7 +728,8 @@ ${bold("Step 3 of 3 \u2014 Budget tracking (how it knows how much quota is left)
|
|
|
769
728
|
rl.close();
|
|
770
729
|
}
|
|
771
730
|
}
|
|
772
|
-
|
|
731
|
+
const { backend, budgetProvider, repoUrl, verifyNow, targetDir, target } = answers;
|
|
732
|
+
assertCanWriteConfig(targetDir, target, opts.force === true || answers.overwriteConsented);
|
|
773
733
|
await writeFile2(target, renderConfig(backend, budgetProvider, repoUrl), "utf8");
|
|
774
734
|
console.log(`
|
|
775
735
|
${step("Config", target)}`);
|
|
@@ -789,19 +749,95 @@ ${renderOnboardingSummary({
|
|
|
789
749
|
})}`
|
|
790
750
|
);
|
|
791
751
|
}
|
|
752
|
+
async function collectAnswers(rl, opts, cwd = process.cwd()) {
|
|
753
|
+
let targetDir = cwd;
|
|
754
|
+
let target = join2(targetDir, "afterburner.config.mjs");
|
|
755
|
+
console.log(
|
|
756
|
+
"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"
|
|
757
|
+
);
|
|
758
|
+
console.log(bold("Step 1 of 3 \u2014 Engine (who does the work, and what it can spend)"));
|
|
759
|
+
BACKENDS.forEach((name, i) => {
|
|
760
|
+
console.log(` ${i + 1}. ${name}: ${ENGINE_CHOICES[name]}`);
|
|
761
|
+
});
|
|
762
|
+
const backend = BACKENDS[await askChoice(rl, "Engine [1]: ", BACKENDS.length)] ?? "dry-run";
|
|
763
|
+
console.log(step("Engine", backend));
|
|
764
|
+
console.log(`
|
|
765
|
+
${bold("Step 2 of 3 \u2014 Repository (the allowlist of what it may touch)")}`);
|
|
766
|
+
console.log(' - a local path (e.g. ~/code/my-project, or "." for this folder)');
|
|
767
|
+
console.log(" \u2192 a dry run can scan it for candidate tasks right away");
|
|
768
|
+
console.log(" - a GitHub URL (e.g. https://github.com/you/repo)");
|
|
769
|
+
console.log(" \u2192 saved to your allowlist; used once live runs are enabled");
|
|
770
|
+
console.log(" - leave empty to skip for now (the health check flags it until a repo is added)");
|
|
771
|
+
const repoAnswer = (await rl.question("Repo path or URL: ")).trim();
|
|
772
|
+
const repoPath = localRepoPath(repoAnswer, cwd);
|
|
773
|
+
const repoUrl = repoPath ?? repoAnswer;
|
|
774
|
+
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;
|
|
775
|
+
console.log(step("Repo", repoEcho));
|
|
776
|
+
if (repoPath && repoPath !== targetDir && !hasConfig(repoPath)) {
|
|
777
|
+
console.log("\nWhere should the config live?");
|
|
778
|
+
console.log(` 1. ${repoPath} (next to the repo)`);
|
|
779
|
+
console.log(` 2. ${targetDir} (current directory)`);
|
|
780
|
+
if (await askChoice(rl, "Config location [1]: ", 2) === 0) {
|
|
781
|
+
targetDir = repoPath;
|
|
782
|
+
target = join2(targetDir, "afterburner.config.mjs");
|
|
783
|
+
}
|
|
784
|
+
} else if (repoPath && repoPath !== targetDir) {
|
|
785
|
+
console.log(
|
|
786
|
+
dim(
|
|
787
|
+
`Note: ${repoPath} already has an afterburner config; this one is written to the current directory.`
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const overwriteConsented = await resolveConfigConflict(
|
|
792
|
+
rl,
|
|
793
|
+
targetDir,
|
|
794
|
+
target,
|
|
795
|
+
opts.force === true
|
|
796
|
+
);
|
|
797
|
+
console.log(
|
|
798
|
+
`
|
|
799
|
+
${bold("Step 3 of 3 \u2014 Budget tracking (how it knows how much quota is left)")}`
|
|
800
|
+
);
|
|
801
|
+
console.log(" 1. manual: trust the numbers in the config; tweak them anytime. Easy start.");
|
|
802
|
+
console.log(" 2. claude-usage: read your real usage from Claude Code. Most accurate; needs");
|
|
803
|
+
console.log(" `afterburner statusline install` after setup (the health check reminds you).");
|
|
804
|
+
console.log(" 3. claude-code-transcripts: estimate from local Claude Code session logs.");
|
|
805
|
+
const budgetProvider = BUDGET_PROVIDERS[await askChoice(rl, "Budget tracking [1]: ", BUDGET_PROVIDERS.length)] ?? "manual";
|
|
806
|
+
console.log(step("Budget", budgetProvider));
|
|
807
|
+
const verifyAnswer = (await rl.question("\nRun the health check now (afterburner doctor)? [Y/n]: ")).trim().toLowerCase();
|
|
808
|
+
const verifyNow = verifyAnswer === "" || verifyAnswer === "y" || verifyAnswer === "yes";
|
|
809
|
+
return { backend, budgetProvider, repoUrl, verifyNow, targetDir, target, overwriteConsented };
|
|
810
|
+
}
|
|
811
|
+
async function resolveConfigConflict(rl, targetDir, target, force) {
|
|
812
|
+
const { exact, shadowing } = configConflict(targetDir, target);
|
|
813
|
+
if (shadowing.length > 0) failShadowing(shadowing, target);
|
|
814
|
+
if (!exact || force) return false;
|
|
815
|
+
if (!await confirmOverwrite(rl, exact)) fail("Aborted. Existing config kept.");
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
792
818
|
function assertCanWriteConfig(targetDir, target, force) {
|
|
819
|
+
const { exact, shadowing } = configConflict(targetDir, target);
|
|
820
|
+
if (shadowing.length > 0) failShadowing(shadowing, target);
|
|
821
|
+
if (exact && !force) fail(`${exact} already exists. Pass --force to overwrite it.`);
|
|
822
|
+
}
|
|
823
|
+
function configConflict(targetDir, target) {
|
|
793
824
|
const existing = CONFIG_FILENAMES.map((name) => join2(targetDir, name)).filter(
|
|
794
825
|
(p) => existsSync3(p)
|
|
795
826
|
);
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
)
|
|
804
|
-
|
|
827
|
+
return {
|
|
828
|
+
exact: existing.includes(target) ? target : null,
|
|
829
|
+
shadowing: existing.filter((p) => p !== target)
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function failShadowing(shadowing, target) {
|
|
833
|
+
fail(
|
|
834
|
+
`Remove ${shadowing.join(", ")} first, or cosmiconfig will load it instead of the generated ${target}.`
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
async function confirmOverwrite(rl, target) {
|
|
838
|
+
const answer = (await rl.question(`
|
|
839
|
+
${target} already exists. Overwrite it? [y/N]: `)).trim().toLowerCase();
|
|
840
|
+
return answer === "y" || answer === "yes";
|
|
805
841
|
}
|
|
806
842
|
function hasConfig(dir) {
|
|
807
843
|
return CONFIG_FILENAMES.some((name) => existsSync3(join2(dir, name)));
|
|
@@ -1177,11 +1213,11 @@ function renderDefaultStatusLine(data) {
|
|
|
1177
1213
|
}
|
|
1178
1214
|
function readStdin() {
|
|
1179
1215
|
if (process.stdin.isTTY) return Promise.resolve("");
|
|
1180
|
-
return new Promise((
|
|
1216
|
+
return new Promise((resolve7, reject) => {
|
|
1181
1217
|
let buf = "";
|
|
1182
1218
|
process.stdin.setEncoding("utf8");
|
|
1183
1219
|
process.stdin.on("data", (c) => buf += c);
|
|
1184
|
-
process.stdin.on("end", () =>
|
|
1220
|
+
process.stdin.on("end", () => resolve7(buf));
|
|
1185
1221
|
process.stdin.on("error", reject);
|
|
1186
1222
|
});
|
|
1187
1223
|
}
|
|
@@ -1194,10 +1230,10 @@ async function readWrappedState() {
|
|
|
1194
1230
|
}
|
|
1195
1231
|
}
|
|
1196
1232
|
function passThrough(command, input) {
|
|
1197
|
-
return new Promise((
|
|
1233
|
+
return new Promise((resolve7) => {
|
|
1198
1234
|
const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
|
|
1199
|
-
child.on("error", () =>
|
|
1200
|
-
child.on("close", () =>
|
|
1235
|
+
child.on("error", () => resolve7());
|
|
1236
|
+
child.on("close", () => resolve7());
|
|
1201
1237
|
child.stdin.on("error", () => {
|
|
1202
1238
|
});
|
|
1203
1239
|
child.stdin.end(input);
|
|
@@ -1284,53 +1320,9 @@ async function uninstall() {
|
|
|
1284
1320
|
);
|
|
1285
1321
|
}
|
|
1286
1322
|
|
|
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
|
-
}
|
|
1323
|
+
// src/cli/commands/update.ts
|
|
1324
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1325
|
+
import { dirname as dirname5, resolve as resolve6 } from "path";
|
|
1334
1326
|
|
|
1335
1327
|
// src/cli/update-check.ts
|
|
1336
1328
|
import { mkdir as mkdir5, readFile as readFile2, writeFile as writeFile5 } from "fs/promises";
|
|
@@ -1349,7 +1341,7 @@ async function maybeNotifyUpdate(opts) {
|
|
|
1349
1341
|
packageName: opts.packageName,
|
|
1350
1342
|
currentVersion: opts.currentVersion,
|
|
1351
1343
|
latestVersion: cached.latestVersion,
|
|
1352
|
-
updateCommand:
|
|
1344
|
+
updateCommand: resolveUpdateCommand(opts)
|
|
1353
1345
|
}) : null;
|
|
1354
1346
|
if (cachedNotice) process.stderr.write(`${cachedNotice}
|
|
1355
1347
|
`);
|
|
@@ -1368,13 +1360,13 @@ async function maybeNotifyUpdate(opts) {
|
|
|
1368
1360
|
packageName: opts.packageName,
|
|
1369
1361
|
currentVersion: opts.currentVersion,
|
|
1370
1362
|
latestVersion,
|
|
1371
|
-
updateCommand:
|
|
1363
|
+
updateCommand: resolveUpdateCommand(opts)
|
|
1372
1364
|
});
|
|
1373
1365
|
if (freshNotice) process.stderr.write(`${freshNotice}
|
|
1374
1366
|
`);
|
|
1375
1367
|
}
|
|
1376
1368
|
}
|
|
1377
|
-
var UPDATE_NOTIFY_EXCLUDED = /* @__PURE__ */ new Set(["statusline", "mcp"]);
|
|
1369
|
+
var UPDATE_NOTIFY_EXCLUDED = /* @__PURE__ */ new Set(["statusline", "mcp", "update"]);
|
|
1378
1370
|
function shouldNotifyForCommand(commandName) {
|
|
1379
1371
|
return !UPDATE_NOTIFY_EXCLUDED.has(commandName);
|
|
1380
1372
|
}
|
|
@@ -1429,8 +1421,25 @@ async function fetchLatestVersion(opts) {
|
|
|
1429
1421
|
clearTimeout(timeout);
|
|
1430
1422
|
}
|
|
1431
1423
|
}
|
|
1432
|
-
function
|
|
1433
|
-
return isPathInside2(opts.cliEntryPath, opts.cwd) ? "
|
|
1424
|
+
function detectInstallKind(opts) {
|
|
1425
|
+
return isPathInside2(opts.cliEntryPath, opts.cwd) ? "source" : "global";
|
|
1426
|
+
}
|
|
1427
|
+
function detectGlobalPackageManager(cliEntryPath) {
|
|
1428
|
+
const path = (cliEntryPath ?? "").replaceAll("\\", "/").toLowerCase();
|
|
1429
|
+
if (path.includes("/pnpm/")) return "pnpm";
|
|
1430
|
+
if (path.includes("/.bun/")) return "bun";
|
|
1431
|
+
if (path.includes("/yarn/") || path.includes("/.yarn/")) return "yarn";
|
|
1432
|
+
return "npm";
|
|
1433
|
+
}
|
|
1434
|
+
var GLOBAL_UPDATE_COMMANDS = {
|
|
1435
|
+
npm: (packageName) => `npm install -g ${packageName}@latest`,
|
|
1436
|
+
pnpm: (packageName) => `pnpm add -g ${packageName}@latest`,
|
|
1437
|
+
yarn: (packageName) => `yarn global add ${packageName}@latest`,
|
|
1438
|
+
bun: (packageName) => `bun add -g ${packageName}@latest`
|
|
1439
|
+
};
|
|
1440
|
+
function resolveUpdateCommand(opts) {
|
|
1441
|
+
if (detectInstallKind(opts) === "source") return "pnpm build && npm install -g . --force";
|
|
1442
|
+
return GLOBAL_UPDATE_COMMANDS[detectGlobalPackageManager(opts.cliEntryPath)](opts.packageName);
|
|
1434
1443
|
}
|
|
1435
1444
|
function isPathInside2(candidate, parent) {
|
|
1436
1445
|
if (!candidate) return false;
|
|
@@ -1453,6 +1462,112 @@ function parseSemver2(version) {
|
|
|
1453
1462
|
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
1454
1463
|
}
|
|
1455
1464
|
|
|
1465
|
+
// src/cli/commands/update.ts
|
|
1466
|
+
var DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
1467
|
+
function planUpdate(opts) {
|
|
1468
|
+
const kind = detectInstallKind(opts);
|
|
1469
|
+
const registry = opts.registry ?? (kind === "source" ? void 0 : DEFAULT_REGISTRY);
|
|
1470
|
+
const display = withRegistry(resolveUpdateCommand(opts), registry);
|
|
1471
|
+
if (opts.print) return { mode: "print", display };
|
|
1472
|
+
if (kind === "source") {
|
|
1473
|
+
const root = opts.cliEntryPath ? resolve6(dirname5(opts.cliEntryPath), "..", "..") : opts.cwd;
|
|
1474
|
+
return {
|
|
1475
|
+
mode: "execute",
|
|
1476
|
+
display,
|
|
1477
|
+
step: { run: display, cwd: root },
|
|
1478
|
+
note: `Rebuilds the checkout at ${root}; it does not fetch new code. Run \`git pull\` there first for the latest.`
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
return { mode: "execute", display, step: { run: display } };
|
|
1482
|
+
}
|
|
1483
|
+
function registerUpdate(program2, packageInfo2) {
|
|
1484
|
+
program2.command("update").description("Update Afterburner to the latest release").option("--print", "only print the update command, do not run it").option(
|
|
1485
|
+
"--registry <url>",
|
|
1486
|
+
"registry for the install (default: registry.npmjs.org, matching the update check)"
|
|
1487
|
+
).action(async (opts) => {
|
|
1488
|
+
const plan = planUpdate({
|
|
1489
|
+
...packageInfo2,
|
|
1490
|
+
cliEntryPath: process.argv[1],
|
|
1491
|
+
cwd: process.cwd(),
|
|
1492
|
+
print: opts.print === true,
|
|
1493
|
+
registry: opts.registry
|
|
1494
|
+
});
|
|
1495
|
+
console.log(section(emoji.rocket, "Update Afterburner"));
|
|
1496
|
+
if (plan.mode === "print" || !plan.step) {
|
|
1497
|
+
console.log(nextCmd(plan.display, "run this to update"));
|
|
1498
|
+
console.log(nextCmd("afterburner doctor --check-updates", "verify afterwards"));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
console.log(` ${dim("will run:")} ${plan.display}`);
|
|
1502
|
+
if (plan.note) console.log(` ${yellow(plan.note)}`);
|
|
1503
|
+
console.log(`
|
|
1504
|
+
${dim(`$ ${plan.step.run}`)}`);
|
|
1505
|
+
const result = spawnSync2(plan.step.run, {
|
|
1506
|
+
shell: true,
|
|
1507
|
+
stdio: "inherit",
|
|
1508
|
+
cwd: plan.step.cwd
|
|
1509
|
+
});
|
|
1510
|
+
if (result.error || result.status !== 0) {
|
|
1511
|
+
const why = result.error ? result.error.message : `exit code ${result.status}`;
|
|
1512
|
+
fail(`
|
|
1513
|
+
Update failed (${why}). Your existing install is unchanged.`);
|
|
1514
|
+
}
|
|
1515
|
+
console.log(`
|
|
1516
|
+
${green("Updated. Run `afterburner doctor --check-updates` to confirm.")}`);
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
function withRegistry(command, registry) {
|
|
1520
|
+
return registry ? `${command} --registry=${registry}` : command;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// src/cli/commands/watch.ts
|
|
1524
|
+
function registerWatch(program2) {
|
|
1525
|
+
program2.command("watch").description("Foreground scheduler daemon; prefer schedule install for unattended runs").option("--config <path>", "path to a config file").option(
|
|
1526
|
+
"--live",
|
|
1527
|
+
"arm live execution for every tick; only takes effect when agent.backend is a live engine (two-part opt-in)"
|
|
1528
|
+
).action(async (opts) => {
|
|
1529
|
+
const { config, filepath } = await loadConfigOrExit(opts.config);
|
|
1530
|
+
const live = opts.live === true;
|
|
1531
|
+
const runner = createRunner(config, live);
|
|
1532
|
+
const downgrade = liveDowngradeReason(config, live, filepath);
|
|
1533
|
+
if (downgrade) console.error(errYellow(downgrade));
|
|
1534
|
+
const { provider, source } = createBudgetProvider(config, {
|
|
1535
|
+
onNote: (m) => console.error(`[afterburner] ${m}`)
|
|
1536
|
+
});
|
|
1537
|
+
console.log(banner());
|
|
1538
|
+
console.log(
|
|
1539
|
+
`${deco(emoji.jet)}${bold("watching")} ${dim(
|
|
1540
|
+
`cron="${config.schedule.cron}" tz=${config.schedule.timezone} mode=${runner.backend === "dry-run" ? "dry-run" : `LIVE (${runner.backend})`} budget=${source} config=${filepath}`
|
|
1541
|
+
)}`
|
|
1542
|
+
);
|
|
1543
|
+
const handle = startWatch({
|
|
1544
|
+
cron: config.schedule.cron,
|
|
1545
|
+
timezone: config.schedule.timezone,
|
|
1546
|
+
onTick: async () => {
|
|
1547
|
+
console.log(`[afterburner] tick ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1548
|
+
const outcomes = await runOnce({
|
|
1549
|
+
config,
|
|
1550
|
+
budgetProvider: provider,
|
|
1551
|
+
selector: createSelector(config),
|
|
1552
|
+
runner,
|
|
1553
|
+
store: new JsonlRunStore(),
|
|
1554
|
+
notifier: new ConsoleNotifier()
|
|
1555
|
+
});
|
|
1556
|
+
for (const outcome of outcomes) {
|
|
1557
|
+
console.log(`[afterburner] ${outcome.repoUrl}: ${outcome.status}, ${outcome.reason}`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
const stop = () => {
|
|
1562
|
+
console.log("\n[afterburner] stopping watcher");
|
|
1563
|
+
handle.stop();
|
|
1564
|
+
process.exit(0);
|
|
1565
|
+
};
|
|
1566
|
+
process.on("SIGINT", stop);
|
|
1567
|
+
process.on("SIGTERM", stop);
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1456
1571
|
// src/cli/welcome.ts
|
|
1457
1572
|
function renderWelcome(configPath) {
|
|
1458
1573
|
const lines = [banner(), ""];
|
|
@@ -1510,6 +1625,7 @@ registerWatch(program);
|
|
|
1510
1625
|
registerSchedule(program);
|
|
1511
1626
|
registerLog(program);
|
|
1512
1627
|
registerSkill(program);
|
|
1628
|
+
registerUpdate(program, packageInfo);
|
|
1513
1629
|
registerStatusline(program);
|
|
1514
1630
|
registerMcp(program);
|
|
1515
1631
|
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.6",
|
|
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",
|