From 772c25f605c005d75c3d7fb64ecb6307bd67b58e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 17:00:25 +0100 Subject: [PATCH] feat(cli): make the agentic-teams stack usable from the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 ++-- completions/mcpctl.bash | 68 +++++++++++++++- completions/mcpctl.fish | 103 ++++++++++++++++++++++-- scripts/generate-completions.ts | 18 ++++- src/cli/src/commands/create.ts | 110 ++++++++++++++++++++++++- src/cli/src/commands/get.ts | 76 ++++++++++++++++++ src/cli/src/commands/repos.ts | 115 +++++++++++++++++++++++++++ src/cli/src/commands/shared.ts | 30 +++++-- src/cli/src/commands/tasks.ts | 137 ++++++++++++++++++++++++++++++++ src/cli/src/index.ts | 16 ++++ 10 files changed, 658 insertions(+), 31 deletions(-) create mode 100644 src/cli/src/commands/repos.ts create mode 100644 src/cli/src/commands/tasks.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e1ad59f..287d7e1 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2863,9 +2863,9 @@ "131", "133" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.459Z" }, { "id": "137", @@ -2877,9 +2877,9 @@ "dependencies": [ "136" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.469Z" }, { "id": "138", @@ -2905,9 +2905,9 @@ "dependencies": [ "138" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.475Z" }, { "id": "140", @@ -3265,9 +3265,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T15:42:40.452Z", + "lastModified": "2026-08-08T16:00:25.475Z", "taskCount": 163, - "completedCount": 119, + "completedCount": 122, "tags": [ "master" ] diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..8b4f96d 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,11 +5,11 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" - local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp" # Check if --project/-p was given local has_project=false @@ -136,6 +136,51 @@ _mcpctl() { esac fi return ;; + repos) + local repos_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$repos_sub" ]]; then + COMPREPLY=($(compgen -W "discover import help" -- "$cur")) + else + case "$repos_sub" in + discover) + COMPREPLY=($(compgen -W "-q --query --project --limit -o --output -h --help" -- "$cur")) + ;; + import) + COMPREPLY=($(compgen -W "--project --ssh -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; + tasks) + local tasks_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$tasks_sub" ]]; then + COMPREPLY=($(compgen -W "advance verdict accept reject history help" -- "$cur")) + else + case "$tasks_sub" in + advance) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + verdict) + COMPREPLY=($(compgen -W "--stage --commit --summary --pass --fail -h --help" -- "$cur")) + ;; + accept) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + reject) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + history) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; get) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "$resources -o --output -p --project -A --all -h --help" -- "$cur")) @@ -175,9 +220,24 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "gitprovider epic epictask task repo server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in + gitprovider) + COMPREPLY=($(compgen -W "--url --type --description --secret --key --skip-probe -h --help" -- "$cur")) + ;; + epic) + COMPREPLY=($(compgen -W "--project --content --content-file --description --criterion -h --help" -- "$cur")) + ;; + epictask) + COMPREPLY=($(compgen -W "--epic --project --description --depends-on -h --help" -- "$cur")) + ;; + task) + COMPREPLY=($(compgen -W "--epictask --title --description --risk --repo -h --help" -- "$cur")) + ;; + repo) + COMPREPLY=($(compgen -W "--project --url --branch --path --secret --key -h --help" -- "$cur")) + ;; server) COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --from-template --env-from-secret --force -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ff85934..ec047eb 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -72,14 +72,19 @@ function __mcpctl_resolve_resource case promptrequest promptrequests pr; echo promptrequests case serverattachment serverattachments sa; echo serverattachments case proxymodel proxymodels pm; echo proxymodels - case task tasks inference-task inference-tasks; echo inference-tasks + case inference-task inference-tasks itask itasks; echo inference-tasks + case epic epics; echo epics + case epictask epictasks et; echo epictasks + case task tasks; echo tasks + case repo repos repository repositories; echo repos + case gitprovider gitproviders forge gp; echo gitproviders case all; echo all case '*'; echo $argv[1] end end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -224,11 +229,13 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a repos -d 'Discover and import git repositories from a registered gitprovider' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a tasks -d 'Drive a work item: advance state, record verdicts, accept or reject' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources (servers, projects, instances, all)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent or a project (REPL or one-shot).' @@ -252,7 +259,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' @@ -297,8 +304,52 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-sk # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' +# repos subcommands +set -l repos_cmds discover import +complete -c mcpctl -n "__fish_seen_subcommand_from repos; and not __fish_seen_subcommand_from $repos_cmds" -a discover -d 'List repositories available on a gitprovider' +complete -c mcpctl -n "__fish_seen_subcommand_from repos; and not __fish_seen_subcommand_from $repos_cmds" -a import -d 'Import selected repositories into a project' + +# repos discover options +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -s q -l query -d 'Filter by name' -x +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -l project -d 'Mark which repos this project already has' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -l limit -d 'Max results' -x +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -s o -l output -d 'table | json' -x + +# repos import options +complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l project -d 'Project to import into' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l ssh -d 'Use ssh clone URLs instead of https' + +# tasks subcommands +set -l tasks_cmds advance verdict accept reject history +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a advance -d 'Move a task to the next state' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a verdict -d 'Record a QA / Security / SRE verdict, pinned to the commit it judged' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review — fires the merge' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a reject -d 'Send a task back with direction (the comment is required)' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a history -d 'Show the transition log and recorded verdicts' + +# tasks advance options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks advance" -l note -d 'Note recorded on the transition' -x + +# tasks verdict options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l stage -d 'qa_review | security_review | sre_review' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l commit -d 'Commit the verdict applies to' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l summary -d 'What was found' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l pass -d 'Mark the verdict as passing' +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l fail -d 'Mark the verdict as failing' + +# tasks accept options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks accept" -l note -d 'Comment stored with the decision' -x + +# tasks reject options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks reject" -l note -d 'What needs to change — this is what the agent works from' -x + # create subcommands -set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest +set -l create_cmds gitprovider epic epictask task repo server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a gitprovider -d 'Register a git forge (Gitea) so its repositories can be discovered and imported' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a epic -d 'Create an Epic — the versioned vision tasks are measured against' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a epictask -d 'Create an EpicTask — a roadmap node under an Epic' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a task -d 'Create a work item under an EpicTask' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a repo -d 'Attach a git repository to a project by URL (see `mcpctl repos discover` to import from a provider)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a llm -d 'Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)' @@ -315,6 +366,42 @@ complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a serverattachment -d 'Attach a server to a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request (pending proposal that needs approval)' +# create gitprovider options +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l url -d 'Instance base URL, e.g. https://mysources.co.uk' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l type -d 'Driver type' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l secret -d 'Secret holding the API token' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l key -d 'Key within that secret' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l skip-probe -d 'Skip the credential check at registration time' + +# create epic options +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l project -d 'Project this Epic belongs to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l content -d 'The vision statement' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l content-file -d 'Read the vision statement from a file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l description -d 'Short description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l criterion -d 'Done-criterion "id=text" (repeat); prefix id with q: for a quality criterion' -x + +# create epictask options +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l epic -d 'Parent Epic' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l project -d 'Disambiguate the Epic by project' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l depends-on -d 'Prerequisite epictask in the same Epic (repeat)' -x + +# create task options +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l epictask -d 'Parent EpicTask' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l title -d 'Human-readable title' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l risk -d 'trivial | normal | high — drives which review gates are required' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l repo -d 'CodeRepo id this task works in' -x + +# create repo options +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l project -d 'Project to attach to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l url -d 'Clone URL (https:// or ssh://)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l branch -d 'Default branch' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l path -d 'Workspace-relative checkout path (defaults to the repo name)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l secret -d 'Secret holding the git credential' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l key -d 'Key within that secret' -x + # create server options complete -c mcpctl -n "__mcpctl_subcmd_active create server" -s d -l description -d 'Server description' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l package-name -d 'Package name (npm, PyPI, Go module, etc.)' -x diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 497b0a4..a61c7d3 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -186,7 +186,9 @@ async function extractTree(): Promise { const CANONICAL_RESOURCES = [ 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'personalities', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', - 'serverattachments', 'proxymodels', 'inference-tasks', 'all', + 'serverattachments', 'proxymodels', 'inference-tasks', + // Agentic teams + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -206,10 +208,18 @@ const ALIAS_ENTRIES: [string, string][] = [ ['promptrequest', 'promptrequests'], ['promptrequests', 'promptrequests'], ['pr', 'promptrequests'], ['serverattachment', 'serverattachments'], ['serverattachments', 'serverattachments'], ['sa', 'serverattachments'], ['proxymodel', 'proxymodels'], ['proxymodels', 'proxymodels'], ['pm', 'proxymodels'], - // v5: inference-task queue. Short forms (`task`, `tasks`) are what - // operators actually type — `mcpctl get tasks`. - ['task', 'inference-tasks'], ['tasks', 'inference-tasks'], + // v5 shipped the inference queue under the short `task`/`tasks` aliases. + // Agentic-teams work items own those now (see shared.ts); the queue keeps + // only its explicit spellings plus an `itask` shorthand. ['inference-task', 'inference-tasks'], ['inference-tasks', 'inference-tasks'], + ['itask', 'inference-tasks'], ['itasks', 'inference-tasks'], + // 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'], ['all', 'all'], ]; diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 6f64801..cab750f 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -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('', 'Provider name (lowercase alphanumeric with hyphens)') + .requiredOption('--url ', 'Instance base URL, e.g. https://mysources.co.uk') + .option('--type ', 'Driver type', 'gitea') + .option('--description ', 'Description') + .option('--secret ', 'Secret holding the API token') + .option('--key ', 'Key within that secret', 'TOKEN') + .option('--skip-probe', 'Skip the credential check at registration time') + .action(async (name: string, opts) => { + const body: Record = { 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('', 'Epic name (lowercase alphanumeric with hyphens)') + .requiredOption('--project ', 'Project this Epic belongs to') + .option('--content ', 'The vision statement') + .option('--content-file ', 'Read the vision statement from a file') + .option('--description ', 'Short description') + .option('--criterion ', '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 = { 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('', 'EpicTask name (lowercase alphanumeric with hyphens)') + .requiredOption('--epic ', 'Parent Epic') + .option('--project ', 'Disambiguate the Epic by project') + .option('--description ', 'Description') + .option('--depends-on ', 'Prerequisite epictask in the same Epic (repeat)', collect, []) + .action(async (name: string, opts) => { + const body: Record = { 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('', 'Task name (lowercase alphanumeric with hyphens)') + .requiredOption('--epictask ', 'Parent EpicTask') + .requiredOption('--title ', 'Human-readable title') + .option('--description ', 'Description') + .option('--risk ', 'trivial | normal | high — drives which review gates are required', 'normal') + .option('--repo ', 'CodeRepo id this task works in') + .action(async (name: string, opts) => { + const body: Record = { + 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('', 'Repo name (lowercase alphanumeric with hyphens)') + .requiredOption('--project ', 'Project to attach to') + .requiredOption('--url ', 'Clone URL (https:// or ssh://)') + .option('--branch ', 'Default branch', 'main') + .option('--path ', 'Workspace-relative checkout path (defaults to the repo name)') + .option('--secret ', 'Secret holding the git credential') + .option('--key ', 'Key within that secret', 'TOKEN') + .action(async (name: string, opts) => { + const body: Record = { + 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') diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 0014f95..401bf92 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -413,8 +413,79 @@ const proxymodelColumns: Column[] = [ }, 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[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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>[] { switch (resource) { + case 'epics': + return epicColumns as unknown as Column>[]; + case 'epictasks': + return epicTaskColumns as unknown as Column>[]; + case 'tasks': + return taskColumns as unknown as Column>[]; + case 'repos': + return codeRepoColumns as unknown as Column>[]; + case 'gitproviders': + return gitProviderColumns as unknown as Column>[]; case 'servers': return serverColumns as unknown as Column>[]; case 'projects': @@ -479,6 +550,11 @@ const RESOURCE_KIND: Record = { llms: 'llm', agents: 'agent', personalities: 'personality', + epics: 'epic', + epictasks: 'epictask', + tasks: 'task', + repos: 'repo', + gitproviders: 'gitprovider', }; /** diff --git a/src/cli/src/commands/repos.ts b/src/cli/src/commands/repos.ts new file mode 100644 index 0000000..ee1eb0f --- /dev/null +++ b/src/cli/src/commands/repos.ts @@ -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('', 'GitProvider name or id') + .option('-q, --query ', 'Filter by name') + .option('--project ', 'Mark which repos this project already has') + .option('--limit ', 'Max results', (s: string) => parseInt(s, 10)) + .option('-o, --output ', '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( + `/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 [...]`); + }); + + cmd.command('import') + .description('Import selected repositories into a project') + .argument('', 'GitProvider name or id') + .argument('', 'Owner-qualified names, e.g. michal/mcpctl') + .requiredOption('--project ', '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(`/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; +} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 5fd244a..6ac08e7 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -34,6 +34,22 @@ export const RESOURCE_ALIASES: Record = { 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 = { 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', }; diff --git a/src/cli/src/commands/tasks.ts b/src/cli/src/commands/tasks.ts new file mode 100644 index 0000000..ffee69e --- /dev/null +++ b/src/cli/src/commands/tasks.ts @@ -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 => + resolveNameOrId(client, 'tasks', nameOrId); + + cmd.command('advance') + .description('Move a task to the next state') + .argument('', 'Task name or id') + .argument('', 'Target state (see `mcpctl describe task ` for legal moves)') + .option('--note ', 'Note recorded on the transition') + .action(async (task: string, state: string, opts) => { + const id = await idOf(task); + const body: Record = { toState: state }; + if (opts.note !== undefined) body.note = opts.note; + const updated = await client.post(`/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 name or id') + .requiredOption('--stage ', 'qa_review | security_review | sre_review') + .requiredOption('--commit ', 'Commit the verdict applies to') + .requiredOption('--summary ', '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 name or id') + .option('--note ', '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(`/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 name or id') + .requiredOption('--note ', '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(`/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 name or id') + .action(async (task: string) => { + const id = await idOf(task); + const [events, verdicts] = await Promise.all([ + client.get(`/api/v1/tasks/${id}/events`), + client.get(`/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; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..a5c3da7 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); + } + // --project scoping for mcptokens if (!nameOrId && resource === 'mcptokens' && projectName) { return client.get(`/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),