feat(cli): make the agentic-teams stack usable from the terminal

Everything so far was API-only. This adds the CLI surface and proves the whole
vertical works end to end against a real mcpd and a real Gitea.

  mcpctl create gitprovider mysources --url https://mysources.co.uk \
      --secret gitea-token --key TOKEN
  mcpctl repos discover mysources --query mcp --project demo
  mcpctl repos import   mysources --project demo michal/mcpctl
  mcpctl create epic ship-agents --project demo --content "..." \
      --criterion "cli=CLI can drive a task" --criterion "q:feel=reads at a glance"
  mcpctl create epictask foundation --epic ship-agents
  mcpctl create task wire-cli --epictask foundation --title "Wire the CLI"
  mcpctl tasks advance wire-cli in_progress
  mcpctl tasks verdict wire-cli --stage qa_review --commit 3f2ab19 --pass --summary "..."
  mcpctl tasks accept  wire-cli --note "ship it"
  mcpctl tasks history wire-cli

`repos` and `tasks` are their own verb groups rather than more create/edit
flags. Discovery is inherently two-step — look at what is there, then pick — and
accept/reject are the human gate, not field writes.

`get` gained columns for all five resources. The Epic row shows completeness as
"40% (2/5)" and CodeRepo shows SOURCE/FROM, so imported repos say where they
came from at a glance.

BREAKING: `mcpctl get tasks` now lists WORK ITEMS. The inference queue keeps
`inference-task(s)` and gains `itask(s)`; the short `task`/`tasks` aliases move
to work items. This completes the split already made in RBAC (a186d95) — having
the CLI and RBAC disagree about what "tasks" means would be worse than the
rename.

Verified end to end, not just unit tested: mcpd run locally against the test
database, admin bootstrapped, then the full flow driven through the built CLI.
Discovery returned the real repositories from https://mysources.co.uk, import
created the CodeRepo with provenance, re-discovery marked it `imported`, and the
task walked todo → in_progress → building → self_testing → qa_review with a
FAILING QA verdict correctly refusing `done`, then bounced back, passed on a
second commit, and merged through the accept gate. `tasks history` shows both
verdicts and all twelve transitions.

Two bugs the walkthrough caught that unit tests had not: `get repos` and
`get epics` crashed on `padEnd` because Prisma includes hand back `{ name }`
rather than a bare string, and the completions guard test failed because the
resource lists had changed without regenerating.

Local: build clean, workspace 2440 passed, db 128 passed (same 4 pre-existing
Secret failures), lint errors unchanged at the repo's pre-existing 794 (+2
no-console warnings, the same shape every other command registration uses).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-08 17:00:25 +01:00
parent b70300d389
commit 772c25f605
10 changed files with 658 additions and 31 deletions

View File

