kankaku-tui 0.2.1 → 0.3.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/README.md CHANGED
@@ -15,24 +15,60 @@ 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 header/panel/footer look as the rest of the
20
+ app (there's no sidebar in the wizard itself: the panel's own title
21
+ tracks progress instead, e.g. `Setup · Agents 1/5`, numbering only the
22
+ steps this run will actually show). `kankaku` with no arguments does the
23
+ same the very first time (no `~/.kankaku/tui.json` yet); after that first
24
+ run it opens straight into the Dashboard as usual.
25
+
26
+ The wizard's steps, `enter` to advance and `esc` to go back throughout
27
+ (`esc` at the first step, Agents, quits):
28
+
29
+ 1. **Agents** — a checklist (`space` toggles) that also carries detection
30
+ for every agent kankaku knows about: pi, gentle-shell and Claude Code
31
+ each show `configured (<path, shortened with ~>)` or `not configured`;
32
+ pre-checked means already configured, and unchecking a configured
33
+ agent schedules removing kankaku from it, not just skipping it. Codex
34
+ and OpenCode are listed but disabled, showing `no adapter yet` (found,
35
+ but kankaku can't write its config) or `not installed` (not found).
36
+ 2. **Claude Code** — the `kankaku-claude` checkout path (only shown when
37
+ Claude Code is checked and not already configured), guessed from any
38
+ existing `statusLine`.
39
+ 3. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
40
+ inline health check, reusing the current credentials as the default),
41
+ `install locally`, or `skip`. Installing locally looks for a
42
+ `kankaku-hub` checkout first; if found, its path is prefilled and
43
+ confirming it downloads PocketBase, starts the dev server in the
44
+ background and creates a **development** service account (its email
45
+ and password are fixed constants from `kankaku-hub`'s own dev script,
46
+ never meant for anything but a local machine) — the wizard writes that
47
+ account as this machine's hub credentials. Without a checkout, the
48
+ wizard shows the exact commands to run in another terminal and a `c`
49
+ "check again" action, plus `m` to acknowledge you'll install it
50
+ manually.
51
+ 4. **Roots** — the comma-separated project roots, defaulting to the
52
+ current `tui.json` (or the parent of the current directory the first
53
+ time). See "Configuration" below for how deep each root is searched.
54
+ 5. **Review** — the plan: one line per change, with the exact file it
55
+ touches. `enter` applies it.
56
+ 6. **Apply** — runs each change and shows its result
57
+ (`wrote`/`unchanged`/`removed`/`started`/`error: …`) as it happens.
58
+ 7. **Done** — a summary, then `enter` opens the Dashboard in place — no
59
+ restart.
60
+
61
+ `kankaku setup --yes` and `kankaku setup --dry-run` stay exactly as
62
+ before: non-interactive, driven by argv/env only, never opening the
63
+ wizard (even on a TTY). `--yes` accepts every question's own default
64
+ without asking; `--dry-run` prints the plan — each step's state
65
+ (`done`/`todo`/`unavailable`) and the exact file it would change —
66
+ without writing anything. Nothing is ever written without either an
67
+ explicit answer (in the wizard or the `--yes`/readline flow) or `--yes`
68
+ itself. Before the first change to any file, kankaku setup creates a
69
+ `<file>.bak` next to it; re-running `kankaku setup` in any form is always
70
+ safe, since it only ever writes what is still missing or what you
71
+ explicitly change.
36
72
 
37
73
  `kankaku setup` ends with, and `kankaku doctor` prints on its own, the
38
74
  same read-only report: one line per agent, one for the hub, one for
@@ -57,10 +93,23 @@ this repo with `npm run dev`.
57
93
  }
