kankaku-claude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "kankaku",
3
+ "description": "Records how long Claude Code works on each of your prompts: wall time, waiting time, work time and cost, per prompt, in kankaku's worklog format.",
4
+ "version": "0.1.0",
5
+ "author": {
6
+ "name": "soyunninja"
7
+ },
8
+ "homepage": "https://kankaku.io",
9
+ "repository": "https://github.com/soyunninja/kankaku-claude",
10
+ "license": "MIT"
11
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - Phase 1: per-prompt work records from Claude Code hooks. Writes one
12
+ `WorkRecord` per user prompt to `<KANKAKU_DIR>/worklog.jsonl`, in the same
13
+ schema the kankaku pi extension writes, so kankaku's existing
14
+ report/export/hub tooling can consume it unchanged.
15
+ - Event sourcing per session (`<KANKAKU_DIR>/claude/<session_id>.events.jsonl`)
16
+ plus pure replay into a fresh `WorkTracker`, since Claude Code hooks are
17
+ fresh short-lived processes with no persistent in-memory state.
18
+ - All 9 documented hook events wired: `SessionStart`, `UserPromptSubmit`,
19
+ `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`,
20
+ `SubagentStop`, `Stop`, `SessionEnd`.
21
+ - Per-prompt cost from the statusline's `cost.total_cost_usd`, as a delta
22
+ across the prompt.
23
+ - Crash recovery: a dead session with an open prompt is recovered as an
24
+ `interrupted` record on the next `SessionStart`.
25
+ - `src/statusline.ts` (a manually wired `statusLine` command) reports the
26
+ open prompt's elapsed clock and the current cost.
27
+ - `node src/cli.ts report|status|setup` and the `/kankaku:report`,
28
+ `/kankaku:status`, `/kankaku:setup` slash commands.
29
+
30
+ - Manual hub sync via `node src/cli.ts sync [all|status]` and the
31
+ `/kankaku:sync` slash command (default sync only). No automatic sync hooks;
32
+ prompts are omitted by default.
33
+
34
+ ### Fixed
35
+
36
+ - T7: the statusline command is wired globally in `~/.claude/settings.json`,
37
+ so it runs in every Claude Code session on the machine, plugin loaded or
38
+ not. It used to write cost into the open project's own
39
+ `.kankaku/claude/<session_id>.state.json`, creating a placeholder file
40
+ (`pid: 0`, `cwd: ""`) in whatever project happened to be open and
41
+ recreating it after `SessionEnd` deleted it — and `isAlive(0)`
42
+ (`process.kill(0, 0)` signals the whole process GROUP) reported that
43
+ placeholder alive forever, so crash recovery never swept it.
44
+ - Cost now lives only under `~/.kankaku/claude/cost/<session_id>.json`
45
+ (`src/cost-store.ts`, new; node builtins only), never under a project.
46
+ The statusline only reads a project's state file, read-only, for the
47
+ open-prompt clock; a missing state file renders `kankaku idle` without
48
+ creating anything.
49
+ - `SessionState` no longer has a `cost` field; `mergeCost` is removed.
50
+ - `isAlive` returns `false` for any non-positive or non-integer pid
51
+ without calling `process.kill`; `recoverStaleSessions` treats
52
+ `pid <= 0` as dead independently of `isAlive`'s answer.
53
+ - `SessionEnd` also deletes the session's cost file; `SessionStart`
54
+ (non-`compact`) sweeps cost files older than 7 days.
55
+ - `node src/cli.ts status` reads cost from the cost file.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 soyunninja
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # kankaku-claude
2
+
3
+ A [Claude Code](https://claude.com/claude-code) plugin that records how long
4
+ Claude Code works on each of your prompts, in the same worklog format as the
5
+ [kankaku](https://kankaku.io) pi extension.
6
+
7
+ ## What it measures
8
+
9
+ One record per user prompt, appended to `<KANKAKU_DIR>/worklog.jsonl`:
10
+
11
+ - **Wall time** — from the moment you submit a prompt to the moment Claude
12
+ Code settles it.
13
+ - **Waiting time** — time spent inside an `AskUserQuestion` tool call or
14
+ waiting on a permission dialog.
15
+ - **Work time** — wall time minus waiting time.
16
+ - **Cost** — the prompt's share of the session's running `total_cost_usd`
17
+ (see "Limitations" below for how this is derived).
18
+
19
+ Records are written in kankaku's own `WorkRecord` schema
20
+ (`WORK_RECORD_SCHEMA = 1`), the exact one the kankaku pi extension writes to
21
+ its own `worklog.jsonl`. This means kankaku's existing report and export
22
+ tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
23
+ manual hub sync through kankaku's public hub adapters (below).
24
+
25
+ ## Requirements
26
+
27
+ - Claude Code with plugin support.
28
+ - Node.js >= 24 on `PATH` (this package's `.ts` sources run directly under
29
+ Node's built-in type stripping; there is no build step).
30
+
31
+ ## Install
32
+
33
+ **From a local path (development).** Clone this repository, then point
34
+ Claude Code at it directly:
35
+
36
+ ```bash
37
+ claude --plugin-dir /path/to/kankaku-claude
38
+ ```
39
+
40
+ If your Claude Code version does not recognize `--plugin-dir`, or plugin
41
+ loading has changed since this was written, check your installed version's
42
+ own plugin documentation (`claude --help`, or `/plugin` inside a session) for
43
+ the current local-install flow.
44
+
45
+ **From a marketplace.** Once this plugin is published to a marketplace:
46
+
47
+ ```
48
+ /plugin marketplace add <marketplace-source>
49
+ /plugin install kankaku@<marketplace-name>
50
+ ```
51
+
52
+ On install, if `package.json` and `package-lock.json` are both present (as
53
+ they are here), Claude Code runs `npm ci --ignore-scripts` for you; a failure
54
+ there never blocks the plugin from loading.
55
+
56
+ ## The manual step you cannot skip
57
+
58
+ Claude Code plugins cannot set `statusLine` for themselves — there is no
59
+ programmatic way for a plugin to add a `statusLine` entry to your settings.
60
+ The statusline is also the *only* documented source of per-prompt cost
61
+ (`cost.total_cost_usd`); hooks never receive it. So, once installed, run:
62
+
63
+ ```
64
+ /kankaku:setup
65
+ ```
66
+
67
+ and paste the printed `statusLine` block into `~/.claude/settings.json`
68
+ (merge it with whatever is already there rather than overwriting the file).
69
+ Without this step, kankaku-claude still records wall/waiting/work time, but
70
+ every record's cost stays unset.
71
+
72
+ Because this settings.json entry is global, the statusline command runs in
73
+ every Claude Code session on the machine, plugin loaded or not — including
74
+ projects that never installed kankaku-claude. It writes only to
75
+ `~/.kankaku/claude/cost/<session_id>.json` (never under any project) and
76
+ only reads a project's own state file, so an unrelated session never leaves
77
+ anything behind in whatever project happens to be open.
78
+
79
+ ## Commands
80
+
81
+ - `/kankaku:report` — a report of recent work, grouped by day (wraps
82
+ `node src/cli.ts report`).
83
+ - `/kankaku:status` — the sessions kankaku-claude currently has state for:
84
+ session id, whether its process is still alive, whether a prompt is open,
85
+ and the last cost the statusline reported (wraps `node src/cli.ts status`).
86
+ - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
87
+ `node src/cli.ts setup`).
88
+ - `/kankaku:sync` — manually syncs local work records to the hub (wraps
89
+ `node src/cli.ts sync`; the slash command does not forward arguments).
90
+
91
+ The report, status, and setup subcommands are also available directly via
92
+ `node src/cli.ts <report|status|setup>`; `report` accepts `--days N` and
93
+ defaults to the last 7 days.
94
+
95
+ ### Manual hub sync
96
+
97
+ Configure hub credentials with `KANKAKU_PB_URL`, `KANKAKU_PB_EMAIL`, and
98
+ `KANKAKU_PB_PASSWORD`, or use `~/.kankaku/credentials.json` (the shared
99
+ kankaku hub credential file). Then run `/kankaku:sync` or
100
+ `node src/cli.ts sync` in the project whose worklog you want to upload.
101
+ For options not forwarded by the slash command, use the CLI directly:
102
+
103
+ | Command | Purpose |
104
+ |---------|---------|
105
+ | `node src/cli.ts sync` | Manually sync the recent window (24 hours by default). |
106
+ | `node src/cli.ts sync all` | Request a full sync. |
107
+ | `node src/cli.ts sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
108
+
109
+ Optional environment settings:
110
+
111
+ | Variable | Effect |
112
+ |----------|--------|
113
+ | `KANKAKU_MACHINE` | Machine label for uploaded records (defaults to the host name). |
114
+ | `KANKAKU_SYNC_PROMPT` | Prompt privacy: omitted by default; set `truncated` or `full` to include prompts. |
115
+ | `KANKAKU_SYNC_WINDOW_HOURS` | Recent sync window in hours (defaults to 24). |
116
+ | `KANKAKU_SYNC_RECORDS` | Set to `0` to disable uploading raw `work_records` children; consolidated `task_entries` still sync. |
117
+
118
+ This first slice is **manual-only**: no automatic sync hooks run yet.
119
+
120
+ ## Where the files live
121
+
122
+ - `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
123
+ shared with (and readable by) kankaku's own tooling.
124
+ - `<KANKAKU_DIR>/claude/<session_id>.events.jsonl` and
125
+ `<KANKAKU_DIR>/claude/<session_id>.state.json` — kankaku-claude's own
126
+ per-session working files (event log and small state file). These are
127
+ implementation detail, not part of the shared worklog format, and are
128
+ cleaned up once a session ends. Only the hooks ever write this state file;
129
+ the statusline command only reads it (see below).
130
+ - `~/.kankaku/claude/cost/<session_id>.json` — the per-session cost the
131
+ statusline last reported, **always under your home directory, never under
132
+ a project.** The statusline command is wired into `~/.claude/settings.json`
133
+ globally, so it runs on every Claude Code session on the machine; writing
134
+ cost next to a project's own state file would litter whatever project
135
+ happens to be open with another session's data. This directory is
136
+ owner-only (`0700`), each cost file is `0600`, and a file older than 7
137
+ days is swept on the next `SessionStart`.
138
+
139
+ `KANKAKU_DIR` defaults to `.kankaku` (relative to the session's working
140
+ directory); set the `KANKAKU_DIR` environment variable to use an absolute
141
+ path or a different relative one, exactly as the pi extension does.
142
+
143
+ ## Crash recovery
144
+
145
+ If Claude Code's process is killed mid-prompt (or mid-session), the next
146
+ session's `SessionStart` hook scans for other sessions' state files whose
147
+ process is no longer alive. A dead session with an open prompt is replayed
148
+ and appended as one `status: "interrupted"` record before its files are
149
+ deleted; a dead session with no open prompt just has its files deleted. This
150
+ also runs for the current session's own leftover state at `SessionEnd`.
151
+
152
+ ## Limitations
153
+
154
+ - **`turns` is always 1 per run.** Claude Code hooks give no way to observe
155
+ provider-level retries/turns inside one run; every replayed run reports
156
+ exactly one turn.
157
+ - **No token counts.** Hooks never carry input/output/cache token numbers,
158
+ only the statusline's aggregate `total_cost_usd`; per-record `usage` token
159
+ fields stay at zero, cost is the only populated figure.
160
+ - **Cost is a per-prompt delta of the session total, from the statusline,
161
+ and needs the manual setup step.** A tiny race is possible: the statusline
162
+ can render after `Stop` has already computed the delta, in which case a
163
+ sliver of one prompt's cost is attributed to the next prompt instead.
164
+ - **A permission wait ends at the next hook event, not when you actually
165
+ click.** There is no documented hook that fires the moment you answer a
166
+ permission dialog, so the waiting span closes at whatever hook fires next
167
+ (typically the tool's own `PreToolUse`), not at the click itself.
168
+ - **A background `Agent`/`Task` subagent span ends when the tool call
169
+ returns, not when the subagent actually finishes.** Claude Code does not
170
+ document a link between a `SubagentStart`/`SubagentStop` pair and the
171
+ `tool_use_id` that launched it, so kankaku-claude cannot join them; the
172
+ subagent's own time is not separately measured here.
173
+ - **Hub sync is manual-only.** Use `/kankaku:sync` or the direct CLI as
174
+ described above; no automatic sync hooks run yet. Prompts are omitted
175
+ from uploads unless `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
176
+
177
+ See [kankaku.io](https://kankaku.io) for the pi extension this plugin shares
178
+ its worklog format with.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Show a kankaku report of recent Claude Code work (wall, waiting, work time and cost, per prompt)
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI report command and show its output to the user verbatim,
7
+ inside a code block, with no summarizing or reformatting:
8
+
9
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" report
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Print the statusLine snippet to add to ~/.claude/settings.json so kankaku can read per-prompt cost
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI setup command and show its output to the user verbatim.
7
+ Then tell the user, briefly: paste the printed `statusLine` block into their
8
+ `~/.claude/settings.json` (merging it if that file already has other keys),
9
+ because a Claude Code plugin cannot set `statusLine` for itself — the
10
+ statusline is the only documented source of per-session cost.
11
+
12
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" setup
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Show the current kankaku session states tracked for this project (pid liveness, open prompt, last cost)
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI status command and show its output to the user verbatim,
7
+ inside a code block, with no summarizing or reformatting:
8
+
9
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" status
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: Manually sync local kankaku work records to the hub
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI sync command and show its output to the user verbatim,
7
+ inside a code block, with no summarizing or reformatting:
8
+
9
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync
10
+
11
+ For a full sync or local sync status, use the direct CLI instead:
12
+ `node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync all` or
13
+ `node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync status`.
14
+ This slash command runs the default sync only; it does not forward arguments.
@@ -0,0 +1,103 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
9
+ "timeout": 15
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "UserPromptSubmit": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
20
+ "timeout": 5
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PreToolUse": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
31
+ "timeout": 5
32
+ }
33
+ ]
34
+ }
35
+ ],
36
+ "PostToolUse": [
37
+ {
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
42
+ "timeout": 5
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "PermissionRequest": [
48
+ {
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
53
+ "timeout": 5
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "SubagentStart": [
59
+ {
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
64
+ "timeout": 5
65
+ }
66
+ ]
67
+ }
68
+ ],
69
+ "SubagentStop": [
70
+ {
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
75
+ "timeout": 5
76
+ }
77
+ ]
78
+ }
79
+ ],
80
+ "Stop": [
81
+ {
82
+ "hooks": [
83
+ {
84
+ "type": "command",
85
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
86
+ "timeout": 15
87
+ }
88
+ ]
89
+ }
90
+ ],
91
+ "SessionEnd": [
92
+ {
93
+ "hooks": [
94
+ {
95
+ "type": "command",
96
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
97
+ "timeout": 15
98
+ }
99
+ ]
100
+ }
101
+ ]
102
+ }
103
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "kankaku-claude",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code plugin that records how long the agent works on each user prompt, in kankaku's worklog format.",
5
+ "type": "module",
6
+ "files": [
7
+ ".claude-plugin/",
8
+ "commands/",
9
+ "hooks/",
10
+ "src/",
11
+ "README.md",
12
+ "CHANGELOG.md",
13
+ "LICENSE",
14
+ "tsconfig.json"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "soyunninja",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/soyunninja/kankaku-claude.git"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test tests/*.test.ts",
24
+ "typecheck": "tsc --noEmit",
25
+ "check": "npm run typecheck && npm test"
26
+ },
27
+ "dependencies": {
28
+ "kankaku": "^0.6.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^24.13.4",
32
+ "typescript": "^5.7.0"
33
+ }
34
+ }
@@ -0,0 +1,68 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+
4
+ export interface PsInfo {
5
+ ppid: number;
6
+ comm: string;
7
+ }
8
+
9
+ const SKIP_COMMS = new Set(["sh", "bash", "zsh", "dash", "fish", "node"]);
10
+ const MAX_HOPS = 6;
11
+
12
+ export interface ResolveClaudePidInput {
13
+ startPid: number;
14
+ runPs: (pid: number) => PsInfo | undefined;
15
+ }
16
+
17
+ /**
18
+ * Walks ancestors from `startPid` (at most `MAX_HOPS` hops), returning the
19
+ * first pid whose `comm` basename is not a shell or `node` — the Claude
20
+ * Code process itself, skipping the shell/node layers a hook can be
21
+ * launched through. Falls back to `startPid` when `ps` fails or nothing
22
+ * qualifies within the hop budget.
23
+ */
24
+ export function resolveClaudePid({ startPid, runPs }: ResolveClaudePidInput): number {
25
+ let pid = startPid;
26
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
27
+ const info = runPs(pid);
28
+ if (!info) return startPid;
29
+ if (!SKIP_COMMS.has(basename(info.comm))) return pid;
30
+ pid = info.ppid;
31
+ }
32
+ return startPid;
33
+ }
34
+
35
+ /** Production `runPs`: `ps -o ppid=,comm= -p <pid>`, bounded to 200ms. */
36
+ export function runPsProcess(pid: number): PsInfo | undefined {
37
+ try {
38
+ const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
39
+ timeout: 200,
40
+ encoding: "utf8",
41
+ });
42
+ const match = out.trim().match(/^(\d+)\s+(.+)$/);
43
+ if (!match) return undefined;
44
+ const ppid = Number(match[1]);
45
+ const comm = match[2]?.trim();
46
+ if (!Number.isFinite(ppid) || !comm) return undefined;
47
+ return { ppid, comm };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * `true` unless `process.kill(pid, 0)` throws with a code other than
55
+ * `EPERM`. A non-positive or non-integer pid is always `false`, without
56
+ * calling `process.kill` at all: `pid` 0 signals the whole process GROUP
57
+ * (T7 — the defect that let a placeholder state file's `pid: 0` read back
58
+ * as alive forever).
59
+ */
60
+ export function isAlive(pid: number): boolean {
61
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (error) {
66
+ return (error as NodeJS.ErrnoException).code === "EPERM";
67
+ }
68
+ }
@@ -0,0 +1,100 @@
1
+ import { basename, join } from "node:path";
2
+ import { JsonlWorkLog } from "kankaku/hub";
3
+ import { listStateFiles, resolveKankakuDir } from "./paths.ts";
4
+ import { readState } from "./session-state.ts";
5
+ import { readCost } from "./cost-store.ts";
6
+ import { formatReport } from "./report.ts";
7
+ import { runSyncCli } from "./sync-cli.ts";
8
+
9
+ export interface CliDeps {
10
+ env: NodeJS.ProcessEnv;
11
+ cwd: string;
12
+ now: () => number;
13
+ isAlive: (pid: number) => boolean;
14
+ /** Absolute path to the plugin/repo root (the directory containing `.claude-plugin/`). */
15
+ pluginRoot: string;
16
+ }
17
+
18
+ export interface CliResult {
19
+ stdout: string;
20
+ exitCode: number;
21
+ /** Usage or sync error output. */
22
+ stderr?: string;
23
+ }
24
+
25
+ const USAGE = "usage: node src/cli.ts <report|status|setup|sync> [--days N]\n";
26
+
27
+ /** CLI commands, resolved from `deps.cwd`. */
28
+ export async function runCli(argv: string[], deps: CliDeps): Promise<CliResult> {
29
+ const [command, ...rest] = argv;
30
+ switch (command) {
31
+ case "report":
32
+ return runReport(rest, deps);
33
+ case "status":
34
+ return runStatus(deps);
35
+ case "setup":
36
+ return runSetup(deps);
37
+ case "sync":
38
+ return runSyncCli(rest, deps);
39
+ default:
40
+ return { stdout: "", exitCode: 1, stderr: USAGE };
41
+ }
42
+ }
43
+
44
+ function runReport(args: string[], deps: CliDeps): CliResult {
45
+ const days = parseDays(args);
46
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
47
+ const records = new JsonlWorkLog(kankakuDir).readAll();
48
+ const stdout = formatReport(records, { now: deps.now(), ...(days !== undefined ? { days } : {}) });
49
+ return { stdout, exitCode: 0 };
50
+ }
51
+
52
+ function parseDays(args: string[]): number | undefined {
53
+ const idx = args.indexOf("--days");
54
+ if (idx === -1) return undefined;
55
+ const value = Number(args[idx + 1]);
56
+ return Number.isFinite(value) && value > 0 ? value : undefined;
57
+ }
58
+
59
+ function runStatus(deps: CliDeps): CliResult {
60
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
61
+ const claudeDir = join(kankakuDir, "claude");
62
+ const files = listStateFiles(claudeDir);
63
+ if (files.length === 0) {
64
+ return { stdout: "No active sessions.\n", exitCode: 0 };
65
+ }
66
+ const lines = files
67
+ .sort()
68
+ .map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
69
+ return { stdout: lines.join("\n") + "\n", exitCode: 0 };
70
+ }
71
+
72
+ function formatStatusLine(
73
+ sessionId: string,
74
+ stateFile: string,
75
+ isAlive: (pid: number) => boolean,
76
+ env: NodeJS.ProcessEnv,
77
+ ): string {
78
+ const state = readState(stateFile);
79
+ if (!state) return `${sessionId} (unreadable state)`;
80
+ const aliveWord = isAlive(state.pid) ? "alive" : "dead";
81
+ const promptWord = state.promptOpen
82
+ ? `prompt open since ${new Date(state.promptOpen.startedAt).toISOString()}`
83
+ : "idle";
84
+ const cost = readCost(env, sessionId);
85
+ const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
86
+ return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
87
+ }
88
+
89
+ function sessionIdFromStateFile(file: string): string {
90
+ return basename(file).replace(/\.state\.json$/, "");
91
+ }
92
+
93
+ function runSetup(deps: CliDeps): CliResult {
94
+ const statuslinePath = join(deps.pluginRoot, "src", "statusline.ts");
95
+ const command = `node "${statuslinePath}"`;
96
+ const snippet = JSON.stringify({ statusLine: { type: "command", command } }, null, 2);
97
+ const explanation =
98
+ "Claude Code plugins cannot set statusLine themselves, so this must be added to ~/.claude/settings.json manually: the statusline is the only documented source of per-session cost.";
99
+ return { stdout: `${snippet}\n\n${explanation}\n`, exitCode: 0 };
100
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname } from "node:path";
3
+ import { runCli } from "./cli-core.ts";
4
+ import { isAlive } from "./claude-pid.ts";
5
+
6
+ // src/cli.ts -> src -> repo root (the directory that contains .claude-plugin/).
7
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
+
9
+ const result = await runCli(process.argv.slice(2), {
10
+ env: process.env,
11
+ cwd: process.cwd(),
12
+ now: () => Date.now(),
13
+ isAlive,
14
+ pluginRoot,
15
+ });
16
+
17
+ if (result.stdout) process.stdout.write(result.stdout);
18
+ if (result.stderr) process.stderr.write(result.stderr);
19
+ process.exit(result.exitCode);