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 +71 -22
- package/dist/adapters/project-discovery.js +78 -21
- package/dist/adapters/setup/child-process-runner.js +33 -0
- package/dist/adapters/setup/claude.js +19 -0
- package/dist/adapters/setup/local-hub.js +86 -0
- package/dist/adapters/setup/pi.js +15 -0
- package/dist/cli.js +126 -7
- package/dist/domain/setup-wizard.js +245 -0
- package/dist/ports/script-runner.js +1 -0
- package/dist/ui/app.js +11 -2
- package/dist/ui/components/checklist.js +34 -0
- package/dist/ui/components/radio.js +29 -0
- package/dist/ui/components/text-input.js +64 -0
- package/dist/ui/setup/wizard-screen.js +195 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,24 +15,60 @@ npm i -g kankaku-tui
|
|
|
15
15
|
kankaku setup
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`kankaku setup`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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(
|
|
9
|
-
.
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|