kankaku-tui 0.2.1 → 0.3.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
@@ -15,24 +15,57 @@ npm i -g kankaku-tui
15
15
  kankaku setup
16
16
  ```
17
17
 
18
- `kankaku setup` detects every coding agent kankaku knows how to configure
19
- on this machine (pi, gentle-shell, Claude Code — each by its own settings
20
- file) and, for each one that already exists but isn't wired up, offers to
21
- add it: `npm:kankaku` to a pi-family `packages` array, or Claude Code's
22
- `statusLine` pointing at a `kankaku-claude` checkout. It also offers to
23
- configure hub credentials (reusing `~/.kankaku/credentials.json` or the
24
- `KANKAKU_PB_*` environment when either is already set) and writes this
25
- app's own `~/.kankaku/tui.json`. Codex and OpenCode are detected and
26
- reported, but kankaku has no adapter for either yet.
27
-
28
- Every question has a sensible default; `kankaku setup --yes` accepts every
29
- default without asking, and `kankaku setup --dry-run` prints the plan —
30
- each step's state (`done`/`todo`/`unavailable`) and the exact file it
31
- would change — without writing anything. Nothing is ever written without
32
- either an explicit prompt answer or `--yes`. Before the first change to
33
- any file, kankaku setup creates a `<file>.bak` next to it; re-running
34
- `kankaku setup` is always safe, since it only ever writes what is still
35
- missing or what you explicitly change.
18
+ On a real terminal, `kankaku setup` opens as a full-screen wizard — one
19
+ step at a time, in the same sidebar/panel look as the rest of the app.
20
+ `kankaku` with no arguments does the same the very first time (no
21
+ `~/.kankaku/tui.json` yet); after that first run it opens straight into
22
+ the Dashboard as usual.
23
+
24
+ The wizard's steps, `enter` to advance and `esc` to go back throughout
25
+ (`esc` at the first step quits):
26
+
27
+ 1. **Detect** — which agents kankaku found on this machine, and their
28
+ current state.
29
+ 2. **Agents** — a checklist (`space` toggles): pi, gentle-shell, Claude
30
+ Code. Pre-checked means already configured; unchecking a configured
31
+ agent schedules removing kankaku from it, not just skipping it. Codex
32
+ and OpenCode are listed but disabled — no adapter yet.
33
+ 3. **Claude Code** — the `kankaku-claude` checkout path (only shown when
34
+ Claude Code is checked and not already configured), guessed from any
35
+ existing `statusLine`.
36
+ 4. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
37
+ inline health check, reusing the current credentials as the default),
38
+ `install locally`, or `skip`. Installing locally looks for a
39
+ `kankaku-hub` checkout first; if found, its path is prefilled and
40
+ confirming it downloads PocketBase, starts the dev server in the
41
+ background and creates a **development** service account (its email
42
+ and password are fixed constants from `kankaku-hub`'s own dev script,
43
+ never meant for anything but a local machine) — the wizard writes that
44
+ account as this machine's hub credentials. Without a checkout, the
45
+ wizard shows the exact commands to run in another terminal and a `c`
46
+ "check again" action, plus `m` to acknowledge you'll install it
47
+ manually.
48
+ 5. **Roots** — the comma-separated project roots, defaulting to the
49
+ current `tui.json` (or the parent of the current directory the first
50
+ time).
51
+ 6. **Review** — the plan: one line per change, with the exact file it
52
+ touches. `enter` applies it.
53
+ 7. **Apply** — runs each change and shows its result
54
+ (`wrote`/`unchanged`/`removed`/`started`/`error: …`) as it happens.
55
+ 8. **Done** — a summary, then `enter` opens the Dashboard in place — no
56
+ restart.
57
+
58
+ `kankaku setup --yes` and `kankaku setup --dry-run` stay exactly as
59
+ before: non-interactive, driven by argv/env only, never opening the
60
+ wizard (even on a TTY). `--yes` accepts every question's own default
61
+ without asking; `--dry-run` prints the plan — each step's state
62
+ (`done`/`todo`/`unavailable`) and the exact file it would change —
63
+ without writing anything. Nothing is ever written without either an
64
+ explicit answer (in the wizard or the `--yes`/readline flow) or `--yes`
65
+ itself. Before the first change to any file, kankaku setup creates a
66
+ `<file>.bak` next to it; re-running `kankaku setup` in any form is always
67
+ safe, since it only ever writes what is still missing or what you
68
+ explicitly change.
36
69
 
37
70
  `kankaku setup` ends with, and `kankaku doctor` prints on its own, the
38
71
  same read-only report: one line per agent, one for the hub, one for
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The real `ScriptRunner`: `node:child_process` over the given cmd/args.
3
+ * `run` collects stdout/stderr and waits for exit; `spawnDetached` starts a
4
+ * detached, unref'd process whose combined output is appended to `logFile`,
5
+ * for `local-hub.ts#installLocalHub`'s `dev.sh`.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { openSync } from "node:fs";
9
+ export function createChildProcessRunner() {
10
+ return {
11
+ run(cmd, args, opts) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(cmd, args, { cwd: opts.cwd });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ child.stdout?.on("data", (chunk) => {
17
+ stdout += chunk.toString();
18
+ });
19
+ child.stderr?.on("data", (chunk) => {
20
+ stderr += chunk.toString();
21
+ });
22
+ child.on("error", reject);
23
+ child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
24
+ });
25
+ },
26
+ spawnDetached(cmd, args, opts) {
27
+ const fd = openSync(opts.logFile, "a");
28
+ const child = spawn(cmd, args, { cwd: opts.cwd, detached: true, stdio: ["ignore", fd, fd] });
29
+ child.unref();
30
+ return { pid: child.pid ?? -1 };
31
+ },
32
+ };
33
+ }
@@ -23,3 +23,22 @@ export function writeStatusLine(settingsPath, checkoutPath) {
23
23
  writeJsonAtomic(settingsPath, { ...existing, statusLine: { type: "command", command } });
24
24
  return { changed: true };
25
25
  }
