kankaku-claude 0.2.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +38 -1
- package/README.md +66 -17
- package/commands/doctor.md +1 -1
- package/commands/report.md +1 -1
- package/commands/setup.md +1 -1
- package/commands/status.md +1 -1
- package/commands/sync-all.md +1 -1
- package/commands/sync-status.md +1 -1
- package/commands/sync.md +3 -3
- package/dist/auto-sync.js +28 -0
- package/dist/claude-pid.js +61 -0
- package/dist/cli-core.js +75 -0
- package/{src/cli.ts → dist/cli.js} +11 -12
- package/dist/cost-store.js +121 -0
- package/dist/doctor.js +100 -0
- package/dist/event-log.js +31 -0
- package/dist/events.js +72 -0
- package/dist/handle-hook.js +256 -0
- package/dist/hook.js +38 -0
- package/dist/inflight-recovery.js +66 -0
- package/dist/paths.js +35 -0
- package/dist/prompts.js +33 -0
- package/dist/record.js +20 -0
- package/dist/replay.js +101 -0
- package/dist/report.js +102 -0
- package/dist/session-state.js +47 -0
- package/dist/statusline-core.js +71 -0
- package/dist/statusline.js +34 -0
- package/dist/sync-cli.js +100 -0
- package/hooks/hooks.json +9 -9
- package/package.json +9 -7
- package/src/auto-sync.ts +0 -32
- package/src/claude-pid.ts +0 -68
- package/src/cli-core.ts +0 -103
- package/src/cost-store.ts +0 -138
- package/src/doctor.ts +0 -89
- package/src/event-log.ts +0 -33
- package/src/events.ts +0 -145
- package/src/handle-hook.ts +0 -299
- package/src/hook.ts +0 -41
- package/src/inflight-recovery.ts +0 -82
- package/src/paths.ts +0 -50
- package/src/prompts.ts +0 -42
- package/src/record.ts +0 -28
- package/src/replay.ts +0 -123
- package/src/report.ts +0 -132
- package/src/session-state.ts +0 -71
- package/src/statusline-core.ts +0 -89
- package/src/statusline.ts +0 -36
- package/src/sync-cli.ts +0 -122
- package/tsconfig.json +0 -14
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kankaku",
|
|
3
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.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "soyunninja"
|
|
7
7
|
},
|
|
8
8
|
"homepage": "https://kankaku.io",
|
|
9
|
-
"repository": "https://github.com/soyunninja/kankaku
|
|
9
|
+
"repository": "https://github.com/soyunninja/kankaku",
|
|
10
10
|
"license": "MIT"
|
|
11
11
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,35 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 0.10.0 — 2026-09-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Installed as a dependency, the plugin could not run at all.** This
|
|
12
|
+
package now compiles to `dist/` (`npm run build`, `tsc -p
|
|
13
|
+
tsconfig.build.json`) and ships `dist/` instead of `src/`/`tsconfig.json`
|
|
14
|
+
in `package.json`'s `files`. Every hook/statusline/CLI command
|
|
15
|
+
(`hooks/hooks.json`, `commands/*.md`, the `statusLine` snippet
|
|
16
|
+
`kankaku setup`/`/kankaku:setup` prints) now points at the compiled
|
|
17
|
+
`dist/*.js`, never `src/*.ts` — Node 24 refuses to type-strip a `.ts`
|
|
18
|
+
file once it sits under a `node_modules` directory
|
|
19
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a `kankaku-tui`
|
|
20
|
+
install of this package (its dependency, not a checkout) could not run
|
|
21
|
+
a single hook, the statusline, or the CLI before this fix. `npm run
|
|
22
|
+
check`/`prepublishOnly` build first; a manual `--plugin-dir` checkout
|
|
23
|
+
now needs `npm install && npm run build` once (see README "Install").
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- README "Install": the recommended path is now `npm i -g kankaku-tui &&
|
|
28
|
+
kankaku setup` — `kankaku-tui` depends on this package directly and its
|
|
29
|
+
setup wizard/`--yes` flow writes both `statusLine` and `hooks` into
|
|
30
|
+
`~/.claude/settings.json` in one step, so no checkout or `--plugin-dir`
|
|
31
|
+
is required for the plugin to measure time or report cost.
|
|
32
|
+
`--plugin-dir <checkout>` remains documented as the manual/dev path
|
|
33
|
+
(also the only way to get the `/kankaku:*` slash commands), with a note
|
|
34
|
+
that combining it with a `kankaku setup`-configured machine double-runs
|
|
35
|
+
the hooks and double-writes worklog records.
|
|
8
36
|
|
|
9
37
|
### Added
|
|
10
38
|
|
|
@@ -62,3 +90,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
62
90
|
- `SessionEnd` also deletes the session's cost file; `SessionStart`
|
|
63
91
|
(non-`compact`) sweeps cost files older than 7 days.
|
|
64
92
|
- `node src/cli.ts status` reads cost from the cost file.
|
|
93
|
+
|
|
94
|
+
## 0.9.0 — 2026-09-28
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
|
|
98
|
+
- Moved into the kankaku monorepo (`github.com/soyunninja/kankaku`,
|
|
99
|
+
`packages/claude`); versions are now lockstep with the other client
|
|
100
|
+
packages (`kankaku`, `kankaku-tui`). Depends on `kankaku ^0.9.0` (was
|
|
101
|
+
`^0.6.0`). No behaviour change.
|
package/README.md
CHANGED
|
@@ -4,6 +4,9 @@ A [Claude Code](https://claude.com/claude-code) plugin that records how long
|
|
|
4
4
|
Claude Code works on each of your prompts, in the same worklog format as the
|
|
5
5
|
[kankaku](https://kankaku.io) pi extension.
|
|
6
6
|
|
|
7
|
+
This package lives in the [kankaku monorepo](https://github.com/soyunninja/kankaku),
|
|
8
|
+
under `packages/claude`.
|
|
9
|
+
|
|
7
10
|
## What it measures
|
|
8
11
|
|
|
9
12
|
One record per user prompt, appended to `<KANKAKU_DIR>/worklog.jsonl`:
|
|
@@ -25,22 +28,67 @@ manual and best-effort automatic hub sync through kankaku's public hub adapters
|
|
|
25
28
|
## Requirements
|
|
26
29
|
|
|
27
30
|
- Claude Code with plugin support.
|
|
28
|
-
- Node.js >= 24 on `PATH
|
|
29
|
-
|
|
31
|
+
- Node.js >= 24 on `PATH`. This package ships a compiled `dist/` (`npm
|
|
32
|
+
run build`, `tsc -p tsconfig.build.json`) — Node refuses to type-strip
|
|
33
|
+
a `.ts` file once it sits under a `node_modules` directory
|
|
34
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so every published
|
|
35
|
+
hook/statusline/CLI command runs the built `dist/*.js` file, never the
|
|
36
|
+
`.ts` source. Installed via `kankaku-tui` (below) this needs no action
|
|
37
|
+
from you; a manual `--plugin-dir` checkout must run `npm install && npm
|
|
38
|
+
run build` once before Claude Code can load it (see "Manual/dev"
|
|
39
|
+
below).
|
|
30
40
|
|
|
31
41
|
## Install
|
|
32
42
|
|
|
33
|
-
**
|
|
34
|
-
|
|
43
|
+
**Recommended: `kankaku-tui`'s setup wizard.** This package lives inside
|
|
44
|
+
`kankaku-tui`'s own dependency tree, so installing and running its setup
|
|
45
|
+
wizard configures Claude Code for you — statusline and hooks together, in
|
|
46
|
+
one step, with no checkout and no `--plugin-dir`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm i -g kankaku-tui
|
|
50
|
+
kankaku setup
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Checking Claude Code in the wizard (or confirming it in `kankaku setup
|
|
54
|
+
--yes`) writes `statusLine` and `hooks` into `~/.claude/settings.json`,
|
|
55
|
+
resolved from this package's own bundled files, merged with — never
|
|
56
|
+
clobbering — whatever else is already there. See `kankaku-tui`'s own
|
|
57
|
+
README ("Claude Code") for the full behaviour, the
|
|
58
|
+
`--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` override, and why you
|
|
59
|
+
should drop `--plugin-dir` (below) once this has run.
|
|
60
|
+
|
|
61
|
+
**Manual/dev: `--plugin-dir` (also required for the `/kankaku:*` slash
|
|
62
|
+
commands).** Clone this repository, build it once, then point Claude Code
|
|
63
|
+
at it directly:
|
|
35
64
|
|
|
36
65
|
```bash
|
|
66
|
+
git clone https://github.com/soyunninja/kankaku.git
|
|
67
|
+
cd kankaku/packages/claude
|
|
68
|
+
npm install && npm run build
|
|
37
69
|
claude --plugin-dir /path/to/kankaku-claude
|
|
38
70
|
```
|
|
39
71
|
|
|
72
|
+
`npm run build` compiles `src/` into `dist/`; the hooks, statusline and
|
|
73
|
+
`/kankaku:*` commands all run `dist/*.js`, never `src/*.ts` — a checkout
|
|
74
|
+
that skips this step fails at the hook's very first launch with
|
|
75
|
+
`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` (or, for `--plugin-dir`
|
|
76
|
+
itself, simply nothing recorded). Re-run `npm run build` after pulling
|
|
77
|
+
new commits.
|
|
78
|
+
|
|
40
79
|
If your Claude Code version does not recognize `--plugin-dir`, or plugin
|
|
41
80
|
loading has changed since this was written, check your installed version's
|
|
42
81
|
own plugin documentation (`claude --help`, or `/plugin` inside a session) for
|
|
43
|
-
the current local-install flow.
|
|
82
|
+
the current local-install flow. `--plugin-dir` is also the only way to get
|
|
83
|
+
the `/kankaku:*` slash commands (`/kankaku:report`, `/kankaku:setup`, …),
|
|
84
|
+
since a Claude Code plugin loaded only through settings.json hooks — the
|
|
85
|
+
recommended path above — never registers commands.
|
|
86
|
+
|
|
87
|
+
**Do not combine the two.** If `kankaku setup` has already configured this
|
|
88
|
+
machine's `~/.claude/settings.json` hooks, loading the plugin again with
|
|
89
|
+
`--plugin-dir` runs the same hooks twice per event and double-writes
|
|
90
|
+
worklog records. Use `--plugin-dir` only on a machine `kankaku setup` has
|
|
91
|
+
not touched, or drop it once setup has run.
|
|
44
92
|
|
|
45
93
|
**From a marketplace.** Once this plugin is published to a marketplace:
|
|
46
94
|
|
|
@@ -58,7 +106,8 @@ there never blocks the plugin from loading.
|
|
|
58
106
|
Claude Code plugins cannot set `statusLine` for themselves — there is no
|
|
59
107
|
programmatic way for a plugin to add a `statusLine` entry to your settings.
|
|
60
108
|
The statusline is also the *only* documented source of per-prompt cost
|
|
61
|
-
(`cost.total_cost_usd`); hooks never receive it.
|
|
109
|
+
(`cost.total_cost_usd`); hooks never receive it. `kankaku setup` (above)
|
|
110
|
+
writes it for you automatically; without `kankaku-tui`, run:
|
|
62
111
|
|
|
63
112
|
```
|
|
64
113
|
/kankaku:setup
|
|
@@ -79,22 +128,22 @@ anything behind in whatever project happens to be open.
|
|
|
79
128
|
## Commands
|
|
80
129
|
|
|
81
130
|
- `/kankaku:report` — a report of recent work, grouped by day (wraps
|
|
82
|
-
`node
|
|
131
|
+
`node dist/cli.js report`).
|
|
83
132
|
- `/kankaku:status` — the sessions kankaku-claude currently has state for:
|
|
84
133
|
session id, whether its process is still alive, whether a prompt is open,
|
|
85
|
-
and the last cost the statusline reported (wraps `node
|
|
134
|
+
and the last cost the statusline reported (wraps `node dist/cli.js status`).
|
|
86
135
|
- `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
|
|
87
|
-
`node
|
|
136
|
+
`node dist/cli.js setup`).
|
|
88
137
|
- `/kankaku:sync` — manually syncs recent local work records to the hub
|
|
89
|
-
(wraps `node
|
|
138
|
+
(wraps `node dist/cli.js sync`; the slash command does not forward arguments).
|
|
90
139
|
- `/kankaku:sync-status` — inspects local pending counts and sync state
|
|
91
|
-
(wraps `node
|
|
92
|
-
- `/kankaku:sync-all` — requests a full sync (wraps `node
|
|
140
|
+
(wraps `node dist/cli.js sync status`; no hub request or credentials required).
|
|
141
|
+
- `/kankaku:sync-all` — requests a full sync (wraps `node dist/cli.js sync all`).
|
|
93
142
|
- `/kankaku:doctor` — a read-only local diagnostic of plugin files, session and
|
|
94
|
-
cost visibility, and hub sync state (wraps `node
|
|
143
|
+
cost visibility, and hub sync state (wraps `node dist/cli.js doctor`).
|
|
95
144
|
|
|
96
145
|
The report, status, setup, and doctor subcommands are also available directly via
|
|
97
|
-
`node
|
|
146
|
+
`node dist/cli.js <report|status|setup|doctor>`; `report` accepts `--days N` and
|
|
98
147
|
defaults to the last 7 days. Doctor reads only local data; it never contacts the
|
|
99
148
|
hub or prints credentials. A missing cost file means cost has not been observed
|
|
100
149
|
under the current `HOME` (it does not prove the statusline is misconfigured).
|
|
@@ -111,9 +160,9 @@ request or credentials. The same operations are available directly via CLI:
|
|
|
111
160
|
|
|
112
161
|
| Command | Purpose |
|
|
113
162
|
|---------|---------|
|
|
114
|
-
| `node
|
|
115
|
-
| `node
|
|
116
|
-
| `node
|
|
163
|
+
| `node dist/cli.js sync` | Manually sync the recent window (24 hours by default). |
|
|
164
|
+
| `node dist/cli.js sync all` | Request a full sync. |
|
|
165
|
+
| `node dist/cli.js sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
|
|
117
166
|
|
|
118
167
|
Optional environment settings:
|
|
119
168
|
|
package/commands/doctor.md
CHANGED
package/commands/report.md
CHANGED
package/commands/setup.md
CHANGED
|
@@ -9,4 +9,4 @@ Then tell the user, briefly: paste the printed `statusLine` block into their
|
|
|
9
9
|
because a Claude Code plugin cannot set `statusLine` for itself — the
|
|
10
10
|
statusline is the only documented source of per-session cost.
|
|
11
11
|
|
|
12
|
-
!node "${CLAUDE_PLUGIN_ROOT}/
|
|
12
|
+
!node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" setup
|
package/commands/status.md
CHANGED
package/commands/sync-all.md
CHANGED
|
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
|
|
|
6
6
|
Run the kankaku CLI sync all command and show its output to the user verbatim,
|
|
7
7
|
inside a code block, with no summarizing or reformatting:
|
|
8
8
|
|
|
9
|
-
!node "${CLAUDE_PLUGIN_ROOT}/
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync all
|
package/commands/sync-status.md
CHANGED
|
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
|
|
|
6
6
|
Run the kankaku CLI sync status command and show its output to the user verbatim,
|
|
7
7
|
inside a code block, with no summarizing or reformatting:
|
|
8
8
|
|
|
9
|
-
!node "${CLAUDE_PLUGIN_ROOT}/
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync status
|
package/commands/sync.md
CHANGED
|
@@ -6,9 +6,9 @@ allowed-tools: Bash(node:*)
|
|
|
6
6
|
Run the kankaku CLI sync command and show its output to the user verbatim,
|
|
7
7
|
inside a code block, with no summarizing or reformatting:
|
|
8
8
|
|
|
9
|
-
!node "${CLAUDE_PLUGIN_ROOT}/
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync
|
|
10
10
|
|
|
11
11
|
For a full sync or local sync status, use the direct CLI instead:
|
|
12
|
-
`node "${CLAUDE_PLUGIN_ROOT}/
|
|
13
|
-
`node "${CLAUDE_PLUGIN_ROOT}/
|
|
12
|
+
`node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync all` or
|
|
13
|
+
`node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync status`.
|
|
14
14
|
This slash command runs the default sync only; it does not forward arguments.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Best-effort heavy-hook sync; credential/transport errors never escape into record handling. */
|
|
2
|
+
export async function autoSync(trigger, deps) {
|
|
3
|
+
if (deps.env.KANKAKU_SYNC_AUTO === "0")
|
|
4
|
+
return;
|
|
5
|
+
try {
|
|
6
|
+
const { resolveHubCredentials } = await import("kankaku/hub");
|
|
7
|
+
const { homedir } = await import("node:os");
|
|
8
|
+
const hub = resolveHubCredentials({ env: deps.env, homeDir: deps.homeDir ?? (() => deps.env.HOME || homedir()) });
|
|
9
|
+
if (!hub.credentials || hub.invalidReason)
|
|
10
|
+
return;
|
|
11
|
+
const { syncConfigured } = await import("./sync-cli.js");
|
|
12
|
+
// One deadline across catalog and upload requests; the public client also
|
|
13
|
+
// imposes its own per-request timeout. No timer persists after the hook.
|
|
14
|
+
const deadline = AbortSignal.timeout(8_000);
|
|
15
|
+
const doFetch = deps.fetch ?? fetch;
|
|
16
|
+
const boundedFetch = (input, init) => doFetch(input, {
|
|
17
|
+
...init,
|
|
18
|
+
signal: init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline,
|
|
19
|
+
});
|
|
20
|
+
const result = await syncConfigured({ ...deps, fetch: boundedFetch }, hub.credentials, { trigger });
|
|
21
|
+
if (result.error || result.failed.length) {
|
|
22
|
+
deps.stderr(`kankaku auto-sync: ${result.error ?? result.failed.map((f) => f.reason).join("; ")}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
const SKIP_COMMS = new Set(["sh", "bash", "zsh", "dash", "fish", "node"]);
|
|
4
|
+
const MAX_HOPS = 6;
|
|
5
|
+
/**
|
|
6
|
+
* Walks ancestors from `startPid` (at most `MAX_HOPS` hops), returning the
|
|
7
|
+
* first pid whose `comm` basename is not a shell or `node` — the Claude
|
|
8
|
+
* Code process itself, skipping the shell/node layers a hook can be
|
|
9
|
+
* launched through. Falls back to `startPid` when `ps` fails or nothing
|
|
10
|
+
* qualifies within the hop budget.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveClaudePid({ startPid, runPs }) {
|
|
13
|
+
let pid = startPid;
|
|
14
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
15
|
+
const info = runPs(pid);
|
|
16
|
+
if (!info)
|
|
17
|
+
return startPid;
|
|
18
|
+
if (!SKIP_COMMS.has(basename(info.comm)))
|
|
19
|
+
return pid;
|
|
20
|
+
pid = info.ppid;
|
|
21
|
+
}
|
|
22
|
+
return startPid;
|
|
23
|
+
}
|
|
24
|
+
/** Production `runPs`: `ps -o ppid=,comm= -p <pid>`, bounded to 200ms. */
|
|
25
|
+
export function runPsProcess(pid) {
|
|
26
|
+
try {
|
|
27
|
+
const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
|
|
28
|
+
timeout: 200,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
});
|
|
31
|
+
const match = out.trim().match(/^(\d+)\s+(.+)$/);
|
|
32
|
+
if (!match)
|
|
33
|
+
return undefined;
|
|
34
|
+
const ppid = Number(match[1]);
|
|
35
|
+
const comm = match[2]?.trim();
|
|
36
|
+
if (!Number.isFinite(ppid) || !comm)
|
|
37
|
+
return undefined;
|
|
38
|
+
return { ppid, comm };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `true` unless `process.kill(pid, 0)` throws with a code other than
|
|
46
|
+
* `EPERM`. A non-positive or non-integer pid is always `false`, without
|
|
47
|
+
* calling `process.kill` at all: `pid` 0 signals the whole process GROUP
|
|
48
|
+
* (T7 — the defect that let a placeholder state file's `pid: 0` read back
|
|
49
|
+
* as alive forever).
|
|
50
|
+
*/
|
|
51
|
+
export function isAlive(pid) {
|
|
52
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return error.code === "EPERM";
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/cli-core.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { JsonlWorkLog } from "kankaku/hub";
|
|
3
|
+
import { listStateFiles, resolveKankakuDir } from "./paths.js";
|
|
4
|
+
import { readState } from "./session-state.js";
|
|
5
|
+
import { readCost } from "./cost-store.js";
|
|
6
|
+
import { formatReport } from "./report.js";
|
|
7
|
+
import { runSyncCli } from "./sync-cli.js";
|
|
8
|
+
import { runDoctor } from "./doctor.js";
|
|
9
|
+
const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor> [--days N]\n";
|
|
10
|
+
/** CLI commands, resolved from `deps.cwd`. */
|
|
11
|
+
export async function runCli(argv, deps) {
|
|
12
|
+
const [command, ...rest] = argv;
|
|
13
|
+
switch (command) {
|
|
14
|
+
case "report":
|
|
15
|
+
return runReport(rest, deps);
|
|
16
|
+
case "status":
|
|
17
|
+
return runStatus(deps);
|
|
18
|
+
case "setup":
|
|
19
|
+
return runSetup(deps);
|
|
20
|
+
case "sync":
|
|
21
|
+
return runSyncCli(rest, deps);
|
|
22
|
+
case "doctor":
|
|
23
|
+
return { stdout: runDoctor(deps), exitCode: 0 };
|
|
24
|
+
default:
|
|
25
|
+
return { stdout: "", exitCode: 1, stderr: USAGE };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function runReport(args, deps) {
|
|
29
|
+
const days = parseDays(args);
|
|
30
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
31
|
+
const records = new JsonlWorkLog(kankakuDir).readAll();
|
|
32
|
+
const stdout = formatReport(records, { now: deps.now(), ...(days !== undefined ? { days } : {}) });
|
|
33
|
+
return { stdout, exitCode: 0 };
|
|
34
|
+
}
|
|
35
|
+
function parseDays(args) {
|
|
36
|
+
const idx = args.indexOf("--days");
|
|
37
|
+
if (idx === -1)
|
|
38
|
+
return undefined;
|
|
39
|
+
const value = Number(args[idx + 1]);
|
|
40
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
function runStatus(deps) {
|
|
43
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
44
|
+
const claudeDir = join(kankakuDir, "claude");
|
|
45
|
+
const files = listStateFiles(claudeDir);
|
|
46
|
+
if (files.length === 0) {
|
|
47
|
+
return { stdout: "No active sessions.\n", exitCode: 0 };
|
|
48
|
+
}
|
|
49
|
+
const lines = files
|
|
50
|
+
.sort()
|
|
51
|
+
.map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
|
|
52
|
+
return { stdout: lines.join("\n") + "\n", exitCode: 0 };
|
|
53
|
+
}
|
|
54
|
+
function formatStatusLine(sessionId, stateFile, isAlive, env) {
|
|
55
|
+
const state = readState(stateFile);
|
|
56
|
+
if (!state)
|
|
57
|
+
return `${sessionId} (unreadable state)`;
|
|
58
|
+
const aliveWord = isAlive(state.pid) ? "alive" : "dead";
|
|
59
|
+
const promptWord = state.promptOpen
|
|
60
|
+
? `prompt open since ${new Date(state.promptOpen.startedAt).toISOString()}`
|
|
61
|
+
: "idle";
|
|
62
|
+
const cost = readCost(env, sessionId);
|
|
63
|
+
const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
|
|
64
|
+
return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
|
|
65
|
+
}
|
|
66
|
+
function sessionIdFromStateFile(file) {
|
|
67
|
+
return basename(file).replace(/\.state\.json$/, "");
|
|
68
|
+
}
|
|
69
|
+
function runSetup(deps) {
|
|
70
|
+
const statuslinePath = join(deps.pluginRoot, "dist", "statusline.js");
|
|
71
|
+
const command = `node "${statuslinePath}"`;
|
|
72
|
+
const snippet = JSON.stringify({ statusLine: { type: "command", command } }, null, 2);
|
|
73
|
+
const explanation = "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.";
|
|
74
|
+
return { stdout: `${snippet}\n\n${explanation}\n`, exitCode: 0 };
|
|
75
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { runCli } from "./cli-core.
|
|
4
|
-
import { isAlive } from "./claude-pid.
|
|
5
|
-
|
|
3
|
+
import { runCli } from "./cli-core.js";
|
|
4
|
+
import { isAlive } from "./claude-pid.js";
|
|
6
5
|
// src/cli.ts -> src -> repo root (the directory that contains .claude-plugin/).
|
|
7
6
|
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
-
|
|
9
7
|
const result = await runCli(process.argv.slice(2), {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
env: process.env,
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
now: () => Date.now(),
|
|
11
|
+
isAlive,
|
|
12
|
+
pluginRoot,
|
|
15
13
|
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (result.stderr)
|
|
14
|
+
if (result.stdout)
|
|
15
|
+
process.stdout.write(result.stdout);
|
|
16
|
+
if (result.stderr)
|
|
17
|
+
process.stderr.write(result.stderr);
|
|
19
18
|
process.exit(result.exitCode);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Cost lives under the HOME directory, never a project: the statusline
|
|
7
|
+
* command is wired globally and runs in every session on the machine, so a
|
|
8
|
+
* project-relative path would litter whatever project happens to be open
|
|
9
|
+
* (the defect this module fixes — see `odd/tasks/hook-tracking.md` T7).
|
|
10
|
+
* Imports only node builtins so light hooks that read/write cost stay off
|
|
11
|
+
* the `kankaku` import path.
|
|
12
|
+
*/
|
|
13
|
+
export function costDir(env) {
|
|
14
|
+
const home = env.HOME || homedir();
|
|
15
|
+
return join(home, ".kankaku", "claude", "cost");
|
|
16
|
+
}
|
|
17
|
+
export function costFile(env, sessionId) {
|
|
18
|
+
return join(costDir(env), `${sessionId}.json`);
|
|
19
|
+
}
|
|
20
|
+
function isCostState(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null)
|
|
22
|
+
return false;
|
|
23
|
+
const o = value;
|
|
24
|
+
return (typeof o.totalUsd === "number" &&
|
|
25
|
+
typeof o.updatedAt === "number" &&
|
|
26
|
+
(o.model === undefined || typeof o.model === "string"));
|
|
27
|
+
}
|
|
28
|
+
/** `undefined` when the file is absent or its contents are malformed. */
|
|
29
|
+
export function readCost(env, sessionId) {
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = readFileSync(costFile(env, sessionId), "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return isCostState(parsed) ? parsed : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Atomic tmp+rename write. Creates `claude/` and `claude/cost/` (both owned
|
|
48
|
+
* exclusively by this plugin) as owner-only (`0700`) when it creates them —
|
|
49
|
+
* `mkdirSync`'s `mode` only ever applies to a directory the call actually
|
|
50
|
+
* creates, so an existing `~/.kankaku` is never chmod'd by this. The written
|
|
51
|
+
* file is chmod'd `0600` best-effort. Never lowers `updatedAt`: a statusline
|
|
52
|
+
* write racing an older one loses.
|
|
53
|
+
*/
|
|
54
|
+
export function writeCost(env, sessionId, cost) {
|
|
55
|
+
const dir = costDir(env);
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const existing = readCost(env, sessionId);
|
|
58
|
+
if (existing && existing.updatedAt > cost.updatedAt)
|
|
59
|
+
return;
|
|
60
|
+
const file = costFile(env, sessionId);
|
|
61
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
62
|
+
writeFileSync(tmp, JSON.stringify(cost));
|
|
63
|
+
renameSync(tmp, file);
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(file, 0o600);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best-effort
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function deleteCost(env, sessionId) {
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(costFile(env, sessionId));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// already gone
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Deletes cost files whose `updatedAt` (or mtime, when the file is
|
|
81
|
+
* unparsable) is older than `maxAgeMs` (default 7 days). No-op when the
|
|
82
|
+
* cost directory does not exist yet.
|
|
83
|
+
*/
|
|
84
|
+
export function sweepStaleCostFiles(env, opts) {
|
|
85
|
+
const maxAgeMs = opts.maxAgeMs ?? SEVEN_DAYS_MS;
|
|
86
|
+
const dir = costDir(env);
|
|
87
|
+
let entries;
|
|
88
|
+
try {
|
|
89
|
+
entries = readdirSync(dir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const name of entries) {
|
|
95
|
+
if (!name.endsWith(".json"))
|
|
96
|
+
continue;
|
|
97
|
+
const file = join(dir, name);
|
|
98
|
+
const sessionId = name.slice(0, -".json".length);
|
|
99
|
+
const cost = readCost(env, sessionId);
|
|
100
|
+
let age;
|
|
101
|
+
if (cost) {
|
|
102
|
+
age = opts.now - cost.updatedAt;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
age = opts.now - statSync(file).mtimeMs;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (age > maxAgeMs) {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(file);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// already gone
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|