kankaku-tui 0.1.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/LICENSE +21 -0
- package/README.md +230 -0
- package/dist/adapters/app-info.js +13 -0
- package/dist/adapters/hub.js +101 -0
- package/dist/adapters/project-discovery.js +43 -0
- package/dist/adapters/tui-config.js +35 -0
- package/dist/adapters/worklog-reader.js +9 -0
- package/dist/cli.js +347 -0
- package/dist/domain/catalog-model.js +45 -0
- package/dist/domain/dashboard-model.js +82 -0
- package/dist/domain/list-window.js +31 -0
- package/dist/domain/nav-model.js +69 -0
- package/dist/domain/quick-actions.js +49 -0
- package/dist/domain/sync-model.js +17 -0
- package/dist/domain/tasks-model.js +51 -0
- package/dist/domain/text-wrap.js +47 -0
- package/dist/domain/today-model.js +75 -0
- package/dist/ports/project-source.js +1 -0
- package/dist/ui/app.js +65 -0
- package/dist/ui/catalog-screen.js +100 -0
- package/dist/ui/components/bar.js +20 -0
- package/dist/ui/components/header-bar.js +8 -0
- package/dist/ui/components/key-hints.js +8 -0
- package/dist/ui/components/panel.js +32 -0
- package/dist/ui/components/sidebar.js +30 -0
- package/dist/ui/components/sparkline.js +19 -0
- package/dist/ui/components/table.js +55 -0
- package/dist/ui/dashboard-screen.js +217 -0
- package/dist/ui/layout.js +54 -0
- package/dist/ui/sync-screen.js +127 -0
- package/dist/ui/tasks-screen.js +166 -0
- package/dist/ui/theme.js +81 -0
- package/dist/ui/today-screen.js +161 -0
- package/package.json +38 -0
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,230 @@
|
|
|
1
|
+
# kankaku-tui
|
|
2
|
+
|
|
3
|
+
A standalone terminal app, `kankaku`, that reads every project's
|
|
4
|
+
`.kankaku/worklog.jsonl` under a configurable list of roots and shows the
|
|
5
|
+
day across projects — the one view the [kankaku](https://kankaku.io) pi
|
|
6
|
+
panel cannot give, since it only ever sees the one project pi is running
|
|
7
|
+
in. Built with [Ink](https://github.com/vadimdemedes/ink) on Node 24. Four
|
|
8
|
+
screens — Dashboard, Tasks, Catalog and Sync — share one tab bar, and each
|
|
9
|
+
has a plain-text subcommand for scripts and cron.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Until the next kankaku release, this package depends on the sibling
|
|
14
|
+
`kankaku` checkout via `file:../kankaku`, so both repos must sit next to
|
|
15
|
+
each other on disk. Run `npm install` inside `kankaku-tui/`.
|
|
16
|
+
|
|
17
|
+
Later, once published: `npm install -g kankaku-tui`. For now, run it from
|
|
18
|
+
this repo with `npm run dev`.
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
`~/.kankaku/tui.json`:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"roots": ["/absolute/path/to/workspace", "~/another-workspace"]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Each root is either a project itself (it has its own `.kankaku/worklog.jsonl`)
|
|
31
|
+
or a directory containing one or more projects as direct subdirectories.
|
|
32
|
+
`~` expands to the home directory. Missing or malformed config falls back
|
|
33
|
+
to the current working directory as the only root.
|
|
34
|
+
|
|
35
|
+
### Hub credentials (Catalog and Sync)
|
|
36
|
+
|
|
37
|
+
The Catalog and Sync screens (and their subcommands) talk to the same
|
|
38
|
+
PocketBase hub kankaku itself syncs to, through kankaku's own
|
|
39
|
+
`resolveHubCredentials`: `~/.kankaku/credentials.json`
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"url": "https://your-hub.example.com",
|
|
44
|
+
"email": "you@example.com",
|
|
45
|
+
"password": "…"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
or the environment (env takes precedence per field over the file):
|
|
50
|
+
|
|
51
|
+
- `KANKAKU_PB_URL`, `KANKAKU_PB_EMAIL`, `KANKAKU_PB_PASSWORD`
|
|
52
|
+
- `KANKAKU_SYNC_WINDOW_HOURS` — revisit window for `sync`/`sync status` (default 24)
|
|
53
|
+
- `KANKAKU_SYNC_PROMPT` — `none` (default), `truncated` or `full`
|
|
54
|
+
- `KANKAKU_SYNC_RECORDS` — set to `0` to skip uploading individual `work_records`
|
|
55
|
+
- `KANKAKU_MACHINE` — overrides the reported hostname
|
|
56
|
+
|
|
57
|
+
Without credentials, the Catalog and Sync screens show a one-line note
|
|
58
|
+
instead of a list; `kankaku catalog` and `kankaku sync status` print the
|
|
59
|
+
same note and exit 0 (no network attempted); `kankaku catalog refresh` and
|
|
60
|
+
`kankaku sync`/`kankaku sync all` print an error and exit 1.
|
|
61
|
+
|
|
62
|
+
Every sync uploaded from here is stamped `plugin: kankaku-tui`; a task's
|
|
63
|
+
`agent` comes from its own orchestrator record when it carries one (see
|
|
64
|
+
kankaku's `hub-entry.ts`), else falls back to `agent: unknown` — this app
|
|
65
|
+
never guesses which coding agent produced someone else's worklog.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
- `kankaku` — opens the interactive TUI on the Dashboard screen.
|
|
70
|
+
- `kankaku today [--roots a,b]` — today's work per project, plain text.
|
|
71
|
+
- `kankaku tasks [--all]` — every task's line (kankaku's own `formatTasks`),
|
|
72
|
+
grouped under a `== <project> ==` header per project; restricted to
|
|
73
|
+
today unless `--all`.
|
|
74
|
+
- `kankaku catalog [refresh]` — without `refresh`, reports the locally
|
|
75
|
+
cached client/project counts (no network); `refresh` fetches a fresh
|
|
76
|
+
snapshot from the hub and caches it to `~/.kankaku/catalog.json`.
|
|
77
|
+
- `kankaku sync [status|all] [--project <dir>]` — `status` reports the
|
|
78
|
+
pending count and last sync per project, no network; with no argument,
|
|
79
|
+
syncs the pending window; `all` does a full resync. Defaults to every
|
|
80
|
+
discovered project, sequentially; `--project <dir>` restricts to one.
|
|
81
|
+
|
|
82
|
+
`--roots` (on `today`/`tasks`) overrides the configured roots for that run.
|
|
83
|
+
|
|
84
|
+
`--theme <name>` picks one of the three built-in colour presets for the
|
|
85
|
+
interactive TUI; `KANKAKU_TUI_THEME=<name>` does the same through the
|
|
86
|
+
environment (the flag wins when both are given). The valid names are
|
|
87
|
+
`gentleman-sexy` (the default), `gentleman-cute` and `gentle` — resolved
|
|
88
|
+
hex values copied from [gentle-pi](https://github.com/Gentleman-Programming/gentle-pi)'s
|
|
89
|
+
own themes (MIT), so this TUI matches the owner's pi panel instead of an
|
|
90
|
+
unrelated default. An unknown name prints a usage error listing the valid
|
|
91
|
+
names and exits 1 without opening the TUI.
|
|
92
|
+
|
|
93
|
+
## Screens
|
|
94
|
+
|
|
95
|
+
One visual system drives all four screens: a left sidebar for navigation,
|
|
96
|
+
titled bordered panels, aligned tables with a highlighted selection, text
|
|
97
|
+
bars and sparklines, a header line and a footer of key hints — all driven
|
|
98
|
+
by a single theme of colour roles (`src/ui/theme.ts`, see `--theme` above
|
|
99
|
+
for the three built-in presets). The app runs fullscreen, in the
|
|
100
|
+
terminal's alternate screen buffer: the frame fills the whole terminal
|
|
101
|
+
height, resizing live with the terminal. The sidebar sits beside the
|
|
102
|
+
screen at 100+ terminal columns, stacks full-width above it at 70-99
|
|
103
|
+
columns, and collapses to a one-line tab strip below 70 columns; a
|
|
104
|
+
selected row or card is always marked with a visible `›`, never colour
|
|
105
|
+
alone. The sidebar itself shows which zone has focus: its border switches
|
|
106
|
+
to the active border colour and the active item gets a full-row highlight
|
|
107
|
+
when it has focus, dropping back to a plain `›` marker with no highlight
|
|
108
|
+
once focus moves to the screen's own content.
|
|
109
|
+
|
|
110
|
+
Every panel in the main area is sized to a fixed height derived from the
|
|
111
|
+
terminal's own height, so it never grows with its content and shifts the
|
|
112
|
+
rest of the screen — a long value (e.g. the Tasks screen's full prompt)
|
|
113
|
+
is wrapped and, if it still doesn't fit the panel's fixed height, clipped
|
|
114
|
+
with a trailing `… N more lines` note instead of silently overflowing or
|
|
115
|
+
pushing the header out of view.
|
|
116
|
+
|
|
117
|
+
Any list that can grow past the available height (the Tasks table, the
|
|
118
|
+
Catalog Clients/Projects lists, the Dashboard Projects table, the Sync
|
|
119
|
+
card grid) scrolls instead of overflowing the terminal: the viewport
|
|
120
|
+
follows the current selection, and a `↑ N more` / `↓ N more` line marks
|
|
121
|
+
rows hidden above or below it.
|
|
122
|
+
|
|
123
|
+
Dashboard is the app's home screen: a Today card (work/wait/cost/tasks/
|
|
124
|
+
cache hit — it shows today's numbers, hence its own title), a Last 7 days
|
|
125
|
+
card (work and cost sparklines with weekday labels), a Projects table
|
|
126
|
+
(work, cost and a share bar per project), a Hub card (pending/stale, last
|
|
127
|
+
sync time, catalog summary) and a Quick actions panel (`c` refresh the
|
|
128
|
+
catalog, `s` sync every project, `S` full-sync every project, `r` reload):
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
>_ kankaku 0.1.0 hub ● kankaku.soyun.ninja · synced 08:20
|
|
132
|
+
┌──────────────┐ ╭─[ Today ]────────────────────╮ ╭─[ Last 7 days ]──────────────────╮
|
|
133
|
+
│ › Dashboard │ │ work 1h 42m │ │ work ▂▅▇▃▁▆█ cost ▁▃▆▂▁▅█ │
|
|
134
|
+
│ Tasks │ │ wait 6m cost $9.83 │ │ mon tue wed thu fri sat sun │
|
|
135
|
+
│ Catalog │ │ tasks 12 cache hit 68%│ ╰──────────────────────────────────╯
|
|
136
|
+
│ Sync │ ╰──────────────────────────────╯ ╭─[ Hub ]──────────────────────────╮
|
|
137
|
+
│ │ ╭─[ Projects ]────────────────────────────╮ │ pending 1 · stale 0 │
|
|
138
|
+
│ │ │ project work cost share │ │ last sync ok 08:20 │
|
|
139
|
+
│ │ │ kankaku 1h 02m $6.49 ████████░░ │ │ catalog 9 clients · │
|
|
140
|
+
│ │ │ kankaku-tui 31m $2.10 █████░░░░░ │ │ 17 projects │
|
|
141
|
+
│ │ │ kankaku-hub 9m $1.24 ██░░░░░░░░ │ ╰─────────────────────────╯
|
|
142
|
+
│ │ ╰─────────────────────────────────────────╯
|
|
143
|
+
├──────────────┤
|
|
144
|
+
│ roots 1 │
|
|
145
|
+
│ projects 3 │
|
|
146
|
+
└──────────────┘
|
|
147
|
+
↑↓ move enter open r refresh 1-4 screens q quit
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The Quick actions panel sits below the Hub card in wide mode (100+
|
|
151
|
+
columns), or right after the Projects table in stacked mode (70-99
|
|
152
|
+
columns):
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
╭─[ Quick actions ]────────────────╮
|
|
156
|
+
│ c refresh catalog │
|
|
157
|
+
│ s sync all projects │
|
|
158
|
+
│ S full sync all │
|
|
159
|
+
│ r reload │
|
|
160
|
+
│ catalog: 9 clients · 17 projects │
|
|
161
|
+
╰──────────────────────────────────╯
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The bottom line is the status line: empty until the first action runs,
|
|
165
|
+
`… <label>` while one is running, its result message once it settles
|
|
166
|
+
(e.g. the catalog refresh above, or a sync summary), `error: <message>`
|
|
167
|
+
if it failed, or `hub not configured (~/.kankaku/credentials.json)` when
|
|
168
|
+
the hub has no credentials — in which case `c`/`s`/`S` do nothing.
|
|
169
|
+
|
|
170
|
+
- **Tasks** — a table (time, project, work, cost, prompt) with a
|
|
171
|
+
highlighted row on the left, and a `[ Task ]` detail panel on the right
|
|
172
|
+
showing the selected row's full prompt, client, project, hub task,
|
|
173
|
+
wall/work/wait time, cost, cache hit and subagent count.
|
|
174
|
+
- **Catalog** — `[ Clients ]` on the left; the selected client's
|
|
175
|
+
`[ Projects ]`, with open/doing hub task counts, on the right. The
|
|
176
|
+
Clients panel header shows the cache's age and a `(stale)` flag.
|
|
177
|
+
- **Sync** — one card per project in a wrapping grid; the selected card is
|
|
178
|
+
highlighted, and each action's result line shows inside its card while
|
|
179
|
+
it runs and once it settles.
|
|
180
|
+
|
|
181
|
+
## Keys (TUI)
|
|
182
|
+
|
|
183
|
+
The app has two focus zones — the sidebar and the active screen's own main
|
|
184
|
+
content — and one of them always has focus (`domain/nav-model.ts`'s
|
|
185
|
+
`NavState.focus`, starting on the sidebar). `1`-`4` switch the Dashboard/
|
|
186
|
+
Tasks/Catalog/Sync tab bar and `q` quits from anywhere, in either zone; every
|
|
187
|
+
other key belongs to whichever zone currently has focus, so a screen's own
|
|
188
|
+
list never moves by accident while you are still picking a screen.
|
|
189
|
+
|
|
190
|
+
- **Sidebar focused** (the app's own starting state) — `↑`/`↓` move
|
|
191
|
+
between screens, and the screen switches as you move, so you see each
|
|
192
|
+
one before committing to it. `enter`, `→` or `Tab` focus the main zone
|
|
193
|
+
(the screen you last landed on).
|
|
194
|
+
- **Main zone focused** — the active screen's own keys work as below.
|
|
195
|
+
`←` or `Tab` return focus to the sidebar. `esc` also returns to the
|
|
196
|
+
sidebar, unless the screen consumes it first: on Tasks with a project
|
|
197
|
+
filter set (from Dashboard's `enter`), the first `esc` clears the filter
|
|
198
|
+
and the next `esc` returns to the sidebar.
|
|
199
|
+
|
|
200
|
+
The focused zone is visible in the frame: the sidebar's active-item marker
|
|
201
|
+
is in the accent colour when the sidebar is focused and muted otherwise,
|
|
202
|
+
the focused screen's primary panel gets the accent border, and the footer
|
|
203
|
+
key hints change — the sidebar's own hints while it is focused, the
|
|
204
|
+
screen's hints plus `← menu` while the main zone is focused.
|
|
205
|
+
|
|
206
|
+
The app fills the whole terminal; every scrolling list (Tasks, Catalog's
|
|
207
|
+
Clients/Projects, Dashboard's Projects, Sync's cards) additionally takes
|
|
208
|
+
`PageUp`/`PageDown` to move a full window at a time and `Home`/`End` to
|
|
209
|
+
jump to the first/last row.
|
|
210
|
+
|
|
211
|
+
- **Dashboard** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the
|
|
212
|
+
Projects selection, `enter` opens the selected project in Tasks
|
|
213
|
+
(filtered to it), `r` refresh; the Quick actions panel additionally
|
|
214
|
+
takes `c` (refresh catalog), `s` (sync all projects) and `S` (full sync
|
|
215
|
+
all) — one at a time, ignored while another is running.
|
|
216
|
+
- **Tasks** — `a` toggle today/all, `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End`
|
|
217
|
+
move the selection, `r` refresh, `esc` clears a project filter set from
|
|
218
|
+
Dashboard.
|
|
219
|
+
- **Catalog** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the client
|
|
220
|
+
selection, `r` refresh from the hub.
|
|
221
|
+
- **Sync** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection,
|
|
222
|
+
`s` sync the selected project, `f` full-sync the selected project, `S`
|
|
223
|
+
sync every project. Each action's summary shows inline in its card
|
|
224
|
+
while it runs and once it settles.
|
|
225
|
+
|
|
226
|
+
The TUI never writes to disk on its own — Dashboard, Tasks and read-only
|
|
227
|
+
Catalog views write nothing at all; Catalog's `refresh`, Sync's
|
|
228
|
+
`s`/`f`/`S` and Dashboard's Quick actions `c`/`s`/`S` write only through
|
|
229
|
+
kankaku's own adapters (`CachedCatalog`, `SyncStateStore`, the hub
|
|
230
|
+
itself), exactly as kankaku's own sync paths do.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** Reads the `version` field from `<root>/package.json`; `"0.0.0"` when it is missing. */
|
|
5
|
+
export function readAppVersion(root) {
|
|
6
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
7
|
+
return pkg.version ?? "0.0.0";
|
|
8
|
+
}
|
|
9
|
+
/** This package's own version, read from its `package.json` next to `dist/adapters/app-info.js` (or `src/adapters/app-info.ts` under `tsx`) — used by the header bar's `>_ kankaku <version>` label. */
|
|
10
|
+
export function readOwnVersion() {
|
|
11
|
+
const root = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
12
|
+
return readAppVersion(root);
|
|
13
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared hub adapter: credentials, catalog and per-project sync, used by
|
|
3
|
+
* both the Catalog/Sync screens and their `kankaku catalog`/`kankaku sync`
|
|
4
|
+
* subcommands. Mirrors kankaku-claude's `sync-cli.ts#syncConfigured` (see
|
|
5
|
+
* `odd/tasks/screens-tasks-catalog-sync.md`), but scoped to one explicit
|
|
6
|
+
* `ProjectRef` per call rather than the process's own `cwd`, since one TUI
|
|
7
|
+
* process can sync several projects.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { hostname as osHostname } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { CachedCatalog, JsonlWorkLog, PocketBaseClient, PocketBaseSink, SyncStateStore, computeSyncStatus, createPocketBaseCatalogFetcher, resolveHubCredentials, runSync, safeHomeDir, } from "kankaku/hub";
|
|
14
|
+
const HUB_UNCONFIGURED_REASON = "hub credentials are not configured (KANKAKU_PB_URL/_EMAIL/_PASSWORD or ~/.kankaku/credentials.json)";
|
|
15
|
+
/** Resolve hub credentials via kankaku's own `resolveHubCredentials`, collapsing its result into one pass/fail outcome with a display reason for the Catalog/Sync screens and subcommands. */
|
|
16
|
+
export function resolveHub(deps) {
|
|
17
|
+
const { credentials, invalidReason } = resolveHubCredentials(deps);
|
|
18
|
+
if (invalidReason)
|
|
19
|
+
return { ok: false, reason: `invalid hub URL: ${invalidReason}` };
|
|
20
|
+
if (!credentials)
|
|
21
|
+
return { ok: false, reason: HUB_UNCONFIGURED_REASON };
|
|
22
|
+
return { ok: true, credentials };
|
|
23
|
+
}
|
|
24
|
+
/** Build the disk-backed `CachedCatalog` (`<homeDir>/.kankaku/catalog.json`) for `credentials`, mirroring kankaku-claude's `syncConfigured`. */
|
|
25
|
+
export function createCatalog(credentials, deps) {
|
|
26
|
+
const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
|
|
27
|
+
const home = safeHomeDir(deps.homeDir) ?? deps.homeDir();
|
|
28
|
+
return new CachedCatalog({
|
|
29
|
+
filePath: join(home, ".kankaku", "catalog.json"),
|
|
30
|
+
url: credentials.url,
|
|
31
|
+
clock: { now: deps.now },
|
|
32
|
+
fetchCatalog: createPocketBaseCatalogFetcher(client),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Refresh `catalog` against the hub and return the new snapshot (`undefined` on failure — see `CachedCatalog.refresh`). */
|
|
36
|
+
export function refreshCatalog(catalog) {
|
|
37
|
+
return catalog.refresh();
|
|
38
|
+
}
|
|
39
|
+
/** `KANKAKU_SYNC_WINDOW_HOURS`, parsed the same way as kankaku's own sync paths; defaults to 24. */
|
|
40
|
+
function windowHours(env) {
|
|
41
|
+
const value = Number(env.KANKAKU_SYNC_WINDOW_HOURS);
|
|
42
|
+
return env.KANKAKU_SYNC_WINDOW_HOURS && Number.isFinite(value) && value > 0 ? value : 24;
|
|
43
|
+
}
|
|
44
|
+
/** `KANKAKU_SYNC_PROMPT`, defaulting to `"none"`. */
|
|
45
|
+
function promptMode(env) {
|
|
46
|
+
const value = env.KANKAKU_SYNC_PROMPT;
|
|
47
|
+
return value === "truncated" || value === "full" ? value : "none";
|
|
48
|
+
}
|
|
49
|
+
/** This package's own `version`, read from its `package.json` next to `dist/adapters/hub.js` (or `src/adapters/hub.ts` under `tsx`). */
|
|
50
|
+
function packageVersion() {
|
|
51
|
+
const root = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
52
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
53
|
+
return pkg.version;
|
|
54
|
+
}
|
|
55
|
+
/** Read `<project.dir>/.kankaku` as a no-network sync status snapshot: pending count, tasks stale outside the revisit window, and the persisted `SyncState` (if any). Never touches any project other than `project`. */
|
|
56
|
+
export function computeProjectSyncStatus(project, credentials, env) {
|
|
57
|
+
const dir = join(project.dir, ".kankaku");
|
|
58
|
+
const log = new JsonlWorkLog(dir);
|
|
59
|
+
const stateStore = new SyncStateStore({ dir, pid: process.pid });
|
|
60
|
+
return computeSyncStatus(log, stateStore, credentials.url, windowHours(env));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sync one project against the hub: mirrors kankaku-claude's
|
|
64
|
+
* `sync-cli.ts#syncConfigured` (`JsonlWorkLog`, `SyncStateStore`,
|
|
65
|
+
* `PocketBaseSink` fed the refreshed catalog snapshot including `tasks`,
|
|
66
|
+
* `runSync`), but stamped with this TUI's own agent/plugin identity —
|
|
67
|
+
* `agent: "unknown"` (kankaku's `hub-entry.ts` prefers the record's own
|
|
68
|
+
* `agent` when it carries one, so this fallback only ever reaches a
|
|
69
|
+
* never-labelled legacy row) and `plugin: "kankaku-tui"`. Never touches
|
|
70
|
+
* any project other than `project`.
|
|
71
|
+
*/
|
|
72
|
+
export async function syncProject(project, credentials, options, deps) {
|
|
73
|
+
const dir = join(project.dir, ".kankaku");
|
|
74
|
+
const log = new JsonlWorkLog(dir);
|
|
75
|
+
const stateStore = new SyncStateStore({ dir, pid: process.pid, now: deps.now });
|
|
76
|
+
const catalog = createCatalog(credentials, deps);
|
|
77
|
+
// On an offline hub the cached snapshot (if any) remains usable.
|
|
78
|
+
await catalog.refresh();
|
|
79
|
+
const snapshot = catalog.read();
|
|
80
|
+
const pluginVersion = packageVersion();
|
|
81
|
+
const sink = {
|
|
82
|
+
push: async (tasks) => {
|
|
83
|
+
if (tasks.length === 0)
|
|
84
|
+
return [];
|
|
85
|
+
const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
|
|
86
|
+
return new PocketBaseSink({
|
|
87
|
+
client,
|
|
88
|
+
clients: snapshot?.clients ?? [],
|
|
89
|
+
projects: snapshot?.projects ?? [],
|
|
90
|
+
tasks: snapshot?.tasks ?? [],
|
|
91
|
+
machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? osHostname)(),
|
|
92
|
+
promptMode: promptMode(deps.env),
|
|
93
|
+
syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
|
|
94
|
+
agent: "unknown",
|
|
95
|
+
plugin: "kankaku-tui",
|
|
96
|
+
pluginVersion,
|
|
97
|
+
}).push(tasks);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return runSync({ log, sink, stateStore, clock: { now: deps.now }, target: credentials.url, windowHours: windowHours(deps.env) }, options);
|
|
101
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
function hasWorklog(dir) {
|
|
4
|
+
return existsSync(join(dir, ".kankaku", "worklog.jsonl"));
|
|
5
|
+
}
|
|
6
|
+
function childDirs(root) {
|
|
7
|
+
try {
|
|
8
|
+
return readdirSync(root)
|
|
9
|
+
.map((entry) => join(root, entry))
|
|
10
|
+
.filter((entry) => {
|
|
11
|
+
try {
|
|
12
|
+
return statSync(entry).isDirectory();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Discover projects under `roots`: a root that itself has
|
|
25
|
+
* `.kankaku/worklog.jsonl` is a project; otherwise each direct child
|
|
26
|
+
* directory with one is a project. Deduped by `dir`, sorted by `name`.
|
|
27
|
+
* Never throws on an unreadable or missing root.
|
|
28
|
+
*/
|
|
29
|
+
export function discoverProjects(roots) {
|
|
30
|
+
const byDir = new Map();
|
|
31
|
+
for (const root of roots) {
|
|
32
|
+
if (hasWorklog(root)) {
|
|
33
|
+
byDir.set(root, { name: basename(root), dir: root });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const child of childDirs(root)) {
|
|
37
|
+
if (hasWorklog(child)) {
|
|
38
|
+
byDir.set(child, { name: basename(child), dir: child });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Array.from(byDir.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function expandHome(path, homeDir) {
|
|
4
|
+
if (path === "~")
|
|
5
|
+
return homeDir;
|
|
6
|
+
if (path.startsWith("~/"))
|
|
7
|
+
return join(homeDir, path.slice(2));
|
|
8
|
+
return path;
|
|
9
|
+
}
|
|
10
|
+
function isStringArray(value) {
|
|
11
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read `<homeDir>/.kankaku/tui.json` (`{ "roots": [...] }`, `~` expanded
|
|
15
|
+
* against `homeDir`). A missing or malformed file falls back to `cwd` as
|
|
16
|
+
* the only root, so the app always has something to discover projects
|
|
17
|
+
* under.
|
|
18
|
+
*/
|
|
19
|
+
export function readTuiConfig(homeDir, cwd) {
|
|
20
|
+
const configPath = join(homeDir, ".kankaku", "tui.json");
|
|
21
|
+
if (!existsSync(configPath)) {
|
|
22
|
+
return { roots: [cwd] };
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || !isStringArray(parsed["roots"])) {
|
|
27
|
+
return { roots: [cwd] };
|
|
28
|
+
}
|
|
29
|
+
const roots = parsed.roots.map((root) => expandHome(root, homeDir));
|
|
30
|
+
return { roots };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { roots: [cwd] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { JsonlWorkLog } from "kankaku/hub";
|
|
3
|
+
/**
|
|
4
|
+
* Read every {@link WorkRecord} appended under `<project.dir>/.kankaku`,
|
|
5
|
+
* via kankaku's own `JsonlWorkLog` (which reads `<dir>/worklog.jsonl`).
|
|
6
|
+
*/
|
|
7
|
+
export function readProjectRecords(project) {
|
|
8
|
+
return new JsonlWorkLog(join(project.dir, ".kankaku")).readAll();
|
|
9
|
+
}
|