@kaban-board/cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +980 -36
- package/dist/kaban-hook +14 -0
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import {
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { Command as Command12 } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/commands/add.ts
|
|
7
8
|
import { KabanError } from "@kaban-board/core";
|
|
@@ -127,19 +128,575 @@ var doneCommand = new Command2("done").description("Mark a task as done").argume
|
|
|
127
128
|
}
|
|
128
129
|
});
|
|
129
130
|
|
|
130
|
-
// src/commands/
|
|
131
|
-
import {
|
|
132
|
-
import {
|
|
131
|
+
// src/commands/hook.ts
|
|
132
|
+
import { spawn } from "node:child_process";
|
|
133
|
+
import { existsSync as existsSync3, realpathSync } from "node:fs";
|
|
134
|
+
import { chmod, copyFile as copyFile2, mkdir, readFile as readFile2, stat, unlink } from "node:fs/promises";
|
|
135
|
+
import { homedir as homedir2 } from "node:os";
|
|
136
|
+
import { dirname, join as join3 } from "node:path";
|
|
137
|
+
import * as p from "@clack/prompts";
|
|
138
|
+
import chalk from "chalk";
|
|
133
139
|
import { Command as Command3 } from "commander";
|
|
134
|
-
|
|
140
|
+
|
|
141
|
+
// src/hook/settings-manager.ts
|
|
142
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
143
|
+
import { copyFile, readFile, writeFile } from "node:fs/promises";
|
|
144
|
+
import { homedir } from "node:os";
|
|
145
|
+
import { join as join2 } from "node:path";
|
|
146
|
+
|
|
147
|
+
// src/hook/schemas.ts
|
|
148
|
+
import { z } from "zod";
|
|
149
|
+
var TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
|
|
150
|
+
var TodoPrioritySchema = z.enum(["high", "medium", "low"]);
|
|
151
|
+
var TodoItemSchema = z.object({
|
|
152
|
+
id: z.string().min(1),
|
|
153
|
+
content: z.string().min(1).max(500),
|
|
154
|
+
status: TodoStatusSchema,
|
|
155
|
+
priority: TodoPrioritySchema
|
|
156
|
+
});
|
|
157
|
+
var TodoWriteInputSchema = z.object({
|
|
158
|
+
todos: z.array(TodoItemSchema)
|
|
159
|
+
});
|
|
160
|
+
var HookInputSchema = z.object({
|
|
161
|
+
session_id: z.string(),
|
|
162
|
+
transcript_path: z.string(),
|
|
163
|
+
cwd: z.string(),
|
|
164
|
+
permission_mode: z.string(),
|
|
165
|
+
hook_event_name: z.literal("PostToolUse"),
|
|
166
|
+
tool_name: z.string(),
|
|
167
|
+
tool_input: z.unknown(),
|
|
168
|
+
tool_response: z.unknown().optional(),
|
|
169
|
+
tool_use_id: z.string()
|
|
170
|
+
});
|
|
171
|
+
var TodoWriteHookInputSchema = HookInputSchema.extend({
|
|
172
|
+
tool_name: z.literal("TodoWrite"),
|
|
173
|
+
tool_input: TodoWriteInputSchema
|
|
174
|
+
});
|
|
175
|
+
var HookCommandSchema = z.object({
|
|
176
|
+
type: z.literal("command"),
|
|
177
|
+
command: z.string(),
|
|
178
|
+
timeout: z.number().optional()
|
|
179
|
+
});
|
|
180
|
+
var HookEntrySchema = z.object({
|
|
181
|
+
matcher: z.string(),
|
|
182
|
+
hooks: z.array(HookCommandSchema),
|
|
183
|
+
description: z.string().optional()
|
|
184
|
+
});
|
|
185
|
+
var HooksConfigSchema = z.object({
|
|
186
|
+
PostToolUse: z.array(HookEntrySchema).optional(),
|
|
187
|
+
PreToolUse: z.array(HookEntrySchema).optional(),
|
|
188
|
+
Notification: z.array(HookEntrySchema).optional(),
|
|
189
|
+
Stop: z.array(HookEntrySchema).optional()
|
|
190
|
+
});
|
|
191
|
+
var ClaudeSettingsSchema = z.object({
|
|
192
|
+
hooks: HooksConfigSchema.optional()
|
|
193
|
+
}).passthrough();
|
|
194
|
+
var TODOWRITE_HOOK_ENTRY = {
|
|
195
|
+
matcher: "TodoWrite",
|
|
196
|
+
hooks: [
|
|
197
|
+
{
|
|
198
|
+
type: "command",
|
|
199
|
+
command: "~/.claude/hooks/kaban-hook",
|
|
200
|
+
timeout: 10
|
|
201
|
+
}
|
|
202
|
+
],
|
|
203
|
+
description: "Auto-sync TodoWrite changes to Kaban board"
|
|
204
|
+
};
|
|
205
|
+
var SyncConfigSchema = z.object({
|
|
206
|
+
conflictStrategy: z.enum(["todowrite_wins", "status_priority", "kaban_wins"]).default("status_priority"),
|
|
207
|
+
deletionPolicy: z.enum(["preserve", "archive", "delete"]).default("preserve"),
|
|
208
|
+
cancelledPolicy: z.enum(["skip", "backlog"]).default("skip"),
|
|
209
|
+
syncCooldownMs: z.number().min(0).max(5000).default(200),
|
|
210
|
+
maxTitleLength: z.number().min(50).max(1000).default(200),
|
|
211
|
+
logEnabled: z.boolean().default(true),
|
|
212
|
+
logPath: z.string().default("~/.claude/hooks/sync.log")
|
|
213
|
+
});
|
|
214
|
+
var DEFAULT_CONFIG = {
|
|
215
|
+
conflictStrategy: "status_priority",
|
|
216
|
+
deletionPolicy: "preserve",
|
|
217
|
+
cancelledPolicy: "skip",
|
|
218
|
+
syncCooldownMs: 200,
|
|
219
|
+
maxTitleLength: 200,
|
|
220
|
+
logEnabled: true,
|
|
221
|
+
logPath: "~/.claude/hooks/sync.log"
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/hook/settings-manager.ts
|
|
225
|
+
var SETTINGS_PATH = join2(homedir(), ".claude", "settings.json");
|
|
226
|
+
|
|
227
|
+
class SettingsManager {
|
|
228
|
+
settingsPath;
|
|
229
|
+
constructor(settingsPath = SETTINGS_PATH) {
|
|
230
|
+
this.settingsPath = settingsPath;
|
|
231
|
+
}
|
|
232
|
+
async read() {
|
|
233
|
+
if (!existsSync2(this.settingsPath)) {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
const content = await readFile(this.settingsPath, "utf-8");
|
|
237
|
+
const parsed = JSON.parse(content);
|
|
238
|
+
return ClaudeSettingsSchema.parse(parsed);
|
|
239
|
+
}
|
|
240
|
+
async write(settings) {
|
|
241
|
+
const content = JSON.stringify(settings, null, 2);
|
|
242
|
+
await writeFile(this.settingsPath, `${content}
|
|
243
|
+
`);
|
|
244
|
+
}
|
|
245
|
+
async backup() {
|
|
246
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
247
|
+
const backupPath = `${this.settingsPath}.backup-${timestamp}`;
|
|
248
|
+
await copyFile(this.settingsPath, backupPath);
|
|
249
|
+
return backupPath;
|
|
250
|
+
}
|
|
251
|
+
async addHook() {
|
|
252
|
+
const settings = await this.read();
|
|
253
|
+
if (this.hasHook(settings)) {
|
|
254
|
+
return { added: false };
|
|
255
|
+
}
|
|
256
|
+
let backupPath;
|
|
257
|
+
if (existsSync2(this.settingsPath)) {
|
|
258
|
+
backupPath = await this.backup();
|
|
259
|
+
}
|
|
260
|
+
if (!settings.hooks) {
|
|
261
|
+
settings.hooks = {};
|
|
262
|
+
}
|
|
263
|
+
if (!settings.hooks.PostToolUse) {
|
|
264
|
+
settings.hooks.PostToolUse = [];
|
|
265
|
+
}
|
|
266
|
+
settings.hooks.PostToolUse.push(TODOWRITE_HOOK_ENTRY);
|
|
267
|
+
await this.write(settings);
|
|
268
|
+
return { added: true, backupPath };
|
|
269
|
+
}
|
|
270
|
+
async removeHook() {
|
|
271
|
+
const settings = await this.read();
|
|
272
|
+
if (!this.hasHook(settings)) {
|
|
273
|
+
return { removed: false };
|
|
274
|
+
}
|
|
275
|
+
const hooks = settings.hooks;
|
|
276
|
+
if (hooks?.PostToolUse) {
|
|
277
|
+
hooks.PostToolUse = hooks.PostToolUse.filter((hook) => !this.isTodoWriteHook(hook));
|
|
278
|
+
if (hooks.PostToolUse.length === 0) {
|
|
279
|
+
delete hooks.PostToolUse;
|
|
280
|
+
}
|
|
281
|
+
if (Object.keys(hooks).length === 0) {
|
|
282
|
+
delete settings.hooks;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await this.write(settings);
|
|
286
|
+
return { removed: true };
|
|
287
|
+
}
|
|
288
|
+
hasHook(settings) {
|
|
289
|
+
const postToolUseHooks = settings.hooks?.PostToolUse;
|
|
290
|
+
if (!postToolUseHooks)
|
|
291
|
+
return false;
|
|
292
|
+
return postToolUseHooks.some((hook) => this.isTodoWriteHook(hook));
|
|
293
|
+
}
|
|
294
|
+
isTodoWriteHook(hook) {
|
|
295
|
+
return hook.matcher === "TodoWrite" && hook.hooks.some((h) => h.command.includes("kaban-hook"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/commands/hook.ts
|
|
300
|
+
var HOOKS_DIR = join3(homedir2(), ".claude", "hooks");
|
|
301
|
+
var HOOK_BINARY_NAME = "kaban-hook";
|
|
302
|
+
var LOG_FILE = "sync.log";
|
|
303
|
+
async function checkKabanCli() {
|
|
304
|
+
return new Promise((resolve2) => {
|
|
305
|
+
try {
|
|
306
|
+
const proc = spawn("kaban", ["--version"]);
|
|
307
|
+
let stdout = "";
|
|
308
|
+
proc.stdout.on("data", (data) => {
|
|
309
|
+
stdout += data.toString();
|
|
310
|
+
});
|
|
311
|
+
proc.on("close", (code) => {
|
|
312
|
+
if (code === 0) {
|
|
313
|
+
resolve2({ ok: true, message: stdout.trim() });
|
|
314
|
+
} else {
|
|
315
|
+
resolve2({ ok: false, message: "not working" });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
proc.on("error", () => {
|
|
319
|
+
resolve2({ ok: false, message: "not found in PATH" });
|
|
320
|
+
});
|
|
321
|
+
} catch {
|
|
322
|
+
resolve2({ ok: false, message: "not found in PATH" });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async function checkDependencies() {
|
|
327
|
+
const results = [];
|
|
328
|
+
const isBun = typeof Bun !== "undefined";
|
|
329
|
+
results.push({
|
|
330
|
+
name: "Runtime",
|
|
331
|
+
ok: true,
|
|
332
|
+
message: isBun ? `Bun v${Bun.version}` : `Node ${process.version}`
|
|
333
|
+
});
|
|
334
|
+
const claudeDir = join3(homedir2(), ".claude");
|
|
335
|
+
if (existsSync3(claudeDir)) {
|
|
336
|
+
results.push({
|
|
337
|
+
name: "Claude Code",
|
|
338
|
+
ok: true,
|
|
339
|
+
message: "~/.claude/ exists"
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
results.push({
|
|
343
|
+
name: "Claude Code",
|
|
344
|
+
ok: false,
|
|
345
|
+
message: "~/.claude/ not found - run Claude Code first"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const settingsPath = join3(claudeDir, "settings.json");
|
|
349
|
+
if (existsSync3(settingsPath)) {
|
|
350
|
+
try {
|
|
351
|
+
const content = await readFile2(settingsPath, "utf-8");
|
|
352
|
+
JSON.parse(content);
|
|
353
|
+
results.push({
|
|
354
|
+
name: "Settings",
|
|
355
|
+
ok: true,
|
|
356
|
+
message: "settings.json valid"
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
results.push({
|
|
360
|
+
name: "Settings",
|
|
361
|
+
ok: false,
|
|
362
|
+
message: "settings.json is not valid JSON"
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
results.push({
|
|
367
|
+
name: "Settings",
|
|
368
|
+
ok: true,
|
|
369
|
+
message: "settings.json will be created"
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const kabanInstalled = await checkKabanCli();
|
|
373
|
+
results.push({
|
|
374
|
+
name: "Kaban CLI",
|
|
375
|
+
ok: kabanInstalled.ok,
|
|
376
|
+
message: kabanInstalled.message
|
|
377
|
+
});
|
|
378
|
+
const allOk = results.every((r) => r.ok);
|
|
379
|
+
return { ok: allOk, results };
|
|
380
|
+
}
|
|
381
|
+
function formatCheckResults(results) {
|
|
382
|
+
const maxNameLen = Math.max(...results.map((r) => r.name.length));
|
|
383
|
+
return results.map((r) => {
|
|
384
|
+
const icon = r.ok ? chalk.green("✓") : chalk.red("✗");
|
|
385
|
+
const name = r.name.padEnd(maxNameLen);
|
|
386
|
+
const msg = r.ok ? chalk.dim(r.message) : chalk.red(r.message);
|
|
387
|
+
return ` ${icon} ${name} ${msg}`;
|
|
388
|
+
}).join(`
|
|
389
|
+
`);
|
|
390
|
+
}
|
|
391
|
+
async function installHook(spinner2) {
|
|
392
|
+
spinner2.message("Creating hooks directory...");
|
|
393
|
+
if (!existsSync3(HOOKS_DIR)) {
|
|
394
|
+
await mkdir(HOOKS_DIR, { recursive: true });
|
|
395
|
+
}
|
|
396
|
+
spinner2.message("Installing hook binary...");
|
|
397
|
+
const scriptPath = realpathSync(process.argv[1]);
|
|
398
|
+
const scriptDir = dirname(scriptPath);
|
|
399
|
+
const isDevMode = scriptPath.includes("/src/");
|
|
400
|
+
const distDir = isDevMode ? join3(scriptDir, "..", "dist") : scriptDir;
|
|
401
|
+
const sourceBinary = join3(distDir, HOOK_BINARY_NAME);
|
|
402
|
+
const targetBinary = join3(HOOKS_DIR, HOOK_BINARY_NAME);
|
|
403
|
+
if (!existsSync3(sourceBinary)) {
|
|
404
|
+
throw new Error(`Binary not found at ${sourceBinary}. Run 'bun run build' first.`);
|
|
405
|
+
}
|
|
406
|
+
await copyFile2(sourceBinary, targetBinary);
|
|
407
|
+
await chmod(targetBinary, 493);
|
|
408
|
+
spinner2.message("Configuring Claude Code settings...");
|
|
409
|
+
const settingsManager = new SettingsManager;
|
|
410
|
+
const result = await settingsManager.addHook();
|
|
411
|
+
const installResult = {
|
|
412
|
+
hookInstalled: true,
|
|
413
|
+
hookConfigured: result.added
|
|
414
|
+
};
|
|
415
|
+
if (result.backupPath) {
|
|
416
|
+
installResult.backupPath = result.backupPath;
|
|
417
|
+
}
|
|
418
|
+
return installResult;
|
|
419
|
+
}
|
|
420
|
+
async function uninstallHook(cleanLogs) {
|
|
421
|
+
const result = {
|
|
422
|
+
hookRemoved: false,
|
|
423
|
+
binaryRemoved: false,
|
|
424
|
+
logRemoved: false
|
|
425
|
+
};
|
|
426
|
+
const settingsManager = new SettingsManager;
|
|
427
|
+
const hookResult = await settingsManager.removeHook();
|
|
428
|
+
result.hookRemoved = hookResult.removed;
|
|
429
|
+
const binaryPath = join3(HOOKS_DIR, HOOK_BINARY_NAME);
|
|
430
|
+
if (existsSync3(binaryPath)) {
|
|
431
|
+
await unlink(binaryPath);
|
|
432
|
+
result.binaryRemoved = true;
|
|
433
|
+
}
|
|
434
|
+
const logPath = join3(HOOKS_DIR, LOG_FILE);
|
|
435
|
+
if (existsSync3(logPath) && cleanLogs) {
|
|
436
|
+
await unlink(logPath);
|
|
437
|
+
result.logRemoved = true;
|
|
438
|
+
}
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
async function getRecentActivity() {
|
|
442
|
+
const logPath = join3(HOOKS_DIR, LOG_FILE);
|
|
443
|
+
if (!existsSync3(logPath)) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const content = await readFile2(logPath, "utf-8");
|
|
448
|
+
const lines = content.trim().split(`
|
|
449
|
+
`).filter(Boolean);
|
|
450
|
+
const entries = lines.map((line) => JSON.parse(line));
|
|
451
|
+
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
452
|
+
const recentEntries = entries.filter((e) => new Date(e.timestamp) > oneDayAgo);
|
|
453
|
+
const stats = recentEntries.reduce((acc, e) => ({
|
|
454
|
+
syncs: acc.syncs + 1,
|
|
455
|
+
created: acc.created + e.created,
|
|
456
|
+
moved: acc.moved + e.moved,
|
|
457
|
+
errors: acc.errors + e.errors.length
|
|
458
|
+
}), { syncs: 0, created: 0, moved: 0, errors: 0 });
|
|
459
|
+
const lastEntry = entries.at(-1);
|
|
460
|
+
const logStats = await stat(logPath);
|
|
461
|
+
const result = { ...stats };
|
|
462
|
+
if (lastEntry) {
|
|
463
|
+
result.lastSync = new Date(lastEntry.timestamp);
|
|
464
|
+
}
|
|
465
|
+
if (logStats.size > 0) {
|
|
466
|
+
result.logSize = logStats.size;
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function formatTimeAgo(date) {
|
|
474
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
475
|
+
if (seconds < 60)
|
|
476
|
+
return `${seconds} sec ago`;
|
|
477
|
+
if (seconds < 3600)
|
|
478
|
+
return `${Math.floor(seconds / 60)} min ago`;
|
|
479
|
+
if (seconds < 86400)
|
|
480
|
+
return `${Math.floor(seconds / 3600)} hours ago`;
|
|
481
|
+
return `${Math.floor(seconds / 86400)} days ago`;
|
|
482
|
+
}
|
|
483
|
+
function formatSize(bytes) {
|
|
484
|
+
if (bytes < 1024)
|
|
485
|
+
return `${bytes} B`;
|
|
486
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
487
|
+
}
|
|
488
|
+
var installCommand = new Command3("install").description("Install TodoWrite sync hook for Claude Code").option("-y, --yes", "Skip confirmation").action(async (options) => {
|
|
489
|
+
p.intro(chalk.bgCyan.black(" kaban hook install "));
|
|
490
|
+
const s = p.spinner();
|
|
491
|
+
s.start("Checking dependencies...");
|
|
492
|
+
const { ok, results } = await checkDependencies();
|
|
493
|
+
s.stop("Dependencies checked");
|
|
494
|
+
p.note(formatCheckResults(results), "Environment");
|
|
495
|
+
if (!ok) {
|
|
496
|
+
p.cancel("Dependency check failed. Please fix the issues above.");
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
if (!options.yes) {
|
|
500
|
+
const proceed = await p.confirm({
|
|
501
|
+
message: "Install TodoWrite-Kaban sync hook?",
|
|
502
|
+
initialValue: true
|
|
503
|
+
});
|
|
504
|
+
if (p.isCancel(proceed) || !proceed) {
|
|
505
|
+
p.cancel("Installation cancelled.");
|
|
506
|
+
process.exit(0);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
s.start("Installing...");
|
|
510
|
+
try {
|
|
511
|
+
const installResult = await installHook(s);
|
|
512
|
+
s.stop("Installation complete");
|
|
513
|
+
const summaryLines = [
|
|
514
|
+
"",
|
|
515
|
+
` ${chalk.cyan("Hook Binary")}`,
|
|
516
|
+
` ${chalk.dim("Path:")} ~/.claude/hooks/${HOOK_BINARY_NAME}`,
|
|
517
|
+
"",
|
|
518
|
+
` ${chalk.cyan("Hook Configuration")}`,
|
|
519
|
+
` ${chalk.dim("Event:")} PostToolUse`,
|
|
520
|
+
` ${chalk.dim("Matcher:")} TodoWrite`,
|
|
521
|
+
` ${chalk.dim("Timeout:")} 10s`,
|
|
522
|
+
""
|
|
523
|
+
];
|
|
524
|
+
if (installResult.backupPath) {
|
|
525
|
+
summaryLines.push(` ${chalk.cyan("Backup")}`);
|
|
526
|
+
summaryLines.push(` ${chalk.dim(installResult.backupPath)}`);
|
|
527
|
+
summaryLines.push("");
|
|
528
|
+
}
|
|
529
|
+
if (!installResult.hookConfigured) {
|
|
530
|
+
summaryLines.push(` ${chalk.yellow("⚠")} Hook was already configured`);
|
|
531
|
+
summaryLines.push("");
|
|
532
|
+
}
|
|
533
|
+
p.note(summaryLines.join(`
|
|
534
|
+
`), "Installation Summary");
|
|
535
|
+
p.log.success("TodoWrite changes will now auto-sync to Kaban board!");
|
|
536
|
+
p.note([
|
|
537
|
+
` ${chalk.cyan("Verify:")} kaban hook status`,
|
|
538
|
+
` ${chalk.cyan("Logs:")} ~/.claude/hooks/sync.log`,
|
|
539
|
+
` ${chalk.cyan("Remove:")} kaban hook uninstall`
|
|
540
|
+
].join(`
|
|
541
|
+
`), "Next Steps");
|
|
542
|
+
p.outro(chalk.green("Done!"));
|
|
543
|
+
} catch (error2) {
|
|
544
|
+
s.stop("Installation failed");
|
|
545
|
+
p.cancel(error2 instanceof Error ? error2.message : "Unknown error");
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
var uninstallCommand = new Command3("uninstall").description("Remove TodoWrite sync hook").option("-y, --yes", "Skip confirmation").option("--clean", "Also remove sync logs").action(async (options) => {
|
|
550
|
+
p.intro(chalk.bgRed.white(" kaban hook uninstall "));
|
|
551
|
+
const binaryExists = existsSync3(join3(HOOKS_DIR, HOOK_BINARY_NAME));
|
|
552
|
+
const logExists = existsSync3(join3(HOOKS_DIR, LOG_FILE));
|
|
553
|
+
const settingsManager = new SettingsManager;
|
|
554
|
+
let hookExists = false;
|
|
555
|
+
try {
|
|
556
|
+
const settings = await settingsManager.read();
|
|
557
|
+
hookExists = settingsManager.hasHook(settings);
|
|
558
|
+
} catch {
|
|
559
|
+
hookExists = false;
|
|
560
|
+
}
|
|
561
|
+
if (!binaryExists && !hookExists) {
|
|
562
|
+
p.log.warn("TodoWrite-Kaban sync hook is not installed.");
|
|
563
|
+
p.outro("Nothing to uninstall.");
|
|
564
|
+
process.exit(0);
|
|
565
|
+
}
|
|
566
|
+
const formatStatusLine = (exists, label) => {
|
|
567
|
+
const icon = exists ? chalk.green("✓") : chalk.dim("○");
|
|
568
|
+
const text = exists ? "" : chalk.dim(" (not found)");
|
|
569
|
+
return ` ${icon} ${label}${text}`;
|
|
570
|
+
};
|
|
571
|
+
const formatLogStatus = () => {
|
|
572
|
+
if (!logExists)
|
|
573
|
+
return formatStatusLine(false, "Sync log");
|
|
574
|
+
const suffix = options.clean ? chalk.yellow(" (will be removed)") : chalk.dim(" (will be preserved)");
|
|
575
|
+
return ` ${chalk.yellow("○")} Sync log${suffix}`;
|
|
576
|
+
};
|
|
577
|
+
const statusLines = [
|
|
578
|
+
formatStatusLine(binaryExists, "Hook binary"),
|
|
579
|
+
formatStatusLine(hookExists, "Settings configuration"),
|
|
580
|
+
formatLogStatus()
|
|
581
|
+
];
|
|
582
|
+
p.note(statusLines.join(`
|
|
583
|
+
`), "Current Installation");
|
|
584
|
+
if (!options.yes) {
|
|
585
|
+
const confirmed = await p.confirm({
|
|
586
|
+
message: "Remove TodoWrite-Kaban sync?",
|
|
587
|
+
initialValue: false
|
|
588
|
+
});
|
|
589
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
590
|
+
p.cancel("Uninstallation cancelled.");
|
|
591
|
+
process.exit(0);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const s = p.spinner();
|
|
595
|
+
s.start("Removing...");
|
|
596
|
+
try {
|
|
597
|
+
const result = await uninstallHook(options.clean);
|
|
598
|
+
s.stop("Removal complete");
|
|
599
|
+
const summaryLines = [];
|
|
600
|
+
if (result.hookRemoved)
|
|
601
|
+
summaryLines.push(` ${chalk.green("✓")} Removed hook from settings.json`);
|
|
602
|
+
if (result.binaryRemoved)
|
|
603
|
+
summaryLines.push(` ${chalk.green("✓")} Removed ${HOOK_BINARY_NAME}`);
|
|
604
|
+
if (result.logRemoved)
|
|
605
|
+
summaryLines.push(` ${chalk.green("✓")} Removed ${LOG_FILE}`);
|
|
606
|
+
if (logExists && !options.clean)
|
|
607
|
+
summaryLines.push(` ${chalk.yellow("○")} Preserved ${LOG_FILE} (use --clean to remove)`);
|
|
608
|
+
if (summaryLines.length > 0) {
|
|
609
|
+
p.note(summaryLines.join(`
|
|
610
|
+
`), "Removed");
|
|
611
|
+
}
|
|
612
|
+
p.outro(chalk.green("Done!"));
|
|
613
|
+
} catch (error2) {
|
|
614
|
+
s.stop("Removal failed");
|
|
615
|
+
p.cancel(error2 instanceof Error ? error2.message : "Unknown error");
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
var statusCommand = new Command3("status").description("Check hook installation status").action(async () => {
|
|
620
|
+
p.intro(chalk.bgBlue.white(" kaban hook status "));
|
|
621
|
+
const s = p.spinner();
|
|
622
|
+
s.start("Checking status...");
|
|
623
|
+
const binaryPath = join3(HOOKS_DIR, HOOK_BINARY_NAME);
|
|
624
|
+
const binaryExists = existsSync3(binaryPath);
|
|
625
|
+
const settingsManager = new SettingsManager;
|
|
626
|
+
let hookConfigured = false;
|
|
627
|
+
try {
|
|
628
|
+
const settings = await settingsManager.read();
|
|
629
|
+
hookConfigured = settingsManager.hasHook(settings);
|
|
630
|
+
} catch {
|
|
631
|
+
hookConfigured = false;
|
|
632
|
+
}
|
|
633
|
+
const kabanCheck = await checkKabanCli();
|
|
634
|
+
const activity = await getRecentActivity();
|
|
635
|
+
s.stop("Status checked");
|
|
636
|
+
const results = [
|
|
637
|
+
{
|
|
638
|
+
name: "Hook Binary",
|
|
639
|
+
ok: binaryExists,
|
|
640
|
+
detail: binaryExists ? binaryPath : "Not found"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "Settings Config",
|
|
644
|
+
ok: hookConfigured,
|
|
645
|
+
detail: hookConfigured ? "PostToolUse[TodoWrite] active" : "Hook not configured"
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
name: "Kaban CLI",
|
|
649
|
+
ok: kabanCheck.ok,
|
|
650
|
+
detail: kabanCheck.ok ? kabanCheck.message : "Not found in PATH"
|
|
651
|
+
}
|
|
652
|
+
];
|
|
653
|
+
const maxNameLen = Math.max(...results.map((r) => r.name.length));
|
|
654
|
+
const statusLines = results.map((r) => {
|
|
655
|
+
const icon = r.ok ? chalk.green("✓") : chalk.red("✗");
|
|
656
|
+
const name = r.name.padEnd(maxNameLen);
|
|
657
|
+
const detail = r.ok ? chalk.dim(r.detail) : chalk.red(r.detail);
|
|
658
|
+
return ` ${icon} ${name} ${detail}`;
|
|
659
|
+
});
|
|
660
|
+
p.note(statusLines.join(`
|
|
661
|
+
`), "Installation Status");
|
|
662
|
+
if (activity) {
|
|
663
|
+
const activityLines = [
|
|
664
|
+
` ${chalk.cyan("Syncs (24h):")} ${activity.syncs}`,
|
|
665
|
+
` ${chalk.cyan("Tasks created:")} ${activity.created}`,
|
|
666
|
+
` ${chalk.cyan("Tasks moved:")} ${activity.moved}`,
|
|
667
|
+
` ${chalk.cyan("Errors:")} ${activity.errors > 0 ? chalk.red(activity.errors.toString()) : chalk.green("0")}`,
|
|
668
|
+
"",
|
|
669
|
+
activity.lastSync ? ` ${chalk.dim("Last sync:")} ${formatTimeAgo(activity.lastSync)}` : "",
|
|
670
|
+
activity.logSize ? ` ${chalk.dim("Log size:")} ${formatSize(activity.logSize)}` : ""
|
|
671
|
+
].filter(Boolean);
|
|
672
|
+
p.note(activityLines.join(`
|
|
673
|
+
`), "Recent Activity");
|
|
674
|
+
} else {
|
|
675
|
+
p.log.info("No sync activity logged yet.");
|
|
676
|
+
}
|
|
677
|
+
const allOk = results.every((r) => r.ok);
|
|
678
|
+
if (allOk) {
|
|
679
|
+
p.outro(chalk.green("All systems operational!"));
|
|
680
|
+
} else {
|
|
681
|
+
p.outro(chalk.yellow("Some checks failed. Run 'kaban hook install' to fix."));
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
var hookCommand = new Command3("hook").description("Manage TodoWrite sync hook for Claude Code").addCommand(installCommand).addCommand(uninstallCommand).addCommand(statusCommand);
|
|
686
|
+
|
|
687
|
+
// src/commands/init.ts
|
|
688
|
+
import { existsSync as existsSync4, mkdirSync, writeFileSync } from "node:fs";
|
|
689
|
+
import { BoardService as BoardService2, createDb as createDb2, DEFAULT_CONFIG as DEFAULT_CONFIG2, initializeSchema } from "@kaban-board/core";
|
|
690
|
+
import { Command as Command4 } from "commander";
|
|
691
|
+
var initCommand = new Command4("init").description("Initialize a new Kaban board in the current directory").option("-n, --name <name>", "Board name", "Kaban Board").action(async (options) => {
|
|
135
692
|
const { kabanDir, dbPath, configPath } = getKabanPaths();
|
|
136
|
-
if (
|
|
693
|
+
if (existsSync4(dbPath)) {
|
|
137
694
|
console.error("Error: Board already exists in this directory");
|
|
138
695
|
process.exit(1);
|
|
139
696
|
}
|
|
140
697
|
mkdirSync(kabanDir, { recursive: true });
|
|
141
698
|
const config = {
|
|
142
|
-
...
|
|
699
|
+
...DEFAULT_CONFIG2,
|
|
143
700
|
board: { name: options.name }
|
|
144
701
|
};
|
|
145
702
|
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
@@ -154,7 +711,7 @@ var initCommand = new Command3("init").description("Initialize a new Kaban board
|
|
|
154
711
|
|
|
155
712
|
// src/commands/list.ts
|
|
156
713
|
import { KabanError as KabanError3 } from "@kaban-board/core";
|
|
157
|
-
import { Command as
|
|
714
|
+
import { Command as Command5 } from "commander";
|
|
158
715
|
function sortTasks(tasks, sortBy, reverse) {
|
|
159
716
|
const sorted = [...tasks].sort((a, b) => {
|
|
160
717
|
switch (sortBy) {
|
|
@@ -170,7 +727,7 @@ function sortTasks(tasks, sortBy, reverse) {
|
|
|
170
727
|
});
|
|
171
728
|
return reverse ? sorted.reverse() : sorted;
|
|
172
729
|
}
|
|
173
|
-
var listCommand = new
|
|
730
|
+
var listCommand = new Command5("list").description("List tasks").option("-c, --column <column>", "Filter by column").option("-a, --agent <agent>", "Filter by creator agent").option("-u, --assignee <assignee>", "Filter by assigned agent").option("-b, --blocked", "Show only blocked tasks").option("-s, --sort <field>", "Sort by: name, date, updated").option("-r, --reverse", "Reverse sort order").option("-j, --json", "Output as JSON").action(async (options) => {
|
|
174
731
|
const json = options.json;
|
|
175
732
|
try {
|
|
176
733
|
const { taskService, boardService } = getContext();
|
|
@@ -222,12 +779,12 @@ var listCommand = new Command4("list").description("List tasks").option("-c, --c
|
|
|
222
779
|
});
|
|
223
780
|
|
|
224
781
|
// src/commands/mcp.ts
|
|
225
|
-
import { existsSync as
|
|
226
|
-
import { join as
|
|
782
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
783
|
+
import { join as join4 } from "node:path";
|
|
227
784
|
import {
|
|
228
785
|
BoardService as BoardService3,
|
|
229
786
|
createDb as createDb3,
|
|
230
|
-
DEFAULT_CONFIG as
|
|
787
|
+
DEFAULT_CONFIG as DEFAULT_CONFIG3,
|
|
231
788
|
initializeSchema as initializeSchema2,
|
|
232
789
|
TaskService as TaskService2
|
|
233
790
|
} from "@kaban-board/core";
|
|
@@ -239,19 +796,19 @@ import {
|
|
|
239
796
|
ListToolsRequestSchema,
|
|
240
797
|
ReadResourceRequestSchema
|
|
241
798
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
242
|
-
import { Command as
|
|
799
|
+
import { Command as Command6 } from "commander";
|
|
243
800
|
function getKabanPaths2(basePath) {
|
|
244
801
|
const base = basePath ?? process.cwd();
|
|
245
|
-
const kabanDir =
|
|
802
|
+
const kabanDir = join4(base, ".kaban");
|
|
246
803
|
return {
|
|
247
804
|
kabanDir,
|
|
248
|
-
dbPath:
|
|
249
|
-
configPath:
|
|
805
|
+
dbPath: join4(kabanDir, "board.db"),
|
|
806
|
+
configPath: join4(kabanDir, "config.json")
|
|
250
807
|
};
|
|
251
808
|
}
|
|
252
809
|
function createContext(basePath) {
|
|
253
810
|
const { dbPath, configPath } = getKabanPaths2(basePath);
|
|
254
|
-
if (!
|
|
811
|
+
if (!existsSync5(dbPath)) {
|
|
255
812
|
throw new Error("No board found. Run 'kaban init' first");
|
|
256
813
|
}
|
|
257
814
|
const db = createDb3(dbPath);
|
|
@@ -390,7 +947,7 @@ async function startMcpServer(workingDirectory) {
|
|
|
390
947
|
const { name: boardName = "Kaban Board", path: basePath } = args ?? {};
|
|
391
948
|
const targetPath = basePath ?? workingDirectory;
|
|
392
949
|
const { kabanDir, dbPath, configPath } = getKabanPaths2(targetPath);
|
|
393
|
-
if (
|
|
950
|
+
if (existsSync5(dbPath)) {
|
|
394
951
|
return {
|
|
395
952
|
content: [
|
|
396
953
|
{
|
|
@@ -403,7 +960,7 @@ async function startMcpServer(workingDirectory) {
|
|
|
403
960
|
}
|
|
404
961
|
mkdirSync2(kabanDir, { recursive: true });
|
|
405
962
|
const config = {
|
|
406
|
-
...
|
|
963
|
+
...DEFAULT_CONFIG3,
|
|
407
964
|
board: { name: boardName }
|
|
408
965
|
};
|
|
409
966
|
writeFileSync2(configPath, JSON.stringify(config, null, 2));
|
|
@@ -617,15 +1174,15 @@ async function startMcpServer(workingDirectory) {
|
|
|
617
1174
|
await server.connect(transport);
|
|
618
1175
|
console.error("Kaban MCP server running on stdio");
|
|
619
1176
|
}
|
|
620
|
-
var mcpCommand = new
|
|
1177
|
+
var mcpCommand = new Command6("mcp").description("Start MCP server for AI agent integration").option("-p, --path <path>", "Working directory for Kaban board").action(async (options) => {
|
|
621
1178
|
const workingDirectory = options.path ?? process.env.KABAN_PATH ?? process.cwd();
|
|
622
1179
|
await startMcpServer(workingDirectory);
|
|
623
1180
|
});
|
|
624
1181
|
|
|
625
1182
|
// src/commands/move.ts
|
|
626
1183
|
import { KabanError as KabanError4 } from "@kaban-board/core";
|
|
627
|
-
import { Command as
|
|
628
|
-
var moveCommand = new
|
|
1184
|
+
import { Command as Command7 } from "commander";
|
|
1185
|
+
var moveCommand = new Command7("move").description("Move a task to a different column").argument("<id>", "Task ID (can be partial)").argument("[column]", "Target column").option("-n, --next", "Move to next column").option("-f, --force", "Force move even if WIP limit exceeded").option("-j, --json", "Output as JSON").action(async (id, column, options) => {
|
|
629
1186
|
const json = options.json;
|
|
630
1187
|
try {
|
|
631
1188
|
const { taskService, boardService } = getContext();
|
|
@@ -678,9 +1235,9 @@ var moveCommand = new Command6("move").description("Move a task to a different c
|
|
|
678
1235
|
|
|
679
1236
|
// src/commands/schema.ts
|
|
680
1237
|
import { jsonSchemas } from "@kaban-board/core";
|
|
681
|
-
import { Command as
|
|
1238
|
+
import { Command as Command8 } from "commander";
|
|
682
1239
|
var availableSchemas = Object.keys(jsonSchemas);
|
|
683
|
-
var schemaCommand = new
|
|
1240
|
+
var schemaCommand = new Command8("schema").description("Output JSON schemas for AI agents").argument("[name]", "Schema name (omit to list available)").action((name) => {
|
|
684
1241
|
if (!name) {
|
|
685
1242
|
console.log("Available schemas:");
|
|
686
1243
|
for (const schemaName of availableSchemas) {
|
|
@@ -701,8 +1258,8 @@ Usage: kaban schema <name>`);
|
|
|
701
1258
|
|
|
702
1259
|
// src/commands/status.ts
|
|
703
1260
|
import { KabanError as KabanError5 } from "@kaban-board/core";
|
|
704
|
-
import { Command as
|
|
705
|
-
var
|
|
1261
|
+
import { Command as Command9 } from "commander";
|
|
1262
|
+
var statusCommand2 = new Command9("status").description("Show board status summary").option("-j, --json", "Output as JSON").action(async (options) => {
|
|
706
1263
|
const json = options.json;
|
|
707
1264
|
try {
|
|
708
1265
|
const { taskService, boardService } = getContext();
|
|
@@ -755,15 +1312,398 @@ var statusCommand = new Command8("status").description("Show board status summar
|
|
|
755
1312
|
}
|
|
756
1313
|
});
|
|
757
1314
|
|
|
1315
|
+
// src/commands/sync.ts
|
|
1316
|
+
import { Command as Command10 } from "commander";
|
|
1317
|
+
|
|
1318
|
+
// src/hook/constants.ts
|
|
1319
|
+
var STATUS_PRIORITY = {
|
|
1320
|
+
completed: 3,
|
|
1321
|
+
in_progress: 2,
|
|
1322
|
+
pending: 1,
|
|
1323
|
+
cancelled: 0
|
|
1324
|
+
};
|
|
1325
|
+
var STATUS_TO_COLUMN = {
|
|
1326
|
+
pending: "todo",
|
|
1327
|
+
in_progress: "in_progress",
|
|
1328
|
+
completed: "done",
|
|
1329
|
+
cancelled: "backlog"
|
|
1330
|
+
};
|
|
1331
|
+
var COLUMN_TO_STATUS = {
|
|
1332
|
+
backlog: "pending",
|
|
1333
|
+
todo: "pending",
|
|
1334
|
+
in_progress: "in_progress",
|
|
1335
|
+
review: "in_progress",
|
|
1336
|
+
done: "completed"
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// src/hook/conflict-resolver.ts
|
|
1340
|
+
class ConflictResolver {
|
|
1341
|
+
strategy;
|
|
1342
|
+
constructor(strategy) {
|
|
1343
|
+
this.strategy = strategy;
|
|
1344
|
+
}
|
|
1345
|
+
resolve(todo, kabanTask) {
|
|
1346
|
+
const kabanStatus = this.columnToStatus(kabanTask.columnId);
|
|
1347
|
+
const todoColumn = STATUS_TO_COLUMN[todo.status];
|
|
1348
|
+
if (this.strategy === "todowrite_wins") {
|
|
1349
|
+
return {
|
|
1350
|
+
winner: "todo",
|
|
1351
|
+
targetColumn: todoColumn,
|
|
1352
|
+
reason: "todowrite_wins strategy"
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
if (this.strategy === "kaban_wins") {
|
|
1356
|
+
return {
|
|
1357
|
+
winner: "kaban",
|
|
1358
|
+
targetColumn: kabanTask.columnId,
|
|
1359
|
+
reason: "kaban_wins strategy"
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
if (todo.status === "completed") {
|
|
1363
|
+
return {
|
|
1364
|
+
winner: "todo",
|
|
1365
|
+
targetColumn: "done",
|
|
1366
|
+
reason: "completed status always wins (terminal state)"
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
if (kabanTask.columnId === "done") {
|
|
1370
|
+
return {
|
|
1371
|
+
winner: "kaban",
|
|
1372
|
+
targetColumn: "done",
|
|
1373
|
+
reason: "kaban task already completed (terminal state)"
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const todoPriority = STATUS_PRIORITY[todo.status];
|
|
1377
|
+
const kabanPriority = STATUS_PRIORITY[kabanStatus];
|
|
1378
|
+
if (todoPriority > kabanPriority) {
|
|
1379
|
+
return {
|
|
1380
|
+
winner: "todo",
|
|
1381
|
+
targetColumn: todoColumn,
|
|
1382
|
+
reason: `todo status priority (${todoPriority}) > kaban (${kabanPriority})`
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
if (kabanPriority > todoPriority) {
|
|
1386
|
+
return {
|
|
1387
|
+
winner: "kaban",
|
|
1388
|
+
targetColumn: kabanTask.columnId,
|
|
1389
|
+
reason: `kaban status priority (${kabanPriority}) > todo (${todoPriority})`
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
return {
|
|
1393
|
+
winner: "todo",
|
|
1394
|
+
targetColumn: todoColumn,
|
|
1395
|
+
reason: "equal priority, todo wins (most recent)"
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
shouldSync(todo, cancelledPolicy) {
|
|
1399
|
+
if (todo.status === "cancelled") {
|
|
1400
|
+
return cancelledPolicy === "backlog";
|
|
1401
|
+
}
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
columnToStatus(columnId) {
|
|
1405
|
+
return COLUMN_TO_STATUS[columnId] ?? "pending";
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/hook/kaban-client.ts
|
|
1410
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1411
|
+
|
|
1412
|
+
class KabanClient {
|
|
1413
|
+
cwd;
|
|
1414
|
+
constructor(cwd) {
|
|
1415
|
+
this.cwd = cwd;
|
|
1416
|
+
}
|
|
1417
|
+
async boardExists() {
|
|
1418
|
+
try {
|
|
1419
|
+
const result = await this.exec(["kaban", "status", "--json"]);
|
|
1420
|
+
return result.exitCode === 0;
|
|
1421
|
+
} catch {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
async listTasks(columnId) {
|
|
1426
|
+
const args = ["kaban", "list", "--json"];
|
|
1427
|
+
if (columnId) {
|
|
1428
|
+
args.push("--column", columnId);
|
|
1429
|
+
}
|
|
1430
|
+
const result = await this.exec(args);
|
|
1431
|
+
if (result.exitCode !== 0) {
|
|
1432
|
+
return [];
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
const tasks = JSON.parse(result.stdout);
|
|
1436
|
+
return tasks.map((t) => ({
|
|
1437
|
+
id: t.id,
|
|
1438
|
+
title: t.title,
|
|
1439
|
+
columnId: t.columnId,
|
|
1440
|
+
description: t.description,
|
|
1441
|
+
labels: t.labels
|
|
1442
|
+
}));
|
|
1443
|
+
} catch {
|
|
1444
|
+
return [];
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
async getTaskById(id) {
|
|
1448
|
+
const result = await this.exec(["kaban", "get", id, "--json"]);
|
|
1449
|
+
if (result.exitCode !== 0) {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
try {
|
|
1453
|
+
const task = JSON.parse(result.stdout);
|
|
1454
|
+
return {
|
|
1455
|
+
id: task.id,
|
|
1456
|
+
title: task.title,
|
|
1457
|
+
columnId: task.columnId,
|
|
1458
|
+
description: task.description,
|
|
1459
|
+
labels: task.labels
|
|
1460
|
+
};
|
|
1461
|
+
} catch {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
async findTaskByTitle(title) {
|
|
1466
|
+
const tasks = await this.listTasks();
|
|
1467
|
+
return tasks.find((t) => t.title === title) ?? null;
|
|
1468
|
+
}
|
|
1469
|
+
async addTask(title, columnId = "todo") {
|
|
1470
|
+
const result = await this.exec(["kaban", "add", title, "--column", columnId, "--json"]);
|
|
1471
|
+
if (result.exitCode !== 0) {
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
const response = JSON.parse(result.stdout);
|
|
1476
|
+
return response.data?.id ?? response.id ?? null;
|
|
1477
|
+
} catch {
|
|
1478
|
+
const match = result.stdout.match(/id[":]*\s*["']?([A-Z0-9]+)/i);
|
|
1479
|
+
return match?.[1] ?? null;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
async moveTask(id, columnId) {
|
|
1483
|
+
const result = await this.exec(["kaban", "move", id, "--column", columnId]);
|
|
1484
|
+
return result.exitCode === 0;
|
|
1485
|
+
}
|
|
1486
|
+
async completeTask(id) {
|
|
1487
|
+
const result = await this.exec(["kaban", "done", id]);
|
|
1488
|
+
return result.exitCode === 0;
|
|
1489
|
+
}
|
|
1490
|
+
async getStatus() {
|
|
1491
|
+
const result = await this.exec(["kaban", "status", "--json"]);
|
|
1492
|
+
if (result.exitCode !== 0) {
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1495
|
+
try {
|
|
1496
|
+
return JSON.parse(result.stdout);
|
|
1497
|
+
} catch {
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
exec(args) {
|
|
1502
|
+
return new Promise((resolve2) => {
|
|
1503
|
+
const [cmd, ...cmdArgs] = args;
|
|
1504
|
+
const proc = spawn2(cmd, cmdArgs, { cwd: this.cwd });
|
|
1505
|
+
let stdout = "";
|
|
1506
|
+
let stderr = "";
|
|
1507
|
+
proc.stdout.on("data", (data) => {
|
|
1508
|
+
stdout += data.toString();
|
|
1509
|
+
});
|
|
1510
|
+
proc.stderr.on("data", (data) => {
|
|
1511
|
+
stderr += data.toString();
|
|
1512
|
+
});
|
|
1513
|
+
proc.on("close", (code) => {
|
|
1514
|
+
resolve2({ exitCode: code ?? 1, stdout, stderr });
|
|
1515
|
+
});
|
|
1516
|
+
proc.on("error", () => {
|
|
1517
|
+
resolve2({ exitCode: 1, stdout: "", stderr: "spawn error" });
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// src/hook/sync-engine.ts
|
|
1524
|
+
class SyncEngine {
|
|
1525
|
+
kaban;
|
|
1526
|
+
resolver;
|
|
1527
|
+
config;
|
|
1528
|
+
constructor(cwd, config) {
|
|
1529
|
+
this.kaban = new KabanClient(cwd);
|
|
1530
|
+
this.resolver = new ConflictResolver(config.conflictStrategy);
|
|
1531
|
+
this.config = config;
|
|
1532
|
+
}
|
|
1533
|
+
async sync(todos) {
|
|
1534
|
+
const result = {
|
|
1535
|
+
success: true,
|
|
1536
|
+
created: 0,
|
|
1537
|
+
moved: 0,
|
|
1538
|
+
skipped: 0,
|
|
1539
|
+
errors: []
|
|
1540
|
+
};
|
|
1541
|
+
if (!await this.kaban.boardExists()) {
|
|
1542
|
+
result.skipped = todos.length;
|
|
1543
|
+
return result;
|
|
1544
|
+
}
|
|
1545
|
+
if (todos.length === 0) {
|
|
1546
|
+
return result;
|
|
1547
|
+
}
|
|
1548
|
+
const kabanTasks = await this.kaban.listTasks();
|
|
1549
|
+
const tasksByTitle = new Map(kabanTasks.map((t) => [t.title, t]));
|
|
1550
|
+
const tasksById = new Map(kabanTasks.map((t) => [t.id, t]));
|
|
1551
|
+
for (const todo of todos) {
|
|
1552
|
+
if (!this.resolver.shouldSync(todo, this.config.cancelledPolicy)) {
|
|
1553
|
+
result.skipped++;
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
try {
|
|
1557
|
+
const syncResult = await this.syncTodo(todo, tasksByTitle, tasksById);
|
|
1558
|
+
if (syncResult === "created")
|
|
1559
|
+
result.created++;
|
|
1560
|
+
else if (syncResult === "moved")
|
|
1561
|
+
result.moved++;
|
|
1562
|
+
else
|
|
1563
|
+
result.skipped++;
|
|
1564
|
+
} catch (error2) {
|
|
1565
|
+
const errorMsg = error2 instanceof Error ? error2.message : String(error2);
|
|
1566
|
+
result.errors.push(`${this.truncateTitle(todo.content)}: ${errorMsg}`);
|
|
1567
|
+
result.success = false;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return result;
|
|
1571
|
+
}
|
|
1572
|
+
async syncTodo(todo, tasksByTitle, tasksById) {
|
|
1573
|
+
const normalizedTitle = this.truncateTitle(todo.content);
|
|
1574
|
+
const existing = tasksById.get(todo.id) ?? tasksByTitle.get(normalizedTitle) ?? tasksByTitle.get(todo.content);
|
|
1575
|
+
if (existing) {
|
|
1576
|
+
return this.handleExisting(todo, existing);
|
|
1577
|
+
}
|
|
1578
|
+
return this.handleNew(todo);
|
|
1579
|
+
}
|
|
1580
|
+
async handleExisting(todo, existing) {
|
|
1581
|
+
const resolution = this.resolver.resolve(todo, existing);
|
|
1582
|
+
if (resolution.winner === "kaban" || existing.columnId === resolution.targetColumn) {
|
|
1583
|
+
return "skipped";
|
|
1584
|
+
}
|
|
1585
|
+
if (resolution.targetColumn === "done") {
|
|
1586
|
+
const success2 = await this.kaban.completeTask(existing.id);
|
|
1587
|
+
if (!success2)
|
|
1588
|
+
throw new Error("failed to complete task");
|
|
1589
|
+
} else {
|
|
1590
|
+
const success2 = await this.kaban.moveTask(existing.id, resolution.targetColumn);
|
|
1591
|
+
if (!success2)
|
|
1592
|
+
throw new Error(`failed to move task to ${resolution.targetColumn}`);
|
|
1593
|
+
}
|
|
1594
|
+
return "moved";
|
|
1595
|
+
}
|
|
1596
|
+
async handleNew(todo) {
|
|
1597
|
+
const column = todo.status === "cancelled" ? "backlog" : STATUS_TO_COLUMN[todo.status];
|
|
1598
|
+
const title = this.truncateTitle(todo.content);
|
|
1599
|
+
const taskId = await this.kaban.addTask(title, column);
|
|
1600
|
+
if (!taskId) {
|
|
1601
|
+
throw new Error("failed to create task");
|
|
1602
|
+
}
|
|
1603
|
+
return "created";
|
|
1604
|
+
}
|
|
1605
|
+
truncateTitle(title) {
|
|
1606
|
+
if (title.length <= this.config.maxTitleLength)
|
|
1607
|
+
return title;
|
|
1608
|
+
return `${title.slice(0, this.config.maxTitleLength - 3)}...`;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/hook/sync-logger.ts
|
|
1613
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1614
|
+
import { appendFile, mkdir as mkdir2 } from "node:fs/promises";
|
|
1615
|
+
import { homedir as homedir3 } from "node:os";
|
|
1616
|
+
import { dirname as dirname2 } from "node:path";
|
|
1617
|
+
|
|
1618
|
+
class SyncLogger {
|
|
1619
|
+
logPath;
|
|
1620
|
+
constructor(logPath) {
|
|
1621
|
+
this.logPath = this.expandPath(logPath);
|
|
1622
|
+
}
|
|
1623
|
+
async log(todosCount, result, durationMs) {
|
|
1624
|
+
const entry = {
|
|
1625
|
+
timestamp: new Date().toISOString(),
|
|
1626
|
+
todosCount,
|
|
1627
|
+
created: result.created,
|
|
1628
|
+
moved: result.moved,
|
|
1629
|
+
skipped: result.skipped,
|
|
1630
|
+
errors: result.errors,
|
|
1631
|
+
durationMs
|
|
1632
|
+
};
|
|
1633
|
+
await this.ensureDir();
|
|
1634
|
+
await appendFile(this.logPath, `${JSON.stringify(entry)}
|
|
1635
|
+
`);
|
|
1636
|
+
}
|
|
1637
|
+
async ensureDir() {
|
|
1638
|
+
const dir = dirname2(this.logPath);
|
|
1639
|
+
if (!existsSync6(dir)) {
|
|
1640
|
+
await mkdir2(dir, { recursive: true });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
expandPath(path) {
|
|
1644
|
+
if (path.startsWith("~/")) {
|
|
1645
|
+
return path.replace("~", homedir3());
|
|
1646
|
+
}
|
|
1647
|
+
return path;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// src/commands/sync.ts
|
|
1652
|
+
var syncCommand = new Command10("sync").description("Sync TodoWrite input to Kaban board (reads from stdin)").option("--no-log", "Disable sync logging").action(async (options) => {
|
|
1653
|
+
const startTime = performance.now();
|
|
1654
|
+
let input;
|
|
1655
|
+
try {
|
|
1656
|
+
const chunks = [];
|
|
1657
|
+
for await (const chunk of process.stdin) {
|
|
1658
|
+
chunks.push(chunk);
|
|
1659
|
+
}
|
|
1660
|
+
input = Buffer.concat(chunks).toString("utf-8");
|
|
1661
|
+
} catch {
|
|
1662
|
+
process.exit(0);
|
|
1663
|
+
}
|
|
1664
|
+
if (!input.trim()) {
|
|
1665
|
+
process.exit(0);
|
|
1666
|
+
}
|
|
1667
|
+
let parsed;
|
|
1668
|
+
try {
|
|
1669
|
+
parsed = JSON.parse(input);
|
|
1670
|
+
} catch {
|
|
1671
|
+
process.exit(0);
|
|
1672
|
+
}
|
|
1673
|
+
const validation = TodoWriteHookInputSchema.safeParse(parsed);
|
|
1674
|
+
if (!validation.success) {
|
|
1675
|
+
process.exit(0);
|
|
1676
|
+
}
|
|
1677
|
+
const hookInput = validation.data;
|
|
1678
|
+
const { cwd, tool_input } = hookInput;
|
|
1679
|
+
const { todos } = tool_input;
|
|
1680
|
+
if (todos.length === 0) {
|
|
1681
|
+
process.exit(0);
|
|
1682
|
+
}
|
|
1683
|
+
const config = { ...DEFAULT_CONFIG, logEnabled: options.log !== false };
|
|
1684
|
+
const engine = new SyncEngine(cwd, config);
|
|
1685
|
+
const logger = new SyncLogger(config.logPath);
|
|
1686
|
+
try {
|
|
1687
|
+
const result = await engine.sync(todos);
|
|
1688
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
1689
|
+
if (config.logEnabled) {
|
|
1690
|
+
await logger.log(todos.length, result, durationMs);
|
|
1691
|
+
}
|
|
1692
|
+
process.exit(result.success ? 0 : 1);
|
|
1693
|
+
} catch {
|
|
1694
|
+
process.exit(1);
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
|
|
758
1698
|
// src/commands/tui.ts
|
|
759
|
-
import { spawn } from "node:child_process";
|
|
760
|
-
import { dirname, join as
|
|
1699
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1700
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
761
1701
|
import { fileURLToPath } from "node:url";
|
|
762
|
-
import { Command as
|
|
763
|
-
var __dirname2 =
|
|
764
|
-
var tuiCommand = new
|
|
765
|
-
const tuiEntry =
|
|
766
|
-
const child =
|
|
1702
|
+
import { Command as Command11 } from "commander";
|
|
1703
|
+
var __dirname2 = dirname3(fileURLToPath(import.meta.url));
|
|
1704
|
+
var tuiCommand = new Command11("tui").description("Start interactive Terminal UI").action(async () => {
|
|
1705
|
+
const tuiEntry = join5(__dirname2, "../../../tui/src/index.ts");
|
|
1706
|
+
const child = spawn3("bun", ["run", tuiEntry], {
|
|
767
1707
|
stdio: "inherit",
|
|
768
1708
|
cwd: process.cwd()
|
|
769
1709
|
});
|
|
@@ -773,15 +1713,19 @@ var tuiCommand = new Command9("tui").description("Start interactive Terminal UI"
|
|
|
773
1713
|
});
|
|
774
1714
|
|
|
775
1715
|
// src/index.ts
|
|
776
|
-
var
|
|
777
|
-
|
|
1716
|
+
var require2 = createRequire(import.meta.url);
|
|
1717
|
+
var pkg = require2("../package.json");
|
|
1718
|
+
var program = new Command12;
|
|
1719
|
+
program.name("kaban").description("Terminal Kanban for AI Code Agents").version(pkg.version);
|
|
778
1720
|
program.addCommand(initCommand);
|
|
779
1721
|
program.addCommand(addCommand);
|
|
780
1722
|
program.addCommand(listCommand);
|
|
781
1723
|
program.addCommand(moveCommand);
|
|
782
1724
|
program.addCommand(doneCommand);
|
|
783
|
-
program.addCommand(
|
|
1725
|
+
program.addCommand(statusCommand2);
|
|
784
1726
|
program.addCommand(schemaCommand);
|
|
785
1727
|
program.addCommand(mcpCommand);
|
|
786
1728
|
program.addCommand(tuiCommand);
|
|
1729
|
+
program.addCommand(hookCommand);
|
|
1730
|
+
program.addCommand(syncCommand);
|
|
787
1731
|
program.parse();
|
package/dist/kaban-hook
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/hook-entry.ts
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
var kaban = spawn("kaban", ["sync"], {
|
|
6
|
+
stdio: ["pipe", "inherit", "inherit"]
|
|
7
|
+
});
|
|
8
|
+
process.stdin.pipe(kaban.stdin);
|
|
9
|
+
kaban.on("close", (code) => {
|
|
10
|
+
process.exit(code ?? 0);
|
|
11
|
+
});
|
|
12
|
+
kaban.on("error", () => {
|
|
13
|
+
process.exit(0);
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaban-board/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Terminal Kanban for AI Code Agents - CLI and MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,16 +10,19 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "bun build ./src/index.ts --outdir ./dist --target node --packages external && chmod +x ./dist/index.js",
|
|
13
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --packages external && bun build ./src/hook-entry.ts --outfile ./dist/kaban-hook --target node && chmod +x ./dist/index.js ./dist/kaban-hook",
|
|
14
14
|
"dev": "bun run ./src/index.ts",
|
|
15
15
|
"test": "bun test",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"prepublishOnly": "npm run build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@
|
|
20
|
+
"@clack/prompts": "^0.11.0",
|
|
21
|
+
"@kaban-board/core": "0.2.0",
|
|
21
22
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
22
|
-
"
|
|
23
|
+
"chalk": "^5.6.2",
|
|
24
|
+
"commander": "^12.0.0",
|
|
25
|
+
"zod": "^4.3.5"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
25
28
|
"@types/bun": "latest",
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
"url": "https://github.com/beshkenadze/kaban/issues"
|
|
39
42
|
},
|
|
40
43
|
"license": "MIT",
|
|
44
|
+
"author": "Aleksandr Beshkenadze <beshkenadze@gmail.com>",
|
|
41
45
|
"keywords": [
|
|
42
46
|
"kanban",
|
|
43
47
|
"ai",
|