@kaban-board/cli 0.1.3 → 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 +976 -35
- package/dist/kaban-hook +14 -0
- package/package.json +7 -4
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as Command10 } from "commander";
|
|
5
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { Command as Command12 } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
8
|
import { KabanError } from "@kaban-board/core";
|
|
@@ -128,19 +128,575 @@ var doneCommand = new Command2("done").description("Mark a task as done").argume
|
|
|
128
128
|
}
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
// src/commands/
|
|
132
|
-
import {
|
|
133
|
-
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";
|
|
134
139
|
import { Command as Command3 } from "commander";
|
|
135
|
-
|
|
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) => {
|
|
136
692
|
const { kabanDir, dbPath, configPath } = getKabanPaths();
|
|
137
|
-
if (
|
|
693
|
+
if (existsSync4(dbPath)) {
|
|
138
694
|
console.error("Error: Board already exists in this directory");
|
|
139
695
|
process.exit(1);
|
|
140
696
|
}
|
|
141
697
|
mkdirSync(kabanDir, { recursive: true });
|
|
142
698
|
const config = {
|
|
143
|
-
...
|
|
699
|
+
...DEFAULT_CONFIG2,
|
|
144
700
|
board: { name: options.name }
|
|
145
701
|
};
|
|
146
702
|
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
@@ -155,7 +711,7 @@ var initCommand = new Command3("init").description("Initialize a new Kaban board
|
|
|
155
711
|
|
|
156
712
|
// src/commands/list.ts
|
|
157
713
|
import { KabanError as KabanError3 } from "@kaban-board/core";
|
|
158
|
-
import { Command as
|
|
714
|
+
import { Command as Command5 } from "commander";
|
|
159
715
|
function sortTasks(tasks, sortBy, reverse) {
|
|
160
716
|
const sorted = [...tasks].sort((a, b) => {
|
|
161
717
|
switch (sortBy) {
|
|
@@ -171,7 +727,7 @@ function sortTasks(tasks, sortBy, reverse) {
|
|
|
171
727
|
});
|
|
172
728
|
return reverse ? sorted.reverse() : sorted;
|
|
173
729
|
}
|
|
174
|
-
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) => {
|
|
175
731
|
const json = options.json;
|
|
176
732
|
try {
|
|
177
733
|
const { taskService, boardService } = getContext();
|
|
@@ -223,12 +779,12 @@ var listCommand = new Command4("list").description("List tasks").option("-c, --c
|
|
|
223
779
|
});
|
|
224
780
|
|
|
225
781
|
// src/commands/mcp.ts
|
|
226
|
-
import { existsSync as
|
|
227
|
-
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";
|
|
228
784
|
import {
|
|
229
785
|
BoardService as BoardService3,
|
|
230
786
|
createDb as createDb3,
|
|
231
|
-
DEFAULT_CONFIG as
|
|
787
|
+
DEFAULT_CONFIG as DEFAULT_CONFIG3,
|
|
232
788
|
initializeSchema as initializeSchema2,
|
|
233
789
|
TaskService as TaskService2
|
|
234
790
|
} from "@kaban-board/core";
|
|
@@ -240,19 +796,19 @@ import {
|
|
|
240
796
|
ListToolsRequestSchema,
|
|
241
797
|
ReadResourceRequestSchema
|
|
242
798
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
243
|
-
import { Command as
|
|
799
|
+
import { Command as Command6 } from "commander";
|
|
244
800
|
function getKabanPaths2(basePath) {
|
|
245
801
|
const base = basePath ?? process.cwd();
|
|
246
|
-
const kabanDir =
|
|
802
|
+
const kabanDir = join4(base, ".kaban");
|
|
247
803
|
return {
|
|
248
804
|
kabanDir,
|
|
249
|
-
dbPath:
|
|
250
|
-
configPath:
|
|
805
|
+
dbPath: join4(kabanDir, "board.db"),
|
|
806
|
+
configPath: join4(kabanDir, "config.json")
|
|
251
807
|
};
|
|
252
808
|
}
|
|
253
809
|
function createContext(basePath) {
|
|
254
810
|
const { dbPath, configPath } = getKabanPaths2(basePath);
|
|
255
|
-
if (!
|
|
811
|
+
if (!existsSync5(dbPath)) {
|
|
256
812
|
throw new Error("No board found. Run 'kaban init' first");
|
|
257
813
|
}
|
|
258
814
|
const db = createDb3(dbPath);
|
|
@@ -391,7 +947,7 @@ async function startMcpServer(workingDirectory) {
|
|
|
391
947
|
const { name: boardName = "Kaban Board", path: basePath } = args ?? {};
|
|
392
948
|
const targetPath = basePath ?? workingDirectory;
|
|
393
949
|
const { kabanDir, dbPath, configPath } = getKabanPaths2(targetPath);
|
|
394
|
-
if (
|
|
950
|
+
if (existsSync5(dbPath)) {
|
|
395
951
|
return {
|
|
396
952
|
content: [
|
|
397
953
|
{
|
|
@@ -404,7 +960,7 @@ async function startMcpServer(workingDirectory) {
|
|
|
404
960
|
}
|
|
405
961
|
mkdirSync2(kabanDir, { recursive: true });
|
|
406
962
|
const config = {
|
|
407
|
-
...
|
|
963
|
+
...DEFAULT_CONFIG3,
|
|
408
964
|
board: { name: boardName }
|
|
409
965
|
};
|
|
410
966
|
writeFileSync2(configPath, JSON.stringify(config, null, 2));
|
|
@@ -618,15 +1174,15 @@ async function startMcpServer(workingDirectory) {
|
|
|
618
1174
|
await server.connect(transport);
|
|
619
1175
|
console.error("Kaban MCP server running on stdio");
|
|
620
1176
|
}
|
|
621
|
-
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) => {
|
|
622
1178
|
const workingDirectory = options.path ?? process.env.KABAN_PATH ?? process.cwd();
|
|
623
1179
|
await startMcpServer(workingDirectory);
|
|
624
1180
|
});
|
|
625
1181
|
|
|
626
1182
|
// src/commands/move.ts
|
|
627
1183
|
import { KabanError as KabanError4 } from "@kaban-board/core";
|
|
628
|
-
import { Command as
|
|
629
|
-
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) => {
|
|
630
1186
|
const json = options.json;
|
|
631
1187
|
try {
|
|
632
1188
|
const { taskService, boardService } = getContext();
|
|
@@ -679,9 +1235,9 @@ var moveCommand = new Command6("move").description("Move a task to a different c
|
|
|
679
1235
|
|
|
680
1236
|
// src/commands/schema.ts
|
|
681
1237
|
import { jsonSchemas } from "@kaban-board/core";
|
|
682
|
-
import { Command as
|
|
1238
|
+
import { Command as Command8 } from "commander";
|
|
683
1239
|
var availableSchemas = Object.keys(jsonSchemas);
|
|
684
|
-
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) => {
|
|
685
1241
|
if (!name) {
|
|
686
1242
|
console.log("Available schemas:");
|
|
687
1243
|
for (const schemaName of availableSchemas) {
|
|
@@ -702,8 +1258,8 @@ Usage: kaban schema <name>`);
|
|
|
702
1258
|
|
|
703
1259
|
// src/commands/status.ts
|
|
704
1260
|
import { KabanError as KabanError5 } from "@kaban-board/core";
|
|
705
|
-
import { Command as
|
|
706
|
-
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) => {
|
|
707
1263
|
const json = options.json;
|
|
708
1264
|
try {
|
|
709
1265
|
const { taskService, boardService } = getContext();
|
|
@@ -756,15 +1312,398 @@ var statusCommand = new Command8("status").description("Show board status summar
|
|
|
756
1312
|
}
|
|
757
1313
|
});
|
|
758
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
|
+
|
|
759
1698
|
// src/commands/tui.ts
|
|
760
|
-
import { spawn } from "node:child_process";
|
|
761
|
-
import { dirname, join as
|
|
1699
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1700
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
762
1701
|
import { fileURLToPath } from "node:url";
|
|
763
|
-
import { Command as
|
|
764
|
-
var __dirname2 =
|
|
765
|
-
var tuiCommand = new
|
|
766
|
-
const tuiEntry =
|
|
767
|
-
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], {
|
|
768
1707
|
stdio: "inherit",
|
|
769
1708
|
cwd: process.cwd()
|
|
770
1709
|
});
|
|
@@ -776,15 +1715,17 @@ var tuiCommand = new Command9("tui").description("Start interactive Terminal UI"
|
|
|
776
1715
|
// src/index.ts
|
|
777
1716
|
var require2 = createRequire(import.meta.url);
|
|
778
1717
|
var pkg = require2("../package.json");
|
|
779
|
-
var program = new
|
|
1718
|
+
var program = new Command12;
|
|
780
1719
|
program.name("kaban").description("Terminal Kanban for AI Code Agents").version(pkg.version);
|
|
781
1720
|
program.addCommand(initCommand);
|
|
782
1721
|
program.addCommand(addCommand);
|
|
783
1722
|
program.addCommand(listCommand);
|
|
784
1723
|
program.addCommand(moveCommand);
|
|
785
1724
|
program.addCommand(doneCommand);
|
|
786
|
-
program.addCommand(
|
|
1725
|
+
program.addCommand(statusCommand2);
|
|
787
1726
|
program.addCommand(schemaCommand);
|
|
788
1727
|
program.addCommand(mcpCommand);
|
|
789
1728
|
program.addCommand(tuiCommand);
|
|
1729
|
+
program.addCommand(hookCommand);
|
|
1730
|
+
program.addCommand(syncCommand);
|
|
790
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",
|