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:
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
115
src/cli/src/commands/repos.ts
Normal file
115
src/cli/src/commands/repos.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
137
src/cli/src/commands/tasks.ts
Normal file
137
src/cli/src/commands/tasks.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user