feat(chat): project-scoped chat — mcpctl chat --project <name>
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Chat directly with a Project (no Agent needed): its Prompts become the system context, its MCP-server tools are callable, its llmProvider/llmModel drive the LLM, and (opt-in) the model can read secret values. History is saved inside the project, attributed per user, resumable, and deletable (RBAC-permitting) — "use it like Claude, scoped to the project". Backend (reuses the agent-chat orchestrator): - ChatThread is now agent-XOR-project (schema + migration + CHECK constraint); new listThreadsByProject / deleteThread on the repo. - ChatService: prepareProjectContext (project prompt + Prompts by priority, llm from llmProvider with llmModel override, project tools), shared runChatLoop/runChatStreamLoop, project thread CRUD with owner enforcement (404-not-403 on foreign threads), admin-override delete. - Gated get_secret virtual tool: offered only with --allow-secrets AND the caller's view:secrets; resolves via SecretService, never routes to a server. - routes/project-chat.ts (chat SSE+non-stream, threads create/list/delete); RBAC run:projects:<name>. CLI: - `mcpctl chat --project <name>` (+ --allow-secrets), one-shot/REPL/resume. - REPL /threads, /resume <id>, /delete <id>; project-aware header + /tools. - `mcpctl get threads --project <name>`, `mcpctl delete thread <id> --project`. - completions regenerated (--project completes project names). Tests: 8 new project-chat unit tests; full mcpd (945) + CLI (508) green; schema validated against Postgres. Docs: docs/chat.md "Project chat" section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,14 +38,16 @@ export interface ChatCommandDeps {
|
||||
|
||||
export function createChatCommand(deps: ChatCommandDeps): Command {
|
||||
return new Command('chat')
|
||||
.description('Open an interactive chat session with an agent (REPL or one-shot).')
|
||||
.argument('<agent>', 'Agent name (see `mcpctl get agents`)')
|
||||
.description('Open an interactive chat session with an agent or a project (REPL or one-shot).')
|
||||
.argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.')
|
||||
.option('-p, --project <name>', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets')
|
||||
.option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)')
|
||||
.option('-m, --message <text>', 'One-shot: send a single message and exit (no REPL)')
|
||||
.option('--thread <id>', 'Resume an existing thread')
|
||||
.option('--system <text>', 'Replace agent.systemPrompt for this session')
|
||||
.option('--system <text>', 'Replace the base system prompt for this session')
|
||||
.option('--system-file <path>', 'Read --system text from a file')
|
||||
.option('--system-append <text>', 'Append to the agent system block for this session')
|
||||
.option('--personality <name>', 'Personality overlay (additive prompts on top of the agent)')
|
||||
.option('--system-append <text>', 'Append to the system block for this session')
|
||||
.option('--personality <name>', 'Personality overlay (agent chat only)')
|
||||
.option('--temperature <n>', 'Sampling temperature (0..2)', parseFloat)
|
||||
.option('--top-p <n>', 'Nucleus sampling cutoff (0..1)', parseFloat)
|
||||
.option('--top-k <n>', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt)
|
||||
@@ -55,18 +57,61 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
|
||||
.option('--allow-tool <name>', 'Restrict to this tool only (repeatable)', collect, [])
|
||||
.option('--extra <kv>', 'Provider-specific knob k=v (repeatable)', collect, [])
|
||||
.option('--no-stream', 'Disable SSE streaming (single JSON response)')
|
||||
.action(async (agent: string, opts: ChatOpts) => {
|
||||
.action(async (agent: string | undefined, opts: ChatOpts) => {
|
||||
const subject = resolveSubject(agent, opts);
|
||||
const overrides = await buildInitialOverrides(opts);
|
||||
|
||||
if (opts.message !== undefined) {
|
||||
await runOneShot(deps, agent, opts.message, opts.thread, overrides, opts.stream);
|
||||
await runOneShot(deps, subject, opts.message, opts.thread, overrides, opts.stream);
|
||||
return;
|
||||
}
|
||||
await runRepl(deps, agent, opts.thread, overrides, opts.stream);
|
||||
await runRepl(deps, subject, opts.thread, overrides, opts.stream);
|
||||
});
|
||||
}
|
||||
|
||||
/** What the chat is bound to: a named Agent or a Project. */
|
||||
interface ChatSubject {
|
||||
kind: 'agent' | 'project';
|
||||
name: string;
|
||||
/** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
|
||||
path: string;
|
||||
/** Project chat only: opt into the gated get_secret tool. */
|
||||
allowSecrets: boolean;
|
||||
}
|
||||
|
||||
function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject {
|
||||
if (opts.project !== undefined && agent !== undefined) {
|
||||
throw new Error('Pass either an <agent> or --project <name>, not both.');
|
||||
}
|
||||
if (opts.project !== undefined) {
|
||||
return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true };
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false };
|
||||
}
|
||||
throw new Error('Specify an <agent> name or --project <name>. See `mcpctl chat --help`.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat request body for a subject. Project chat omits the agent-only
|
||||
* `personality` overlay (the project schema rejects unknown fields) and adds
|
||||
* `allowSecrets` when requested.
|
||||
*/
|
||||
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
|
||||
const o: Record<string, unknown> = { ...overrides };
|
||||
if (subject.kind === 'project') {
|
||||
delete o.personality;
|
||||
if (subject.allowSecrets) o.allowSecrets = true;
|
||||
}
|
||||
const body: Record<string, unknown> = { message, ...o };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
if (stream === true) body.stream = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
interface ChatOpts {
|
||||
project?: string;
|
||||
allowSecrets?: boolean;
|
||||
message?: string;
|
||||
thread?: string;
|
||||
system?: string;
|
||||
@@ -136,18 +181,17 @@ function parseExtraValue(raw: string): unknown {
|
||||
|
||||
async function runOneShot(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
message: string,
|
||||
threadId: string | undefined,
|
||||
overrides: Overrides,
|
||||
stream: boolean | undefined,
|
||||
): Promise<void> {
|
||||
await printChatHeader(deps, agent, overrides);
|
||||
await printChatHeader(deps, subject, overrides);
|
||||
if (stream === false) {
|
||||
const body: Record<string, unknown> = { message, ...overrides };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
const body = chatBody(subject, message, threadId, overrides);
|
||||
const startMs = Date.now();
|
||||
const res = await chatRequestNonStream(deps, agent, body);
|
||||
const res = await chatRequestNonStream(deps, subject, body);
|
||||
const sec = Math.max(0.05, (Date.now() - startMs) / 1000);
|
||||
const words = (res.assistant.match(/\S+/g) ?? []).length;
|
||||
process.stdout.write(`${res.assistant}\n`);
|
||||
@@ -159,7 +203,7 @@ async function runOneShot(
|
||||
}
|
||||
const bar = installStatusBar();
|
||||
try {
|
||||
const finalThread = await streamOnce(deps, agent, message, threadId, overrides, bar);
|
||||
const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
|
||||
process.stderr.write(`\n(thread: ${finalThread})\n`);
|
||||
} finally {
|
||||
bar?.teardown();
|
||||
@@ -168,7 +212,7 @@ async function runOneShot(
|
||||
|
||||
async function runRepl(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
initialThread: string | undefined,
|
||||
initialOverrides: Overrides,
|
||||
stream: boolean | undefined,
|
||||
@@ -178,14 +222,14 @@ async function runRepl(
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
await printChatHeader(deps, agent, overrides);
|
||||
await printChatHeader(deps, subject, overrides);
|
||||
|
||||
// The status bar persists across turns inside a REPL — it shows the last
|
||||
// response's final rate between messages, then refreshes live during the
|
||||
// next stream. Only enabled for streaming mode (no rate to show otherwise).
|
||||
const bar = stream === false ? null : installStatusBar();
|
||||
|
||||
process.stderr.write(`Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
|
||||
process.stderr.write(`Slash commands: /threads /resume /delete /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
|
||||
if (threadId !== undefined) {
|
||||
process.stderr.write(`(resuming thread ${threadId})\n`);
|
||||
}
|
||||
@@ -200,20 +244,22 @@ async function runRepl(
|
||||
}
|
||||
if (line === '') continue;
|
||||
if (line.startsWith('/')) {
|
||||
const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; });
|
||||
const handled = await handleSlash(line, deps, subject, overrides, {
|
||||
get: () => threadId,
|
||||
set: (id) => { threadId = id; },
|
||||
});
|
||||
if (handled === 'quit') break;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (stream === false) {
|
||||
const body: Record<string, unknown> = { message: line, ...overrides };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
const res = await chatRequestNonStream(deps, agent, body);
|
||||
const body = chatBody(subject, line, threadId, overrides);
|
||||
const res = await chatRequestNonStream(deps, subject, body);
|
||||
threadId = res.threadId;
|
||||
process.stdout.write(`${res.assistant}\n`);
|
||||
} else {
|
||||
threadId = await streamOnce(deps, agent, line, threadId, overrides, bar);
|
||||
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -226,12 +272,17 @@ async function runRepl(
|
||||
}
|
||||
}
|
||||
|
||||
interface ThreadControl {
|
||||
get(): string | undefined;
|
||||
set(id: string | undefined): void;
|
||||
}
|
||||
|
||||
async function handleSlash(
|
||||
raw: string,
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
overrides: Overrides,
|
||||
resetThread: () => void,
|
||||
thread: ThreadControl,
|
||||
): Promise<'quit' | 'continue'> {
|
||||
const [cmd, ...rest] = raw.slice(1).split(/\s+/);
|
||||
switch (cmd) {
|
||||
@@ -239,9 +290,59 @@ async function handleSlash(
|
||||
case 'exit':
|
||||
return 'quit';
|
||||
case 'clear':
|
||||
resetThread();
|
||||
process.stderr.write('(new thread\n)');
|
||||
thread.set(undefined);
|
||||
process.stderr.write('(new thread)\n');
|
||||
return 'continue';
|
||||
case 'threads': {
|
||||
try {
|
||||
const rows = await deps.client.get<Array<{ id: string; title: string; lastTurnAt: string }>>(
|
||||
`/api/v1/${subject.path}/threads`,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
process.stderr.write('(no saved threads yet)\n');
|
||||
} else {
|
||||
const active = thread.get();
|
||||
for (const r of rows) {
|
||||
const mark = r.id === active ? '* ' : ' ';
|
||||
const title = r.title !== '' ? ` — ${r.title}` : '';
|
||||
process.stderr.write(`${mark}${r.id} ${new Date(r.lastTurnAt).toLocaleString()}${title}\n`);
|
||||
}
|
||||
process.stderr.write('(/resume <id> to switch, /delete <id> to remove)\n');
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`error listing threads: ${(err as Error).message}\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'resume': {
|
||||
const id = rest[0];
|
||||
if (id === undefined || id === '') {
|
||||
process.stderr.write('usage: /resume <thread-id> (see /threads)\n');
|
||||
} else {
|
||||
thread.set(id);
|
||||
process.stderr.write(`(resuming thread ${id})\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'delete': {
|
||||
const id = rest[0];
|
||||
if (id === undefined || id === '') {
|
||||
process.stderr.write('usage: /delete <thread-id> (see /threads)\n');
|
||||
return 'continue';
|
||||
}
|
||||
if (subject.kind !== 'project') {
|
||||
process.stderr.write('(/delete is project-chat only)\n');
|
||||
return 'continue';
|
||||
}
|
||||
try {
|
||||
await deps.client.delete(`/api/v1/projects/${encodeURIComponent(subject.name)}/threads/${encodeURIComponent(id)}`);
|
||||
process.stderr.write(`(deleted thread ${id})\n`);
|
||||
if (thread.get() === id) thread.set(undefined);
|
||||
} catch (err) {
|
||||
process.stderr.write(`error deleting thread: ${(err as Error).message}\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'system': {
|
||||
const text = rest.join(' ');
|
||||
if (text === '') {
|
||||
@@ -265,15 +366,21 @@ async function handleSlash(
|
||||
}
|
||||
case 'tools': {
|
||||
try {
|
||||
const a = await deps.client.get<{ project: { name: string } | null }>(
|
||||
`/api/v1/agents/${encodeURIComponent(agent)}`,
|
||||
);
|
||||
if (a.project === null) {
|
||||
let projectName: string | null;
|
||||
if (subject.kind === 'project') {
|
||||
projectName = subject.name;
|
||||
} else {
|
||||
const a = await deps.client.get<{ project: { name: string } | null }>(
|
||||
`/api/v1/agents/${encodeURIComponent(subject.name)}`,
|
||||
);
|
||||
projectName = a.project?.name ?? null;
|
||||
}
|
||||
if (projectName === null) {
|
||||
process.stderr.write('(agent has no project — no tools available)\n');
|
||||
return 'continue';
|
||||
}
|
||||
const servers = await deps.client.get<Array<{ server: { name: string } }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(a.project.name)}/servers`,
|
||||
`/api/v1/projects/${encodeURIComponent(projectName)}/servers`,
|
||||
);
|
||||
if (servers.length === 0) {
|
||||
process.stderr.write('(project has no MCP servers attached)\n');
|
||||
@@ -281,6 +388,9 @@ async function handleSlash(
|
||||
for (const s of servers) {
|
||||
process.stderr.write(` ${s.server.name}\n`);
|
||||
}
|
||||
if (subject.kind === 'project' && subject.allowSecrets) {
|
||||
process.stderr.write(' secrets (get_secret — reads secret values)\n');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`error listing tools: ${(err as Error).message}\n`);
|
||||
@@ -288,8 +398,12 @@ async function handleSlash(
|
||||
return 'continue';
|
||||
}
|
||||
case 'save': {
|
||||
if (subject.kind === 'project') {
|
||||
process.stderr.write('(/save is agent-only — project chats have no persisted defaults)\n');
|
||||
return 'continue';
|
||||
}
|
||||
try {
|
||||
await deps.client.put(`/api/v1/agents/${encodeURIComponent(agent)}`, {
|
||||
await deps.client.put(`/api/v1/agents/${encodeURIComponent(subject.name)}`, {
|
||||
defaultParams: stripSession(overrides),
|
||||
});
|
||||
process.stderr.write('(saved current overrides as agent.defaultParams)\n');
|
||||
@@ -339,10 +453,10 @@ function applySetCommand(o: Overrides, key: string, valueRaw: string): void {
|
||||
*/
|
||||
async function chatRequestNonStream(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ assistant: string; threadId: string; turnIndex: number }> {
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
|
||||
const payload = JSON.stringify(body);
|
||||
return new Promise((resolve, reject) => {
|
||||
const driver = url.protocol === 'https:' ? https : http;
|
||||
@@ -388,14 +502,14 @@ async function chatRequestNonStream(
|
||||
/** Stream a single chat call. Returns the resolved threadId. */
|
||||
async function streamOnce(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
message: string,
|
||||
threadId: string | undefined,
|
||||
overrides: Overrides,
|
||||
bar: StatusBar | null = null,
|
||||
): Promise<string> {
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
||||
const body = JSON.stringify({ message, threadId, stream: true, ...overrides });
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
|
||||
const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));
|
||||
|
||||
// Per-response counters. Updated on every text/thinking delta, surfaced
|
||||
// live through the bottom-row status bar and the final stats footer.
|
||||
@@ -683,6 +797,14 @@ interface AgentInfo {
|
||||
defaultPersonality?: { name: string } | null;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt?: string;
|
||||
llmProvider?: string | null;
|
||||
llmModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a startup banner showing what the chat session will be running with:
|
||||
* agent name, LLM, project, the assembled system prompt, and any session
|
||||
@@ -693,22 +815,53 @@ interface AgentInfo {
|
||||
*/
|
||||
async function printChatHeader(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
overrides: Overrides,
|
||||
): Promise<void> {
|
||||
let info: AgentInfo;
|
||||
try {
|
||||
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(agent)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sep = '─'.repeat(60);
|
||||
const out = (s: string): void => { process.stderr.write(`${styleStats(s)}\n`); };
|
||||
const indent = (text: string): string =>
|
||||
text.split('\n').map((l) => ` ${l}`).join('\n');
|
||||
|
||||
if (subject.kind === 'project') {
|
||||
let proj: ProjectInfo;
|
||||
try {
|
||||
proj = await deps.client.get<ProjectInfo>(`/api/v1/projects/${encodeURIComponent(subject.name)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch project metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
out(sep);
|
||||
out(`Project: ${proj.name}${proj.description !== '' ? ` — ${proj.description}` : ''}`);
|
||||
const model = proj.llmModel !== null && proj.llmModel !== undefined && proj.llmModel !== '' ? ` (${proj.llmModel})` : '';
|
||||
out(`LLM: ${proj.llmProvider ?? '(none set — set project.llmProvider)'}${model}`);
|
||||
if (overrides.systemOverride !== undefined) {
|
||||
out('System prompt (--system replaces project.prompt):');
|
||||
out(indent(overrides.systemOverride));
|
||||
} else if (proj.prompt !== undefined && proj.prompt !== '') {
|
||||
out('Project prompt:');
|
||||
out(indent(proj.prompt));
|
||||
}
|
||||
if (overrides.systemAppend !== undefined) {
|
||||
out('System append (--system-append):');
|
||||
out(indent(overrides.systemAppend));
|
||||
}
|
||||
out('(project Prompts auto-appended at chat time; /tools lists MCP servers)');
|
||||
if (subject.allowSecrets) out('Secret access: ENABLED (get_secret tool available — requires view:secrets)');
|
||||
const so = describeSessionOverrides(overrides);
|
||||
if (so !== '') out(`Sampling overrides: ${so}`);
|
||||
out(sep);
|
||||
return;
|
||||
}
|
||||
|
||||
let info: AgentInfo;
|
||||
try {
|
||||
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(subject.name)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
out(sep);
|
||||
out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`);
|
||||
const tail = info.project !== null ? ` Project: ${info.project.name}` : '';
|
||||
|
||||
@@ -19,6 +19,17 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { project?: string; agent?: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Chat threads: project-scoped, deleted by id. Owner can delete their own;
|
||||
// admins with delete:projects can delete anyone's (enforced server-side).
|
||||
if (resource === 'threads') {
|
||||
if (!opts.project) {
|
||||
throw new Error('--project is required. Usage: mcpctl delete thread <id> --project <name>');
|
||||
}
|
||||
await client.delete(`/api/v1/projects/${encodeURIComponent(opts.project)}/threads/${encodeURIComponent(idOrName)}`);
|
||||
log(`thread '${idOrName}' deleted from project '${opts.project}'.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serverattachments: delete serverattachment <server> --project <project>
|
||||
if (resource === 'serverattachments') {
|
||||
if (!opts.project) {
|
||||
|
||||
@@ -234,6 +234,14 @@ const agentColumns: Column<AgentRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string }
|
||||
const threadColumns: Column<ThreadRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'TITLE', key: (r) => truncate(r.title, 40) || '(untitled)', width: 40 },
|
||||
{ header: 'LAST TURN', key: (r) => formatAge(r.lastTurnAt), width: 10 },
|
||||
{ header: 'CREATED', key: (r) => formatAge(r.createdAt), width: 10 },
|
||||
];
|
||||
|
||||
interface PersonalityRow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -441,6 +449,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
return agentColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'personalities':
|
||||
return personalityColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'threads':
|
||||
return threadColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'inference-tasks':
|
||||
return inferenceTaskColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
|
||||
@@ -131,6 +131,14 @@ export function createProgram(): Command {
|
||||
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
|
||||
}
|
||||
|
||||
// Chat threads are project-scoped history — `get threads --project <name>`.
|
||||
if (!nameOrId && resource === 'threads') {
|
||||
if (!projectName) {
|
||||
throw new Error('threads are scoped — pass --project <name> (see `mcpctl chat --project`)');
|
||||
}
|
||||
return client.get<unknown[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/threads`);
|
||||
}
|
||||
|
||||
// Name-based lookup for mcptokens: names are unique only within a project
|
||||
if (nameOrId && resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
|
||||
if (!projectName) {
|
||||
|
||||
Reference in New Issue
Block a user