karajan-code 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.11.0",
3
+ "version": "3.12.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -304,26 +304,11 @@ export function registerMeta(program, { pkgVersion }) {
304
304
  .command("update")
305
305
  .description("Update karajan-code to the latest version from npm")
306
306
  .action(async () => {
307
- // Audit follow-up: was execaCommand (shell parsing). Inputs are
308
- // constants today, but the project standard since #555 is execa
309
- // with arg arrays (no shell). Migrated for consistency.
310
- const { execa } = await import("execa");
311
- console.log(`Current version: ${pkgVersion}`);
312
- console.log("Checking for updates...");
313
- try {
314
- const { stdout } = await execa("npm", ["view", "karajan-code", "version"]);
315
- const latest = stdout.trim();
316
- if (latest === pkgVersion) {
317
- console.log(`Already on the latest version (${pkgVersion}).`);
318
- return;
319
- }
320
- console.log(`Updating ${pkgVersion} → ${latest}...`);
321
- await execa("npm", ["install", "-g", "karajan-code@latest"], { stdio: "inherit" });
322
- console.log(`Updated to ${latest}. Restart Claude to pick up the new MCP server.`);
323
- } catch (err) {
324
- console.error(`Update failed: ${err.message}`);
325
- process.exit(1);
326
- }
307
+ // Output-capturing self-update: npm's deprecation/allow-scripts/funding
308
+ // warnings are hidden on success and only surfaced when the install fails.
309
+ const { performSelfUpdate } = await import("../utils/update-check.js");
310
+ const result = await performSelfUpdate({ currentVersion: pkgVersion });
311
+ if (!result.ok) process.exit(1);
327
312
  });
328
313
 
329
314
  program
