claude-code-kanban 7.18.0 → 7.20.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/lib/ask.js +215 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/ask.ts +163 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/skills/ask/SKILL.md +44 -0
- package/plugin/plugins/claude-code-kanban/skills/ask/references/playground.md +25 -0
- package/plugin/plugins/claude-code-kanban/tests/ask.test.ts +100 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +1 -1
- package/public/app.js +286 -56
- package/public/index.html +24 -3
- package/public/style.css +176 -1
- package/public/vendor/claude-hub-sdk.js +18 -3
- package/server.js +11 -0
package/lib/ask.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The ask modal's server side: one open question per terminal, from the plugin's ask tool. The tool's
|
|
2
|
+
// call waits on it, long-polling /wait until the board answers, the user skips, or the agent is
|
|
3
|
+
// interrupted. Nothing is written to disk: a question outlives neither the board nor its terminal.
|
|
4
|
+
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const express = require('express');
|
|
8
|
+
const { httpError } = require('./http-error');
|
|
9
|
+
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const ASK_PATH = '/api/terminals/:terminalId/ask';
|
|
12
|
+
// The answer of a playground card is its whole state.
|
|
13
|
+
const BODY_LIMIT = '256kb';
|
|
14
|
+
const MAX_LABEL = 200;
|
|
15
|
+
const SIZES = ['md', 'lg', 'full'];
|
|
16
|
+
// $.http.fetch gives up after 30 s, so a wait answers pending before that.
|
|
17
|
+
const WAIT_MAX_MS = 25_000;
|
|
18
|
+
// An answer that lands between two polls waits this long for the next one.
|
|
19
|
+
const DONE_TTL_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
function parseAsk(body) {
|
|
22
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
|
|
23
|
+
const { sessionId, title, file, size = 'lg' } = body;
|
|
24
|
+
if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
|
|
25
|
+
if (typeof title !== 'string' || !title.trim() || title.length > MAX_LABEL) {
|
|
26
|
+
return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
27
|
+
}
|
|
28
|
+
if (typeof file !== 'string' || !path.isAbsolute(file)) return 'file must be an absolute path';
|
|
29
|
+
if (!SIZES.includes(size)) return `size must be one of ${SIZES.join(', ')}`;
|
|
30
|
+
return { sessionId, title, file, size };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A string is a reason the body is refused.
|
|
34
|
+
function parseAnswer(body) {
|
|
35
|
+
const { status, data } = body || {};
|
|
36
|
+
if (status === 'dismissed') return { status };
|
|
37
|
+
if (status !== 'answered') return 'status must be answered or dismissed';
|
|
38
|
+
if (data === undefined) return 'an answer needs data';
|
|
39
|
+
return { status, data };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} [o]
|
|
44
|
+
* @param {() => number} [o.now]
|
|
45
|
+
*/
|
|
46
|
+
function createAskStore(o = {}) {
|
|
47
|
+
const now = o.now || Date.now;
|
|
48
|
+
const open = new Map();
|
|
49
|
+
const done = new Map();
|
|
50
|
+
const waiters = new Map();
|
|
51
|
+
|
|
52
|
+
function sweep() {
|
|
53
|
+
const t = now();
|
|
54
|
+
for (const [id, d] of done) if (t - d.at > DONE_TTL_MS) done.delete(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function settle(ask, result) {
|
|
58
|
+
open.delete(ask.terminalId);
|
|
59
|
+
done.set(ask.id, { result, at: now() });
|
|
60
|
+
for (const w of waiters.get(ask.id) || []) w(result);
|
|
61
|
+
waiters.delete(ask.id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A new question ends the open one, so the board never holds two.
|
|
65
|
+
function ask(terminalId, { sessionId, title, file, size }) {
|
|
66
|
+
sweep();
|
|
67
|
+
const old = open.get(terminalId);
|
|
68
|
+
if (old) settle(old, { status: 'dismissed', reason: 'replaced' });
|
|
69
|
+
const entry = { id: crypto.randomUUID(), terminalId, sessionId, title, file, size, at: now() };
|
|
70
|
+
open.set(terminalId, entry);
|
|
71
|
+
return { entry, replaced: old?.id ?? null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function current(terminalId) {
|
|
75
|
+
return open.get(terminalId) || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function end(terminalId, askId, result) {
|
|
79
|
+
const entry = open.get(terminalId);
|
|
80
|
+
if (!entry || entry.id !== askId) return false;
|
|
81
|
+
settle(entry, result);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolves the result once, or pending after `ms`; an unknown id is gone.
|
|
86
|
+
function wait(askId, ms) {
|
|
87
|
+
const d = done.get(askId);
|
|
88
|
+
if (d) {
|
|
89
|
+
done.delete(askId);
|
|
90
|
+
return Promise.resolve(d.result);
|
|
91
|
+
}
|
|
92
|
+
if (![...open.values()].some((a) => a.id === askId)) return Promise.resolve({ status: 'gone' });
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const list = waiters.get(askId) || [];
|
|
95
|
+
const finish = (result) => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
done.delete(askId);
|
|
98
|
+
resolve(result);
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
const left = (waiters.get(askId) || []).filter((w) => w !== finish);
|
|
102
|
+
if (left.length) waiters.set(askId, left);
|
|
103
|
+
else waiters.delete(askId);
|
|
104
|
+
resolve({ status: 'pending' });
|
|
105
|
+
}, ms);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
list.push(finish);
|
|
108
|
+
waiters.set(askId, list);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function prune(keep) {
|
|
113
|
+
for (const [tid, entry] of open) if (!keep(tid)) settle(entry, { status: 'dismissed', reason: 'terminal ended' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ask, current, end, wait, prune };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function askBodyParser() {
|
|
120
|
+
return express.json({ limit: BODY_LIMIT });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {import('express').Express} app
|
|
125
|
+
* @param {object} o
|
|
126
|
+
* @param {ReturnType<typeof createAskStore>} o.store
|
|
127
|
+
* @param {(token: string|undefined) => boolean} o.authorized the terminal token
|
|
128
|
+
* @param {(terminalId: string) => boolean} o.hasTerminal a live terminal has this id
|
|
129
|
+
* @param {(absPath: string) => Promise<{content: string|null, kind: string|null}>} o.readPreviewFile
|
|
130
|
+
* @param {(event: object) => void} o.broadcast
|
|
131
|
+
*/
|
|
132
|
+
function mountAskRoutes(app, o) {
|
|
133
|
+
const { store } = o;
|
|
134
|
+
|
|
135
|
+
function guard(req, res) {
|
|
136
|
+
if (!o.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
137
|
+
if (!o.hasTerminal(req.params.terminalId)) return res.status(404).json({ error: 'no live terminal with that id' });
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readCard(file) {
|
|
142
|
+
let read;
|
|
143
|
+
try {
|
|
144
|
+
read = await o.readPreviewFile(file);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e.status === 404) throw httpError(400, `file not found: ${file}`);
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
if (read.kind !== 'html') throw httpError(400, 'file must be an HTML file');
|
|
150
|
+
return read.content;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const route = (fn) => (req, res) => fn(req, res).catch((e) => {
|
|
154
|
+
if (res.headersSent) return;
|
|
155
|
+
if (!e.status) console.error(`Error in ${req.method} ${req.path}:`, e);
|
|
156
|
+
res.status(e.status || 500).json({ error: e.status ? e.message : 'Internal error' });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const closed = (terminalId, id, status) => o.broadcast({ type: 'ask:closed', terminalId, id, status });
|
|
160
|
+
|
|
161
|
+
app.post(ASK_PATH, route(async (req, res) => {
|
|
162
|
+
if (guard(req, res)) return;
|
|
163
|
+
const p = parseAsk(req.body);
|
|
164
|
+
if (typeof p === 'string') return res.status(400).json({ error: p });
|
|
165
|
+
await readCard(p.file);
|
|
166
|
+
const { terminalId } = req.params;
|
|
167
|
+
const { entry, replaced } = store.ask(terminalId, p);
|
|
168
|
+
if (replaced) closed(terminalId, replaced, 'dismissed');
|
|
169
|
+
o.broadcast({ type: 'ask:opened', terminalId, id: entry.id, sessionId: entry.sessionId });
|
|
170
|
+
res.json({ id: entry.id });
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
app.get(ASK_PATH, route(async (req, res) => {
|
|
174
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
175
|
+
const entry = store.current(req.params.terminalId);
|
|
176
|
+
if (!entry) return res.json({ ask: null });
|
|
177
|
+
const { id, title, size, file, sessionId } = entry;
|
|
178
|
+
let content = null;
|
|
179
|
+
try {
|
|
180
|
+
content = await readCard(file);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (!e.status) throw e;
|
|
183
|
+
}
|
|
184
|
+
res.json({ ask: { id, title, size, file, sessionId, content } });
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
app.get(`${ASK_PATH}/:askId/wait`, route(async (req, res) => {
|
|
188
|
+
if (guard(req, res)) return;
|
|
189
|
+
const ms = Math.min(Math.max(Number(req.query.ms) || WAIT_MAX_MS, 0), WAIT_MAX_MS);
|
|
190
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
191
|
+
res.json(await store.wait(req.params.askId, ms));
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
// The board answers or skips.
|
|
195
|
+
app.post(`${ASK_PATH}/:askId`, route(async (req, res) => {
|
|
196
|
+
if (guard(req, res)) return;
|
|
197
|
+
const result = parseAnswer(req.body);
|
|
198
|
+
if (typeof result === 'string') return res.status(400).json({ error: result });
|
|
199
|
+
const { terminalId, askId } = req.params;
|
|
200
|
+
if (!store.end(terminalId, askId, result)) return res.status(404).json({ error: 'no such question' });
|
|
201
|
+
closed(terminalId, askId, result.status);
|
|
202
|
+
res.status(204).end();
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
// The tool call was interrupted.
|
|
206
|
+
app.delete(`${ASK_PATH}/:askId`, route(async (req, res) => {
|
|
207
|
+
if (guard(req, res)) return;
|
|
208
|
+
const { terminalId, askId } = req.params;
|
|
209
|
+
if (!store.end(terminalId, askId, { status: 'cancelled' })) return res.status(404).json({ error: 'no such question' });
|
|
210
|
+
closed(terminalId, askId, 'cancelled');
|
|
211
|
+
res.status(204).end();
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { createAskStore, mountAskRoutes, askBodyParser, parseAsk, parseAnswer, ASK_PATH, WAIT_MAX_MS };
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
2
|
+
import { cckRoot } from './context'
|
|
3
|
+
import { boardPort } from './show'
|
|
4
|
+
|
|
5
|
+
export const TOOL = 'mcp__claude-code-kanban__ask'
|
|
6
|
+
|
|
7
|
+
type AskInput = { title: string; file: string; size?: 'md' | 'lg' | 'full' }
|
|
8
|
+
type Target = { url: string; terminalId: string }
|
|
9
|
+
|
|
10
|
+
let cck: Promise<string> | undefined
|
|
11
|
+
|
|
12
|
+
function cckDir($: EngineInterface) {
|
|
13
|
+
cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
|
|
14
|
+
([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
|
|
15
|
+
)
|
|
16
|
+
return cck
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let target: Promise<Target | undefined> | undefined
|
|
20
|
+
|
|
21
|
+
// Both vars come from ptyEnv, so only a session started in cck's embedded terminal has them.
|
|
22
|
+
function terminal($: EngineInterface) {
|
|
23
|
+
target ??= Promise.all([$.env.get('CCK_URL'), $.env.get('CCK_TERMINAL_ID')]).then(([url, terminalId]) =>
|
|
24
|
+
url && terminalId ? { url: url.replace(/\/+$/, ''), terminalId } : undefined,
|
|
25
|
+
)
|
|
26
|
+
return target
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Read on every call: a restarted board writes a new token.
|
|
30
|
+
async function terminalToken($: EngineInterface, url: string) {
|
|
31
|
+
const port = boardPort(url)
|
|
32
|
+
if (!port) return undefined
|
|
33
|
+
try {
|
|
34
|
+
const { token } = JSON.parse(await $.fs.read(`${await cckDir($)}/terminal-tokens/${port}.json`))
|
|
35
|
+
return typeof token === 'string' && token ? token : undefined
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function errorText(status: number, text: string) {
|
|
42
|
+
try {
|
|
43
|
+
const { error } = JSON.parse(text)
|
|
44
|
+
if (typeof error === 'string' && error) return error
|
|
45
|
+
} catch {}
|
|
46
|
+
return `cck answered HTTP ${status}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function send(
|
|
50
|
+
$: EngineInterface,
|
|
51
|
+
url: string,
|
|
52
|
+
path: string,
|
|
53
|
+
headers: Record<string, string>,
|
|
54
|
+
body: unknown,
|
|
55
|
+
): Promise<{ text: string } | { deny: string }> {
|
|
56
|
+
try {
|
|
57
|
+
const res = await $.http.fetch(`${url}${path}`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
})
|
|
62
|
+
return res.ok ? { text: res.text } : { deny: errorText(res.status, res.text) }
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { deny: `Cannot reach cck at ${url}: ${err instanceof Error ? err.message : String(err)}` }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
type Answer = { status: 'answered'; data: unknown } | { status: 'dismissed' | 'cancelled' | 'gone' | 'pending'; reason?: string }
|
|
68
|
+
|
|
69
|
+
// Under $.http.fetch's 30 s limit; the board answers pending at this mark.
|
|
70
|
+
export const WAIT_MS = 20_000
|
|
71
|
+
|
|
72
|
+
export function answerText(title: string, a: Answer) {
|
|
73
|
+
if (a.status === 'answered') return `The user answered "${title}":\n${JSON.stringify(a.data, null, 2)}`
|
|
74
|
+
if (a.status === 'dismissed' && a.reason === 'replaced') return `"${title}" was replaced by a newer question.`
|
|
75
|
+
if (a.status === 'dismissed') return `The user skipped "${title}" without answering.`
|
|
76
|
+
return `"${title}" is no longer open on the board; the user did not answer it.`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const askPath = (t: Target, id = '') =>
|
|
80
|
+
`/api/terminals/${encodeURIComponent(t.terminalId)}/ask${id ? `/${encodeURIComponent(id)}` : ''}`
|
|
81
|
+
|
|
82
|
+
async function poll($: EngineInterface, t: Target, id: string): Promise<Answer | { deny: string }> {
|
|
83
|
+
const token = await terminalToken($, t.url)
|
|
84
|
+
if (!token) return { deny: `No terminal token for the cck server at ${t.url}.` }
|
|
85
|
+
try {
|
|
86
|
+
const res = await $.http.fetch(`${t.url}${askPath(t, id)}/wait?ms=${WAIT_MS}`, {
|
|
87
|
+
headers: { 'x-terminal-token': token },
|
|
88
|
+
})
|
|
89
|
+
if (!res.ok) return { deny: errorText(res.status, res.text) }
|
|
90
|
+
return JSON.parse(res.text) as Answer
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { deny: `Lost the cck board at ${t.url}: ${err instanceof Error ? err.message : String(err)}` }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function cancel($: EngineInterface, t: Target, id: string) {
|
|
97
|
+
const token = await terminalToken($, t.url)
|
|
98
|
+
if (!token) return
|
|
99
|
+
try {
|
|
100
|
+
await $.http.fetch(`${t.url}${askPath(t, id)}`, { method: 'DELETE', headers: { 'x-terminal-token': token } })
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The call waits until the user answers or skips. An interrupt aborts `signal`: the hook stops at once
|
|
105
|
+
// and closes the modal, because the dispatch has gone on without it.
|
|
106
|
+
async function ask($: EngineInterface, t: Target, input: AskInput, signal: AbortSignal) {
|
|
107
|
+
const token = await terminalToken($, t.url)
|
|
108
|
+
if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
|
|
109
|
+
const { title, file, size } = input
|
|
110
|
+
const opened = await send($, t.url, askPath(t), { 'x-terminal-token': token }, {
|
|
111
|
+
sessionId: await $.session.id(),
|
|
112
|
+
title,
|
|
113
|
+
file,
|
|
114
|
+
size,
|
|
115
|
+
})
|
|
116
|
+
if ('deny' in opened) return opened
|
|
117
|
+
const { id } = JSON.parse(opened.text) as { id: string }
|
|
118
|
+
const aborted = new Promise<'aborted'>((resolve) => {
|
|
119
|
+
if (signal.aborted) resolve('aborted')
|
|
120
|
+
signal.addEventListener('abort', () => resolve('aborted'), { once: true })
|
|
121
|
+
})
|
|
122
|
+
for (;;) {
|
|
123
|
+
const got = await Promise.race([poll($, t, id), aborted])
|
|
124
|
+
if (got === 'aborted') {
|
|
125
|
+
await cancel($, t, id)
|
|
126
|
+
return { deny: 'Interrupted; the question was closed.' }
|
|
127
|
+
}
|
|
128
|
+
if ('deny' in got) return got
|
|
129
|
+
if (got.status !== 'pending') return { result: answerText(title, got) }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ASK_TOOL = {
|
|
134
|
+
description:
|
|
135
|
+
'Ask the user through an HTML card in a modal over this terminal, and wait for the answer, which comes back as the tool result: the data the card sent, as JSON, or a line that says the user skipped. For fixed choices, use AskUserQuestion; use ask when the answer needs something to see or control: a preview, a diagram, sliders, a long form, a quiz sheet, a playground. Load the claude-code-kanban:ask skill first: it says how to write the card and how it sends the answer. Write the card to an HTML file in your scratchpad, then pass it as file.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
title: { type: 'string', description: 'The question, short: it titles the modal and the chip that reopens it' },
|
|
140
|
+
file: { type: 'string', description: 'Absolute path of the HTML card' },
|
|
141
|
+
size: {
|
|
142
|
+
type: 'string',
|
|
143
|
+
enum: ['md', 'lg', 'full'],
|
|
144
|
+
description: 'Modal size: md for a short form, lg (default) for most cards, full for a playground or a quiz sheet',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
required: ['title', 'file'],
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Only a session in cck's terminal has a board to draw the modal; elsewhere AskUserQuestion serves.
|
|
152
|
+
export const register: Register = (on) => {
|
|
153
|
+
on('session.start', { isInteractive: true }, async ($, e, next) => {
|
|
154
|
+
if (await terminal($)) await $.tool.register({ name: 'ask', ...ASK_TOOL })
|
|
155
|
+
return next(e)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
on('tool.call', { tool: TOOL }, async ($, e, next) => {
|
|
159
|
+
const t = await terminal($)
|
|
160
|
+
if (!t) return { deny: 'No cck board here: use AskUserQuestion.' }
|
|
161
|
+
return ask($, t, e as unknown as AskInput, next.signal)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Register } from 'claude-code'
|
|
2
2
|
import { register as activity } from './activity'
|
|
3
|
+
import { register as ask } from './ask'
|
|
3
4
|
import { register as context } from './context'
|
|
4
5
|
import { register as doorbell } from './doorbell'
|
|
5
6
|
import { register as show } from './show'
|
|
@@ -9,4 +10,5 @@ export const register: Register = (on, options) => {
|
|
|
9
10
|
activity(on, options)
|
|
10
11
|
doorbell(on, options)
|
|
11
12
|
show(on, options)
|
|
13
|
+
ask(on, options)
|
|
12
14
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask
|
|
3
|
+
description: Ask card guide for the cck ask modal, an HTML question the user answers over the terminal. Cards can be a form, quiz sheet, sliders over a preview, a diagram to pick from, or a playground.
|
|
4
|
+
argument-hint: '[playground|quiz|form] [what to ask]'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Ask cards
|
|
8
|
+
|
|
9
|
+
The `mcp__claude-code-kanban__ask` tool exists only in a Claude Code session started in cck's terminal. Without it, ask with AskUserQuestion.
|
|
10
|
+
|
|
11
|
+
An argument names what to ask. A first word of `playground`, `quiz` or `form` sets the card kind; for a playground, read [references/playground.md](references/playground.md) and build one. With no argument, ask the open question of this conversation.
|
|
12
|
+
|
|
13
|
+
The tool opens the card as a modal over the terminal and waits. The user's answer comes back as the tool result.
|
|
14
|
+
|
|
15
|
+
## HTML contract
|
|
16
|
+
|
|
17
|
+
- Write a body fragment to an `.html` file in your scratchpad: no `<!doctype>`, `<html>`, `<head>` or `<body>`. The modal wraps it in a sandboxed document. `<style>` and `<script>` work; the script has no network and no access to the page around it.
|
|
18
|
+
- Pick the size with `size`: `md` 560 px wide for a short form, `lg` 960 px (default) for most cards, `full` the whole terminal for a playground or a quiz sheet. `md` and `lg` take the content's height up to the terminal's, then scroll, so keep their content in normal flow, without `position: fixed` or `100vh`. A `full` card always fills the terminal.
|
|
19
|
+
- Open the card with an `<h2>` that states the question. The modal title is the short form of it.
|
|
20
|
+
- Open links with `<a href="https://...">`; they open in a new browser tab.
|
|
21
|
+
- Take every color and font from these variables, so the card follows the user's light or dark theme:
|
|
22
|
+
|
|
23
|
+
| Variable | Use |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `--color-bg`, `--color-bg-subtle` | Page and panel background |
|
|
26
|
+
| `--color-text`, `--color-text-muted` | Body text, secondary text |
|
|
27
|
+
| `--color-border` | Lines, box outlines |
|
|
28
|
+
| `--color-accent` | The one thing to look at |
|
|
29
|
+
| `--color-info`, `--color-success`, `--color-warning`, `--color-danger` | Status |
|
|
30
|
+
| `--color-series-1` … `--color-series-8` | Chart series, in order |
|
|
31
|
+
| `--color-ramp-1` … `--color-ramp-5` | One-hue magnitude, least to most |
|
|
32
|
+
| `--font-sans`, `--font-mono` | Fonts |
|
|
33
|
+
|
|
34
|
+
## Sending the answer
|
|
35
|
+
|
|
36
|
+
- From a script: `cck.send(data)`, any JSON value, called from a click or a key press. Use it when the answer is state the card holds, such as a playground's settings or a quiz's picks.
|
|
37
|
+
- From markup: a form `<form data-cck-submit="name">` sends `{action, label, fields}` on submit, with each input's `name` as a field; `required` works. A button `<button data-cck-action="name">` sends its action and label. Action names use letters, digits, `.`, `_` and `-`.
|
|
38
|
+
- Put only the Send button where the card sends: the user's picks are already on screen.
|
|
39
|
+
- Send once, with everything you need: the first answer ends the question. Make each value self-explanatory, so the result reads without the card.
|
|
40
|
+
- A skip comes back as a line that says so. Go on without the answer, and ask in chat only if you cannot.
|
|
41
|
+
|
|
42
|
+
## Exhibits in the card
|
|
43
|
+
|
|
44
|
+
A picture the user answers about follows the show skill's guides, read before you draw: [chart](../show/references/chart.md), [diagram](../show/references/diagram.md), [mockup](../show/references/mockup.md). Their widths are for the narrower show card; the `size` above sets this one.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Playground
|
|
2
|
+
|
|
3
|
+
A playground is an ask card with controls on one side and a live preview on the other. The user moves the controls until the preview looks right, then sends. The answer is the settings and a sentence that says what they mean. Post it with `size: "full"`.
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
- Two columns: controls on the left, about 280 px, and the preview filling the rest. Below 720 px the columns stack, preview first.
|
|
8
|
+
- Group the controls by concern under small headings. Put the rarely used ones in a `<details>` that starts closed.
|
|
9
|
+
- Presets sit above the controls as a row of buttons: 3 to 5 named combinations that look good. The first one is the default, so the card looks right on first load.
|
|
10
|
+
- One Send button closes the controls column. The card has no foot: the preview takes all the height.
|
|
11
|
+
|
|
12
|
+
## State
|
|
13
|
+
|
|
14
|
+
- Keep every setting in one `state` object, with a frozen `DEFAULTS` beside it.
|
|
15
|
+
- One `draw()` builds the preview from `state`. Every control only writes to `state` and calls `draw()`; there is no Apply button.
|
|
16
|
+
- Declare presets in markup, `<button data-preset='{"radius":8,"shadow":"soft"}'>Soft</button>`, and read them in one place. A new preset is one line of HTML.
|
|
17
|
+
- Keep the computation apart from the drawing, so the preview and the summary read the same numbers.
|
|
18
|
+
- Read colors from the theme variables on each draw, in canvas and SVG too.
|
|
19
|
+
|
|
20
|
+
## The answer
|
|
21
|
+
|
|
22
|
+
Send calls `cck.send({ summary, state })`:
|
|
23
|
+
|
|
24
|
+
- `summary` is one short sentence, built at send time, that an agent can act on without the card: the last preset picked and only the settings that differ from it, with words next to the numbers ("a soft 8 px shadow"). Keep that preset in `state`, so the summary compares against it, not against `DEFAULTS`.
|
|
25
|
+
- `state` is the full settings object, for exact values.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { On, ToolCallArgs } from 'claude-code'
|
|
2
|
+
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
+
import { answerText, TOOL, WAIT_MS } from '../hooks/ask'
|
|
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 ASK = `${CCK_URL}/api/terminals/${TERMINAL_ID}/ask`
|
|
9
|
+
|
|
10
|
+
type Reply = { status: number; body: unknown }
|
|
11
|
+
type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
|
|
12
|
+
|
|
13
|
+
// `replies` answers the ask routes in order; the doorbell's polls never settle.
|
|
14
|
+
function engine(on: On, env: Record<string, string>, replies: Reply[] = []) {
|
|
15
|
+
const registered: string[] = []
|
|
16
|
+
const requests: Request[] = []
|
|
17
|
+
mock.clock(on)
|
|
18
|
+
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
|
|
19
|
+
on('fs.read', (_$, e) => {
|
|
20
|
+
if (e.path.replaceAll('\\', '/') === TOKEN_FILE) return { value: '{"token":"tok-abc"}' }
|
|
21
|
+
throw new Error(`ENOENT: ${e.path}`)
|
|
22
|
+
})
|
|
23
|
+
on('session.id', () => ({ value: 'sid-1' }))
|
|
24
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
25
|
+
on('tool.register', (_$, e) => {
|
|
26
|
+
registered.push(e.name)
|
|
27
|
+
return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
|
|
28
|
+
})
|
|
29
|
+
on('http.fetch', (_$, e) => {
|
|
30
|
+
if (!e.url.includes('/ask')) return new Promise(() => {})
|
|
31
|
+
const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
|
|
32
|
+
requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
|
|
33
|
+
const reply = replies.shift()
|
|
34
|
+
if (!reply) throw new Error('no reply')
|
|
35
|
+
return { value: { status: reply.status, ok: reply.status < 300, headers: {}, text: JSON.stringify(reply.body) } }
|
|
36
|
+
})
|
|
37
|
+
return { registered, requests }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
|
|
41
|
+
const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
|
|
42
|
+
const START = { cwd: 'C:/proj', surface: 'terminal', isInteractive: true } as const
|
|
43
|
+
|
|
44
|
+
test('answer lines', async () => {
|
|
45
|
+
expect(answerText('Pick', { status: 'answered', data: { a: 1 } })).toBe('The user answered "Pick":\n{\n "a": 1\n}')
|
|
46
|
+
expect(answerText('Pick', { status: 'dismissed' })).toBe('The user skipped "Pick" without answering.')
|
|
47
|
+
expect(answerText('Pick', { status: 'dismissed', reason: 'replaced' })).toBe('"Pick" was replaced by a newer question.')
|
|
48
|
+
expect(answerText('Pick', { status: 'gone' })).toContain('no longer open')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('only a session in the terminal gets the tool', async ($, on) => {
|
|
52
|
+
const board = engine(on, { CCK_URL })
|
|
53
|
+
await $.session.start(START)
|
|
54
|
+
expect(board.registered).toEqual(['show'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('in the terminal: registers ask beside show', async ($, on) => {
|
|
58
|
+
const board = engine(on, GATE)
|
|
59
|
+
await $.session.start(START)
|
|
60
|
+
expect(board.registered).toEqual(['show', 'ask'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('opens the question, polls past pending, and answers with the data', async ($, on) => {
|
|
64
|
+
const board = engine(on, GATE, [
|
|
65
|
+
{ status: 200, body: { id: 'q1' } },
|
|
66
|
+
{ status: 200, body: { status: 'pending' } },
|
|
67
|
+
{ status: 200, body: { status: 'answered', data: { theme: 'light' } } },
|
|
68
|
+
])
|
|
69
|
+
await $.session.start(START)
|
|
70
|
+
const res = await $.tool.call(call({ title: 'Pick a theme', file: 'C:/s/ask.html', size: 'full' }))
|
|
71
|
+
expect(res.result).toBe(answerText('Pick a theme', { status: 'answered', data: { theme: 'light' } }))
|
|
72
|
+
const headers = { 'x-terminal-token': 'tok-abc' }
|
|
73
|
+
expect(board.requests).toEqual([
|
|
74
|
+
{
|
|
75
|
+
url: ASK,
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
78
|
+
body: { sessionId: 'sid-1', title: 'Pick a theme', file: 'C:/s/ask.html', size: 'full' },
|
|
79
|
+
},
|
|
80
|
+
{ url: `${ASK}/q1/wait?ms=${WAIT_MS}`, method: undefined, headers, body: undefined },
|
|
81
|
+
{ url: `${ASK}/q1/wait?ms=${WAIT_MS}`, method: undefined, headers, body: undefined },
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a skip answers that the user skipped', async ($, on) => {
|
|
86
|
+
engine(on, GATE, [
|
|
87
|
+
{ status: 200, body: { id: 'q1' } },
|
|
88
|
+
{ status: 200, body: { status: 'dismissed' } },
|
|
89
|
+
])
|
|
90
|
+
await $.session.start(START)
|
|
91
|
+
const res = await $.tool.call(call({ title: 'Pick', file: 'C:/s/ask.html' }))
|
|
92
|
+
expect(res.result).toBe('The user skipped "Pick" without answering.')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a board refusal reaches the model with its text', async ($, on) => {
|
|
96
|
+
engine(on, GATE, [{ status: 400, body: { error: 'file must be an HTML file' } }])
|
|
97
|
+
await $.session.start(START)
|
|
98
|
+
const res = await $.tool.call(call({ title: 'Pick', file: 'C:/s/ask.md' }))
|
|
99
|
+
expect(res.deny).toBe('file must be an HTML file')
|
|
100
|
+
})
|
|
@@ -56,7 +56,7 @@ test('helpers: the port of CCK_URL, the answer lines', async () => {
|
|
|
56
56
|
test('in the terminal: the overlay tool, file optional', async ($, on) => {
|
|
57
57
|
const board = engine(on, GATE)
|
|
58
58
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
59
|
-
expect(board.registered).toEqual(
|
|
59
|
+
expect(board.registered[0]).toEqual({ name: 'show', required: ['title'] })
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
test('outside the terminal: the pane tool, file required', async ($, on) => {
|