26
+ /**
27
+ * Removes `statusLine` only when its `command` contains `"kankaku"` (i.e.
28
+ * it looks like kankaku's own statusline, not some other tool's), leaving
29
+ * every other key untouched. A no-op, with no backup and no write, when
30
+ * there is no `statusLine` or it belongs to something else.
31
+ */
32
+ export function removeStatusLine(settingsPath) {
33
+ const existing = readJsonObjectOrEmpty(settingsPath);
34
+ const currentStatusLine = existing["statusLine"];
35
+ const currentCommand = currentStatusLine && typeof currentStatusLine === "object" && !Array.isArray(currentStatusLine)
36
+ ? currentStatusLine["command"]
37
+ : undefined;
38
+ if (typeof currentCommand !== "string" || !currentCommand.includes("kankaku"))
39
+ return { changed: false };
40
+ backupOnce(settingsPath);
41
+ const { statusLine: _statusLine, ...rest } = existing;
42
+ writeJsonAtomic(settingsPath, rest);
43
+ return { changed: true };
44
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Level-1 local hub install for the setup wizard's "install locally" hub
3
+ * option: finds a `kankaku-hub` checkout, runs its own dev scripts through
4
+ * the injected `ScriptRunner` (never spawning the real hub in tests), and
5
+ * returns the service account `scripts/create-dev-accounts.sh` creates.
6
+ * The service account's email/password are that script's own hardcoded
7
+ * constants (`SERVICE_EMAIL`/`SERVICE_PASSWORD`), read from
8
+ * `kankaku-hub/scripts/create-dev-accounts.sh`, not environment-overridable
9
+ * there — kept identical here so the credentials this writes always match
10
+ * what the script actually created.
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ const HUB_URL = "http://127.0.0.1:8090";
15
+ const SERVICE_EMAIL = "kankaku-sync@kankaku.local";
16
+ const SERVICE_PASSWORD = "kankaku-dev-sync";
17
+ const DEFAULT_CHECKOUT_DIR = "~/desarrollo/soyun.ninja/kankaku-hub";
18
+ /** The real `kankaku-hub` remote (`git -C <repo> remote get-url origin`), in its https form so no SSH key is required for a one-off clone. */
19
+ const HUB_CLONE_URL = "https://github.com/soyunninja/kankaku_hub.git";
20
+ const HEALTH_POLL_TIMEOUT_MS = 20000;
21
+ const HEALTH_POLL_INTERVAL_MS = 500;
22
+ /** The first `candidates` entry that looks like a `kankaku-hub` checkout: has `scripts/dev.sh` and `pocketbase/pb_migrations`. */
23
+ export function findHubCheckout(candidates) {
24
+ return candidates.find((candidate) => existsSync(join(candidate, "scripts", "dev.sh")) && existsSync(join(candidate, "pocketbase", "pb_migrations")));
25
+ }
26
+ async function pollHealth(url, deps) {
27
+ const deadline = deps.now() + HEALTH_POLL_TIMEOUT_MS;
28
+ for (;;) {
29
+ try {
30
+ const response = await deps.fetch(url);
31
+ if (response.ok)
32
+ return true;
33
+ }
34
+ catch {
35
+ // Not up yet; keep polling until the deadline.
36
+ }
37
+ if (deps.now() >= deadline)
38
+ return false;
39
+ await deps.sleep(HEALTH_POLL_INTERVAL_MS);
40
+ }
41
+ }
42
+ /**
43
+ * Installs the local hub from `checkout`: downloads PocketBase (skipped
44
+ * when `pocketbase/bin/pocketbase` already exists), starts `scripts/dev.sh`
45
+ * detached (pid at `~/.kankaku/hub/pid`, log at `~/.kankaku/hub/dev.log`),
46
+ * polls `<HUB_URL>/api/health` for up to ~20s, then runs
47
+ * `scripts/create-dev-accounts.sh`. Returns the local hub's URL and service
48
+ * account on success, or `ok: false` with an `error` at the first failing
49
+ * step — never throws.
50
+ */
51
+ export async function installLocalHub(checkout, deps) {
52
+ const pocketbaseBinary = join(checkout, "pocketbase", "bin", "pocketbase");
53
+ if (!existsSync(pocketbaseBinary)) {
54
+ const download = await deps.runner.run(join(checkout, "scripts", "pb-download.sh"), [], { cwd: checkout });
55
+ if (download.code !== 0) {
56
+ return { ok: false, url: HUB_URL, error: `pb-download.sh failed: ${download.stderr || download.stdout || `exit code ${download.code}`}` };
57
+ }
58
+ }
59
+ const hubDir = join(deps.homeDir, ".kankaku", "hub");
60
+ mkdirSync(hubDir, { recursive: true });
61
+ const logFile = join(hubDir, "dev.log");
62
+ const { pid } = deps.runner.spawnDetached(join(checkout, "scripts", "dev.sh"), [], { cwd: checkout, logFile });
63
+ writeFileSync(join(hubDir, "pid"), String(pid));
64
+ const healthy = await pollHealth(`${HUB_URL}/api/health`, deps);
65
+ if (!healthy) {
66
+ return { ok: false, url: HUB_URL, error: "the local hub did not become healthy within 20s" };
67
+ }
68
+ const accounts = await deps.runner.run(join(checkout, "scripts", "create-dev-accounts.sh"), [], { cwd: checkout });
69
+ if (accounts.code !== 0) {
70
+ return { ok: false, url: HUB_URL, error: `create-dev-accounts.sh failed: ${accounts.stderr || accounts.stdout || `exit code ${accounts.code}`}` };
71
+ }
72
+ return { ok: true, url: HUB_URL, serviceEmail: SERVICE_EMAIL, servicePassword: SERVICE_PASSWORD };
73
+ }
74
+ /**
75
+ * The exact command lines to show when no `kankaku-hub` checkout was
76
+ * found: clone it (unless `checkout` names one already on disk), then run
77
+ * its three dev scripts in order.
78
+ */
79
+ export function manualCommands(checkout) {
80
+ const dir = checkout ?? DEFAULT_CHECKOUT_DIR;
81
+ const lines = [];
82
+ if (!checkout)
83
+ lines.push(`git clone ${HUB_CLONE_URL} ${dir}`);
84
+ lines.push(`cd ${dir}`, "scripts/pb-download.sh", "scripts/dev.sh &", "scripts/create-dev-accounts.sh");
85
+ return lines;
86
+ }
@@ -19,3 +19,18 @@ export function addKankakuPackage(settingsPath) {
19
19
  writeJsonAtomic(settingsPath, { ...existing, packages: [...packages, "npm:kankaku"] });
20
20
  return { changed: true };
21
21
  }
22
+ /**
23
+ * Removes every `packages` entry `isKankakuPackage` identifies as kankaku,
24
+ * leaving every other entry and key untouched. A no-op, with no backup and
25
+ * no write, when none is present.
26
+ */
27
+ export function removeKankakuPackage(settingsPath) {
28
+ const existing = readJsonObjectOrEmpty(settingsPath);
29
+ const packages = isStringArray(existing["packages"]) ? existing["packages"] : [];
30
+ const filtered = packages.filter((entry) => !isKankakuPackage(entry));
31
+ if (filtered.length === packages.length)
32
+ return { changed: false };
33
+ backupOnce(settingsPath);
34
+ writeJsonAtomic(settingsPath, { ...existing, packages: filtered });
35
+ return { changed: true };
36
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { homedir, hostname } from "node:os";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { existsSync, realpathSync } from "node:fs";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { render } from "ink";
@@ -21,10 +21,12 @@ import { buildSyncRows } from "./domain/sync-model.js";
21
21
  import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
22
22
  import { detectAgents, formatDoctorLines, formatSetupPlanLines, planSetup } from "./domain/setup-plan.js";
23
23
  import { readAgentFacts } from "./adapters/setup/agents.js";
24
- import { addKankakuPackage } from "./adapters/setup/pi.js";
25
- import { writeStatusLine } from "./adapters/setup/claude.js";
24
+ import { addKankakuPackage, removeKankakuPackage } from "./adapters/setup/pi.js";
25
+ import { removeStatusLine, writeStatusLine } from "./adapters/setup/claude.js";
26
26
  import { checkHubHealth, credentialsPath, writeHubCredentials } from "./adapters/setup/hub.js";
27
27
  import { tuiConfigPath, writeTuiConfig } from "./adapters/setup/tui-config.js";
28
+ import { findHubCheckout, installLocalHub, manualCommands } from "./adapters/setup/local-hub.js";
29
+ import { createChildProcessRunner } from "./adapters/setup/child-process-runner.js";
28
30
  import { createReadlinePrompter } from "./adapters/setup/readline-prompter.js";
29
31
  const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run]|doctor] [--roots a,b] [--theme name]\n";
