kankaku-tui 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,36 @@ in. Built with [Ink](https://github.com/vadimdemedes/ink) on Node 24. Four
8
8
  screens — Dashboard, Tasks, Catalog and Sync — share one tab bar, and each
9
9
  has a plain-text subcommand for scripts and cron.
10
10
 
11
+ ## Install everything
12
+
13
+ ```
14
+ npm i -g kankaku-tui
15
+ kankaku setup
16
+ ```
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.
36
+
37
+ `kankaku setup` ends with, and `kankaku doctor` prints on its own, the
38
+ same read-only report: one line per agent, one for the hub, one for
39
+ `tui.json`, and a `next: …` hint for anything still `todo`.
40
+
11
41
  ## Install
12
42
 
13
43
  Until the next kankaku release, this package depends on the sibling
@@ -78,6 +108,9 @@ never guesses which coding agent produced someone else's worklog.
78
108
  pending count and last sync per project, no network; with no argument,
79
109
  syncs the pending window; `all` does a full resync. Defaults to every
80
110
  discovered project, sequentially; `--project <dir>` restricts to one.
111
+ - `kankaku setup [--yes] [--dry-run]` — see "Install everything" above.
112
+ - `kankaku doctor` — the same read-only report `kankaku setup` ends with,
113
+ without prompting or writing anything.
81
114
 
82
115
  `--roots` (on `today`/`tasks`) overrides the configured roots for that run.
83
116
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Reads the real per-agent settings/config files under `homeDir` into the
3
+ * plain facts `domain/setup-plan.ts#detectAgents` expects. Never throws: a
4
+ * missing or malformed file reads as absent, exactly like
5
+ * `adapters/tui-config.ts#readTuiConfig` and kankaku's own
6
+ * `hub-credentials.ts#readCredentialsFile`.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ function readJsonObject(filePath) {
11
+ if (!existsSync(filePath))
12
+ return undefined;
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
15
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
16
+ return undefined;
17
+ return parsed;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ function isStringArray(value) {
24
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
25
+ }
26
+ function readSettingsPackages(settingsPath) {
27
+ const parsed = readJsonObject(settingsPath);
28
+ if (!parsed)
29
+ return undefined;
30
+ const packages = isStringArray(parsed["packages"]) ? parsed["packages"] : [];
31
+ return { settingsPath, packages };
32
+ }
33
+ function readClaudeSettings(settingsPath) {
34
+ const parsed = readJsonObject(settingsPath);
35
+ if (!parsed)
36
+ return undefined;
37
+ const statusLine = parsed["statusLine"];
38
+ const command = statusLine && typeof statusLine === "object" && !Array.isArray(statusLine) && typeof statusLine["command"] === "string"
39
+ ? statusLine["command"]
40
+ : undefined;
41
+ return { settingsPath, statusLineCommand: command };
42
+ }
43
+ function readConfigFilePresence(configPath) {
44
+ return existsSync(configPath) ? { configPath } : undefined;
45
+ }
46
+ /** Read every agent's settings/config file under `homeDir`, tolerating any absence or malformed content. */
47
+ export function readAgentFacts(homeDir) {
48
+ return {
49
+ pi: readSettingsPackages(join(homeDir, ".pi", "agent", "settings.json")),
50
+ gentleShell: readSettingsPackages(join(homeDir, ".gentle-shell", "agent", "settings.json")),
51
+ claudeCode: readClaudeSettings(join(homeDir, ".claude", "settings.json")),
52
+ codex: readConfigFilePresence(join(homeDir, ".codex", "config.toml")),
53
+ opencode: readConfigFilePresence(join(homeDir, ".config", "opencode", "opencode.json")),
54
+ };
55
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Writer for Claude Code's `~/.claude/settings.json`: sets `statusLine` to
3
+ * run kankaku's `src/statusline.ts` from the given `kankaku-claude`
4
+ * checkout, leaving every other key untouched. A no-op when the
5
+ * `statusLine.command` already runs that exact statusline.ts for this
6
+ * checkout.
7
+ */
8
+ import { backupOnce, readJsonObjectOrEmpty, writeJsonAtomic } from "./json-writer.js";
9
+ /** The exact `statusLine.command` kankaku-tui writes for a given `kankaku-claude` checkout path. */
10
+ export function statusLineCommand(checkoutPath) {
11
+ return `node "${checkoutPath}/src/statusline.ts"`;
12
+ }
13
+ export function writeStatusLine(settingsPath, checkoutPath) {
14
+ const existing = readJsonObjectOrEmpty(settingsPath);
15
+ const command = statusLineCommand(checkoutPath);
16
+ const currentStatusLine = existing["statusLine"];
17
+ const currentCommand = currentStatusLine && typeof currentStatusLine === "object" && !Array.isArray(currentStatusLine)
18
+ ? currentStatusLine["command"]
19
+ : undefined;
20
+ if (currentCommand === command)
21
+ return { changed: false };
22
+ backupOnce(settingsPath);
23
+ writeJsonAtomic(settingsPath, { ...existing, statusLine: { type: "command", command } });
24
+ return { changed: true };
25
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Setup-only hub adapter: writes `~/.kankaku/credentials.json` and checks
3
+ * the hub's health endpoint. Reading hub credentials for normal (non-setup)
4
+ * use is `adapters/hub.ts#resolveHub`, wrapping kankaku's own
5
+ * `resolveHubCredentials` — this file only ever writes.
6
+ */
7
+ import { existsSync, mkdirSync, chmodSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { backupOnce, readJsonObjectOrEmpty, writeJsonAtomic } from "./json-writer.js";
10
+ const OWNER_DIR_MODE = 0o700;
11
+ const OWNER_FILE_MODE = 0o600;
12
+ export function credentialsPath(homeDir) {
13
+ return join(homeDir, ".kankaku", "credentials.json");
14
+ }
15
+ /**
16
+ * Write `credentials` to `<homeDir>/.kankaku/credentials.json`, 0600.
17
+ * `~/.kankaku` is created 0700 only when it does not exist yet — an
18
+ * already-existing `~/.kankaku` (shared with kankaku's own worklog
19
+ * storage) is never chmod'd, mirroring kankaku's own R2 rule. A no-op,
20
+ * with no backup and no write, when the file already holds these exact
21
+ * values.
22
+ */
23
+ export function writeHubCredentials(homeDir, credentials) {
24
+ const dir = join(homeDir, ".kankaku");
25
+ if (!existsSync(dir))
26
+ mkdirSync(dir, { recursive: true, mode: OWNER_DIR_MODE });
27
+ const filePath = credentialsPath(homeDir);
28
+ const existing = readJsonObjectOrEmpty(filePath);
29
+ const unchanged = existsSync(filePath) && existing["url"] === credentials.url && existing["email"] === credentials.email && existing["password"] === credentials.password;
30
+ if (unchanged)
31
+ return { changed: false };
32
+ backupOnce(filePath);
33
+ writeJsonAtomic(filePath, { ...existing, url: credentials.url, email: credentials.email, password: credentials.password });
34
+ chmodSync(filePath, OWNER_FILE_MODE);
35
+ return { changed: true };
36
+ }
37
+ /** `GET <url>/api/health` under a timeout (default 5s); `true` only on an ok response, `false` on any error, non-ok status, or timeout. Never throws. */
38
+ export async function checkHubHealth(url, deps = {}) {
39
+ const doFetch = deps.fetch ?? fetch;
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 5000);
42
+ try {
43
+ const response = await doFetch(`${url.replace(/\/+$/, "")}/api/health`, { signal: controller.signal });
44
+ return response.ok;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared write primitives for every `setup` writer: read-existing-or-{},
3
+ * atomic tmp+rename, and a one-time `<file>.bak` — mirrors kankaku's own
4
+ * `adapters/project-config.ts#writeProjectTargetIds`.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ /** Parse `filePath` as a JSON object, defaulting to `{}` on any absence, malformed JSON, or non-object document. Never throws. */
9
+ export function readJsonObjectOrEmpty(filePath) {
10
+ if (!existsSync(filePath))
11
+ return {};
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
14
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
15
+ return {};
16
+ return parsed;
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /**
23
+ * Copy `filePath`'s current bytes to `<filePath>.bak`, but only the first
24
+ * time: a no-op when `filePath` does not exist yet (nothing to back up) or
25
+ * `<filePath>.bak` already exists (never overwritten).
26
+ */
27
+ export function backupOnce(filePath) {
28
+ if (!existsSync(filePath))
29
+ return;
30
+ const backupPath = `${filePath}.bak`;
31
+ if (existsSync(backupPath))
32
+ return;
33
+ writeFileSync(backupPath, readFileSync(filePath));
34
+ }
35
+ /** Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename, creating the parent directory when missing. */
36
+ export function writeJsonAtomic(filePath, value, dirMode) {
37
+ mkdirSync(dirname(filePath), dirMode !== undefined ? { recursive: true, mode: dirMode } : { recursive: true });
38
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
39
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
40
+ renameSync(tmp, filePath);
41
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Writer for a pi-family settings file (`~/.pi/agent/settings.json`,
3
+ * `~/.gentle-shell/agent/settings.json` — same shape, shared here): adds
4
+ * `"npm:kankaku"` to `packages`, leaving every other key and its order
5
+ * untouched. A no-op when kankaku is already present (`npm:kankaku`, a
6
+ * versioned spec, or a local path — see `domain/setup-plan.ts#isKankakuPackage`).
7
+ */
8
+ import { isKankakuPackage } from "../../domain/setup-plan.js";
9
+ import { backupOnce, readJsonObjectOrEmpty, writeJsonAtomic } from "./json-writer.js";
10
+ function isStringArray(value) {
11
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
12
+ }
13
+ export function addKankakuPackage(settingsPath) {
14
+ const existing = readJsonObjectOrEmpty(settingsPath);
15
+ const packages = isStringArray(existing["packages"]) ? existing["packages"] : [];
16
+ if (packages.some(isKankakuPackage))
17
+ return { changed: false };
18
+ backupOnce(settingsPath);
19
+ writeJsonAtomic(settingsPath, { ...existing, packages: [...packages, "npm:kankaku"] });
20
+ return { changed: true };
21
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `node:readline/promises`-backed `Prompter`. `secret()` hides typed input
3
+ * by routing readline's own echo through a `Writable` that swallows bytes
4
+ * while muted — readline only echoes per keystroke when it treats the
5
+ * output as a terminal (`terminal: true`, the default when `output.isTTY`
6
+ * is set), which is exactly the case that needs hiding; a piped/non-tty
7
+ * output (as in tests) never echoes per keystroke in the first place, so
8
+ * muting there is a no-op rather than a behaviour change.
9
+ */
10
+ import * as readline from "node:readline/promises";
11
+ import { Writable } from "node:stream";
12
+ function createMutableOutput(realOutput) {
13
+ let muted = false;
14
+ const stream = new Writable({
15
+ write(chunk, encoding, callback) {
16
+ if (!muted)
17
+ realOutput.write(chunk, encoding);
18
+ callback();
19
+ },
20
+ });
21
+ // Mirror `isTTY` so readline's terminal-mode autodetection (based on
22
+ // `output.isTTY`) behaves the same as it would against `realOutput`.
23
+ stream.isTTY = realOutput.isTTY;
24
+ return { stream, setMuted: (value) => (muted = value) };
25
+ }
26
+ export function createReadlinePrompter(input, output) {
27
+ return {
28
+ async confirm(question, defaultValue) {
29
+ const rl = readline.createInterface({ input, output });
30
+ try {
31
+ const suffix = defaultValue ? "[Y/n]" : "[y/N]";
32
+ const raw = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
33
+ if (raw === "")
34
+ return defaultValue;
35
+ return raw === "y" || raw === "yes";
36
+ }
37
+ finally {
38
+ rl.close();
39
+ }
40
+ },
41
+ async text(question, defaultValue) {
42
+ const rl = readline.createInterface({ input, output });
43
+ try {
44
+ const raw = (await rl.question(`${question} (${defaultValue}) `)).trim();
45
+ return raw === "" ? defaultValue : raw;
46
+ }
47
+ finally {
48
+ rl.close();
49
+ }
50
+ },
51
+ async secret(question) {
52
+ const mutable = createMutableOutput(output);
53
+ const rl = readline.createInterface({ input, output: mutable.stream });
54
+ try {
55
+ mutable.setMuted(false);
56
+ output.write(`${question} `);
57
+ mutable.setMuted(true);
58
+ const answer = await rl.question("");
59
+ return answer;
60
+ }
61
+ finally {
62
+ mutable.setMuted(false);
63
+ output.write("\n");
64
+ rl.close();
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Setup-only writer for `~/.kankaku/tui.json`. Reading it for normal
3
+ * (non-setup) use is `adapters/tui-config.ts#readTuiConfig` — this file
4
+ * only ever writes.
5
+ */
6
+ import { existsSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { backupOnce, readJsonObjectOrEmpty, writeJsonAtomic } from "./json-writer.js";
9
+ const OWNER_DIR_MODE = 0o700;
10
+ export function tuiConfigPath(homeDir) {
11
+ return join(homeDir, ".kankaku", "tui.json");
12
+ }
13
+ function sameRoots(a, b) {
14
+ return Array.isArray(a) && a.length === b.length && a.every((entry, index) => entry === b[index]);
15
+ }
16
+ /**
17
+ * Write `{ roots }` to `<homeDir>/.kankaku/tui.json`, preserving any other
18
+ * existing key. `~/.kankaku` is created 0700 only when it does not exist
19
+ * yet (never chmod'd otherwise — see `setup/hub.ts#writeHubCredentials`).
20
+ * A no-op, with no backup and no write, when `roots` already matches
21
+ * exactly.
22
+ */
23
+ export function writeTuiConfig(homeDir, roots) {
24
+ const dir = join(homeDir, ".kankaku");
25
+ if (!existsSync(dir))
26
+ mkdirSync(dir, { recursive: true, mode: OWNER_DIR_MODE });
27
+ const filePath = tuiConfigPath(homeDir);
28
+ const existing = readJsonObjectOrEmpty(filePath);
29
+ if (existsSync(filePath) && sameRoots(existing["roots"], roots))
30
+ return { changed: false };
31
+ backupOnce(filePath);
32
+ writeJsonAtomic(filePath, { ...existing, roots });
33
+ return { changed: true };
34
+ }
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
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 { resolve } from "node:path";
5
- import { realpathSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { existsSync, realpathSync } from "node:fs";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { render } from "ink";
8
8
  import { buildTasks, localDay } from "kankaku/domain";
@@ -19,7 +19,14 @@ import { App } from "./ui/app.js";
19
19
  import { buildTasksModel } from "./domain/tasks-model.js";
20
20
  import { buildSyncRows } from "./domain/sync-model.js";
21
21
  import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
22
- const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]] [--roots a,b] [--theme name]\n";
22
+ import { detectAgents, formatDoctorLines, formatSetupPlanLines, planSetup } from "./domain/setup-plan.js";
23
+ import { readAgentFacts } from "./adapters/setup/agents.js";
24
+ import { addKankakuPackage } from "./adapters/setup/pi.js";
25
+ import { writeStatusLine } from "./adapters/setup/claude.js";
26
+ import { checkHubHealth, credentialsPath, writeHubCredentials } from "./adapters/setup/hub.js";
27
+ import { tuiConfigPath, writeTuiConfig } from "./adapters/setup/tui-config.js";
28
+ import { createReadlinePrompter } from "./adapters/setup/readline-prompter.js";
29
+ 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";
23
30
  /** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
24
31
  export function loadToday(roots) {
25
32
  const projects = discoverProjects(roots);
@@ -192,6 +199,130 @@ async function runSyncCommand(args, roots, deps) {
192
199
  if (failed)
193
200
  deps.exit(1);
194
201
  }
202
+ /**
203
+ * Gather the plain facts `domain/setup-plan.ts` needs: every detected
204
+ * agent, hub credentials/health, and whether `tui.json` exists. Read-only;
205
+ * the hub health check (bounded to 5s) is the only network call.
206
+ */
207
+ async function gatherSetupFacts(deps) {
208
+ const agents = detectAgents(readAgentFacts(deps.homeDir));
209
+ const hubResolution = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
210
+ const credPath = credentialsPath(deps.homeDir);
211
+ const hub = hubResolution.ok
212
+ ? {
213
+ credentialsPresent: true,
214
+ url: hubResolution.credentials.url,
215
+ healthOk: await checkHubHealth(hubResolution.credentials.url, { fetch: deps.fetch }),
216
+ credentialsPath: credPath,
217
+ }
218
+ : { credentialsPresent: false, url: undefined, healthOk: undefined, credentialsPath: credPath };
219
+ const tuiPath = tuiConfigPath(deps.homeDir);
220
+ const tui = { present: existsSync(tuiPath), path: tuiPath };
221
+ return { agents, hub, tui };
222
+ }
223
+ /** `kankaku doctor`: read-only report, `domain/setup-plan.ts#formatDoctorLines` verbatim. Also run as the last step of `kankaku setup`. */
224
+ async function runDoctorCommand(deps) {
225
+ const { agents, hub, tui } = await gatherSetupFacts(deps);
226
+ deps.stdout(formatDoctorLines(agents, hub, tui).join("\n"));
227
+ }
228
+ /**
229
+ * Extract a plausible kankaku-claude checkout path from an existing
230
+ * `statusLine.command` of the shape `node "<checkout>/src/statusline.ts"`,
231
+ * whatever package it currently points at — used as the `text()` prompt's
232
+ * default. `undefined` when the command doesn't match that shape at all.
233
+ */
234
+ function guessClaudeCheckout(existingCommand) {
235
+ if (!existingCommand)
236
+ return undefined;
237
+ const match = /^node\s+"(.+)\/src\/statusline\.ts"$/.exec(existingCommand.trim());
238
+ return match ? match[1] : undefined;
239
+ }
240
+ /** Answers one question at a time: `--yes` always takes `defaultValue` without touching `prompter`; otherwise a `Prompter` must have been injected. */
241
+ function makeAsker(yes, prompter) {
242
+ function requirePrompter() {
243
+ if (!prompter)
244
+ throw new Error("kankaku setup: no prompter available (pass --yes or --dry-run, or inject one)");
245
+ return prompter;
246
+ }
247
+ return {
248
+ confirm: (question, defaultValue) => (yes ? Promise.resolve(defaultValue) : requirePrompter().confirm(question, defaultValue)),
249
+ text: (question, defaultValue) => (yes ? Promise.resolve(defaultValue) : requirePrompter().text(question, defaultValue)),
250
+ secret: (question) => (yes ? Promise.resolve("") : requirePrompter().secret(question)),
251
+ };
252
+ }
253
+ /**
254
+ * `kankaku setup [--yes] [--dry-run]`: detects every agent, prompts (or
255
+ * takes each question's own default with `--yes`) for what to install or
256
+ * configure, writes only what was confirmed, and ends with the same
257
+ * report as `kankaku doctor`. `--dry-run` prints the plan and writes
258
+ * nothing — no prompt is asked and no default is applied.
259
+ */
260
+ async function runSetupCommand(args, deps) {
261
+ const dryRun = args.includes("--dry-run");
262
+ const yes = args.includes("--yes");
263
+ const initial = await gatherSetupFacts(deps);
264
+ const steps = planSetup(initial.agents, initial.hub, initial.tui);
265
+ if (dryRun) {
266
+ deps.stdout(formatSetupPlanLines(steps).join("\n"));
267
+ return;
268
+ }
269
+ const ask = makeAsker(yes, deps.prompter);
270
+ const byId = Object.fromEntries(steps.map((step) => [step.id, step]));
271
+ for (const agent of initial.agents) {
272
+ if (agent.id === "codex" || agent.id === "opencode")
273
+ continue;
274
+ const step = byId[agent.id];
275
+ if (step.state !== "todo")
276
+ continue;
277
+ if (agent.id === "claude-code") {
278
+ const doIt = await ask.confirm(`Configure Claude Code's statusLine for kankaku (${step.file})?`, true);
279
+ if (!doIt)
280
+ continue;
281
+ const guessed = guessClaudeCheckout(readAgentFacts(deps.homeDir).claudeCode?.statusLineCommand) ?? "";
282
+ const checkoutPath = await ask.text("Path to your kankaku-claude checkout", guessed);
283
+ if (checkoutPath !== "")
284
+ writeStatusLine(step.file, checkoutPath);
285
+ continue;
286
+ }
287
+ const doIt = await ask.confirm(`Install kankaku in ${step.title} (${step.file})?`, true);
288
+ if (doIt)
289
+ addKankakuPackage(step.file);
290
+ }
291
+ if (byId["hub"].state === "todo" && !initial.hub.credentialsPresent) {
292
+ const doIt = await ask.confirm("Configure hub credentials now?", false);
293
+ if (doIt) {
294
+ const url = await ask.text("Hub URL", "");
295
+ if (url !== "") {
296
+ const email = await ask.text("Email", "");
297
+ const password = await ask.secret("Password");
298
+ writeHubCredentials(deps.homeDir, { url, email, password });
299
+ }
300
+ }
301
+ }
302
+ if (byId["tui-config"].state === "todo") {
303
+ const defaultRoots = dirname(deps.cwd);
304
+ const doIt = await ask.confirm(`Write TUI roots to ${byId["tui-config"].file}?`, true);
305
+ if (doIt) {
306
+ const rootsInput = await ask.text("Roots for the TUI (comma-separated)", defaultRoots);
307
+ const roots = rootsInput
308
+ .split(",")
309
+ .map((root) => root.trim())
310
+ .filter((root) => root.length > 0);
311
+ if (roots.length > 0)
312
+ writeTuiConfig(deps.homeDir, roots);
313
+ }
314
+ }
315
+ const finalHub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
316
+ if (finalHub.ok) {
317
+ const doRefresh = await ask.confirm("Refresh the hub catalog now?", false);
318
+ if (doRefresh) {
319
+ const { now, fetch: fetchOverride } = envDeps(deps);
320
+ const catalog = createCatalog(finalHub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
321
+ await refreshCatalogAdapter(catalog);
322
+ }
323
+ }
324
+ await runDoctorCommand(deps);
325
+ }
195
326
  /**
196
327
  * Resolve the `--theme`/`KANKAKU_TUI_THEME` preset (flag wins), defaulting
197
328
  * to {@link DEFAULT_THEME} when neither is given. An explicit but unknown
@@ -236,6 +367,14 @@ export async function runCli(argv, deps) {
236
367
  await runSyncCommand(rest.slice(1), roots, deps);
237
368
  return;
238
369
  }
370
+ if (command === "setup") {
371
+ await runSetupCommand(rest.slice(1), deps);
372
+ return;
373
+ }
374
+ if (command === "doctor") {
375
+ await runDoctorCommand(deps);
376
+ return;
377
+ }
239
378
  deps.stderr(USAGE);
240
379
  deps.exit(1);
241
380
  }
@@ -360,6 +499,7 @@ if (isMain) {
360
499
  exit: (code) => {
361
500
  process.exit(code);
362
501
  },
502
+ prompter: createReadlinePrompter(process.stdin, process.stdout),
363
503
  renderApp: (roots, theme) => {
364
504
  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 });
365
505
  },
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pure setup domain: which coding agents kankaku can find on this machine,
3
+ * whether each is already configured, and the ordered plan `kankaku setup`
4
+ * and `kankaku doctor` both report from. No I/O, no `Date.now()` — callers
5
+ * read the real files (`src/adapters/setup/*.ts`) and pass plain facts in.
6
+ */
7
+ /**
8
+ * A `packages` entry counts as kankaku when it is the bare npm spec
9
+ * (`npm:kankaku`), a versioned npm spec (`npm:kankaku@x.y.z`), or a path
10
+ * (relative or absolute, npm's `file:`-less local-package shorthand) whose
11
+ * last segment is exactly `kankaku` — e.g. `../../workspace/kankaku`.
12
+ */
13
+ export function isKankakuPackage(entry) {
14
+ if (entry === "npm:kankaku" || entry.startsWith("npm:kankaku@"))
15
+ return true;
16
+ const segments = entry.split("/").filter((segment) => segment.length > 0);
17
+ return segments[segments.length - 1] === "kankaku";
18
+ }
19
+ function settingsPackagesStatus(id, facts) {
20
+ if (!facts)
21
+ return { id, present: false, configured: false, adapterAvailable: true, detail: "not found" };
22
+ return { id, present: true, configured: facts.packages.some(isKankakuPackage), adapterAvailable: true, detail: facts.settingsPath };
23
+ }
24
+ function claudeCodeStatus(facts) {
25
+ if (!facts)
26
+ return { id: "claude-code", present: false, configured: false, adapterAvailable: true, detail: "not found" };
27
+ const configured = facts.statusLineCommand !== undefined && facts.statusLineCommand.includes("kankaku");
28
+ return { id: "claude-code", present: true, configured, adapterAvailable: true, detail: facts.settingsPath };
29
+ }
30
+ function noAdapterStatus(id, facts) {
31
+ if (!facts)
32
+ return { id, present: false, configured: false, adapterAvailable: false, detail: "not found" };
33
+ return { id, present: true, configured: false, adapterAvailable: false, detail: facts.configPath };
34
+ }
35
+ /** Detect every agent kankaku-tui knows about from plain, already-read facts. Order: pi, gentle-shell, claude-code, codex, opencode. */
36
+ export function detectAgents(facts) {
37
+ return [
38
+ settingsPackagesStatus("pi", facts.pi),
39
+ settingsPackagesStatus("gentle-shell", facts.gentleShell),
40
+ claudeCodeStatus(facts.claudeCode),
41
+ noAdapterStatus("codex", facts.codex),
42
+ noAdapterStatus("opencode", facts.opencode),
43
+ ];
44
+ }
45
+ const AGENT_TITLES = {
46
+ pi: "pi",
47
+ "gentle-shell": "gentle-shell",
48
+ "claude-code": "Claude Code",
49
+ codex: "Codex",
50
+ opencode: "OpenCode",
51
+ };
52
+ function todoAction(agent) {
53
+ if (agent.id === "claude-code")
54
+ return `set statusLine in ${agent.detail} to the kankaku status line`;
55
+ return `add "npm:kankaku" to packages in ${agent.detail}`;
56
+ }
57
+ function agentStep(agent) {
58
+ const title = AGENT_TITLES[agent.id];
59
+ if (!agent.adapterAvailable) {
60
+ return { id: agent.id, title, state: "unavailable", action: "no adapter yet", file: "" };
61
+ }
62
+ if (!agent.present) {
63
+ return { id: agent.id, title, state: "unavailable", action: `no settings file found (expected ${agent.detail})`, file: "" };
64
+ }
65
+ if (agent.configured) {
66
+ return { id: agent.id, title, state: "done", action: `already configured (${agent.detail})`, file: agent.detail };
67
+ }
68
+ return { id: agent.id, title, state: "todo", action: todoAction(agent), file: agent.detail };
69
+ }
70
+ function hubStep(hub) {
71
+ if (!hub.credentialsPresent) {
72
+ return { id: "hub", title: "Hub", state: "todo", action: `write hub credentials to ${hub.credentialsPath}`, file: hub.credentialsPath };
73
+ }
74
+ if (hub.healthOk === true) {
75
+ return { id: "hub", title: "Hub", state: "done", action: `credentials present (${hub.url}); health check ok`, file: hub.credentialsPath };
76
+ }
77
+ return {
78
+ id: "hub",
79
+ title: "Hub",
80
+ state: "todo",
81
+ action: `credentials present (${hub.url}) but the health check did not succeed; verify the hub is reachable`,
82
+ file: "",
83
+ };
84
+ }
85
+ function tuiStep(tui) {
86
+ if (tui.present) {
87
+ return { id: "tui-config", title: "TUI config", state: "done", action: `roots already configured (${tui.path})`, file: tui.path };
88
+ }
89
+ return { id: "tui-config", title: "TUI config", state: "todo", action: `write roots to ${tui.path}`, file: tui.path };
90
+ }
91
+ /** Build the ordered setup plan: one step per detected agent, then the hub, then the TUI's own `tui.json`. */
92
+ export function planSetup(agents, hub, tui) {
93
+ return [...agents.map(agentStep), hubStep(hub), tuiStep(tui)];
94
+ }
95
+ /**
96
+ * `kankaku doctor`'s plain-text report: one line per agent, one for the
97
+ * hub, one for `tui.json` (`<title>: <state> — <action>`), followed by a
98
+ * `next: <action>` hint for every step still `todo`.
99
+ */
100
+ export function formatDoctorLines(agents, hub, tui) {
101
+ const steps = planSetup(agents, hub, tui);
102
+ const statusLines = steps.map((step) => `${step.title}: ${step.state} — ${step.action}`);
103
+ const hints = steps.filter((step) => step.state === "todo").map((step) => `next: ${step.action}`);
104
+ return [...statusLines, ...hints];
105
+ }
106
+ /** `kankaku setup [--dry-run]`'s plan report: one line per step, appending the exact file it would change (`[<file>]`) when there is one. */
107
+ export function formatSetupPlanLines(steps) {
108
+ return steps.map((step) => `${step.title}: ${step.state} — ${step.action}${step.file ? ` [${step.file}]` : ""}`);
109
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",