@@ -119,7 +119,115 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)');
.description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)');
// ── Agentic teams ──
cmd.command('gitprovider')
.description('Register a git forge (Gitea) so its repositories can be discovered and imported')
.argument('<name>', 'Provider name (lowercase alphanumeric with hyphens)')
.requiredOption('--url <url>', 'Instance base URL, e.g. https://mysources.co.uk')
.option('--type <type>', 'Driver type', 'gitea')
.option('--description <text>', 'Description')
.option('--secret <name>', 'Secret holding the API token')
.option('--key <key>', 'Key within that secret', 'TOKEN')
.option('--skip-probe', 'Skip the credential check at registration time')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = { name, url: opts.url, type: opts.type };
if (opts.description !== undefined) body.description = opts.description;
if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key };
if (opts.skipProbe === true) body.skipProbe = true;
const created = await client.post<{ name: string; type: string; url: string }>('/api/v1/gitproviders', body);
log(`Created gitprovider '${created.name}' (${created.type} @ ${created.url})`);
});
cmd.command('epic')
.description('Create an Epic — the versioned vision tasks are measured against')
.argument('<name>', 'Epic name (lowercase alphanumeric with hyphens)')
.requiredOption('--project <name>', 'Project this Epic belongs to')
.option('--content <text>', 'The vision statement')
.option('--content-file <path>', 'Read the vision statement from a file')
.option('--description <text>', 'Short description')
.option('--criterion <text>', 'Done-criterion "id=text" (repeat); prefix id with q: for a quality criterion', collect, [])
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (content === undefined && opts.contentFile !== undefined) {
const fs = await import('node:fs/promises');
content = (await fs.readFile(opts.contentFile as string, 'utf-8')).trim();
}
if (content === undefined) throw new Error('Pass --content or --content-file');
const doneCriteria = (opts.criterion as string[]).map((raw) => {
const eq = raw.indexOf('=');
if (eq === -1) throw new Error(`Invalid --criterion '${raw}'. Expected id=text`);
let id = raw.slice(0, eq);
const quality = id.startsWith('q:');
if (quality) id = id.slice(2);
return { id, text: raw.slice(eq + 1), kind: quality ? 'quality' : 'outcome', met: false };
});
const body: Record<string, unknown> = { name, project: opts.project, content };
if (opts.description !== undefined) body.description = opts.description;
if (doneCriteria.length > 0) body.doneCriteria = doneCriteria;
const created = await client.post<{ name: string }>('/api/v1/epics', body);
log(`Created epic '${created.name}' in project '${opts.project as string}'`);
});
cmd.command('epictask')
.description('Create an EpicTask — a roadmap node under an Epic')
.argument('<name>', 'EpicTask name (lowercase alphanumeric with hyphens)')
.requiredOption('--epic <name>', 'Parent Epic')
.option('--project <name>', 'Disambiguate the Epic by project')
.option('--description <text>', 'Description')
.option('--depends-on <name>', 'Prerequisite epictask in the same Epic (repeat)', collect, [])
.action(async (name: string, opts) => {
const body: Record<string, unknown> = { name, epic: opts.epic };
if (opts.project !== undefined) body.project = opts.project;
if (opts.description !== undefined) body.description = opts.description;
const created = await client.post<{ id: string; name: string }>('/api/v1/epictasks', body);
log(`Created epictask '${created.name}'`);
for (const dep of opts.dependsOn as string[]) {
await client.post(`/api/v1/epictasks/${created.id}/edges`, { to: dep });
log(` depends on '${dep}'`);
}
});
cmd.command('task')
.description('Create a work item under an EpicTask')
.argument('<name>', 'Task name (lowercase alphanumeric with hyphens)')
.requiredOption('--epictask <name>', 'Parent EpicTask')
.requiredOption('--title <text>', 'Human-readable title')
.option('--description <text>', 'Description')
.option('--risk <level>', 'trivial | normal | high — drives which review gates are required', 'normal')
.option('--repo <id>', 'CodeRepo id this task works in')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name, epictask: opts.epictask, title: opts.title, risk: opts.risk,
};
if (opts.description !== undefined) body.description = opts.description;
if (opts.repo !== undefined) body.repoId = opts.repo;
const created = await client.post<{ name: string; state: string }>('/api/v1/tasks', body);
log(`Created task '${created.name}' (${created.state}, risk=${opts.risk as string})`);
});
cmd.command('repo')
.description('Attach a git repository to a project by URL (see `mcpctl repos discover` to import from a provider)')
.argument('<name>', 'Repo name (lowercase alphanumeric with hyphens)')
.requiredOption('--project <name>', 'Project to attach to')
.requiredOption('--url <url>', 'Clone URL (https:// or ssh://)')
.option('--branch <name>', 'Default branch', 'main')
.option('--path <path>', 'Workspace-relative checkout path (defaults to the repo name)')
.option('--secret <name>', 'Secret holding the git credential')
.option('--key <key>', 'Key within that secret', 'TOKEN')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name, project: opts.project, url: opts.url, defaultBranch: opts.branch,
};
if (opts.path !== undefined) body.path = opts.path;
if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key };
const created = await client.post<{ name: string }>('/api/v1/repos', body);
log(`Created repo '${created.name}' in project '${opts.project as string}'`);
});
// --- create server ---
cmd.command('server')

View File