30
32
  /** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
@@ -225,6 +227,113 @@ async function runDoctorCommand(deps) {
225
227
  const { agents, hub, tui } = await gatherSetupFacts(deps);
226
228
  deps.stdout(formatDoctorLines(agents, hub, tui).join("\n"));
227
229
  }
230
+ /** Plausible `kankaku-hub` checkout locations to probe for the wizard's Hub step: a sibling of this project's parent, and the documented default under the home directory. */
231
+ function localHubCandidates(deps) {
232
+ return [join(deps.homeDir, "desarrollo", "soyun.ninja", "kankaku-hub"), join(dirname(deps.cwd), "kankaku-hub")];
233
+ }
234
+ /**
235
+ * Gather the plain facts the setup wizard needs (`domain/setup-wizard.ts#createWizardState`):
236
+ * every detected agent, current hub credentials (reused as the "existing
237
+ * hub" step's defaults) and current `tui.json` roots. Entirely read-only,
238
+ * with no network call — the hub's health is checked interactively, from
239
+ * the wizard's own Hub step, never upfront.
240
+ */
241
+ function gatherWizardFacts(deps) {
242
+ const agentFacts = readAgentFacts(deps.homeDir);
243
+ const hubResolution = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
244
+ const credPath = credentialsPath(deps.homeDir);
245
+ const localCheckoutGuess = findHubCheckout(localHubCandidates(deps)) ?? join(deps.homeDir, "desarrollo", "soyun.ninja", "kankaku-hub");
246
+ const hub = hubResolution.ok
247
+ ? {
248
+ credentialsPresent: true,
249
+ url: hubResolution.credentials.url,
250
+ email: hubResolution.credentials.email,
251
+ password: hubResolution.credentials.password,
252
+ credentialsPath: credPath,
253
+ localCheckoutGuess,
254
+ }
255
+ : { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath, localCheckoutGuess };
256
+ const tuiPath = tuiConfigPath(deps.homeDir);
257
+ const roots = {
258
+ current: existsSync(tuiPath) ? readTuiConfig(deps.homeDir, deps.cwd).roots : undefined,
259
+ defaultRoots: [dirname(deps.cwd)],
260
+ path: tuiPath,
261
+ };
262
+ return { agentFacts, hub, roots };
263
+ }
264
+ /** Runs `adapters/setup/local-hub.ts#installLocalHub` for real, sharing one `ScriptRunner`/timer setup between the wizard's `installLocalHub` action and `apply`'s own `install-local-hub` handling. */
265
+ async function performLocalHubInstall(checkout, deps) {
266
+ const { now, fetch: fetchOverride } = envDeps(deps);
267
+ const result = await installLocalHub(checkout, {
268
+ runner: createChildProcessRunner(),
269
+ homeDir: deps.homeDir,
270
+ fetch: fetchOverride ?? fetch,
271
+ now,
272
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
273
+ });
274
+ if (!result.ok)
275
+ throw new Error(result.error ?? "installing the local hub failed");
276
+ return { url: result.url, serviceEmail: result.serviceEmail, servicePassword: result.servicePassword };
277
+ }
278
+ /**
279
+ * Apply one planned `WizardAction` for real, through the matching
280
+ * `adapters/setup/*` writer. Several kinds need a value `planFromWizard`
281
+ * never put in the action itself (`file` is always the target path, not
282
+ * what to write) — `state` carries it: `state.claudeCheckout` for
283
+ * `write-claude`, `state.hub` for `write-hub`, `state.roots` for
284
+ * `write-roots`. `install-local-hub` runs the real installer, then writes
285
+ * its returned dev-account credentials as the hub, labelling them as dev
286
+ * defaults in the result detail. Never throws: any adapter failure becomes
287
+ * an `"error"` outcome instead.
288
+ */
289
+ async function applyWizardAction(action, state, deps) {
290
+ try {
291
+ switch (action.kind) {
292
+ case "install-pi": {
293
+ const result = addKankakuPackage(action.file);
294
+ return { action, outcome: result.changed ? "wrote" : "unchanged" };
295
+ }
296
+ case "remove-pi": {
297
+ const result = removeKankakuPackage(action.file);
298
+ return { action, outcome: result.changed ? "removed" : "unchanged" };
299
+ }
300
+ case "write-claude": {
301
+ const result = writeStatusLine(action.file, state.claudeCheckout);
302
+ return { action, outcome: result.changed ? "wrote" : "unchanged" };
303
+ }
304
+ case "remove-claude": {
305
+ const result = removeStatusLine(action.file);
306
+ return { action, outcome: result.changed ? "removed" : "unchanged" };
307
+ }
308
+ case "write-hub": {
309
+ const result = writeHubCredentials(deps.homeDir, { url: state.hub.url, email: state.hub.email, password: state.hub.password });
310
+ return { action, outcome: result.changed ? "wrote" : "unchanged" };
311
+ }
312
+ case "install-local-hub": {
313
+ const installed = await performLocalHubInstall(action.file, deps);
314
+ writeHubCredentials(deps.homeDir, { url: installed.url, email: installed.serviceEmail, password: installed.servicePassword });
315
+ return { action, outcome: "started", detail: `running at ${installed.url} (dev defaults: ${installed.serviceEmail} / ${installed.servicePassword})` };
316
+ }
317
+ case "write-roots": {
318
+ const result = writeTuiConfig(deps.homeDir, state.roots);
319
+ return { action, outcome: result.changed ? "wrote" : "unchanged" };
320
+ }
321
+ }
322
+ }
323
+ catch (error) {
324
+ return { action, outcome: "error", detail: error instanceof Error ? error.message : String(error) };
325
+ }
326
+ }
327
+ /** Build the setup wizard's `WizardActions` for the interactive app: every write goes through the same real `adapters/setup/*` writers `kankaku setup --yes` uses. Used only by `renderApp`. */
328
+ function buildWizardActions(deps) {
329
+ return {
330
+ apply: (action, state) => applyWizardAction(action, state, deps),
331
+ checkHealth: (url) => checkHubHealth(url, { fetch: deps.fetch }),
332
+ findHubCheckout: () => findHubCheckout(localHubCandidates(deps)),
333
+ manualCommands: (checkout) => manualCommands(checkout),
334
+ installLocalHub: (checkout) => performLocalHubInstall(checkout, deps),
335
+ };
336
+ }
228
337
  /**
229
338
  * Extract a plausible kankaku-claude checkout path from an existing
230
339
  * `statusLine.command` of the shape `node "<checkout>/src/statusline.ts"`,
@@ -352,7 +461,10 @@ export async function runCli(argv, deps) {
352
461
  const { roots, rest } = parseRoots(argvAfterTheme, deps);
353
462
  const [command] = rest;
354
463
  if (command === undefined) {
355
- deps.renderApp(roots, theme);
464
+ // First-run hint: no `tui.json` yet means this machine has never been
465
+ // set up — open straight into the wizard instead of an empty Dashboard.
466
+ const startInWizard = !existsSync(tuiConfigPath(deps.homeDir));
467
+ deps.renderApp(roots, theme, { startInWizard });
356
468
  return;
357
469
  }
358
470
  if (command === "today") {
@@ -373,7 +485,13 @@ export async function runCli(argv, deps) {
373
485
  return;
374
486
  }
375
487
  if (command === "setup") {
376
- await runSetupCommand(rest.slice(1), deps);
488
+ const setupArgs = rest.slice(1);
489
+ const interactiveTTY = !setupArgs.includes("--yes") && !setupArgs.includes("--dry-run") && (deps.isTTY?.() ?? false);
490
+ if (interactiveTTY) {
491
+ deps.renderApp(roots, theme, { startInWizard: true });
492
+ return;
493
+ }
494
+ await runSetupCommand(setupArgs, deps);
377
495
  return;
378
496
  }
379
497
  if (command === "doctor") {
@@ -504,9 +622,10 @@ if (isMain) {
504
622
  exit: (code) => {
505
623
  process.exit(code);
506
624
  },
625
+ isTTY: () => process.stdout.isTTY === true,
507
626
  prompter: createReadlinePrompter(process.stdin, process.stdout),
508
- renderApp: (roots, theme) => {
509
- render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots) }) }), { alternateScreen: true, exitOnCtrlC: true });
627
+ renderApp: (roots, theme, options) => {
628
+ render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: { facts: gatherWizardFacts(realDeps), actions: buildWizardActions(realDeps) }, startInWizard: options?.startInWizard }) }), { alternateScreen: true, exitOnCtrlC: true });
510
629
  },
511
630
  };
512
631
  void runCli(process.argv.slice(2), realDeps);
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Pure domain for `kankaku setup`'s interactive wizard: one `WizardState`
3
+ * carrying the current step, the user's in-progress answers, the computed
4
+ * plan and the applied results, plus reducers that transition it. No I/O —
5
+ * callers (`src/ui/setup/*`, `src/cli.tsx`) read the real files and pass
6
+ * plain facts in via `WizardFacts`, exactly like `domain/setup-plan.ts`.
7
+ */
8
+ import { detectAgents } from "./setup-plan.js";
9
+ const CLAUDE_STATUS_LINE_RE = /^node "(.+)\/src\/statusline\.ts"$/;
10
+ /** The inverse of `adapters/setup/claude.ts#statusLineCommand`: recovers the checkout path from an existing `statusLine.command`, or `""` when it isn't kankaku's. */
11
+ export function guessClaudeCheckout(statusLineCommand) {
12
+ if (!statusLineCommand)
13
+ return "";
14
+ const match = CLAUDE_STATUS_LINE_RE.exec(statusLineCommand);
15
+ return match ? match[1] : "";
16
+ }
17
+ function clearError(errors, key) {
18
+ if (!(key in errors))
19
+ return errors;
20
+ const rest = { ...errors };
21
+ delete rest[key];
22
+ return rest;
23
+ }
24
+ const HUB_ERROR_KEYS = ["url", "email", "password", "localCheckout"];
25
+ function clearHubErrors(errors) {
26
+ return HUB_ERROR_KEYS.reduce((acc, key) => clearError(acc, key), errors);
27
+ }
28
+ /** Build the wizard's initial state from plain, already-read facts: agent detection, current hub credentials and current `tui.json` roots. */
29
+ export function createWizardState(facts) {
30
+ const agents = detectAgents(facts.agentFacts);
31
+ const selected = Object.fromEntries(agents.map((agent) => [agent.id, agent.configured]));
32
+ return {
33
+ step: "detect",
34
+ agents,
35
+ selected,
36
+ claudeCheckout: guessClaudeCheckout(facts.agentFacts.claudeCode?.statusLineCommand),
37
+ hub: {
38
+ mode: facts.hub.credentialsPresent ? "existing" : "skip",
39
+ url: facts.hub.url ?? "",
40
+ email: facts.hub.email ?? "",
41
+ password: facts.hub.password ?? "",
42
+ localCheckout: facts.hub.localCheckoutGuess,
43
+ localManual: false,
44
+ },
45
+ roots: facts.roots.current ?? facts.roots.defaultRoots,
46
+ errors: {},
47
+ plan: [],
48
+ results: [],
49
+ };
50
+ }
51
+ /** Whether the Claude Code step should be shown: Claude is selected and not already configured. */
52
+ function claudeStepNeeded(state) {
53
+ const claude = state.agents.find((agent) => agent.id === "claude-code");
54
+ return state.selected["claude-code"] === true && claude !== undefined && !claude.configured;
55
+ }
56
+ export function toggleAgent(state, id) {
57
+ if (id === "codex" || id === "opencode")
58
+ return state;
59
+ return { ...state, selected: { ...state.selected, [id]: !state.selected[id] } };
60
+ }
61
+ export function setClaudeCheckout(state, value) {
62
+ return { ...state, claudeCheckout: value, errors: clearError(state.errors, "claudeCheckout") };
63
+ }
64
+ export function setHubMode(state, mode) {
65
+ return { ...state, hub: { ...state.hub, mode }, errors: clearHubErrors(state.errors) };
66
+ }
67
+ export function setHubField(state, field, value) {
68
+ return { ...state, hub: { ...state.hub, [field]: value }, errors: clearError(state.errors, field) };
69
+ }
70
+ export function setHubHealth(state, ok) {
71
+ return { ...state, hub: { ...state.hub, healthOk: ok } };
72
+ }
73
+ /** Parses a comma-separated list of roots: trims each entry and drops empties. */
74
+ export function setRoots(state, text) {
75
+ const roots = text
76
+ .split(",")
77
+ .map((entry) => entry.trim())
78
+ .filter((entry) => entry.length > 0);
79
+ return { ...state, roots, errors: clearError(state.errors, "roots") };
80
+ }
81
+ export function setLocalCheckout(state, value) {
82
+ return { ...state, hub: { ...state.hub, localCheckout: value }, errors: clearError(state.errors, "localCheckout") };
83
+ }
84
+ export function setHubLocalManual(state, manual) {
85
+ return { ...state, hub: { ...state.hub, localManual: manual }, errors: clearError(state.errors, "localCheckout") };
86
+ }
87
+ function validateHub(hub) {
88
+ if (hub.mode === "existing") {
89
+ const errors = {};
90
+ if (!/^https?:\/\//.test(hub.url))
91
+ errors.url = "enter a http(s) URL";
92
+ if (!hub.email.includes("@"))
93
+ errors.email = "enter a valid email";
94
+ if (hub.password.trim() === "")
95
+ errors.password = "enter a password";
96
+ return errors;
97
+ }
98
+ if (hub.mode === "local") {
99
+ if (hub.localCheckout.trim() === "" && !hub.localManual) {
100
+ return { localCheckout: "enter a checkout path, or acknowledge installing manually" };
101
+ }
102
+ return {};
103
+ }
104
+ return {};
105
+ }
106
+ function arraysEqual(a, b) {
107
+ return a.length === b.length && a.every((entry, index) => entry === b[index]);
108
+ }
109
+ const PI_HOME_AGENTS = [
110
+ { id: "pi", title: "pi" },
111
+ { id: "gentle-shell", title: "gentle-shell" },
112
+ ];
113
+ /** Build the ordered plan from the current wizard answers, diffed against `facts`' current on-disk state. */
114
+ export function planFromWizard(state, facts) {
115
+ const actions = [];
116
+ for (const { id, title } of PI_HOME_AGENTS) {
117
+ const agent = state.agents.find((candidate) => candidate.id === id);
118
+ if (!agent || !agent.adapterAvailable)
119
+ continue;
120
+ const selected = state.selected[id] === true;
121
+ if (selected === agent.configured)
122
+ continue;
123
+ actions.push(selected
124
+ ? { kind: "install-pi", file: agent.detail, label: `install kankaku in ${title} (${agent.detail})` }
125
+ : { kind: "remove-pi", file: agent.detail, label: `remove kankaku from ${title} (${agent.detail})` });
126
+ }
127
+ const claude = state.agents.find((agent) => agent.id === "claude-code");
128
+ if (claude && claude.adapterAvailable) {
129
+ const selected = state.selected["claude-code"] === true;
130
+ if (selected !== claude.configured) {
131
+ actions.push(selected
132
+ ? { kind: "write-claude", file: claude.detail, label: `set the Claude Code status line (${claude.detail})` }
133
+ : { kind: "remove-claude", file: claude.detail, label: `remove the kankaku status line from Claude Code (${claude.detail})` });
134
+ }
135
+ }
136
+ if (state.hub.mode === "existing") {
137
+ const changed = !facts.hub.credentialsPresent ||
138
+ state.hub.url !== (facts.hub.url ?? "") ||
139
+ state.hub.email !== (facts.hub.email ?? "") ||
140
+ state.hub.password !== (facts.hub.password ?? "");
141
+ if (changed) {
142
+ actions.push({ kind: "write-hub", file: facts.hub.credentialsPath, label: `write hub credentials to ${facts.hub.credentialsPath}` });
143
+ }
144
+ }
145
+ else if (state.hub.mode === "local" && state.hub.localCheckout.trim() !== "") {
146
+ actions.push({ kind: "install-local-hub", file: state.hub.localCheckout, label: `install a local hub from ${state.hub.localCheckout}` });
147
+ }
148
+ const sameRoots = facts.roots.current !== undefined && arraysEqual(facts.roots.current, state.roots);
149
+ if (!sameRoots) {
150
+ actions.push({ kind: "write-roots", file: facts.roots.path, label: `write roots to ${facts.roots.path}` });
151
+ }
152
+ return actions;
153
+ }
154
+ /** Advance from the current step: validates it, sets `errors` and stays when invalid, otherwise moves on. */
155
+ export function next(state, facts) {
156
+ switch (state.step) {
157
+ case "detect":
158
+ return { ...state, step: "agents", errors: {} };
159
+ case "agents":
160
+ return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
161
+ case "claude": {
162
+ if (state.claudeCheckout.trim() === "") {
163
+ return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path" } };
164
+ }
165
+ return { ...state, step: "hub", errors: clearError(state.errors, "claudeCheckout") };
166
+ }
167
+ case "hub": {
168
+ const hubErrors = validateHub(state.hub);
169
+ if (Object.keys(hubErrors).length > 0) {
170
+ return { ...state, errors: { ...clearHubErrors(state.errors), ...hubErrors } };
171
+ }
172
+ return { ...state, step: "roots", errors: clearHubErrors(state.errors) };
173
+ }
174
+ case "roots": {
175
+ if (state.roots.length === 0) {
176
+ return { ...state, errors: { ...state.errors, roots: "enter at least one root directory" } };
177
+ }
178
+ const reviewState = { ...state, step: "review", errors: clearError(state.errors, "roots") };
179
+ return { ...reviewState, plan: planFromWizard(reviewState, facts) };
180
+ }
181
+ case "review":
182
+ return { ...state, step: "apply" };
183
+ case "apply":
184
+ return state.results.length >= state.plan.length ? { ...state, step: "done" } : state;
185
+ case "done":
186
+ return state;
187
+ }
188
+ }
189
+ /** Move back to the previous step, mirroring `next`'s claude-step skip. Terminal/first steps (`detect`, `apply`, `done`) are no-ops. */
190
+ export function back(state) {
191
+ switch (state.step) {
192
+ case "detect":
193
+ return state;
194
+ case "agents":
195
+ return { ...state, step: "detect", errors: {} };
196
+ case "claude":
197
+ return { ...state, step: "agents", errors: {} };
198
+ case "hub":
199
+ return { ...state, step: claudeStepNeeded(state) ? "claude" : "agents", errors: {} };
200
+ case "roots":
201
+ return { ...state, step: "hub", errors: {} };
202
+ case "review":
203
+ return { ...state, step: "roots", errors: {} };
204
+ case "apply":
205
+ return state;
206
+ case "done":
207
+ return state;
208
+ }
209
+ }
210
+ export function applyResult(state, result) {
211
+ return { ...state, results: [...state.results, result] };
212
+ }
213
+ /** The footer key hints for a given step. */
214
+ export function hintsForStep(step) {
215
+ const quit = { key: "q", label: "quit" };
216
+ switch (step) {
217
+ case "detect":
218
+ return [{ key: "enter", label: "next" }, quit];
219
+ case "agents":
220
+ return [{ key: "space", label: "toggle" }, { key: "↑↓", label: "move" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
221
+ case "claude":
222
+ return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
223
+ case "hub":
224
+ return [{ key: "↑↓", label: "choose" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
225
+ case "roots":
226
+ return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
227
+ case "review":
228
+ return [{ key: "enter", label: "apply" }, { key: "esc", label: "back" }, quit];
229
+ case "apply":
230
+ return [quit];
231
+ case "done":
232
+ return [{ key: "enter", label: "open dashboard" }, quit];
233
+ }
234
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui/app.js CHANGED
@@ -6,6 +6,7 @@ import { DashboardScreen } from "./dashboard-screen.js";
6
6
  import { TasksScreen } from "./tasks-screen.js";
7
7
  import { CatalogScreen } from "./catalog-screen.js";
8
8
  import { SyncScreen } from "./sync-screen.js";
9
+ import { SetupWizard } from "./setup/wizard-screen.js";
9
10
  /**
10
11
  * The app shell: owns navigation state (`domain/nav-model.ts`) and renders
11
12
  * whichever screen is active, each already wrapped in the shared
@@ -24,9 +25,10 @@ import { SyncScreen } from "./sync-screen.js";
24
25
  * filter — this hook only predicts whether that will happen, to decide
25
26
  * whether it should also move focus.
26
27
  */
27
- export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions }) {
28
+ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions, wizard, startInWizard }) {
28
29
  const { exit } = useApp();
29
30
  const [nav, setNav] = useState(INITIAL_NAV_STATE);
31
+ const [showWizard, setShowWizard] = useState(startInWizard === true && wizard !== undefined);
30
32
  useInput((input, key) => {
31
33
  if (input === "q") {
32
34
  exit();
@@ -59,7 +61,14 @@ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashb
59
61
  }
60
62
  return state;
61
63
  });
62
- });
64
+ },
65
+ // While the wizard is active, it owns every keystroke itself (including
66
+ // `q`, gated there by whether a text field currently has focus) — this
67
+ // app-wide handler must stay out of the way entirely.
68
+ { isActive: !showWizard });
63
69
  const focused = nav.focus === "main";
