@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 CHANGED
@@ -1,14 +1,20 @@
1
1
  <p align="center">
2
- <img src="assets/afterburner-logo.png" alt="Afterburner logo" width="150" />
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>Turn unused Claude subscription quota into small, reviewed pull requests.</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
- <code>budget-aware</code> · <code>one bounded task</code> · <code>dry-run first</code> ·
24
- <code>no telemetry</code>
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 watches how much of your Claude budget is left. When there's enough headroom, it
28
- picks one small, checkable coding task against a repo you've allowlisted and shows exactly
29
- what it would do.
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
- The coding agent isn't the interesting part; that already exists. What Afterburner adds is
32
- the decision layer: working out when it's safe to spend, and what's worth spending on.
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
- | What it does | Why it matters |
38
- | ------------------ | ----------------------------------------------------------------------- |
39
- | Checks both caps | Uses weekly headroom and 5-hour session availability before doing work. |
40
- | Keeps work bounded | Runs one task per ignition cycle, sized to fit your configured budget. |
41
- | Stays review-first | Plans a `claude/` branch and PR; live output stays away from default. |
42
- | Starts safe | Dry-run is the default, and live runs require a two-part opt-in. |
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 first release isn't on npm yet, so install from source —
107
- clone the repo, then `pnpm install && pnpm build && npm install -g .`. Once published, the
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 # once published; until then see above
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=pimmesz/afterburner&type=Date)](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
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.1",
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: "./dist/cli/index.js",
47
- abr: "./dist/cli/index.js"
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 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;
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
- 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";
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
- assertCanWriteConfig(targetDir, target, opts.force === true);
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
- 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
- }
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((resolve6, reject) => {
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", () => resolve6(buf));
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((resolve6) => {
1233
+ return new Promise((resolve7) => {
1198
1234
  const child = spawn(command, { shell: true, stdio: ["pipe", "inherit", "inherit"] });
1199
- child.on("error", () => resolve6());
1200
- child.on("close", () => resolve6());
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/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
- }
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: updateCommand(opts)
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: updateCommand(opts)
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 updateCommand(opts) {
1433
- return isPathInside2(opts.cliEntryPath, opts.cwd) ? "pnpm build && npm install -g ." : `npm install -g ${opts.packageName}@latest`;
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) {
@@ -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.1",
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": "./dist/cli/index.js",
16
- "abr": "./dist/cli/index.js"
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",