local-kanban 1.6.3

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
package/README.ru.md ADDED
@@ -0,0 +1,144 @@
1
+ # local-kanban
2
+
3
+ [![tests](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml/badge.svg)](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml)
4
+
5
+ Локальная канбан-доска для работы **в паре с Claude Code**: человек планирует и принимает задачи, Claude берёт их из очереди, делает и отчитывается — через токен-экономный CLI `kb`. Опционально зеркалит всё в GitHub (issues + Projects v2).
6
+
7
+ English version — [README.md](README.md).
8
+
9
+ ![Доска](docs/screenshot-board.png)
10
+
11
+ ![Дашборд](docs/screenshot-dashboard.png)
12
+
13
+ ## Зачем
14
+
15
+ - **Очередь для Claude, а не просто доска.** Статусы повторяют реальный цикл: `бэклог → сделать → подготовка → делаю → деплою → проверяю → готово`. Claude берёт следующую задачу через `kb take`, работает и останавливается на «Проверяю» — принимает всегда человек.
16
+ - **Локально прежде всего.** Всё живёт в одном SQLite-файле на твоей машине. GitHub-синк опционален и выключен, пока не настроишь.
17
+ - **Честные метрики.** Время считается только пока Claude реально работает; дашборд показывает streak, цикл задачи и что ждёт тебя.
18
+
19
+ ## Модель безопасности — прочитай сначала
20
+
21
+ Сервер слушает **только 127.0.0.1**, авторизации **нет**. Доска строго локальная: граница доверия — твоя машина. **Никогда не выставляй порт наружу** (никаких reverse proxy, `0.0.0.0`, проброса портов). Сторонние интеграции запускаются на той же машине и ходят на `127.0.0.1` (см. [docs/API.md](docs/API.md)).
22
+
23
+ ## Требования
24
+
25
+ - **Node.js ≥ 22** (macOS или Linux) — у большинства стоит 24 LTS или новее
26
+ - Опционально, только для GitHub-синка: [`gh` CLI](https://cli.github.com/) с правом `project`:
27
+ `gh auth login && gh auth refresh -s project`
28
+
29
+ ## Установка
30
+
31
+ ```bash
32
+ npm install -g local-kanban
33
+ local-kanban # мастер настройки
34
+ local-kanban start # доска на http://localhost:3100
35
+ ```
36
+
37
+ Мастер спросит порт, папку данных, нужен ли GitHub-синк и ставить ли в `~/.claude/skills` два скилла для Claude Code — скилл самой доски и деплой-скилл. У каждого вопроса есть ответ по умолчанию, поэтому Enter-Enter-Enter достаточно. Он же создаст `ecosystem.config.cjs` для автозапуска через [pm2](https://pm2.keymetrics.io/).
38
+
39
+ У `local-kanban start` есть `--port <N>` и `--data <путь>`. Данные по умолчанию лежат в `~/.local-kanban`, вне каталога пакета, поэтому обновление пакета никогда не трогает доску.
40
+
41
+ Из исходников:
42
+
43
+ ```bash
44
+ git clone <адрес-репозитория> local-kanban && cd local-kanban
45
+ npm install
46
+ npx local-kanban # мастер настройки (необязательно)
47
+ npm start # доска на http://localhost:3100
48
+ ```
49
+
50
+ Команда `kb` глобально: `npm link` (или `npm install -g .`).
51
+
52
+ Первый запуск покажет пустой дашборд с предложением создать **демо-проект** — один клик создаёт, один удаляет.
53
+
54
+ ### Настройку может довести сам Claude
55
+
56
+ На последнем шаге мастера первого запуска показывается готовый промпт (на языке доски). Вставь его
57
+ в Claude Code — он проверит, что работает `kb`, спросит, нужно ли зеркало в GitHub, заведёт первые
58
+ проекты с их путями и деплоем и покажет результат. Тот же промпт лежит в **Настройки → О доске**,
59
+ чтобы к нему можно было вернуться.
60
+
61
+ ### Скиллы Claude Code
62
+
63
+ В `skills/` лежат два скилла:
64
+
65
+ - `skills/kanban` — учит Claude рабочему циклу задач (`kb take` → работа → `kb review`);
66
+ - `skills/deploy` — универсальный деплой от данных реестра проектов (`kb info`).
67
+
68
+ Установка — симлинк или копия:
69
+
70
+ ```bash
71
+ ln -s "$(pwd)/skills/kanban" ~/.claude/skills/kanban
72
+ ln -s "$(pwd)/skills/deploy" ~/.claude/skills/deploy
73
+ ```
74
+
75
+ ## GitHub-синк (опционально)
76
+
77
+ Укажи owner и репозиторий для issues в **Настройки → Синхронизация** (или env `KB_GH_OWNER` / `KB_GH_REPO`). Без них доска работает в local-only режиме: без очереди и предупреждений. С ними каждая задача становится issue, а каждый проект получает GitHub Project `kb: <slug>` с теми же колонками. Синк односторонний (доска → GitHub), фоновый.
78
+
79
+ ## Обновление
80
+
81
+ Установка из npm:
82
+
83
+ ```bash
84
+ npm install -g local-kanban@latest
85
+ local-kanban skills # обновить скиллы Claude Code — они копия, а не ссылка
86
+ ```
87
+
88
+ Клон репозитория:
89
+
90
+ ```bash
91
+ npm run update # git pull + npm install + pm2 restart (если применимо)
92
+ ```
93
+
94
+ Обновления не трогают данные: `data/` в .gitignore, миграции схемы прогоняются на старте автоматически — **со снапшотом до миграции** в `data/backups/pre-migrate/`.
95
+
96
+ **Откат:** останови сервер, восстанови свежий файл из `data/backups/` (суточные) или `data/backups/pre-migrate/` поверх `data/kanban.db`, затем `git checkout <предыдущий тег>` и запусти снова.
97
+
98
+ Релизы живут в `main` (стабильное, с тегами); ежедневная разработка — в `dev`.
99
+
100
+ ## Переменные окружения
101
+
102
+ | Переменная | Дефолт | Смысл |
103
+ |---|---|---|
104
+ | `PORT` | `3100` | HTTP-порт (привязан к 127.0.0.1) |
105
+ | `KB_DATA_DIR` | `./data` | папка данных (SQLite, вложения, бэкапы) |
106
+ | `KB_LOCAL_ROOT` | `~/claude-projects` | корень локальных проектов-папок |
107
+ | `KB_URL` | `http://127.0.0.1:3100` | адрес доски для CLI `kb` |
108
+ | `KB_GH_OWNER` / `KB_GH_REPO` | — | цель GitHub-синка; пусто = синк выключен (можно задать и в Настройках) |
109
+ | `KB_PANEL_URL` | — | свой HTTP-источник статусов сервисов; пусто = локальный `pm2 jlist` |
110
+ | `KB_PANEL_INFO` | — | опциональный info.json внешней панели для синка категорий |
111
+ | `KB_SSE_MAX` | `20` | потолок одновременных SSE-соединений |
112
+ | `KB_SKILLS_EXTRA` | — | дополнительные корни скиллов через `:` |
113
+
114
+ Старые имена `PANEL_URL` и `PANEL_INFO` (без префикса) продолжают работать как запасные.
115
+
116
+ ## Метрики и метка `noclaude`
117
+
118
+ Задачи, сделанные **руками** (без Claude), помечаются меткой `noclaude` — они считаются на дашборде отдельно и не подмешиваются в метрики Claude (время работы, цикл, доведено %), которые иначе исказились бы нулевым временем. Метка необязательна, можно ставить позже. Ручным задачам разрешён прямой переход в «Проверяю»/«Готово».
119
+
120
+ ## Расширение
121
+
122
+ Полный HTTP API и SSE-поток событий — официальная точка расширения: боты, статистика и интеграции пишутся отдельными программами, без плагинов внутри страницы. См. [docs/API.md](docs/API.md).
123
+
124
+ ## Разработка
125
+
126
+ ```bash
127
+ node --test # БД изолируется через KB_DATA_DIR
128
+ ```
129
+
130
+ Дев-окружение, флоу веток и правило миграций — в [CONTRIBUTING.md](CONTRIBUTING.md). Архитектура — в [ARCHITECTURE.md](ARCHITECTURE.md), что менялось между версиями — в [CHANGELOG.md](CHANGELOG.md).
131
+
132
+ ## Язык интерфейса
133
+
134
+ Исходный язык интерфейса — английский, русский приходит переводом. Доска следует языку системы и переключается в **Настройки → Основные**; строка без перевода остаётся английской, а не утекает в чужую локаль.
135
+
136
+ ## Использование в работе
137
+
138
+ Доска под лицензией MIT и бесплатна для всех: ничего не отключено и нечего активировать. Если она
139
+ зарабатывает тебе деньги — в компании или на платных заказах — $12 в год за человека помогают её
140
+ поддерживать. Это просьба, а не правило: [docs/COMMERCIAL.md](docs/COMMERCIAL.md).
141
+
142
+ ## Лицензия
143
+
144
+ [MIT](LICENSE).
package/bin/kb ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ // kb — the token-efficient CLI for the kanban board (localhost:3100).
3
+ // Tasks are addressed by key (TIC-3) or by global id. One line of output per action.
4
+
5
+ import { parsePm2Services } from '../src/pm2-services.js';
6
+
7
+ const BASE = process.env.KB_URL || 'http://127.0.0.1:3100';
8
+ const PRI = { 0: '', 1: 'p1', 2: 'p2', 3: 'p3' };
9
+ const PRI_BY_NAME = { none: 0, low: 1, med: 2, medium: 2, high: 3, p0: 0, p1: 1, p2: 2, p3: 3 };
10
+ const ST_ALIAS = {
11
+ b: 'backlog', backlog: 'backlog', t: 'todo', todo: 'todo',
12
+ pp: 'prep', prep: 'prep', d: 'doing', doing: 'doing',
13
+ dp: 'deploy', deploy: 'deploy', r: 'review', review: 'review', dn: 'done', done: 'done',
14
+ c: 'cancelled', cancelled: 'cancelled', cancel: 'cancelled',
15
+ };
16
+
17
+ // Exit codes are a contract for automation (scripts gate their moves on them):
18
+ // 0 — success, 1 — the board is not responding, 2 — the request was rejected (4xx or an
19
+ // unknown command), 3 — kb itself broke, 4 — the board answered, but with a failure of its
20
+ // own (5xx) or with something that is not JSON. Previously an exception exited with 1 as well,
21
+ // so "the board is down" was indistinguishable from "kb is broken".
22
+ // Code 4 exists because 2 was answering two different questions at once. A live board that
23
+ // answers with an honest 500 (a broken row in the database) used to exit with the SAME code as
24
+ // a lawful refusal of a transition — there was no way to tell "the board falls over on my
25
+ // request" from "you are not allowed to do that". Worse, a 200 whose body is not JSON made
26
+ // `kb mv` print "undefined undefined" and exit 0: it reported a move that never happened, and
27
+ // that is exactly what someone whose KB_URL points at a different port would see (several
28
+ // ports are live on this machine).
29
+ const EXIT = { DOWN: 1, REJECTED: 2, INTERNAL: 3, BOARD_ERROR: 4 };
30
+
31
+ function die(msg, code = EXIT.REJECTED) { process.stderr.write(msg + '\n'); process.exit(code); }
32
+
33
+ async function api(method, path, body) {
34
+ let res;
35
+ try {
36
+ res = await fetch(BASE + path, {
37
+ method,
38
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
39
+ body: body ? JSON.stringify(body) : undefined,
40
+ });
41
+ } catch {
42
+ die('kb: board is not responding — pm2 restart kanban', EXIT.DOWN);
43
+ }
44
+ if (res.status === 204) return null;
45
+ const text = await res.text();
46
+ let data = null;
47
+ let parsed = false;
48
+ try { data = JSON.parse(text); parsed = true; } catch { /* not JSON — see below */ }
49
+ if (!res.ok) {
50
+ const detail = parsed ? (data?.error || res.status) : `${res.status} ${text.slice(0, 200)}`;
51
+ // 5xx is the board failing on us, not us asking for something forbidden — its own code.
52
+ die(`kb: ${detail}`, res.status >= 500 ? EXIT.BOARD_ERROR : EXIT.REJECTED);
53
+ }
54
+ if (!parsed) {
55
+ die(`kb: the board answered ${res.status} with something that is not JSON — is KB_URL (${BASE}) the board?`, EXIT.BOARD_ERROR);
56
+ }
57
+ return data;
58
+ }
59
+
60
+ function taskLine(t) {
61
+ const flags = [t.blocked ? '!' : '', PRI[t.priority] || ''].filter(Boolean).join(' ');
62
+ const labels = (t.labels || []).length ? ` [${t.labels.join(',')}]` : '';
63
+ return `${t.key} ${t.status}${flags ? ' ' + flags : ''} ${t.title}${labels}`;
64
+ }
65
+
66
+ const [cmd, ...args] = process.argv.slice(2);
67
+
68
+ const commands = {
69
+ async p() {
70
+ const ps = await api('GET', '/api/projects');
71
+ if (!ps.length) return console.log('no projects');
72
+ for (const p of ps) {
73
+ console.log(`${p.slug} (${p.prefix}) b${p.c_backlog || 0} t${p.c_todo || 0} pp${p.c_prep || 0} d${p.c_doing || 0} dp${p.c_deploy || 0} r${p.c_review || 0} ✓${p.c_done || 0}${p.c_blocked ? ` !${p.c_blocked}` : ''}`);
74
+ }
75
+ },
76
+
77
+ async ls() {
78
+ const [proj] = args.filter((a) => !a.startsWith('-'));
79
+ if (!proj) die('kb ls <project> [-s status] [-a]');
80
+ const si = args.indexOf('-s');
81
+ const q = new URLSearchParams({ project: proj });
82
+ if (si >= 0) q.set('status', ST_ALIAS[args[si + 1]] || args[si + 1]);
83
+ if (args.includes('-a')) q.set('all', '1');
84
+ const ts = await api('GET', `/api/tasks?${q}`);
85
+ if (!ts.length) return console.log('empty');
86
+ for (const t of ts) console.log(taskLine(t));
87
+ },
88
+
89
+ async next() {
90
+ const [proj] = args;
91
+ if (!proj) die('kb next <project>');
92
+ const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
93
+ const free = ts.filter((t) => !t.blocked);
94
+ console.log(free.length ? taskLine(free[0]) : 'empty');
95
+ },
96
+
97
+ // take the next task and put it straight into prep (study before code); prints the last
98
+ // comment from the human (notes from a send-back). Writing code → kb mv <key> doing.
99
+ async take() {
100
+ const [proj] = args;
101
+ if (!proj) die('kb take <project>');
102
+ const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
103
+ const free = ts.filter((t) => !t.blocked);
104
+ if (!free.length) return console.log('empty');
105
+ const t = await api('PATCH', `/api/tasks/${free[0].id}`, { status: 'prep' });
106
+ console.log(taskLine({ ...t, labels: t.labels }));
107
+ if (t.description) console.log(t.description);
108
+ if (t.gh_issue_number) console.log(`issue: ${t.gh_issue_number}`);
109
+ const atts = await api('GET', `/api/tasks/${t.id}/attachments`);
110
+ for (const a of atts) console.log(`📎 screenshot (open it with Read): ${a.path}`);
111
+ const cs = await api('GET', `/api/tasks/${t.id}/comments`);
112
+ const lastHuman = cs.filter((c) => c.author === 'me' && c.body).pop();
113
+ if (lastHuman) console.log(`> 👤 ${lastHuman.body}`);
114
+ // comments often carry corrections — print the paths of the latest images so they get opened
115
+ for (const c of cs.filter((c) => c.image_path).slice(-3)) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
116
+ },
117
+
118
+ // leave a note and move to review in one command
119
+ async review() {
120
+ const [id, note] = args;
121
+ if (!id || !note) die('kb review <key> "note"');
122
+ await api('POST', `/api/tasks/${id}/comments`, { body: note, author: 'claude' });
123
+ const t = await api('PATCH', `/api/tasks/${id}`, { status: 'review' });
124
+ console.log(`${t.key} review 📝`);
125
+ },
126
+
127
+ async show() {
128
+ const [id] = args;
129
+ if (!id) die('kb show <key>');
130
+ const t = await api('GET', `/api/tasks/${id}`);
131
+ console.log(`${t.key} ${t.project} ${t.status}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
132
+ console.log(t.title);
133
+ if (t.description) console.log(t.description);
134
+ if (t.gh_issue_url) console.log(`issue: ${t.gh_issue_url}`);
135
+ for (const a of (t.attachments || [])) console.log(`📎 screenshot (open it with Read): ${a.path}`);
136
+ const cs = await api('GET', `/api/tasks/${id}/comments`);
137
+ for (const c of cs.slice(-5)) {
138
+ if (c.body) console.log(`> ${c.author === 'claude' ? '🤖' : '👤'} ${c.body}`);
139
+ if (c.image_path) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
140
+ }
141
+ },
142
+
143
+ async add() {
144
+ const flagVals = new Set();
145
+ ['-d', '-p', '-s'].forEach((f) => { const i = args.indexOf(f); if (i >= 0) flagVals.add(i + 1); });
146
+ const pos = args.filter((a, i) => !a.startsWith('-') && !flagVals.has(i));
147
+ const [proj, title] = pos;
148
+ if (!proj || !title) die('kb add <project> "title" [-d "description"] [-p high|med|low] [-s todo]');
149
+ const di = args.indexOf('-d');
150
+ const pi = args.indexOf('-p');
151
+ const si = args.indexOf('-s');
152
+ const body = { project: proj, title };
153
+ if (di >= 0) body.description = args[di + 1] || '';
154
+ if (pi >= 0) {
155
+ const pr = PRI_BY_NAME[args[pi + 1]];
156
+ if (pr === undefined) die('kb: priority high|med|low|none');
157
+ body.priority = pr;
158
+ }
159
+ if (si >= 0) {
160
+ body.status = ST_ALIAS[args[si + 1]];
161
+ if (!body.status) die('kb: status backlog|todo|prep|doing|deploy|review|done');
162
+ }
163
+ const t = await api('POST', '/api/tasks', body);
164
+ console.log(`${t.key} ${t.status} ${t.project}`);
165
+ },
166
+
167
+ async mv() {
168
+ const [id, st] = args;
169
+ const status = ST_ALIAS[st];
170
+ if (!id || !status) die('kb mv <key> backlog|todo|prep|doing|deploy|review|done|cancelled');
171
+ const t = await api('PATCH', `/api/tasks/${id}`, { status });
172
+ console.log(`${t.key} ${t.status}`);
173
+ },
174
+
175
+ async done() {
176
+ const [id] = args;
177
+ if (!id) die('kb done <key>');
178
+ const t = await api('PATCH', `/api/tasks/${id}`, { status: 'done' });
179
+ console.log(`${t.key} done`);
180
+ },
181
+
182
+ async cancel() {
183
+ const [id] = args;
184
+ if (!id) die('kb cancel <key>');
185
+ const t = await api('PATCH', `/api/tasks/${id}`, { status: 'cancelled' });
186
+ console.log(`${t.key} cancelled`);
187
+ },
188
+
189
+ async pri() {
190
+ const [id, p] = args;
191
+ const pr = PRI_BY_NAME[p];
192
+ if (!id || pr === undefined) die('kb pri <key> high|med|low|none');
193
+ const t = await api('PATCH', `/api/tasks/${id}`, { priority: pr });
194
+ console.log(`${t.key} ${PRI[t.priority] || 'p0'}`);
195
+ },
196
+
197
+ async lbl() {
198
+ const [id, ...ops] = args;
199
+ if (!id || !ops.length) die('kb lbl <key> +bug -ui (+ to add, - to remove)');
200
+ const t = await api('GET', `/api/tasks/${id}`);
201
+ const labels = new Set(t.labels);
202
+ for (const op of ops) {
203
+ if (op.startsWith('+')) labels.add(op.slice(1));
204
+ else if (op.startsWith('-')) labels.delete(op.slice(1));
205
+ }
206
+ const fresh = await api('PATCH', `/api/tasks/${id}`, { labels: [...labels] });
207
+ console.log(`${fresh.key} [${fresh.labels.join(',')}]`);
208
+ },
209
+
210
+ async desc() {
211
+ const [id, text] = args;
212
+ if (!id || text === undefined) die('kb desc <key> "description" (set or overwrite the description)');
213
+ const t = await api('PATCH', `/api/tasks/${id}`, { description: text });
214
+ console.log(`${t.key} 📝 description updated`);
215
+ },
216
+
217
+ async note() {
218
+ const [id, body] = args;
219
+ if (!id || !body) die('kb note <key> "text"');
220
+ await api('POST', `/api/tasks/${id}/comments`, { body, author: 'claude' });
221
+ const t = await api('GET', `/api/tasks/${id}`);
222
+ console.log(`${t.key} 📝`);
223
+ },
224
+
225
+ async link() {
226
+ const [id, other] = args;
227
+ if (!id || !other) die('kb link <key> <key> (link two tasks)');
228
+ const links = await api('POST', `/api/tasks/${id}/links`, { key: other });
229
+ console.log(`${id} ↔ ${links.map((l) => l.key).join(', ') || '—'}`);
230
+ },
231
+
232
+ async unlink() {
233
+ const [id, other] = args;
234
+ if (!id || !other) die('kb unlink <key> <key>');
235
+ const links = await api('DELETE', `/api/tasks/${id}/links/${other}`);
236
+ console.log(`${id} ↔ ${links.map((l) => l.key).join(', ') || '—'}`);
237
+ },
238
+
239
+ async block() {
240
+ const [id, reason] = args;
241
+ if (!id) die('kb block <key> ["reason"]');
242
+ const t = await api('PATCH', `/api/tasks/${id}`, { blocked: true, blocked_reason: reason || '' });
243
+ console.log(`${t.key} blocked${t.blocked_reason ? ': ' + t.blocked_reason : ''}`);
244
+ },
245
+
246
+ async unblock() {
247
+ const [id] = args;
248
+ if (!id) die('kb unblock <key>');
249
+ const t = await api('PATCH', `/api/tasks/${id}`, { blocked: false });
250
+ console.log(`${t.key} unblocked`);
251
+ },
252
+
253
+ async rm() {
254
+ const [id] = args;
255
+ if (!id) die('kb rm <key>');
256
+ await api('DELETE', `/api/tasks/${id}`);
257
+ console.log(`${id} deleted`);
258
+ },
259
+
260
+ async info() {
261
+ const [proj] = args;
262
+ if (!proj) die('kb info <project>');
263
+ const p = await api('GET', `/api/projects/${proj}`);
264
+ const out = [`${p.slug} (${p.prefix}): ${p.name}`];
265
+ if (p.path) out.push(`path: ${p.path}`);
266
+ if (p.deploy_skill) out.push(`deploy_skill: ${p.deploy_skill}${p.deploy_skill_path ? ` (${p.deploy_skill_path})` : ''}`);
267
+ if (p.server) out.push(`server: ${p.server}${p.server_path ? ':' + p.server_path : ''}`);
268
+ const services = parsePm2Services(p.pm2_services);
269
+ if (services.length) out.push(`pm2: ${services.join(', ')}`);
270
+ if (p.domain) out.push(`domain: ${p.domain}`);
271
+ console.log(out.join('\n'));
272
+ },
273
+
274
+ async svc() {
275
+ const [proj] = args;
276
+ if (!proj) die('kb svc <project>');
277
+ const list = await api('GET', `/api/projects/${proj}/status`);
278
+ for (const s of list) {
279
+ if (s.status === 'not found') { console.log(`${s.name}: not found in pm2`); continue; }
280
+ console.log(`${s.name}: ${s.status} | up ${s.uptime_h}h | rst ${s.restarts} | ${s.mem_mb}MB | cpu ${s.cpu}%`);
281
+ }
282
+ },
283
+
284
+ async sync() {
285
+ const s = await api('GET', '/api/sync');
286
+ if (s.configured === false) { console.log('sync is off (local-only) — set owner/repo in the board settings or via KB_GH_OWNER/KB_GH_REPO'); return; }
287
+ let line = `queue:${s.pending}`;
288
+ if (s.failed.length) line += ` failed:${s.failed.length} (${s.failed[0].op}: ${(s.failed[0].last_error || '').slice(0, 80)})`;
289
+ if (s.gh === false) line += ' [gh unavailable: gh auth login && gh auth refresh -s project]';
290
+ console.log(line);
291
+ },
292
+
293
+ async retry() {
294
+ await api('POST', '/api/sync/retry');
295
+ console.log('ok');
296
+ },
297
+ };
298
+
299
+ if (!cmd || !commands[cmd]) {
300
+ die('kb p|ls|next|take|show|add|mv|review|done|cancel|pri|lbl|desc|note|link|unlink|block|unblock|rm|info|svc|sync|retry');
301
+ }
302
+ commands[cmd]().catch((e) => {
303
+ // die() inside a command already exited the process — only real exceptions reach here.
304
+ if (process.env.KB_DEBUG) process.stderr.write((e && e.stack) || String(e));
305
+ die(`kb: internal error — ${(e && e.message) || e} (stack trace: KB_DEBUG=1)`, EXIT.INTERNAL);
306
+ });
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // The install and setup wizard. `npx local-kanban` is the only command a newcomer needs to
3
+ // know: Enter-Enter-Enter and the board is running on http://localhost:3100.
4
+ // Principles:
5
+ // - zero dependencies (the project has three in total; pulling in a prompts library for a
6
+ // wizard is not worth it);
7
+ // - EVERY question has a default, so the wizard can be walked through blind;
8
+ // - without a TTY (a pipe, CI, `npx … < /dev/null`) no questions are asked at all and the
9
+ // defaults are used — otherwise the install would silently hang waiting for an answer;
10
+ // - nothing happens quietly: every step prints what it wrote and where.
11
+ import { createInterface } from 'node:readline/promises';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync, lstatSync, rmSync, writeFileSync, mkdirSync, symlinkSync, cpSync, readFileSync } from 'node:fs';
14
+ import { join, dirname, resolve, sep } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { homedir } from 'node:os';
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ // The binary has subcommands. Without one it runs the wizard; `start` runs the board.
21
+ // `npm start` is unavailable from an installed package — package.json scripts only exist in a
22
+ // clone of the repository — yet the wizard used to recommend exactly that, which left nothing
23
+ // to start the board with after `npx local-kanban`.
24
+ const argv = process.argv.slice(2).filter((a) => a !== '--yes');
25
+ const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : '';
26
+ const flag = (name) => {
27
+ const i = argv.indexOf(`--${name}`);
28
+ if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('-')) return argv[i + 1];
29
+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
30
+ return eq ? eq.slice(name.length + 3) : '';
31
+ };
32
+
33
+ // code inside node_modules means an npm/npx install: no package.json scripts and no git there,
34
+ // so the advice at the end of the wizard has to be different
35
+ const packaged = ROOT.split(sep).includes('node_modules');
36
+
37
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.argv.includes('--yes') && !cmd;
38
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
39
+
40
+ const say = (s = '') => process.stdout.write(s + '\n');
41
+ const done = (s) => say(` ✓ ${s}`);
42
+ const skip = (s) => say(` · ${s}`);
43
+
44
+ async function ask(question, def) {
45
+ if (!interactive) return def;
46
+ const a = (await rl.question(`${question} [${def}]: `)).trim();
47
+ return a || def;
48
+ }
49
+ async function confirm(question, def = true) {
50
+ if (!interactive) return def;
51
+ const a = (await rl.question(`${question} [${def ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase();
52
+ if (!a) return def;
53
+ return ['y', 'yes'].includes(a);
54
+ }
55
+
56
+ function ghReady() {
57
+ // the sync needs more than authentication: without the project scope create_issue succeeds
58
+ // but the card never lands on the Projects v2 board, and the user hunts for the cause
59
+ try {
60
+ const out = execFileSync('gh', ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
61
+ return { ok: true, project: /\bproject\b/.test(out) };
62
+ } catch {
63
+ return { ok: false, project: false };
64
+ }
65
+ }
66
+
67
+ const usage = () => say(`
68
+ local-kanban install and setup wizard
69
+ local-kanban start run the board
70
+ --port <N> port (defaults to 3100, or PORT)
71
+ --data <dir> data directory (defaults to whatever the board resolves on its own)
72
+ local-kanban skills refresh the Claude Code skills in ~/.claude/skills
73
+ local-kanban help this help
74
+ `);
75
+
76
+ function installSkills({ force = false, quiet = false } = {}) {
77
+ const skillsDir = join(homedir(), '.claude', 'skills');
78
+ mkdirSync(skillsDir, { recursive: true });
79
+ for (const name of ['kanban', 'deploy']) {
80
+ const target = join(skillsDir, name);
81
+ const source = join(ROOT, 'skills', name);
82
+ const linked = existsSync(target) && lstatSync(target).isSymbolicLink();
83
+ if (existsSync(target) && !force) { if (!quiet) skip(`${target} already exists — leaving it alone`); continue; }
84
+ // A clone links, so edits to the skill apply immediately and there is nothing to refresh.
85
+ if (linked) { if (!quiet) skip(`${target} is a symlink — nothing to refresh`); continue; }
86
+ try {
87
+ // from a package this is a COPY. A symlink would point inside node_modules, and updating or
88
+ // removing the package would silently take the skill away from Claude.
89
+ if (packaged || force) {
90
+ rmSync(target, { recursive: true, force: true });
91
+ cpSync(source, target, { recursive: true });
92
+ if (!quiet) done(`${target} (copy)`);
93
+ } else { symlinkSync(source, target); if (!quiet) done(`${target} → skills/${name}`); }
94
+ } catch (e) { skip(`could not install the skill ${target}: ${e.message}`); }
95
+ }
96
+ }
97
+
98
+ if (cmd === 'skills') {
99
+ installSkills({ force: true });
100
+ say('\n The skills in ~/.claude/skills now match this version of the package.');
101
+ process.exit(0);
102
+ }
103
+
104
+ if (cmd === 'start') {
105
+ if (flag('port')) process.env.PORT = flag('port');
106
+ if (flag('data')) process.env.KB_DATA_DIR = resolve(flag('data'));
107
+ // the server prints its address and backup path itself; the data directory is resolved by config.js
108
+ await import(pathToFileURL(join(ROOT, 'src', 'server.js')).href);
109
+ // the wizard follows below and must not run under `start`; the listening socket holds the process
110
+ await new Promise(() => {});
111
+ }
112
+
113
+ if (cmd === 'help' || argv.includes('--help') || argv.includes('-h')) { usage(); process.exit(0); }
114
+ if (cmd && cmd !== 'setup') {
115
+ say(`\n unknown command: ${cmd}`);
116
+ usage();
117
+ process.exit(2);
118
+ }
119
+
120
+ say('\n local-kanban — install\n');
121
+
122
+ /* ── 1. port and data directory ─────────────────────────────────────────── */
123
+ // imported here rather than at the top: a static import would run BEFORE the `start` branch,
124
+ // which sets KB_DATA_DIR from --data, and the server would receive a stale cached value
125
+ const { DATA_DIR, expandPath } = await import(pathToFileURL(join(ROOT, 'src', 'config.js')).href);
126
+ const port = await ask('Board port (listens on 127.0.0.1 only)', '3100');
127
+ // the default comes from config.js, the single place that decides where data lives — otherwise
128
+ // the wizard would offer a directory inside the package and the board would open a different one
129
+ // A tilde or a relative path is expanded here, and the result is shown back: the shell only
130
+ // expands `~` when it is unquoted, and nothing expands it inside a pm2 config file. Writing the
131
+ // answer down verbatim used to create a real directory named `~` next to wherever the board was
132
+ // started from, and the board then died with a raw stack from @fastify/static.
133
+ const dataAnswer = await ask('Data directory (SQLite, attachments, backups)', DATA_DIR);
134
+ const dataDir = expandPath(dataAnswer);
135
+ if (dataDir !== dataAnswer) say(` → ${dataDir}`);
136
+
137
+ /* ── 2. GitHub sync ─────────────────────────────────────────────────────── */
138
+ let ghOwner = '';
139
+ let ghRepo = '';
140
+ say('\n GitHub sync is optional: without it the board is fully functional, just local-only.');
141
+ if (await confirm(' Mirror tasks to GitHub?', false)) {
142
+ const gh = ghReady();
143
+ if (!gh.ok) {
144
+ skip('gh is not authenticated — skipping. Later: gh auth login && gh auth refresh -s project,');
145
+ skip(' then set owner/repo under Settings → Sync on the board');
146
+ } else {
147
+ if (!gh.project) skip('the token has no project scope — run: gh auth refresh -s project');
148
+ ghOwner = await ask(' GitHub owner', '');
149
+ ghRepo = await ask(' Repository for issues (owner/repo)', ghOwner ? `${ghOwner}/tasks` : '');
150
+ }
151
+ }
152
+
153
+ /* ── 3. pm2 config ──────────────────────────────────────────────────────── */
154
+ // for an npm install the package directory belongs to the package manager, so a config written
155
+ // there lives until the first update. Keep it next to the data, which does survive updates.
156
+ const cfgDir = packaged ? dataDir : ROOT;
157
+ const cfgPath = join(cfgDir, 'ecosystem.config.cjs');
158
+ say('');
159
+ if (existsSync(cfgPath)) {
160
+ skip(`${cfgPath} already exists — leaving it alone`);
161
+ } else if (await confirm('Create ecosystem.config.cjs for autostart under pm2?', true)) {
162
+ mkdirSync(cfgDir, { recursive: true });
163
+ const env = [
164
+ ` PORT: '${port}',`,
165
+ " NODE_ENV: 'production',",
166
+ ` KB_DATA_DIR: '${dataDir}',`,
167
+ ghOwner ? ` KB_GH_OWNER: '${ghOwner}',` : null,
168
+ ghRepo ? ` KB_GH_REPO: '${ghRepo}',` : null,
169
+ ].filter(Boolean).join('\n');
170
+ writeFileSync(cfgPath, `// Created by the local-kanban wizard. Git-ignored, so local paths never leave your machine.
171
+ module.exports = {
172
+ apps: [
173
+ {
174
+ name: 'kanban',
175
+ script: 'src/server.js',
176
+ cwd: '${ROOT}',
177
+ env: {
178
+ ${env}
179
+ },
180
+ max_memory_restart: '200M',
181
+ merge_logs: true,
182
+ },
183
+ ],
184
+ };
185
+ `);
186
+ done(`wrote ${cfgPath}`);
187
+ say(` start it: pm2 start ${cfgPath} && pm2 save`);
188
+ say(' start on login: pm2 startup (prints a command you then have to run)');
189
+ } else {
190
+ skip(`without pm2 — start it manually: ${packaged ? 'local-kanban start' : 'npm start'}`);
191
+ }
192
+
193
+ /* ── 4. Claude Code skills ──────────────────────────────────────────────── */
194
+ say('');
195
+ if (await confirm('Install the Claude Code skills (kanban, deploy) into ~/.claude/skills?', true)) {
196
+ installSkills();
197
+ if (packaged) say(' after an update, refresh them: local-kanban skills');
198
+ } else {
199
+ skip('skills not installed — later: ln -s "$(pwd)/skills/kanban" ~/.claude/skills/kanban');
200
+ }
201
+
202
+ /* ── 5. the kb command ──────────────────────────────────────────────────── */
203
+ say('');
204
+ say(packaged
205
+ ? ' The kb command (the one Claude calls) is already installed with the package.'
206
+ : ' The kb command (the one Claude calls): npm link — or npm install -g .');
207
+ if (port !== '3100') say(` Non-default port, so kb needs the address: export KB_URL=http://127.0.0.1:${port}`);
208
+
209
+ /* ── 6. first run ───────────────────────────────────────────────────────── */
210
+ say('');
211
+ say(` Done. The board will be at http://localhost:${port}`);
212
+ say(' The first run shows a wizard: language, sync, demo project.');
213
+ // the most useful thing at the end is not a list of commands but a ready prompt for Claude;
214
+ // worth mentioning before the user even opens the browser
215
+ say(' It ends with a ready prompt for Claude — it finishes the setup and asks what it needs.');
216
+ say(` Start it now: ${packaged ? 'local-kanban start' : 'npm start'}\n`);
217
+ rl?.close();