From e8bbcb293f7cf8949611ab1a4c65a782d9b0eaeb Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 7 Mar 2026 20:49:12 +0000 Subject: [PATCH] Add ccc-server/client/queue bins and blessed queue TUI - bin/ccc-server, ccc-client, ccc-queue wired up via package.json bin - client/config.mjs: shared secrets/user resolution from CLI args or CCC_SECRETS/CCC_USER env vars - ccc-queue: blessed TUI with two-pane layout (list + detail), polls every 2s, y/n to approve/deny selected item, r to refresh, q to quit Co-Authored-By: Claude Sonnet 4.6 --- bin/ccc-client.mjs | 2 + bin/ccc-queue.mjs | 164 +++++++++++++++++++++++++++++++++++++++++++++ bin/ccc-server.mjs | 2 + client/config.mjs | 57 ++++++++++++++++ client/index.mjs | 75 +++++++-------------- package.json | 6 ++ 6 files changed, 255 insertions(+), 51 deletions(-) create mode 100755 bin/ccc-client.mjs create mode 100755 bin/ccc-queue.mjs create mode 100755 bin/ccc-server.mjs create mode 100644 client/config.mjs diff --git a/bin/ccc-client.mjs b/bin/ccc-client.mjs new file mode 100755 index 0000000..fd9d7a9 --- /dev/null +++ b/bin/ccc-client.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../client/index.mjs'; diff --git a/bin/ccc-queue.mjs b/bin/ccc-queue.mjs new file mode 100755 index 0000000..951166a --- /dev/null +++ b/bin/ccc-queue.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import blessed from 'blessed'; +import { load_client_config } from '../client/config.mjs'; +import { get_queue, resolve_queue_item } from '../client/index.mjs'; + +const POLL_INTERVAL = 2000; + +const { username, secret } = load_client_config(process.argv); + +const screen = blessed.screen({ + smartCSR: true, + title: 'ccc-queue', +}); + +// ── Layout ──────────────────────────────────────────────────────────────────── + +const list_box = blessed.list({ + top: 0, + left: 0, + width: '40%', + height: '100%-3', + border: { type: 'line' }, + label: ' Pending Actions ', + scrollable: true, + keys: true, + vi: true, + mouse: true, + style: { + selected: { bg: 'blue', fg: 'white', bold: true }, + border: { fg: 'cyan' }, + label: { fg: 'cyan', bold: true }, + }, +}); + +const detail_box = blessed.box({ + top: 0, + left: '40%', + width: '60%', + height: '100%-3', + border: { type: 'line' }, + label: ' Details ', + scrollable: true, + alwaysScroll: true, + tags: true, + style: { + border: { fg: 'cyan' }, + label: { fg: 'cyan', bold: true }, + }, +}); + +const status_bar = blessed.box({ + bottom: 0, + left: 0, + width: '100%', + height: 3, + border: { type: 'line' }, + tags: true, + style: { border: { fg: 'grey' } }, +}); + +screen.append(list_box); +screen.append(detail_box); +screen.append(status_bar); + +// ── State ───────────────────────────────────────────────────────────────────── + +let items = []; +let status_text = ''; + +function set_status(text, is_error = false) { + status_text = text; + const color = is_error ? 'red' : 'green'; + status_bar.setContent( + ` {${color}-fg}${text}{/${color}-fg} ` + + `{grey-fg}[y]{/grey-fg} approve ` + + `{grey-fg}[n]{/grey-fg} deny ` + + `{grey-fg}[r]{/grey-fg} refresh ` + + `{grey-fg}[q]{/grey-fg} quit` + ); + screen.render(); +} + +function render_detail(item) { + if (!item) { + detail_box.setContent('{grey-fg}No item selected{/grey-fg}'); + return; + } + const lines = [ + `{bold}Action:{/bold} ${item.action}`, + `{bold}ID:{/bold} ${item.id}`, + `{bold}Submitted by:{/bold} ${item.submitted_by}`, + `{bold}Created:{/bold} ${item.created_at}`, + '', + '{bold}Params:{/bold}', + ]; + for (const [k, v] of Object.entries(item.params ?? {})) { + lines.push(` {cyan-fg}${k}{/cyan-fg}: ${v}`); + } + detail_box.setContent(lines.join('\n')); +} + +function render_list() { + const selected = list_box.selected ?? 0; + list_box.clearItems(); + if (items.length === 0) { + list_box.addItem('{grey-fg}(no pending items){/grey-fg}'); + } else { + for (const item of items) { + list_box.addItem(`{yellow-fg}${item.id.slice(0, 8)}{/yellow-fg} ${item.action}`); + } + list_box.select(Math.min(selected, items.length - 1)); + } + render_detail(items[list_box.selected] ?? null); + screen.render(); +} + +// ── Polling ─────────────────────────────────────────────────────────────────── + +async function refresh() { + try { + items = await get_queue(username, secret); + render_list(); + set_status(`Last refresh: ${new Date().toLocaleTimeString()}`); + } catch (err) { + set_status(`Refresh failed: ${err.message}`, true); + } +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + +async function decide(decision) { + const item = items[list_box.selected]; + if (!item) { + return; + } + set_status(`Sending ${decision} for ${item.id.slice(0, 8)}…`); + try { + await resolve_queue_item(item.id, decision, username, secret); + await refresh(); + } catch (err) { + set_status(`Failed: ${err.message}`, true); + } +} + +// ── Keys ────────────────────────────────────────────────────────────────────── + +screen.key(['q', 'C-c'], () => process.exit(0)); +screen.key('r', refresh); +screen.key('y', () => decide('approve')); +screen.key('n', () => decide('deny')); + +list_box.on('select item', () => { + render_detail(items[list_box.selected] ?? null); + screen.render(); +}); + +list_box.focus(); + +// ── Boot ────────────────────────────────────────────────────────────────────── + +set_status('Connecting…'); +refresh(); +const poll = setInterval(refresh, POLL_INTERVAL); +poll.unref(); diff --git a/bin/ccc-server.mjs b/bin/ccc-server.mjs new file mode 100755 index 0000000..4fb0973 --- /dev/null +++ b/bin/ccc-server.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../server/index.mjs'; diff --git a/client/config.mjs b/client/config.mjs new file mode 100644 index 0000000..c7e87bf --- /dev/null +++ b/client/config.mjs @@ -0,0 +1,57 @@ +// Resolve client config from CLI args or environment variables. +// Precedence: CLI args > env vars > error +// +// Env vars: +// CCC_SECRETS path to secrets file +// CCC_USER username to authenticate as + +import { readFileSync } from 'fs'; + +function get_arg(argv, flag) { + const i = argv.indexOf(flag); + return i !== -1 ? argv[i + 1] : null; +} + +export function load_client_config(argv) { + const secrets_path = get_arg(argv, '--secrets') || process.env.CCC_SECRETS; + const username = get_arg(argv, '--user') || process.env.CCC_USER; + + if (!secrets_path) { + console.error('Secrets file required: --secrets or CCC_SECRETS='); + process.exit(1); + } + if (!username) { + console.error('Username required: --user or CCC_USER='); + process.exit(1); + } + + let secrets; + try { + secrets = JSON.parse(readFileSync(secrets_path, 'utf8')); + } catch (err) { + console.error(`Cannot read secrets file: ${err.message}`); + process.exit(1); + } + + const user_entry = secrets.users?.[username]; + if (!user_entry) { + console.error(`User '${username}' not found in secrets file`); + process.exit(1); + } + + return { username, secret: user_entry.secret }; +} + +export function get_remaining(argv) { + const result = []; + let i = 2; + while (i < argv.length) { + if (argv[i] === '--secrets' || argv[i] === '--user') { + i += 2; + } else { + result.push(argv[i]); + i++; + } + } + return result; +} diff --git a/client/index.mjs b/client/index.mjs index 384dc26..9882f1e 100644 --- a/client/index.mjs +++ b/client/index.mjs @@ -1,71 +1,46 @@ #!/usr/bin/env node // Conduit client — thin CLI wrapper for Claude to call the conduit server. // Usage: -// node client/index.mjs --secrets /path/to/secrets.json --user agent '{"action": "list-actions"}' -// node client/index.mjs --secrets /path/to/secrets.json --user agent '{"action":' '"edit-file",' '"filename": "/workspace/foo.mjs"}' +// ccc-client '{"action": "list-actions"}' +// ccc-client '{"action":' '"edit-file",' '"filename": "/workspace/foo.mjs"}' -import { readFileSync } from 'fs'; import { sign_request } from './auth.mjs'; +import { load_client_config, get_remaining } from './config.mjs'; const BASE_URL = process.env.CONDUIT_URL || 'http://localhost:3015'; -function get_arg(argv, flag) { - const i = argv.indexOf(flag); - return i !== -1 ? argv[i + 1] : null; -} - -function get_remaining(argv) { - // Collect all args that aren't --secrets or --user and their values - const result = []; - let i = 2; - while (i < argv.length) { - if (argv[i] === '--secrets' || argv[i] === '--user') { - i += 2; - } else { - result.push(argv[i]); - i++; - } - } - return result; -} - -async function call_action(payload, auth_headers) { +export async function call_action(payload, username, secret) { const body_string = JSON.stringify(payload); const res = await fetch(`${BASE_URL}/action`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...auth_headers(body_string) }, + headers: { 'Content-Type': 'application/json', ...sign_request(secret, username, body_string) }, body: body_string, }); const body = await res.json(); return { status: res.status, body }; } +export async function get_queue(username, secret) { + const res = await fetch(`${BASE_URL}/queue`, { + headers: sign_request(secret, username, ''), + }); + return res.json(); +} + +export async function resolve_queue_item(id, decision, username, secret) { + const res = await fetch(`${BASE_URL}/queue/${id}/${decision}`, { + method: 'POST', + headers: sign_request(secret, username, ''), + }); + return res.json(); +} + async function main() { - const secrets_path = get_arg(process.argv, '--secrets'); - const username = get_arg(process.argv, '--user'); - - if (!secrets_path || !username) { - console.error('Usage: conduit --secrets --user '); - process.exit(1); - } - - let secrets; - try { - secrets = JSON.parse(readFileSync(secrets_path, 'utf8')); - } catch (err) { - console.error(`Cannot read secrets file: ${err.message}`); - process.exit(1); - } - - const user_entry = secrets.users?.[username]; - if (!user_entry) { - console.error(`User '${username}' not found in secrets file`); - process.exit(1); - } - + const { username, secret } = load_client_config(process.argv); const remaining = get_remaining(process.argv); + if (!remaining.length) { - console.error('Usage: conduit --secrets --user '); + console.error('Usage: ccc-client '); process.exit(1); } @@ -77,9 +52,7 @@ async function main() { process.exit(1); } - const auth_headers = (body_string) => sign_request(user_entry.secret, username, body_string); - const { status, body } = await call_action(payload, auth_headers); - + const { status, body } = await call_action(payload, username, secret); console.log(JSON.stringify(body, null, 2)); process.exit(status >= 400 ? 1 : 0); } diff --git a/package.json b/package.json index bbbad23..92283f9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,13 @@ "server": "node server/index.mjs", "client": "node client/index.mjs" }, + "bin": { + "ccc-server": "bin/ccc-server.mjs", + "ccc-client": "bin/ccc-client.mjs", + "ccc-queue": "bin/ccc-queue.mjs" + }, "dependencies": { + "blessed": "^0.1.81", "express": "^5.2.1" } }