@armstrongnate/april 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,18 +110,54 @@ Healthy logs include `Starting webhook forwarder for <repo>` and no immediate er
110
110
 
111
111
  ## Commands
112
112
 
113
+ ### Setup & service
114
+
113
115
  | Command | What it does |
114
116
  | --- | --- |
115
- | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to the configured agent's skill dir (`~/.claude/skills/` for claude, `~/.agents/skills/` for codex). **Only writes files that don't already exist** — never overwrites. |
117
+ | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the bundled skills (`issue-worker`, `issue-investigator`) to the configured agent's skill dir (`~/.claude/skills/` for claude, `~/.agents/skills/` for codex). **Only writes files that don't already exist** — never overwrites. |
116
118
  | `april install` | Installs and starts the user service. Pass `--print` to see the unit/plist without writing it. |
117
- | `april install-skill [-y]` | Install or refresh the issue-worker skill. Prompts before overwriting an existing copy; `--yes` skips the prompt (use in non-interactive scripts). |
118
- | `april upgrade [VER]` | Upgrade the npm package, regenerate the unit, restart the service, and reconcile the skill. |
119
+ | `april install-skill [-y]` | Install or refresh the bundled skills. Prompts before overwriting a changed copy; `--yes` skips the prompt (use in non-interactive scripts). |
120
+ | `april upgrade [VER]` | Upgrade the npm package, regenerate the unit, restart the service, and reconcile the skills. |
119
121
  | `april uninstall` | Stops and removes the service. |
120
122
  | `april start` / `stop` / `restart` | Lifecycle. |
121
123
  | `april status` | Shows service status. |
122
124
  | `april logs -f [-n N]` | Streams logs. `-n` sets line count (default 100). |
123
125
  | `april daemon` | Runs the worker in the foreground (for debugging; the service uses `dist/index.js` directly). |
124
126
 
