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.
- package/CHANGELOG.md +381 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.ru.md +144 -0
- package/bin/kb +306 -0
- package/bin/local-kanban +217 -0
- package/ecosystem.example.cjs +25 -0
- package/package.json +52 -0
- package/public/app.js +18 -0
- package/public/boot-fallback.js +3 -0
- package/public/drawer-sync.js +14 -0
- package/public/favicon-180.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-192.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +138 -0
- package/public/js/board.js +698 -0
- package/public/js/chaos.js +247 -0
- package/public/js/core.js +821 -0
- package/public/js/dash.js +114 -0
- package/public/js/drawer.js +919 -0
- package/public/js/horizon.js +140 -0
- package/public/js/init.js +208 -0
- package/public/js/project.js +338 -0
- package/public/js/settings.js +905 -0
- package/public/js/sidebar.js +640 -0
- package/public/js/sse.js +386 -0
- package/public/manifest.webmanifest +32 -0
- package/public/offline.html +39 -0
- package/public/style.css +1247 -0
- package/public/sw.js +31 -0
- package/scripts/raycast/open-kanban.sh +18 -0
- package/scripts/update.js +43 -0
- package/skills/deploy/SKILL.md +70 -0
- package/skills/kanban/SKILL.md +217 -0
- package/src/backup.js +111 -0
- package/src/bus.js +10 -0
- package/src/config.js +40 -0
- package/src/db.js +321 -0
- package/src/error-handler.js +11 -0
- package/src/file-snapshot.js +22 -0
- package/src/origin-guard.js +32 -0
- package/src/pm2-services.js +31 -0
- package/src/query-params.js +17 -0
- package/src/repo-base.js +23 -0
- package/src/restore.js +153 -0
- package/src/routes/dashboard.js +132 -0
- package/src/routes/events.js +30 -0
- package/src/routes/horizons.js +66 -0
- package/src/routes/projects.js +532 -0
- package/src/routes/skills.js +116 -0
- package/src/routes/system.js +285 -0
- package/src/routes/tasks.js +477 -0
- package/src/schema.sql +135 -0
- package/src/server.js +100 -0
- package/src/skills.js +119 -0
- package/src/sync/github.js +265 -0
- package/src/sync/worker.js +136 -0
- package/src/task-shape.js +29 -0
package/README.ru.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# local-kanban
|
|
2
|
+
|
|
3
|
+
[](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
|
+

|
|
10
|
+
|
|
11
|
+

|
|
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
|
+
});
|
package/bin/local-kanban
ADDED
|
@@ -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();
|