@@ -413,8 +413,79 @@ const proxymodelColumns: Column<ProxymodelRow>[] = [
}, width: 45 },
];
// ── Agentic teams ──
interface EpicRow {
id: string; name: string; project?: { name: string }; status: string; semver: string;
completeness?: { met: number; total: number; percent: number };
}
const epicColumns: Column<EpicRow>[] = [
{ header: 'NAME', key: 'name', width: 24 },
{ header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 18 },
{ header: 'STATUS', key: 'status', width: 24 },
{ header: 'DONE', key: (r) => r.completeness ? `${String(r.completeness.percent)}% (${String(r.completeness.met)}/${String(r.completeness.total)})` : '-', width: 14 },
{ header: 'SEMVER', key: 'semver', width: 8 },
{ header: 'ID', key: 'id' },
];
interface EpicTaskRow { id: string; name: string; status: string; epicId: string }
const epicTaskColumns: Column<EpicTaskRow>[] = [
{ header: 'NAME', key: 'name', width: 28 },
{ header: 'STATUS', key: 'status', width: 12 },
{ header: 'EPIC', key: 'epicId', width: 26 },
{ header: 'ID', key: 'id' },
];
interface TaskRow {
id: string; name: string; title: string; state: string; risk: string;
assigneeId: string | null; prUrl: string | null;
}
const taskColumns: Column<TaskRow>[] = [
{ header: 'NAME', key: 'name', width: 24 },
{ header: 'TITLE', key: 'title', width: 34 },
{ header: 'STATE', key: 'state', width: 16 },
{ header: 'RISK', key: 'risk', width: 8 },
{ header: 'PR', key: (r) => r.prUrl ?? '-', width: 30 },
{ header: 'ID', key: 'id' },
];
interface CodeRepoRow {
id: string; name: string; project?: { name: string }; url: string; defaultBranch: string;
provider: string; sourceProvider: string | null; sourceFullName: string | null;
}
const codeRepoColumns: Column<CodeRepoRow>[] = [
{ header: 'NAME', key: 'name', width: 22 },
{ header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 16 },
{ header: 'BRANCH', key: 'defaultBranch', width: 10 },
{ header: 'SOURCE', key: (r) => r.sourceFullName ?? '-', width: 28 },
{ header: 'FROM', key: (r) => r.sourceProvider ?? '-', width: 14 },
{ header: 'URL', key: 'url', width: 44 },
];
interface GitProviderRow {
id: string; name: string; type: string; url: string; description: string;
credSecretKey: string | null;
}
const gitProviderColumns: Column<GitProviderRow>[] = [
{ header: 'NAME', key: 'name', width: 20 },
{ header: 'TYPE', key: 'type', width: 10 },
{ header: 'URL', key: 'url', width: 40 },
{ header: 'CRED', key: (r) => (r.credSecretKey !== null && r.credSecretKey !== '') ? 'set' : '-', width: 6 },
{ header: 'ID', key: 'id' },
];
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
switch (resource) {
case 'epics':
return epicColumns as unknown as Column<Record<string, unknown>>[];
case 'epictasks':
return epicTaskColumns as unknown as Column<Record<string, unknown>>[];
case 'tasks':
return taskColumns as unknown as Column<Record<string, unknown>>[];
case 'repos':
return codeRepoColumns as unknown as Column<Record<string, unknown>>[];
case 'gitproviders':
return gitProviderColumns as unknown as Column<Record<string, unknown>>[];
case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'projects':
@@ -479,6 +550,11 @@ const RESOURCE_KIND: Record<string, string> = {
llms: 'llm',
agents: 'agent',
personalities: 'personality',
epics: 'epic',
epictasks: 'epictask',
tasks: 'task',
repos: 'repo',
gitproviders: 'gitprovider',
};
/**

View File

@@ -0,0 +1,115 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface ReposCommandDeps {
client: ApiClient;
log: (msg: string) => void;
}
interface DiscoveredRepo {
fullName: string;
name: string;
description: string;
defaultBranch: string;
private: boolean;
empty: boolean;
archived: boolean;
imported: boolean;
unavailableReason?: string;
}
interface ImportResult {
imported: Array<{ name: string; url: string }>;
skipped: Array<{ fullName: string; reason: string }>;
}
function pad(s: string, n: number): string {
return s.length >= n ? s : s + ' '.repeat(n - s.length);
}
/**
* `mcpctl repos` — discover repositories on a registered git provider and
* import the ones you want.
*
* Its own verb group rather than more `create` flags, because discovery is a
* two-step flow: look at what is there, then pick.
*/
export function createReposCommand(deps: ReposCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('repos')
.description('Discover and import git repositories from a registered gitprovider');
cmd.command('discover')
.description('List repositories available on a gitprovider')
.argument('<provider>', 'GitProvider name or id')
.option('-q, --query <text>', 'Filter by name')
.option('--project <name>', 'Mark which repos this project already has')
.option('--limit <n>', 'Max results', (s: string) => parseInt(s, 10))
.option('-o, --output <format>', 'table | json', 'table')
.action(async (provider: string, opts) => {
const id = await resolveNameOrId(client, 'gitproviders', provider);
const params = new URLSearchParams();
if (opts.query !== undefined) params.set('q', opts.query as string);
if (opts.project !== undefined) params.set('project', opts.project as string);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
const rows = await client.get<DiscoveredRepo[]>(
`/api/v1/gitproviders/${id}/repos${qs === '' ? '' : `?${qs}`}`,
);
if (opts.output === 'json') {
log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
log('No repositories found.');
return;
}
log(`${pad('REPOSITORY', 40)}${pad('BRANCH', 10)}${pad('VIS', 9)}${pad('STATUS', 12)}DESCRIPTION`);
log('-'.repeat(100));
for (const r of rows) {
const status = r.imported
? 'imported'
: r.unavailableReason !== undefined ? 'unavailable' : '-';
log(
pad(r.fullName, 40) +
pad(r.defaultBranch, 10) +
pad(r.private ? 'private' : 'public', 9) +
pad(status, 12) +
(r.unavailableReason ?? r.description).slice(0, 40),
);
}
log('');
log(`${String(rows.length)} repositories. Import with:`);
log(` mcpctl repos import ${provider} --project <project> <repo> [<repo>...]`);
});
cmd.command('import')
.description('Import selected repositories into a project')
.argument('<provider>', 'GitProvider name or id')
.argument('<repos...>', 'Owner-qualified names, e.g. michal/mcpctl')
.requiredOption('--project <name>', 'Project to import into')
.option('--ssh', 'Use ssh clone URLs instead of https')
.action(async (provider: string, repos: string[], opts) => {
const id = await resolveNameOrId(client, 'gitproviders', provider);
const result = await client.post<ImportResult>(`/api/v1/gitproviders/${id}/import`, {
project: opts.project,
repos,
...(opts.ssh === true ? { ssh: true } : {}),
});
for (const r of result.imported) log(`Imported ${r.name} ← ${r.url}`);
// Partial success is the contract, so skips are reported, never silent.
for (const s of result.skipped) log(`Skipped ${s.fullName} (${s.reason})`);
log('');
log(`${String(result.imported.length)} imported, ${String(result.skipped.length)} skipped.`);
});
return cmd;
}

View File

@@ -34,6 +34,22 @@ export const RESOURCE_ALIASES: Record<string, string> = {
skill: 'skills',
skills: 'skills',
sk: 'skills',
// ── Agentic teams ──
epic: 'epics',
epics: 'epics',
epictask: 'epictasks',
epictasks: 'epictasks',
et: 'epictasks',
task: 'tasks',
tasks: 'tasks',
repo: 'repos',
repos: 'repos',
repository: 'repos',
repositories: 'repos',
gitprovider: 'gitproviders',
gitproviders: 'gitproviders',
forge: 'gitproviders',
gp: 'gitproviders',
serverattachment: 'serverattachments',
serverattachments: 'serverattachments',
sa: 'serverattachments',
@@ -55,14 +71,16 @@ export const RESOURCE_ALIASES: Record<string, string> = {
personalities: 'personalities',
thread: 'threads',
threads: 'threads',
// v5: durable inference task queue. URL prefix is `/api/v1/inference-tasks`
// (multi-word for clarity); the operator typically wants short
// forms like `mcpctl get tasks`. All variants normalize to the
// canonical plural so URL construction works uniformly.
task: 'inference-tasks',
tasks: 'inference-tasks',
// v5 shipped the durable inference queue under the short `task`/`tasks`
// aliases, on the reasoning that inference was the only "task" we had.
// Agentic-teams work items take those names now (see the `task: 'tasks'`
// entry above and the matching RBAC split in
// src/mcpd/src/bootstrap/rbac-tasks-backfill.ts), so the queue keeps only its
// explicit spellings. `mcpctl get tasks` now lists WORK ITEMS.
'inference-task': 'inference-tasks',
'inference-tasks': 'inference-tasks',
itask: 'inference-tasks',
itasks: 'inference-tasks',
all: 'all',
};

View File

@@ -0,0 +1,137 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface TasksCommandDeps {
client: ApiClient;
log: (msg: string) => void;
}
interface TaskView {
id: string;
name: string;
title: string;
state: string;
risk: string;
legalTransitions?: string[];
}
interface Verdict {
stage: string;
commit: string;
passed: boolean;
summary: string;
createdAt: string;
}
interface TaskEvent {
fromState: string | null;
toState: string;
actorType: string;
note: string | null;
createdAt: string;
}
/**
* `mcpctl tasks` — drive a work item through its lifecycle.
*
* Separate from `create task` because these are verbs on an existing task, and
* because accept/reject are the human gate: a yes/no on a resolved outcome.
*/
export function createTasksCommand(deps: TasksCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('tasks')
.description('Drive a work item: advance state, record verdicts, accept or reject');
const idOf = async (nameOrId: string): Promise<string> =>
resolveNameOrId(client, 'tasks', nameOrId);
cmd.command('advance')
.description('Move a task to the next state')
.argument('<task>', 'Task name or id')
.argument('<state>', 'Target state (see `mcpctl describe task <name>` for legal moves)')
.option('--note <text>', 'Note recorded on the transition')
.action(async (task: string, state: string, opts) => {
const id = await idOf(task);
const body: Record<string, unknown> = { toState: state };
if (opts.note !== undefined) body.note = opts.note;
const updated = await client.post<TaskView>(`/api/v1/tasks/${id}/transition`, body);
log(`${updated.name}: → ${updated.state}`);
if (updated.legalTransitions !== undefined) {
log(` next: ${updated.legalTransitions.join(', ') || '(terminal)'}`);
}
});
cmd.command('verdict')
.description('Record a QA / Security / SRE verdict, pinned to the commit it judged')
.argument('<task>', 'Task name or id')
.requiredOption('--stage <stage>', 'qa_review | security_review | sre_review')
.requiredOption('--commit <sha>', 'Commit the verdict applies to')
.requiredOption('--summary <text>', 'What was found')
.option('--pass', 'Mark the verdict as passing')
.option('--fail', 'Mark the verdict as failing')
.action(async (task: string, opts) => {
if ((opts.pass === true) === (opts.fail === true)) {
throw new Error('Pass exactly one of --pass or --fail');
}
const id = await idOf(task);
await client.post(`/api/v1/tasks/${id}/verdicts`, {
stage: opts.stage, commit: opts.commit, summary: opts.summary, passed: opts.pass === true,
});
log(`Recorded ${opts.stage as string} verdict on ${String(opts.commit).slice(0, 8)}: ${opts.pass === true ? 'PASS' : 'FAIL'}`);
});
cmd.command('accept')
.description('Accept a task awaiting review — fires the merge')
.argument('<task>', 'Task name or id')
.option('--note <text>', 'Comment stored with the decision')
.action(async (task: string, opts) => {
const id = await idOf(task);
const body = opts.note === undefined ? {} : { note: opts.note };
const updated = await client.post<TaskView>(`/api/v1/tasks/${id}/accept`, body);
log(`${updated.name}: accepted → ${updated.state}`);
});
cmd.command('reject')
.description('Send a task back with direction (the comment is required)')
.argument('<task>', 'Task name or id')
.requiredOption('--note <text>', 'What needs to change — this is what the agent works from')
.action(async (task: string, opts) => {
const id = await idOf(task);
const updated = await client.post<TaskView>(`/api/v1/tasks/${id}/reject`, { note: opts.note });
log(`${updated.name}: rejected → ${updated.state}`);
});
cmd.command('history')
.description('Show the transition log and recorded verdicts')
.argument('<task>', 'Task name or id')
.action(async (task: string) => {
const id = await idOf(task);
const [events, verdicts] = await Promise.all([
client.get<TaskEvent[]>(`/api/v1/tasks/${id}/events`),
client.get<Verdict[]>(`/api/v1/tasks/${id}/verdicts`),
]);
log('TRANSITIONS');
for (const e of events) {
const when = new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19);
const from = e.fromState ?? '(new)';
log(` ${when} ${from} → ${e.toState} [${e.actorType}]${e.note !== null ? ` ${e.note}` : ''}`);
}
if (verdicts.length === 0) {
log('');
log('VERDICTS (none recorded)');
return;
}
log('');
log('VERDICTS');
for (const v of verdicts) {
log(` ${v.passed ? 'PASS' : 'FAIL'} ${v.stage} @${v.commit.slice(0, 8)} ${v.summary}`);
}
});
return cmd;
}

View File

@@ -4,6 +4,8 @@ import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js';
import { createReposCommand } from './commands/repos.js';
import { createTasksCommand } from './commands/tasks.js';
import { createDescribeCommand } from './commands/describe.js';
import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
@@ -127,6 +129,12 @@ export function createProgram(): Command {
}
}
// --project scoping for the agentic-teams resources
if (nameOrId === undefined && projectName !== undefined && projectName !== ''
&& (resource === 'epics' || resource === 'repos')) {
return client.get<unknown[]>(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`);
}
// --project scoping for mcptokens
if (!nameOrId && resource === 'mcptokens' && projectName) {
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
@@ -209,6 +217,14 @@ export function createProgram(): Command {
return client.get(`/api/v1/${resource}/${id}`);
};
program.addCommand(createReposCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createTasksCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createGetCommand({
fetchResource,
log: (...args) => console.log(...args),