claude-code-kanban 4.14.1 → 4.17.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/cli.js +54 -32
- package/install.js +37 -7
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +26 -25
- package/public/app.js +373 -113
- package/public/index.html +56 -31
- package/public/style.css +312 -82
- package/server.js +94 -19
package/server.js
CHANGED
|
@@ -32,7 +32,8 @@ const {
|
|
|
32
32
|
|
|
33
33
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
34
34
|
const { runInstall, runUninstall } = require("./install");
|
|
35
|
-
|
|
35
|
+
const pluginOnly = process.argv.includes("--plugin-only");
|
|
36
|
+
(process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
|
|
36
37
|
.then(() => process.exit(0))
|
|
37
38
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
38
39
|
return;
|
|
@@ -2612,26 +2613,65 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2612
2613
|
}
|
|
2613
2614
|
});
|
|
2614
2615
|
|
|
2615
|
-
// API:
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2616
|
+
// API: File preview — read file and broadcast to clients
|
|
2617
|
+
const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
|
|
2618
|
+
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
2619
|
+
const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
|
|
2620
|
+
|
|
2621
|
+
function previewError(status, message) {
|
|
2622
|
+
const err = new Error(message);
|
|
2623
|
+
err.status = status;
|
|
2624
|
+
return err;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// Existence + kind of a file target, with the HTTP status codes both the preview and
|
|
2628
|
+
// the link-a-file endpoints report. `kind` is null for anything the previewer can't
|
|
2629
|
+
// render — the caller decides whether that disqualifies the path.
|
|
2630
|
+
async function statFileTarget(absPath) {
|
|
2623
2631
|
try {
|
|
2624
|
-
|
|
2632
|
+
const stats = await fs.stat(absPath);
|
|
2633
|
+
if (!stats.isFile()) throw previewError(400, 'Not a file');
|
|
2634
|
+
return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
|
|
2625
2635
|
} catch (e) {
|
|
2626
|
-
if (e.
|
|
2627
|
-
if (e.code === '
|
|
2636
|
+
if (e.status) throw e;
|
|
2637
|
+
if (e.code === 'ENOENT') throw previewError(404, 'File not found');
|
|
2638
|
+
if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
|
|
2628
2639
|
throw e;
|
|
2629
2640
|
}
|
|
2630
2641
|
}
|
|
2631
2642
|
|
|
2643
|
+
// Checks the file is previewable without reading it — the broadcast path needs the
|
|
2644
|
+
// validation (and its status codes) but never the content.
|
|
2645
|
+
async function validatePreviewFile(absPath) {
|
|
2646
|
+
const { kind, size } = await statFileTarget(absPath);
|
|
2647
|
+
if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
|
|
2648
|
+
if (size > PREVIEW_MAX_BYTES) {
|
|
2649
|
+
throw previewError(
|
|
2650
|
+
400,
|
|
2651
|
+
`Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
return { kind, size };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
async function readPreviewFile(absPath) {
|
|
2658
|
+
const { kind } = await validatePreviewFile(absPath);
|
|
2659
|
+
return { content: await fs.readFile(absPath, 'utf8'), kind };
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// Paths copied out of a browser or a slide deck carry a URL fragment
|
|
2663
|
+
// ("deck.html#10"). Dropped only when the literal path is missing and the trimmed one
|
|
2664
|
+
// exists, so a filename that genuinely contains '#' still resolves to itself.
|
|
2665
|
+
function dropUrlFragment(abs) {
|
|
2666
|
+
const i = abs.lastIndexOf('#');
|
|
2667
|
+
if (i <= 0 || existsSync(abs)) return abs;
|
|
2668
|
+
const trimmed = abs.slice(0, i);
|
|
2669
|
+
return existsSync(trimmed) ? trimmed : abs;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2632
2672
|
function resolvePreviewPath(filePath, base) {
|
|
2633
2673
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
2634
|
-
if (path.isAbsolute(filePath)) return filePath;
|
|
2674
|
+
if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
|
|
2635
2675
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
2636
2676
|
let baseDir = base;
|
|
2637
2677
|
try {
|
|
@@ -2640,9 +2680,9 @@ function resolvePreviewPath(filePath, base) {
|
|
|
2640
2680
|
// base doesn't exist — fall back to dirname if it looks like a file
|
|
2641
2681
|
if (path.extname(base)) baseDir = path.dirname(base);
|
|
2642
2682
|
}
|
|
2643
|
-
return path.resolve(baseDir, filePath);
|
|
2683
|
+
return dropUrlFragment(path.resolve(baseDir, filePath));
|
|
2644
2684
|
}
|
|
2645
|
-
return path.resolve(filePath);
|
|
2685
|
+
return dropUrlFragment(path.resolve(filePath));
|
|
2646
2686
|
}
|
|
2647
2687
|
|
|
2648
2688
|
app.post('/api/preview', async (req, res) => {
|
|
@@ -2650,8 +2690,10 @@ app.post('/api/preview', async (req, res) => {
|
|
|
2650
2690
|
const { path: filePath, sessionId, base } = req.body || {};
|
|
2651
2691
|
const abs = resolvePreviewPath(filePath, base);
|
|
2652
2692
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2653
|
-
|
|
2654
|
-
|
|
2693
|
+
// Validate here so the CLI still gets 400/404, but broadcast the path only —
|
|
2694
|
+
// each tab fetches the document itself instead of it being fanned out over SSE.
|
|
2695
|
+
await validatePreviewFile(abs);
|
|
2696
|
+
broadcast({ type: 'preview:open', path: abs, sessionId: sessionId || null });
|
|
2655
2697
|
res.json({ success: true });
|
|
2656
2698
|
} catch (error) {
|
|
2657
2699
|
console.error('Error in /api/preview:', error);
|
|
@@ -2659,6 +2701,24 @@ app.post('/api/preview', async (req, res) => {
|
|
|
2659
2701
|
}
|
|
2660
2702
|
});
|
|
2661
2703
|
|
|
2704
|
+
// API: Link a file to a session's sidebar docs without opening the preview modal.
|
|
2705
|
+
// Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
|
|
2706
|
+
// opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
|
|
2707
|
+
app.post('/api/document/link', async (req, res) => {
|
|
2708
|
+
try {
|
|
2709
|
+
const { path: filePath, sessionId, unlink } = req.body || {};
|
|
2710
|
+
if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
2711
|
+
const abs = resolvePreviewPath(filePath);
|
|
2712
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2713
|
+
if (!unlink) await statFileTarget(abs);
|
|
2714
|
+
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
|
|
2715
|
+
res.json({ success: true });
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
console.error('Error in /api/document/link:', error);
|
|
2718
|
+
res.status(error.status || 500).json({ error: error.message || 'Link failed' });
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2662
2722
|
app.get('/api/session/resolve', (req, res) => {
|
|
2663
2723
|
try {
|
|
2664
2724
|
const idArg = (req.query.id || '').toString();
|
|
@@ -2731,14 +2791,29 @@ app.get('/api/preview', async (req, res) => {
|
|
|
2731
2791
|
try {
|
|
2732
2792
|
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
2733
2793
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2734
|
-
const content = await
|
|
2735
|
-
res.json({ path: abs, content });
|
|
2794
|
+
const { content, kind } = await readPreviewFile(abs);
|
|
2795
|
+
res.json({ path: abs, content, kind });
|
|
2736
2796
|
} catch (error) {
|
|
2737
2797
|
console.error('Error in GET /api/preview:', error);
|
|
2738
2798
|
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
2739
2799
|
}
|
|
2740
2800
|
});
|
|
2741
2801
|
|
|
2802
|
+
// API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
|
|
2803
|
+
// extension-restricted — a linked file the previewer can't render (kind: null) is
|
|
2804
|
+
// still linkable, the client just opens it in the editor instead.
|
|
2805
|
+
app.get('/api/file/resolve', async (req, res) => {
|
|
2806
|
+
try {
|
|
2807
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
2808
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2809
|
+
// No size cap here — an unpreviewable file of any size is still linkable.
|
|
2810
|
+
res.json({ path: abs, ...(await statFileTarget(abs)) });
|
|
2811
|
+
} catch (error) {
|
|
2812
|
+
console.error('Error in GET /api/file/resolve:', error);
|
|
2813
|
+
res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
|
|
2814
|
+
}
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2742
2817
|
// SSE endpoint for live updates
|
|
2743
2818
|
app.get('/api/events', (req, res) => {
|
|
2744
2819
|
res.setHeader('Content-Type', 'text/event-stream');
|