127
+ ### Runtime & work
128
+
129
+ These run **in-process** against the filesystem, the session backend, and `gh` — so they work whether or not the daemon is running. State is derived from `.worktrees/gh-*` dirs and live sessions, not from daemon memory.
130
+
131
+ | Command | What it does |
132
+ | --- | --- |
133
+ | `april ps [--json]` | Lists active work — issues in flight and `inv-` investigations — with session liveness and the owning repo. Shows a daemon-liveness line (uptime, forwarders) when the daemon's `/status` endpoint is reachable. |
134
+ | `april config [--path\|--validate\|--json]` | Prints the resolved config and its path. `--validate` exits non-zero if invalid; `--path` prints just the path; `--json` emits JSON. |
135
+ | `april doctor` | Health checklist without starting the daemon: config validity, required tools on PATH, `gh-webhook` extension, `gh` auth, repo paths, service unit, daemon reachability, and (Linux) linger. Exits non-zero on hard failures. |
136
+ | `april run <issue> [--repo O/N]` | Manually start work on an issue (`123` or `owner/name#123`) — the same path the daemon takes on a labeled webhook, minus the label requirement. `--repo` disambiguates a bare number across repos. |
137
+ | `april cancel <issue> [--requeue]` | Stop an issue's work: kill the session and remove the worktree, and remove the `agent:wip` label. `--requeue` re-adds `agent:todo` so the daemon picks it up again. |
138
+ | `april kill <slug\|issue> [--worktree]` | Kill a single session by slug or issue number (handles `inv-` investigations too). `--worktree` also removes the backing worktree. |
139
+ | `april clean [--force] [--repo O/N]` | Prune orphaned worktrees — stale (no live session) work whose issue is **closed** and has **no open PR**. Dry run unless `--force`; anything in progress or in review is kept. |
140
+ | `april investigate "<problem>" [--repo O/N] [--auto]` | Dispatch a research agent in the current directory to investigate a problem and file a GitHub issue. See [Investigate](#investigate) below. |
141
+
142
+ ## Investigate
143
+
144
+ `april investigate` is the front half of the loop: it turns a vague problem into a well-scoped GitHub issue, which the daemon can then pick up and implement.
145
+
146
+ ```bash
147
+ april investigate "study space pull-to-refresh shows a duplicate spinner on ios"
148
+ ```
149
+
150
+ It spawns a research agent (using the configured `llm` and the bundled `issue-investigator` skill) **in the current working directory** — which need not be a repo, and may be your home directory. This is deliberate: an investigation can span repos, and the owning repo isn't always known up front. The agent reads the configured repos' local checkouts and uses `gh` to research, decides where the work belongs, and files the issue there.
151
+
152
+ Each investigation runs as its own session (named `inv-…`), so it shows up in `april ps` and can be torn down with `april kill <slug>`.
153
+
154
+ **Modes:**
155
+
156
+ - **Deferred (default).** The agent creates the issue assigned to you, *without* the trigger label, and prints the URL. You review it and label it `agent:todo` when you're ready to hand it to the daemon. This matches the usual discovery → review → handoff flow.
157
+ - **`--auto`.** The agent also applies the trigger label, so the daemon picks the issue up and starts implementation immediately — one command goes from prompt → research → issue → PR.
158
+
159
+ Use `--repo OWNER/NAME` to suggest the owning repo (the agent still decides), and `--dry-run` to print the session name and the exact prompt that would be dispatched without spawning anything.
160
+
125
161
  ## Environment variables
126
162
 
127
163
  The daemon reads extra env vars from `~/.config/april/env`. One `KEY=VALUE` per line, `#` for comments, optional double-quotes around values:
@@ -168,7 +204,7 @@ codex:
168
204
  | Skill install dir | `~/.claude/skills/` | `~/.agents/skills/` |
169
205
  | Default approval behavior | `permissionMode: "auto"` | `askForApproval: "never"` |
170
206
 
171
- The bundled `issue-worker` skill is the same SKILL.md for both; april just installs it under the right tree based on `llm`. Switching agents later: edit `config.yaml`, then run `april install-skill && april restart`.
207
+ The bundled skills (`issue-worker`, used by the daemon; `issue-investigator`, used by `april investigate`) are the same SKILL.md for both agents; april just installs them under the right tree based on `llm`. Switching agents later: edit `config.yaml`, then run `april install-skill && april restart`.
172
208
 
173
209
  ## Service backend
174
210
 
package/dist/cli.js CHANGED
@@ -3,31 +3,58 @@ import { backend } from "./service/index.js";
3
3
  import { run as runInit } from "./commands/init.js";
4
4
  import { run as runUpgrade } from "./commands/upgrade.js";
5
5
  import { run as runInstallSkill } from "./commands/install-skill.js";
6
+ import { run as runConfig } from "./commands/config.js";
7
+ import { run as runDoctor } from "./commands/doctor.js";
8
+ import { run as runPs } from "./commands/ps.js";
9
+ import { run as runRunIssue } from "./commands/run-issue.js";
10
+ import { run as runCancel } from "./commands/cancel.js";
11
+ import { run as runKill } from "./commands/kill.js";
12
+ import { run as runClean } from "./commands/clean.js";
13
+ import { run as runInvestigate } from "./commands/investigate.js";
6
14
  const HELP = `april — issue worker
7
15
 
8
16
  Usage:
9
17
  april <command> [options]
10
18
 
11
- Commands:
12
- init Copy bundled config, skill, and env file if missing.
19
+ Setup:
20
+ init Copy bundled config, skills, and env file if missing.
13
21
  install [--print] Install and start the user service. --print emits the unit/plist to stdout instead.
14
- install-skill [-y] Install or refresh the issue-worker skill. Prompts before overwriting an existing
22
+ install-skill [-y] Install or refresh the bundled skills. Prompts before overwriting a changed
15
23
  one; --yes (-y) skips the prompt.
16
- upgrade [VER] Upgrade the npm package, regenerate the unit, restart, and reconcile the skill.
24
+ upgrade [VER] Upgrade the npm package, regenerate the unit, restart, and reconcile the skills.
17
25
  VER defaults to "latest". --with npm|pnpm|yarn overrides the package manager.
18
26
  uninstall Stop and remove the user service
27
+
28
+ Service lifecycle:
19
29
  start Start the service
20
30
  stop Stop the service
21
31
  restart Restart the service
22
32
  status Show service status
23
33
  logs [-f] [-n N] Show service logs (-f to follow, -n lines, default 100)
24
34
  daemon Run april in the foreground (used by the service; rarely invoked directly)
35
+
36
+ Runtime:
37
+ ps [--json] List active work (issues in flight + investigations).
38
+ config [--path|--validate|--json]
39
+ Print/validate the resolved config.
40
+ doctor Check prereqs and health (config, tools, gh auth, repos, service, daemon).
41
+
42
+ Work:
43
+ run <issue> Manually start work on an issue (123 or owner/name#123). --repo to disambiguate.
44
+ cancel <issue> Stop an issue's work (kill session + remove worktree). --requeue re-adds agent:todo.
45
+ kill <slug|issue> Kill one session (incl. investigations). --worktree also removes the worktree.
46
+ clean [--force] Prune orphaned worktrees (closed issue, no open PR). Dry run unless --force.
47
+ investigate, inv "<problem>" [--repo O/N] [--auto]
48
+ Dispatch a research agent in the current dir to investigate a problem and file
49
+ a GitHub issue. Deferred (review) by default; --auto labels it for pickup.
50
+
51
+ Meta:
25
52
  help Show this help
26
53
  version Show version
27
54
 
28
55
  Notes:
29
56
  Nothing is ever overwritten silently. To reset config, delete ~/.config/april/config.yaml
30
- and re-run init. To refresh the skill, use install-skill (it prompts before overwriting).
57
+ and re-run init. To refresh skills, use install-skill (it prompts before overwriting).
31
58
  `;
32
59
  function parseLogsArgs(args) {
33
60
  let follow = false;
@@ -83,6 +110,30 @@ async function main() {
83
110
  if (cmd === "install-skill") {
84
111
  return await runInstallSkill(rest);
85
112
  }
113
+ if (cmd === "config") {
114
+ return runConfig(rest);
115
+ }
116
+ if (cmd === "doctor") {
117
+ return await runDoctor(rest);
118
+ }
119
+ if (cmd === "ps") {
120
+ return await runPs(rest);
121
+ }
122
+ if (cmd === "run") {
123
+ return await runRunIssue(rest);
124
+ }
125
+ if (cmd === "cancel") {
126
+ return await runCancel(rest);
127
+ }
128
+ if (cmd === "kill") {
129
+ return await runKill(rest);
130
+ }
131
+ if (cmd === "clean") {
132
+ return await runClean(rest);
133
+ }
134
+ if (cmd === "investigate" || cmd === "inv") {
135
+ return await runInvestigate(rest);
136
+ }
86
137
  if (cmd === "daemon") {
87
138
  // Run the long-running process inline. Importing index.js triggers main();
88
139
  // we then hang forever and let its SIGINT/SIGTERM handlers terminate the process.
@@ -0,0 +1,72 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { findConfigPath, parseConfigFile } from "../config.js";
3
+ import { handlePrClosed } from "../spawner.js";
4
+ import { parseIssueRef, resolveRepo, resolveActiveSlug } from "../work.js";
5
+ const USAGE = `april cancel <issue> [--repo OWNER/NAME] [--requeue]
6
+
7
+ Stop work on an issue: kill its session and remove its worktree.
8
+ Removes the agent:wip label so it won't look in-flight.
9
+
10
+ <issue> 123, #123, or owner/name#123
11
+ --repo Repo to act on when <issue> is a bare number and multiple are configured.
12
+ --requeue Re-add agent:todo so the daemon picks it up again.`;
13
+ function editLabels(repo, issueNumber, requeue) {
14
+ const args = [
15
+ "issue", "edit", String(issueNumber),
16
+ "--repo", `${repo.owner}/${repo.name}`,
17
+ "--remove-label", "agent:wip",
18
+ ];
19
+ if (requeue)
20
+ args.push("--add-label", "agent:todo");
21
+ try {
22
+ execFileSync("gh", args, { timeout: 15_000, stdio: "pipe" });
23
+ console.log(`Labels updated: removed agent:wip${requeue ? ", added agent:todo" : ""}`);
24
+ }
25
+ catch (err) {
26
+ console.warn(`Could not update labels: ${err instanceof Error ? err.message : String(err)}`);
27
+ }
28
+ }
29
+ export async function run(args) {
30
+ let ref;
31
+ let repoFlag;
32
+ let requeue = false;
33
+ for (let i = 0; i < args.length; i++) {
34
+ const a = args[i];
35
+ if (a === "--repo") {
36
+ repoFlag = args[++i];
37
+ if (!repoFlag)
38
+ throw new Error("--repo requires a value");
39
+ }
40
+ else if (a === "--requeue") {
41
+ requeue = true;
42
+ }
43
+ else if (a.startsWith("--")) {
44
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
45
+ return 2;
46
+ }
47
+ else if (!ref) {
48
+ ref = a;
49
+ }
50
+ else {
51
+ console.error(`Unexpected argument: ${a}\n\n${USAGE}`);
52
+ return 2;
53
+ }
54
+ }
55
+ if (!ref) {
56
+ console.error(`Missing issue reference.\n\n${USAGE}`);
57
+ return 2;
58
+ }
59
+ const config = parseConfigFile(findConfigPath());
60
+ const { repoRef, number } = parseIssueRef(ref);
61
+ const repo = resolveRepo(config, repoFlag ?? repoRef);
62
+ const slug = await resolveActiveSlug(repo, number, config);
63
+ if (!slug) {
64
+ console.log(`Nothing active for ${repo.owner}/${repo.name}#${number}.`);
65
+ return 0;
66
+ }
67
+ console.log(`Cancelling ${repo.owner}/${repo.name}#${number} (${slug})…`);
68
+ await handlePrClosed(repo, slug, config);
69
+ editLabels(repo, number, requeue);
70
+ console.log("Done.");
71
+ return 0;
72
+ }
@@ -0,0 +1,118 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { findConfigPath, parseConfigFile } from "../config.js";
4
+ import { getSessionBackend } from "../session/index.js";
5
+ import { listActiveWork } from "../work.js";
6
+ const execFileP = promisify(execFile);
7
+ const USAGE = `april clean [--repo OWNER/NAME] [--force]
8
+
9
+ Prune orphaned worktrees — stale (no live session) work whose issue is closed
10
+ on GitHub and has no open PR. Conservative by design: anything in progress,
11
+ still open, or in review (open PR) is kept.
12
+
13
+ --repo Limit to one repo.
14
+ --force Actually remove. Without it, clean only reports (dry run).`;
15
+ async function gh(repoKey, args) {
16
+ const { stdout } = await execFileP("gh", ["--repo", repoKey, ...args], { timeout: 30_000 });
17
+ return stdout;
18
+ }
19
+ async function classify(item) {
20
+ if (item.kind === "investigation")
21
+ return { action: "keep", reason: "investigation (running)" };
22
+ if (item.sessionAlive)
23
+ return { action: "keep", reason: "session live" };
24
+ if (!item.worktreePath || !item.repo)
25
+ return { action: "keep", reason: "no worktree" };
26
+ if (!item.issueNumber)
27
+ return { action: "keep", reason: "no issue number" };
28
+ // Stale worktree — cross-reference GitHub before declaring it an orphan.
29
+ const [stateOut, prOut] = await Promise.allSettled([
30
+ gh(item.repo, ["issue", "view", String(item.issueNumber), "--json", "state"]),
31
+ gh(item.repo, ["pr", "list", "--head", item.slug, "--state", "open", "--json", "number"]),
32
+ ]);
33
+ if (stateOut.status !== "fulfilled")
34
+ return { action: "keep", reason: "issue state unknown" };
35
+ const state = JSON.parse(stateOut.value).state?.toLowerCase();
36
+ if (state !== "closed")
37
+ return { action: "keep", reason: `issue ${state ?? "?"}` };
38
+ if (prOut.status !== "fulfilled")
39
+ return { action: "keep", reason: "PR state unknown" };
40
+ if (JSON.parse(prOut.value).length > 0)
41
+ return { action: "keep", reason: "open PR" };
42
+ return { action: "remove", reason: "issue closed, no open PR" };
43
+ }
44
+ function removeWorktree(config, item) {
45
+ try {
46
+ execFileSync("wt", ["remove", item.slug, "-f", "-D"], {
47
+ cwd: item.repoPath,
48
+ timeout: 60_000,
49
+ stdio: "pipe",
50
+ });
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ console.warn(` wt remove ${item.slug} failed: ${err instanceof Error ? err.message : String(err)}`);
55
+ return false;
56
+ }
57
+ }
58
+ export async function run(args) {
59
+ let repoFlag;
60
+ let force = false;
61
+ for (let i = 0; i < args.length; i++) {
62
+ const a = args[i];
63
+ if (a === "--repo") {
64
+ repoFlag = args[++i];
65
+ if (!repoFlag)
66
+ throw new Error("--repo requires a value");
67
+ }
68
+ else if (a === "--force") {
69
+ force = true;
70
+ }
71
+ else {
72
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
73
+ return 2;
74
+ }
75
+ }
76
+ const config = parseConfigFile(findConfigPath());
77
+ let items = await listActiveWork(config);
78
+ if (repoFlag)
79
+ items = items.filter((it) => it.repo === repoFlag || it.repo?.endsWith(`/${repoFlag}`));
80
+ if (items.length === 0) {
81
+ console.log("Nothing to clean.");
82
+ return 0;
83
+ }
84
+ console.log(`april clean ${force ? "(--force)" : "(dry run)"} — checking ${items.length} item(s) against GitHub…\n`);
85
+ const dispositions = await Promise.all(items.map(classify));
86
+ const removable = [];
87
+ for (let i = 0; i < items.length; i++) {
88
+ const it = items[i];
89
+ const d = dispositions[i];
90
+ if (d.action === "remove") {
91
+ removable.push(it);
92
+ console.log(` REMOVE ${it.slug} (${d.reason})`);
93
+ }
94
+ else {
95
+ console.log(` keep ${it.slug} (${d.reason})`);
96
+ }
97
+ }
98
+ console.log("");
99
+ if (removable.length === 0) {
100
+ console.log("No orphans to remove.");
101
+ return 0;
102
+ }
103
+ if (!force) {
104
+ console.log(`${removable.length} orphaned worktree(s) eligible for removal. Re-run with --force to remove them.`);
105
+ return 0;
106
+ }
107
+ const backend = getSessionBackend(config);
108
+ let removed = 0;
109
+ for (const it of removable) {
110
+ await backend.kill(it.slug); // no-op if no session
111
+ if (removeWorktree(config, it)) {
112
+ removed++;
113
+ console.log(` removed ${it.slug}`);
114
+ }
115
+ }
116
+ console.log(`\nRemoved ${removed}/${removable.length} orphan(s).`);
117
+ return 0;
118
+ }
@@ -0,0 +1,47 @@
1
+ import { stringify as stringifyYaml } from "yaml";
2
+ import { findConfigPath, parseConfigFile } from "../config.js";
3
+ const USAGE = `april config [--path] [--validate] [--json]
4
+
5
+ (no flags) Print the resolved config file path and its parsed contents.
6
+ --path Print only the resolved config file path.
7
+ --validate Parse and validate the config; exit non-zero if invalid.
8
+ --json Emit the parsed config as JSON instead of YAML.`;
9
+ export function run(args) {
10
+ for (const a of args) {
11
+ if (!["--path", "--validate", "--json"].includes(a)) {
12
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
13
+ return 2;
14
+ }
15
+ }
16
+ const wantPath = args.includes("--path");
17
+ const wantValidate = args.includes("--validate");
18
+ const wantJson = args.includes("--json");
19
+ let path;
20
+ try {
21
+ path = findConfigPath();
22
+ }
23
+ catch (err) {
24
+ console.error(err instanceof Error ? err.message : String(err));
25
+ return 1;
26
+ }
27
+ if (wantPath) {
28
+ console.log(path);
29
+ return 0;
30
+ }
31
+ let config;
32
+ try {
33
+ config = parseConfigFile(path);
34
+ }
35
+ catch (err) {
36
+ console.error(`✗ Invalid config (${path}):`);
37
+ console.error(` ${err instanceof Error ? err.message : String(err)}`);
38
+ return 1;
39
+ }
40
+ if (wantValidate) {
41
+ console.log(`✓ Config is valid (${path})`);
42
+ return 0;
43
+ }
44
+ console.log(`# ${path}`);
45
+ console.log(wantJson ? JSON.stringify(config, null, 2) : stringifyYaml(config).trimEnd());
46
+ return 0;
47
+ }
@@ -0,0 +1,119 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { findConfigPath, parseConfigFile, checkTool, requiredTools } from "../config.js";
4
+ import { getAgent } from "../agents.js";
5
+ import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "../precheck.js";
6
+ import { systemdUnitPath, launchdPlistPath } from "../service/paths.js";
7
+ import { lingerEnabled } from "../service/systemd.js";
8
+ import { daemonReachable } from "../daemon.js";
9
+ const GLYPH = { ok: "✓", warn: "⚠", fail: "✗" };
10
+ class Report {
11
+ rows = [];
12
+ add(level, label, detail) {
13
+ this.rows.push({ level, label, detail });
14
+ }
15
+ print() {
16
+ for (const r of this.rows) {
17
+ console.log(` ${GLYPH[r.level]} ${r.label}${r.detail ? ` — ${r.detail}` : ""}`);
18
+ }
19
+ }
20
+ get failed() {
21
+ return this.rows.some((r) => r.level === "fail");
22
+ }
23
+ }
24
+ function isGitRepo(path) {
25
+ try {
26
+ execFileSync("git", ["-C", path, "rev-parse", "--git-dir"], { stdio: "pipe", timeout: 10_000 });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function ghAuthOk() {
34
+ try {
35
+ const out = execFileSync("gh", ["auth", "token"], {
36
+ encoding: "utf-8",
37
+ stdio: ["ignore", "pipe", "ignore"],
38
+ timeout: 10_000,
39
+ });
40
+ return out.trim().length > 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export async function run(_args) {
47
+ const report = new Report();
48
+ // 1. Config
49
+ let config;
50
+ try {
51
+ const path = findConfigPath();
52
+ config = parseConfigFile(path);
53
+ report.add("ok", "config", `valid (${path})`);
54
+ }
55
+ catch (err) {
56
+ report.add("fail", "config", err instanceof Error ? err.message : String(err));
57
+ }
58
+ // 2. Required tools
59
+ const tools = config
60
+ ? requiredTools(getAgent(config.llm).cli, config.sessionManager ?? "tmux")
61
+ : ["gh", "git"];
62
+ for (const tool of tools) {
63
+ if (checkTool(tool))
64
+ report.add("ok", `tool: ${tool}`, "on PATH");
65
+ else
66
+ report.add("fail", `tool: ${tool}`, "not found on PATH");
67
+ }
68
+ // 3. gh-webhook extension
69
+ if (isGhWebhookExtensionInstalled())
70
+ report.add("ok", "gh extension cli/gh-webhook", "installed");
71
+ else
72
+ report.add("fail", "gh extension cli/gh-webhook", `missing — ${GH_EXTENSION_INSTALL_CMD}`);
73
+ // 4. gh auth
74
+ if (ghAuthOk())
75
+ report.add("ok", "gh auth", "token available");
76
+ else
77
+ report.add("warn", "gh auth", "no extractable token — set GH_TOKEN/GH_ENTERPRISE_TOKEN in ~/.config/april/env");
78
+ // 5. Repo paths
79
+ if (config) {
80
+ for (const repo of config.repos) {
81
+ const key = `${repo.owner}/${repo.name}`;
82
+ if (!existsSync(repo.path))
83
+ report.add("fail", `repo: ${key}`, `path missing: ${repo.path}`);
84
+ else if (!isGitRepo(repo.path))
85
+ report.add("fail", `repo: ${key}`, `not a git repo: ${repo.path}`);
86
+ else
87
+ report.add("ok", `repo: ${key}`, repo.path);
88
+ }
89
+ }
90
+ // 6. Service installed
91
+ const unitPath = process.platform === "darwin" ? launchdPlistPath() : systemdUnitPath();
92
+ if (existsSync(unitPath))
93
+ report.add("ok", "service unit", unitPath);
94
+ else
95
+ report.add("warn", "service unit", `not installed — run \`april install\``);
96
+ // 7. Daemon reachable
97
+ if (config) {
98
+ if (await daemonReachable(config.port))
99
+ report.add("ok", "daemon", `responding on :${config.port}`);
100
+ else
101
+ report.add("warn", "daemon", `not reachable on :${config.port} (not running?)`);
102
+ }
103
+ // 8. Linger (Linux only)
104
+ if (process.platform === "linux") {
105
+ if (lingerEnabled())
106
+ report.add("ok", "linger", "enabled");
107
+ else
108
+ report.add("warn", "linger", `disabled — april stops at logout. sudo loginctl enable-linger ${process.env.USER ?? "$USER"}`);
109
+ }
110
+ console.log("april doctor\n");
111
+ report.print();
112
+ console.log("");
113
+ if (report.failed) {
114
+ console.log("✗ One or more required checks failed.");
115
+ return 1;
116
+ }
117
+ console.log("✓ All required checks passed.");
118
+ return 0;
119
+ }
@@ -4,7 +4,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { ensureEnvFile, envFilePath } from "../service/envfile.js";
6
6
  import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "../precheck.js";
7
- import { skillDestPath, bundledSkillPath, compareSkill } from "../skill.js";
7
+ import { skillDestPath, bundledSkillPath, compareSkill, ALL_BUNDLED_SKILLS } from "../skill.js";
8
8
  // Resolve the bundled package root from this file's installed location.
9
9
  // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml lives)
10
10
  function packageRoot() {
@@ -32,16 +32,18 @@ export function run(_args) {
32
32
  return 1;
33
33
  }
34
34
  const configResult = copyIfMissing(configSrc, configDst, "config");
35
- const skillSrc = bundledSkillPath();
36
- if (!existsSync(skillSrc)) {
37
- console.error(` Cannot find bundled skill at ${skillSrc}`);
38
- return 1;
39
- }
40
- // Skill install path depends on the configured LLM. Before init writes
35
+ // Skill install paths depend on the configured LLM. Before init writes
41
36
  // a config, configuredLlmKind() falls back to claude, which matches the
42
37
  // bundled example. If the user switches the agent later, they re-run
43
38
  // `april install-skill`.
44
- copyIfMissing(skillSrc, skillDestPath(), "skill");
39
+ for (const skill of ALL_BUNDLED_SKILLS) {
40
+ const skillSrc = bundledSkillPath(skill);
41
+ if (!existsSync(skillSrc)) {
42
+ console.error(` Cannot find bundled skill at ${skillSrc}`);
43
+ return 1;
44
+ }
45
+ copyIfMissing(skillSrc, skillDestPath(skill), `skill ${skill}`);
46
+ }
45
47
  const envState = ensureEnvFile();
46
48
  console.log(` env: ${envState === "created" ? "wrote" : "already exists"} ${envFilePath()}`);
47
49
  console.log("");
@@ -54,10 +56,10 @@ export function run(_args) {
54
56
  console.log(` Install with: ${GH_EXTENSION_INSTALL_CMD}`);
55
57
  console.log(" (april will refuse to start without it.)");
56
58
  }
57
- // If the installed skill differs from what we shipped, surface it without prompting.
58
- if (compareSkill() === "differs-from-bundled") {
59
+ // If any installed skill differs from what we shipped, surface it without prompting.
60
+ if (ALL_BUNDLED_SKILLS.some((s) => compareSkill(s) === "differs-from-bundled")) {
59
61
  console.log("");
60
- console.log(` i installed skill differs from bundled. Refresh with: april install-skill`);
62
+ console.log(` i an installed skill differs from bundled. Refresh with: april install-skill`);
61
63
  }
62
64
  console.log("");
63
65
  if (configResult === "wrote") {
@@ -2,50 +2,59 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin, stdout } from "node:process";
5
- import { skillDestPath, bundledSkillPath, compareSkill } from "../skill.js";
6
- export async function run(args) {
7
- const yes = args.includes("--yes") || args.includes("-y");
8
- const src = bundledSkillPath();
5
+ import { skillDestPath, bundledSkillPath, compareSkill, ALL_BUNDLED_SKILLS } from "../skill.js";
6
+ async function confirm(question) {
7
+ const rl = createInterface({ input: stdin, output: stdout });
8
+ const answer = (await rl.question(question)).trim().toLowerCase();
9
+ rl.close();
10
+ return answer === "y" || answer === "yes";
11
+ }
12
+ /** Install or refresh one bundled skill. Returns 0 on success, non-zero on hard error. */
13
+ async function installSkill(skill, yes) {
14
+ const src = bundledSkillPath(skill);
9
15
  if (!existsSync(src)) {
10
16
  console.error(`Cannot find bundled skill at ${src}`);
11
17
  return 1;
12
18
  }
13
- const dst = skillDestPath();
14
- const state = compareSkill();
19
+ const dst = skillDestPath(skill);
20
+ const state = compareSkill(skill);
15
21
  if (state === "matches-bundled") {
16
- console.log(`✓ Skill at ${dst} is already up to date`);
22
+ console.log(`✓ ${skill}: already up to date (${dst})`);
17
23
  return 0;
18
24
  }
19
25
  if (state === "missing") {
20
26
  mkdirSync(dirname(dst), { recursive: true });
21
27
  copyFileSync(src, dst);
22
- console.log(`✓ Installed skill at ${dst}`);
28
+ console.log(`✓ ${skill}: installed at ${dst}`);
23
29
  return 0;
24
30
  }
25
31
  // differs-from-bundled
26
- console.log("Bundled issue-worker skill differs from the installed copy.");
32
+ console.log(`${skill}: bundled copy differs from the installed one.`);
27
33
  console.log(` installed: ${dst}`);
28
34
  console.log(` bundled: ${src}`);
29
- console.log("");
30
- console.log(`Diff with: diff ${dst} ${src}`);
31
- console.log("");
35
+ console.log(` Diff with: diff ${dst} ${src}`);
32
36
  if (!yes) {
33
37
  if (!stdin.isTTY) {
34
- console.error("Refusing to overwrite without confirmation in a non-interactive session.\n" +
38
+ console.error(`Refusing to overwrite ${skill} without confirmation in a non-interactive session.\n` +
35
39
  "Re-run with --yes to confirm, or run interactively to be prompted.");
36
40
  return 1;
37
41
  }
38
- const rl = createInterface({ input: stdin, output: stdout });
39
- const answer = (await rl.question("Overwrite installed skill with bundled version? [y/N] "))
40
- .trim()
41
- .toLowerCase();
42
- rl.close();
43
- if (answer !== "y" && answer !== "yes") {
44
- console.log("Skipped — installed skill is unchanged.");
42
+ if (!(await confirm(`Overwrite installed ${skill} with bundled version? [y/N] `))) {
43
+ console.log(`Skipped ${skill} is unchanged.`);
45
44
  return 0;
46
45
  }
47
46
  }
48
47
  copyFileSync(src, dst);
49
- console.log(`✓ Overwrote skill at ${dst}`);
48
+ console.log(`✓ ${skill}: overwrote ${dst}`);
50
49
  return 0;
51
50
  }
51
+ export async function run(args) {
52
+ const yes = args.includes("--yes") || args.includes("-y");
53
+ let exit = 0;
54
+ for (const skill of ALL_BUNDLED_SKILLS) {
55
+ const code = await installSkill(skill, yes);
56
+ if (code !== 0)
57
+ exit = code;
58
+ }
59
+ return exit;
60
+ }