70
+ if (showWizard && wizard) {
71
+ return _jsx(SetupWizard, { facts: wizard.facts, actions: wizard.actions, onDone: () => setShowWizard(false), onQuit: () => exit(), version: version });
72
+ }
64
73
  return (_jsxs(_Fragment, { children: [nav.screen === "dashboard" && (_jsx(DashboardScreen, { load: loadToday, actions: dashboardActions, roots: roots, version: version, focused: focused, onOpenProject: (project) => setNav((state) => openProjectInTasks(state, project)) })), nav.screen === "tasks" && (_jsx(TasksScreen, { load: loadTasks, roots: roots, version: version, focused: focused, ...(nav.projectFilter !== undefined ? { projectFilter: nav.projectFilter } : {}), onClearFilter: () => setNav((state) => clearProjectFilter(state)) })), nav.screen === "catalog" && _jsx(CatalogScreen, { ...catalog, roots: roots, version: version, focused: focused }), nav.screen === "sync" && _jsx(SyncScreen, { ...sync, roots: roots, version: version, focused: focused })] }));
65
74
  }
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /**
5
+ * A checkbox list: `[x] label` checked, `[ ] label` unchecked, `[-] label
6
+ * (note)` disabled. Space toggles the item at `cursor` (a no-op when
7
+ * disabled); ↑/↓ call `onMove(-1|1)` — clamping is the caller's job, same
8
+ * as `Table`'s `selectedIndex`. The cursor row gets a visible `› ` marker,
9
+ * never colour alone.
10
+ */
11
+ export function Checklist({ items, cursor, onToggle, onMove, focused }) {
12
+ const theme = useTheme();
13
+ useInput((input, key) => {
14
+ if (key.upArrow) {
15
+ onMove(-1);
16
+ return;
17
+ }
18
+ if (key.downArrow) {
19
+ onMove(1);
20
+ return;
21
+ }
22
+ if (input === " ") {
23
+ const item = items[cursor];
24
+ if (item && !item.disabled)
25
+ onToggle(item.id);
26
+ }
27
+ }, { isActive: focused });
28
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item, index) => {
29
+ const marker = item.disabled ? "[-]" : item.checked ? "[x]" : "[ ]";
30
+ const note = item.disabled && item.note ? ` (${item.note})` : "";
31
+ const cursorMark = index === cursor ? "› " : " ";
32
+ return (_jsx(Text, { color: item.disabled ? theme.dim : theme.text, children: `${cursorMark}${marker} ${item.label}${note}` }, item.id));
33
+ }) }));
34
+ }
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /**
5
+ * A single-select radio list: `(•) label` for the selected option, `( )
6
+ * label` otherwise. ↑/↓ select the previous/next option directly (clamped
7
+ * at the ends). The currently highlighted row also gets a visible `› `
8
+ * marker, matching `Checklist`'s and `Table`'s own convention.
9
+ */
10
+ export function Radio({ options, value, onChange, focused }) {
11
+ const theme = useTheme();
12
+ const index = options.findIndex((option) => option.value === value);
13
+ useInput((_input, key) => {
14
+ if (key.upArrow) {
15
+ const targetIndex = Math.max(index - 1, 0);
16
+ const previous = options[targetIndex];
17
+ if (previous && targetIndex !== index)
18
+ onChange(previous.value);
19
+ return;
20
+ }
21
+ if (key.downArrow) {
22
+ const targetIndex = Math.min(index + 1, options.length - 1);
23
+ const next = options[targetIndex];
24
+ if (next && targetIndex !== index)
25
+ onChange(next.value);
26
+ }
27
+ }, { isActive: focused });
28
+ return (_jsx(Box, { flexDirection: "column", children: options.map((option, i) => (_jsx(Text, { color: theme.text, children: `${i === index ? "› " : " "}${option.value === value ? "(•)" : "( )"} ${option.label}` }, option.value))) }));
29
+ }
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Text, useInput } from "ink";
4
+ import { useTheme } from "../theme.js";
5
+ /**
6
+ * A single-line, fully controlled text input: printable characters insert
7
+ * at the cursor, Backspace/Delete remove the character before/after it,
8
+ * ←/→ and Home/End move it, Enter calls `onSubmit`. Only active while
9
+ * `focused` (`useInput`'s own `isActive`, matching every other kankaku-tui
10
+ * component). The cursor is a visible `▏` marker, shown only while
11
+ * focused — never colour alone (see AGENTS.md's ink-testing-library note).
12
+ */
13
+ export function TextInput({ value, onChange, onSubmit, placeholder = "", masked = false, focused }) {
14
+ const theme = useTheme();
15
+ const [cursor, setCursor] = useState(value.length);
16
+ const position = Math.min(Math.max(cursor, 0), value.length);
17
+ useInput((input, key) => {
18
+ if (key.return) {
19
+ onSubmit?.(value);
20
+ return;
21
+ }
22
+ if (key.leftArrow) {
23
+ setCursor(Math.max(position - 1, 0));
24
+ return;
25
+ }
26
+ if (key.rightArrow) {
27
+ setCursor(Math.min(position + 1, value.length));
28
+ return;
29
+ }
30
+ if (key.home) {
31
+ setCursor(0);
32
+ return;
33
+ }
34
+ if (key.end) {
35
+ setCursor(value.length);
36
+ return;
37
+ }
38
+ if (key.backspace) {
39
+ if (position === 0)
40
+ return;
41
+ onChange(value.slice(0, position - 1) + value.slice(position));
42
+ setCursor(position - 1);
43
+ return;
44
+ }
45
+ if (key.delete) {
46
+ if (position >= value.length)
47
+ return;
48
+ onChange(value.slice(0, position) + value.slice(position + 1));
49
+ return;
50
+ }
51
+ if (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.tab || key.escape || key.ctrl || key.meta)
52
+ return;
53
+ if (!input)
54
+ return;
55
+ onChange(value.slice(0, position) + input + value.slice(position));
56
+ setCursor(position + input.length);
57
+ }, { isActive: focused });
58
+ const cursorMark = focused ? "▏" : "";
59
+ if (value.length === 0) {
60
+ return (_jsxs(Text, { children: [cursorMark, _jsx(Text, { color: theme.dim, children: placeholder })] }));
61
+ }
62
+ const displayed = masked ? "•".repeat(value.length) : value;
63
+ return (_jsxs(Text, { children: [displayed.slice(0, position), cursorMark, displayed.slice(position)] }));
64
+ }
@@ -0,0 +1,198 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { applyResult, back, createWizardState, hintsForStep, next, planFromWizard, setClaudeCheckout, setHubField, setHubHealth, setHubLocalManual, setHubMode, setLocalCheckout, setRoots, toggleAgent, } from "../../domain/setup-wizard.js";
5
+ import { Layout } from "../layout.js";
6
+ import { Panel } from "../components/panel.js";
7
+ import { Table } from "../components/table.js";
8
+ import { Checklist } from "../components/checklist.js";
9
+ import { Radio } from "../components/radio.js";
10
+ import { TextInput } from "../components/text-input.js";
11
+ import { useTheme } from "../theme.js";
12
+ /** The seven steps shown in the sidebar; `apply` has no entry of its own (it is the Review step's own execution, not a step the user navigates to). */
13
+ const SIDEBAR_STEPS = ["detect", "agents", "claude", "hub", "roots", "review", "done"];
14
+ const STEP_TITLES = {
15
+ detect: "Detect",
16
+ agents: "Agents",
17
+ claude: "Claude",
18
+ hub: "Hub",
19
+ roots: "Roots",
20
+ review: "Review",
21
+ apply: "Apply",
22
+ done: "Done",
23
+ };
24
+ const AGENT_TITLES = {
25
+ pi: "pi",
26
+ "gentle-shell": "gentle-shell",
27
+ "claude-code": "Claude Code",
28
+ codex: "Codex",
29
+ opencode: "OpenCode",
30
+ };
31
+ /** The sidebar's own active id: `apply` has no sidebar row, so it maps to `review` (the step it is executing). */
32
+ function sidebarActiveId(step) {
33
+ return step === "apply" ? "review" : step;
34
+ }
35
+ function wizardSidebarItems(step) {
36
+ const activeIndex = SIDEBAR_STEPS.indexOf(sidebarActiveId(step));
37
+ return SIDEBAR_STEPS.map((entry, index) => ({
38
+ id: entry,
39
+ key: String(index + 1),
40
+ label: index < activeIndex ? `✓ ${STEP_TITLES[entry]}` : STEP_TITLES[entry],
41
+ }));
42
+ }
43
+ function agentStateLabel(agent) {
44
+ if (!agent.adapterAvailable)
45
+ return "no adapter";
46
+ if (!agent.present)
47
+ return "not found";
48
+ return agent.configured ? "configured" : "present";
49
+ }
50
+ const DETECT_FIXED_COLUMNS_WIDTH = 14 + 1 + 12 + 1 + 2 + 2;
51
+ function detectColumns(panelWidth) {
52
+ const detailWidth = Math.max(panelWidth - DETECT_FIXED_COLUMNS_WIDTH, 10);
53
+ return [
54
+ { key: "agent", header: "agent", width: 14 },
55
+ { key: "state", header: "state", width: 12 },
56
+ { key: "detail", header: "detail", width: detailWidth },
57
+ ];
58
+ }
59
+ function detectCell(row, key) {
60
+ switch (key) {
61
+ case "agent":
62
+ return AGENT_TITLES[row.id];
63
+ case "state":
64
+ return agentStateLabel(row);
65
+ case "detail":
66
+ return row.detail;
67
+ default:
68
+ return "";
69
+ }
70
+ }
71
+ const REVIEW_FIXED_COLUMNS_WIDTH = 30 + 1 + 2 + 2;
72
+ function reviewColumns(panelWidth) {
73
+ const labelWidth = Math.max(panelWidth - REVIEW_FIXED_COLUMNS_WIDTH, 20);
74
+ return [
75
+ { key: "label", header: "action", width: labelWidth },
76
+ { key: "file", header: "file", width: 30 },
77
+ ];
78
+ }
79
+ function reviewCell(row, key) {
80
+ return key === "file" ? row.file : row.label;
81
+ }
82
+ function applyResultLine(result) {
83
+ if (result.outcome === "error")
84
+ return `error: ${result.detail ?? result.action.label}`;
85
+ const detail = result.detail ? ` (${result.detail})` : "";
86
+ return `${result.outcome} ${result.action.file}${detail}`;
87
+ }
88
+ function agentChecklistItems(agents, selected) {
89
+ return agents.map((agent) => {
90
+ const disabled = agent.id === "codex" || agent.id === "opencode";
91
+ return {
92
+ id: agent.id,
93
+ label: AGENT_TITLES[agent.id],
94
+ checked: selected[agent.id] === true,
95
+ disabled,
96
+ note: disabled ? "no adapter yet" : undefined,
97
+ };
98
+ });
99
+ }
100
+ const HUB_MODE_OPTIONS = [
101
+ { value: "existing", label: "use an existing hub" },
102
+ { value: "local", label: "install locally" },
103
+ { value: "skip", label: "skip" },
104
+ ];
105
+ /** Mirrors `adapters/setup/local-hub.ts`'s own `HUB_URL`: where a manually-started local hub is expected to answer. */
106
+ const LOCAL_HUB_URL = "http://127.0.0.1:8090";
107
+ function healthLine(healthOk, checking) {
108
+ if (checking)
109
+ return "checking…";
110
+ if (healthOk === undefined)
111
+ return "";
112
+ return healthOk ? "health ok" : "health failed";
113
+ }
114
+ /**
115
+ * `kankaku setup`'s interactive wizard: one full-screen step at a time
116
+ * inside the shared `Layout` frame, driven by `domain/setup-wizard.ts`'s
117
+ * pure reducers. Enter advances (validating first), Esc goes back (quits
118
+ * from Detect, via `onQuit`); `q` quits from anywhere a text field isn't
119
+ * currently capturing keystrokes.
120
+ */
121
+ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns, rows }) {
122
+ const theme = useTheme();
123
+ const [state, setState] = useState(() => createWizardState(facts));
124
+ const [agentCursor, setAgentCursor] = useState(0);
125
+ const [hubFocus, setHubFocus] = useState(0);
126
+ const [healthChecking, setHealthChecking] = useState(false);
127
+ const [rootsText, setRootsText] = useState(() => state.roots.join(", "));
128
+ const applyStartedRef = useRef(false);
129
+ useEffect(() => {
130
+ if (state.step === "hub")
131
+ setHubFocus(0);
132
+ }, [state.step]);
133
+ const foundCheckout = state.hub.mode === "local" ? actions.findHubCheckout() : undefined;
134
+ const hasCheckoutField = state.hub.mode === "local" && (state.hub.localCheckout.trim() !== "" || foundCheckout !== undefined);
135
+ const checkoutValue = state.hub.localCheckout.trim() !== "" ? state.hub.localCheckout : (foundCheckout ?? "");
136
+ const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" && hasCheckoutField ? 2 : 1;
137
+ const textInputFocused = state.step === "claude" || state.step === "roots" || (state.step === "hub" && hubFocus >= 1 && hubFocus < hubFieldCount);
138
+ useEffect(() => {
139
+ if (state.step !== "apply" || applyStartedRef.current)
140
+ return;
141
+ applyStartedRef.current = true;
142
+ const snapshot = state;
143
+ void (async () => {
144
+ for (const action of snapshot.plan) {
145
+ const result = await actions.apply(action, snapshot);
146
+ setState((s) => applyResult(s, result));
147
+ }
148
+ })();
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, [state.step]);
151
+ useInput((input, key) => {
152
+ if (key.tab && state.step === "hub") {
153
+ setHubFocus((current) => (current + 1) % hubFieldCount);
154
+ return;
155
+ }
156
+ if (key.return) {
157
+ if (state.step === "done") {
158
+ onDone();
159
+ return;
160
+ }
161
+ setState((s) => next(s, facts));
162
+ return;
163
+ }
164
+ if (key.escape) {
165
+ if (state.step === "detect") {
166
+ onQuit();
167
+ return;
168
+ }
169
+ setState((s) => back(s));
170
+ return;
171
+ }
172
+ if (!textInputFocused && input === "q") {
173
+ onQuit();
174
+ return;
175
+ }
176
+ if (state.step === "hub" && !textInputFocused) {
177
+ if (input === "c") {
178
+ const url = state.hub.mode === "existing" ? state.hub.url : LOCAL_HUB_URL;
179
+ setHealthChecking(true);
180
+ void actions.checkHealth(url).then((ok) => {
181
+ setState((s) => setHubHealth(s, ok));
182
+ setHealthChecking(false);
183
+ });
184
+ return;
185
+ }
186
+ if (input === "m" && state.hub.mode === "local" && !hasCheckoutField) {
187
+ setState((s) => setHubLocalManual(s, true));
188
+ return;
189
+ }
190
+ }
191
+ });
192
+ const stepTitle = STEP_TITLES[state.step];
193
+ const hints = hintsForStep(state.step);
194
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku setup${version ? ` ${version}` : ""}`, sidebarItems: wizardSidebarItems(state.step), activeId: sidebarActiveId(state.step), sidebarStats: [], keyHints: hints, focus: "main", children: ({ mainWidth, mainHeight }) => (_jsxs(Panel, { title: `Setup · ${stepTitle}`, width: mainWidth, height: mainHeight, active: true, children: [state.step === "detect" && (_jsx(Table, { columns: detectColumns(mainWidth), rows: state.agents, rowKey: (row) => row.id, cell: detectCell, emptyText: "no agents detected" })), state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path:" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "local checkout path:" }), _jsx(TextInput, { value: checkoutValue, onChange: (value) => setState((s) => setLocalCheckout(s, value)), focused: hubFocus === 1 })] })), state.hub.mode === "local" && !hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no kankaku-hub checkout found; run these commands, then check again:" }), actions.manualCommands(state.hub.localCheckout || undefined).map((line) => (_jsx(Text, { children: ` ${line}` }, line))), _jsx(Text, { children: `[ check again ] c ${healthLine(state.hub.healthOk, healthChecking)}` }), _jsx(Text, { children: `[ m ] acknowledge manual install${state.hub.localManual ? " (acknowledged)" : ""}` }), state.errors.localCheckout && _jsx(Text, { color: theme.error, children: state.errors.localCheckout })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
195
+ setRootsText(value);
196
+ setState((s) => setRoots(s, value));
197
+ }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" })), state.step === "apply" && (_jsxs(Box, { flexDirection: "column", children: [state.results.map((result, index) => (_jsx(Text, { children: applyResultLine(result) }, `${result.action.kind}-${index}`))), state.results.length < state.plan.length && _jsx(Text, { dimColor: true, children: "applying\u2026" })] })), state.step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `setup complete: ${state.results.length} change(s) applied` }), _jsx(Text, { dimColor: true, children: "enter open dashboard" })] }))] })) }));
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "license": "MIT",
6
6
  "type": "module",