claude-code-kanban 7.5.0 → 7.6.1
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/hub-app.json +5 -0
- package/lib/panes.js +16 -2
- package/lib/parsers.js +27 -2
- package/lib/retention.js +29 -6
- package/lib/session-events.js +154 -42
- package/lib/show.js +385 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +27 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +1149 -108
- package/public/index.html +7 -6
- package/public/style.css +770 -9
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +71 -24
- package/lib/dispatch.js +0 -32
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { On } from 'claude-code'
|
|
2
2
|
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
-
import { boardUrl, RETRY_MS } from '../hooks/doorbell'
|
|
3
|
+
import { boardUrl, parseReply, RETRY_MS } from '../hooks/doorbell'
|
|
4
4
|
|
|
5
5
|
test('the board URL comes from CCK_URL, else from server.json', async () => {
|
|
6
6
|
expect(boardUrl('http://127.0.0.1:4000/', '{"port":3541}')).toBe('http://127.0.0.1:4000')
|
|
@@ -10,7 +10,9 @@ test('the board URL comes from CCK_URL, else from server.json', async () => {
|
|
|
10
10
|
expect(boardUrl(undefined, undefined)).toBe(undefined)
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
type Reply = { status: number; events?: string[]; seqs?: number[]; board?: string }
|
|
14
|
+
|
|
15
|
+
function engine(on: On, replies: (() => Reply)[], submit?: () => Promise<unknown>) {
|
|
14
16
|
const urls: string[] = []
|
|
15
17
|
const prompts: string[] = []
|
|
16
18
|
let sessionId = 'sid-1'
|
|
@@ -25,11 +27,12 @@ function engine(on: On, replies: (() => { status: number; events?: string[] })[]
|
|
|
25
27
|
urls.push(e.url)
|
|
26
28
|
const reply = replies.shift()
|
|
27
29
|
if (!reply) return new Promise(() => {})
|
|
28
|
-
const { status, events = [] } = reply()
|
|
29
|
-
return { value: { status, ok: status < 300, headers: {}, text: JSON.stringify({ events }) } }
|
|
30
|
+
const { status, events = [], seqs, board } = reply()
|
|
31
|
+
return { value: { status, ok: status < 300, headers: {}, text: JSON.stringify({ events, seqs, board }) } }
|
|
30
32
|
})
|
|
31
|
-
on('prompt.submit', (_$, e) => {
|
|
33
|
+
on('prompt.submit', async (_$, e) => {
|
|
32
34
|
prompts.push(e.text)
|
|
35
|
+
if (submit) await submit()
|
|
33
36
|
return { text: e.text }
|
|
34
37
|
})
|
|
35
38
|
return { urls, prompts, clear: () => (sessionId = 'sid-2') }
|
|
@@ -58,17 +61,73 @@ test('polls the board, submits a burst as one prompt, and drops the backlog for
|
|
|
58
61
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
59
62
|
await clock.settle()
|
|
60
63
|
|
|
64
|
+
const base = 'http://127.0.0.1:3541/api/sessions'
|
|
61
65
|
expect(board.prompts).toEqual(['line one\nline two'])
|
|
62
66
|
expect(board.urls).toEqual([
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
`${base}/sid-1/events?wait=25&board=&got=0&ack=0&first=1`,
|
|
68
|
+
`${base}/sid-1/events?wait=25&board=&got=0&ack=0`,
|
|
65
69
|
])
|
|
66
70
|
|
|
67
71
|
await clock.advance(RETRY_MS)
|
|
68
72
|
expect(board.urls.slice(2)).toEqual([
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
`${base}/sid-1/events?wait=25&board=&got=0&ack=0`,
|
|
74
|
+
`${base}/sid-2/events?wait=25&board=&got=0&ack=0&first=1&prev=sid-1`,
|
|
75
|
+
`${base}/sid-2/events?wait=25&board=&got=0&ack=0`,
|
|
72
76
|
])
|
|
73
77
|
expect(board.prompts).toEqual(['line one\nline two', 'after clear'])
|
|
74
78
|
})
|
|
79
|
+
|
|
80
|
+
test('keeps polling while a submit waits, sends what came in as one prompt, and acks only after the submit', async ($, on) => {
|
|
81
|
+
const clock = mock.clock(on)
|
|
82
|
+
let release = () => {}
|
|
83
|
+
const replies = [
|
|
84
|
+
() => ({ status: 200, events: ['move'], seqs: [1], board: 'b1' }),
|
|
85
|
+
() => ({ status: 200, events: ['review one'], seqs: [2], board: 'b1' }),
|
|
86
|
+
() => ({ status: 200, events: ['review two', 'review one'], seqs: [3, 2], board: 'b1' }),
|
|
87
|
+
]
|
|
88
|
+
let held = true
|
|
89
|
+
const board = engine(on, replies, () => (held ? new Promise<void>(r => (release = () => ((held = false), r()))) : Promise.resolve()))
|
|
90
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
91
|
+
await clock.settle()
|
|
92
|
+
|
|
93
|
+
const base = 'http://127.0.0.1:3541/api/sessions/sid-1/events?wait=25'
|
|
94
|
+
expect(board.prompts).toEqual(['move'])
|
|
95
|
+
expect(board.urls).toEqual([
|
|
96
|
+
`${base}&board=&got=0&ack=0&first=1`,
|
|
97
|
+
`${base}&board=b1&got=1&ack=0`,
|
|
98
|
+
`${base}&board=b1&got=2&ack=0`,
|
|
99
|
+
`${base}&board=b1&got=3&ack=0`,
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
release()
|
|
103
|
+
await clock.settle()
|
|
104
|
+
expect(board.prompts).toEqual(['move', 'review one\nreview two'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('acks a submitted line on the next poll', async ($, on) => {
|
|
108
|
+
const clock = mock.clock(on)
|
|
109
|
+
const replies = [() => ({ status: 200, events: ['review'], seqs: [4], board: 'b1' }), () => ({ status: 200, board: 'b1' })]
|
|
110
|
+
const board = engine(on, replies)
|
|
111
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
112
|
+
await clock.settle()
|
|
113
|
+
expect(board.urls.at(-1)).toContain('board=b1&got=4&ack=4')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('starts the count again when the board restarts', async ($, on) => {
|
|
117
|
+
const clock = mock.clock(on)
|
|
118
|
+
const replies = [
|
|
119
|
+
() => ({ status: 200, events: ['old'], seqs: [9], board: 'b1' }),
|
|
120
|
+
() => ({ status: 200, board: 'b1' }),
|
|
121
|
+
() => ({ status: 200, events: ['new'], seqs: [1], board: 'b2' }),
|
|
122
|
+
]
|
|
123
|
+
const board = engine(on, replies)
|
|
124
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
125
|
+
await clock.settle()
|
|
126
|
+
expect(board.prompts).toEqual(['old', 'new'])
|
|
127
|
+
expect(board.urls.at(-1)).toContain('board=b2&got=1&ack=1')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('reads a reply from a board without acks', () => {
|
|
131
|
+
expect(parseReply('{"events":["a",3,"b"]}')).toEqual({ lines: [{ text: 'a', seq: undefined }, { text: 'b', seq: undefined }], board: '' })
|
|
132
|
+
expect(parseReply('{"events":["a"],"seqs":[5],"board":"b1"}')).toEqual({ lines: [{ text: 'a', seq: 5 }], board: 'b1' })
|
|
133
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { On, ToolCallArgs } from 'claude-code'
|
|
2
|
+
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
import { boardPort, dropSkillLine, shownText, TOOL } from '../hooks/show'
|
|
4
|
+
|
|
5
|
+
const CCK_URL = 'http://127.0.0.1:4100'
|
|
6
|
+
const TERMINAL_ID = 'term-1'
|
|
7
|
+
const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
|
|
8
|
+
const LISTING = [
|
|
9
|
+
'The following skills are available for use with the Skill tool:',
|
|
10
|
+
'',
|
|
11
|
+
'- claude-code-kanban:dispatch',
|
|
12
|
+
'- claude-code-kanban:show',
|
|
13
|
+
'- claude-code-kanban:kanban: Kanban board CLI',
|
|
14
|
+
'- meta:show-me: Help the user understand the current topic visually',
|
|
15
|
+
].join('\n')
|
|
16
|
+
|
|
17
|
+
type Reply = { status: number; body: unknown } | Error
|
|
18
|
+
type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
|
|
19
|
+
|
|
20
|
+
function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
21
|
+
const registered: string[] = []
|
|
22
|
+
const requests: Request[] = []
|
|
23
|
+
const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}' }
|
|
24
|
+
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
|
|
25
|
+
on('fs.read', (_$, e) => {
|
|
26
|
+
const text = files[e.path.replaceAll('\\', '/')]
|
|
27
|
+
if (text === undefined) throw new Error(`ENOENT: ${e.path}`)
|
|
28
|
+
return { value: text }
|
|
29
|
+
})
|
|
30
|
+
on('session.id', () => ({ value: 'sid-1' }))
|
|
31
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
32
|
+
on('tool.register', (_$, e) => {
|
|
33
|
+
registered.push(e.name)
|
|
34
|
+
return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
|
|
35
|
+
})
|
|
36
|
+
on('prompt.attachment', (_$, e) => ({ text: e.text }))
|
|
37
|
+
on('http.fetch', (_$, e) => {
|
|
38
|
+
if (!e.url.includes('/show')) return new Promise(() => {})
|
|
39
|
+
const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
|
|
40
|
+
requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
|
|
41
|
+
if (!reply) throw new Error('no reply')
|
|
42
|
+
if (reply instanceof Error) return { deny: reply.message }
|
|
43
|
+
return { value: { status: reply.status, ok: reply.status < 300, headers: {}, text: JSON.stringify(reply.body) } }
|
|
44
|
+
})
|
|
45
|
+
return { registered, requests, files }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
|
|
49
|
+
const listing = { type: 'skill_listing', text: LISTING, origin: { kind: 'engine' } } as const
|
|
50
|
+
const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
|
|
51
|
+
|
|
52
|
+
test('helpers: the port of CCK_URL, the answer line, the listing filter', async () => {
|
|
53
|
+
expect(boardPort('http://127.0.0.1:4100')).toBe('4100')
|
|
54
|
+
expect(boardPort('http://localhost:3541/')).toBe('3541')
|
|
55
|
+
expect(boardPort('http://localhost')).toBe(undefined)
|
|
56
|
+
expect(shownText({ title: 'Plan', key: 'plan', index: 3, count: 7 })).toBe('Shown "Plan" (plan, 3/7)')
|
|
57
|
+
expect(shownText({ title: 'Map', key: null, index: 1, count: 1 })).toBe('Shown "Map" (1/1)')
|
|
58
|
+
expect(shownText({ title: 'Plan', key: 'plan', index: 1, count: 1, path: 'C:/s/p1.md' }, true)).toBe(
|
|
59
|
+
'Card "Plan" (plan, 1/1) is ready. Write it to C:/s/p1.md; the card refreshes on each save.',
|
|
60
|
+
)
|
|
61
|
+
expect(dropSkillLine(LISTING)).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
|
|
62
|
+
expect(dropSkillLine('- claude-code-kanban:show: Show a diagram\n- x')).toBe('- x')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('gate off: no tool, and the skill line is dropped', async ($, on) => {
|
|
66
|
+
const board = engine(on, { CCK_URL })
|
|
67
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
68
|
+
expect(board.registered).toEqual([])
|
|
69
|
+
const { text } = await $.prompt.attachment(listing)
|
|
70
|
+
expect(text).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('gate on: the tool is registered and the skill line stays', async ($, on) => {
|
|
74
|
+
const board = engine(on, GATE)
|
|
75
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
76
|
+
expect(board.registered).toEqual(['show'])
|
|
77
|
+
const { text } = await $.prompt.attachment(listing)
|
|
78
|
+
expect(text).toBe(LISTING)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a -p run gets no tool', async ($, on) => {
|
|
82
|
+
const board = engine(on, GATE)
|
|
83
|
+
await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
|
|
84
|
+
expect(board.registered).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('posts the body with the terminal token and answers with the position', async ($, on) => {
|
|
88
|
+
const board = engine(on, GATE, {
|
|
89
|
+
status: 200,
|
|
90
|
+
body: { id: 'p1', title: 'Plan', key: 'plan', index: 2, count: 3, replaced: false },
|
|
91
|
+
})
|
|
92
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
93
|
+
const res = await $.tool.call(call({ title: 'Plan', key: 'plan', kind: 'markdown', file: 'C:/proj/plan.md' }))
|
|
94
|
+
expect(res.result).toBe('Shown "Plan" (plan, 2/3)')
|
|
95
|
+
expect(board.requests).toEqual([
|
|
96
|
+
{
|
|
97
|
+
url: `${CCK_URL}/api/terminals/${TERMINAL_ID}/show`,
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json', 'x-terminal-token': 'tok-abc' },
|
|
100
|
+
body: { sessionId: 'sid-1', title: 'Plan', key: 'plan', kind: 'markdown', file: 'C:/proj/plan.md' },
|
|
101
|
+
},
|
|
102
|
+
])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a claim sends no file and answers with the file to write', async ($, on) => {
|
|
106
|
+
const path = 'C:/s/.cck/show/p1.md'
|
|
107
|
+
const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Plan', key: 'plan', index: 1, count: 1, path } })
|
|
108
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
109
|
+
const res = await $.tool.call(call({ title: 'Plan', key: 'plan' }))
|
|
110
|
+
expect(res.result).toBe(`Card "Plan" (plan, 1/1) is ready. Write it to ${path}; the card refreshes on each save.`)
|
|
111
|
+
expect(board.requests[0]?.body).toEqual({ sessionId: 'sid-1', title: 'Plan', key: 'plan' })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a 400 answer reaches the model as an error with the server text', async ($, on) => {
|
|
115
|
+
const error = 'file not found: C:/proj/gone.md'
|
|
116
|
+
engine(on, GATE, { status: 400, body: { error } })
|
|
117
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
118
|
+
const res = await $.tool.call(call({ title: 'Gone', file: 'C:/proj/gone.md' }))
|
|
119
|
+
expect(res.deny).toBe(error)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a network error reaches the model as an error', async ($, on) => {
|
|
123
|
+
engine(on, GATE, new Error('connect ECONNREFUSED 127.0.0.1:4100'))
|
|
124
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
125
|
+
const res = await $.tool.call(call({ title: 'Plan' }))
|
|
126
|
+
expect(res.deny).toContain(`Cannot reach cck at ${CCK_URL}`)
|
|
127
|
+
expect(res.deny).toContain('ECONNREFUSED')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('no token file: no request, an error that names the board', async ($, on) => {
|
|
131
|
+
const board = engine(on, GATE)
|
|
132
|
+
delete board.files[TOKEN_FILE]
|
|
133
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
134
|
+
const res = await $.tool.call(call({ title: 'Plan' }))
|
|
135
|
+
expect(res.deny).toContain('No terminal token')
|
|
136
|
+
expect(board.requests).toEqual([])
|
|
137
|
+
})
|