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 <noreply@anthropic.com>
This commit is contained in:
2
bin/ccc-client.mjs
Executable file
2
bin/ccc-client.mjs
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import '../client/index.mjs';
|
||||||
164
bin/ccc-queue.mjs
Executable file
164
bin/ccc-queue.mjs
Executable file
@@ -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();
|
||||||
2
bin/ccc-server.mjs
Executable file
2
bin/ccc-server.mjs
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import '../server/index.mjs';
|
||||||
57
client/config.mjs
Normal file
57
client/config.mjs
Normal file
@@ -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 <path> or CCC_SECRETS=<path>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!username) {
|
||||||
|
console.error('Username required: --user <name> or CCC_USER=<name>');
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,71 +1,46 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Conduit client — thin CLI wrapper for Claude to call the conduit server.
|
// Conduit client — thin CLI wrapper for Claude to call the conduit server.
|
||||||
// Usage:
|
// Usage:
|
||||||
// node client/index.mjs --secrets /path/to/secrets.json --user agent '{"action": "list-actions"}'
|
// ccc-client '{"action": "list-actions"}'
|
||||||
// node client/index.mjs --secrets /path/to/secrets.json --user agent '{"action":' '"edit-file",' '"filename": "/workspace/foo.mjs"}'
|
// ccc-client '{"action":' '"edit-file",' '"filename": "/workspace/foo.mjs"}'
|
||||||
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { sign_request } from './auth.mjs';
|
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';
|
const BASE_URL = process.env.CONDUIT_URL || 'http://localhost:3015';
|
||||||
|
|
||||||
function get_arg(argv, flag) {
|
export async function call_action(payload, username, secret) {
|
||||||
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) {
|
|
||||||
const body_string = JSON.stringify(payload);
|
const body_string = JSON.stringify(payload);
|
||||||
const res = await fetch(`${BASE_URL}/action`, {
|
const res = await fetch(`${BASE_URL}/action`, {
|
||||||
method: 'POST',
|
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,
|
body: body_string,
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
return { status: res.status, body };
|
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() {
|
async function main() {
|
||||||
const secrets_path = get_arg(process.argv, '--secrets');
|
const { username, secret } = load_client_config(process.argv);
|
||||||
const username = get_arg(process.argv, '--user');
|
|
||||||
|
|
||||||
if (!secrets_path || !username) {
|
|
||||||
console.error('Usage: conduit --secrets <path> --user <name> <json payload>');
|
|
||||||
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 remaining = get_remaining(process.argv);
|
const remaining = get_remaining(process.argv);
|
||||||
|
|
||||||
if (!remaining.length) {
|
if (!remaining.length) {
|
||||||
console.error('Usage: conduit --secrets <path> --user <name> <json payload>');
|
console.error('Usage: ccc-client <json payload>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +52,7 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth_headers = (body_string) => sign_request(user_entry.secret, username, body_string);
|
const { status, body } = await call_action(payload, username, secret);
|
||||||
const { status, body } = await call_action(payload, auth_headers);
|
|
||||||
|
|
||||||
console.log(JSON.stringify(body, null, 2));
|
console.log(JSON.stringify(body, null, 2));
|
||||||
process.exit(status >= 400 ? 1 : 0);
|
process.exit(status >= 400 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
"server": "node server/index.mjs",
|
"server": "node server/index.mjs",
|
||||||
"client": "node client/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": {
|
"dependencies": {
|
||||||
|
"blessed": "^0.1.81",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user