58
94
  ```
59
95
 
60
- Each root is either a project itself (it has its own `.kankaku/worklog.jsonl`)
61
- or a directory containing one or more projects as direct subdirectories.
62
- `~` expands to the home directory. Missing or malformed config falls back
63
- to the current working directory as the only root.
96
+ ### Projects across roots
97
+
98
+ Each root is searched recursively for projects, up to 5 directory levels
99
+ below it by default: a directory is a project once it has its own
100
+ `.kankaku/worklog.jsonl` — including the root itself — and the search
101
+ still continues below it, so a stray worklog in a parent directory (a pi
102
+ session run once in `~/desarrollo`) never hides the projects beneath;
103
+ every directory is listed at most once. Subdirectories are searched one
104
+ level deeper, skipping `node_modules`, `.git` and any hidden
105
+ (dot-prefixed) directory. This lets one root cover a whole workspace, e.g.
106
+ `~/desarrollo` finding every project under `~/desarrollo/<client>/<project>`
107
+ without listing each one. Projects are deduped by real (symlink-resolved)
108
+ path and sorted by name — the directory's basename, or the last two path
109
+ segments joined with `/` when two discovered projects share a basename
110
+ (e.g. `clientA/shared` and `clientB/shared`). `~` expands to the home
111
+ directory. Missing or malformed config falls back to the current working
112
+ directory as the only root.
64
113
 
65
114
  ### Hub credentials (Catalog and Sync)
66
115
 
@@ -1,12 +1,20 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { basename, join } from "node:path";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, join, sep } from "node:path";
3
+ /** How many directory levels below each root {@link discoverProjects} descends into by default. */
4
+ const DEFAULT_MAX_DEPTH = 5;
5
+ const SKIPPED_DIR_NAMES = new Set(["node_modules", ".git"]);
3
6
  function hasWorklog(dir) {
4
7
  return existsSync(join(dir, ".kankaku", "worklog.jsonl"));
5
8
  }
6
- function childDirs(root) {
9
+ /** `node_modules`, `.git`, and any dot-prefixed (hidden) directory are never searched. */
10
+ function isSkipped(name) {
11
+ return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
12
+ }
13
+ function childDirs(dir) {
7
14
  try {
8
- return readdirSync(root)
9
- .map((entry) => join(root, entry))
15
+ return readdirSync(dir)
16
+ .filter((name) => !isSkipped(name))
17
+ .map((name) => join(dir, name))
10
18
  .filter((entry) => {
11
19
  try {
12
20
  return statSync(entry).isDirectory();
@@ -21,23 +29,72 @@ function childDirs(root) {
21
29
  }
22
30
  }
23
31
  /**
24
- * Discover projects under `roots`: a root that itself has
25
- * `.kankaku/worklog.jsonl` is a project; otherwise each direct child
26
- * directory with one is a project. Deduped by `dir`, sorted by `name`.
27
- * Never throws on an unreadable or missing root.
32
+ * The real (symlink-resolved) path, used only as the dedup key — the
33
+ * `ProjectRef.dir` returned to callers always stays the path as
34
+ * discovered. Falls back to `dir` itself when it cannot be resolved (e.g.
35
+ * a permission error), so an unreadable directory never throws.
36
+ */
37
+ function realDirPath(dir) {
38
+ try {
39
+ return realpathSync(dir);
40
+ }
41
+ catch {
42
+ return dir;
43
+ }
44
+ }
45
+ /**
46
+ * Walk `dir`, `depth` levels below its root: a directory with
47
+ * `.kankaku/worklog.jsonl` is a project, and the walk still continues
48
+ * below it — a pi session run once in a parent directory (`~/desarrollo`)
49
+ * leaves a worklog there, and that stray file must never hide the real
50
+ * projects beneath (`~/desarrollo/<client>/<project>`). Subdirectories
51
+ * (skipping `node_modules`, `.git` and any hidden directory) are walked
52
+ * one level deeper, up to `maxDepth`.
28
53
  */
29
- export function discoverProjects(roots) {
30
- const byDir = new Map();
54
+ function walk(dir, depth, maxDepth, found) {
55
+ if (hasWorklog(dir))
56
+ found.set(realDirPath(dir), dir);
57
+ if (depth >= maxDepth)
58
+ return;
59
+ for (const child of childDirs(dir)) {
60
+ walk(child, depth + 1, maxDepth, found);
61
+ }
62
+ }
63
+ /** `basename(dir)`, or the last two path segments joined with `/` when that basename is ambiguous (shared by another discovered project). */
64
+ function projectName(dir, duplicateBasenames) {
65
+ const base = basename(dir);
66
+ if (!duplicateBasenames.has(base))
67
+ return base;
68
+ const segments = dir.split(sep).filter((segment) => segment.length > 0);
69
+ return segments.slice(-2).join("/");
70
+ }
71
+ /**
72
+ * Discover projects under `roots`, searching up to `options.maxDepth`
73
+ * directory levels below each root (default {@link DEFAULT_MAX_DEPTH}): a
74
+ * directory with `.kankaku/worklog.jsonl` is a project and is never
75
+ * descended into (so a project nested inside another project is never
76
+ * double-counted); otherwise its subdirectories are searched one level
77
+ * deeper, skipping `node_modules`, `.git` and any hidden (dot-prefixed)
78
+ * directory. Deduped by real (symlink-resolved) path; sorted by `name`,
79
+ * which is the directory basename, or the last two path segments joined
80
+ * with `/` when two discovered projects share a basename. Never throws on
81
+ * an unreadable or missing root or subdirectory — those are silently
82
+ * skipped.
83
+ */
84
+ export function discoverProjects(roots, options = {}) {
85
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
86
+ const found = new Map();
31
87
  for (const root of roots) {
32
- if (hasWorklog(root)) {
33
- byDir.set(root, { name: basename(root), dir: root });
34
- continue;
35
- }
36
- for (const child of childDirs(root)) {
37
- if (hasWorklog(child)) {
38
- byDir.set(child, { name: basename(child), dir: child });
39
- }
40
- }
88
+ walk(root, 0, maxDepth, found);
89
+ }
90
+ const dirs = Array.from(found.values());
91
+ const basenameCounts = new Map();
92
+ for (const dir of dirs) {
93
+ const base = basename(dir);
94
+ basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
41
95
  }
42
- return Array.from(byDir.values()).sort((a, b) => a.name.localeCompare(b.name));
96
+ const duplicateBasenames = new Set(Array.from(basenameCounts.entries())
97
+ .filter(([, count]) => count > 1)
98
+ .map(([base]) => base));
99
+ return dirs.map((dir) => ({ name: projectName(dir, duplicateBasenames), dir })).sort((a, b) => a.name.localeCompare(b.name));
43
100
  }
@@ -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, homeDir: deps.homeDir };
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,245 @@
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
+ /**
18
+ * Shorten `path` for display by replacing a leading `homeDir` with `~`.
19
+ * Only a real path-boundary match counts (`homeDir` itself, or `homeDir`
20
+ * followed by `/`) — a sibling directory that merely shares the prefix
21
+ * (e.g. `/homework` vs `/home`) is left unchanged. `path` is returned as-is
22
+ * when it doesn't start under `homeDir`.
23
+ */
24
+ export function shortenHome(path, homeDir) {
25
+ if (homeDir === "")
26
+ return path;
27
+ if (path === homeDir)
28
+ return "~";
29
+ if (path.startsWith(`${homeDir}/`))
30
+ return `~${path.slice(homeDir.length)}`;
31
+ return path;
32
+ }
33
+ function clearError(errors, key) {
34
+ if (!(key in errors))
35
+ return errors;
36
+ const rest = { ...errors };
37
+ delete rest[key];
38
+ return rest;
39
+ }
40
+ const HUB_ERROR_KEYS = ["url", "email", "password", "localCheckout"];
41
+ function clearHubErrors(errors) {
42
+ return HUB_ERROR_KEYS.reduce((acc, key) => clearError(acc, key), errors);
43
+ }
44
+ /** Build the wizard's initial state from plain, already-read facts: agent detection, current hub credentials and current `tui.json` roots. */
45
+ export function createWizardState(facts) {
46
+ const agents = detectAgents(facts.agentFacts);
47
+ const selected = Object.fromEntries(agents.map((agent) => [agent.id, agent.configured]));
48
+ return {
49
+ step: "agents",
50
+ agents,
51
+ selected,
52
+ claudeCheckout: guessClaudeCheckout(facts.agentFacts.claudeCode?.statusLineCommand),
53
+ hub: {
54
+ mode: facts.hub.credentialsPresent ? "existing" : "skip",
55
+ url: facts.hub.url ?? "",
56
+ email: facts.hub.email ?? "",
57
+ password: facts.hub.password ?? "",
58
+ localCheckout: facts.hub.localCheckoutGuess,
59
+ localManual: false,
60
+ },
61
+ roots: facts.roots.current ?? facts.roots.defaultRoots,
62
+ errors: {},
63
+ plan: [],
64
+ results: [],
65
+ };
66
+ }
67
+ /** Whether the Claude Code step should be shown: Claude is selected and not already configured. Exported so `ui/setup/wizard-screen.tsx` can number only the steps that will actually be shown for this run. */
68
+ export function claudeStepNeeded(state) {
69
+ const claude = state.agents.find((agent) => agent.id === "claude-code");
70
+ return state.selected["claude-code"] === true && claude !== undefined && !claude.configured;
71
+ }
72
+ export function toggleAgent(state, id) {
73
+ if (id === "codex" || id === "opencode")
74
+ return state;
75
+ return { ...state, selected: { ...state.selected, [id]: !state.selected[id] } };
76
+ }
77
+ export function setClaudeCheckout(state, value) {
78
+ return { ...state, claudeCheckout: value, errors: clearError(state.errors, "claudeCheckout") };
79
+ }
80
+ export function setHubMode(state, mode) {
81
+ return { ...state, hub: { ...state.hub, mode }, errors: clearHubErrors(state.errors) };
82
+ }
83
+ export function setHubField(state, field, value) {
84
+ return { ...state, hub: { ...state.hub, [field]: value }, errors: clearError(state.errors, field) };
85
+ }
86
+ export function setHubHealth(state, ok) {
87
+ return { ...state, hub: { ...state.hub, healthOk: ok } };
88
+ }
89
+ /** Parses a comma-separated list of roots: trims each entry and drops empties. */
90
+ export function setRoots(state, text) {
91
+ const roots = text
92
+ .split(",")
93
+ .map((entry) => entry.trim())
94
+ .filter((entry) => entry.length > 0);
95
+ return { ...state, roots, errors: clearError(state.errors, "roots") };
96
+ }
97
+ export function setLocalCheckout(state, value) {
98
+ return { ...state, hub: { ...state.hub, localCheckout: value }, errors: clearError(state.errors, "localCheckout") };
99
+ }
100
+ export function setHubLocalManual(state, manual) {
101
+ return { ...state, hub: { ...state.hub, localManual: manual }, errors: clearError(state.errors, "localCheckout") };
102
+ }
103
+ function validateHub(hub) {
104
+ if (hub.mode === "existing") {
105
+ const errors = {};
106
+ if (!/^https?:\/\//.test(hub.url))
107
+ errors.url = "enter a http(s) URL";
108
+ if (!hub.email.includes("@"))
109
+ errors.email = "enter a valid email";
110
+ if (hub.password.trim() === "")
111
+ errors.password = "enter a password";
112
+ return errors;
113
+ }
114
+ if (hub.mode === "local") {
115
+ if (hub.localCheckout.trim() === "" && !hub.localManual) {
116
+ return { localCheckout: "enter a checkout path, or acknowledge installing manually" };
117
+ }
118
+ return {};
119
+ }
120
+ return {};
121
+ }
122
+ function arraysEqual(a, b) {
123
+ return a.length === b.length && a.every((entry, index) => entry === b[index]);
124
+ }
125
+ const PI_HOME_AGENTS = [
126
+ { id: "pi", title: "pi" },
127
+ { id: "gentle-shell", title: "gentle-shell" },
128
+ ];
129
+ /** Build the ordered plan from the current wizard answers, diffed against `facts`' current on-disk state. */
130
+ export function planFromWizard(state, facts) {
131
+ const actions = [];
132
+ for (const { id, title } of PI_HOME_AGENTS) {
133
+ const agent = state.agents.find((candidate) => candidate.id === id);
134
+ if (!agent || !agent.adapterAvailable)
135
+ continue;
136
+ const selected = state.selected[id] === true;
137
+ if (selected === agent.configured)
138
+ continue;
139
+ actions.push(selected
140
+ ? { kind: "install-pi", file: agent.detail, label: `install kankaku in ${title} (${agent.detail})` }
141
+ : { kind: "remove-pi", file: agent.detail, label: `remove kankaku from ${title} (${agent.detail})` });
142
+ }
143
+ const claude = state.agents.find((agent) => agent.id === "claude-code");
144
+ if (claude && claude.adapterAvailable) {
145
+ const selected = state.selected["claude-code"] === true;
146
+ if (selected !== claude.configured) {
147
+ actions.push(selected
148
+ ? { kind: "write-claude", file: claude.detail, label: `set the Claude Code status line (${claude.detail})` }
149
+ : { kind: "remove-claude", file: claude.detail, label: `remove the kankaku status line from Claude Code (${claude.detail})` });
150
+ }
151
+ }
152
+ if (state.hub.mode === "existing") {
153
+ const changed = !facts.hub.credentialsPresent ||
154
+ state.hub.url !== (facts.hub.url ?? "") ||
155
+ state.hub.email !== (facts.hub.email ?? "") ||
156
+ state.hub.password !== (facts.hub.password ?? "");
157
+ if (changed) {
158
+ actions.push({ kind: "write-hub", file: facts.hub.credentialsPath, label: `write hub credentials to ${facts.hub.credentialsPath}` });
159
+ }
160
+ }
161
+ else if (state.hub.mode === "local" && state.hub.localCheckout.trim() !== "") {
162
+ actions.push({ kind: "install-local-hub", file: state.hub.localCheckout, label: `install a local hub from ${state.hub.localCheckout}` });
163
+ }
164
+ const sameRoots = facts.roots.current !== undefined && arraysEqual(facts.roots.current, state.roots);
165
+ if (!sameRoots) {
166
+ actions.push({ kind: "write-roots", file: facts.roots.path, label: `write roots to ${facts.roots.path}` });
167
+ }
168
+ return actions;
169
+ }
170
+ /** Advance from the current step: validates it, sets `errors` and stays when invalid, otherwise moves on. */
171
+ export function next(state, facts) {
172
+ switch (state.step) {
173
+ case "agents":
174
+ return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
175
+ case "claude": {
176
+ if (state.claudeCheckout.trim() === "") {
177
+ return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path" } };
178
+ }
179
+ return { ...state, step: "hub", errors: clearError(state.errors, "claudeCheckout") };
180
+ }
181
+ case "hub": {
182
+ const hubErrors = validateHub(state.hub);
183
+ if (Object.keys(hubErrors).length > 0) {
184
+ return { ...state, errors: { ...clearHubErrors(state.errors), ...hubErrors } };
185
+ }
186
+ return { ...state, step: "roots", errors: clearHubErrors(state.errors) };
187
+ }
188
+ case "roots": {
189
+ if (state.roots.length === 0) {
190
+ return { ...state, errors: { ...state.errors, roots: "enter at least one root directory" } };
191
+ }
192
+ const reviewState = { ...state, step: "review", errors: clearError(state.errors, "roots") };
193
+ return { ...reviewState, plan: planFromWizard(reviewState, facts) };
194
+ }
195
+ case "review":
196
+ return { ...state, step: "apply" };
197
+ case "apply":
198
+ return state.results.length >= state.plan.length ? { ...state, step: "done" } : state;
199
+ case "done":
200
+ return state;
201
+ }
202
+ }
203
+ /** Move back to the previous step, mirroring `next`'s claude-step skip. Terminal/first steps (`agents`, `apply`, `done`) are no-ops. */
204
+ export function back(state) {
205
+ switch (state.step) {
206
+ case "agents":
207
+ return state;
208
+ case "claude":
209
+ return { ...state, step: "agents", errors: {} };
210
+ case "hub":
211
+ return { ...state, step: claudeStepNeeded(state) ? "claude" : "agents", errors: {} };
212
+ case "roots":
213
+ return { ...state, step: "hub", errors: {} };
214
+ case "review":
215
+ return { ...state, step: "roots", errors: {} };
216
+ case "apply":
217
+ return state;
218
+ case "done":
219
+ return state;
220
+ }
221
+ }
222
+ export function applyResult(state, result) {
223
+ return { ...state, results: [...state.results, result] };
224
+ }
225
+ /** The footer key hints for a given step. */
226
+ export function hintsForStep(step) {
227
+ const quit = { key: "q", label: "quit" };
228
+ switch (step) {
229
+ case "agents":
230
+ // The first step: esc quits (handled by the caller), so it isn't hinted as "back" here.
231
+ return [{ key: "space", label: "toggle" }, { key: "↑↓", label: "move" }, { key: "enter", label: "next" }, quit];
232
+ case "claude":
233
+ return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
234
+ case "hub":
235
+ return [{ key: "↑↓", label: "choose" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
236
+ case "roots":
237
+ return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
238
+ case "review":
239
+ return [{ key: "enter", label: "apply" }, { key: "esc", label: "back" }, quit];
240
+ case "apply":
241
+ return [quit];
242
+ case "done":
243
+ return [{ key: "enter", label: "open dashboard" }, quit];
244
+ }
245
+ }
@@ -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,195 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import { applyResult, back, claudeStepNeeded, createWizardState, hintsForStep, next, planFromWizard, setClaudeCheckout, setHubField, setHubHealth, setHubLocalManual, setHubMode, setLocalCheckout, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
5
+ import { HeaderBar } from "../components/header-bar.js";
6
+ import { KeyHints } from "../components/key-hints.js";
7
+ import { Panel } from "../components/panel.js";
8
+ import { Table } from "../components/table.js";
9
+ import { Checklist } from "../components/checklist.js";
10
+ import { Radio } from "../components/radio.js";
11
+ import { TextInput } from "../components/text-input.js";
12
+ import { useTheme } from "../theme.js";
13
+ /** Fallback terminal height when neither `rows` nor `useStdout().rows` is available (matches `ui/layout.tsx`'s own fallback). */
14
+ const DEFAULT_ROWS = 24;
15
+ /** Rows used by the header line. */
16
+ const HEADER_ROWS = 1;
17
+ /** Rows used by the footer key-hints line. */
18
+ const FOOTER_ROWS = 1;
19
+ /** Panel titles for every step. Used verbatim for `apply`/`done` (never numbered); `agents`/`claude`/`hub`/`roots`/`review` get a `n/total` suffix from {@link panelTitle}. */
20
+ const STEP_TITLES = {
21
+ agents: "Agents",
22
+ claude: "Claude Code",
23
+ hub: "Hub",
24
+ roots: "Roots",
25
+ review: "Review",
26
+ apply: "Apply",
27
+ done: "Done",
28
+ };
29
+ /** The steps a run numbers in its panel title, in order; `claude` is dropped from this list when the run doesn't need it (see {@link numberedStepsForRun}). `apply` and `done` are never numbered — `apply` is Review's own execution and `done` is the terminal summary. */
30
+ const NUMBERED_STEPS = ["agents", "claude", "hub", "roots", "review"];
31
+ /** The steps that will actually be shown for this run, e.g. `["agents", "hub", "roots", "review"]` when Claude Code is skipped. */
32
+ function numberedStepsForRun(state) {
33
+ return NUMBERED_STEPS.filter((step) => step !== "claude" || claudeStepNeeded(state));
34
+ }
35
+ /** `[ Setup · Agents 1/5 ]`-style progress title: `n/total` over only the steps this run will show; `apply`/`done` render unnumbered. */
36
+ function panelTitle(state) {
37
+ const title = STEP_TITLES[state.step];
38
+ const steps = numberedStepsForRun(state);
39
+ const index = steps.indexOf(state.step);
40
+ return index === -1 ? `Setup · ${title}` : `Setup · ${title} ${index + 1}/${steps.length}`;
41
+ }
42
+ const AGENT_TITLES = {
43
+ pi: "pi",
44
+ "gentle-shell": "gentle-shell",
45
+ "claude-code": "Claude Code",
46
+ codex: "Codex",
47
+ opencode: "OpenCode",
48
+ };
49
+ /** The Agents checklist row's note: presence/configured state for pi/gentle-shell/Claude Code (always checkable), or why an agent with no adapter yet (Codex/OpenCode, always disabled) can't be. */
50
+ function agentNote(agent, homeDir) {
51
+ if (!agent.adapterAvailable)
52
+ return agent.present ? "no adapter yet" : "not installed";
53
+ return agent.configured ? `configured (${shortenHome(agent.detail, homeDir)})` : "not configured";
54
+ }
55
+ const REVIEW_FIXED_COLUMNS_WIDTH = 30 + 1 + 2 + 2;
56
+ function reviewColumns(panelWidth) {
57
+ const labelWidth = Math.max(panelWidth - REVIEW_FIXED_COLUMNS_WIDTH, 20);
58
+ return [
59
+ { key: "label", header: "action", width: labelWidth },
60
+ { key: "file", header: "file", width: 30 },
61
+ ];
62
+ }
63
+ function reviewCell(row, key) {
64
+ return key === "file" ? row.file : row.label;
65
+ }
66
+ function applyResultLine(result) {
67
+ if (result.outcome === "error")
68
+ return `error: ${result.detail ?? result.action.label}`;
69
+ const detail = result.detail ? ` (${result.detail})` : "";
70
+ return `${result.outcome} ${result.action.file}${detail}`;
71
+ }
72
+ /**
73
+ * Detection is folded into the Agents checklist itself (R1): each row's
74
+ * label already carries the agent's presence/configured state, e.g.
75
+ * `pi — configured (~/.pi/agent/settings.json)` or `gentle-shell — not
76
+ * configured`; disabled rows (no adapter yet) read `Codex — no adapter
77
+ * yet` or `OpenCode — not installed`. `note` is left unset so `Checklist`
78
+ * never appends its own `(note)` suffix on top.
79
+ */
80
+ function agentChecklistItems(agents, selected, homeDir) {
81
+ return agents.map((agent) => ({
82
+ id: agent.id,
83
+ label: `${AGENT_TITLES[agent.id]} — ${agentNote(agent, homeDir)}`,
84
+ checked: selected[agent.id] === true,
85
+ disabled: !agent.adapterAvailable,
86
+ }));
87
+ }
88
+ const HUB_MODE_OPTIONS = [
89
+ { value: "existing", label: "use an existing hub" },
90
+ { value: "local", label: "install locally" },
91
+ { value: "skip", label: "skip" },
92
+ ];
93
+ /** Mirrors `adapters/setup/local-hub.ts`'s own `HUB_URL`: where a manually-started local hub is expected to answer. */
94
+ const LOCAL_HUB_URL = "http://127.0.0.1:8090";
95
+ function healthLine(healthOk, checking) {
96
+ if (checking)
97
+ return "checking…";
98
+ if (healthOk === undefined)
99
+ return "";
100
+ return healthOk ? "health ok" : "health failed";
101
+ }
102
+ /**
103
+ * `kankaku setup`'s interactive wizard: one full-screen step at a time,
104
+ * driven by `domain/setup-wizard.ts`'s pure reducers. Unlike every other
105
+ * screen it renders its own header/panel/footer frame directly instead of
106
+ * `ui/layout.tsx`'s `Layout` — there is no sidebar to show, since the
107
+ * wizard's progress lives in the panel's own title instead (`panelTitle`,
108
+ * e.g. `Setup · Agents 1/5`, numbering only the steps this run will
109
+ * actually show — Claude Code is dropped when it isn't needed). Enter
110
+ * advances (validating first), Esc goes back (quits from Agents, the
111
+ * first step, via `onQuit`); `q` quits from anywhere a text field isn't
112
+ * currently capturing keystrokes.
113
+ */
114
+ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns, rows }) {
115
+ const theme = useTheme();
116
+ const [state, setState] = useState(() => createWizardState(facts));
117
+ const [agentCursor, setAgentCursor] = useState(0);
118
+ const [hubFocus, setHubFocus] = useState(0);
119
+ const [healthChecking, setHealthChecking] = useState(false);
120
+ const [rootsText, setRootsText] = useState(() => state.roots.join(", "));
121
+ const applyStartedRef = useRef(false);
122
+ useEffect(() => {
123
+ if (state.step === "hub")
124
+ setHubFocus(0);
125
+ }, [state.step]);
126
+ const foundCheckout = state.hub.mode === "local" ? actions.findHubCheckout() : undefined;
127
+ const hasCheckoutField = state.hub.mode === "local" && (state.hub.localCheckout.trim() !== "" || foundCheckout !== undefined);
128
+ const checkoutValue = state.hub.localCheckout.trim() !== "" ? state.hub.localCheckout : (foundCheckout ?? "");
129
+ const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" && hasCheckoutField ? 2 : 1;
130
+ const textInputFocused = state.step === "claude" || state.step === "roots" || (state.step === "hub" && hubFocus >= 1 && hubFocus < hubFieldCount);
131
+ useEffect(() => {
132
+ if (state.step !== "apply" || applyStartedRef.current)
133
+ return;
134
+ applyStartedRef.current = true;
135
+ const snapshot = state;
136
+ void (async () => {
137
+ for (const action of snapshot.plan) {
138
+ const result = await actions.apply(action, snapshot);
139
+ setState((s) => applyResult(s, result));
140
+ }
141
+ })();
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [state.step]);
144
+ useInput((input, key) => {
145
+ if (key.tab && state.step === "hub") {
146
+ setHubFocus((current) => (current + 1) % hubFieldCount);
147
+ return;
148
+ }
149
+ if (key.return) {
150
+ if (state.step === "done") {
151
+ onDone();
152
+ return;
153
+ }
154
+ setState((s) => next(s, facts));
155
+ return;
156
+ }
157
+ if (key.escape) {
158
+ if (state.step === "agents") {
159
+ onQuit();
160
+ return;
161
+ }
162
+ setState((s) => back(s));
163
+ return;
164
+ }
165
+ if (!textInputFocused && input === "q") {
166
+ onQuit();
167
+ return;
168
+ }
169
+ if (state.step === "hub" && !textInputFocused) {
170
+ if (input === "c") {
171
+ const url = state.hub.mode === "existing" ? state.hub.url : LOCAL_HUB_URL;
172
+ setHealthChecking(true);
173
+ void actions.checkHealth(url).then((ok) => {
174
+ setState((s) => setHubHealth(s, ok));
175
+ setHealthChecking(false);
176
+ });
177
+ return;
178
+ }
179
+ if (input === "m" && state.hub.mode === "local" && !hasCheckoutField) {
180
+ setState((s) => setHubLocalManual(s, true));
181
+ return;
182
+ }
183
+ }
184
+ });
185
+ const hints = hintsForStep(state.step);
186
+ const { stdout } = useStdout();
187
+ const width = columns ?? stdout?.columns ?? 80;
188
+ const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
189
+ const mainWidth = width;
190
+ const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
191
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), 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) => {
192
+ setRootsText(value);
193
+ setState((s) => setRoots(s, value));
194
+ }, 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" })] }))] }) }), _jsx(KeyHints, { hints: hints })] }));
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "license": "MIT",
6
6
  "type": "module",