@@ -226,7 +226,7 @@ export function registerPipeline(program, { pkgVersion }) {
226
226
 
227
227
  program
228
228
  .command("install-tools")
229
- .description("Install external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
229
+ .description("Install the tools kj needs (git, agent CLI: claude+codex) plus the external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
230
230
  .option("--only <tools>", "Comma-separated subset (e.g. \"semgrep,osv-scanner\"). Bypasses stack-gating.")
231
231
  .option("-y, --yes", "Auto-accept all prompts (non-interactive)")
232
232
  .option("--dry-run", "Show what would be installed without running anything")
@@ -18,7 +18,7 @@ import os from "node:os";
18
18
  import path from "node:path";
19
19
  import fs from "node:fs/promises";
20
20
  import { checkBinary } from "../utils/agent-detect.js";
21
- import { getInstallHint, detectPackageManagers, appliesToStack } from "../utils/install-hints.js";
21
+ import { getInstallHint, detectPackageManagers, appliesToStack, gitInstallPlan } from "../utils/install-hints.js";
22
22
  import { detectProjectStack } from "../utils/stack-detect.js";
23
23
  import { resolveStandalone } from "../utils/binary-sources.js";
24
24
  import { downloadBinary, binDir, runInstallCommand } from "../utils/tool-installer.js";
@@ -26,7 +26,17 @@ import { dockerInstallPlan } from "../utils/docker-install.js";
26
26
 
27
27
  const execFileAsync = promisify(execFile);
28
28
 
29
- const ALL_TOOLS = ["semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
29
+ // git and the agent CLI lead the list: both are `kj doctor` *required* tools,
30
+ // so a blank machine wants them before the optional audit tools.
31
+ const ALL_TOOLS = ["git", "agent-cli", "semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
32
+
33
+ // The default pipeline is coder=claude, reviewer=codex, so `agent-cli` installs
34
+ // exactly those two. gemini stays out of the default: it is a supported
35
+ // reviewer, not a required one. Both ship as global npm packages.
36
+ const AGENT_CLIS = [
37
+ { bin: "claude", pkg: "@anthropic-ai/claude-code", url: "https://docs.anthropic.com/en/docs/claude-code" },
38
+ { bin: "codex", pkg: "@openai/codex", url: "https://github.com/openai/codex" },
39
+ ];
30
40
 
31
41
  function parseOnlyList(raw) {
32
42
  if (!raw) return null;
@@ -56,11 +66,100 @@ async function isInstalled(tool) {
56
66
  // sonar is a Docker container, not a binary. We check it separately
57
67
  // by looking at running containers via `docker ps`.
58
68
  if (tool === "sonar") return checkSonarRunning();
69
+ // agent-cli is "installed" only when BOTH default-pipeline CLIs are present;
70
+ // a partial install (one of two) still routes to handleAgentCli.
71
+ if (tool === "agent-cli") {
72
+ const present = await Promise.all(AGENT_CLIS.map(async (c) => (await checkBinary(c.bin)).ok));
73
+ return present.every(Boolean);
74
+ }
59
75
  if (tool === "lighthouse") return (await checkBinary("lighthouse")).ok;
60
76
  if (tool === "docker") return (await checkBinary("docker")).ok;
61
77
  return (await checkBinary(tool)).ok;
62
78
  }
63
79
 
80
+ /**
81
+ * Agent-CLI handler. Installs whichever of the default-pipeline CLIs
82
+ * (coder=claude, reviewer=codex) are missing, via global npm. Opt-in with the
83
+ * exact command shown first. When npm itself is absent we surface the manual
84
+ * commands + URLs rather than failing silently.
85
+ */
86
+ async function handleAgentCli({ available, dryRun, yes, logger }) {
87
+ const missing = [];
88
+ for (const cli of AGENT_CLIS) {
89
+ if (!(await checkBinary(cli.bin)).ok) missing.push(cli);
90
+ }
91
+ if (missing.length === 0) return { tool: "agent-cli", action: "already-installed" };
92
+
93
+ if (!available.npm) {
94
+ const commands = missing.map((c) => `npm install -g ${c.pkg}`);
95
+ logger.warn?.(`✗ agent-cli: npm not found — install manually: ${commands.join(" ; ")}`);
96
+ return { tool: "agent-cli", action: "manual", reason: "npm not available", missing: missing.map((c) => c.bin), commands, manualUrls: missing.map((c) => c.url) };
97
+ }
98
+
99
+ const installed = [];
100
+ for (const cli of missing) {
101
+ const command = `npm install -g ${cli.pkg}`;
102
+ if (dryRun) {
103
+ logger.info?.(`▸ ${cli.bin}: would run \`${command}\``);
104
+ installed.push({ bin: cli.bin, action: "dry-run", command });
105
+ continue;
106
+ }
107
+ const proceed = yes || await promptYesNo(`Install ${cli.bin} with: ${command}?`);
108
+ if (!proceed) {
109
+ logger.info?.(`⊘ ${cli.bin}: declined`);
110
+ installed.push({ bin: cli.bin, action: "declined", command });
111
+ continue;
112
+ }
113
+ logger.info?.(`▸ ${cli.bin}: running \`${command}\`...`);
114
+ const r = await runInstallCommand(command, { interactive: false });
115
+ if (r.ok) { logger.info?.(`✓ ${cli.bin}: installed`); installed.push({ bin: cli.bin, action: "installed", command }); }
116
+ else { const err = r.error || `exit ${r.code}`; logger.warn?.(`✗ ${cli.bin}: install failed (${err})`); installed.push({ bin: cli.bin, action: "failed", command, error: err }); }
117
+ }
118
+
119
+ const action = dryRun ? "dry-run"
120
+ : installed.some((r) => r.action === "failed") ? "failed"
121
+ : installed.some((r) => r.action === "installed") ? "installed"
122
+ : "declined";
123
+ return { tool: "agent-cli", action, installed };
124
+ }
125
+
126
+ /**
127
+ * git handler. git installs through the OS package manager; on Linux that
128
+ * needs root, so those runs go through the interactive (tty-inherited) path
129
+ * where sudo prompts the user directly — kj never captures the password. The
130
+ * exact command is shown first and the install is opt-in, defaulting to NO for
131
+ * the privileged case. When no package manager matches, we surface the manual
132
+ * download URL instead of failing silently.
133
+ */
134
+ async function handleGit({ available, dryRun, yes, logger }) {
135
+ const plan = gitInstallPlan(available);
136
+ if (!plan.command) {
137
+ logger.warn?.(`✗ git: no supported package manager here — install manually: ${plan.manualUrl}`);
138
+ return { tool: "git", action: "manual", reason: "no supported package manager found", manualUrl: plan.manualUrl };
139
+ }
140
+ if (dryRun) {
141
+ logger.info?.(`▸ git: would run \`${plan.command}\` (manager: ${plan.manager}${plan.needsSudo ? ", needs sudo" : ""})`);
142
+ return { tool: "git", action: "dry-run", command: plan.command, manager: plan.manager };
143
+ }
144
+ if (plan.needsSudo) {
145
+ logger.warn?.("git: this step needs sudo — you'll be prompted for your password on your own terminal (kj never sees it).");
146
+ }
147
+ const proceed = yes || await promptYesNo(`Install git with: ${plan.command}?`, !plan.needsSudo);
148
+ if (!proceed) {
149
+ logger.info?.("⊘ git: declined");
150
+ return { tool: "git", action: "declined", command: plan.command };
151
+ }
152
+ logger.info?.(`▸ git: running \`${plan.command}\`...`);
153
+ const r = await runInstallCommand(plan.command, { interactive: plan.needsSudo });
154
+ if (r.ok) {
155
+ logger.info?.(`✓ git: installed via ${plan.manager}`);
156
+ return { tool: "git", action: "installed", manager: plan.manager };
157
+ }
158
+ const err = r.error || `exit ${r.code}`;
159
+ logger.warn?.(`✗ git: install failed (${err})`);
160
+ return { tool: "git", action: "failed", command: plan.command, error: err };
161
+ }
162
+
64
163
  async function checkSonarRunning() {
65
164
  try {
66
165
  const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{.Names}}", "--filter", "name=sonarqube"], { timeout: 5000 });
@@ -236,6 +335,18 @@ export async function installToolsCommand(opts = {}) {
236
335
  continue;
237
336
  }
238
337
 
338
+ if (tool === "git") {
339
+ const result = await handleGit({ available, dryRun, yes, logger });
340
+ results.push(result);
341
+ continue;
342
+ }
343
+
344
+ if (tool === "agent-cli") {
345
+ const result = await handleAgentCli({ available, dryRun, yes, logger });
346
+ results.push(result);
347
+ continue;
348
+ }
349
+
239
350
  if (tool === "docker") {
240
351
  const result = await handleDocker({ available, dryRun, yes, logger });
241
352
  results.push(result);
@@ -116,8 +116,36 @@ const MANUAL_URLS = {
116
116
  "osv-scanner": "https://google.github.io/osv-scanner/installation/",
117
117
  lighthouse: "https://github.com/GoogleChrome/lighthouse#using-the-cli",
118
118
  docker: "https://docs.docker.com/get-docker/",
119
+ git: "https://git-scm.com/downloads",
119
120
  };
120
121
 
122
+ // git is a `kj doctor` *required* tool. Unlike the audit tools it is installed
123
+ // through the OS package manager, and on Linux that needs root — so the plan
124
+ // carries a `needsSudo` flag and the caller runs those through the interactive
125
+ // (tty-inherited) path, where sudo prompts the user directly and kj never sees
126
+ // the password. brew/scoop/choco install at user scope, no sudo.
127
+ const GIT_CANDIDATES = [
128
+ { manager: "brew", command: "brew install git", needsSudo: false },
129
+ { manager: "apt", command: "sudo apt-get install -y git", needsSudo: true },
130
+ { manager: "dnf", command: "sudo dnf install -y git", needsSudo: true },
131
+ { manager: "choco", command: "choco install git -y", needsSudo: false },
132
+ { manager: "scoop", command: "scoop install git", needsSudo: false },
133
+ ];
134
+
135
+ /**
136
+ * Pick a git install plan for the current machine. Returns a manual plan
137
+ * (command null + URL) when no supported package manager is present.
138
+ *
139
+ * @param {Record<PackageManager, boolean>} available
140
+ * @returns {{ command: string|null, manager: PackageManager|null, needsSudo: boolean, manualUrl: string }}
141
+ */
142
+ export function gitInstallPlan(available = {}) {
143
+ for (const { manager, command, needsSudo } of GIT_CANDIDATES) {
144
+ if (available[manager]) return { command, manager, needsSudo, manualUrl: MANUAL_URLS.git };
145
+ }
146
+ return { command: null, manager: null, needsSudo: false, manualUrl: MANUAL_URLS.git };
147
+ }
148
+
121
149
  /**
122
150
  * Tools whose hint depends on the project stack. lighthouse is only
123
151
  * relevant for frontend projects; suppressing the hint elsewhere keeps
@@ -103,6 +103,53 @@ export async function printUpdateNotice(currentVersion) {
103
103
  }
104
104
  }
105
105
 
106
+ /**
107
+ * Run the npm-channel self-update (`kj update`). Captures npm's output instead
108
+ * of streaming it: a successful update shows only the progress + result line,
109
+ * so npm's deprecation / allow-scripts / funding noise — build plumbing, not
110
+ * actionable for whoever runs the command — never reaches the user. On failure
111
+ * the captured stdout/stderr IS surfaced, so real errors (native build,
112
+ * permissions) stay diagnosable — never a silent failure.
113
+ *
114
+ * @param {Object} opts
115
+ * @param {string} opts.currentVersion - version of the running kj
116
+ * @param {(cmd: string, args: string[]) => Promise<{stdout: string, stderr: string}>} [opts.exec] - injectable runner (defaults to execa)
117
+ * @param {Console} [opts.logger]
118
+ * @returns {Promise<{ ok: boolean, alreadyLatest?: boolean, latest?: string }>}
119
+ */
120
+ export async function performSelfUpdate({ currentVersion, exec, logger = console } = {}) {
121
+ const run = exec || (async (cmd, args) => (await import("execa")).execa(cmd, args));
122
+ logger.log(`Current version: ${currentVersion}`);
123
+ logger.log("Checking for updates...");
124
+
125
+ let latest;
126
+ try {
127
+ const { stdout } = await run("npm", ["view", PACKAGE_NAME, "version"]);
128
+ latest = stdout.trim();
129
+ } catch (err) {
130
+ logger.error(`Update failed: ${err.shortMessage || err.message}`);
131
+ return { ok: false };
132
+ }
133
+
134
+ if (latest === currentVersion) {
135
+ logger.log(`Already on the latest version (${currentVersion}).`);
136
+ return { ok: true, alreadyLatest: true, latest };
137
+ }
138
+
139
+ logger.log(`Updating ${currentVersion} → ${latest}... (this can take a few minutes)`);
140
+ try {
141
+ // No stdio:inherit — capture and drop npm's warnings on the success path.
142
+ await run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
143
+ logger.log(`Updated to ${latest}. Restart Claude to pick up the new MCP server.`);
144
+ return { ok: true, latest };
145
+ } catch (err) {
146
+ if (err.stdout) logger.error(err.stdout);
147
+ if (err.stderr) logger.error(err.stderr);
148
+ logger.error(`Update failed: ${err.shortMessage || err.message}`);
149
+ return { ok: false };
150
+ }
151
+ }
152
+
106
153
  /** Simple semver compare: returns >0 if a > b, <0 if a < b, 0 if equal */
107
154
  function compareVersions(a, b) {
108
155
  const pa = a.split(".").map(Number);