kankaku-tui 0.1.2 → 0.2.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 +33 -0
- package/dist/adapters/setup/agents.js +55 -0
- package/dist/adapters/setup/claude.js +25 -0
- package/dist/adapters/setup/hub.js +52 -0
- package/dist/adapters/setup/json-writer.js +41 -0
- package/dist/adapters/setup/pi.js +21 -0
- package/dist/adapters/setup/readline-prompter.js +68 -0
- package/dist/adapters/setup/tui-config.js +34 -0
- package/dist/cli.js +148 -3
- package/dist/domain/setup-plan.js +109 -0
- package/dist/ports/prompter.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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,135 @@ 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
|
+
/** Tell the user what setup just did to a file: nothing is written silently. */
|
|
261
|
+
function announceWrite(deps, file, result) {
|
|
262
|
+
deps.stdout(result.changed ? `wrote ${file}` : `unchanged ${file}`);
|
|
263
|
+
}
|
|
264
|
+
async function runSetupCommand(args, deps) {
|
|
265
|
+
const dryRun = args.includes("--dry-run");
|
|
266
|
+
const yes = args.includes("--yes");
|
|
267
|
+
const initial = await gatherSetupFacts(deps);
|
|
268
|
+
const steps = planSetup(initial.agents, initial.hub, initial.tui);
|
|
269
|
+
if (dryRun) {
|
|
270
|
+
deps.stdout(formatSetupPlanLines(steps).join("\n"));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const ask = makeAsker(yes, deps.prompter);
|
|
274
|
+
const byId = Object.fromEntries(steps.map((step) => [step.id, step]));
|
|
275
|
+
for (const agent of initial.agents) {
|
|
276
|
+
if (agent.id === "codex" || agent.id === "opencode")
|
|
277
|
+
continue;
|
|
278
|
+
const step = byId[agent.id];
|
|
279
|
+
if (step.state !== "todo")
|
|
280
|
+
continue;
|
|
281
|
+
if (agent.id === "claude-code") {
|
|
282
|
+
const doIt = await ask.confirm(`Configure Claude Code's statusLine for kankaku (${step.file})?`, true);
|
|
283
|
+
if (!doIt)
|
|
284
|
+
continue;
|
|
285
|
+
const guessed = guessClaudeCheckout(readAgentFacts(deps.homeDir).claudeCode?.statusLineCommand) ?? "";
|
|
286
|
+
const checkoutPath = await ask.text("Path to your kankaku-claude checkout", guessed);
|
|
287
|
+
if (checkoutPath !== "")
|
|
288
|
+
announceWrite(deps, step.file, writeStatusLine(step.file, checkoutPath));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const doIt = await ask.confirm(`Install kankaku in ${step.title} (${step.file})?`, true);
|
|
292
|
+
if (doIt)
|
|
293
|
+
announceWrite(deps, step.file, addKankakuPackage(step.file));
|
|
294
|
+
}
|
|
295
|
+
if (byId["hub"].state === "todo" && !initial.hub.credentialsPresent) {
|
|
296
|
+
const doIt = await ask.confirm("Configure hub credentials now?", false);
|
|
297
|
+
if (doIt) {
|
|
298
|
+
const url = await ask.text("Hub URL", "");
|
|
299
|
+
if (url !== "") {
|
|
300
|
+
const email = await ask.text("Email", "");
|
|
301
|
+
const password = await ask.secret("Password");
|
|
302
|
+
announceWrite(deps, credentialsPath(deps.homeDir), writeHubCredentials(deps.homeDir, { url, email, password }));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (byId["tui-config"].state === "todo") {
|
|
307
|
+
const defaultRoots = dirname(deps.cwd);
|
|
308
|
+
const doIt = await ask.confirm(`Write TUI roots to ${byId["tui-config"].file}?`, true);
|
|
309
|
+
if (doIt) {
|
|
310
|
+
const rootsInput = await ask.text("Roots for the TUI (comma-separated)", defaultRoots);
|
|
311
|
+
const roots = rootsInput
|
|
312
|
+
.split(",")
|
|
313
|
+
.map((root) => root.trim())
|
|
314
|
+
.filter((root) => root.length > 0);
|
|
315
|
+
if (roots.length > 0)
|
|
316
|
+
announceWrite(deps, tuiConfigPath(deps.homeDir), writeTuiConfig(deps.homeDir, roots));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const finalHub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
320
|
+
if (finalHub.ok) {
|
|
321
|
+
const doRefresh = await ask.confirm("Refresh the hub catalog now?", false);
|
|
322
|
+
if (doRefresh) {
|
|
323
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
324
|
+
const catalog = createCatalog(finalHub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
325
|
+
const snapshot = await refreshCatalogAdapter(catalog);
|
|
326
|
+
deps.stdout(formatCatalogRefreshLines(snapshot).join("\n"));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
await runDoctorCommand(deps);
|
|
330
|
+
}
|
|
195
331
|
/**
|
|
196
332
|
* Resolve the `--theme`/`KANKAKU_TUI_THEME` preset (flag wins), defaulting
|
|
197
333
|
* to {@link DEFAULT_THEME} when neither is given. An explicit but unknown
|
|
@@ -236,6 +372,14 @@ export async function runCli(argv, deps) {
|
|
|
236
372
|
await runSyncCommand(rest.slice(1), roots, deps);
|
|
237
373
|
return;
|
|
238
374
|
}
|
|
375
|
+
if (command === "setup") {
|
|
376
|
+
await runSetupCommand(rest.slice(1), deps);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (command === "doctor") {
|
|
380
|
+
await runDoctorCommand(deps);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
239
383
|
deps.stderr(USAGE);
|
|
240
384
|
deps.exit(1);
|
|
241
385
|
}
|
|
@@ -360,6 +504,7 @@ if (isMain) {
|
|
|
360
504
|
exit: (code) => {
|
|
361
505
|
process.exit(code);
|
|
362
506
|
},
|
|
507
|
+
prompter: createReadlinePrompter(process.stdin, process.stdout),
|
|
363
508
|
renderApp: (roots, theme) => {
|
|
364
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 });
|
|
365
510
|
},
|
|
@@ -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 {};
|