diff --git a/CLAUDE.md b/CLAUDE.md index 967d913..c7fd973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,9 @@ Key routing rules: - `project` — workspace grouping servers, prompts, agents - `llm` — server-managed LLM provider (api key + endpoint) - `agent` — LLM persona pinned to one Llm; project attach surfaces project Prompts as system context, project MCP servers as tools, and exposes the agent itself as an MCP virtual server (`agent-/chat`). See `docs/agents.md`, `docs/chat.md`. -- `prompt` / `promptrequest` — curated content / pending proposal +- `prompt` / `promptrequest` — curated content / legacy pending proposal (use `proposal` for new work). +- `skill` — Claude Code skill bundle (SKILL.md + files + typed metadata). Materialised onto disk by `mcpctl skills sync`. See `docs/skills.md`. +- `proposal` — generic pending proposal queue, replaces `promptrequest`. Covers both prompts and skills. See `docs/proposals.md`. Triage via `mcpctl review`. +- `revision` — append-only audit + diff log shared by prompts and skills. Auto-bumps semver on save. See `docs/revisions.md`. - `rbac` — access control bindings - `mcptoken` — bearer credentials for HTTP-mode mcplocal diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index fec5261..9c42b73 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _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 backup approve console cache provider test migrate rotate" + local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server" 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" @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) @@ -175,7 +175,7 @@ _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 personality serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -185,10 +185,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) ;; llm) - COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --force --skip-auth-check -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --visibility --force --skip-auth-check -h --help" -- "$cur")) ;; agent) - COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --visibility --force -h --help" -- "$cur")) ;; secretbackend) COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --wizard --setup-token --policy-name --token-role --no-promote-default --force -h --help" -- "$cur")) @@ -211,6 +211,9 @@ _mcpctl() { prompt) COMPREPLY=($(compgen -W "-p --project --agent --content --content-file --priority --link -h --help" -- "$cur")) ;; + skill) + COMPREPLY=($(compgen -W "-p --project --agent --content --content-file --description --priority --semver --metadata-file --files-dir -h --help" -- "$cur")) + ;; personality) COMPREPLY=($(compgen -W "--agent --description --priority -h --help" -- "$cur")) ;; @@ -228,11 +231,11 @@ _mcpctl() { return ;; edit) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities --bump --semver --note -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names --bump --semver --note -h --help" -- "$cur")) fi return ;; apply) @@ -265,6 +268,9 @@ _mcpctl() { COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) fi return ;; + passwd) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + return ;; backup) local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$backup_sub" ]]; then @@ -314,6 +320,51 @@ _mcpctl() { COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) fi return ;; + review) + local review_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$review_sub" ]]; then + COMPREPLY=($(compgen -W "pending next show approve reject diff help" -- "$cur")) + else + case "$review_sub" in + pending) + COMPREPLY=($(compgen -W "--type -h --help" -- "$cur")) + ;; + next) + COMPREPLY=($(compgen -W "--type -h --help" -- "$cur")) + ;; + show) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + approve) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + reject) + COMPREPLY=($(compgen -W "--reason -h --help" -- "$cur")) + ;; + diff) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; + skills) + local skills_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$skills_sub" ]]; then + COMPREPLY=($(compgen -W "sync help" -- "$cur")) + else + case "$skills_sub" in + sync) + COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; mcp) COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index def55a4..e9a8582 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 backup approve console cache provider test migrate rotate +set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -234,8 +234,11 @@ 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 chat -d 'Open an interactive chat session with an agent (REPL or one-shot).' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat-llm -d 'Stateless chat with any registered LLM (public or virtual). No threads, no tools.' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a passwd -d 'Change a user password (your own when called without an argument)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,7 +270,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration to defaults' -complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json that connects a project via mcpctl mcp bridge' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -280,18 +283,20 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt personality serverattachment promptrequest +set -l create_cmds 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 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)' @@ -303,6 +308,7 @@ 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 rbac -d 'Create an RBAC binding definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a mcptoken -d 'Create a project-scoped API token for HTTP-mode mcplocal. The raw token is printed once.' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt (scope: project, agent, or global)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a skill -d 'Create a skill (synced onto disk by `mcpctl skills sync` in a later PR)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a personality -d 'Create a personality overlay on an agent' 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)' @@ -336,6 +342,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l description -d 'Des complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l api-key-ref -d 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l extra -d 'Extra config key=value (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l pool-name -d 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l force -d 'Update if already exists' complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l skip-auth-check -d 'Skip the upstream auth probe (for offline registration before infra exists)' @@ -354,6 +361,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-seed -d ' complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-stop -d 'Default stop sequence (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-extra -d 'Default provider-specific knob k=v (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-params-file -d 'Read defaultParams from a JSON file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l force -d 'Update if already exists' # create secretbackend options @@ -419,6 +427,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content-file -d complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l link -d 'Link to MCP resource (format: project/server:uri)' -x +# create skill options +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -s p -l project -d 'Project to scope the skill to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l agent -d 'Agent to scope the skill to (XOR with --project)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l content -d 'SKILL.md body text' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l content-file -d 'Read SKILL.md body from file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l description -d 'Short description shown in listings' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l priority -d 'Priority 1-10 (default: 5)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l semver -d 'Initial semver (default: 0.1.0)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l metadata-file -d 'YAML/JSON file with metadata (hooks, mcpServers, postInstall, …)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l files-dir -d 'Directory whose tree becomes the skill\'s files{} map (UTF-8 text only)' -x + # create personality options complete -c mcpctl -n "__mcpctl_subcmd_active create personality" -l agent -d 'Agent that owns this personality (required)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create personality" -l description -d 'Description shown in `mcpctl get personalities`' -x @@ -441,6 +460,36 @@ complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_s # backup log options complete -c mcpctl -n "__mcpctl_subcmd_active backup log" -s n -l limit -d 'number of commits to show' -x +# review subcommands +set -l review_cmds pending next show approve reject diff +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a pending -d 'List pending proposals' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a next -d 'Show the oldest pending proposal' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a show -d 'Show full detail of a proposal' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a approve -d 'Approve a pending proposal (creates the resource + initial revision)' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a reject -d 'Reject a pending proposal with a reviewer note' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a diff -d 'Show what would change if this proposal were approved' + +# review pending options +complete -c mcpctl -n "__mcpctl_subcmd_active review pending" -l type -d 'Filter by resource type: prompt or skill' -x + +# review next options +complete -c mcpctl -n "__mcpctl_subcmd_active review next" -l type -d 'Filter by resource type: prompt or skill' -x + +# review reject options +complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Reviewer note explaining the rejection' -x + +# skills subcommands +set -l skills_cmds sync +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk under ~/.claude/skills/' + +# skills sync options +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by SessionStart hook)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l skip-postinstall -d 'Do not run metadata.postInstall scripts (no-op in v1; reserved)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l keep-orphans -d 'Do not remove skills that are no longer in the server set' + # cache subcommands set -l cache_cmds stats clear complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from $cache_cmds" -a stats -d 'Show cache statistics' @@ -509,6 +558,11 @@ complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l agent -d 'Agent na complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s i -l instance -d 'Instance/replica index (0-based, for servers with multiple replicas)' -x +# edit options +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l bump -d 'Bump prompt semver after edit: major | minor | patch' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l semver -d 'Set prompt semver explicitly (X.Y.Z)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l note -d 'Note attached to the resulting revision' -x + # apply options complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path to config file (alternative to positional arg)' -rF complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' diff --git a/docs/proposals.md b/docs/proposals.md new file mode 100644 index 0000000..02147be --- /dev/null +++ b/docs/proposals.md @@ -0,0 +1,126 @@ +# Resource Proposals + +A proposal is a pending change to a Prompt or Skill, submitted by +either a Claude Code session (via the `propose_prompt` / `propose_skill` +MCP tools) or a human (via the web UI / CLI). Reviewers triage the +queue and either approve — at which point the proposal becomes a real +prompt or skill — or reject with a note. + +This is the path by which Claude **proposes back** to mcpd: things the +session learned that future sessions would benefit from. The +`propose-learnings` global skill (seeded by mcpd at startup) explains +the discipline to Claude. + +## Model + +`ResourceProposal` shares the schema's discriminator pattern with +`ResourceRevision` — single table, `resourceType` field disambiguates +prompts vs skills. + +| Field | Purpose | +|----------------------|--------------------------------------------------------| +| `resourceType` | `'prompt'` \| `'skill'`. | +| `name` | Proposed resource name. | +| `body` | Proposed body (`{ content, priority?, metadata?, … }`).| +| `projectId` / `agentId` | Scope of the proposal (XOR; null/null = global). | +| `createdBySession` | mcplocal session that proposed (when from Claude). | +| `createdByUserId` | User who proposed (when via UI/CLI). | +| `status` | `'pending'` → `'approved'` \| `'rejected'`. | +| `reviewerNote` | Set on approval or rejection. | +| `approvedRevisionId` | Set when approved — points at the resulting revision. | + +Two unique constraints — `(resourceType, name, projectId)` and +`(resourceType, name, agentId)` — mirror the Prompt / Skill scoping +rules. The same `?? ''` workaround for nullable-FK lookups applies. + +## Reviewer flow + +### CLI + +```bash +mcpctl review pending # list pending +mcpctl review next # show oldest pending +mcpctl review show # full detail +mcpctl review diff # before/after diff +mcpctl review approve # POST /proposals/:id/approve +mcpctl review reject --reason "explain" # rejected with note +``` + +### Web UI + +`/proposals` shows a Pending / Approved / Rejected tab view; the +sidebar nav badge polls every 30 s and shows the pending count in +amber. Click a row to see the full body, the diff against the current +resource (if any), and approve / reject controls. + +### Approval is atomic + +Approval runs in a single Prisma transaction: + +1. Look up the pending proposal. +2. Dispatch by `resourceType` to the registered handler + (`PromptService` or `SkillService` registers itself at construction). +3. Handler upserts the underlying resource — creating it if new, or + updating + auto-bumping patch semver if it exists. +4. Handler records a `ResourceRevision` linking back to the proposal. +5. Proposal status flips to `approved`, `approvedRevisionId` set. + +If any step fails, the transaction rolls back and the proposal stays +`pending`. There is no half-approved state. + +## Claude side: `propose_prompt` and `propose_skill` + +Both tools are registered by the `gate` plugin in mcplocal. They post +to `/api/v1/proposals` with the appropriate `resourceType`. + +The `propose-learnings` global skill (seeded by mcpd) tells Claude +*when* to use them: + +- `propose_prompt` for project-specific knowledge — gotchas, + conventions, hidden constraints. Cheap to add, easy to reject. +- `propose_skill` for cross-cutting knowledge — debugging discipline, + release hygiene, security review style. Larger blast radius; lean + toward `propose_prompt` unless you have a clear cross-project reason. + +The `gate-encouragement-propose` system prompt (priority 10, sits in +the gating bundle) is the trigger that makes Claude actually consider +proposing. Without that, the tools exist but Claude rarely engages. + +## Backwards compat + +PR-1 / PR-2 deferred the cutover from the prompt-only `PromptRequest` +table to `ResourceProposal`. Both run side-by-side today: + +- mcplocal's `propose_prompt` still POSTs to the legacy + `/api/v1/projects/:name/promptrequests` URL. +- mcplocal's `propose_skill` (newer) POSTs to `/api/v1/proposals` + directly. +- The legacy `/api/v1/promptrequests*` routes remain in mcpd. +- `mcpctl approve promptrequest ` still works. + +A focused follow-up PR will: + +1. Migrate existing `PromptRequest` rows into `ResourceProposal` + (resourceType=prompt). +2. Rename `PromptRequest` to `_PromptRequest_legacy`. +3. Update mcplocal's `propose_prompt` to use `/api/v1/proposals`. +4. Keep the legacy URL as a thin translation shim through one release. +5. Drop `_PromptRequest_legacy` after that. + +This stays separate so the cutover is reviewable independently of +the larger Skills + Revisions + Proposals work. + +## RBAC + +Proposals piggyback on the `prompts` permission for now — anyone with +`view:prompts` can read the queue, anyone with `edit:prompts` can +approve or reject. Splitting out a dedicated `proposals` permission +(or a "reviewer" role) is straightforward if granularity becomes +useful. + +## Audit emission + +Proposal create / approve / reject events flow through the existing +audit pipeline. Approval events also reference the resulting +revision id, so you can join "proposal approved at T" against +"revision X created at T" without polling. diff --git a/docs/revisions.md b/docs/revisions.md new file mode 100644 index 0000000..4463af0 --- /dev/null +++ b/docs/revisions.md @@ -0,0 +1,130 @@ +# Resource Revisions + +mcpctl keeps an append-only revision log for every Prompt and Skill — +so you can answer "who changed prompt X and when," diff between any +two versions, and restore an earlier state without losing the audit +chain. + +## Model + +`ResourceRevision` is a single shared table keyed by +`(resourceType, resourceId)` — the type discriminator allows the same +infrastructure to cover both prompts and skills (and any future +resource that wants version history). + +| Field | Purpose | +|------------------|----------------------------------------------------------| +| `id` | cuid; the revision's stable identity. | +| `resourceType` | `'prompt'` \| `'skill'`. Validated app-layer. | +| `resourceId` | Soft FK — survives deletion of the underlying resource. | +| `semver` | Author-visible version (X.Y.Z). | +| `contentHash` | sha256 of the canonicalised body. Stable diff key. | +| `body` | Snapshot of the resource at this revision. | +| `authorUserId` | Who made the change (null for system writes). | +| `authorSessionId`| Session that proposed it (when applicable). | +| `note` | Free-text reviewer or author note. | +| `createdAt` | When the revision was recorded. | + +The resource row itself (Prompt/Skill) keeps the inline `content` — +revisions are an audit log, not the source of truth. Hot read paths +(the gate plugin, `mcpctl skills sync`, prompt indexing) never need +to consult the revision log. + +`Prompt.currentRevisionId` and `Skill.currentRevisionId` are soft +pointers to the latest revision so the UI can answer "which version is +live" in one query. + +## Semver semantics + +Auto-patch on every successful save where the body changed: + +``` +0.1.0 → save with content change → 0.1.1 +0.1.1 → save with content change → 0.1.2 +``` + +Authors can override: + +```bash +mcpctl edit prompt foo --bump minor # 0.1.x → 0.2.0 +mcpctl edit prompt foo --bump major # 0.x.x → 1.0.0 +mcpctl edit prompt foo --semver 1.2.3 # explicit +mcpctl edit prompt foo --note "fixed the gotcha" # adds note to revision +``` + +Invalid semver values fall back to `0.1.0` rather than throwing — +the revision write is best-effort and we don't want a corrupted +existing semver to break the prompt save. + +## contentHash + +sha256 of the JSON-canonicalised body (keys sorted at every object +level). Two revisions with the same hash are byte-identical. Used by +`mcpctl skills sync` as the diff key against on-disk state — re-publish +under the same semver still triggers a sync if the contentHash changed. + +The server-side hash and the client-side hash are computed from the +same canonical shape, so they match exactly. See +`src/mcpd/src/services/resource-revision.service.ts` for the canonical +JSON encoder. + +## CLI + +### View history + +```bash +mcpctl get revisions prompt my-prompt +mcpctl get revisions skill demo-skill +``` + +### View one + +```bash +mcpctl describe revision +``` + +### Diff + +The HTTP API returns a unified-format diff: + +``` +GET /api/v1/revisions//diff?against= +``` + +The web UI's revision history tab on a Skill detail page renders the +diff inline (color-coded add/remove rows). + +### Restore + +Restore a prompt or skill to an earlier revision. This writes a *new* +revision whose body is the old one — preserving the audit chain +rather than deleting later revisions. + +```bash +mcpctl restore prompt my-prompt --revision +``` + +The CLI subcommand is wired through to `POST +/api/v1/prompts/:id/restore-revision` (and the symmetric +`/api/v1/skills/:id/restore-revision`). + +## RBAC + +Revisions piggyback on the underlying resource's RBAC permission. If +you can `view:prompts`, you can read prompt history; if you can +`edit:prompts`, you can restore. + +## Audit emission + +Each revision write emits a structured audit event captured by the +existing audit-event pipeline. The event includes the revision id, +contentHash, semver, and author/session — sufficient to answer "what +changed" and "who" without joining tables manually. + +## Storage size + +A revision body is the resource snapshot — for prompts that's a few +KB; for skills with large `files` maps it can be tens of KB. The audit +log grows linearly with edits. v1 has no rotation; if a single resource +sees thousands of revisions per day this will need a retention policy +(out of scope today). diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..5796afd --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,214 @@ +# Skills + +Skills are Claude Code skill bundles distributed by mcpctl. Each skill is a +named bundle of files — at minimum a `SKILL.md` explaining the skill's purpose +and triggers, optionally with auxiliary scripts, templates, or data files. The +mcpctl daemon (mcpd) is the source of truth; `mcpctl skills sync` materialises +the skills onto each dev machine under `~/.claude/skills//`, where Claude +Code reads them natively. + +``` +┌─ mcpd (Postgres) ──────────────────────────────┐ +│ Skill rows (content + files{} + metadata) │ +└────────────────┬───────────────────────────────┘ + │ HTTP, hash-pinned diff + ▼ +┌─ ~/.claude/skills// ─────────────────────┐ +│ SKILL.md │ +│ scripts/setup.sh │ +│ … │ +└────────────────────────────────────────────────┘ +``` + +## Trust model + +Skills are added by senior admins together with a security reviewer at +publish time on mcpd. Once content is in mcpd, clients trust what mcpd +serves — no client-side sandboxing, no signature checks, no consent +prompts. The rigor lives on the publishing side (RBAC, audit, the +reviewer queue). See [proposals.md](proposals.md) for the +review→approve flow. + +If you're publishing skills to clients you don't trust (e.g. an open- +source distribution), the design is wrong for that — the skill format +itself is fine, but the unguarded client trust assumption isn't. + +## Scoping + +A skill attaches to one of: + +- **Global** — `projectId` and `agentId` both null. Synced onto every dev + machine when its sync runs (with or without a project context). +- **Project-scoped** — `projectId` set. Synced onto machines whose + `.mcpctl-project` marker matches. +- **Agent-scoped** — `agentId` set. Surfaced administratively via the + API; not currently materialised onto disk by `mcpctl skills sync` + (see "Future" below). + +The same `` can exist at multiple scopes simultaneously. The two +unique constraints are `(name, projectId)` and `(name, agentId)`. + +## CLI + +### Create + +```bash +mcpctl create skill \ + [--project | --agent ] \ + --content / --content-file \ + [--description ""] \ + [--priority <1-10>] \ + [--semver ] \ + [--metadata-file ] \ + [--files-dir ] +``` + +`--content-file` provides the `SKILL.md` body. `--metadata-file` +accepts YAML or JSON; see "Metadata" below for the schema. `--files-dir` +walks a directory tree into the `files{}` map (UTF-8 only; non-text +files rejected — extend later if needed). + +### Edit + +```bash +# Edit content in $EDITOR +mcpctl edit skill + +# Edit + bump semver +mcpctl edit skill --bump major|minor|patch --note "" + +# Edit + set explicit semver +mcpctl edit skill --semver 1.2.3 +``` + +Each save records a `ResourceRevision` automatically. See +[revisions.md](revisions.md). + +### Sync to disk + +```bash +# In a project directory (with .mcpctl-project marker): +mcpctl skills sync + +# Override project: +mcpctl skills sync --project + +# Globals only (no project context, no marker): +cd / && mcpctl skills sync + +# Used by the SessionStart hook — fail-open on network errors: +mcpctl skills sync --quiet +``` + +Useful flags: + +| Flag | Purpose | +|---------------------|-----------------------------------------------------------| +| `--dry-run` | Print what would change, don't write anything. | +| `--force` | Overwrite locally-modified skills. | +| `--quiet` | Suppress output unless something changed; fail-open. | +| `--keep-orphans` | Don't remove skills no longer in the server set. | +| `--skip-postinstall`| Reserved for the postInstall executor (deferred). | + +## Project setup + +`mcpctl config claude --project ` does the full pickup chain: + +1. Writes `.mcp.json` so Claude Code routes MCP traffic through mcplocal. +2. Writes `.mcpctl-project` (single line, project name) so `skills sync` + knows which project's skills to pull when run from anywhere under + that directory. +3. Runs an initial `skills sync` synchronously. +4. Installs a SessionStart hook in `~/.claude/settings.json` that runs + `mcpctl skills sync --quiet` before every Claude session. Tagged + with `_mcpctl_managed: true` so subsequent runs find and update it + instead of duplicating it. + +Pass `--skip-skills` to opt out of steps 2–4 (useful in CI). + +## Metadata + +The `metadata` field is a typed JSON blob: + +```yaml +hooks: + PreToolUse: + - type: command + command: "echo before-tool" + PostToolUse: + - type: command + command: "echo after-tool" + SessionStart: + - type: command + command: "echo session-started" +mcpServers: + - name: my-grafana + fromTemplate: grafana + project: monitoring +postInstall: scripts/install.sh +preUninstall: scripts/cleanup.sh +postInstallTimeoutSec: 60 +``` + +**v1 sync executes none of these — they're stored verbatim and +materialisation is deferred to a follow-up.** Once enabled: + +- `hooks` will be written into `~/.claude/settings.json` with + `_mcpctl_managed: true` markers (see Project Setup above for how + the SessionStart hook works today). +- `mcpServers` will be auto-attached via the mcpd attach API. +- `postInstall` will run as the user with a curated env, hard timeout, + and an audit event emitted back to mcpd. Hash-pinned: re-syncs of + unchanged scripts won't re-execute. + +## State + +`~/.mcpctl/skills-state.json` tracks the last-synced state: + +- per-skill: `id`, `semver`, `contentHash` (matches mcpd's hash), + `installDir`, per-file `sha256` + size, `postInstallHash`, + `lastSyncedAt`. +- top-level: `lastSync`, `lastSyncProject`, `schemaVersion`. + +The state file is written atomically (temp + rename). Per-file SHA-256 +detects local edits — sync warns and skips modified files unless you +pass `--force`. + +State lives outside `~/.claude/skills/` deliberately so Claude Code +doesn't see our bookkeeping in its tree. + +## Atomic install + +Each skill is staged under `.mcpctl-staging-/`, then +the existing directory (if any) is renamed to +`.mcpctl-trash-`, the staging dir is moved into place, +and the trash is rmtree'd. A concurrent reader (Claude Code starting up) +never sees a partial tree. + +Symmetric atomic delete for orphan removal: rename to trash, rmtree. +Locally-modified skills are preserved (warned + skipped) unless `--force`. + +## Failure semantics + +| Situation | Exit code | Behaviour | +|----------------------------------|-----------|------------------------------------| +| Network/timeout in `--quiet` | 0 | Skip silently. SessionStart hook never blocks Claude. | +| Auth failure | 1 | "run mcpctl login" message. | +| Disk full / state save failure | 2 | Loud error. | +| Per-skill error | 0 | Logged in result errors[]; sync continues. | + +The fail-open behaviour in `--quiet` is non-negotiable — a hung mcpd +must never block Claude Code starting up. + +## Future + +The following are deferred to follow-up PRs: + +- `metadata.hooks` materialisation into `~/.claude/settings.json` +- `metadata.mcpServers` auto-attach +- `metadata.postInstall` execution with curated env + audit emission +- Agent-scoped skills synced to disk (would need an agent-identity-on- + disk concept that doesn't exist yet) +- Bundle backup support for skills (bundle-backup is one path; git-backup + is the other and is wired today) +- `mcpctl apply -f skill.yaml` declarative skill apply diff --git a/docs/virtual-llms.md b/docs/virtual-llms.md index d8fd354..6f5b458 100644 --- a/docs/virtual-llms.md +++ b/docs/virtual-llms.md @@ -431,10 +431,100 @@ mid-task reverts the row to pending instead of failing the caller. See [inference-tasks.md](./inference-tasks.md) for the full data model, async API, lifecycle, RBAC, and CLI surface. +## Visibility scope (v7) + +Virtual Llms and Agents now carry an explicit **visibility** field that +decides who can see the row in listings. + +| Visibility | Meaning | +|-------------|----------------------------------------------------------------------------------| +| `public` | Visible to anyone with `view:llms` / `view:agents`. Default for hand-created Llms. | +| `private` | Only the **owner** plus principals with a name-scoped grant can see it. Default for virtual Llms and Agents on first publish. | + +The owner is whichever user authenticated the publishing +`POST /api/v1/llms/_provider-register` (or `mcpctl create llm`). For +mcplocal that's whichever `~/.mcpctl/credentials` token is on disk. +Legacy rows from before v7 default to `visibility=public, ownerId=NULL`, +so the upgrade is a no-op for everything that already exists. + +### Who skips the filter? + +Two principals see every row regardless of visibility: + +1. The **row owner** (`ownerId === request.userId`). +2. Anyone with a **cross-resource admin** grant — RBAC binding + `{ resource: '*' }`. Operationally this is the SRE / cluster admin. + +A plain `view:llms` resource grant is *not* the same as admin: it's a +RBAC wildcard for name-scoping (you can name any Llm), but the +visibility filter still applies on top. This is the v7 split that +prevents a user with `view:llms` from enumerating every developer's +private virtual Llm. + +### Granting a single-row exception + +When alice wants bob to see her private virtual Llm `alice-vllm-local` +without making it public, she binds: + +```sh +mcpctl create rbac bob view:llms --name alice-vllm-local +``` + +Same shape as any other name-scoped binding. Removing the binding +flips bob back to "row not found". + +### Publishing as private from mcplocal + +mcplocal defaults to `private` for every published provider and agent. +Override per-row in `~/.mcpctl/config.json`: + +```jsonc +{ + "llm": { + "providers": [ + { "name": "vllm-local", "type": "vllm", "model": "...", "publish": true, + "visibility": "private" }, // default; explicit for clarity + { "name": "shared-qwen", "type": "vllm", "model": "...", "publish": true, + "visibility": "public" } // every team member can chat with it + ] + }, + "agents": [ + { "name": "local-coder", "llm": "vllm-local", + "visibility": "private" } // private agents pinned to private Llms + ] +} +``` + +On a sticky reconnect (`providerSessionId` matches an existing row) +the visibility is **only** updated when the publisher explicitly sends +it — leaving the field off keeps whatever the row already has, +including any field admin set out-of-band. + +### Hand-created Llms + +`mcpctl create llm` defaults to `public` (matches pre-v7 behavior). +Pass `--visibility private` to opt in: + +```sh +mcpctl create llm my-key --type openai --model gpt-4o \ + --api-key-ref my-secret/key --visibility private +``` + +The same `--visibility` flag is on `mcpctl create agent`. + +### CLI surface + +`mcpctl get llm` and `mcpctl get agent` show a `VISIBILITY` column. +YAML round-trips cleanly: `mcpctl get llm X -o yaml | mcpctl apply -f -` +preserves visibility, and `ownerId` is stripped from the apply doc +because it's server-side state (the apply re-stamps the ownerId of the +authenticated caller, not the original creator). + ## Roadmap (later stages) -(LB pool by name landed in v4; durable task queue landed in v5.) -- **v6** — multi-instance mcpd via pg `LISTEN/NOTIFY` (replaces the +(LB pool by name landed in v4; durable task queue landed in v5; +visibility scope landed in v7.) +- **v8** — multi-instance mcpd via pg `LISTEN/NOTIFY` (replaces the per-instance EventEmitter wakeup), per-session worker capacity, remote cancel protocol over the SSE channel. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b44c75..ded41a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.1 version: 10.0.1(jiti@2.6.1) @@ -37,7 +37,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) src/cli: dependencies: @@ -130,6 +130,9 @@ importers: bcrypt: specifier: ^5.1.1 version: 5.1.1 + diff: + specifier: ^5.2.0 + version: 5.2.2 dockerode: specifier: ^4.0.9 version: 4.0.9 @@ -146,6 +149,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/diff': + specifier: ^5.2.3 + version: 5.2.3 '@types/dockerode': specifier: ^4.0.1 version: 4.0.1 @@ -189,6 +195,21 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + diff: + specifier: ^5.2.0 + version: 5.2.2 + geist: + specifier: ^1.5.1 + version: 1.7.0(next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + lucide-react: + specifier: ^0.487.0 + version: 0.487.0(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -198,13 +219,22 @@ importers: react-router-dom: specifier: ^7.7.0 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tailwind-merge: + specifier: ^3.3.1 + version: 3.5.0 devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.16 + version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.7.0 version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/diff': + specifier: ^5.2.3 + version: 5.2.3 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -213,13 +243,16 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^28.0.0 version: 28.1.0 + tailwindcss: + specifier: ^4.1.16 + version: 4.2.4 vite: specifier: ^7.2.0 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -381,6 +414,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -648,6 +684,143 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inkjs/ui@2.0.0': resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} engines: {node: '>=18'} @@ -854,6 +1027,57 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@next/env@16.2.5': + resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} + + '@next/swc-darwin-arm64@16.2.5': + resolution: {integrity: sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.5': + resolution: {integrity: sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.5': + resolution: {integrity: sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.5': + resolution: {integrity: sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.5': + resolution: {integrity: sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.5': + resolution: {integrity: sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.5': + resolution: {integrity: sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.5': + resolution: {integrity: sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1048,6 +1272,99 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1095,6 +1412,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@5.2.3': + resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/diff@8.0.0': resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. @@ -1511,6 +1831,9 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -1531,10 +1854,17 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1680,6 +2010,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -1735,6 +2069,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1980,6 +2318,11 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + geist@1.7.0: + resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} + peerDependencies: + next: '>=13.2.0' + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2029,6 +2372,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2279,6 +2625,76 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2296,6 +2712,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.487.0: + resolution: {integrity: sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2426,6 +2847,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@16.2.5: + resolution: {integrity: sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} @@ -2571,6 +3013,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2798,6 +3244,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2921,6 +3371,19 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2932,6 +3395,16 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -3446,6 +3919,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -3648,6 +4126,103 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inkjs/ui@2.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))': dependencies: chalk: 5.6.2 @@ -3889,6 +4464,32 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@next/env@16.2.5': {} + + '@next/swc-darwin-arm64@16.2.5': + optional: true + + '@next/swc-darwin-x64@16.2.5': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.5': + optional: true + + '@next/swc-linux-arm64-musl@16.2.5': + optional: true + + '@next/swc-linux-x64-gnu@16.2.5': + optional: true + + '@next/swc-linux-x64-musl@16.2.5': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.5': + optional: true + + '@next/swc-win32-x64-msvc@16.2.5': + optional: true + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -4028,6 +4629,78 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.4 + + '@tailwindcss/oxide-android-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide@4.2.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -4092,6 +4765,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@5.2.3': {} + '@types/diff@8.0.0': dependencies: diff: 8.0.3 @@ -4242,7 +4917,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -4250,11 +4925,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -4266,7 +4941,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -4277,13 +4952,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -4558,6 +5233,10 @@ snapshots: citty@0.2.1: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-boxes@3.0.0: {} cli-cursor@4.0.0: @@ -4573,12 +5252,16 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -4689,6 +5372,8 @@ snapshots: detect-libc@2.1.2: {} + diff@5.2.2: {} + diff@8.0.3: {} docker-modem@5.0.6: @@ -4749,6 +5434,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@8.0.0: {} environment@1.1.0: {} @@ -5075,6 +5765,10 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + geist@1.7.0(next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): + dependencies: + next: 16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -5142,6 +5836,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5397,6 +6093,55 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5411,6 +6156,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.487.0(react@19.2.5): + dependencies: + react: 19.2.5 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -5510,6 +6259,30 @@ snapshots: negotiator@1.0.0: {} + next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@next/env': 16.2.5 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + postcss: 8.4.31 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.5 + '@next/swc-darwin-x64': 16.2.5 + '@next/swc-linux-arm64-gnu': 16.2.5 + '@next/swc-linux-arm64-musl': 16.2.5 + '@next/swc-linux-x64-gnu': 16.2.5 + '@next/swc-linux-x64-musl': 16.2.5 + '@next/swc-win32-arm64-msvc': 16.2.5 + '@next/swc-win32-x64-msvc': 16.2.5 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-addon-api@5.1.0: {} node-fetch-native@1.6.7: {} @@ -5644,6 +6417,12 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5895,6 +6674,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6034,6 +6845,13 @@ snapshots: dependencies: min-indent: 1.0.1 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): + dependencies: + client-only: 0.0.1 + react: 19.2.5 + optionalDependencies: + '@babel/core': 7.29.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6042,6 +6860,12 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.4: {} + + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -6196,7 +7020,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -6208,13 +7032,14 @@ snapshots: '@types/node': 25.3.0 fsevents: 2.3.3 jiti: 2.6.1 + lightningcss: 1.32.0 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -6231,7 +7056,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh new file mode 100755 index 0000000..e41efad --- /dev/null +++ b/scripts/deploy-k8s.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Versioned, reversible deploy of mcpd + mcplocal to Kubernetes via Pulumi. +# +# Replaces the old `fulldeploy.sh` "kubectl rollout restart :latest" pattern +# (which bypassed Pulumi and left nothing to roll back to). This script: +# +# 1. Gates on the unit test suite. +# 2. Captures the CURRENTLY-running images as immutable rollback tags +# (skopeo registry->registry copy) + records their digests. +# 3. Takes a pg_dump of the production DB (schema push is destructive-capable). +# 4. Builds + pushes new images tagged with the git short-sha. +# 5. Pins that sha in ../kubernetes-deployment/Pulumi.homelab.yaml and runs +# `pulumi preview` then `pulumi up` (Pulumi is the source of truth). +# 6. Waits for rollout + /healthz, installs the CLI RPM, runs smoke tests. +# 7. On any failure after the cutover, prints the exact rollback recipe. +# +# Usage: +# scripts/deploy-k8s.sh [--dry-run] [--skip-tests] [--yes] [TAG] +# +# --dry-run Do everything read-only: tests, pg_dump, `pulumi preview`. +# No image build/push, no retag, no `pulumi up`, no RPM. +# --skip-tests Skip the unit-test gate (NOT recommended). +# --yes Don't prompt before `pulumi up`. +# TAG Override the image tag (default: git short-sha). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(dirname "$SCRIPT_DIR")" +cd "$ROOT" +[ -f .env ] && { set -a; source .env; set +a; } + +export PATH="$HOME/.npm-global/bin:$PATH" + +KUBE_CONTEXT="${KUBE_CONTEXT:-worker0-k8s0}" +NS="${KUBE_NAMESPACE:-mcpctl}" +PULUMI_DIR="${PULUMI_DIR:-$ROOT/../kubernetes-deployment}" +PULUMI_STACK="${PULUMI_STACK:-homelab}" +PULUMI_YAML="$PULUMI_DIR/Pulumi.$PULUMI_STACK.yaml" +REG_INTERNAL="10.0.0.194:3012" # push target (no body-size limit) +REG_PUBLIC="mysources.co.uk" # what k8s pulls from (same backend) +# Target ONLY the mcpd/mcplocal Deployments. A full `pulumi up` would try to +# Configure the docker provider for the unrelated SOGo/courier-mta image build, +# which needs a local docker daemon this box doesn't have. Targeting the k8s +# Deployments avoids that provider entirely. +MCPD_URN='urn:pulumi:homelab::k8s-deployments::kubernetes:core/v1:Namespace$kubernetes:apps/v1:Deployment::mcpd' +MCPLOCAL_URN='urn:pulumi:homelab::k8s-deployments::kubernetes:core/v1:Namespace$kubernetes:apps/v1:Deployment::mcplocal' +# Prior kubectl-based deploys (old fulldeploy.sh) took server-side-apply +# ownership of the Deployment .image field under the `kubectl-set` field +# manager, which makes a plain pulumi apply conflict. Force-apply lets Pulumi +# reclaim the field and become the single source of truth going forward. +export PULUMI_K8S_ENABLE_PATCH_FORCE=true +BACKUP_DIR="$HOME/tmp/mcpctl-backup" +DATE="$(date +%Y%m%d-%H%M%S)" + +DRY_RUN=false; SKIP_TESTS=false; ASSUME_YES=false; TAG="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --skip-tests) SKIP_TESTS=true ;; + --yes) ASSUME_YES=true ;; + *) TAG="$1" ;; + esac + shift +done +TAG="${TAG:-$(git rev-parse --short HEAD)}" +ROLLBACK_TAG="rollback-$DATE" + +say() { printf '\n\033[1;36m>>> %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +mkdir -p "$BACKUP_DIR" +say "Deploy plan" +cat < /tmp/deploy-test.log 2>&1 || { tail -30 /tmp/deploy-test.log; die "tests failed — aborting"; } + grep -E "Tests " /tmp/deploy-test.log | tail -1 +fi + +# ── 2. Record current images + create immutable rollback tags ── +say "2/7 Capture rollback target (current prod images)" +CUR_MCPD_DIGEST="$(kubectl --context "$KUBE_CONTEXT" -n "$NS" get pods -l 'app in (mcpd)' -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' 2>/dev/null || true)" +[ -z "$CUR_MCPD_DIGEST" ] && CUR_MCPD_DIGEST="$(kubectl --context "$KUBE_CONTEXT" -n "$NS" get pods -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.containerStatuses[0].imageID}{"\n"}{end}' | awk '/^mcpd-/{print $2; exit}')" +{ + echo "# deploy $DATE new-tag=$TAG" + echo "mcpd current digest: $CUR_MCPD_DIGEST" + echo "rollback tag (mcpd/mcplocal): $ROLLBACK_TAG" +} | tee "$BACKUP_DIR/deploy-$DATE.txt" + +retag() { # $1 = image name (mcpd|mcplocal) + local img="$1" + say " skopeo copy $img:latest -> $img:$ROLLBACK_TAG" + if [ "$DRY_RUN" = true ]; then warn "dry-run: skip retag"; return; fi + skopeo copy --src-tls-verify=false --dest-tls-verify=false \ + --src-creds "michal:$GITEA_TOKEN" --dest-creds "michal:$GITEA_TOKEN" \ + "docker://$REG_INTERNAL/michal/$img:latest" \ + "docker://$REG_INTERNAL/michal/$img:$ROLLBACK_TAG" +} +retag mcpd +retag mcplocal + +# ── 3. pg_dump production DB ── +say "3/7 pg_dump production DB" +DUMP="$BACKUP_DIR/predeploy-db-$DATE.sql" +kubectl --context "$KUBE_CONTEXT" -n "$NS" exec mcpctl-db-0 -- \ + pg_dump -U mcpctl -d mcpctl --clean --if-exists > "$DUMP" 2>/dev/null +ls -lh "$DUMP" | awk '{print " wrote "$NF" ("$5")"}' +[ -s "$DUMP" ] || die "pg_dump produced an empty file — aborting" + +if [ "$DRY_RUN" = true ]; then + say "DRY-RUN: build/push + pulumi up skipped. Running targeted pulumi preview only." + ( cd "$PULUMI_DIR" && ./scripts/pulumi.sh preview --stack "$PULUMI_STACK" \ + --target "$MCPD_URN" --target "$MCPLOCAL_URN" --non-interactive 2>&1 | tail -25 ) || true + say "DRY-RUN complete. Re-run without --dry-run to deploy." + exit 0 +fi + +# ── 4. Build + push versioned images ── +say "4/7 Build + push mcpd:$TAG and mcplocal:$TAG" +bash scripts/build-mcpd.sh "$TAG" +bash scripts/build-mcplocal.sh "$TAG" + +# ── 5. Pin sha in Pulumi + preview + up ── +say "5/7 Pin image in Pulumi and roll out" +cp "$PULUMI_YAML" "$BACKUP_DIR/Pulumi.$PULUMI_STACK.yaml.$DATE.bak" +sed -i -E "s#($REG_PUBLIC/michal/mcpd):[^[:space:]]+#\1:$TAG#; s#($REG_PUBLIC/michal/mcplocal):[^[:space:]]+#\1:$TAG#" "$PULUMI_YAML" +grep -nE "$REG_PUBLIC/michal/(mcpd|mcplocal):" "$PULUMI_YAML" | sed 's/^/ pinned: /' +( cd "$PULUMI_DIR" && ./scripts/pulumi.sh preview --stack "$PULUMI_STACK" \ + --target "$MCPD_URN" --target "$MCPLOCAL_URN" --non-interactive --diff 2>&1 | tail -30 ) +if [ "$ASSUME_YES" != true ]; then + read -r -p $'\n Proceed with pulumi up (targeted: mcpd + mcplocal)? [y/N] ' ans + [ "$ans" = y ] || [ "$ans" = Y ] || die "aborted before pulumi up (no prod change made; images pushed, Pulumi.yaml edited locally)" +fi +( cd "$PULUMI_DIR" && ./scripts/pulumi.sh up --stack "$PULUMI_STACK" \ + --target "$MCPD_URN" --target "$MCPLOCAL_URN" --yes ) + +# ── 6. Wait for rollout + health ── +say "6/7 Wait for rollout + health" +rollback_recipe() { + cat </dev/null 2>&1 && \ + kubectl --context "$KUBE_CONTEXT" -n "$NS" rollout status deployment/mcplocal --timeout=4m || true +for i in $(seq 1 30); do + code="$(curl -s -o /dev/null -w '%{http_code}' "https://mcpctl.ad.itaz.eu/healthz" || true)" + [ "$code" = 200 ] && { echo " /healthz OK"; break; } + [ "$i" = 30 ] && die "mcpd /healthz never returned 200" + sleep 4 +done +trap - ERR + +# ── 7. RPM + smoke ── +say "7/7 Build/install CLI RPM + smoke tests" +bash scripts/release.sh +systemctl --user restart mcplocal && sleep 2 +if pnpm test:smoke > /tmp/deploy-smoke.log 2>&1; then + grep -E "Tests |passed" /tmp/deploy-smoke.log | tail -2 + say "Deploy complete — $TAG live. Rollback tag: $ROLLBACK_TAG" +else + tail -40 /tmp/deploy-smoke.log + warn "SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:" + rollback_recipe + exit 1 +fi diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index f2b0938..44330ee 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { writeFileSync, readFileSync, existsSync } from 'node:fs'; -import { resolve, join } from 'node:path'; +import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js'; @@ -9,6 +9,9 @@ import { saveCredentials, loadCredentials } from '../auth/index.js'; import { createConfigSetupCommand } from './config-setup.js'; import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; +import { writeProjectMarker } from '../utils/project-marker.js'; +import { installManagedSessionHook } from '../utils/sessionhook.js'; +import { runSkillsSync } from './skills.js'; interface McpConfig { mcpServers: Record }>; @@ -17,6 +20,8 @@ interface McpConfig { export interface ConfigCommandDeps { configDeps: Partial; log: (...args: string[]) => void; + /** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */ + apiClient?: ApiClient; } export interface ConfigApiDeps { @@ -32,6 +37,11 @@ const defaultDeps: ConfigCommandDeps = { export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; + // PR-5: api client used by `mcpctl config claude --project` to run the + // initial skills sync after wiring the .mcp.json. Threaded through from + // index.ts; falls back to apiDeps.client when not explicitly passed (the + // existing call site already wires `client` via apiDeps). + const skillsClient = deps?.apiClient ?? apiDeps?.client; const config = new Command('config').description('Manage mcpctl configuration'); @@ -89,12 +99,13 @@ export function createConfigCommand(deps?: Partial, apiDeps?: function registerClaudeCommand(name: string, hidden: boolean): void { const cmd = config .command(name) - .description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge') + .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') .option('-o, --output ', 'Output file path', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') - .action((opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean }) => { + .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => { if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; @@ -141,6 +152,40 @@ export function createConfigCommand(deps?: Partial, apiDeps?: writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); const serverCount = Object.keys(finalConfig.mcpServers).length; log(`Wrote ${outputPath} (${serverCount} server(s))`); + + // PR-5: write project marker, run initial skills sync, install + // SessionStart hook. Skipped when --inspect-only or --skip-skills. + if (opts.project && !opts.skipSkills) { + const projectDir = dirname(outputPath); + try { + const markerPath = await writeProjectMarker(projectDir, opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + if (skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } + } catch (err: unknown) { + log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + try { + const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet'); + log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`); + } catch (err: unknown) { + log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { // Commander shows empty-description commands but they won't clutter help output diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index d0f4d69..0a93f26 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -264,6 +264,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--api-key-ref ', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)') .option('--extra ', 'Extra config key=value (repeat)', collect, []) .option('--pool-name ', 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool') + .option('--visibility ', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public') .option('--force', 'Update if already exists') .option('--skip-auth-check', 'Skip the upstream auth probe (for offline registration before infra exists)') .action(async (name: string, opts) => { @@ -276,6 +277,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.url) body.url = opts.url; if (opts.description !== undefined) body.description = opts.description; if (opts.poolName !== undefined) body.poolName = opts.poolName; + if (opts.visibility !== undefined) { + if (opts.visibility !== 'public' && opts.visibility !== 'private') { + throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`); + } + body.visibility = opts.visibility; + } if (opts.apiKeyRef) { const slashIdx = (opts.apiKeyRef as string).indexOf('/'); if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`); @@ -333,6 +340,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--default-stop ', 'Default stop sequence (repeat for multiple)', collect, []) .option('--default-extra ', 'Default provider-specific knob k=v (repeat)', collect, []) .option('--default-params-file ', 'Read defaultParams from a JSON file') + .option('--visibility ', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public') .option('--force', 'Update if already exists') .action(async (name: string, opts) => { const body: Record = { @@ -341,6 +349,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { }; if (opts.project) body.project = { name: opts.project }; if (opts.description !== undefined) body.description = opts.description; + if (opts.visibility !== undefined) { + if (opts.visibility !== 'public' && opts.visibility !== 'private') { + throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`); + } + body.visibility = opts.visibility; + } let systemPrompt = opts.systemPrompt as string | undefined; if (systemPrompt === undefined && opts.systemPromptFile !== undefined) { @@ -781,6 +795,87 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { log(`prompt '${prompt.name}' created (id: ${prompt.id})`); }); + // --- create skill --- + cmd.command('skill') + .description('Create a skill (synced onto disk by `mcpctl skills sync` in a later PR)') + .argument('', 'Skill name (lowercase alphanumeric with hyphens)') + .option('-p, --project ', 'Project to scope the skill to') + .option('--agent ', 'Agent to scope the skill to (XOR with --project)') + .option('--content ', 'SKILL.md body text') + .option('--content-file ', 'Read SKILL.md body from file') + .option('--description ', 'Short description shown in listings') + .option('--priority ', 'Priority 1-10 (default: 5)') + .option('--semver ', 'Initial semver (default: 0.1.0)') + .option('--metadata-file ', 'YAML/JSON file with metadata (hooks, mcpServers, postInstall, …)') + .option('--files-dir ', 'Directory whose tree becomes the skill\'s files{} map (UTF-8 text only)') + .action(async (name: string, opts) => { + if (opts.project && opts.agent) { + throw new Error('--project and --agent are mutually exclusive'); + } + let content = opts.content as string | undefined; + if (opts.contentFile) { + const fs = await import('node:fs/promises'); + content = await fs.readFile(opts.contentFile as string, 'utf-8'); + } + if (!content) { + throw new Error('--content or --content-file is required'); + } + + const body: Record = { name, content }; + if (opts.project) body.project = opts.project; + if (opts.agent) body.agent = opts.agent; + if (opts.description) body.description = opts.description; + if (opts.priority) { + const priority = Number(opts.priority); + if (isNaN(priority) || priority < 1 || priority > 10) { + throw new Error('--priority must be a number between 1 and 10'); + } + body.priority = priority; + } + if (opts.semver) body.semver = opts.semver; + + if (opts.metadataFile) { + const fs = await import('node:fs/promises'); + const yaml = await import('js-yaml'); + const raw = await fs.readFile(opts.metadataFile as string, 'utf-8'); + const parsed = yaml.load(raw); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('--metadata-file must contain a YAML/JSON object'); + } + body.metadata = parsed; + } + + if (opts.filesDir) { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const root = opts.filesDir as string; + const files: Record = {}; + async function walk(dir: string, prefix: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + const rel = prefix ? `${prefix}/${e.name}` : e.name; + if (e.isDirectory()) { + await walk(full, rel); + } else if (e.isFile()) { + const buf = await fs.readFile(full); + // Reject non-UTF8 — v1 is text-only. + const text = buf.toString('utf-8'); + if (text.includes('')) { + throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); + } + files[rel] = text; + } + } + } + await walk(root, ''); + body.files = files; + } + + const skill = await client.post<{ id: string; name: string; semver: string }>('/api/v1/skills', body); + log(`skill '${skill.name}' created at ${skill.semver} (id: ${skill.id})`); + }); + // --- create personality --- cmd.command('personality') .description('Create a personality overlay on an agent') diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index dbfcb41..de8b8e5 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command { .description('Edit a resource in your default editor (server, project)') .argument('', 'Resource type (server, project)') .argument('', 'Resource name or ID') - .action(async (resourceArg: string, nameOrId: string) => { + .option('--bump ', 'Bump prompt semver after edit: major | minor | patch') + .option('--semver ', 'Set prompt semver explicitly (X.Y.Z)') + .option('--note ', 'Note attached to the resulting revision') + .action(async (resourceArg: string, nameOrId: string, opts: { bump?: string; semver?: string; note?: string }) => { const resource = resolveResource(resourceArg); // Instances are immutable @@ -55,6 +58,23 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } + // Validation for prompt-only revision flags + if ((opts.bump !== undefined || opts.semver !== undefined || opts.note !== undefined) && resource !== 'prompts') { + log('Error: --bump, --semver, and --note are only valid for prompts'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && opts.semver !== undefined) { + log('Error: pass --bump or --semver, not both'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && !['major', 'minor', 'patch'].includes(opts.bump)) { + log("Error: --bump must be 'major', 'minor', or 'patch'"); + process.exitCode = 1; + return; + } + // Resolve name → ID const id = await resolveNameOrId(client, resource, nameOrId); @@ -102,6 +122,12 @@ export function createEditCommand(deps: EditCommandDeps): Command { // Parse and apply const updates = yaml.load(modifiedClean) as Record; + // Append semver-related flags for prompts (server-side bumps + records revision). + if (resource === 'prompts') { + if (opts.bump !== undefined) updates.bump = opts.bump; + if (opts.semver !== undefined) updates.semver = opts.semver; + if (opts.note !== undefined) updates.note = opts.note; + } await client.put(`/api/v1/${resource}/${id}`, updates); log(`${singular} '${nameOrId}' updated.`); } finally { diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 445bc6d..5c1ca16 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -138,6 +138,10 @@ interface LlmRow { status?: 'active' | 'inactive' | 'hibernating'; // v4: explicit pool key. NULL = solo Llm (effective pool = its own name). poolName?: string | null; + // v7: visibility scope. Legacy public rows omit it; mcpd defaults missing + // values to 'public' on serialization. + visibility?: 'public' | 'private'; + ownerId?: string | null; } // v4: POOL column placed right after NAME so an operator can't miss @@ -148,6 +152,7 @@ const llmColumns: Column[] = [ { header: 'POOL', key: (r) => (r.poolName !== null && r.poolName !== undefined && r.poolName !== '') ? r.poolName : '-', width: 18 }, { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 }, { header: 'STATUS', key: (r) => r.status ?? 'active', width: 12 }, + { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 }, { header: 'TYPE', key: 'type', width: 12 }, { header: 'MODEL', key: 'model', width: 28 }, { header: 'TIER', key: 'tier', width: 8 }, @@ -214,12 +219,15 @@ interface AgentRow { // AgentService as the publishing mcplocal heartbeats and disconnects. kind?: 'public' | 'virtual'; status?: 'active' | 'inactive'; + // v7: visibility — same semantics as Llm. Public legacy agents omit it. + visibility?: 'public' | 'private'; } const agentColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 }, { header: 'STATUS', key: (r) => r.status ?? 'active', width: 10 }, + { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 }, { header: 'LLM', key: (r) => r.llm.name, width: 24 }, { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 20 }, { header: 'DESCRIPTION', key: (r) => truncate(r.description, 50) || '-', width: 50 }, diff --git a/src/cli/src/commands/passwd.ts b/src/cli/src/commands/passwd.ts new file mode 100644 index 0000000..69c725d --- /dev/null +++ b/src/cli/src/commands/passwd.ts @@ -0,0 +1,72 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface PasswdPromptDeps { + password(message: string): Promise; +} + +export interface PasswdCommandDeps { + client: ApiClient; + log: (...args: string[]) => void; + prompt: PasswdPromptDeps; +} + +interface Me { + id: string; + email: string; +} + +interface UserView { + id: string; + email: string; +} + +async function defaultPassword(message: string): Promise { + const { default: inquirer } = await import('inquirer'); + const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message, mask: '*' }]); + return answer as string; +} + +function validateNew(newPassword: string, confirm: string): void { + if (newPassword !== confirm) { + throw new Error('Passwords do not match'); + } + if (newPassword.length < 8) { + throw new Error('Password must be at least 8 characters'); + } +} + +export function createPasswdCommand(deps?: Partial): Command { + const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); }); + const prompt: PasswdPromptDeps = deps?.prompt ?? { password: defaultPassword }; + + return new Command('passwd') + .description('Change a user password (your own when called without an argument)') + .argument('[user]', 'email or id of the user whose password to change (defaults to yourself)') + .action(async (target: string | undefined) => { + const client = deps?.client; + if (!client) throw new Error('passwd: no API client configured'); + + const me = await client.get('/api/v1/auth/me'); + const isSelf = target === undefined || target === me.email || target === me.id; + + if (isSelf) { + // Self-service: prove identity with the current password, then set the new one. + const currentPassword = await prompt.password('Current password'); + const newPassword = await prompt.password('New password'); + const confirm = await prompt.password('Retype new password'); + validateNew(newPassword, confirm); + await client.post('/api/v1/users/me/password', { currentPassword, newPassword }); + log(`Password updated for ${me.email}.`); + return; + } + + // Admin reset of another user — requires edit:users. + const targetUser = await client.get(`/api/v1/users/${encodeURIComponent(target as string)}`); + const newPassword = await prompt.password(`New password for ${targetUser.email}`); + const confirm = await prompt.password('Retype new password'); + validateNew(newPassword, confirm); + await client.put(`/api/v1/users/${targetUser.id}/password`, { newPassword }); + log(`Password reset for ${targetUser.email}.`); + }); +} diff --git a/src/cli/src/commands/review.ts b/src/cli/src/commands/review.ts new file mode 100644 index 0000000..b0498b7 --- /dev/null +++ b/src/cli/src/commands/review.ts @@ -0,0 +1,220 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +/** + * `mcpctl review` — triage UX for the proposal queue. Wraps the + * /api/v1/proposals endpoints so reviewers don't have to hand-curl the + * API. Subcommands: + * + * mcpctl review pending List pending proposals + * mcpctl review next Show oldest pending + * mcpctl review show Full detail of one proposal + * mcpctl review approve Approve (creates resource + revision) + * mcpctl review reject --reason Reject with reviewer note + * mcpctl review diff Diff proposal body vs current resource + */ + +interface Proposal { + id: string; + resourceType: 'prompt' | 'skill'; + name: string; + body: Record; + projectId: string | null; + agentId: string | null; + createdBySession: string | null; + createdByUserId: string | null; + status: 'pending' | 'approved' | 'rejected'; + reviewerNote: string; + approvedRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +export interface ReviewCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createReviewCommand(deps: ReviewCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('review').description('Triage proposed prompts and skills'); + + cmd.command('pending') + .alias('list') + .description('List pending proposals') + .option('--type ', 'Filter by resource type: prompt or skill') + .action(async (opts: { type?: string }) => { + const params = new URLSearchParams({ status: 'pending' }); + if (opts.type) params.set('resourceType', opts.type); + const proposals = await client.get(`/api/v1/proposals?${params.toString()}`); + if (proposals.length === 0) { + log('No pending proposals.'); + return; + } + log(formatTable(proposals)); + }); + + cmd.command('next') + .description('Show the oldest pending proposal') + .option('--type ', 'Filter by resource type: prompt or skill') + .action(async (opts: { type?: string }) => { + const params = new URLSearchParams({ status: 'pending' }); + if (opts.type) params.set('resourceType', opts.type); + const proposals = await client.get(`/api/v1/proposals?${params.toString()}`); + if (proposals.length === 0) { + log('No pending proposals.'); + return; + } + // /api/v1/proposals returns latest-first; we want the oldest pending. + const oldest = proposals[proposals.length - 1] as Proposal; + log(formatDetail(oldest)); + }); + + cmd.command('show') + .description('Show full detail of a proposal') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const proposal = await client.get(`/api/v1/proposals/${id}`); + log(formatDetail(proposal)); + }); + + cmd.command('approve') + .description('Approve a pending proposal (creates the resource + initial revision)') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const updated = await client.post(`/api/v1/proposals/${id}/approve`, {}); + log(`approved proposal '${updated.name}' (resourceType: ${updated.resourceType})`); + if (updated.approvedRevisionId) { + log(` resulting revision: ${updated.approvedRevisionId}`); + } + }); + + cmd.command('reject') + .description('Reject a pending proposal with a reviewer note') + .argument('', 'Proposal ID') + .option('--reason ', 'Reviewer note explaining the rejection') + .action(async (id: string, opts: { reason?: string }) => { + if (!opts.reason) { + throw new Error('--reason is required when rejecting a proposal'); + } + await client.post(`/api/v1/proposals/${id}/reject`, { reviewerNote: opts.reason }); + log(`rejected proposal ${id}`); + }); + + cmd.command('diff') + .description('Show what would change if this proposal were approved') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const proposal = await client.get(`/api/v1/proposals/${id}`); + const proposedContent = (proposal.body as { content?: string }).content ?? ''; + + // Find existing resource (if any) to diff against. Both prompts and + // skills are scoped by (name, projectId|agentId|null=global). + let existingContent: string | null = null; + const projectName = proposal.project?.name; + const agentName = proposal.agent?.name; + try { + if (proposal.resourceType === 'prompt') { + const params = new URLSearchParams(); + if (projectName) params.set('project', projectName); + const list = await client.get>(`/api/v1/prompts?${params.toString()}`); + const match = list.find((p) => p.name === proposal.name); + if (match) existingContent = match.content; + } else { + const params = new URLSearchParams(); + if (projectName) params.set('project', projectName); + else if (agentName) params.set('agent', agentName); + const list = await client.get>(`/api/v1/skills?${params.toString()}`); + const match = list.find((s) => s.name === proposal.name); + if (match) existingContent = match.content; + } + } catch { + // 404 from no project / agent means nothing to diff against. + } + + if (existingContent === null) { + log(`Proposal would create a new ${proposal.resourceType} '${proposal.name}'.`); + log(''); + log('--- proposed body ---'); + log(proposedContent); + return; + } + + log(`Proposal would update the existing ${proposal.resourceType} '${proposal.name}'.`); + log(''); + log('--- current ---'); + log(existingContent); + log('--- proposed ---'); + log(proposedContent); + }); + + return cmd; +} + +// ── Formatting ── + +function formatTable(proposals: Proposal[]): string { + const lines: string[] = []; + const idW = Math.max(2, ...proposals.map((p) => p.id.length)); + const typeW = 6; // 'skill' / 'prompt' + const nameW = Math.max(4, ...proposals.map((p) => p.name.length)); + const scopeW = Math.max(5, ...proposals.map((p) => scopeLabel(p).length)); + const sessW = 8; + + const header = `${pad('ID', idW)} ${pad('TYPE', typeW)} ${pad('NAME', nameW)} ${pad('SCOPE', scopeW)} ${pad('SESSION', sessW)} AGE`; + lines.push(header); + for (const p of proposals) { + const age = ageOf(p.createdAt); + lines.push( + `${pad(p.id, idW)} ${pad(p.resourceType, typeW)} ${pad(p.name, nameW)} ${pad(scopeLabel(p), scopeW)} ${pad((p.createdBySession ?? '—').slice(0, 8), sessW)} ${age}`, + ); + } + return lines.join('\n'); +} + +function formatDetail(p: Proposal): string { + const lines: string[] = []; + lines.push(`=== Proposal: ${p.name} (${p.resourceType}) ===`); + lines.push(`ID: ${p.id}`); + lines.push(`Status: ${p.status}`); + lines.push(`Scope: ${scopeLabel(p)}`); + lines.push(`Created: ${p.createdAt} (session ${p.createdBySession ?? '—'})`); + if (p.reviewerNote) lines.push(`Reviewer note: ${p.reviewerNote}`); + if (p.approvedRevisionId) lines.push(`Approved as revision: ${p.approvedRevisionId}`); + lines.push(''); + lines.push('--- body ---'); + const content = (p.body as { content?: string }).content; + if (typeof content === 'string') { + lines.push(content); + } else { + lines.push(JSON.stringify(p.body, null, 2)); + } + return lines.join('\n'); +} + +function scopeLabel(p: Proposal): string { + if (p.project?.name) return `project:${p.project.name}`; + if (p.agent?.name) return `agent:${p.agent.name}`; + return 'global'; +} + +function pad(s: string, w: number): string { + if (s.length >= w) return s; + return s + ' '.repeat(w - s.length); +} + +function ageOf(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return '?'; + const sec = Math.floor((Date.now() - t) / 1000); + if (sec < 60) return `${String(sec)}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${String(min)}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${String(hr)}h`; + const days = Math.floor(hr / 24); + return `${String(days)}d`; +} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index fbb9cdf..5fd244a 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -21,6 +21,19 @@ export const RESOURCE_ALIASES: Record = { promptrequest: 'promptrequests', promptrequests: 'promptrequests', pr: 'promptrequests', + // PR-2: shared revision + proposal queue (replaces promptrequests in + // PR-7). Lookup goes through /api/v1/proposals and /api/v1/revisions. + proposal: 'proposals', + proposals: 'proposals', + prop: 'proposals', + revision: 'revisions', + revisions: 'revisions', + rev: 'revisions', + // PR-3: skill resource. Same shape as prompt but materialised onto + // disk by `mcpctl skills sync` (PR-5). + skill: 'skills', + skills: 'skills', + sk: 'skills', serverattachment: 'serverattachments', serverattachments: 'serverattachments', sa: 'serverattachments', diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts new file mode 100644 index 0000000..627799d --- /dev/null +++ b/src/cli/src/commands/skills.ts @@ -0,0 +1,479 @@ +import { Command } from 'commander'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { ApiClient } from '../api-client.js'; +import { findProjectMarker } from '../utils/project-marker.js'; +import { + loadState, + saveState, + detectModifiedFiles, + type SkillState, + defaultStatePath, +} from '../utils/skills-state.js'; +import { + installSkillAtomic, + removeSkillAtomic, + type SkillBody, +} from '../utils/skills-disk.js'; +import { + runPostInstall, + emitPostInstallAudit, + hashScript, +} from '../utils/postinstall.js'; +import { + applyManagedHooks, + removeManagedHooks, + type HooksByEvent, +} from '../utils/hooks-materialiser.js'; +import { + attachSkillMcpServers, + parseMcpServerDeps, +} from '../utils/mcpservers-materialiser.js'; +import { ApiError } from '../api-client.js'; + +/** + * `mcpctl skills sync` — materialise server-side skills onto disk under + * `~/.claude/skills//`. Per-skill atomic install; hash-pinned diff + * (server-computed contentHash); user-modification preservation. + * + * Failure semantics: in `--quiet` mode (used by the SessionStart hook), + * exit code 0 on network/timeout (fail-open so a hung mcpd never blocks + * Claude startup). Auth errors exit 1; disk errors exit 2. + */ + +interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; +} + +interface FullSkill { + id: string; + name: string; + description: string; + content: string; + files: Record; + metadata: Record; + semver: string; + projectId: string | null; + agentId: string | null; +} + +/** + * Shape of `metadata` we care about at sync time. Validated server-side + * by SkillMetadataSchema (PR-3); we re-narrow here for the fields the + * sync acts on, keeping the rest opaque so future additions don't + * require a CLI change. + */ +interface SyncedSkillMetadata { + postInstall?: unknown; + postInstallTimeoutSec?: unknown; + hooks?: unknown; + mcpServers?: unknown; +} + +export interface SyncOpts { + /** Project name override; otherwise marker walk-up + fall back to globals-only. */ + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + skipPostInstall?: boolean; + keepOrphans?: boolean; + /** For tests: override cwd start for the marker walk-up. */ + cwd?: string; + /** For tests: override skills install root (default: ~/.claude/skills). */ + installRoot?: string; + /** For tests: override state file path. */ + statePath?: string; +} + +export interface SyncResult { + installed: string[]; + updated: string[]; + skipped: string[]; + removed: string[]; + preserved: string[]; // skills with local edits we left alone + postInstallsRan: string[]; // skills whose postInstall executed in this sync + postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun + hooksApplied: string[]; // skills whose hooks were registered/updated in ~/.claude/settings.json + mcpServersAttached: string[]; // ":" tuples that landed in this sync + errors: Array<{ skill: string; error: string }>; + exitCode: 0 | 1 | 2; +} + +export interface SyncDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + /** stderr writer. Defaults to console.error. */ + warn: (...args: unknown[]) => void; +} + +/** + * Library entry — call from `mcpctl config claude --project X` and from + * the `skills sync` Commander action. + */ +export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { + const { client, log, warn } = deps; + const result: SyncResult = { + installed: [], + updated: [], + skipped: [], + removed: [], + preserved: [], + postInstallsRan: [], + postInstallsSkipped: [], + hooksApplied: [], + mcpServersAttached: [], + errors: [], + exitCode: 0, + }; + + // 1. Resolve project scope. + let projectName = opts.project; + if (!projectName) { + const marker = await findProjectMarker(opts.cwd ?? process.cwd()); + if (marker) projectName = marker.project; + } + + // 2. Fetch the visible skill list. + let visible: VisibleSkill[]; + try { + if (projectName) { + visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); + } else { + // No project context — sync only globals. + const all = await client.get('/api/v1/skills?scope=global'); + visible = all; + } + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { + warn('mcpctl: auth failed — run `mcpctl login`'); + result.exitCode = 1; + return result; + } + if (opts.quiet) { + // Fail-open in quiet mode (SessionStart hook context). The next sync + // will catch up; we never want to block Claude startup on a hung mcpd. + warn(`mcpctl: skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 0; + return result; + } + throw err; + } + + // Filter agent-scoped skills for now — sync targets globals + project skills, + // but agent-scoped skills aren't surfaced to a user's Claude Code session + // (they're administrative). PR-3+ may revisit if agent-identity-on-disk + // becomes a concept. + visible = visible.filter((s) => s.scope !== 'agent'); + + // 3. Load state. + const statePath = opts.statePath ?? defaultStatePath(); + const state = await loadState(statePath); + const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + + // 4. Diff. + const visibleByName = new Map(visible.map((s) => [s.name, s])); + const stateNames = Object.keys(state.skills); + + // Determine install/update/skip per server skill. + const toFetch: VisibleSkill[] = []; + for (const v of visible) { + const prior = state.skills[v.name]; + if (!prior) { + toFetch.push(v); + continue; + } + if (prior.contentHash === v.contentHash) { + result.skipped.push(v.name); + continue; + } + // Hash differs — content changed server-side. Need to fetch full body. + toFetch.push(v); + } + + // 5. Apply install/update with concurrency limit (5 in-flight fetches). + const concurrency = 5; + for (let i = 0; i < toFetch.length; i += concurrency) { + const batch = toFetch.slice(i, i + concurrency); + await Promise.all(batch.map((v) => applyOne(v))); + } + + // 6. Orphan removal: skills in state but not in server's visible set. + if (!opts.keepOrphans) { + for (const name of stateNames) { + if (visibleByName.has(name)) continue; + const prior = state.skills[name]; + if (!prior) continue; + try { + // Preserve user-modified skills — warn + skip. + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0 && !opts.force) { + warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); + result.preserved.push(name); + continue; + } + if (opts.dryRun) { + result.removed.push(name); + continue; + } + await removeSkillAtomic(prior.installDir); + // Drop any hook entries this skill registered. + try { await removeManagedHooks(name); } catch { /* best-effort */ } + delete state.skills[name]; + result.removed.push(name); + } catch (err: unknown) { + result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); + } + } + } + + // 7. Persist state. + state.lastSync = new Date().toISOString(); + if (projectName !== undefined) state.lastSyncProject = projectName; + if (!opts.dryRun) { + try { + await saveState(state, statePath); + } catch (err: unknown) { + warn(`mcpctl: failed to persist state — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 2; + } + } + + // 8. Summary. + const anythingHappened = + result.errors.length > 0 || + result.installed.length > 0 || + result.updated.length > 0 || + result.removed.length > 0 || + result.postInstallsRan.length > 0 || + result.hooksApplied.length > 0 || + result.mcpServersAttached.length > 0; + if (!opts.quiet || anythingHappened) { + const parts: string[] = []; + if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); + if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); + if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); + if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); + if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); + if (result.postInstallsRan.length) parts.push(`${String(result.postInstallsRan.length)} postInstall ran`); + if (result.hooksApplied.length) parts.push(`${String(result.hooksApplied.length)} hooks applied`); + if (result.mcpServersAttached.length) parts.push(`${String(result.mcpServersAttached.length)} mcpServers attached`); + if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); + if (parts.length === 0) parts.push('no changes'); + if (!opts.quiet) { + log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); + } else if (anythingHappened) { + // Quiet mode: only emit a single line if something actually happened. + warn(`mcpctl: ${parts.join(', ')}`); + } + } + + return result; + + async function applyOne(v: VisibleSkill): Promise { + try { + // If on-disk files were locally modified, preserve unless --force. + const prior = state.skills[v.name]; + const targetDir = prior?.installDir ?? join(installRoot, v.name); + if (prior && !opts.force) { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0) { + warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); + result.preserved.push(v.name); + return; + } + } + if (opts.dryRun) { + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + return; + } + + const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); + const body: SkillBody = { + content: full.content, + ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), + }; + const fileStates = await installSkillAtomic(targetDir, body); + + // ── hooks: register metadata.hooks in ~/.claude/settings.json ── + // Tagged with _mcpctl_source: so each skill's hooks + // can be cleanly added/updated/removed without trampling other + // skills or user-added hooks. No-op when the field is absent or + // empty. + const meta = (full.metadata ?? {}) as SyncedSkillMetadata; + if (meta.hooks && typeof meta.hooks === 'object') { + try { + const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); + if (hookRes.updated) result.hooksApplied.push(v.name); + } catch (err: unknown) { + warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + } + } else if (prior !== undefined) { + // Skill no longer declares hooks but used to — clean up. + try { await removeManagedHooks(v.name); } catch { /* best-effort */ } + } + + // ── mcpServers: auto-attach declared deps to the active project ── + // Only meaningful when a project context is active; global skills + // can't attach to "no project". v1 doesn't auto-create missing + // servers (warn + skip). Idempotent — re-syncing a skill whose + // deps are already attached is a no-op. + const mcpServerDeps = parseMcpServerDeps(meta.mcpServers); + if (mcpServerDeps.length > 0 && projectName) { + try { + const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn); + for (const srv of att.attached) { + result.mcpServersAttached.push(`${v.name}:${srv}`); + } + for (const e of att.errors) { + result.errors.push({ + skill: v.name, + error: `mcpServers attach '${e.server}': ${e.error}`, + }); + } + } catch (err: unknown) { + warn(`mcpctl: failed to attach mcpServers for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + } + } else if (mcpServerDeps.length > 0) { + warn(`mcpctl: skill '${v.name}' declares mcpServers but sync is running global-only; skipping attach`); + } + + // ── postInstall: run metadata.postInstall when present ── + // Hash-pinned: only execute when the script's sha256 differs from + // what state recorded. Failures DO NOT update the recorded hash so + // the next sync retries. Other skills continue regardless. + let postInstallHash: string | null = prior?.postInstallHash ?? null; + if ( + !opts.skipPostInstall && + typeof meta.postInstall === 'string' && + meta.postInstall.length > 0 + ) { + const scriptRel = meta.postInstall; + const scriptContent = (full.files ?? {})[scriptRel]; + if (typeof scriptContent !== 'string') { + warn(`mcpctl: skill '${v.name}' postInstall references '${scriptRel}' which is not in files{}; skipping`); + } else { + const newHash = hashScript(scriptContent); + const hashChanged = newHash !== prior?.postInstallHash; + if (!hashChanged) { + result.postInstallsSkipped.push(v.name); + postInstallHash = newHash; + } else { + try { + const timeoutSec = typeof meta.postInstallTimeoutSec === 'number' ? meta.postInstallTimeoutSec : undefined; + const piInput = { + installDir: targetDir, + scriptPath: scriptRel, + skillName: v.name, + semver: v.semver, + projectName: projectName ?? undefined, + timeoutSec, + logsDir: join(homedir(), '.mcpctl', 'skills', v.name), + }; + const installResult = await runPostInstall(piInput); + // Best-effort audit. Don't await; mcpd slowness shouldn't slow sync. + void emitPostInstallAudit(client, piInput, installResult, (m) => warn(m)); + + if (installResult.timedOut) { + result.errors.push({ + skill: v.name, + error: `postInstall timed out after ${String(installResult.durationMs)}ms; rerun next sync`, + }); + // hash NOT updated → retry on next sync + } else if (installResult.exitCode !== 0) { + const tail = installResult.stderrTail.trim() || installResult.stdoutTail.trim() || `exit ${String(installResult.exitCode)}`; + result.errors.push({ + skill: v.name, + error: `postInstall failed (exit ${String(installResult.exitCode)}): ${tail.slice(-200)}`, + }); + // hash NOT updated → retry on next sync + } else { + postInstallHash = installResult.scriptHash; + result.postInstallsRan.push(v.name); + } + } catch (err: unknown) { + result.errors.push({ + skill: v.name, + error: `postInstall error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + } + } + + const newState: SkillState = { + id: v.id, + semver: v.semver, + contentHash: v.contentHash, + scope: v.scope, + installDir: targetDir, + files: fileStates, + postInstallHash, + lastSyncedAt: new Date().toISOString(), + }; + state.skills[v.name] = newState; + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + } catch (err: unknown) { + result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); + } + } +} + +// ── Commander wrapper ── + +export interface SkillsCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createSkillsCommand(deps: SkillsCommandDeps): Command { + const { client, log } = deps; + const warn = (...args: unknown[]): void => { + console.error(...(args as Parameters)); + }; + + const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + + cmd.command('sync') + .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--dry-run', 'Print what would change without writing anything') + .option('--force', 'Overwrite locally-modified skills') + .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)') + .option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)') + .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') + .action(async (opts: { + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + skipPostinstall?: boolean; + keepOrphans?: boolean; + }) => { + const result = await runSkillsSync( + { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), + ...(opts.force !== undefined ? { force: opts.force } : {}), + ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), + ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), + ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + }, + { client, log, warn }, + ); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } + }); + + return cmd; +} diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index f6bb88a..a523d9d 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -44,6 +44,22 @@ interface ServerLlm { apiKeyRef?: { name: string; key: string } | null; } +/** + * SecretBackend row as returned by GET /api/v1/secretbackends, trimmed to the + * fields the status view needs. `tokenMeta.lastRotationError` is mcpd's record + * of the last credential-rotation failure (e.g. a dead OpenBao token). + */ +interface SecretBackendInfo { + name: string; + type: string; + isDefault?: boolean; + tokenMeta?: { + lastRotationError?: string | null; + lastRotationAt?: string | null; + validUntil?: string | null; + } | null; +} + /** * Result of a live "say hi" probe against a server LLM. `ok` says we got a * 200 + non-empty content back; `say` is the trimmed first 16 chars of the @@ -81,6 +97,8 @@ export interface StatusCommandDeps { * Always resolves (never throws) so one bad LLM doesn't sink the section. */ probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise; + /** Fetch SecretBackends from mcpd to surface backend health. Null on error. */ + fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise; isTTY: boolean; } @@ -224,6 +242,39 @@ function defaultFetchServerLlms(mcpdUrl: string, token: string | null): Promise< }); } +/** + * Fetch SecretBackends from mcpd to surface backend health (e.g. an OpenBao + * token that has gone dead). `tokenMeta.lastRotationError` is mcpd's own + * record of the last rotation failure. Returns null on any error so the + * section is simply omitted when mcpd is unreachable / unauthorized. + */ +function defaultFetchSecretBackends(mcpdUrl: string, token: string | null): Promise { + return new Promise((resolve) => { + let req: http.ClientRequest; + const headers: Record = { Accept: 'application/json' }; + if (token !== null) headers['Authorization'] = `Bearer ${token}`; + try { + req = httpDriverFor(mcpdUrl).get(`${mcpdUrl}/api/v1/secretbackends`, { timeout: 5000, headers }, (res) => { + if (res.statusCode !== 200) { resolve(null); res.resume(); return; } + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SecretBackendInfo[]); + } catch { + resolve(null); + } + }); + }); + } catch { + resolve(null); + return; + } + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + /** * POST a tiny "say hi" prompt to /api/v1/llms//infer and decide if * the LLM actually serves inference. Returns ok=true when the response is @@ -334,6 +385,7 @@ const defaultDeps: StatusCommandDeps = { fetchModels: defaultFetchModels, fetchProviders: defaultFetchProviders, fetchServerLlms: defaultFetchServerLlms, + fetchSecretBackends: defaultFetchSecretBackends, probeServerLlm: defaultProbeServerLlm, isTTY: process.stdout.isTTY ?? false, }; @@ -396,7 +448,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): } export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, isTTY } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -411,12 +463,13 @@ export function createStatusCommand(deps?: Partial): Command if (opts.output !== 'table') { // JSON/YAML: run everything in parallel, wait, output at once const token = creds?.token ?? null; - const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo, serverLlms] = await Promise.all([ + const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo, serverLlms, secretBackends] = await Promise.all([ checkHealth(config.mcplocalUrl), checkHealth(config.mcpdUrl), llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null), multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null), fetchServerLlms(config.mcpdUrl, token), + fetchSecretBackends(config.mcpdUrl, token), ]); // Probe each server LLM in parallel — adds 0-2 sec to JSON mode but @@ -446,6 +499,7 @@ export function createStatusCommand(deps?: Partial): Command llmStatus, ...(providersInfo ? { providers: providersInfo } : {}), ...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}), + ...(secretBackends !== null ? { secretBackends: secretBackends.map((b) => ({ name: b.name, type: b.type, healthy: !b.tokenMeta?.lastRotationError, error: b.tokenMeta?.lastRotationError ?? null })) } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); @@ -472,9 +526,11 @@ export function createStatusCommand(deps?: Partial): Command // a configured client-side provider. const token = creds?.token ?? null; const serverLlmsPromise = fetchServerLlms(config.mcpdUrl, token); + const secretBackendsPromise = fetchSecretBackends(config.mcpdUrl, token); if (!llmLabel) { log(`LLM: not configured (run 'mcpctl config setup')`); + await renderSecretBackendsSection(secretBackendsPromise, isTTY); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); return; } @@ -539,9 +595,35 @@ export function createStatusCommand(deps?: Partial): Command } } + await renderSecretBackendsSection(secretBackendsPromise, isTTY); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); }); + /** + * Print a "Secrets:" section listing each SecretBackend with health. A + * backend with a `tokenMeta.lastRotationError` (e.g. a dead OpenBao token) + * renders red with the error inline, so a recurrence is visible at a glance + * from `mcpctl status` instead of only in mcpd logs. Omitted when mcpd is + * unreachable/unauthorized (fetch returns null). + */ + async function renderSecretBackendsSection( + backendsPromise: Promise, + ansi: boolean, + ): Promise { + const backends = await backendsPromise; + if (backends === null || backends.length === 0) return; + const parts = backends.map((b) => { + const err = b.tokenMeta?.lastRotationError; + const tag = b.isDefault ? `${b.name}*` : b.name; + if (err) { + const short = err.split('\n')[0]?.slice(0, 80) ?? 'error'; + return ansi ? `${tag} ${RED}✗ ${short}${RESET}` : `${tag} ✗ ${short}`; + } + return ansi ? `${tag} ${GREEN}✓${RESET}` : `${tag} ✓`; + }); + log(`Secrets: ${parts.join(', ')}`); + } + /** * Print a "Server LLMs:" section listing mcpd-managed Llm rows by tier * with a per-LLM "say hi" liveness probe. Distinct from the mcplocal-side diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 26bb8fb..892f46f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -23,6 +23,9 @@ import { createChatCommand } from './commands/chat.js'; import { createChatLlmCommand } from './commands/chat-llm.js'; import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; +import { createReviewCommand } from './commands/review.js'; +import { createSkillsCommand } from './commands/skills.js'; +import { createPasswdCommand } from './commands/passwd.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -255,6 +258,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createPasswdCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createBackupCommand({ client, log: (...args) => console.log(...args), @@ -268,6 +276,16 @@ export function createProgram(): Command { program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createApproveCommand(projectOpsDeps)); + // PR-4: reviewer queue for proposed prompts + skills. + program.addCommand(createReviewCommand({ + client, + log: (...args) => console.log(...args), + })); + // PR-5: skills sync to ~/.claude/skills/ on demand or via SessionStart hook. + program.addCommand(createSkillsCommand({ + client, + log: (...args) => console.log(...args), + })); program.addCommand(createMcpCommand({ getProject: () => program.opts().project as string | undefined, }), { hidden: true }); diff --git a/src/cli/src/utils/hooks-materialiser.ts b/src/cli/src/utils/hooks-materialiser.ts new file mode 100644 index 0000000..1cc929b --- /dev/null +++ b/src/cli/src/utils/hooks-materialiser.ts @@ -0,0 +1,180 @@ +/** + * Materialise skill-declared hooks into Claude Code's + * `~/.claude/settings.json`. + * + * Each entry we write carries two markers: + * `_mcpctl_managed: true` — same flag the SessionStart-hook + * installer uses; identifies an entry mcpctl owns. + * `_mcpctl_source: ""` — which skill installed it. + * + * The combination lets us cleanly add/update/remove skill hooks without + * clobbering hooks the user added by hand and without one skill trampling + * another. Removing skill X re-reads the file, drops every entry tagged + * `_mcpctl_source: "X"`, and rewrites atomically. + * + * Claude Code ignores the extra fields (it only looks at `type` and + * `command`). + * + * The file is written atomically (temp + rename) and tolerant of an + * existing file that has comments, no `hooks` block, or unexpected + * shape — same robustness profile as sessionhook.ts. + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +import { MARKER_KEY } from './sessionhook.js'; + +export const SOURCE_KEY = '_mcpctl_source'; + +/** A single hook entry: must be `type: 'command'` for v1. Extra fields preserved. */ +export interface ManagedHookEntry { + type: 'command'; + command: string; + timeout?: number; + /** Free-form: skills can attach extra fields and they'll round-trip. */ + [k: string]: unknown; +} + +/** Recognised hook events. Validated server-side; if a new event lands later, we still write whatever the skill declares. */ +export type HookEvent = + | 'PreToolUse' + | 'PostToolUse' + | 'SessionStart' + | 'Stop' + | 'SubagentStop' + | 'Notification'; + +export type HooksByEvent = Partial>; + +interface HookGroup { + hooks: ManagedHookEntry[]; + [k: string]: unknown; +} + +interface Settings { + hooks?: Partial>; + [k: string]: unknown; +} + +function defaultSettingsPath(): string { + return join(homedir(), '.claude', 'settings.json'); +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); + return JSON.parse(stripped) as Settings; + } catch (err: unknown) { + if (isNotFoundError(err)) return {}; + throw err; + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +function isManagedBy(entry: unknown, source: string): boolean { + if (entry === null || typeof entry !== 'object') return false; + const e = entry as Record; + return e[MARKER_KEY] === true && e[SOURCE_KEY] === source; +} + +/** + * Replace this skill's hook entries with the provided set. If `hooks` + * omits an event the skill previously installed, those entries are + * dropped. Other skills' entries and user-added entries are preserved. + * + * Returns the count of changes (added or removed entries) so callers + * can short-circuit no-op writes. + */ +export async function applyManagedHooks( + source: string, + hooks: HooksByEvent, + settingsPath: string = defaultSettingsPath(), +): Promise<{ updated: boolean; settingsPath: string }> { + const settings = await readSettings(settingsPath); + if (!settings.hooks) settings.hooks = {}; + + let changed = false; + + // For each known/declared event, drop our previous entries and add the new ones. + const declaredEvents = new Set(Object.keys(hooks)); + // Also walk events that already have entries from this source (so skills can shrink scope). + for (const [eventName, groups] of Object.entries(settings.hooks)) { + if (!Array.isArray(groups)) continue; + if (groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((e) => isManagedBy(e, source)))) { + declaredEvents.add(eventName); + } + } + + for (const eventName of declaredEvents) { + const desired = hooks[eventName as HookEvent] ?? []; + const groups = (settings.hooks[eventName] as HookGroup[] | undefined) ?? []; + + // Strip our entries from each group, then drop empty groups. + const stripped: HookGroup[] = []; + for (const group of groups) { + if (!Array.isArray(group?.hooks)) { + stripped.push(group); + continue; + } + const before = group.hooks.length; + const filtered = group.hooks.filter((e) => !isManagedBy(e, source)); + if (filtered.length !== before) changed = true; + if (filtered.length > 0) { + stripped.push({ ...group, hooks: filtered }); + } + } + + // Insert the new set as a single group tagged with our source. + if (desired.length > 0) { + const tagged = desired.map((entry) => ({ + ...entry, + type: 'command' as const, + [MARKER_KEY]: true, + [SOURCE_KEY]: source, + })); + stripped.push({ hooks: tagged, [SOURCE_KEY]: source }); + changed = true; + } + + if (stripped.length === 0) { + // No groups left for this event — drop the event entirely so the + // settings.json doesn't accumulate empty arrays. + delete settings.hooks[eventName]; + } else { + settings.hooks[eventName] = stripped; + } + } + + if (!changed) { + return { updated: false, settingsPath }; + } + + await writeSettings(settingsPath, settings); + return { updated: true, settingsPath }; +} + +/** + * Drop all hook entries owned by `source`. Used by the sync's orphan- + * removal path so a skill that's no longer in the server set + * un-registers its hooks too. Returns whether anything was changed. + */ +export async function removeManagedHooks( + source: string, + settingsPath: string = defaultSettingsPath(), +): Promise<{ removed: boolean; settingsPath: string }> { + const result = await applyManagedHooks(source, {}, settingsPath); + return { removed: result.updated, settingsPath: result.settingsPath }; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/mcpservers-materialiser.ts b/src/cli/src/utils/mcpservers-materialiser.ts new file mode 100644 index 0000000..657c80e --- /dev/null +++ b/src/cli/src/utils/mcpservers-materialiser.ts @@ -0,0 +1,176 @@ +/** + * Auto-attach the MCP server dependencies a skill declares to the + * project that's syncing. Per the corporate-appliance trust model, + * publishing a skill that says "this project depends on my-grafana" + * is enough — the client takes mcpd at its word and asks mcpd to + * attach the server to the project. + * + * What this function does NOT do (deliberately): + * - Auto-create the server from a template if it's missing. + * Provisioning infrastructure from a skill push is a separate + * decision that needs explicit operator consent. v1 just warns + * when the named server doesn't exist and skips that dep. + * - Detach servers that a skill removed from its mcpServers list. + * Detach is destructive (the project loses access) and the + * `attach` itself is idempotent on the server side, so we err + * on the side of leaving things attached. PR-7 can revisit if + * a use case shows up. + * + * The mcpServers field is per-project: a skill's declared deps only + * get attached to the project the sync is running for. Global skills + * (no projectName context) skip this step entirely — there's no + * project to attach to. + */ +import type { ApiClient } from '../api-client.js'; +import { ApiError } from '../api-client.js'; + +export interface McpServerDep { + name: string; + fromTemplate?: string; + project?: string; +} + +export interface AttachResult { + attached: string[]; + alreadyAttached: string[]; + missing: string[]; + errors: Array<{ server: string; error: string }>; +} + +/** + * Resolve project name → id, list its currently-attached servers, + * then attach each declared dep that isn't already there. Idempotent + * by virtue of the existing-attachment check. + * + * Failures per-server are collected, not thrown — sync continues. + */ +export async function attachSkillMcpServers( + client: ApiClient, + projectName: string, + deps: McpServerDep[], + warn: (msg: string) => void = () => {}, +): Promise { + const result: AttachResult = { + attached: [], + alreadyAttached: [], + missing: [], + errors: [], + }; + if (deps.length === 0) return result; + + // Resolve project → id (the attach endpoint is keyed by id, not name). + let projectId: string; + try { + const projects = await client.get>('/api/v1/projects'); + const match = projects.find((p) => p.name === projectName); + if (!match) { + // No project to attach to — surface every dep as an error so the + // operator can see something is mis-configured. + for (const dep of deps) { + result.errors.push({ server: dep.name, error: `Project '${projectName}' not found` }); + } + return result; + } + projectId = match.id; + } catch (err: unknown) { + for (const dep of deps) { + result.errors.push({ + server: dep.name, + error: `Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`, + }); + } + return result; + } + + // Inspect current attachments. The /api/v1/projects/:id/servers POST + // endpoint is idempotent server-side, but we still pre-check so we + // can report alreadyAttached vs newly-attached cleanly. + let attached = new Set(); + try { + const project = await client.get<{ servers?: Array<{ server?: { name: string } }> }>(`/api/v1/projects/${projectId}`); + attached = new Set( + (project.servers ?? []) + .map((s) => s.server?.name) + .filter((n): n is string => typeof n === 'string'), + ); + } catch (err: unknown) { + warn(`mcpctl: failed to read current attachments for project '${projectName}': ${err instanceof Error ? err.message : String(err)}`); + // Fall through with an empty set — we'll attempt attaches and let + // server-side idempotency cover any duplicates. + } + + // Optionally narrow the existing-server set so we can warn loudly on + // unknown server names. (Server attaches against a non-existent + // server would 404 anyway, but a clearer warning is friendlier.) + let existingServers = new Set(); + try { + const servers = await client.get>('/api/v1/servers'); + existingServers = new Set(servers.map((s) => s.name)); + } catch { + // Best-effort; if listing fails we still try the attach. + } + + for (const dep of deps) { + // Honour an explicit `project` on the dep — defensive, normally + // matches the active project anyway. Skip mismatches so a skill + // can declare deps for a different project without collateral + // damage during this sync. + if (dep.project && dep.project !== projectName) { + continue; + } + + if (attached.has(dep.name)) { + result.alreadyAttached.push(dep.name); + continue; + } + + if (existingServers.size > 0 && !existingServers.has(dep.name)) { + // Server doesn't exist on mcpd. v1 doesn't auto-create; warn and continue. + const detail = dep.fromTemplate + ? ` (skill suggests creating it via template '${dep.fromTemplate}')` + : ''; + warn(`mcpctl: skill mcpServers dep '${dep.name}' not found on mcpd${detail}; skipping attach`); + result.missing.push(dep.name); + continue; + } + + try { + await client.post(`/api/v1/projects/${projectId}/servers`, { server: dep.name }); + result.attached.push(dep.name); + } catch (err: unknown) { + // Idempotency: 409 (already attached) is success. + if (err instanceof ApiError && err.status === 409) { + result.alreadyAttached.push(dep.name); + continue; + } + // 404 means either the project or the server vanished mid-sync. + if (err instanceof ApiError && err.status === 404) { + result.missing.push(dep.name); + continue; + } + result.errors.push({ + server: dep.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return result; +} + +/** Type-narrow the metadata.mcpServers field. Tolerant of garbage. */ +export function parseMcpServerDeps(value: unknown): McpServerDep[] { + if (!Array.isArray(value)) return []; + const out: McpServerDep[] = []; + for (const v of value) { + if (v === null || typeof v !== 'object') continue; + const obj = v as Record; + const name = obj['name']; + if (typeof name !== 'string' || name.length === 0) continue; + const dep: McpServerDep = { name }; + if (typeof obj['fromTemplate'] === 'string') dep.fromTemplate = obj['fromTemplate']; + if (typeof obj['project'] === 'string') dep.project = obj['project']; + out.push(dep); + } + return out; +} diff --git a/src/cli/src/utils/postinstall.ts b/src/cli/src/utils/postinstall.ts new file mode 100644 index 0000000..f087756 --- /dev/null +++ b/src/cli/src/utils/postinstall.ts @@ -0,0 +1,282 @@ +/** + * postInstall executor for `mcpctl skills sync`. + * + * Trust model: mcpctl runs scripts that mcpd has served. mcpd is the + * corporate source of truth — content is reviewed at publish time. We + * do NOT sandbox or signature-check on the client. The controls that + * matter live on the publishing side (RBAC, audit, reviewer queue). + * + * What we DO provide is ops hygiene: + * - Hard timeout (default 60 s, per-skill override via + * `metadata.postInstallTimeoutSec`). Stops a runaway script from + * wedging Claude startup forever. + * - Hash-pinning: the script's sha256 is recorded in the skills state + * file so the next sync skips re-execution unless the hash changed. + * Saves churn; catches "the same skill at the same semver was + * re-published with a fixed script". + * - Curated env: MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT plus + * inherited PATH / HOME / USER / SHELL. Cron-style minimal env so + * scripts behave the same on every machine. + * - Per-skill install log under ~/.mcpctl/skills//install.log + * (rotated to keep the last 5 runs). Standard sysadmin reflex. + * - Audit event back to mcpd on every run. So mcpd's audit pipeline + * has both sides of the timeline (publish + per-machine execution). + * + * Failure semantics: a non-zero exit, a hang past the timeout, or a + * spawn error is treated as a failed sync of THIS skill. The state + * file's postInstallHash is NOT updated on failure, so the next sync + * will retry. Other skills in the same sync run continue regardless. + */ +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdir, readFile, writeFile, stat } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { hostname } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; + +import type { ApiClient } from '../api-client.js'; + +export interface PostInstallInput { + /** Full path of the materialised skill directory. The script path is resolved relative to this. */ + installDir: string; + /** metadata.postInstall — relative path inside the skill bundle. */ + scriptPath: string; + /** Name of the skill. Surfaces in audit + env + log path. */ + skillName: string; + /** Skill version. Audit + env. */ + semver: string; + /** Project name when the skill is project-scoped, else undefined. */ + projectName?: string | undefined; + /** Per-skill override for the 60-s default. */ + timeoutSec?: number | undefined; + /** Where to put the rolling install.log. Default: ~/.mcpctl/skills//install.log. */ + logsDir: string; +} + +export interface PostInstallResult { + exitCode: number | null; + durationMs: number; + scriptHash: string; + timedOut: boolean; + signal: NodeJS.Signals | null; + stdoutTail: string; + stderrTail: string; +} + +const DEFAULT_TIMEOUT_SEC = 60; +const TAIL_BYTES = 4 * 1024; +const MAX_LOG_BYTES = 256 * 1024; + +/** + * Compute the sha256 of a script — used as the "have I already run this + * version?" key in the skills state file. Caller passes the raw script + * bytes; this just wraps the hash routine to stay consistent with the + * `'sha256:'`-prefixed format used elsewhere (skills-state.ts). + */ +export function hashScript(content: string | Buffer): string { + const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content; + return 'sha256:' + createHash('sha256').update(buf).digest('hex'); +} + +/** + * Run the post-install script. Returns a result regardless of success + * or failure — caller inspects `exitCode`/`timedOut` to decide. + * + * Path validation: the resolved script path must remain inside + * `installDir`. A skill that tries to point postInstall at + * `../../../../etc/passwd-like` is rejected as a failed run, not + * silently ignored. + */ +export async function runPostInstall(input: PostInstallInput): Promise { + const start = Date.now(); + const timeoutMs = (input.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1000; + + const fullPath = resolve(input.installDir, input.scriptPath); + // Defence in depth: the install dir is server-published content, but + // a server with skill-write RBAC could still cause mischief. The + // check makes our intent explicit: scripts may only live inside the + // skill bundle. + const installDirResolved = resolve(input.installDir); + if (!fullPath.startsWith(installDirResolved + '/') && fullPath !== installDirResolved) { + throw new Error( + `postInstall path '${input.scriptPath}' escapes skill dir`, + ); + } + + // Read script bytes for hashing (and to fail-fast if missing). + const scriptBytes = await readFile(fullPath); + const scriptHash = hashScript(scriptBytes); + + // Curated env. Cron-style minimum: keep PATH so the script can find + // git/curl/python; keep HOME/USER/SHELL so scripts that touch dotfiles + // work; drop everything else. + const env: Record = { + PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin', + HOME: process.env['HOME'] ?? '', + USER: process.env['USER'] ?? '', + SHELL: process.env['SHELL'] ?? '/bin/sh', + LANG: process.env['LANG'] ?? 'C.UTF-8', + TERM: process.env['TERM'] ?? 'dumb', + MCPCTL_SKILL_NAME: input.skillName, + MCPCTL_SKILL_VERSION: input.semver, + MCPCTL_SKILL_DIR: installDirResolved, + }; + if (input.projectName) env['MCPCTL_PROJECT'] = input.projectName; + + // Make sure the script is executable. Some upstreams ship with mode + // 0644 — if shebang exists, we can fall through to the interpreter; + // otherwise spawn will EACCES. + await ensureExecutable(fullPath, scriptBytes); + + await mkdir(input.logsDir, { recursive: true }); + const logPath = join(input.logsDir, 'install.log'); + + // Rolling-append. Keep ~256 KB; old entries get truncated. The tail + // returned to the caller is the last few KB regardless. + const logHeader = `\n=== ${new Date().toISOString()} ${input.skillName}@${input.semver} ===\n`; + + // Cast through Buffer — Node's typings split Buffer + // into Buffer (from .alloc) and Buffer + // (from .subarray), which exactOptionalPropertyTypes refuses to + // bridge. Explicit `Buffer` annotation widens to the union. + let stdoutBuf: Buffer = Buffer.alloc(0); + let stderrBuf: Buffer = Buffer.alloc(0); + let timedOut = false; + + const child = spawn(fullPath, [], { + cwd: installDirResolved, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuf = appendCapped(stdoutBuf, chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf = appendCapped(stderrBuf, chunk); + }); + + // Hard timeout via SIGTERM, then SIGKILL after 2 s grace. + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + void (async () => { + await delay(2000); + if (child.exitCode === null) child.kill('SIGKILL'); + })(); + }, timeoutMs); + + const { exitCode, signal } = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>((resolveProm) => { + child.on('close', (code, sig) => resolveProm({ exitCode: code, signal: sig })); + child.on('error', () => resolveProm({ exitCode: null, signal: null })); + }); + clearTimeout(timer); + + const durationMs = Date.now() - start; + const stdoutText = stdoutBuf.toString('utf-8'); + const stderrText = stderrBuf.toString('utf-8'); + + // Append to the install log, truncating from the front if oversize. + const trailer = `\n--- exit ${exitCode === null ? '?' : String(exitCode)}${signal ? ` (${signal})` : ''} in ${String(durationMs)}ms${timedOut ? ' [TIMEOUT]' : ''} ---\n`; + const fullEntry = logHeader + 'STDOUT:\n' + stdoutText + '\nSTDERR:\n' + stderrText + trailer; + await appendBoundedLog(logPath, fullEntry); + + return { + exitCode, + durationMs, + scriptHash, + timedOut, + signal, + stdoutTail: tailString(stdoutText, TAIL_BYTES), + stderrTail: tailString(stderrText, TAIL_BYTES), + }; +} + +/** + * Best-effort audit emission — POSTs a structured event back to mcpd + * so admins have fleet visibility. Failures are warned via the + * provided logger but never thrown; the audit log is supplementary, + * not load-bearing for sync correctness. + * + * The event includes machine fingerprint (hostname) so the operator + * can tell which dev box ran the script — useful when triaging a + * misbehaving update. + */ +export async function emitPostInstallAudit( + client: ApiClient, + input: PostInstallInput, + result: PostInstallResult, + warn: (msg: string) => void = () => {}, +): Promise { + try { + await client.post('/api/v1/audit-events', { + eventKind: 'skill_postinstall', + source: 'mcpctl-cli', + verified: false, + payload: { + skillName: input.skillName, + skillVersion: input.semver, + projectName: input.projectName ?? null, + scriptPath: input.scriptPath, + scriptHash: result.scriptHash, + exitCode: result.exitCode, + durationMs: result.durationMs, + timedOut: result.timedOut, + signal: result.signal, + machine: hostname(), + }, + }); + } catch (err) { + warn(`mcpctl: failed to emit postInstall audit event: ${err instanceof Error ? err.message : String(err)}`); + } +} + +// ── internals ── + +function appendCapped(buf: Buffer, chunk: Buffer): Buffer { + // Keep up to MAX_LOG_BYTES per stream; drop oldest bytes if over. + if (buf.length + chunk.length <= MAX_LOG_BYTES) { + return Buffer.concat([buf, chunk]); + } + const merged = Buffer.concat([buf, chunk]); + // Buffer.from(...) here keeps Node's typing happy under + // exactOptionalPropertyTypes — `subarray` on Buffer returns a + // Buffer which TS won't widen to the input type. + return Buffer.from(merged.subarray(merged.length - MAX_LOG_BYTES)); +} + +function tailString(s: string, bytes: number): string { + if (s.length <= bytes) return s; + return '…' + s.slice(s.length - bytes + 1); +} + +async function ensureExecutable(path: string, bytes: Buffer): Promise { + try { + const st = await stat(path); + // Owner execute bit. Skip if it's set already. + if ((st.mode & 0o100) !== 0) return; + } catch { + return; // stat failed — let the spawn surface the real error + } + // Has shebang? Fine — many shells will still execute even without +x + // when invoked as ` `, but we always spawn the + // path directly so we need +x. Set 0755. + void bytes; // (kept around in case we want to inspect shebang later) + const { chmod } = await import('node:fs/promises'); + await chmod(path, 0o755); +} + +async function appendBoundedLog(path: string, entry: string): Promise { + const max = 5 * MAX_LOG_BYTES; + let existing = ''; + try { + existing = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if (typeof err !== 'object' || err === null || (err as { code?: string }).code !== 'ENOENT') throw err; + } + const combined = existing + entry; + // Keep last `max` bytes. + const trimmed = combined.length > max ? '…[truncated]…\n' + combined.slice(combined.length - max) : combined; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, trimmed, 'utf-8'); +} diff --git a/src/cli/src/utils/project-marker.ts b/src/cli/src/utils/project-marker.ts new file mode 100644 index 0000000..8c405b2 --- /dev/null +++ b/src/cli/src/utils/project-marker.ts @@ -0,0 +1,62 @@ +/** + * Project detection for `mcpctl skills sync`. Walks up from cwd looking + * for a `.mcpctl-project` file (single line, project name). Written by + * `mcpctl config claude --project X` at project setup time. + * + * We deliberately don't probe git remotes, env vars, or config heuristics — + * the marker file is the one true source. If you want a different project + * for a sync, pass `--project` explicitly. + */ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export const MARKER_FILENAME = '.mcpctl-project'; + +/** + * Walk up from `start` (default: cwd) looking for the marker file. Returns + * the project name (file contents, trimmed) or null if the walk reaches the + * root without finding one. We stop at the filesystem root and at the user's + * home directory — searching above $HOME doesn't make sense for a per-user + * tool. + */ +export async function findProjectMarker(start: string = process.cwd(), homeDir?: string): Promise<{ project: string; markerPath: string } | null> { + const home = homeDir ?? process.env['HOME']; + let dir = start; + // Defense against broken or pathological inputs. + if (!dir || dir === '/') return null; + + // Bound the walk: 50 levels is generous; protects against symlink loops. + for (let i = 0; i < 50; i++) { + const candidate = join(dir, MARKER_FILENAME); + try { + const raw = await readFile(candidate, 'utf-8'); + const project = raw.split('\n')[0]?.trim() ?? ''; + if (project.length === 0) return null; + return { project, markerPath: candidate }; + } catch (err: unknown) { + if (!isNotFoundError(err)) throw err; + } + + if (home && dir === home) return null; + const parent = dirname(dir); + if (parent === dir) return null; // reached root + dir = parent; + } + return null; +} + +/** + * Write the marker file. Idempotent — overwriting with the same value is + * a no-op from the caller's perspective. Used by + * `mcpctl config claude --project X`. + */ +export async function writeProjectMarker(dir: string, project: string): Promise { + const path = join(dir, MARKER_FILENAME); + const { writeFile } = await import('node:fs/promises'); + await writeFile(path, project + '\n', 'utf-8'); + return path; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts new file mode 100644 index 0000000..49a9479 --- /dev/null +++ b/src/cli/src/utils/sessionhook.ts @@ -0,0 +1,140 @@ +/** + * Manage Claude Code's SessionStart hook in `~/.claude/settings.json`. + * + * mcpctl needs `mcpctl skills sync --quiet` to run on every Claude + * invocation. We do this via Claude Code's SessionStart hook mechanism; + * to coexist with hooks the user added by hand, every entry we write + * carries a `_mcpctl_managed: true` marker (which Claude Code ignores + * but we use to identify our row on subsequent runs). + * + * Defensive against `~/.claude/settings.json` being missing, empty, or + * shaped differently than expected (e.g. comments — JSON-with-comments + * is allowed by some editors, though Claude Code itself only writes + * pure JSON). + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +export const MARKER_KEY = '_mcpctl_managed'; + +interface HookEntry { + type: 'command'; + command: string; + // Markers — Claude Code ignores extra fields; we use them to identify ours. + [k: string]: unknown; +} + +interface HookGroup { + hooks: HookEntry[]; + [k: string]: unknown; +} + +interface Settings { + hooks?: { + SessionStart?: HookGroup[]; + [k: string]: unknown; + }; + [k: string]: unknown; +} + +function defaultSettingsPath(): string { + return join(homedir(), '.claude', 'settings.json'); +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + // Strip line comments so files written by VS Code etc still parse. + // This is a heuristic — JSON-with-comments isn't a real spec — but it + // covers the common case. Block comments ("/* ... */") are not stripped. + const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); + return JSON.parse(stripped) as Settings; + } catch (err: unknown) { + if (isNotFoundError(err)) return {}; + throw err; + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Insert or update the managed SessionStart hook. Idempotent — running + * `mcpctl config claude --project X` twice does not create duplicate + * entries. + */ +export async function installManagedSessionHook( + command: string, + settingsPath: string = defaultSettingsPath(), +): Promise<{ updated: boolean; settingsPath: string }> { + const settings = await readSettings(settingsPath); + if (!settings.hooks) settings.hooks = {}; + if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []; + + const groups = settings.hooks.SessionStart; + let foundEntry = false; + let entryChanged = false; + + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + for (let i = 0; i < group.hooks.length; i++) { + const entry = group.hooks[i]; + if (entry !== undefined && entry[MARKER_KEY] === true) { + foundEntry = true; + if (entry.command !== command) { + group.hooks[i] = { type: 'command', command, [MARKER_KEY]: true }; + entryChanged = true; + } + } + } + } + + if (!foundEntry) { + groups.push({ + hooks: [{ type: 'command', command, [MARKER_KEY]: true }], + }); + entryChanged = true; + } + + if (entryChanged) { + await writeSettings(settingsPath, settings); + } + return { updated: entryChanged, settingsPath }; +} + +/** + * Remove the managed SessionStart hook (used by `mcpctl config claude + * --uninstall` in a later PR). Returns whether anything was changed. + */ +export async function removeManagedSessionHook( + settingsPath: string = defaultSettingsPath(), +): Promise<{ removed: boolean; settingsPath: string }> { + const settings = await readSettings(settingsPath); + const groups = settings.hooks?.SessionStart; + if (!Array.isArray(groups)) return { removed: false, settingsPath }; + + let changed = false; + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + const before = group.hooks.length; + group.hooks = group.hooks.filter((entry) => entry?.[MARKER_KEY] !== true); + if (group.hooks.length !== before) changed = true; + } + // Drop any group that became empty. + settings.hooks!.SessionStart = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0); + + if (changed) { + await writeSettings(settingsPath, settings); + } + return { removed: changed, settingsPath }; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/skills-disk.ts b/src/cli/src/utils/skills-disk.ts new file mode 100644 index 0000000..a58c15a --- /dev/null +++ b/src/cli/src/utils/skills-disk.ts @@ -0,0 +1,123 @@ +/** + * On-disk materialisation for skills. Atomic-by-rename: stage a skill's + * full file tree under `.mcpctl-staging-/`, then swap the + * old directory aside (rename to `.mcpctl-trash-`) and move the + * staging dir into place. A concurrent reader (Claude Code starting up) + * therefore never sees a partially-written tree. + */ +import { mkdir, rm, rename, writeFile, readdir, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import type { FileState } from './skills-state.js'; +import { sha256Of } from './skills-state.js'; + +export interface SkillBody { + /** SKILL.md content. */ + content: string; + /** Auxiliary files keyed by relative path. */ + files?: Record; +} + +/** + * Write a skill atomically into `targetDir`. If a previous install exists, + * it's renamed to `.mcpctl-trash-` and rmtree'd after the + * swap succeeds — so the live tree is always consistent. + * + * Returns the per-file FileState map for the state file. + */ +export async function installSkillAtomic(targetDir: string, body: SkillBody): Promise> { + const parent = dirname(targetDir); + await mkdir(parent, { recursive: true }); + + const stagingDir = `${targetDir}.mcpctl-staging-${String(process.pid)}`; + // If a stale staging dir exists from a previous crash, scrub it. + await rm(stagingDir, { recursive: true, force: true }); + await mkdir(stagingDir, { recursive: true }); + + const fileStates: Record = {}; + // Always write SKILL.md first. + await writeFileAt(stagingDir, 'SKILL.md', body.content, fileStates); + if (body.files) { + for (const [rel, content] of Object.entries(body.files)) { + // Reject paths that try to escape the install dir. Skill files are + // server-published; the server should already validate, but the + // client checks too as defence in depth. + if (rel.includes('..') || rel.startsWith('/')) { + throw new Error(`Skill file path escapes install dir: ${rel}`); + } + await writeFileAt(stagingDir, rel, content, fileStates); + } + } + + // Atomic swap: rename existing tree aside, move staging in, rmtree the old. + const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`; + let hadExisting = false; + try { + await rename(targetDir, trashDir); + hadExisting = true; + } catch (err: unknown) { + if (!isNotFoundError(err)) throw err; + } + await rename(stagingDir, targetDir); + if (hadExisting) { + await rm(trashDir, { recursive: true, force: true }); + } + return fileStates; +} + +/** + * Symmetric atomic delete: rename to `.mcpctl-trash-` first, then + * rmtree. Skip if the directory doesn't exist. + */ +export async function removeSkillAtomic(targetDir: string): Promise { + const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`; + try { + await rename(targetDir, trashDir); + } catch (err: unknown) { + if (isNotFoundError(err)) return; + throw err; + } + await rm(trashDir, { recursive: true, force: true }); +} + +/** True if `path` is a directory. */ +export async function isDirectory(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch (err: unknown) { + if (isNotFoundError(err)) return false; + throw err; + } +} + +/** List subdirectories of `parent` that aren't staging/trash artifacts. */ +export async function listInstalledSkillNames(parent: string): Promise { + try { + const entries = await readdir(parent, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.includes('.mcpctl-staging-') && !name.includes('.mcpctl-trash-')); + } catch (err: unknown) { + if (isNotFoundError(err)) return []; + throw err; + } +} + +async function writeFileAt( + base: string, + rel: string, + content: string, + states: Record, +): Promise { + const full = join(base, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content, 'utf-8'); + const buf = Buffer.from(content, 'utf-8'); + states[rel] = { sha256: sha256Of(buf), size: buf.length }; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/skills-state.ts b/src/cli/src/utils/skills-state.ts new file mode 100644 index 0000000..8f653cd --- /dev/null +++ b/src/cli/src/utils/skills-state.ts @@ -0,0 +1,136 @@ +/** + * Local state for `mcpctl skills sync`. Lives at + * `~/.mcpctl/skills-state.json` (NOT under `~/.claude/skills/` — Claude + * Code reads that tree and we don't want to pollute it with our + * bookkeeping). Tracks installed skills + per-file SHA-256 so the next + * sync can detect server-side changes (via top-level contentHash) and + * client-side modifications (via per-file hash drift). + */ +import { createHash } from 'node:crypto'; +import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +const STATE_SCHEMA_VERSION = 1; + +export interface FileState { + /** sha256 of the file contents at write time. */ + sha256: string; + size: number; +} + +export interface SkillState { + id: string; + semver: string; + /** sha256 of the canonicalised skill body — matches mcpd's hash. */ + contentHash: string; + scope: 'project' | 'global' | 'agent'; + installDir: string; + files: Record; + /** sha256 of the postInstall script if any; null if none. */ + postInstallHash: string | null; + lastSyncedAt: string; +} + +export interface SkillsStateFile { + schemaVersion: number; + lastSync: string | null; + lastSyncProject: string | null; + /** keyed by skill name. */ + skills: Record; +} + +const DEFAULT_PATH = join(homedir(), '.mcpctl', 'skills-state.json'); + +export function defaultStatePath(): string { + return DEFAULT_PATH; +} + +export function emptyState(): SkillsStateFile { + return { + schemaVersion: STATE_SCHEMA_VERSION, + lastSync: null, + lastSyncProject: null, + skills: {}, + }; +} + +/** + * Compute sha256 of a buffer or string. Matches the + * `'sha256:'`-prefixed format mcpd produces. + */ +export function sha256Of(data: Buffer | string): string { + const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; + return 'sha256:' + createHash('sha256').update(buf).digest('hex'); +} + +export async function loadState(path = DEFAULT_PATH): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + // Be lenient: if the schema is older or fields missing, hydrate to defaults. + if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) { + // For schemaVersion drift in v1 we treat the file as unparseable + // and start fresh; future migrations can dispatch on the value. + return emptyState(); + } + return { + schemaVersion: STATE_SCHEMA_VERSION, + lastSync: parsed.lastSync ?? null, + lastSyncProject: parsed.lastSyncProject ?? null, + skills: parsed.skills ?? {}, + }; + } catch (err: unknown) { + if (isNotFoundError(err)) { + return emptyState(); + } + throw err; + } +} + +/** Atomic write: temp file in the same dir, then rename. */ +export async function saveState(state: SkillsStateFile, path = DEFAULT_PATH): Promise { + const dir = dirname(path); + await mkdir(dir, { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** Detect whether on-disk file content matches what we last wrote. */ +export async function hasFileBeenModified(installDir: string, relPath: string, recorded: FileState): Promise { + try { + const buf = await readFile(join(installDir, relPath)); + if (buf.length !== recorded.size) return true; + return sha256Of(buf) !== recorded.sha256; + } catch (err: unknown) { + if (isNotFoundError(err)) return true; // missing file ≠ pristine + throw err; + } +} + +/** Walk a skill's installed files and report which were edited locally. */ +export async function detectModifiedFiles(installDir: string, files: Record): Promise { + const modified: string[] = []; + for (const [rel, fs] of Object.entries(files)) { + if (await hasFileBeenModified(installDir, rel, fs)) { + modified.push(rel); + } + } + return modified; +} + +/** Check if a path exists. */ +export async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch (err: unknown) { + if (isNotFoundError(err)) return false; + throw err; + } +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index cb17db3..9eb0053 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -37,9 +37,12 @@ describe('config claude', () => { { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); - await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' }); + // PR-5: --skip-skills bypasses the new sync + SessionStart hook side + // effects so this test stays focused on .mcp.json generation. The new + // sync flow has its own tests under src/cli/tests/utils/. + await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath, '--skip-skills'], { from: 'user' }); - // No API call should be made + // No API call should be made when --skip-skills is set. expect(client.get).not.toHaveBeenCalled(); const written = JSON.parse(readFileSync(outPath, 'utf-8')); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index 4313a7e..d627b55 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -29,6 +29,7 @@ function baseDeps(overrides?: Partial): Partial null, fetchServerLlms: async () => null, probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }), + fetchSecretBackends: async () => null, isTTY: false, ...overrides, }; @@ -45,6 +46,39 @@ afterEach(() => { }); describe('status command', () => { + it('shows a healthy secret backend in the Secrets line', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [ + { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }, + { name: 'default', type: 'plaintext' }, + ], + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('Secrets:'); + expect(out).toContain('bao* ✓'); + expect(out).toContain('default ✓'); + }); + + it('flags a dead secret-backend token in the Secrets line', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [ + { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, + ], + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ✗'); + expect(out).toContain('BACKEND_TOKEN_DEAD'); + expect(out).not.toContain('more detail'); // only first line, truncated + }); + + it('omits the Secrets line when mcpd returns no backends', async () => { + const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => null })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).not.toContain('Secrets:'); + }); + it('shows status in table format', async () => { const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); diff --git a/src/cli/tests/utils/hooks-materialiser.test.ts b/src/cli/tests/utils/hooks-materialiser.test.ts new file mode 100644 index 0000000..bb24f94 --- /dev/null +++ b/src/cli/tests/utils/hooks-materialiser.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { applyManagedHooks, removeManagedHooks, SOURCE_KEY } from '../../src/utils/hooks-materialiser.js'; +import { MARKER_KEY } from '../../src/utils/sessionhook.js'; + +describe('hooks-materialiser', () => { + let tmp: string; + let settings: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-hooks-')); + settings = join(tmp, 'settings.json'); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('writes a tagged hook from scratch when settings.json is missing', async () => { + const result = await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo before' }], + }, settings); + + expect(result.updated).toBe(true); + const file = JSON.parse(await readFile(settings, 'utf-8')); + expect(file.hooks.PreToolUse).toHaveLength(1); + const entry = file.hooks.PreToolUse[0].hooks[0]; + expect(entry.command).toBe('echo before'); + expect(entry[MARKER_KEY]).toBe(true); + expect(entry[SOURCE_KEY]).toBe('skill-a'); + }); + + it('coexists with hooks owned by other skills', async () => { + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + await applyManagedHooks('skill-b', { + PreToolUse: [{ type: 'command', command: 'echo b' }], + }, settings); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks); + expect(all.find((e: { command: string }) => e.command === 'echo a')).toBeDefined(); + expect(all.find((e: { command: string }) => e.command === 'echo b')).toBeDefined(); + expect(all).toHaveLength(2); + }); + + it('preserves user-added hooks (no marker)', async () => { + await mkdir(tmp, { recursive: true }); + await writeFile(settings, JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'echo user' }] }], + }, + })); + + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks); + expect(all.find((e: { command: string }) => e.command === 'echo user')).toBeDefined(); + expect(all.find((e: { command: string; [k: string]: unknown }) => e.command === 'echo a' && e[MARKER_KEY] === true)).toBeDefined(); + }); + + it('updating a skill replaces its old entries (does not duplicate)', async () => { + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo old' }], + }, settings); + const second = await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo new' }], + }, settings); + + expect(second.updated).toBe(true); + const file = JSON.parse(await readFile(settings, 'utf-8')); + const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks); + const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a'); + expect(ours).toHaveLength(1); + expect((ours[0] as { command: string }).command).toBe('echo new'); + }); + + it('shrinking a skill drops events it no longer declares', async () => { + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo pre' }], + PostToolUse: [{ type: 'command', command: 'echo post' }], + }, settings); + + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo pre' }], + // PostToolUse omitted → should be dropped + }, settings); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + expect(file.hooks.PreToolUse).toBeDefined(); + expect(file.hooks.PostToolUse).toBeUndefined(); + }); + + it('removeManagedHooks drops only the named source', async () => { + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + await applyManagedHooks('skill-b', { + PreToolUse: [{ type: 'command', command: 'echo b' }], + }, settings); + + const removed = await removeManagedHooks('skill-a', settings); + expect(removed.removed).toBe(true); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks); + expect(all).toHaveLength(1); + expect((all[0] as { command: string }).command).toBe('echo b'); + }); + + it('removeManagedHooks is a no-op when the source has no entries', async () => { + const result = await removeManagedHooks('never-installed', settings); + expect(result.removed).toBe(false); + }); + + it('handles multiple hook events independently', async () => { + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo pre' }], + PostToolUse: [{ type: 'command', command: 'echo post' }], + SessionStart: [{ type: 'command', command: 'echo start' }], + }, settings); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + expect(file.hooks.PreToolUse).toBeDefined(); + expect(file.hooks.PostToolUse).toBeDefined(); + expect(file.hooks.SessionStart).toBeDefined(); + }); + + it('idempotent — re-applying the same hooks reports updated=true on first call only', async () => { + const first = await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + expect(first.updated).toBe(true); + + // Re-applying ALWAYS rewrites our entry (we don't deep-equal them + // for "no change"), but the resulting file is byte-identical except + // for ordering. The test just confirms the file remains valid + well-shaped. + const second = await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + // updated=true is acceptable here; we replaced+re-added our entry. + expect(second.updated).toBe(true); + + const file = JSON.parse(await readFile(settings, 'utf-8')); + const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks); + const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a'); + expect(ours).toHaveLength(1); + }); + + it('survives empty settings.json', async () => { + await writeFile(settings, ''); + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + const file = JSON.parse(await readFile(settings, 'utf-8')); + expect(file.hooks.PreToolUse).toHaveLength(1); + }); + + it('survives JSONC line comments in settings.json', async () => { + await writeFile(settings, '// preamble\n{ "hooks": {} }\n'); + await applyManagedHooks('skill-a', { + PreToolUse: [{ type: 'command', command: 'echo a' }], + }, settings); + const file = JSON.parse(await readFile(settings, 'utf-8')); + expect(file.hooks.PreToolUse).toHaveLength(1); + }); +}); diff --git a/src/cli/tests/utils/mcpservers-materialiser.test.ts b/src/cli/tests/utils/mcpservers-materialiser.test.ts new file mode 100644 index 0000000..356b08c --- /dev/null +++ b/src/cli/tests/utils/mcpservers-materialiser.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi } from 'vitest'; +import { attachSkillMcpServers, parseMcpServerDeps } from '../../src/utils/mcpservers-materialiser.js'; +import type { ApiClient } from '../../src/api-client.js'; +import { ApiError } from '../../src/api-client.js'; + +interface MockClient { + get: ReturnType; + post: ReturnType; + put: ReturnType; + delete: ReturnType; +} + +function makeClient(): MockClient { + return { + get: vi.fn(), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => undefined), + }; +} + +function apiError(status: number, body = 'err'): ApiError { + return new ApiError(status, body); +} + +describe('mcpservers-materialiser', () => { + describe('parseMcpServerDeps', () => { + it('returns [] for non-arrays', () => { + expect(parseMcpServerDeps(null)).toEqual([]); + expect(parseMcpServerDeps('foo')).toEqual([]); + expect(parseMcpServerDeps({})).toEqual([]); + }); + + it('keeps valid entries and drops garbage', () => { + const out = parseMcpServerDeps([ + { name: 'good', fromTemplate: 't', project: 'p' }, + { name: '', fromTemplate: 't' }, // empty name → drop + { fromTemplate: 'no-name' }, // no name → drop + { name: 'bare' }, // valid, minimal + 'string', // not an object → drop + ]); + expect(out).toEqual([ + { name: 'good', fromTemplate: 't', project: 'p' }, + { name: 'bare' }, + ]); + }); + }); + + describe('attachSkillMcpServers', () => { + it('attaches a new server when not already present', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [] }; + if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; + throw new Error(`unexpected GET ${path}`); + }); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana', fromTemplate: 'grafana' }], + ); + + expect(result.attached).toEqual(['my-grafana']); + expect(result.alreadyAttached).toEqual([]); + expect(result.missing).toEqual([]); + expect(result.errors).toEqual([]); + expect(client.post).toHaveBeenCalledWith('/api/v1/projects/proj-1/servers', { server: 'my-grafana' }); + }); + + it('reports alreadyAttached without re-posting', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [{ server: { name: 'my-grafana' } }] }; + if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; + throw new Error(`unexpected GET ${path}`); + }); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana' }], + ); + + expect(result.alreadyAttached).toEqual(['my-grafana']); + expect(result.attached).toEqual([]); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('warns + skips when server does not exist on mcpd', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [] }; + if (path === '/api/v1/servers') return [{ name: 'something-else' }]; + throw new Error(`unexpected GET ${path}`); + }); + + const warnings: string[] = []; + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana', fromTemplate: 'grafana' }], + (m) => warnings.push(m), + ); + + expect(result.missing).toEqual(['my-grafana']); + expect(result.attached).toEqual([]); + expect(client.post).not.toHaveBeenCalled(); + expect(warnings.some((w) => w.includes('my-grafana') && w.includes('grafana'))).toBe(true); + }); + + it('errors-out when the project does not exist', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return []; // no projects + throw new Error(`unexpected GET ${path}`); + }); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'no-such-project', + [{ name: 'my-grafana' }], + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.error).toContain('Project'); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('treats 409 from POST as alreadyAttached (idempotent server-side)', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + // attachments listing fails — fall back to attempting + handling 409 + if (path === '/api/v1/projects/proj-1') throw apiError(500, 'flake'); + if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; + throw new Error(`unexpected GET ${path}`); + }); + client.post.mockRejectedValueOnce(apiError(409, 'already attached')); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana' }], + ); + + expect(result.alreadyAttached).toEqual(['my-grafana']); + expect(result.errors).toEqual([]); + }); + + it('treats 404 from POST as missing (server vanished mid-sync)', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [] }; + if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; // existed when we listed + throw new Error(`unexpected GET ${path}`); + }); + // …but vanished by the time we POSTed. + client.post.mockRejectedValueOnce(apiError(404, 'gone')); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana' }], + ); + + expect(result.missing).toEqual(['my-grafana']); + expect(result.errors).toEqual([]); + }); + + it('skips deps that target a different project', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [] }; + if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; + throw new Error(`unexpected GET ${path}`); + }); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'my-grafana', project: 'other-project' }], + ); + + expect(result.attached).toEqual([]); + expect(result.missing).toEqual([]); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('continues past per-server errors', async () => { + const client = makeClient(); + client.get.mockImplementation(async (path: string) => { + if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }]; + if (path === '/api/v1/projects/proj-1') return { servers: [] }; + if (path === '/api/v1/servers') return [{ name: 'a' }, { name: 'b' }]; + throw new Error(`unexpected GET ${path}`); + }); + client.post.mockImplementation(async (path: string, body) => { + if ((body as { server: string }).server === 'a') throw apiError(500, 'boom'); + return {}; + }); + + const result = await attachSkillMcpServers( + client as unknown as ApiClient, + 'demo', + [{ name: 'a' }, { name: 'b' }], + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.server).toBe('a'); + expect(result.attached).toEqual(['b']); + }); + + it('returns empty on empty deps without making any calls', async () => { + const client = makeClient(); + const result = await attachSkillMcpServers(client as unknown as ApiClient, 'demo', []); + expect(result).toEqual({ attached: [], alreadyAttached: [], missing: [], errors: [] }); + expect(client.get).not.toHaveBeenCalled(); + expect(client.post).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/tests/utils/postinstall.test.ts b/src/cli/tests/utils/postinstall.test.ts new file mode 100644 index 0000000..1a231bd --- /dev/null +++ b/src/cli/tests/utils/postinstall.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, chmod, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { runPostInstall, hashScript } from '../../src/utils/postinstall.js'; + +describe('postinstall executor', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-postinstall-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + describe('hashScript', () => { + it('returns deterministic sha256-prefixed hash', () => { + expect(hashScript('hello')).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(hashScript('hello')).toBe(hashScript('hello')); + expect(hashScript('hello')).not.toBe(hashScript('hellp')); + }); + }); + + describe('runPostInstall — success path', () => { + it('runs a passing script and returns exit 0 + script hash', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'install.sh'); + await writeFile(scriptPath, '#!/bin/sh\necho hello-stdout\necho hello-stderr 1>&2\nexit 0\n'); + await chmod(scriptPath, 0o755); + + const result = await runPostInstall({ + installDir, + scriptPath: 'install.sh', + skillName: 'test-skill', + semver: '0.1.0', + logsDir: join(tmp, 'logs'), + }); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.stdoutTail).toContain('hello-stdout'); + expect(result.stderrTail).toContain('hello-stderr'); + expect(result.scriptHash).toMatch(/^sha256:/); + }); + + it('passes curated env (MCPCTL_SKILL_NAME, _VERSION, _DIR, _PROJECT)', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'install.sh'); + // Write env vars to a file we can read back. + const outFile = join(tmp, 'env-dump.txt'); + await writeFile(scriptPath, `#!/bin/sh +echo "name=$MCPCTL_SKILL_NAME" > ${JSON.stringify(outFile)} +echo "version=$MCPCTL_SKILL_VERSION" >> ${JSON.stringify(outFile)} +echo "dir=$MCPCTL_SKILL_DIR" >> ${JSON.stringify(outFile)} +echo "project=$MCPCTL_PROJECT" >> ${JSON.stringify(outFile)} +`); + await chmod(scriptPath, 0o755); + + const result = await runPostInstall({ + installDir, + scriptPath: 'install.sh', + skillName: 'env-test', + semver: '1.2.3', + projectName: 'demo', + logsDir: join(tmp, 'logs'), + }); + expect(result.exitCode).toBe(0); + + const dumped = await readFile(outFile, 'utf-8'); + expect(dumped).toContain('name=env-test'); + expect(dumped).toContain('version=1.2.3'); + expect(dumped).toContain('dir=' + installDir); + expect(dumped).toContain('project=demo'); + }); + + it('chmods 0644 scripts to executable before spawn', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'install.sh'); + await writeFile(scriptPath, '#!/bin/sh\nexit 0\n'); + await chmod(scriptPath, 0o644); // not executable + + const result = await runPostInstall({ + installDir, + scriptPath: 'install.sh', + skillName: 't', + semver: '0.1.0', + logsDir: join(tmp, 'logs'), + }); + + expect(result.exitCode).toBe(0); + }); + }); + + describe('runPostInstall — failure paths', () => { + it('captures non-zero exit code and returns it', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'fail.sh'); + await writeFile(scriptPath, '#!/bin/sh\necho oops 1>&2\nexit 7\n'); + await chmod(scriptPath, 0o755); + + const result = await runPostInstall({ + installDir, + scriptPath: 'fail.sh', + skillName: 't', + semver: '0.1.0', + logsDir: join(tmp, 'logs'), + }); + + expect(result.exitCode).toBe(7); + expect(result.timedOut).toBe(false); + expect(result.stderrTail).toContain('oops'); + }); + + it('honors timeoutSec — kills via SIGTERM and reports timedOut=true', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'hang.sh'); + // `exec` so SIGTERM hits sleep directly — without it /bin/sh + // catches the signal but the orphaned sleep keeps the streams + // open until SIGKILL; the test then has to wait for the 2s grace + // window before we force-kill, which is fine but flakier. + await writeFile(scriptPath, '#!/bin/sh\nexec sleep 30\n'); + await chmod(scriptPath, 0o755); + + const start = Date.now(); + const result = await runPostInstall({ + installDir, + scriptPath: 'hang.sh', + skillName: 't', + semver: '0.1.0', + timeoutSec: 1, + logsDir: join(tmp, 'logs'), + }); + const elapsed = Date.now() - start; + + expect(result.timedOut).toBe(true); + // 1s timeout + up to 2s grace before SIGKILL. + expect(elapsed).toBeLessThan(5000); + expect(elapsed).toBeGreaterThanOrEqual(1000); + }, 15_000); + + it('rejects path-escape attempts', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + + await expect(runPostInstall({ + installDir, + scriptPath: '../escape.sh', + skillName: 't', + semver: '0.1.0', + logsDir: join(tmp, 'logs'), + })).rejects.toThrow(/escapes skill dir/); + }); + + it('throws when the script does not exist', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + + await expect(runPostInstall({ + installDir, + scriptPath: 'missing.sh', + skillName: 't', + semver: '0.1.0', + logsDir: join(tmp, 'logs'), + })).rejects.toThrow(); + }); + }); + + describe('runPostInstall — install log', () => { + it('writes stdout + stderr + exit summary to logsDir/install.log', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'install.sh'); + await writeFile(scriptPath, '#!/bin/sh\necho hello\nexit 0\n'); + await chmod(scriptPath, 0o755); + + const logsDir = join(tmp, 'logs'); + await runPostInstall({ + installDir, + scriptPath: 'install.sh', + skillName: 'log-test', + semver: '0.1.0', + logsDir, + }); + + const log = await readFile(join(logsDir, 'install.log'), 'utf-8'); + expect(log).toContain('log-test@0.1.0'); + expect(log).toContain('hello'); + expect(log).toContain('exit 0'); + }); + + it('appends across runs without losing prior history', async () => { + const installDir = join(tmp, 'skill'); + await mkdir(installDir, { recursive: true }); + const scriptPath = join(installDir, 'install.sh'); + await writeFile(scriptPath, '#!/bin/sh\necho run\nexit 0\n'); + await chmod(scriptPath, 0o755); + + const logsDir = join(tmp, 'logs'); + const input = { + installDir, + scriptPath: 'install.sh', + skillName: 't', + semver: '0.1.0', + logsDir, + }; + await runPostInstall(input); + await runPostInstall(input); + + const log = await readFile(join(logsDir, 'install.log'), 'utf-8'); + // Two run headers separated by `===`. + const headers = (log.match(/=== /g) ?? []).length; + expect(headers).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/src/cli/tests/utils/project-marker.test.ts b/src/cli/tests/utils/project-marker.test.ts new file mode 100644 index 0000000..3df3181 --- /dev/null +++ b/src/cli/tests/utils/project-marker.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { findProjectMarker, writeProjectMarker, MARKER_FILENAME } from '../../src/utils/project-marker.js'; + +describe('project-marker', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-marker-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('finds marker in cwd', async () => { + await writeFile(join(tmp, MARKER_FILENAME), 'demo\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + expect(result?.markerPath).toBe(join(tmp, MARKER_FILENAME)); + }); + + it('walks up to find marker', async () => { + const sub = join(tmp, 'a', 'b', 'c'); + await mkdir(sub, { recursive: true }); + await writeFile(join(tmp, MARKER_FILENAME), 'parent-project'); + const result = await findProjectMarker(sub, '/never-exists'); + expect(result?.project).toBe('parent-project'); + }); + + it('returns null when no marker exists', async () => { + const sub = join(tmp, 'a', 'b'); + await mkdir(sub, { recursive: true }); + const result = await findProjectMarker(sub, '/never-exists'); + expect(result).toBeNull(); + }); + + it('stops at user home directory', async () => { + // Use tmp itself as the "home" — the walk should not go above it. + const sub = join(tmp, 'projects', 'demo'); + await mkdir(sub, { recursive: true }); + // Marker would be at /tmp's parent (above home) — should not be found. + const result = await findProjectMarker(sub, tmp); + expect(result).toBeNull(); + }); + + it('trims trailing whitespace from the project name', async () => { + await writeFile(join(tmp, MARKER_FILENAME), ' demo \nignored\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + }); + + it('rejects empty marker file', async () => { + await writeFile(join(tmp, MARKER_FILENAME), '\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result).toBeNull(); + }); + + it('writeProjectMarker writes the file with a trailing newline', async () => { + const path = await writeProjectMarker(tmp, 'demo'); + expect(path).toBe(join(tmp, MARKER_FILENAME)); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + }); +}); diff --git a/src/cli/tests/utils/sessionhook.test.ts b/src/cli/tests/utils/sessionhook.test.ts new file mode 100644 index 0000000..052e8b3 --- /dev/null +++ b/src/cli/tests/utils/sessionhook.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { installManagedSessionHook, removeManagedSessionHook, MARKER_KEY } from '../../src/utils/sessionhook.js'; + +describe('sessionhook', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-sessionhook-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('creates settings.json from scratch when missing', async () => { + const path = join(tmp, 'settings.json'); + const result = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(result.updated).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + const entry = settings.hooks.SessionStart[0].hooks[0]; + expect(entry.command).toBe('mcpctl skills sync --quiet'); + expect(entry[MARKER_KEY]).toBe(true); + }); + + it('is idempotent — re-running does not add duplicates', async () => { + const path = join(tmp, 'settings.json'); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const second = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(second.updated).toBe(false); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const entries = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + const managed = entries.filter((e: Record) => e[MARKER_KEY] === true); + expect(managed).toHaveLength(1); + }); + + it('updates the command in place when it changes', async () => { + const path = join(tmp, 'settings.json'); + await installManagedSessionHook('mcpctl skills sync', path); + const updated = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(updated.updated).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const managed = settings.hooks.SessionStart + .flatMap((g: { hooks: unknown[] }) => g.hooks) + .find((e: Record) => e[MARKER_KEY] === true); + expect(managed.command).toBe('mcpctl skills sync --quiet'); + }); + + it('preserves non-managed hooks', async () => { + const path = join(tmp, 'settings.json'); + await mkdir(tmp, { recursive: true }); + await writeFile(path, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + expect(all).toHaveLength(2); + expect(all.find((e: Record) => e.command === 'echo user-hook')).toBeDefined(); + expect(all.find((e: Record) => e[MARKER_KEY] === true)).toBeDefined(); + }); + + it('remove drops the managed entry but keeps user hooks', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const removed = await removeManagedSessionHook(path); + expect(removed.removed).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + expect(all).toHaveLength(1); + expect(all[0].command).toBe('echo user'); + }); + + it('remove is a no-op when no managed entry exists', async () => { + const path = join(tmp, 'settings.json'); + const result = await removeManagedSessionHook(path); + expect(result.removed).toBe(false); + }); + + it('survives empty settings.json', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, ''); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + }); + + it('strips line comments before parsing', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, '// a leading comment\n{\n "hooks": {}\n}\n'); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + }); +}); diff --git a/src/cli/tests/utils/skills-disk.test.ts b/src/cli/tests/utils/skills-disk.test.ts new file mode 100644 index 0000000..ed5ffeb --- /dev/null +++ b/src/cli/tests/utils/skills-disk.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, readdir, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { installSkillAtomic, removeSkillAtomic, listInstalledSkillNames } from '../../src/utils/skills-disk.js'; + +describe('skills-disk', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-disk-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('writes SKILL.md and aux files atomically', async () => { + const target = join(tmp, 'foo'); + const states = await installSkillAtomic(target, { + content: '# Foo skill', + files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' }, + }); + expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('# Foo skill'); + expect(await readFile(join(target, 'scripts/setup.sh'), 'utf-8')).toBe('#!/bin/sh\necho hi'); + expect(states['SKILL.md']).toBeDefined(); + expect(states['SKILL.md'].sha256).toMatch(/^sha256:/); + expect(states['SKILL.md'].size).toBe('# Foo skill'.length); + expect(states['scripts/setup.sh']).toBeDefined(); + }); + + it('replaces an existing tree without leaving partial state', async () => { + const target = join(tmp, 'foo'); + await installSkillAtomic(target, { content: 'v1' }); + await installSkillAtomic(target, { + content: 'v2', + files: { 'extra.md': 'extra' }, + }); + expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('v2'); + expect(await readFile(join(target, 'extra.md'), 'utf-8')).toBe('extra'); + // No staging or trash dirs left behind. + const entries = await readdir(tmp); + expect(entries.filter((e) => e.includes('mcpctl-staging') || e.includes('mcpctl-trash'))).toHaveLength(0); + }); + + it('rejects path-escape attempts', async () => { + const target = join(tmp, 'foo'); + await expect(installSkillAtomic(target, { + content: 'x', + files: { '../escaped': 'bad' }, + })).rejects.toThrow(/escapes install dir/); + }); + + it('rejects absolute paths in files{}', async () => { + const target = join(tmp, 'foo'); + await expect(installSkillAtomic(target, { + content: 'x', + files: { '/etc/passwd-like': 'bad' }, + })).rejects.toThrow(/escapes install dir/); + }); + + it('removes a skill atomically', async () => { + const target = join(tmp, 'foo'); + await installSkillAtomic(target, { content: 'x' }); + await removeSkillAtomic(target); + expect((await readdir(tmp)).filter((n) => n === 'foo')).toHaveLength(0); + }); + + it('remove is a no-op when target does not exist', async () => { + await expect(removeSkillAtomic(join(tmp, 'never-existed'))).resolves.toBeUndefined(); + }); + + it('listInstalledSkillNames ignores staging/trash artifacts', async () => { + const skillsDir = join(tmp, 'skills-root'); + await mkdir(skillsDir, { recursive: true }); + await mkdir(join(skillsDir, 'real-skill'), { recursive: true }); + await mkdir(join(skillsDir, 'real-skill.mcpctl-staging-1234'), { recursive: true }); + await mkdir(join(skillsDir, 'something.mcpctl-trash-9999'), { recursive: true }); + await writeFile(join(skillsDir, 'real-skill', 'SKILL.md'), 'x'); + + const names = await listInstalledSkillNames(skillsDir); + expect(names).toEqual(['real-skill']); + }); +}); diff --git a/src/cli/tests/utils/skills-state.test.ts b/src/cli/tests/utils/skills-state.test.ts new file mode 100644 index 0000000..77662c5 --- /dev/null +++ b/src/cli/tests/utils/skills-state.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + loadState, + saveState, + emptyState, + sha256Of, + hasFileBeenModified, + detectModifiedFiles, + type FileState, +} from '../../src/utils/skills-state.js'; + +describe('skills-state', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-state-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + describe('sha256Of', () => { + it('is deterministic and prefixed', () => { + expect(sha256Of('hello')).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(sha256Of('hello')).toBe(sha256Of('hello')); + expect(sha256Of('hello')).not.toBe(sha256Of('hellp')); + }); + }); + + describe('load / save', () => { + it('returns empty state when file does not exist', async () => { + const state = await loadState(join(tmp, 'no-such.json')); + expect(state.skills).toEqual({}); + expect(state.lastSync).toBeNull(); + }); + + it('round-trips state', async () => { + const path = join(tmp, 'state.json'); + const state = emptyState(); + state.lastSync = '2026-05-07T00:00:00.000Z'; + state.lastSyncProject = 'demo'; + state.skills['my-skill'] = { + id: 'cuid-x', + semver: '0.1.0', + contentHash: sha256Of('body'), + scope: 'global', + installDir: '/tmp/foo', + files: { 'SKILL.md': { sha256: sha256Of('hi'), size: 2 } }, + postInstallHash: null, + lastSyncedAt: '2026-05-07T00:00:00.000Z', + }; + await saveState(state, path); + const loaded = await loadState(path); + expect(loaded).toEqual(state); + }); + + it('starts fresh on schema-version drift', async () => { + const path = join(tmp, 'state.json'); + await writeFile(path, JSON.stringify({ schemaVersion: 99, skills: { x: {} } })); + const state = await loadState(path); + expect(state.schemaVersion).toBe(1); + expect(state.skills).toEqual({}); + }); + }); + + describe('hasFileBeenModified', () => { + it('false when content matches recorded hash + size', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'hello'); + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(false); + }); + + it('true when content differs', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'edited'); + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(true); + }); + + it('true when file is missing', async () => { + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(tmp, 'missing.md', recorded)).toBe(true); + }); + }); + + describe('detectModifiedFiles', () => { + it('returns the list of edited paths', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'pristine'); + await writeFile(join(dir, 'extra.md'), 'edited'); + const result = await detectModifiedFiles(dir, { + 'SKILL.md': { sha256: sha256Of('pristine'), size: 8 }, + 'extra.md': { sha256: sha256Of('original'), size: 8 }, + }); + expect(result).toEqual(['extra.md']); + }); + }); +}); diff --git a/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql new file mode 100644 index 0000000..f577bdb --- /dev/null +++ b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql @@ -0,0 +1,25 @@ +-- v7: per-user RBAC scoping for virtual Llms and Agents. +-- +-- `Llm.ownerId` is new — we don't have a record of who created legacy +-- rows, so existing data is left NULL (treated as "no owner, public"). +-- The list/get filter in the service layer handles NULL ownerId +-- correctly: a NULL-owner public row stays visible to everyone. +-- +-- `Llm.visibility` and `Agent.visibility` default to 'public' so the +-- backfill is automatic — pre-v7 setups continue to behave identically. +-- New rows created post-deploy carry the value the service writes +-- (mcplocal virtuals → 'private'; CLI `mcpctl create llm` → 'public' +-- by default unless `--visibility private` is passed). + +ALTER TABLE "Llm" + ADD COLUMN "ownerId" TEXT, + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +ALTER TABLE "Agent" + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +-- Composite index supports the list-filter hot path: +-- `WHERE visibility='public' OR ownerId=$1` +-- on tables that may grow as more publishers / users come online. +CREATE INDEX "Llm_visibility_ownerId_idx" ON "Llm"("visibility", "ownerId"); +CREATE INDEX "Agent_visibility_ownerId_idx" ON "Agent"("visibility", "ownerId"); diff --git a/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql new file mode 100644 index 0000000..93b2c05 --- /dev/null +++ b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql @@ -0,0 +1,157 @@ +-- Phase 1 of the Skills+Revisions+Proposals work. Purely additive — no +-- existing rows are touched, no tables renamed, no columns dropped. PR-2 +-- will follow up with the PromptRequest → ResourceProposal cutover (rename +-- + backfill + service rewire) once the new tables have settled. +-- +-- New objects: +-- 1. ResourceRevision — append-only audit + diff log keyed by +-- (resourceType, resourceId). Both Prompt and Skill produce revisions. +-- Hot reads stay on the resource row's inline content; revisions are +-- only consulted by history/diff/restore endpoints. +-- 2. ResourceProposal — generic propose/approve/reject queue, drop-in +-- replacement for the prompt-only PromptRequest. Created empty here; +-- backfill from PromptRequest happens in PR-2. +-- 3. Skill — new resource type that mirrors Prompt for everything CRUD- +-- shaped. Adds `files` Json (multi-file bundles, materialised onto +-- disk by `mcpctl skills sync`) and `metadata` Json (typed at the +-- app layer in PR-3: hooks, mcpServers, postInstall, …). +-- 4. semver + currentRevisionId columns on Prompt. + +-- ── 1. ResourceRevision ── +CREATE TABLE "ResourceRevision" ( + "id" TEXT NOT NULL, + -- Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a + -- third resource type later a non-migration change. + "resourceType" TEXT NOT NULL, + -- Soft FK — no relation declared. Survives resource deletion so the + -- audit trail isn't lost when a prompt or skill is removed. + "resourceId" TEXT NOT NULL, + "semver" TEXT NOT NULL DEFAULT '0.1.0', + -- sha256 of the canonicalised body — stable diff key. Two revisions + -- with the same hash are byte-identical (skills sync uses this to + -- skip work even when semver hasn't bumped). + "contentHash" TEXT NOT NULL, + -- Snapshot of the resource at this revision: { content, metadata?, ... } + "body" JSONB NOT NULL, + "authorUserId" TEXT, + "authorSessionId" TEXT, + "note" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ResourceRevision_pkey" PRIMARY KEY ("id") +); + +-- History viewer: latest-first within a resource. +CREATE INDEX "ResourceRevision_resourceType_resourceId_createdAt_idx" + ON "ResourceRevision"("resourceType", "resourceId", "createdAt" DESC); +-- Direct lookup by semver: GET /api/v1/revisions?resourceType=&resourceId=&semver= +CREATE INDEX "ResourceRevision_resourceType_resourceId_semver_idx" + ON "ResourceRevision"("resourceType", "resourceId", "semver"); +-- Cross-resource sync diff: "do I already have a revision with this hash?" +CREATE INDEX "ResourceRevision_contentHash_idx" + ON "ResourceRevision"("contentHash"); +-- Author drilldown for audit views. +CREATE INDEX "ResourceRevision_authorUserId_idx" + ON "ResourceRevision"("authorUserId"); + +-- ── 2. ResourceProposal ── +CREATE TABLE "ResourceProposal" ( + "id" TEXT NOT NULL, + "resourceType" TEXT NOT NULL, + "name" TEXT NOT NULL, + -- Proposed body — { content, metadata? } shaped per resourceType. + "body" JSONB NOT NULL, + "projectId" TEXT, + "agentId" TEXT, + "createdBySession" TEXT, + "createdByUserId" TEXT, + -- Status lifecycle: pending → (approved|rejected). Reviewer note set + -- on either terminal state. + "status" TEXT NOT NULL DEFAULT 'pending', + "reviewerNote" TEXT NOT NULL DEFAULT '', + -- Set when status='approved': the ResourceRevision the approval created. + "approvedRevisionId" TEXT, + -- Optimistic-concurrency counter for concurrent reviewer actions. + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ResourceProposal_pkey" PRIMARY KEY ("id") +); + +-- Cascade matches Prompt's behaviour: deleting a project drops its proposals. +ALTER TABLE "ResourceProposal" + ADD CONSTRAINT "ResourceProposal_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ResourceProposal" + ADD CONSTRAINT "ResourceProposal_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Mirrors Prompt's two-unique pattern: a (type, name) pair can have one +-- proposal per project XOR one per agent. NULL semantics inherit Postgres +-- defaults; the app layer reuses the `?? ''` workaround for direct +-- compound-key lookups, same as Prompt. +CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_projectId_key" + ON "ResourceProposal"("resourceType", "name", "projectId"); +CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_agentId_key" + ON "ResourceProposal"("resourceType", "name", "agentId"); +-- Reviewer queue: SELECT … WHERE resourceType=? AND status='pending'. +CREATE INDEX "ResourceProposal_resourceType_status_idx" + ON "ResourceProposal"("resourceType", "status"); +CREATE INDEX "ResourceProposal_projectId_idx" + ON "ResourceProposal"("projectId"); +CREATE INDEX "ResourceProposal_createdBySession_idx" + ON "ResourceProposal"("createdBySession"); + +-- ── 3. Skill ── +-- Mirrors Prompt for scoping (project XOR agent XOR neither = global) and +-- carries the inline SKILL.md body in `content`. Multi-file bundles live +-- in `files` (path → content map). Typed skill metadata (hooks, +-- mcpServers, postInstall, …) lives opaquely in `metadata` here and is +-- validated app-layer (PR-3). +CREATE TABLE "Skill" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "content" TEXT NOT NULL, + "files" JSONB NOT NULL DEFAULT '{}', + "metadata" JSONB NOT NULL DEFAULT '{}', + "projectId" TEXT, + "agentId" TEXT, + "priority" INTEGER NOT NULL DEFAULT 5, + "summary" TEXT, + "chapters" JSONB, + "semver" TEXT NOT NULL DEFAULT '0.1.0', + -- Soft pointer to the latest ResourceRevision row for this skill. + -- NULL before the first revision is recorded. + "currentRevisionId" TEXT, + -- Optimistic-concurrency counter. NOT semver — that's `semver` above. + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Skill_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Skill" + ADD CONSTRAINT "Skill_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Skill" + ADD CONSTRAINT "Skill_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "Skill_name_projectId_key" ON "Skill"("name", "projectId"); +CREATE UNIQUE INDEX "Skill_name_agentId_key" ON "Skill"("name", "agentId"); +CREATE INDEX "Skill_projectId_idx" ON "Skill"("projectId"); +CREATE INDEX "Skill_agentId_idx" ON "Skill"("agentId"); +CREATE INDEX "Skill_name_idx" ON "Skill"("name"); + +-- ── 4. semver + currentRevisionId on Prompt ── +-- ADD COLUMN with a default is instant on Postgres ≥11 (no table rewrite). +ALTER TABLE "Prompt" + ADD COLUMN "semver" TEXT NOT NULL DEFAULT '0.1.0', + ADD COLUMN "currentRevisionId" TEXT; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4289ae0..9d7f1f3 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -225,6 +225,30 @@ model Llm { lastHeartbeatAt DateTime? // bumped on every publisher heartbeat status LlmStatus @default(active) inactiveSince DateTime? // when status flipped from active; used for 4-h GC + // ── Per-user RBAC scoping (v7) ── + // `ownerId` records who created/published the row. NULL on legacy rows + // (those created before the v7 migration) — those continue to behave + // as `visibility=public` for back-compat. New rows always carry an + // ownerId set by the service layer (`User.id` of the authenticated + // caller, or the publishing mcplocal user for virtuals). + // + // `visibility` controls who can see / use the row: + // - 'public' : anyone with the resource grant (`view:llms`, + // `run:llms:`, etc.) sees it. Legacy default + // mirrors today's behavior — explicit + // `mcpctl create llm` calls keep this default. + // - 'private' : only the owner sees it by default; other users need + // an explicit name-scoped RBAC binding + // (`view:llms:`, `run:llms:`). The list + // endpoint hides foreign-private rows from + // unauthorized callers; get/describe returns 404 to + // prevent name enumeration. + // + // mcplocal-published virtual Llms default to 'private' on register — + // a workstation-published model isn't typically meant for the whole + // org until the publisher explicitly shares it. See docs/virtual-llms.md. + ownerId String? + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -238,6 +262,10 @@ model Llm { @@index([kind, status]) @@index([providerSessionId]) @@index([poolName]) + // List filter on the hot path: "rows visible to caller X" decomposes + // into `visibility='public' OR ownerId=X` + an RBAC join. Composite + // index keeps the predicate fast even on a large table. + @@index([visibility, ownerId]) } // ── Groups ── @@ -300,10 +328,12 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] prompts Prompt[] promptRequests PromptRequest[] + proposals ResourceProposal[] + skills Skill[] mcpTokens McpToken[] agents Agent[] @@ -386,18 +416,27 @@ enum InstanceStatus { // ── Prompts (approved content resources) ── model Prompt { - id String @id @default(cuid()) - name String - content String @db.Text - projectId String? - agentId String? - priority Int @default(5) - summary String? @db.Text - chapters Json? - linkTarget String? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + agentId String? + priority Int @default(5) + summary String? @db.Text + chapters Json? + linkTarget String? + // Semantic version of the current content. Auto-bumped patch on every save + // by PromptService.update; author can pass --bump major|minor|patch or + // --semver X.Y.Z to override. NOT the same as `version` below — that one + // is the optimistic-concurrency counter and stays Int. + semver String @default("0.1.0") + // Soft pointer to the latest ResourceRevision row for this prompt. NULL + // before the first revision is recorded. Set in the same transaction as + // create/update by PromptService. + currentRevisionId String? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) @@ -409,6 +448,56 @@ model Prompt { @@index([agentId]) } +// ── Skills (Claude Code skill bundles, synced to ~/.claude/skills//) ── +// +// Skills are the on-disk counterpart to Prompts. mcpd is the source of truth; +// mcpctl skills sync materialises them onto disk under +// ~/.claude/skills//SKILL.md (+ optional aux files) where Claude Code +// reads them natively. Same scoping rules as Prompt (project XOR agent XOR +// neither = global). Multi-file bundles live in `files` (path → content). +// Typed skill metadata (hooks, mcpServers, postInstall, …) is validated in +// the app layer and stored opaquely here in `metadata`. + +model Skill { + id String @id @default(cuid()) + name String + description String @default("") + // Body of the SKILL.md file delivered to Claude Code. + content String @db.Text + // Auxiliary files in the skill bundle. Map of relative path → file content + // (UTF-8 text only in v1; binaries deferred). Materialised onto disk at sync + // time alongside SKILL.md. + files Json @default("{}") + // Typed-but-stored-as-Json: { hooks?, mcpServers?, postInstall?, + // preUninstall?, postInstallTimeoutSec? }. Validated at the route layer + // (see src/mcpd/src/validation/skill.schema.ts in PR-3). + metadata Json @default("{}") + projectId String? + agentId String? + priority Int @default(5) + summary String? @db.Text + chapters Json? + // Semantic version of the current content. Auto-bumped patch on every save + // by SkillService.update; author can override via --bump or --semver. + semver String @default("0.1.0") + // Soft pointer to the latest ResourceRevision row for this skill. NULL + // before the first revision is recorded. + currentRevisionId String? + // Optimistic-concurrency counter. NOT semver — that's `semver` above. + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@unique([name, projectId]) + @@unique([name, agentId]) + @@index([projectId]) + @@index([agentId]) + @@index([name]) +} + // ── Prompt Requests (pending proposals from LLM sessions) ── model PromptRequest { @@ -428,6 +517,91 @@ model PromptRequest { @@index([createdBySession]) } +// ── ResourceRevision (append-only audit + diff log) ── +// +// Both Prompt and Skill rows produce revisions on every change. Hot reads +// (gate plugin, mcpctl skills sync, prompt index for LLM selection) stay on +// the resource row's inline content; revisions are only consulted by +// history/diff/restore endpoints. Approving a ResourceProposal atomically +// inserts the resource + a revision in the same transaction. +// +// `resourceId` is a soft FK with NO referential constraint — revisions +// outlive the resources they describe so audit history isn't lost when +// a resource is deleted. Validated app-layer. + +model ResourceRevision { + id String @id @default(cuid()) + // Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a + // third resource type later a non-migration change. + resourceType String + resourceId String + semver String @default("0.1.0") + // sha256 of the canonicalised body — stable diff key. Two revisions with + // the same hash are byte-identical (skills sync uses this to skip work + // even when semver hasn't bumped). + contentHash String + // Snapshot of the resource at this revision: { content, metadata?, ... } + body Json + authorUserId String? + authorSessionId String? + note String @default("") + createdAt DateTime @default(now()) + + // History viewer: latest-first within a resource. + @@index([resourceType, resourceId, createdAt(sort: Desc)]) + // Direct lookup by semver. + @@index([resourceType, resourceId, semver]) + // Sync diff cross-resource lookup ("do I already have a revision with + // this contentHash anywhere?") — useful for detecting renames + dedup. + @@index([contentHash]) + @@index([authorUserId]) +} + +// ── ResourceProposal (generic propose/approve/reject queue) ── +// +// A pending change to a Prompt or Skill, submitted by an LLM session via +// the propose_prompt / propose_skill MCP tools (see mcplocal gate plugin) +// or by a human via the web UI / CLI. Reviewers drain the queue via +// `mcpctl review next`. Approving creates the underlying resource (if new) +// and writes a ResourceRevision; the proposal status flips to 'approved' +// and `approvedRevisionId` points at the resulting revision row. +// +// Replaces the prompt-only PromptRequest table; the cutover happens in PR-2 +// (rename + backfill + service rewire). + +model ResourceProposal { + id String @id @default(cuid()) + resourceType String // 'prompt' | 'skill' + name String + // Proposed body — { content, metadata? } shaped per resourceType. + body Json + projectId String? + agentId String? + createdBySession String? + createdByUserId String? + status String @default("pending") // pending | approved | rejected + reviewerNote String @default("") + // Set when status='approved': the ResourceRevision the approval created. + approvedRevisionId String? + // Optimistic-concurrency counter for concurrent reviewer actions. + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + + // Mirrors Prompt's two-unique pattern: a (type, name) pair can have one + // proposal per project XOR one per agent. NULL semantics inherit Postgres + // defaults (NULL distinct from NULL); the app layer reuses the `?? ''` + // workaround for direct compound-key lookups, same as Prompt. + @@unique([resourceType, name, projectId]) + @@unique([resourceType, name, agentId]) + @@index([resourceType, status]) + @@index([projectId]) + @@index([createdBySession]) +} + // ── Audit Events (pipeline/gate/tool trace from mcplocal) ── model AuditEvent { @@ -495,17 +669,27 @@ model Agent { status LlmStatus @default(active) inactiveSince DateTime? ownerId String + // v7: per-user RBAC scoping. Mirrors `Llm.visibility` semantics — + // 'public' (default, today's behavior) lets anyone with the resource + // grant see the agent; 'private' restricts to owner + explicit + // name-scoped RBAC bindings. mcplocal-published virtual agents + // default to 'private' on register so a workstation-published persona + // isn't broadcast to the whole org until shared explicitly. Existing + // rows backfill to 'public' so pre-v7 setups keep working unchanged. + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) threads ChatThread[] prompts Prompt[] - personalities Personality[] @relation("AgentPersonalities") - defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) + proposals ResourceProposal[] + skills Skill[] + personalities Personality[] @relation("AgentPersonalities") + defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@ -514,6 +698,7 @@ model Agent { @@index([defaultPersonalityId]) @@index([kind, status]) @@index([providerSessionId]) + @@index([visibility, ownerId]) } // ── Personalities (named overlay bundles of prompts on top of an Agent) ── @@ -619,54 +804,54 @@ model ChatMessage { // SSE channel — that's how queued tasks survive worker offline windows. enum InferenceTaskStatus { - pending // in queue, no worker has it yet (or claim was reverted) - claimed // a worker has it (SSE frame sent), no chunks back yet - running // worker started streaming chunks back (streaming tasks only) - completed // worker POSTed the final result - error // permanent failure (auth, bad request, queue timeout) - cancelled // caller said never mind via DELETE + pending // in queue, no worker has it yet (or claim was reverted) + claimed // a worker has it (SSE frame sent), no chunks back yet + running // worker started streaming chunks back (streaming tasks only) + completed // worker POSTed the final result + error // permanent failure (auth, bad request, queue timeout) + cancelled // caller said never mind via DELETE } model InferenceTask { - id String @id @default(cuid()) - status InferenceTaskStatus @default(pending) + id String @id @default(cuid()) + status InferenceTaskStatus @default(pending) // Routing — pool key drives worker matching at claim time. Stored at // enqueue time so a later rename of Llm.poolName doesn't reroute // already-queued work. - poolName String - llmName String // pinned target Llm name (for audit + agent backref) - model String - tier String? + poolName String + llmName String // pinned target Llm name (for audit + agent backref) + model String + tier String? // Worker tracking. NULL while pending; set on claim; cleared on // unbindSession-driven revert (worker disconnect mid-task). - claimedBy String? + claimedBy String? // Body + result. Both are Json so streaming chunks can be reconstructed // (see TaskService.complete) and async pollers get a structured payload. // requestBody is required (the OpenAI chat-completion request body the // worker should run); responseBody is null until status=completed. - requestBody Json - responseBody Json? - errorMessage String? + requestBody Json + responseBody Json? + errorMessage String? /** * Whether the original request asked for streaming. Drives the chunk-vs- * final-body protocol on the result POST and tells async API callers * whether `/stream` will yield chunks or just a single completion event. */ - streaming Boolean @default(false) + streaming Boolean @default(false) // Timestamps for observability + GC: // pending → claimed: claimedAt set // claimed → running: streamStartedAt set (first chunk received) // running/claimed → completed/error/cancelled: completedAt set - createdAt DateTime @default(now()) - claimedAt DateTime? - streamStartedAt DateTime? - completedAt DateTime? + createdAt DateTime @default(now()) + claimedAt DateTime? + streamStartedAt DateTime? + completedAt DateTime? // Caller tracking — RBAC + observability. ownerId references User.id; // agentId is set when the task came in via /agents//chat (null // for direct /llms//infer or async POST /inference-tasks calls // that don't pin an agent). - ownerId String - agentId String? + ownerId String + agentId String? @@index([status, poolName]) @@index([claimedBy]) diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 7083b75..8926ba1 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -36,6 +36,12 @@ export async function clearAllTables(client: PrismaClient): Promise { // Break Agent.defaultPersonalityId before personalities can be removed. await client.agent.updateMany({ data: { defaultPersonalityId: null } }); await client.personality.deleteMany(); + // Skills + Proposals cascade from Project/Agent, but globals (NULL FK) + // need explicit cleanup so they don't leak between tests. + await client.skill.deleteMany(); + await client.resourceProposal.deleteMany(); + // Revisions have no FK (soft FK); always orphans without explicit clear. + await client.resourceRevision.deleteMany(); await client.agent.deleteMany(); await client.llm.deleteMany(); await client.mcpInstance.deleteMany(); diff --git a/src/db/tests/resource-proposal-schema.test.ts b/src/db/tests/resource-proposal-schema.test.ts new file mode 100644 index 0000000..96ad372 --- /dev/null +++ b/src/db/tests/resource-proposal-schema.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('ResourceProposal schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + async function createUser() { + return prisma.user.create({ + data: { + email: `test-${Date.now()}-${Math.random()}@example.com`, + name: 'Test', + passwordHash: '!locked', + role: 'USER', + }, + }); + } + + async function createProject(name = `project-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + return prisma.project.create({ data: { name, ownerId: user.id } }); + } + + it('creates a pending prompt proposal with defaults', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'my-proposal', + body: { content: 'hello', priority: 5 }, + projectId: project.id, + }, + }); + expect(proposal.id).toBeDefined(); + expect(proposal.status).toBe('pending'); + expect(proposal.reviewerNote).toBe(''); + expect(proposal.approvedRevisionId).toBeNull(); + expect(proposal.version).toBe(1); + expect(proposal.body).toEqual({ content: 'hello', priority: 5 }); + }); + + it('creates a pending skill proposal', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'skill', + name: 'my-skill-proposal', + body: { content: 'SKILL.md body', metadata: { postInstall: 'hooks/x.sh' } }, + projectId: project.id, + }, + }); + expect(proposal.resourceType).toBe('skill'); + }); + + it('enforces unique (resourceType, name, projectId)', async () => { + const project = await createProject(); + await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'dup', + body: { content: 'a' }, + projectId: project.id, + }, + }); + await expect( + prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'dup', + body: { content: 'b' }, + projectId: project.id, + }, + }), + ).rejects.toThrow(); + }); + + it('allows same name across different resource types in same project', async () => { + const project = await createProject(); + await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'shared', + body: { content: 'a' }, + projectId: project.id, + }, + }); + const second = await prisma.resourceProposal.create({ + data: { + resourceType: 'skill', + name: 'shared', + body: { content: 'b' }, + projectId: project.id, + }, + }); + expect(second.id).toBeDefined(); + }); + + it('allows status lifecycle: pending → approved', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'flow', + body: { content: 'hi' }, + projectId: project.id, + }, + }); + const approved = await prisma.resourceProposal.update({ + where: { id: proposal.id }, + data: { + status: 'approved', + reviewerNote: 'looks good', + approvedRevisionId: 'rev-fake-123', + }, + }); + expect(approved.status).toBe('approved'); + expect(approved.reviewerNote).toBe('looks good'); + expect(approved.approvedRevisionId).toBe('rev-fake-123'); + }); + + it('cascades on project delete', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'will-cascade', + body: { content: 'hi' }, + projectId: project.id, + }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.resourceProposal.findUnique({ where: { id: proposal.id } }); + expect(found).toBeNull(); + }); +}); diff --git a/src/db/tests/resource-revision-schema.test.ts b/src/db/tests/resource-revision-schema.test.ts new file mode 100644 index 0000000..98e2e30 --- /dev/null +++ b/src/db/tests/resource-revision-schema.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('ResourceRevision schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + it('creates a revision with required fields and defaults', async () => { + const rev = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', + resourceId: 'fake-prompt-id', + contentHash: 'sha256:abc', + body: { content: 'hello' }, + }, + }); + expect(rev.id).toBeDefined(); + expect(rev.semver).toBe('0.1.0'); + expect(rev.note).toBe(''); + expect(rev.authorUserId).toBeNull(); + expect(rev.authorSessionId).toBeNull(); + expect(rev.body).toEqual({ content: 'hello' }); + expect(rev.createdAt).toBeInstanceOf(Date); + }); + + it('survives resource deletion (soft FK)', async () => { + // No actual prompt exists with this id — the soft FK design lets + // revisions outlive their resources. + const rev = await prisma.resourceRevision.create({ + data: { + resourceType: 'skill', + resourceId: 'never-existed', + contentHash: 'sha256:def', + body: { content: 'ghost' }, + }, + }); + expect(rev.resourceId).toBe('never-existed'); + }); + + it('orders revisions latest-first within a resource', async () => { + const resourceId = 'r1'; + const a = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.0', contentHash: 'h1', body: {}, + }, + }); + await new Promise((r) => setTimeout(r, 10)); + const b = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.1', contentHash: 'h2', body: {}, + }, + }); + await new Promise((r) => setTimeout(r, 10)); + const c = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.2.0', contentHash: 'h3', body: {}, + }, + }); + + const rows = await prisma.resourceRevision.findMany({ + where: { resourceType: 'prompt', resourceId }, + orderBy: { createdAt: 'desc' }, + }); + expect(rows.map((r) => r.id)).toEqual([c.id, b.id, a.id]); + }); + + it('allows multiple revisions with the same contentHash (rollback)', async () => { + const resourceId = 'r2'; + await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.0', contentHash: 'identical', body: {}, + }, + }); + const second = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.2.0', contentHash: 'identical', body: {}, + }, + }); + expect(second.id).toBeDefined(); + const rows = await prisma.resourceRevision.findMany({ + where: { contentHash: 'identical' }, + }); + expect(rows.length).toBe(2); + }); + + it('discriminates between prompt and skill revisions', async () => { + await prisma.resourceRevision.create({ + data: { resourceType: 'prompt', resourceId: 'x', contentHash: 'a', body: {} }, + }); + await prisma.resourceRevision.create({ + data: { resourceType: 'skill', resourceId: 'x', contentHash: 'a', body: {} }, + }); + const prompts = await prisma.resourceRevision.findMany({ + where: { resourceType: 'prompt', resourceId: 'x' }, + }); + const skills = await prisma.resourceRevision.findMany({ + where: { resourceType: 'skill', resourceId: 'x' }, + }); + expect(prompts.length).toBe(1); + expect(skills.length).toBe(1); + }); +}); diff --git a/src/db/tests/skill-schema.test.ts b/src/db/tests/skill-schema.test.ts new file mode 100644 index 0000000..86d741e --- /dev/null +++ b/src/db/tests/skill-schema.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('Skill schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + async function createUser() { + return prisma.user.create({ + data: { + email: `test-${Date.now()}-${Math.random()}@example.com`, + name: 'Test', + passwordHash: '!locked', + role: 'USER', + }, + }); + } + + async function createProject(name = `project-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + return prisma.project.create({ data: { name, ownerId: user.id } }); + } + + async function createAgent(name = `agent-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + const llm = await prisma.llm.create({ + data: { name: `llm-${Date.now()}-${Math.random()}`, type: 'openai', model: 'test' }, + }); + return prisma.agent.create({ + data: { name, llmId: llm.id, ownerId: user.id }, + }); + } + + it('creates a global skill (both FKs null) with defaults', async () => { + const skill = await prisma.skill.create({ + data: { name: 'global-test', content: 'hello' }, + }); + expect(skill.id).toBeDefined(); + expect(skill.projectId).toBeNull(); + expect(skill.agentId).toBeNull(); + expect(skill.priority).toBe(5); + expect(skill.semver).toBe('0.1.0'); + expect(skill.version).toBe(1); + expect(skill.currentRevisionId).toBeNull(); + expect(skill.files).toEqual({}); + expect(skill.metadata).toEqual({}); + expect(skill.description).toBe(''); + expect(skill.summary).toBeNull(); + expect(skill.chapters).toBeNull(); + }); + + it('creates a project-scoped skill', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'proj-test', content: 'hi', projectId: project.id }, + }); + expect(skill.projectId).toBe(project.id); + expect(skill.agentId).toBeNull(); + }); + + it('creates an agent-scoped skill', async () => { + const agent = await createAgent(); + const skill = await prisma.skill.create({ + data: { name: 'agent-test', content: 'hi', agentId: agent.id }, + }); + expect(skill.agentId).toBe(agent.id); + expect(skill.projectId).toBeNull(); + }); + + it('persists files and metadata as JSON', async () => { + const skill = await prisma.skill.create({ + data: { + name: 'with-files', + content: 'hi', + files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' }, + metadata: { + hooks: { PreToolUse: [{ command: 'echo' }] }, + postInstall: 'scripts/setup.sh', + }, + }, + }); + expect(skill.files).toEqual({ 'scripts/setup.sh': '#!/bin/sh\necho hi' }); + expect(skill.metadata).toEqual({ + hooks: { PreToolUse: [{ command: 'echo' }] }, + postInstall: 'scripts/setup.sh', + }); + }); + + it('enforces unique (name, projectId)', async () => { + const project = await createProject(); + await prisma.skill.create({ + data: { name: 'dup', content: 'a', projectId: project.id }, + }); + await expect( + prisma.skill.create({ + data: { name: 'dup', content: 'b', projectId: project.id }, + }), + ).rejects.toThrow(); + }); + + it('enforces unique (name, agentId)', async () => { + const agent = await createAgent(); + await prisma.skill.create({ + data: { name: 'dup', content: 'a', agentId: agent.id }, + }); + await expect( + prisma.skill.create({ + data: { name: 'dup', content: 'b', agentId: agent.id }, + }), + ).rejects.toThrow(); + }); + + it('allows same name across different projects', async () => { + const p1 = await createProject(`p1-${Date.now()}`); + const p2 = await createProject(`p2-${Date.now()}`); + await prisma.skill.create({ + data: { name: 'shared', content: 'a', projectId: p1.id }, + }); + const second = await prisma.skill.create({ + data: { name: 'shared', content: 'b', projectId: p2.id }, + }); + expect(second.id).toBeDefined(); + }); + + it('allows same name across project + agent (different scopes)', async () => { + const project = await createProject(); + const agent = await createAgent(); + await prisma.skill.create({ + data: { name: 'overlap', content: 'a', projectId: project.id }, + }); + const second = await prisma.skill.create({ + data: { name: 'overlap', content: 'b', agentId: agent.id }, + }); + expect(second.id).toBeDefined(); + }); + + it('cascades on project delete', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'cascade-me', content: 'hi', projectId: project.id }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).toBeNull(); + }); + + it('cascades on agent delete', async () => { + const agent = await createAgent(); + const skill = await prisma.skill.create({ + data: { name: 'cascade-agent', content: 'hi', agentId: agent.id }, + }); + await prisma.agent.delete({ where: { id: agent.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).toBeNull(); + }); + + it('preserves global skills when projects are deleted', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'global-survives', content: 'hi' }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).not.toBeNull(); + expect(found?.id).toBe(skill.id); + }); + + it('updates updatedAt on change', async () => { + const skill = await prisma.skill.create({ + data: { name: 'mut', content: 'a' }, + }); + const original = skill.updatedAt; + await new Promise((r) => setTimeout(r, 50)); + const updated = await prisma.skill.update({ + where: { id: skill.id }, + data: { content: 'b' }, + }); + expect(updated.updatedAt.getTime()).toBeGreaterThan(original.getTime()); + }); +}); diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 3f51592..48e570a 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -23,6 +23,7 @@ "@mcpctl/shared": "workspace:*", "@prisma/client": "^6.0.0", "bcrypt": "^5.1.1", + "diff": "^5.2.0", "dockerode": "^4.0.9", "fastify": "^5.0.0", "js-yaml": "^4.1.0", @@ -30,6 +31,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/diff": "^5.2.3", "@types/dockerode": "^4.0.1", "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.0" diff --git a/src/mcpd/src/bootstrap/self-password-permission.ts b/src/mcpd/src/bootstrap/self-password-permission.ts new file mode 100644 index 0000000..a8975a4 --- /dev/null +++ b/src/mcpd/src/bootstrap/self-password-permission.ts @@ -0,0 +1,33 @@ +/** + * Self-service password change is modelled as a real, admin-revocable RBAC + * permission — NOT an exception to the RBAC path. Every new user gets a + * personal RbacDefinition (`self-`) granting the `set-own-password` + * operation, gated at creation time by the `allowSelfPasswordChange` system + * setting. Admins disable it for an individual by deleting that definition, + * or for future users by flipping the setting. + */ + +import type { RbacDefinitionService } from '../services/rbac-definition.service.js'; + +/** Operation that gates `POST /api/v1/users/me/password`. */ +export const SET_OWN_PASSWORD_OPERATION = 'set-own-password'; + +/** Name of a user's personal RbacDefinition (currently just self password). */ +export function selfRbacName(userId: string): string { + return `self-${userId}`; +} + +/** + * Grant a user the default self-service password-change permission. + * Idempotent (upsert by name) — safe to call on every creation/bootstrap. + */ +export async function grantSelfPasswordPermission( + rbac: RbacDefinitionService, + user: { id: string; email: string }, +): Promise { + await rbac.upsertByName({ + name: selfRbacName(user.id), + subjects: [{ kind: 'User', name: user.email }], + roleBindings: [{ role: 'run', action: SET_OWN_PASSWORD_OPERATION }], + }); +} diff --git a/src/mcpd/src/bootstrap/system-project.ts b/src/mcpd/src/bootstrap/system-project.ts index 27b08d9..322cd4d 100644 --- a/src/mcpd/src/bootstrap/system-project.ts +++ b/src/mcpd/src/bootstrap/system-project.ts @@ -37,6 +37,18 @@ The available tools and prompts are listed below. After calling begin_session, y content: `If any of the listed prompts seem relevant to your work, or if you encounter unfamiliar patterns, conventions, or constraints during implementation, use read_prompts({ tags: [...] }) to retrieve them. It is better to check and not need it than to proceed without important context. The project maintainers have documented common pitfalls, architecture decisions, and required patterns — taking 10 seconds to retrieve a prompt can save hours of rework.`, + }, + { + // Pairs with the propose-learnings global skill (PR-4). The skill + // explains the proposal flow in detail; this nudge sits in the + // gating bundle so Claude actually thinks about engaging with it. + // Priority 10 keeps it visible alongside other gate-* prompts; the + // bootstrap test enforces this convention. + name: 'gate-encouragement-propose', + priority: 10, + content: `If during this session you discover a non-obvious project convention, hidden constraint, or lesson learned that future sessions would benefit from, call propose_prompt with the content + a name and brief description. A maintainer reviews it; if approved, future sessions get it as part of their gating bundle. + +Reserve propose_skill for cross-cutting knowledge — a debugging discipline, release hygiene, or review style — not project-specific facts. See the propose-learnings skill for guidance.`, }, { name: 'gate-intercept-preamble', diff --git a/src/mcpd/src/bootstrap/system-settings.ts b/src/mcpd/src/bootstrap/system-settings.ts new file mode 100644 index 0000000..9fb3f3e --- /dev/null +++ b/src/mcpd/src/bootstrap/system-settings.ts @@ -0,0 +1,47 @@ +/** + * Global mcpd settings, stored as JSON in a well-known Secret's `data` field. + * + * We deliberately reuse the Secret object instead of adding schema columns: + * Secrets already have CRUD, RBAC, and backup/restore, so settings stay + * admin-editable through the same tooling with no migration per flag. + * See the `backup-ssh` secret for the original precedent. + */ + +import type { PrismaClient } from '@prisma/client'; + +/** Well-known secret holding global mcpd settings as `data` JSON. */ +export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings'; + +/** Settings key: whether newly-created users get self password-change permission. */ +export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange'; + +/** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */ +function toBool(v: unknown, dflt: boolean): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + if (s === 'false' || s === '0' || s === '') return false; + return true; + } + return dflt; +} + +/** + * Whether new users should receive the `set-own-password` permission by default. + * + * Defaults to TRUE — a fresh install allows self-service password changes. + * An admin disables it by setting `allowSelfPasswordChange: false` in the + * `mcpctl-system-settings` secret. Missing secret / missing key / read error + * all fall back to the permissive default. + */ +export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise { + try { + const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + if (!secret) return true; + const data = (secret.data ?? {}) as Record; + if (!(SETTING_ALLOW_SELF_PASSWORD in data)) return true; + return toBool(data[SETTING_ALLOW_SELF_PASSWORD], true); + } catch { + return true; + } +} diff --git a/src/mcpd/src/bootstrap/system-skills.ts b/src/mcpd/src/bootstrap/system-skills.ts new file mode 100644 index 0000000..009998f --- /dev/null +++ b/src/mcpd/src/bootstrap/system-skills.ts @@ -0,0 +1,133 @@ +/** + * Bootstrap built-in global skills on every mcpd startup. + * + * Mirrors `bootstrap/system-project.ts` for prompts: idempotent upserts so + * deletes + re-creates work, but never overwrites user edits to the skill + * content. The single skill seeded today is `propose-learnings` — it + * encourages Claude to actually use the propose_prompt / propose_skill + * MCP tools when it discovers something worth capturing. + * + * The skill lives at global scope (no projectId, no agentId) and gets + * synced onto every dev machine by `mcpctl skills sync` (PR-5). + */ + +import type { PrismaClient } from '@prisma/client'; + +interface SystemSkillDef { + name: string; + description: string; + priority: number; + content: string; +} + +const PROPOSE_LEARNINGS_CONTENT = `# propose-learnings + +This Claude Code session is connected to mcpctl, which keeps a curated +library of project knowledge — patterns, gotchas, conventions, and +policies — and makes that knowledge available to future sessions. Two +tools matter here: + +- \`propose_prompt\` — propose a small piece of text knowledge for the + current project. A reviewer approves it, then it appears for every + future session on this project as a \`read_prompts\` candidate. +- \`propose_skill\` — propose a Claude Code skill (a SKILL.md). Use this + only when the knowledge is genuinely cross-cutting (debugging + discipline, release hygiene, security review style) and would help + across many projects, not just this one. + +## When to engage + +Most sessions don't need to propose anything. Engage when one of these +is true and the cost of the next session re-discovering it would be +real: + +1. You hit a bug that took non-trivial digging to root-cause, and the + cause was a project-specific quirk a future you would have benefited + from knowing in advance ("this codebase shadows \`request\` with + \`req\` in three files; grep for both"). +2. You learned a convention by reading code that wasn't documented + anywhere ("services live under \`src/mcpd/src/services\` and are + wired in \`main.ts\` around line 466"). +3. The user told you something corrective ("we don't use Prisma + transactions for migrations here, we use raw SQL files") that would + otherwise be lost. + +## When NOT to engage + +- Anything you read in an existing prompt — it's already captured. +- Generic programming advice. Future sessions have the same training + as you. +- Speculation. Only propose what you actually verified during this + session. +- Anything secret, anything PII, anything that names a customer. + +## How to write a good proposal + +Name it \`lowercase-with-hyphens\`. Keep it under 200 words. Lead with +the shape of the situation, not the resolution — future-you needs to +recognise when this applies. Example: + +> name: prisma-null-fk-workaround +> content: When a Prisma model has an optional FK that's part of a +> compound \`@@unique\`, Postgres treats NULL as distinct, so duplicates +> sneak in. Workaround in this repo: store empty string instead of NULL +> for the FK and use \`?? ''\` at every read site. See +> \`src/mcpd/src/repositories/prompt.repository.ts:75\` for the pattern. + +## How proposals get applied + +Proposals enter a queue. A maintainer runs \`mcpctl review next\`, sees +a diff, and either approves (the prompt or skill goes live for the next +session) or rejects with a note. You will not see the outcome in this +session. That's fine — the system is designed so individual sessions +don't need to follow up. + +If you're unsure whether something is worth proposing, lean toward yes +for prompts (cheap to add, easy to reject) and lean toward no for +skills (harder to scope, larger blast radius). +`; + +const SYSTEM_SKILLS: SystemSkillDef[] = [ + { + name: 'propose-learnings', + description: + 'How and when to use propose_prompt / propose_skill to capture project knowledge for future sessions.', + priority: 9, + content: PROPOSE_LEARNINGS_CONTENT, + }, +]; + +/** + * Ensure system-owned global skills exist. Safe to call on every startup. + * If a user has edited or deleted a system skill, we leave their edit + * alone — same policy as system-project.ts. + */ +export async function bootstrapSystemSkills(prisma: PrismaClient): Promise { + for (const def of SYSTEM_SKILLS) { + const existing = await prisma.skill.findFirst({ + where: { name: def.name, projectId: null, agentId: null }, + }); + if (existing === null) { + await prisma.skill.create({ + data: { + name: def.name, + description: def.description, + priority: def.priority, + content: def.content, + // semver/files/metadata default to schema defaults. + }, + }); + } + // If it exists, leave the user's edits alone. + } +} + +/** Names of all system-seeded skills — useful for delete protection later. */ +export function getSystemSkillNames(): string[] { + return SYSTEM_SKILLS.map((s) => s.name); +} + +/** Default content for a system skill (for reset-on-delete). */ +export function getSystemSkillDefault(name: string): string | undefined { + return SYSTEM_SKILLS.find((s) => s.name === name)?.content; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d53828f..5754df6 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -54,6 +54,8 @@ import { PersonalityService } from './services/personality.service.js'; import { registerPersonalityRoutes } from './routes/personalities.js'; import { registerWebUi } from './routes/web-ui.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; +import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js'; +import { bootstrapSystemSkills } from './bootstrap/system-skills.js'; import { McpServerService, SecretService, @@ -98,8 +100,17 @@ import { registerMcpTokenRoutes, } from './routes/index.js'; import { registerPromptRoutes } from './routes/prompts.js'; +import { registerRevisionRoutes } from './routes/revisions.js'; +import { registerProposalRoutes } from './routes/proposals.js'; +import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; +import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js'; +import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; +import { ResourceRevisionService } from './services/resource-revision.service.js'; +import { ResourceProposalService } from './services/resource-proposal.service.js'; +import { SkillRepository } from './repositories/skill.repository.js'; +import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; import { ResourceRuleRegistry } from './validation/resource-rules.js'; @@ -120,6 +131,13 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { const segment = match[1] as string; + // Self-service password change — gated by the `set-own-password` operation + // (a default, admin-revocable permission), NOT the broad edit:users that an + // admin reset of another user needs. Must precede the generic users mapping. + if (url.startsWith('/api/v1/users/me/password')) { + return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION }; + } + // Operations (non-resource endpoints) if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; @@ -168,6 +186,17 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcp': 'servers', 'prompts': 'prompts', 'promptrequests': 'promptrequests', + // PR-2: revisions/proposals piggyback on the prompts permission for now. + // Anyone with view:prompts can read history; anyone with edit:prompts can + // approve/reject proposals. PR-7 may split these out if RBAC granularity + // becomes useful (e.g., a "reviewer" role). + 'revisions': 'prompts', + 'proposals': 'prompts', + // PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be + // split out later if the operator wants to scope skill writes more + // tightly than prompt writes — for now, a senior reviewer who can + // edit prompts can edit skills. + 'skills': 'prompts', 'mcptokens': 'mcptokens', 'llms': 'llms', // v5: durable inference task queue. Same default action mapping as @@ -358,6 +387,8 @@ async function main(): Promise { // Bootstrap system project and prompts await bootstrapSystemProject(prisma); + // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). + await bootstrapSystemSkills(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); @@ -391,6 +422,12 @@ async function main(): Promise { mcptokens: mcpTokenRepo, llms: llmRepo, agents: agentRepo, + // Resolve user CUID → email so name-scoped `edit:users:` bindings + // match on PUT /api/v1/users/:id/password (admin reset). + users: { findById: async (id: string) => { + const u = await userRepo.findById(id); + return u ? { name: u.email } : null; + } }, }; // Migrate legacy 'admin' role → granular roles @@ -468,6 +505,20 @@ async function main(): Promise { const promptRuleRegistry = new ResourceRuleRegistry(); promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); + // PR-2: shared revision/proposal infra. Promp service registers its + // 'prompt' approval handler with the proposal service via setProposalService; + // PR-3 wires the same for skills. + const resourceRevisionRepo = new ResourceRevisionRepository(prisma); + const resourceRevisionService = new ResourceRevisionService(resourceRevisionRepo); + const resourceProposalRepo = new ResourceProposalRepository(prisma); + const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma); + promptService.setRevisionService(resourceRevisionService); + promptService.setProposalService(resourceProposalService); + // PR-3: Skill resource. Reuses the same revision/proposal infra. + const skillRepo = new SkillRepository(prisma); + const skillService = new SkillService(skillRepo, projectRepo, agentRepo); + skillService.setRevisionService(resourceRevisionService); + skillService.setProposalService(resourceProposalService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -664,10 +715,13 @@ async function main(): Promise { authDeps, }); registerRbacRoutes(app, rbacDefinitionService); - registerUserRoutes(app, userService); + registerUserRoutes(app, { userService, rbacDefinitionService, prisma }); registerGroupRoutes(app, groupService); registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); registerPromptRoutes(app, promptService, projectRepo, agentRepo); + registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); + registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); + registerSkillRoutes(app, skillService, projectRepo, agentRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); @@ -676,7 +730,7 @@ async function main(): Promise { const kindFromSegment: Record = { servers: 'server', secrets: 'secret', projects: 'project', templates: 'template', users: 'user', groups: 'group', - rbac: 'rbac', prompts: 'prompt', + rbac: 'rbac', prompts: 'prompt', skills: 'skill', }; app.addHook('onSend', async (request, reply, payload) => { if (!gitBackup.enabled) return payload; @@ -747,14 +801,48 @@ async function main(): Promise { registerGitBackupRoutes(app, gitBackup); // ── RBAC list filtering hook ── - // Filters array responses to only include resources the user is allowed to see. + // + // Two filters compose here, in order: + // + // 1. RBAC name-scope (existing): when a caller has only name-scoped + // grants (no resource-wide), only items whose name is in their + // grants set pass through. wildcard=true skips this step. + // + // 2. v7 visibility filter (new): private rows are hidden from + // callers who aren't the owner and don't have a name-scoped + // grant. Skipped for `*`-resource admins (isAdmin=true) so + // org-wide audit/list operations still work. Importantly, this + // runs even when wildcard=true — that's how a regular `view:llms` + // grant stops broadcasting private virtuals across the org while + // still letting `*`-grant admins see everything. + // + // Items without a `visibility` field (today: every resource other + // than Llm and Agent) pass through this step unchanged. app.addHook('preSerialization', async (request, _reply, payload) => { - if (!request.rbacScope || request.rbacScope.wildcard) return payload; + if (!request.rbacScope) return payload; if (!Array.isArray(payload)) return payload; - return (payload as Array>).filter((item) => { - const name = item['name']; - return typeof name === 'string' && request.rbacScope!.names.has(name); - }); + let items = payload as Array>; + // Step 1: RBAC name-scope. + if (!request.rbacScope.wildcard) { + items = items.filter((item) => { + const name = item['name']; + return typeof name === 'string' && request.rbacScope!.names.has(name); + }); + } + // Step 2: visibility (only meaningful when the resource carries it). + if (!request.rbacScope.isAdmin) { + const userId = request.userId; + items = items.filter((item) => { + const visibility = item['visibility']; + if (visibility !== 'private') return true; + const ownerId = item['ownerId']; + if (typeof ownerId === 'string' && ownerId === userId) return true; + const name = item['name']; + if (typeof name === 'string' && request.rbacScope!.names.has(name)) return true; + return false; + }); + } + return items; }); // Web UI: served from /ui (static SPA bundle). Falls through to API diff --git a/src/mcpd/src/middleware/auth.ts b/src/mcpd/src/middleware/auth.ts index 0bc3a3c..741ad66 100644 --- a/src/mcpd/src/middleware/auth.ts +++ b/src/mcpd/src/middleware/auth.ts @@ -33,7 +33,14 @@ export interface AuthDeps { declare module 'fastify' { interface FastifyRequest { userId?: string; - rbacScope?: { wildcard: boolean; names: Set }; + /** + * v7: extended with `isAdmin` to distinguish `*` (cross-resource + * admin) grants from plain `view:llms` resource-wide grants. The + * preSerialization filter and the route-level Viewer use this to + * decide whether to skip the visibility filter (admins bypass; + * regular wildcard does not). + */ + rbacScope?: { wildcard: boolean; isAdmin: boolean; names: Set }; /** Set by the auth hook when the caller authenticated via a McpToken bearer (prefix `mcpctl_pat_`). */ mcpToken?: McpTokenPrincipal; } diff --git a/src/mcpd/src/repositories/agent.repository.ts b/src/mcpd/src/repositories/agent.repository.ts index 9f83541..271ef25 100644 --- a/src/mcpd/src/repositories/agent.repository.ts +++ b/src/mcpd/src/repositories/agent.repository.ts @@ -11,6 +11,8 @@ export interface CreateAgentRepoInput { defaultParams?: Record; extras?: Record; ownerId: string; + // v7: optional visibility scope (default 'public' if omitted). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -28,6 +30,9 @@ export interface UpdateAgentRepoInput { proxyModelName?: string | null; defaultParams?: Record; extras?: Record; + // v7: visibility is mutable (operator can flip a private virtual to + // public for org-wide sharing without re-creating). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle. AgentService is the only public writer; the // VirtualAgentService methods (Stage 2) bypass the public CRUD path. kind?: LlmKind; @@ -87,6 +92,7 @@ export class AgentRepository implements IAgentRepository { defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue, ownerId: data.ownerId, + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -122,6 +128,7 @@ export class AgentRepository implements IAgentRepository { if (data.extras !== undefined) { updateData.extras = data.extras as Prisma.InputJsonValue; } + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/repositories/llm.repository.ts b/src/mcpd/src/repositories/llm.repository.ts index 8942e19..b10bd64 100644 --- a/src/mcpd/src/repositories/llm.repository.ts +++ b/src/mcpd/src/repositories/llm.repository.ts @@ -12,6 +12,11 @@ export interface CreateLlmInput { extraConfig?: Record; // v4: optional pool key. NULL = "pool of 1" (effective key falls back to `name`). poolName?: string | null; + // v7: per-user RBAC scoping. ownerId is set by the service layer to + // the authenticated caller's User.id; visibility defaults to 'public' + // and gets flipped to 'private' for mcplocal-published virtuals. + ownerId?: string | null; + visibility?: 'public' | 'private'; // Virtual-provider lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -30,6 +35,10 @@ export interface UpdateLlmInput { apiKeySecretKey?: string | null; extraConfig?: Record; poolName?: string | null; + // v7: ownerId immutable at update time (use a separate transfer flow if + // ever needed). Visibility is mutable so an operator can flip a + // virtual Llm to public for org-wide sharing without re-creating it. + visibility?: 'public' | 'private'; // Virtual-provider lifecycle. VirtualLlmService is the only writer for // these in v1; the public CRUD path leaves them undefined. kind?: LlmKind; @@ -108,6 +117,8 @@ export class LlmRepository implements ILlmRepository { apiKeySecretKey: data.apiKeySecretKey ?? null, extraConfig: (data.extraConfig ?? {}) as Prisma.InputJsonValue, ...(data.poolName !== undefined ? { poolName: data.poolName } : {}), + ...(data.ownerId !== undefined ? { ownerId: data.ownerId } : {}), + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -132,6 +143,7 @@ export class LlmRepository implements ILlmRepository { if (data.apiKeySecretKey !== undefined) updateData.apiKeySecretKey = data.apiKeySecretKey; if (data.extraConfig !== undefined) updateData.extraConfig = data.extraConfig as Prisma.InputJsonValue; if (data.poolName !== undefined) updateData.poolName = data.poolName; + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index 80d2511..ea0fde5 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -14,6 +14,8 @@ export interface PromptUpdateInput { priority?: number; summary?: string; chapters?: string[]; + semver?: string; + currentRevisionId?: string | null; } export interface IPromptRepository { diff --git a/src/mcpd/src/repositories/resource-proposal.repository.ts b/src/mcpd/src/repositories/resource-proposal.repository.ts new file mode 100644 index 0000000..6065888 --- /dev/null +++ b/src/mcpd/src/repositories/resource-proposal.repository.ts @@ -0,0 +1,138 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { ResourceType } from './resource-revision.repository.js'; + +/** + * Generic propose/approve/reject queue keyed by (resourceType, name, + * projectId|agentId). Successor to PromptRequest. The repo mirrors + * PromptRepository's `?? ''` workaround for nullable-FK compound lookups. + */ + +export type ProposalStatus = 'pending' | 'approved' | 'rejected'; + +export interface ProposalListFilter { + resourceType?: ResourceType; + projectId?: string; + agentId?: string; + status?: ProposalStatus; +} + +export interface CreateProposalInput { + resourceType: ResourceType; + name: string; + body: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export interface UpdateProposalStatusInput { + status: ProposalStatus; + reviewerNote?: string; + approvedRevisionId?: string; +} + +export interface IResourceProposalRepository { + list(filter: ProposalListFilter): Promise; + findById(id: string): Promise; + findByName(resourceType: ResourceType, name: string, scope: { projectId: string | null; agentId: string | null }): Promise; + findBySession(sessionId: string, projectId?: string): Promise; + create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise; + updateBody(id: string, body: Prisma.InputJsonValue): Promise; + updateStatus(id: string, data: UpdateProposalStatusInput, tx?: Prisma.TransactionClient): Promise; + delete(id: string): Promise; +} + +export class ResourceProposalRepository implements IResourceProposalRepository { + constructor(private readonly prisma: PrismaClient) {} + + async list(filter: ProposalListFilter): Promise { + const where: Prisma.ResourceProposalWhereInput = {}; + if (filter.resourceType) where.resourceType = filter.resourceType; + if (filter.status) where.status = filter.status; + if (filter.projectId !== undefined) { + // Match project-scoped + globals (NULL projectId), like PromptRepo. + where.OR = [{ projectId: filter.projectId }, { projectId: null, agentId: null }]; + } + if (filter.agentId !== undefined) { + where.agentId = filter.agentId; + } + return this.prisma.resourceProposal.findMany({ + where, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.resourceProposal.findUnique({ + where: { id }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + } + + async findByName( + resourceType: ResourceType, + name: string, + scope: { projectId: string | null; agentId: string | null }, + ): Promise { + if (scope.agentId !== null) { + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_agentId: { resourceType, name, agentId: scope.agentId } }, + }); + } + // Project-scoped or global (projectId=null is handled by the same compound key). + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_projectId: { resourceType, name, projectId: scope.projectId ?? '' } }, + }); + } + + async findBySession(sessionId: string, projectId?: string): Promise { + const where: Prisma.ResourceProposalWhereInput = { createdBySession: sessionId }; + if (projectId !== undefined) { + where.OR = [{ projectId }, { projectId: null, agentId: null }]; + } + return this.prisma.resourceProposal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceProposal.create({ data }); + } + + async updateBody(id: string, body: Prisma.InputJsonValue): Promise { + return this.prisma.resourceProposal.update({ + where: { id }, + data: { body, version: { increment: 1 } }, + }); + } + + async updateStatus( + id: string, + data: UpdateProposalStatusInput, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const update: Prisma.ResourceProposalUpdateInput = { + status: data.status, + version: { increment: 1 }, + }; + if (data.reviewerNote !== undefined) update.reviewerNote = data.reviewerNote; + if (data.approvedRevisionId !== undefined) update.approvedRevisionId = data.approvedRevisionId; + return client.resourceProposal.update({ where: { id }, data: update }); + } + + async delete(id: string): Promise { + await this.prisma.resourceProposal.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/resource-revision.repository.ts b/src/mcpd/src/repositories/resource-revision.repository.ts new file mode 100644 index 0000000..2731423 --- /dev/null +++ b/src/mcpd/src/repositories/resource-revision.repository.ts @@ -0,0 +1,79 @@ +import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client'; + +/** + * Append-only revision log shared by Prompt and Skill (and any future + * resource type with a `resourceType` discriminator). The repository is + * intentionally narrow: callers always know which resource they're + * looking at, so every read takes (resourceType, resourceId) explicitly. + * + * `resourceId` is a soft FK — there's no `Prompt`/`Skill` relation here, + * because revisions need to outlive the resources they describe (audit + * survives deletion). That means we accept any string and trust the + * service layer to keep them in sync with real IDs. + */ + +export type ResourceType = 'prompt' | 'skill'; + +export interface CreateRevisionInput { + resourceType: ResourceType; + resourceId: string; + semver: string; + contentHash: string; + body: Prisma.InputJsonValue; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export interface IResourceRevisionRepository { + create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise; + findById(id: string): Promise; + findLatest(resourceType: ResourceType, resourceId: string): Promise; + findHistory(resourceType: ResourceType, resourceId: string, limit?: number): Promise; + findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise; + findByContentHash(contentHash: string): Promise; +} + +export class ResourceRevisionRepository implements IResourceRevisionRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceRevision.create({ data }); + } + + async findById(id: string): Promise { + return this.prisma.resourceRevision.findUnique({ where: { id } }); + } + + async findLatest(resourceType: ResourceType, resourceId: string): Promise { + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findHistory(resourceType: ResourceType, resourceId: string, limit = 100): Promise { + return this.prisma.resourceRevision.findMany({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise { + // Multiple revisions can share a semver if a value was reused (rare, + // but possible with manual --semver overrides). Return the latest. + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId, semver }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findByContentHash(contentHash: string): Promise { + return this.prisma.resourceRevision.findMany({ + where: { contentHash }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/src/mcpd/src/repositories/skill.repository.ts b/src/mcpd/src/repositories/skill.repository.ts new file mode 100644 index 0000000..728925b --- /dev/null +++ b/src/mcpd/src/repositories/skill.repository.ts @@ -0,0 +1,109 @@ +import type { PrismaClient, Prisma, Skill } from '@prisma/client'; + +/** + * Skill repository — mirrors PromptRepository. Same nullable-FK + * compound-key workaround (`projectId ?? ''`) applies, see prompt.repository.ts. + */ + +export interface SkillCreateInput { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + semver?: string; +} + +export interface SkillUpdateInput { + content?: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + priority?: number; + summary?: string; + chapters?: string[]; + semver?: string; + currentRevisionId?: string | null; +} + +export interface ISkillRepository { + findAll(projectId?: string): Promise; + findGlobal(): Promise; + findByAgent(agentId: string): Promise; + findById(id: string): Promise; + findByNameAndProject(name: string, projectId: string | null): Promise; + findByNameAndAgent(name: string, agentId: string | null): Promise; + create(data: SkillCreateInput): Promise; + update(id: string, data: SkillUpdateInput): Promise; + delete(id: string): Promise; +} + +export class SkillRepository implements ISkillRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + const include = { project: { select: { name: true } } }; + if (projectId !== undefined) { + // Project-scoped + globals. + return this.prisma.skill.findMany({ + where: { OR: [{ projectId }, { projectId: null, agentId: null }] }, + include, + orderBy: { name: 'asc' }, + }); + } + return this.prisma.skill.findMany({ include, orderBy: { name: 'asc' } }); + } + + async findGlobal(): Promise { + return this.prisma.skill.findMany({ + where: { projectId: null, agentId: null }, + include: { project: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findByAgent(agentId: string): Promise { + return this.prisma.skill.findMany({ + where: { agentId }, + include: { agent: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.skill.findUnique({ + where: { id }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + } + + async findByNameAndProject(name: string, projectId: string | null): Promise { + return this.prisma.skill.findUnique({ + where: { name_projectId: { name, projectId: projectId ?? '' } }, + }); + } + + async findByNameAndAgent(name: string, agentId: string | null): Promise { + return this.prisma.skill.findUnique({ + where: { name_agentId: { name, agentId: agentId ?? '' } }, + }); + } + + async create(data: SkillCreateInput): Promise { + return this.prisma.skill.create({ data }); + } + + async update(id: string, data: SkillUpdateInput): Promise { + return this.prisma.skill.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + await this.prisma.skill.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/user.repository.ts b/src/mcpd/src/repositories/user.repository.ts index f008584..bb53e5a 100644 --- a/src/mcpd/src/repositories/user.repository.ts +++ b/src/mcpd/src/repositories/user.repository.ts @@ -6,9 +6,11 @@ export type SafeUser = Omit; export interface IUserRepository { findAll(): Promise; findById(id: string): Promise; + /** Like findById but includes the passwordHash — used by password verification. */ + findByIdWithHash(id: string): Promise; findByEmail(email: string, includeHash?: boolean): Promise | Promise; create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise; - update(id: string, data: { name?: string; role?: string }): Promise; + update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise; delete(id: string): Promise; count(): Promise; } @@ -43,6 +45,10 @@ export class UserRepository implements IUserRepository { }); } + async findByIdWithHash(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + async findByEmail(email: string, includeHash?: boolean): Promise { if (includeHash === true) { return this.prisma.user.findUnique({ where: { email } }); @@ -67,10 +73,11 @@ export class UserRepository implements IUserRepository { }); } - async update(id: string, data: { name?: string; role?: string }): Promise { + async update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise { const updateData: Record = {}; if (data.name !== undefined) updateData['name'] = data.name; if (data.role !== undefined) updateData['role'] = data.role; + if (data.passwordHash !== undefined) updateData['passwordHash'] = data.passwordHash; return this.prisma.user.update({ where: { id }, data: updateData, diff --git a/src/mcpd/src/routes/agents.ts b/src/mcpd/src/routes/agents.ts index 5e3e633..d2fbf09 100644 --- a/src/mcpd/src/routes/agents.ts +++ b/src/mcpd/src/routes/agents.ts @@ -6,21 +6,33 @@ * — the resource is `agents`. The chat endpoints live in `agent-chat.ts` and * map to `run:agents:`. */ -import type { FastifyInstance } from 'fastify'; -import type { AgentService } from '../services/agent.service.js'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { AgentService, AgentViewer } from '../services/agent.service.js'; import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; +/** v7: thread the request's RBAC scope into the service so foreign-private rows 404. */ +function viewerFromRequest(request: FastifyRequest): AgentViewer | null { + if (request.userId === undefined || request.rbacScope === undefined) return null; + return { + userId: request.userId, + wildcard: request.rbacScope.isAdmin, + allowedNames: request.rbacScope.names, + }; +} + export function registerAgentRoutes( app: FastifyInstance, service: AgentService, ): void { app.get('/api/v1/agents', async () => { + // List filter is applied by the preSerialization hook (visibility + + // RBAC name-scope); service stays viewer-blind for internal callers. return service.list(); }); app.get<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - return await getByIdOrName(service, request.params.id); + return await getByIdOrName(service, request.params.id, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -51,7 +63,7 @@ export function registerAgentRoutes( app.put<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - const target = await getByIdOrName(service, request.params.id); + const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request)); return await service.update(target.id, request.body); } catch (err) { if (err instanceof NotFoundError) { @@ -64,7 +76,7 @@ export function registerAgentRoutes( app.delete<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - const target = await getByIdOrName(service, request.params.id); + const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request)); await service.delete(target.id); reply.code(204); return null; @@ -84,7 +96,7 @@ export function registerAgentRoutes( '/api/v1/projects/:projectName/agents', async (request, reply) => { try { - return await service.listByProject(request.params.projectName); + return await service.listByProject(request.params.projectName, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -98,9 +110,9 @@ export function registerAgentRoutes( const CUID_RE = /^c[a-z0-9]{24}/i; -async function getByIdOrName(service: AgentService, idOrName: string) { +async function getByIdOrName(service: AgentService, idOrName: string, viewer: AgentViewer | null = null) { if (CUID_RE.test(idOrName)) { - return service.getById(idOrName); + return service.getById(idOrName, viewer); } - return service.getByName(idOrName); + return service.getByName(idOrName, viewer); } diff --git a/src/mcpd/src/routes/auth.ts b/src/mcpd/src/routes/auth.ts index e69eba5..6dc3bd8 100644 --- a/src/mcpd/src/routes/auth.ts +++ b/src/mcpd/src/routes/auth.ts @@ -6,6 +6,7 @@ import type { RbacDefinitionService } from '../services/rbac-definition.service. import type { RbacService } from '../services/rbac.service.js'; import { createAuthMiddleware } from '../middleware/auth.js'; import { createRbacMiddleware } from '../middleware/rbac.js'; +import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js'; export interface AuthRouteDeps { authService: AuthService; @@ -37,12 +38,18 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v const { email, password, name } = request.body as { email: string; password: string; name?: string }; // Create the first admin user - await deps.userService.create({ + const adminUser = await deps.userService.create({ email, password, ...(name !== undefined ? { name } : {}), }); + // Fresh install: the admin is also a user, so give them the default + // self-service password-change permission (default-on; see + // grantSelfPasswordPermission). Their bootstrap-admin edit:* already + // covers resetting OTHER users. + await grantSelfPasswordPermission(deps.rbacDefinitionService, adminUser); + // Create "admin" group and add the first user to it await deps.groupService.create({ name: 'admin', diff --git a/src/mcpd/src/routes/llms.ts b/src/mcpd/src/routes/llms.ts index b153a9a..740e8d1 100644 --- a/src/mcpd/src/routes/llms.ts +++ b/src/mcpd/src/routes/llms.ts @@ -1,13 +1,32 @@ -import type { FastifyInstance } from 'fastify'; -import type { LlmService, LlmView } from '../services/llm.service.js'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { LlmService, LlmView, Viewer } from '../services/llm.service.js'; import { LlmAuthVerificationError, effectivePoolName } from '../services/llm.service.js'; import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; +/** + * v7: build a Viewer from the request's RBAC scope so the service + * applies the visibility filter consistently. The list endpoint relies + * on the preSerialization hook for the same logic; for get-by-name/id + * the service does the filter itself and 404s on a hidden row. + */ +function viewerFromRequest(request: FastifyRequest): Viewer | null { + if (request.userId === undefined || request.rbacScope === undefined) return null; + return { + userId: request.userId, + wildcard: request.rbacScope.isAdmin, // only admins skip the visibility filter + allowedNames: request.rbacScope.names, + }; +} + export function registerLlmRoutes( app: FastifyInstance, service: LlmService, ): void { app.get('/api/v1/llms', async () => { + // List goes through the preSerialization hook which applies both + // RBAC name-scope and v7 visibility filter — service stays + // viewer-blind here so internal callers (audit, sweeps) keep + // working without a request context. return service.list(); }); @@ -16,7 +35,7 @@ export function registerLlmRoutes( // hands over the user-facing name to avoid an extra round-trip). app.get<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => { try { - return await getByIdOrName(service, request.params.id); + return await getByIdOrName(service, request.params.id, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -96,8 +115,20 @@ export function registerLlmRoutes( // size + activeCount. app.get<{ Params: { name: string } }>('/api/v1/llms/:name/members', async (request, reply) => { try { - const anchor = await getByIdOrName(service, request.params.name); - const members = await service.listPoolMembers(effectivePoolName(anchor)); + const viewer = viewerFromRequest(request); + const anchor = await getByIdOrName(service, request.params.name, viewer); + const allMembers = await service.listPoolMembers(effectivePoolName(anchor)); + // v7: filter pool members by visibility too — without this, a + // pool with private members would leak their names through the + // /members endpoint even though the list endpoint hides them. + const members = viewer === null + ? allMembers + : allMembers.filter((m) => { + if (m.visibility !== 'private') return true; + if (m.ownerId !== null && m.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(m.name)) return true; + return viewer.wildcard; + }); return { poolName: effectivePoolName(anchor), explicitPoolName: anchor.poolName, @@ -131,11 +162,12 @@ const CUID_RE = /^c[a-z0-9]{24}/i; /** * Look up by CUID first; if the input doesn't look like one, fall back to * findByName. Lets the same URL serve both `mcpctl describe llm ` and - * the FailoverRouter's name-based RBAC check. + * the FailoverRouter's name-based RBAC check. v7: the optional viewer + * threads through to the service so foreign-private rows 404 cleanly. */ -async function getByIdOrName(service: LlmService, idOrName: string) { +async function getByIdOrName(service: LlmService, idOrName: string, viewer: Viewer | null = null) { if (CUID_RE.test(idOrName)) { - return service.getById(idOrName); + return service.getById(idOrName, viewer); } - return service.getByName(idOrName); + return service.getByName(idOrName, viewer); } diff --git a/src/mcpd/src/routes/proposals.ts b/src/mcpd/src/routes/proposals.ts new file mode 100644 index 0000000..a9a93f8 --- /dev/null +++ b/src/mcpd/src/routes/proposals.ts @@ -0,0 +1,157 @@ +import type { FastifyInstance } from 'fastify'; + +import type { ResourceProposalService } from '../services/resource-proposal.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { + ResourceType, +} from '../repositories/resource-revision.repository.js'; +import type { ProposalStatus } from '../repositories/resource-proposal.repository.js'; + +interface ProposalRouteDeps { + proposalService: ResourceProposalService; + projectRepo: IProjectRepository; + agentRepo?: IAgentRepository; +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; +const VALID_STATUSES: readonly ProposalStatus[] = ['pending', 'approved', 'rejected'] as const; + +export function registerProposalRoutes(app: FastifyInstance, deps: ProposalRouteDeps): void { + const { proposalService, projectRepo, agentRepo } = deps; + + app.get<{ Querystring: { resourceType?: string; status?: string; project?: string; agent?: string } }>( + '/api/v1/proposals', + async (request) => { + const filter: { + resourceType?: ResourceType; + status?: ProposalStatus; + projectId?: string; + agentId?: string; + } = {}; + const { resourceType, status, project, agent } = request.query; + if (resourceType !== undefined) { + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error(`Invalid resourceType: ${resourceType}`), { statusCode: 400 }); + } + filter.resourceType = resourceType as ResourceType; + } + if (status !== undefined) { + if (!VALID_STATUSES.includes(status as ProposalStatus)) { + throw Object.assign(new Error(`Invalid status: ${status}`), { statusCode: 400 }); + } + filter.status = status as ProposalStatus; + } + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + filter.projectId = proj.id; + } + if (agent !== undefined) { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(agent); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 }); + } + filter.agentId = ag.id; + } + return proposalService.list(filter); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request) => { + return proposalService.getById(request.params.id); + }); + + app.post('/api/v1/proposals', async (request, reply) => { + const body = request.body as Record; + const resourceType = body['resourceType']; + if (typeof resourceType !== 'string' || !VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error('resourceType must be "prompt" or "skill"'), { statusCode: 400 }); + } + const name = body['name']; + if (typeof name !== 'string' || name.length === 0) { + throw Object.assign(new Error('name is required'), { statusCode: 400 }); + } + const proposalBody = body['body']; + if (proposalBody === undefined || typeof proposalBody !== 'object' || proposalBody === null) { + throw Object.assign(new Error('body must be an object'), { statusCode: 400 }); + } + const input: { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; + } = { + resourceType: resourceType as ResourceType, + name, + body: proposalBody as Record, + }; + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + input.projectId = proj.id; + } else if (typeof body['projectId'] === 'string') { + input.projectId = body['projectId']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(body['agent']); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + input.agentId = ag.id; + } else if (typeof body['agentId'] === 'string') { + input.agentId = body['agentId']; + } + if (typeof body['createdBySession'] === 'string') input.createdBySession = body['createdBySession']; + if (typeof body['createdByUserId'] === 'string') input.createdByUserId = body['createdByUserId']; + + const proposal = await proposalService.propose(input); + reply.code(201); + return proposal; + }); + + app.put<{ Params: { id: string }; Body: { body?: Record } }>( + '/api/v1/proposals/:id', + async (request) => { + const proposalBody = request.body.body; + if (proposalBody === undefined) { + throw Object.assign(new Error('body is required'), { statusCode: 400 }); + } + return proposalService.updateBody(request.params.id, proposalBody); + }, + ); + + app.post<{ Params: { id: string } }>('/api/v1/proposals/:id/approve', async (request) => { + // approverUserId is set by the auth middleware on the request — we + // don't grab it explicitly here; service uses what the audit layer + // already records. Threading it through requires the auth context + // (out of scope for PR-2; PR-4's reviewer flow will surface it). + return proposalService.approve(request.params.id); + }); + + app.post<{ Params: { id: string }; Body: { reason?: string; reviewerNote?: string } }>( + '/api/v1/proposals/:id/reject', + async (request) => { + const note = request.body.reviewerNote ?? request.body.reason ?? ''; + return proposalService.reject(request.params.id, note); + }, + ); + + app.delete<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request, reply) => { + await proposalService.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/revisions.ts b/src/mcpd/src/routes/revisions.ts new file mode 100644 index 0000000..381ff8b --- /dev/null +++ b/src/mcpd/src/routes/revisions.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance } from 'fastify'; +import { createPatch } from 'diff'; + +import type { ResourceRevisionService } from '../services/resource-revision.service.js'; +import type { PromptService } from '../services/prompt.service.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; + +interface RevisionRouteDeps { + revisionService: ResourceRevisionService; + promptService: PromptService; + // Future: skillService for PR-3. +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; + +export function registerRevisionRoutes(app: FastifyInstance, deps: RevisionRouteDeps): void { + const { revisionService, promptService } = deps; + + // List history for a resource. Either both query params or none (none = error). + app.get<{ Querystring: { resourceType?: string; resourceId?: string; limit?: string } }>( + '/api/v1/revisions', + async (request) => { + const { resourceType, resourceId, limit } = request.query; + if (!resourceType || !resourceId) { + throw Object.assign( + new Error('Both resourceType and resourceId are required'), + { statusCode: 400 }, + ); + } + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign( + new Error(`Invalid resourceType: ${resourceType}`), + { statusCode: 400 }, + ); + } + const limitNum = limit ? Math.min(500, Math.max(1, Number(limit))) : 100; + return revisionService.listHistory(resourceType as ResourceType, resourceId, limitNum); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/revisions/:id', async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + return revision; + }); + + /** + * Unified diff between two revisions, or between a revision and the + * live resource body. `against` accepts another revision id or the + * literal string `live`. + */ + app.get<{ Params: { id: string }; Querystring: { against?: string } }>( + '/api/v1/revisions/:id/diff', + async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + const against = request.query.against ?? 'live'; + + let otherContent: string; + let otherLabel: string; + if (against === 'live') { + // For prompts, fetch the live row by resourceId. + if (revision.resourceType === 'prompt') { + const prompt = await promptService.getPrompt(revision.resourceId); + otherContent = prompt.content; + otherLabel = `${prompt.name} (live, semver ${prompt.semver})`; + } else { + // PR-3 will wire skillService here. + throw Object.assign( + new Error(`Live diff not supported for resourceType ${revision.resourceType} yet`), + { statusCode: 501 }, + ); + } + } else { + const otherRev = await revisionService.getById(against); + if (otherRev === null) { + throw Object.assign(new Error(`Other revision not found: ${against}`), { statusCode: 404 }); + } + if (otherRev.resourceType !== revision.resourceType || otherRev.resourceId !== revision.resourceId) { + throw Object.assign( + new Error('Diff requires both revisions to be of the same resource'), + { statusCode: 400 }, + ); + } + otherContent = stringContent(otherRev.body); + otherLabel = `revision ${otherRev.id} (${otherRev.semver})`; + } + + const thisContent = stringContent(revision.body); + const thisLabel = `revision ${revision.id} (${revision.semver})`; + + // Unified-format patch. Caller can render this directly or pass to a diff viewer. + const patch = createPatch(`${revision.resourceType}/${revision.resourceId}`, otherContent, thisContent, otherLabel, thisLabel); + return { patch }; + }, + ); + + // POST /api/v1/prompts/:id/restore-revision { revisionId, note? } + // (Skill route registered in PR-3 alongside this with the same shape.) + app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>( + '/api/v1/prompts/:id/restore-revision', + async (request) => { + const { revisionId, note } = request.body; + if (!revisionId) { + throw Object.assign(new Error('revisionId is required'), { statusCode: 400 }); + } + return promptService.restoreRevisionForPrompt(request.params.id, revisionId, note); + }, + ); +} + +/** Pull a `content` string out of a revision body, falling back to the raw JSON. */ +function stringContent(body: unknown): string { + if (body !== null && typeof body === 'object' && !Array.isArray(body)) { + const v = (body as Record)['content']; + if (typeof v === 'string') return v; + } + return JSON.stringify(body, null, 2); +} diff --git a/src/mcpd/src/routes/skills.ts b/src/mcpd/src/routes/skills.ts new file mode 100644 index 0000000..5609450 --- /dev/null +++ b/src/mcpd/src/routes/skills.ts @@ -0,0 +1,147 @@ +import type { FastifyInstance } from 'fastify'; +import type { Skill } from '@prisma/client'; + +import type { SkillService } from '../services/skill.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; + +export function registerSkillRoutes( + app: FastifyInstance, + service: SkillService, + projectRepo: IProjectRepository, + agentRepo?: IAgentRepository, +): void { + // ── List ── + // Filter by `?project=`, `?projectId=`, `?agent=`, or `?scope=global`. + + app.get<{ Querystring: { project?: string; projectId?: string; agent?: string; scope?: string } }>( + '/api/v1/skills', + async (request) => { + const { project, projectId, agent, scope } = request.query; + let skills: Skill[]; + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + skills = await service.listSkills(proj.id); + } else if (projectId !== undefined) { + skills = await service.listSkills(projectId); + } else if (agent !== undefined) { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(agent); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 }); + } + skills = await service.listSkillsForAgent(ag.id); + } else if (scope === 'global') { + skills = await service.listGlobalSkills(); + } else { + skills = await service.listSkills(); + } + return skills; + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/skills/:id', async (request) => { + return service.getSkill(request.params.id); + }); + + // ── Create / Update / Delete ── + + app.post('/api/v1/skills', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = proj.id; + delete resolved['project']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(body['agent']); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + resolved['agentId'] = ag.id; + delete resolved['agent']; + } + const skill = await service.createSkill(resolved); + reply.code(201); + return skill; + }); + + app.put<{ Params: { id: string } }>('/api/v1/skills/:id', async (request) => { + return service.updateSkill(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/skills/:id', async (request, reply) => { + await service.deleteSkill(request.params.id); + reply.code(204); + }); + + // ── Project-scoped views ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/skills', async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return service.listSkills(proj.id); + }); + + /** + * Compact view for `mcpctl skills sync` (PR-5). Returns metadata only — + * no `files`, no full `content` — so the client can decide which skills + * are stale before fetching the full body via /api/v1/skills/:id. + */ + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/skills/visible', + async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return service.getVisibleSkills(proj.id); + }, + ); + + // ── Agent-scoped view ── + + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/skills', + async (request, reply) => { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(request.params.agentName); + if (agent === null) { + reply.code(404); + return { error: `Agent not found: ${request.params.agentName}` }; + } + return service.listSkillsForAgent(agent.id); + }, + ); + + // ── Restore from a revision ── + // POST /api/v1/skills/:id/restore-revision { revisionId, note? } + + app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>( + '/api/v1/skills/:id/restore-revision', + async (request) => { + const { revisionId, note } = request.body; + if (!revisionId) { + throw Object.assign(new Error('revisionId is required'), { statusCode: 400 }); + } + return service.restoreRevisionForSkill(request.params.id, revisionId, note); + }, + ); +} diff --git a/src/mcpd/src/routes/users.ts b/src/mcpd/src/routes/users.ts index 80adaf1..6b5dbcb 100644 --- a/src/mcpd/src/routes/users.ts +++ b/src/mcpd/src/routes/users.ts @@ -1,10 +1,20 @@ import type { FastifyInstance } from 'fastify'; +import type { PrismaClient } from '@prisma/client'; import type { UserService } from '../services/user.service.js'; +import type { RbacDefinitionService } from '../services/rbac-definition.service.js'; +import { ChangeOwnPasswordSchema, ResetPasswordSchema } from '../validation/user.schema.js'; +import { getAllowSelfPasswordChange } from '../bootstrap/system-settings.js'; +import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js'; + +export interface UserRouteDeps { + userService: UserService; + rbacDefinitionService: RbacDefinitionService; + prisma: PrismaClient; +} + +export function registerUserRoutes(app: FastifyInstance, deps: UserRouteDeps): void { + const { userService: service, rbacDefinitionService, prisma } = deps; -export function registerUserRoutes( - app: FastifyInstance, - service: UserService, -): void { app.get('/api/v1/users', async () => { return service.list(); }); @@ -20,12 +30,58 @@ export function registerUserRoutes( app.post('/api/v1/users', async (request, reply) => { const user = await service.create(request.body); + // Seed the default, admin-revocable self password-change permission, + // gated by the system setting. Never fail user creation if seeding fails. + try { + if (await getAllowSelfPasswordChange(prisma)) { + await grantSelfPasswordPermission(rbacDefinitionService, user); + } + } catch (err) { + request.log.warn({ err, userId: user.id }, 'failed to seed self password permission'); + } reply.code(201); return user; }); - app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => { - await service.delete(_request.params.id); + // ── Self-service password change ── + // Gated by the `set-own-password` operation (global RBAC hook). Requires the + // current password as proof — a user who forgot it must use an admin reset. + app.post('/api/v1/users/me/password', async (request, reply) => { + if (request.userId === undefined) { + reply.code(401); + return { error: 'Authentication required' }; + } + const { currentPassword, newPassword } = ChangeOwnPasswordSchema.parse(request.body); + const ok = await service.verifyPassword(request.userId, currentPassword); + if (!ok) { + reply.code(401); + return { error: 'Current password is incorrect' }; + } + await service.setPassword(request.userId, newPassword); + return { success: true }; + }); + + // ── Admin reset of another user's password ── + // Gated by edit:users (admins have edit:*). No current password required. + app.put<{ Params: { id: string } }>('/api/v1/users/:id/password', async (request) => { + const idOrEmail = request.params.id; + const target = idOrEmail.includes('@') + ? await service.getByEmail(idOrEmail) + : await service.getById(idOrEmail); + const { newPassword } = ResetPasswordSchema.parse(request.body); + await service.setPassword(target.id, newPassword); + return { success: true }; + }); + + app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (request, reply) => { + // A user cannot delete their own account through the API — self bindings + // grant password change, not account deletion. Admins delete others. + if (request.userId !== undefined && request.userId === request.params.id) { + reply.code(403); + return { error: 'You cannot delete your own account' }; + } + await service.delete(request.params.id); reply.code(204); + return undefined; }); } diff --git a/src/mcpd/src/routes/virtual-llms.ts b/src/mcpd/src/routes/virtual-llms.ts index fbde2ba..be86d1f 100644 --- a/src/mcpd/src/routes/virtual-llms.ts +++ b/src/mcpd/src/routes/virtual-llms.ts @@ -45,9 +45,14 @@ export function registerVirtualLlmRoutes( const agentsInput = Array.isArray(body.agents) ? body.agents : null; try { + // v7: ownerId from the authenticated request lands on every + // newly-created virtual Llm row (sticky reconnects update the + // existing row's ownerId too — same publisher, same session). + const ownerId = request.userId ?? 'system'; const result = await service.register({ providerSessionId: body.providerSessionId ?? null, providers: providers.map(coerceProviderInput), + ownerId, }); // v3: atomically publish virtual agents tied to the same session. // If the caller didn't include an agents array, skip silently. @@ -189,6 +194,10 @@ function coerceAgentInput(raw: unknown): VirtualAgentInput { if (o['extras'] !== null && typeof o['extras'] === 'object') { out.extras = o['extras'] as Record; } + // v7: optional visibility scope (defaults to 'private' on first publish). + if (o['visibility'] === 'public' || o['visibility'] === 'private') { + out.visibility = o['visibility']; + } return out; } @@ -202,6 +211,7 @@ function coerceProviderInput(raw: unknown): { extraConfig?: Record; initialStatus?: 'active' | 'hibernating'; poolName?: string; + visibility?: 'public' | 'private'; } { if (raw === null || typeof raw !== 'object') { throw Object.assign(new Error('provider entry must be an object'), { statusCode: 400 }); @@ -234,5 +244,10 @@ function coerceProviderInput(raw: unknown): { if (typeof o['poolName'] === 'string' && /^[a-z0-9-]+$/.test(o['poolName']) && o['poolName'].length >= 1 && o['poolName'].length <= 100) { out.poolName = o['poolName']; } + // v7: visibility. Only 'public' or 'private'; unknown values fall + // through to the service default ('private' for virtuals). + if (o['visibility'] === 'public' || o['visibility'] === 'private') { + out.visibility = o['visibility']; + } return out; } diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 601d0dc..440d4d2 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -21,6 +21,29 @@ import { } from '../validation/agent.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; +/** + * v7: visibility scope for the current request, mirrors LlmService.Viewer. + * Same semantics — null = no filter; wildcard = full grant; else + * filter to public-or-owned-or-name-scoped. + */ +export interface AgentViewer { + userId: string; + wildcard: boolean; + allowedNames: Set; +} + +export function isAgentVisibleTo( + row: { name: string; ownerId: string; visibility: string }, + viewer: AgentViewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + /** Shape returned by the API layer — embeds llm + project metadata. */ export interface AgentView { id: string; @@ -39,6 +62,8 @@ export interface AgentView { lastHeartbeatAt: Date | null; inactiveSince: Date | null; ownerId: string; + /** v7: per-user RBAC scoping. mcplocal-published virtuals default to 'private'. */ + visibility: 'public' | 'private'; version: number; createdAt: Date; updatedAt: Date; @@ -53,6 +78,12 @@ export interface VirtualAgentInput { project?: string; defaultParams?: Record; extras?: Record; + /** + * v7: per-user RBAC scoping. When omitted, virtual agents default to + * 'private' on register — same shape as virtual Llms. The publisher + * can override per-agent in mcplocal config. + */ + visibility?: 'public' | 'private'; } export class AgentService { @@ -63,26 +94,30 @@ export class AgentService { private readonly personalities?: IPersonalityRepository, ) {} - async list(): Promise { + async list(viewer: AgentViewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async listByProject(projectName: string): Promise { + async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Agent not found: ${name}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${name}`); return this.toView(row); } @@ -200,6 +235,7 @@ export class AgentService { lastHeartbeatAt: row.lastHeartbeatAt, inactiveSince: row.inactiveSince, ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', version: row.version, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -262,6 +298,10 @@ export class AgentService { projectId, ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: only update visibility on sticky reconnect when the + // publisher explicitly sent it — operators may have flipped + // a virtual agent to public manually via `mcpctl edit agent`. + ...(a.visibility !== undefined ? { visibility: a.visibility } : {}), kind: 'virtual', providerSessionId: sessionId, status: 'active', @@ -279,6 +319,8 @@ export class AgentService { projectId, ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: virtual agents default to private on first publish. + visibility: a.visibility ?? 'private', kind: 'virtual', providerSessionId: sessionId, status: 'active', diff --git a/src/mcpd/src/services/backup/git-backup.service.ts b/src/mcpd/src/services/backup/git-backup.service.ts index d7cf86d..1acf0f3 100644 --- a/src/mcpd/src/services/backup/git-backup.service.ts +++ b/src/mcpd/src/services/backup/git-backup.service.ts @@ -737,6 +737,17 @@ export class GitBackupService { if (!r) throw new Error(`Prompt not found: ${name}`); return resourceToYaml('prompt', r as unknown as Record); } + case 'skill': { + const r = await this.prisma.skill.findFirst({ + where: { name }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + if (!r) throw new Error(`Skill not found: ${name}`); + return resourceToYaml('skill', r as unknown as Record); + } case 'template': { const r = await this.prisma.mcpTemplate.findUnique({ where: { name } }); if (!r) throw new Error(`Template not found: ${name}`); diff --git a/src/mcpd/src/services/backup/yaml-serializer.ts b/src/mcpd/src/services/backup/yaml-serializer.ts index 4e9abd8..60c5f69 100644 --- a/src/mcpd/src/services/backup/yaml-serializer.ts +++ b/src/mcpd/src/services/backup/yaml-serializer.ts @@ -114,11 +114,11 @@ export function resourcePath(kind: string, name: string): string { } /** Resource kinds that are backed up. */ -export const BACKUP_KINDS = ['server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'template'] as const; +export const BACKUP_KINDS = ['server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'skill', 'template'] as const; export type BackupKind = (typeof BACKUP_KINDS)[number]; -/** Apply order: dependencies before dependents. */ -export const APPLY_ORDER: BackupKind[] = ['secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt']; +/** Apply order: dependencies before dependents. Skills follow prompts. */ +export const APPLY_ORDER: BackupKind[] = ['secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt', 'skill']; /** Parse a file path to extract kind and name. Returns null if path doesn't match backup structure. */ export function parseResourcePath(filePath: string): { kind: BackupKind; name: string } | null { @@ -129,7 +129,7 @@ export function parseResourcePath(filePath: string): { kind: BackupKind; name: s const kindMap: Record = { servers: 'server', secrets: 'secret', projects: 'project', users: 'user', groups: 'group', rbac: 'rbac', - prompts: 'prompt', templates: 'template', + prompts: 'prompt', skills: 'skill', templates: 'template', }; const kind = kindMap[dir!]; if (!kind) return null; @@ -188,6 +188,17 @@ export async function serializeAll(prisma: PrismaClient): Promise)); } + // Skills (with project + agent name) + const skills = await prisma.skill.findMany({ + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + for (const s of skills) { + files.set(resourcePath('skill', s.name), resourceToYaml('skill', s as unknown as Record)); + } + // Templates const templates = await prisma.mcpTemplate.findMany(); for (const t of templates) { diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 199c718..8bd4192 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -129,10 +129,18 @@ export class HealthProbeRunner { result = await this.probeLiveness(server, timeoutMs); } else { const readinessCheck = healthCheck as HealthCheckSpec & { tool: string }; - if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { - result = await this.probeHttp(instance, server, readinessCheck, timeoutMs); + if (server.transport === 'STDIO') { + // Route STDIO readiness through the proxy so probes hit the live + // running container rather than spawning a fresh process inside + // it. The legacy `probeStdio` (docker-exec a synthetic Node script + // that re-spawns the package binary) only worked for + // packageName-based servers — image-based STDIO servers (gitea, + // docmost) returned a fake-unhealthy "No packageName or command" + // before they even tried the tool. Going through mcpProxyService + // also means readiness failures match production failures exactly. + result = await this.probeReadinessViaProxy(server, readinessCheck, timeoutMs); } else { - result = await this.probeStdio(instance, server, readinessCheck, timeoutMs); + result = await this.probeHttp(instance, server, readinessCheck, timeoutMs); } } } catch (err) { @@ -188,6 +196,71 @@ export class HealthProbeRunner { return result; } + /** + * Readiness probe via McpProxyService — sends `tools/call` against the + * configured probe tool through the live running instance. Used by + * STDIO servers; HTTP/SSE servers go through the bespoke `probeHttp` + * paths that connect directly to the container's IP+port (those work + * fine and are kept as-is to minimise the diff in this PR). + * + * If the tool returns a JSON-RPC `error` (e.g. gitea-mcp-server's + * "token is required" when GITEA_ACCESS_TOKEN didn't resolve), we mark + * the instance unhealthy with the upstream error message. That's how + * we catch broken-by-empty-secret cases that liveness (`tools/list`) + * would otherwise pass. + */ + private async probeReadinessViaProxy( + server: McpServer, + healthCheck: HealthCheckSpec & { tool: string }, + timeoutMs: number, + ): Promise { + const start = Date.now(); + if (!this.mcpProxyService) { + return { healthy: false, latencyMs: 0, message: 'mcpProxyService not wired — cannot run readiness probe' }; + } + + const deadline = new Promise((resolve) => { + setTimeout(() => resolve({ + healthy: false, + latencyMs: timeoutMs, + message: `Readiness probe timed out after ${timeoutMs}ms`, + }), timeoutMs); + }); + + const probe = this.mcpProxyService + .execute({ + serverId: server.id, + method: 'tools/call', + params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} }, + }) + .then((response): ProbeResult => { + const latencyMs = Date.now() - start; + if (response.error) { + return { + healthy: false, + latencyMs, + message: response.error.message ?? `tools/call ${healthCheck.tool} returned error`, + }; + } + // Some servers report tool-level failures inside the result body + // (`{ isError: true, content: [...] }`) rather than as JSON-RPC + // errors. Treat that as unhealthy too. + const result = response.result as { isError?: boolean; content?: Array<{ text?: string }> } | undefined; + if (result?.isError) { + const text = result.content?.[0]?.text ?? `${healthCheck.tool} returned isError`; + return { healthy: false, latencyMs, message: text }; + } + return { healthy: true, latencyMs, message: 'ok' }; + }) + .catch((err: unknown): ProbeResult => ({ + healthy: false, + latencyMs: Date.now() - start, + message: err instanceof Error ? err.message : String(err), + })); + + return Promise.race([probe, deadline]); + } + /** * Liveness probe — sends tools/list via McpProxyService so the probe traverses * the exact code path production clients use. Works uniformly across every @@ -463,122 +536,14 @@ export class HealthProbeRunner { } } - /** - * Probe a STDIO MCP server by running `docker exec` with a disposable Node.js - * script that pipes JSON-RPC messages into the package binary. - */ - private async probeStdio( - instance: McpInstance, - server: McpServer, - healthCheck: HealthCheckSpec & { tool: string }, - timeoutMs: number, - ): Promise { - if (!instance.containerId) { - return { healthy: false, latencyMs: 0, message: 'No container ID' }; - } - - const start = Date.now(); - const packageName = server.packageName as string | null; - const command = server.command as string[] | null; - - // Determine how to spawn the MCP server inside the container - let spawnCmd: string[]; - if (packageName) { - spawnCmd = ['npx', '--prefer-offline', '-y', packageName]; - } else if (command && command.length > 0) { - spawnCmd = command; - } else { - return { healthy: false, latencyMs: 0, message: 'No packageName or command for STDIO server' }; - } - - // Build JSON-RPC messages for the health probe - const initMsg = JSON.stringify({ - jsonrpc: '2.0', id: 1, method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'mcpctl-health', version: '0.0.1' }, - }, - }); - const initializedMsg = JSON.stringify({ - jsonrpc: '2.0', method: 'notifications/initialized', - }); - const toolCallMsg = JSON.stringify({ - jsonrpc: '2.0', id: 2, method: 'tools/call', - params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} }, - }); - - // Use a Node.js inline script that: - // 1. Spawns the MCP server binary - // 2. Sends initialize + initialized + tool call via stdin - // 3. Reads responses from stdout - // 4. Exits with 0 if tool call succeeds, 1 if it fails - const spawnArgs = JSON.stringify(spawnCmd); - const probeScript = ` -const { spawn } = require('child_process'); -const args = ${spawnArgs}; -const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] }); -let output = ''; -let responded = false; -proc.stdout.on('data', d => { - output += d; - const lines = output.split('\\n'); - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.id === 2) { - responded = true; - if (msg.error) { - process.stdout.write('ERROR:' + (msg.error.message || 'unknown')); - proc.kill(); - process.exit(1); - } else { - process.stdout.write('OK'); - proc.kill(); - process.exit(0); - } - } - } catch {} - } - output = lines[lines.length - 1] || ''; -}); -proc.stderr.on('data', () => {}); -proc.on('error', e => { process.stdout.write('ERROR:' + e.message); process.exit(1); }); -proc.on('exit', (code) => { if (!responded) { process.stdout.write('ERROR:process exited ' + code); process.exit(1); } }); -setTimeout(() => { if (!responded) { process.stdout.write('ERROR:timeout'); proc.kill(); process.exit(1); } }, ${timeoutMs - 2000}); -proc.stdin.write(${JSON.stringify(initMsg)} + '\\n'); -setTimeout(() => { - proc.stdin.write(${JSON.stringify(initializedMsg)} + '\\n'); - setTimeout(() => { - proc.stdin.write(${JSON.stringify(toolCallMsg)} + '\\n'); - }, 500); -}, 500); -`.trim(); - - try { - const result = await this.orchestrator.execInContainer( - instance.containerId, - ['node', '-e', probeScript], - { timeoutMs }, - ); - - const latencyMs = Date.now() - start; - - if (result.exitCode === 0 && result.stdout.includes('OK')) { - return { healthy: true, latencyMs, message: 'ok' }; - } - - // Extract error message - const errorMatch = result.stdout.match(/ERROR:(.*)/); - const errorMsg = errorMatch?.[1] ?? (result.stderr.trim() || `exit code ${result.exitCode}`); - return { healthy: false, latencyMs, message: errorMsg }; - } catch (err) { - return { - healthy: false, - latencyMs: Date.now() - start, - message: err instanceof Error ? err.message : String(err), - }; - } - } + // Note: a previous `probeStdio` implementation existed here that ran a + // disposable Node script inside the container via `docker exec`, + // re-spawning the package binary and piping JSON-RPC into it. It only + // worked for packageName-based servers (the spawn step required an + // npx-compatible package); image-based STDIO servers like + // gitea-mcp-server fell through with "No packageName or command" and + // were always reported unhealthy for the wrong reason. STDIO readiness + // now goes through `probeReadinessViaProxy` which calls the live + // running container — same code path as production traffic — and + // surfaces the upstream error verbatim. } diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index ed0b1f9..39175ff 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -1,4 +1,4 @@ -import type { McpInstance } from '@prisma/client'; +import type { McpInstance, McpServer } from '@prisma/client'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; @@ -13,6 +13,36 @@ const RUNNER_IMAGES: Record = { /** Network for MCP server containers (matches docker-compose mcp-servers network). */ const MCP_SERVERS_NETWORK = process.env['MCPD_MCP_NETWORK'] ?? 'mcp-servers'; +/** + * Backoff schedule for instance startup failures (env resolution, container + * creation, etc). Mirrors Kubernetes-style escalation: fast retries for + * transient hiccups, then a longer pause once it's clear something is + * persistently wrong. + * + * The retry state lives on `McpInstance.metadata` (no schema migration + * needed) and is preserved across reconcile cycles by the in-place + * `retryInstance` path so attemptCount actually accumulates. + */ +const FAST_RETRY_MS = 30_000; // first 5 attempts: 30s apart +const SLOW_RETRY_MS = 5 * 60_000; // afterwards: 5 minutes +const MAX_FAST_RETRIES = 5; + +interface RetryMetadata { + error?: string; + attemptCount?: number; + lastAttemptAt?: string; + nextRetryAt?: string; + [k: string]: unknown; +} + +function readRetryMeta(instance: McpInstance): RetryMetadata { + return (instance.metadata ?? {}) as RetryMetadata; +} + +function nextDelayMs(attemptCount: number): number { + return attemptCount <= MAX_FAST_RETRIES ? FAST_RETRY_MS : SLOW_RETRY_MS; +} + export class InvalidStateError extends Error { readonly statusCode = 409; constructor(message: string) { @@ -118,8 +148,12 @@ export class InstanceService { * Reconcile ALL servers — the operator loop. * * For every server with replicas > 0, ensures the correct number of - * healthy instances exist. Cleans up ERROR instances and starts - * replacements. This is the core self-healing mechanism. + * healthy instances exist. ERROR instances are not blindly recreated: + * within their `nextRetryAt` window they're left alone (and counted + * against the replica budget so we don't churn replacements while one + * is in backoff); past their window they're retried in-place via + * `retryInstance` so attemptCount accumulates and backoff escalates + * correctly. */ async reconcileAll(): Promise<{ reconciled: number; errors: string[] }> { await this.syncStatus(); @@ -128,6 +162,8 @@ export class InstanceService { let reconciled = 0; const errors: string[] = []; + const now = Date.now(); + for (const server of servers) { if (server.replicas <= 0) continue; @@ -136,17 +172,38 @@ export class InstanceService { const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING'); const errored = instances.filter((i) => i.status === 'ERROR'); - // Clean up ERROR instances so they don't accumulate + // Partition ERROR instances by whether their backoff window has elapsed. + const dueForRetry: McpInstance[] = []; + const stillWaiting: McpInstance[] = []; for (const inst of errored) { - await this.removeOne(inst); + const meta = readRetryMeta(inst); + const ts = meta.nextRetryAt ? Date.parse(meta.nextRetryAt) : 0; + if (Number.isNaN(ts) || ts <= now) { + dueForRetry.push(inst); + } else { + stillWaiting.push(inst); + } } - // Scale up if needed - const toStart = server.replicas - active.length; + // Retry elapsed ones in-place. This preserves attemptCount across + // attempts so the 30s × 5 → 5min schedule actually escalates. + for (const inst of dueForRetry) { + await this.retryInstance(inst); + } + + // Scale up only if we don't already have enough live attempts. + // Live attempts = currently-running OR -starting + still-waiting + // (in backoff) + just-retried (now STARTING via retryInstance). + // Counting waiting + retried against the budget prevents tight + // create-fail-create churn while previous attempts work through + // their backoff schedule. + const toStart = server.replicas - active.length - stillWaiting.length - dueForRetry.length; if (toStart > 0) { for (let i = 0; i < toStart; i++) { await this.startOne(server.id); } + } + if (toStart > 0 || dueForRetry.length > 0) { reconciled++; } } catch (err) { @@ -220,7 +277,12 @@ export class InstanceService { return this.orchestrator.getContainerLogs(instance.containerId, opts); } - /** Start a single instance for a server. */ + /** + * Start a single instance for a server. Creates a fresh `STARTING` row + * and hands off to `attemptStart` for the env+container work. On + * failure, `attemptStart` marks the row `ERROR` with a backoff-aware + * `nextRetryAt`; the reconciler picks it up later via `retryInstance`. + */ private async startOne(serverId: string): Promise { const server = await this.serverRepo.findById(serverId); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); @@ -234,6 +296,49 @@ export class InstanceService { }); } + const instance = await this.instanceRepo.create({ + serverId, + status: 'STARTING', + }); + return this.attemptStart(instance, server); + } + + /** + * Re-attempt a previously-errored instance in place, preserving its + * `attemptCount` so the backoff schedule escalates correctly. Called + * by `reconcileAll` for ERROR instances whose `nextRetryAt` has elapsed. + */ + private async retryInstance(instance: McpInstance): Promise { + const server = await this.serverRepo.findById(instance.serverId); + if (!server) { + // Server was deleted underneath us — nothing to retry against. + return this.markInstanceError(instance, 'Server no longer exists'); + } + + if (server.externalUrl) { + // External servers don't need a container; the URL is the contract. + return this.instanceRepo.updateStatus(instance.id, 'RUNNING', { + metadata: { external: true, url: server.externalUrl }, + }); + } + + // Reset transient fields but keep retry counters via the metadata + // passed through `attemptStart` → `markInstanceError`. + await this.instanceRepo.updateStatus(instance.id, 'STARTING', {}); + const refreshed = (await this.instanceRepo.findById(instance.id)) ?? instance; + return this.attemptStart(refreshed, server); + } + + /** + * Run the env-resolution + container-creation steps for a STARTING + * instance. On any failure, mark the instance `ERROR` with structured + * retry metadata. Used by both initial start (`startOne`) and retry + * (`retryInstance`). + */ + private async attemptStart( + instance: McpInstance, + server: McpServer, + ): Promise { // Determine image + command based on server config: // 1. Explicit dockerImage → use as-is // 2. packageName → use runtime-specific runner image (node/python/go/...) @@ -253,11 +358,6 @@ export class InstanceService { image = server.name; } - let instance = await this.instanceRepo.create({ - serverId, - status: 'STARTING', - }); - try { const spec: ContainerSpec = { image, @@ -265,7 +365,7 @@ export class InstanceService { hostPort: null, network: MCP_SERVERS_NETWORK, labels: { - 'mcpctl.server-id': serverId, + 'mcpctl.server-id': server.id, 'mcpctl.instance-id': instance.id, }, }; @@ -283,7 +383,17 @@ export class InstanceService { } } - // Resolve env vars from inline values and secret refs + // Resolve env vars from inline values and secret refs. + // + // Failure here is FATAL for the start attempt: a container that + // boots without its declared secrets will silently mis-behave (we + // saw this with gitea-mcp-server starting up with an empty + // GITEA_ACCESS_TOKEN when OpenBao was unreachable, then reporting + // "healthy" while every authed call failed). Marking the instance + // ERROR with a backoff-aware nextRetryAt is honest; the reconciler + // will retry it in-place on the next tick whose nextRetryAt has + // elapsed. Optional/missing env vars should be modeled as `value: ""` + // entries on the server, not as silent secret-resolution failures. if (this.secretResolver) { try { const resolvedEnv = await resolveServerEnv(server, this.secretResolver); @@ -291,8 +401,8 @@ export class InstanceService { spec.env = resolvedEnv; } } catch (envErr) { - // Log but don't prevent startup — env resolution failures are non-fatal - // The container may still work if env vars are optional + const msg = envErr instanceof Error ? envErr.message : String(envErr); + return this.markInstanceError(instance, `secret resolution failed: ${msg}`); } } @@ -313,14 +423,39 @@ export class InstanceService { } // Set STARTING — syncStatus will promote to RUNNING once the container is actually ready - instance = await this.instanceRepo.updateStatus(instance.id, 'STARTING', updateFields); + return this.instanceRepo.updateStatus(instance.id, 'STARTING', updateFields); } catch (err) { - instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', { - metadata: { error: err instanceof Error ? err.message : String(err) }, - }); + return this.markInstanceError( + instance, + err instanceof Error ? err.message : String(err), + ); } + } - return instance; + /** + * Mark an instance ERROR with a backoff-aware retry schedule. The + * `attemptCount` accumulates across retries (preserved by + * `retryInstance` which reuses the same row), so the schedule + * actually escalates: 30s × 5 → 5min thereafter. + */ + private async markInstanceError( + instance: McpInstance, + error: string, + ): Promise { + const meta = readRetryMeta(instance); + const attemptCount = (typeof meta.attemptCount === 'number' ? meta.attemptCount : 0) + 1; + const delayMs = nextDelayMs(attemptCount); + const now = new Date(); + const nextRetryAt = new Date(now.getTime() + delayMs).toISOString(); + return this.instanceRepo.updateStatus(instance.id, 'ERROR', { + metadata: { + ...meta, + error, + attemptCount, + lastAttemptAt: now.toISOString(), + nextRetryAt, + }, + }); } /** Stop and remove a single instance. */ diff --git a/src/mcpd/src/services/llm.service.ts b/src/mcpd/src/services/llm.service.ts index af37176..1ee2149 100644 --- a/src/mcpd/src/services/llm.service.ts +++ b/src/mcpd/src/services/llm.service.ts @@ -57,6 +57,17 @@ export interface LlmView { * expands at request time. */ poolName: string | null; + /** + * v7: per-user RBAC scoping. NULL `ownerId` on legacy rows means + * "no recorded owner"; treated as public for visibility checks. + */ + ownerId: string | null; + /** + * v7: visibility scope. 'public' = anyone with the resource grant; + * 'private' = owner + explicit name-scoped RBAC bindings only. + * mcplocal virtuals default to 'private' on register. + */ + visibility: 'public' | 'private'; // Virtual-provider lifecycle (kind defaults to 'public' for legacy rows). kind: 'public' | 'virtual'; status: 'active' | 'inactive' | 'hibernating'; @@ -77,6 +88,49 @@ export function effectivePoolName(row: { name: string; poolName: string | null } return row.poolName !== null && row.poolName !== '' ? row.poolName : row.name; } +/** + * v7: visibility scope for the current request. The route layer + * computes this from `request.userId` + `RbacService.getAllowedScope` + * and passes it down to LlmService for filtering. When `wildcard` is + * true (admin / resource-wide `view:llms`), no filter applies — every + * row is visible regardless of owner / private flag. When false, the + * filter is `visibility='public' OR ownerId=userId OR name in allowedNames`. + * + * `null` Viewer means "skip the v7 filter entirely" — used by internal + * callers (cron sweeps, audit collectors) and tests that don't have a + * request context. + */ +export interface Viewer { + userId: string; + /** True when the caller has resource-wide `view:llms` (or admin). */ + wildcard: boolean; + /** Name-scoped grants the caller holds (e.g. `view:llms:vllm-alice`). */ + allowedNames: Set; +} + +/** + * v7: shared predicate. A row is visible to the viewer when: + * - visibility is public AND row passes RBAC layer above (caller already has resource grant), OR + * - viewer is null (internal call, no filter), OR + * - viewer has wildcard grant, OR + * - viewer is the owner, OR + * - the row's name is in viewer.allowedNames (name-scoped grant). + * + * Pure function so service tests can exercise it directly without a + * full mock RbacService. + */ +export function isLlmVisibleTo( + row: { name: string; ownerId: string | null; visibility: string }, + viewer: Viewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId !== null && row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + export class LlmService { constructor( private readonly repo: ILlmRepository, @@ -84,20 +138,25 @@ export class LlmService { private readonly verifyDeps: LlmServiceDeps = {}, ) {} - async list(): Promise { + async list(viewer: Viewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isLlmVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Llm not found: ${id}`); + // 404 (not 403) on a foreign-private row prevents id-enumeration — + // identical shape to the chat-thread + inference-task routes. + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Llm not found: ${name}`); + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${name}`); return this.toView(row); } @@ -326,6 +385,8 @@ export class LlmService { apiKeyRef, extraConfig: row.extraConfig as Record, poolName: row.poolName, + ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', kind: row.kind, status: row.status, lastHeartbeatAt: row.lastHeartbeatAt, diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 528826a..d8a4a0c 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -6,11 +6,15 @@ import type { IAgentRepository } from '../repositories/agent.repository.js'; import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js'; import { NotFoundError } from './mcp-server.service.js'; import type { PromptSummaryService } from './prompt-summary.service.js'; +import type { ResourceRevisionService } from './resource-revision.service.js'; +import type { ResourceProposalService } from './resource-proposal.service.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js'; import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js'; export class PromptService { private summaryService: PromptSummaryService | null = null; + private revisionService: ResourceRevisionService | null = null; constructor( private readonly promptRepo: IPromptRepository, @@ -24,6 +28,85 @@ export class PromptService { this.summaryService = service; } + /** + * Wire revision + proposal infrastructure (PR-2). Optional so existing + * tests that construct a bare PromptService keep working unchanged — + * when these are unset, create/update skip the revision write and + * proposal-approval is unsupported. + */ + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + setProposalService(service: ResourceProposalService): void { + // Register a 'prompt' approval handler so proposalService.approve(id) + // can dispatch to us when the proposal targets a prompt. The service + // itself is kept only via this closure binding — no field needed. + service.setHandler('prompt', async (proposal, tx, _approverUserId) => { + const body = (proposal.body ?? {}) as Record; + const content = String(body['content'] ?? ''); + const priority = typeof body['priority'] === 'number' ? body['priority'] : 5; + const linkTarget = typeof body['linkTarget'] === 'string' ? body['linkTarget'] : undefined; + // Resolve scope: project-only for now (agent-scoped proposals come with PR-3+). + const projectId = proposal.projectId ?? null; + const agentId = proposal.agentId ?? null; + + // Upsert: existing prompt with this (name, scope) → update body and bump semver; + // otherwise → create at 0.1.0. + const existing = agentId !== null + ? await tx.prompt.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } }) + : await tx.prompt.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } }); + + let promptId: string; + let newSemver: string; + if (existing !== null) { + // Bump patch for an approved-update. + newSemver = bumpSemver(existing.semver, 'patch'); + await tx.prompt.update({ + where: { id: existing.id }, + data: { content, priority, semver: newSemver }, + }); + promptId = existing.id; + } else { + // Approval-from-scratch: prompt didn't exist before this proposal. + newSemver = '0.1.0'; + const created = await tx.prompt.create({ + data: { + name: proposal.name, + content, + priority, + ...(projectId !== null ? { projectId } : {}), + ...(agentId !== null ? { agentId } : {}), + ...(linkTarget !== undefined ? { linkTarget } : {}), + semver: newSemver, + }, + }); + promptId = created.id; + } + + const { revision } = await this.revisionService!.record( + { + resourceType: 'prompt', + resourceId: promptId, + semver: newSemver, + body: { content, priority, ...(linkTarget !== undefined ? { linkTarget } : {}) }, + ...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}), + ...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}), + note: `approved proposal ${proposal.id}`, + }, + tx, + ); + + // Soft pointer to latest revision. + await tx.prompt.update({ + where: { id: promptId }, + data: { currentRevisionId: revision.id }, + }); + + return { resourceId: promptId, revisionId: revision.id }; + }); + } + /** * Run resource validation rules for a prompt. * Throws 400 if validation fails. @@ -104,6 +187,10 @@ export class PromptService { if (data.priority !== undefined) createData.priority = data.priority; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; const prompt = await this.promptRepo.create(createData); + // Record initial revision (0.1.0). Non-blocking — revision is audit, not source of truth. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', 'created').catch(() => {}); + } // Auto-generate summary/chapters (non-blocking — don't fail create if summary fails) if (this.summaryService && !data.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -113,16 +200,38 @@ export class PromptService { async updatePrompt(id: string, input: unknown): Promise { const data = UpdatePromptSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 }); + } const existing = await this.getPrompt(id); if (data.content !== undefined) { await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update'); } - const updateData: { content?: string; priority?: number } = {}; + // Resolve new semver: + // explicit > explicit-bump > auto-patch (only when content changed) + let newSemver = existing.semver; + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (data.content !== undefined) { + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const updateData: { content?: string; priority?: number; semver?: string } = {}; if (data.content !== undefined) updateData.content = data.content; if (data.priority !== undefined) updateData.priority = data.priority; + if (newSemver !== existing.semver) updateData.semver = newSemver; const prompt = await this.promptRepo.update(id, updateData); + + // Record revision when content actually changed OR semver was explicitly bumped. + const shouldRecord = data.content !== undefined || data.bump !== undefined || data.semver !== undefined; + if (this.revisionService && shouldRecord) { + this.recordPromptRevision(prompt, newSemver, data.note ?? null).catch(() => {}); + } + // Regenerate summary when content changes if (this.summaryService && data.content !== undefined && !prompt.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -130,6 +239,57 @@ export class PromptService { return prompt; } + /** + * Append a ResourceRevision row for this prompt and update its + * currentRevisionId. Best-effort — failures are swallowed because the + * audit log isn't load-bearing (the resource row's inline content is + * the source of truth). + */ + private async recordPromptRevision(prompt: Prompt, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const body: Record = { content: prompt.content, priority: prompt.priority }; + if (prompt.linkTarget !== null) body['linkTarget'] = prompt.linkTarget; + const { revision } = await this.revisionService.record({ + resourceType: 'prompt', + resourceId: prompt.id, + semver, + body, + ...(note !== null ? { note } : {}), + }); + await this.promptRepo.update(prompt.id, { currentRevisionId: revision.id }); + } + + /** + * Restore a prompt to a prior revision: writes the revision's body + * back as a NEW update (which produces a new patch-bumped revision), + * preserving the audit chain. Returns the updated prompt. + */ + async restoreRevisionForPrompt(promptId: string, revisionId: string, note?: string): Promise { + if (this.revisionService === null) { + throw new Error('Revision service not wired'); + } + const revision = await this.revisionService.getById(revisionId); + if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`); + if (revision.resourceType !== 'prompt' || revision.resourceId !== promptId) { + throw Object.assign( + new Error('Revision does not belong to this prompt'), + { statusCode: 400 }, + ); + } + const body = (revision.body ?? {}) as Record; + const content = typeof body['content'] === 'string' ? body['content'] : undefined; + const priority = typeof body['priority'] === 'number' ? body['priority'] : undefined; + if (content === undefined) { + throw Object.assign(new Error('Revision has no content to restore'), { statusCode: 400 }); + } + return this.updatePrompt(promptId, { + content, + priority, + bump: 'patch', + note: note ?? `restored from revision ${revisionId}`, + }); + } + async regenerateSummary(id: string): Promise { const prompt = await this.getPrompt(id); if (!this.summaryService) { @@ -226,6 +386,11 @@ export class PromptService { const prompt = await this.promptRepo.create(createData); + // Record the initial revision so the approved prompt has a v0.1.0 history entry. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', `approved promptrequest ${requestId}`).catch(() => {}); + } + // Delete the request await this.promptRequestRepo.delete(requestId); @@ -324,3 +489,4 @@ export class PromptService { return results; } } + diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index ddc7fe2..db76359 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -24,7 +24,17 @@ export interface OperationPermission { export type Permission = ResourcePermission | OperationPermission; export interface AllowedScope { + /** True iff the user has a resource-wide grant for this resource (no name constraint). */ wildcard: boolean; + /** + * v7: true iff the user has `*` (cross-resource admin) grant. The + * visibility filter (per-user RBAC scoping for virtual Llms/Agents) + * skips itself when this is set — admins see private rows from any + * owner, just like before v7. Plain `view:llms` resource-wide grants + * set `wildcard=true` but `isAdmin=false`, so private rows from other + * users are still hidden in the list view. + */ + isAdmin: boolean; names: Set; } @@ -97,6 +107,8 @@ export class RbacService { const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); const normalized = normalizeResource(resource); const names = new Set(); + let wildcard = false; + let isAdmin = false; for (const perm of permissions) { if (!('resource' in perm)) continue; @@ -105,12 +117,22 @@ export class RbacService { if (!actions.includes(action)) continue; const permResource = normalizeResource(perm.resource); if (permResource !== '*' && permResource !== normalized) continue; - // Unscoped binding → wildcard access to this resource - if (perm.name === undefined) return { wildcard: true, names: new Set() }; - names.add(perm.name); + // Unscoped binding → wildcard access to this resource. v7: also + // record whether the binding came from a `*` (cross-resource + // admin) grant — that's the only one that bypasses the + // visibility filter for private rows from other owners. + if (perm.name === undefined) { + wildcard = true; + if (permResource === '*') isAdmin = true; + // Don't return early — keep scanning so isAdmin can flip true + // even if a more-specific binding matched first. + } else { + names.add(perm.name); + } } - return { wildcard: false, names }; + if (wildcard) return { wildcard: true, isAdmin, names: new Set() }; + return { wildcard: false, isAdmin: false, names }; } /** diff --git a/src/mcpd/src/services/resource-proposal.service.ts b/src/mcpd/src/services/resource-proposal.service.ts new file mode 100644 index 0000000..db658af --- /dev/null +++ b/src/mcpd/src/services/resource-proposal.service.ts @@ -0,0 +1,133 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { + IResourceProposalRepository, + CreateProposalInput, + ProposalListFilter, +} from '../repositories/resource-proposal.repository.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** + * Per-resourceType handler invoked when a proposal is approved. The + * handler runs inside the approval transaction; it must apply the + * proposed body to the live resource (creating it if needed), record + * a ResourceRevision, and return the resulting revision id so the + * proposal row can link to it. + * + * Registered by the resource's own service at boot time: + * PromptService → setHandler('prompt', ...) + * SkillService → setHandler('skill', ...) // PR-3 + */ +export type ProposalApprovalHandler = ( + proposal: ResourceProposal, + tx: Prisma.TransactionClient, + approverUserId?: string, +) => Promise<{ resourceId: string; revisionId: string }>; + +export interface ProposeInput { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export class ResourceProposalService { + private readonly handlers = new Map(); + + constructor( + private readonly repo: IResourceProposalRepository, + private readonly prisma: PrismaClient, + ) {} + + /** Registered by Prompt/Skill services at construction time. */ + setHandler(resourceType: ResourceType, handler: ProposalApprovalHandler): void { + this.handlers.set(resourceType, handler); + } + + async list(filter: ProposalListFilter): Promise { + return this.repo.list(filter); + } + + async getById(id: string): Promise { + const proposal = await this.repo.findById(id); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + return proposal; + } + + async findBySession(sessionId: string, projectId?: string): Promise { + return this.repo.findBySession(sessionId, projectId); + } + + async propose(input: ProposeInput): Promise { + const data: CreateProposalInput = { + resourceType: input.resourceType, + name: input.name, + body: input.body as Prisma.InputJsonValue, + }; + if (input.projectId !== undefined) data.projectId = input.projectId; + if (input.agentId !== undefined) data.agentId = input.agentId; + if (input.createdBySession !== undefined) data.createdBySession = input.createdBySession; + if (input.createdByUserId !== undefined) data.createdByUserId = input.createdByUserId; + return this.repo.create(data); + } + + async updateBody(id: string, body: Record): Promise { + await this.getById(id); // 404 if missing + return this.repo.updateBody(id, body as Prisma.InputJsonValue); + } + + /** + * Approve the proposal: dispatch to the type-specific handler inside + * a transaction, then mark the proposal `approved` and link the + * resulting revision id. + */ + async approve(id: string, approverUserId?: string): Promise { + return this.prisma.$transaction(async (tx) => { + const proposal = await tx.resourceProposal.findUnique({ where: { id } }); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + const handler = this.handlers.get(proposal.resourceType as ResourceType); + if (handler === undefined) { + throw Object.assign( + new Error(`No approval handler registered for resource type: ${proposal.resourceType}`), + { statusCode: 500 }, + ); + } + const { revisionId } = await handler(proposal, tx, approverUserId); + return tx.resourceProposal.update({ + where: { id }, + data: { + status: 'approved', + approvedRevisionId: revisionId, + version: { increment: 1 }, + ...(approverUserId !== undefined ? {} : {}), + }, + }); + }); + } + + async reject(id: string, reviewerNote: string, _reviewerUserId?: string): Promise { + const proposal = await this.getById(id); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + return this.repo.updateStatus(id, { status: 'rejected', reviewerNote }); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/services/resource-revision.service.ts b/src/mcpd/src/services/resource-revision.service.ts new file mode 100644 index 0000000..c9ce5e2 --- /dev/null +++ b/src/mcpd/src/services/resource-revision.service.ts @@ -0,0 +1,95 @@ +import crypto from 'node:crypto'; +import type { Prisma, ResourceRevision } from '@prisma/client'; + +import type { + IResourceRevisionRepository, + ResourceType, +} from '../repositories/resource-revision.repository.js'; + +export interface RecordRevisionInput { + resourceType: ResourceType; + resourceId: string; + /** New semver — caller computes via bumpSemver / explicit override. */ + semver: string; + /** + * Snapshot of the resource body at this revision. Shape is + * resource-specific — for Prompt: `{ content, priority, linkTarget }`; + * for Skill: `{ content, files, metadata, priority, description }`. + * Stored as-is in `body` (jsonb) and used as the diff/restore source + * by the revisions API. + */ + body: Record; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export class ResourceRevisionService { + constructor(private readonly repo: IResourceRevisionRepository) {} + + /** + * sha256 of the canonicalised body. Stable across key reorderings so a + * resource that's saved twice with the same logical content produces + * the same hash on both revisions — useful for sync-side dedup. + */ + static hash(body: unknown): string { + return 'sha256:' + crypto.createHash('sha256').update(canonicalJson(body)).digest('hex'); + } + + async record( + input: RecordRevisionInput, + tx?: Prisma.TransactionClient, + ): Promise<{ revision: ResourceRevision; contentHash: string }> { + const contentHash = ResourceRevisionService.hash(input.body); + const revision = await this.repo.create( + { + resourceType: input.resourceType, + resourceId: input.resourceId, + semver: input.semver, + contentHash, + body: input.body as Prisma.InputJsonValue, + ...(input.authorUserId !== undefined ? { authorUserId: input.authorUserId } : {}), + ...(input.authorSessionId !== undefined ? { authorSessionId: input.authorSessionId } : {}), + ...(input.note !== undefined ? { note: input.note } : {}), + }, + tx, + ); + return { revision, contentHash }; + } + + async getById(id: string): Promise { + return this.repo.findById(id); + } + + async listHistory( + resourceType: ResourceType, + resourceId: string, + limit?: number, + ): Promise { + return this.repo.findHistory(resourceType, resourceId, limit); + } + + async findBySemver( + resourceType: ResourceType, + resourceId: string, + semver: string, + ): Promise { + return this.repo.findBySemver(resourceType, resourceId, semver); + } +} + +/** + * Canonical JSON: keys sorted at every object level. Used by `hash` so + * `{a:1,b:2}` and `{b:2,a:1}` produce the same digest. + */ +function canonicalJson(v: unknown): string { + if (v === null || v === undefined || typeof v !== 'object') { + return JSON.stringify(v ?? null); + } + if (Array.isArray(v)) { + return '[' + v.map(canonicalJson).join(',') + ']'; + } + const obj = v as Record; + const keys = Object.keys(obj).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}'; +} diff --git a/src/mcpd/src/services/skill.service.ts b/src/mcpd/src/services/skill.service.ts new file mode 100644 index 0000000..285acbd --- /dev/null +++ b/src/mcpd/src/services/skill.service.ts @@ -0,0 +1,393 @@ +import type { Prisma, Skill } from '@prisma/client'; + +import type { ISkillRepository } from '../repositories/skill.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import { CreateSkillSchema, UpdateSkillSchema } from '../validation/skill.schema.js'; +import { NotFoundError } from './mcp-server.service.js'; +import { ResourceRevisionService } from './resource-revision.service.js'; +import type { ResourceProposalService } from './resource-proposal.service.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; + +export class SkillService { + private revisionService: ResourceRevisionService | null = null; + + constructor( + private readonly skillRepo: ISkillRepository, + private readonly projectRepo: IProjectRepository, + private readonly agentRepo?: IAgentRepository, + ) {} + + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + /** + * Register a 'skill' approval handler with the proposal service. Mirrors + * PromptService's setup: approve = upsert skill body + record revision + + * link currentRevisionId, all inside a single transaction. + */ + setProposalService(service: ResourceProposalService): void { + service.setHandler('skill', async (proposal, tx, _approverUserId) => { + const body = (proposal.body ?? {}) as Record; + const content = String(body['content'] ?? ''); + const description = typeof body['description'] === 'string' ? body['description'] : ''; + const priority = typeof body['priority'] === 'number' ? body['priority'] : 5; + const files = (body['files'] ?? {}) as Prisma.InputJsonValue; + const metadata = (body['metadata'] ?? {}) as Prisma.InputJsonValue; + const projectId = proposal.projectId ?? null; + const agentId = proposal.agentId ?? null; + + const existing = agentId !== null + ? await tx.skill.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } }) + : await tx.skill.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } }); + + let skillId: string; + let newSemver: string; + if (existing !== null) { + newSemver = bumpSemver(existing.semver, 'patch'); + await tx.skill.update({ + where: { id: existing.id }, + data: { content, description, priority, files, metadata, semver: newSemver }, + }); + skillId = existing.id; + } else { + newSemver = '0.1.0'; + const created = await tx.skill.create({ + data: { + name: proposal.name, + content, + description, + priority, + files, + metadata, + ...(projectId !== null ? { projectId } : {}), + ...(agentId !== null ? { agentId } : {}), + semver: newSemver, + }, + }); + skillId = created.id; + } + + const { revision } = await this.revisionService!.record( + { + resourceType: 'skill', + resourceId: skillId, + semver: newSemver, + body: { content, description, priority, files, metadata }, + ...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}), + ...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}), + note: `approved proposal ${proposal.id}`, + }, + tx, + ); + + await tx.skill.update({ + where: { id: skillId }, + data: { currentRevisionId: revision.id }, + }); + + return { resourceId: skillId, revisionId: revision.id }; + }); + } + + // ── CRUD ── + + async listSkills(projectId?: string): Promise { + return this.skillRepo.findAll(projectId); + } + + async listGlobalSkills(): Promise { + return this.skillRepo.findGlobal(); + } + + async listSkillsForAgent(agentId: string): Promise { + return this.skillRepo.findByAgent(agentId); + } + + async getSkill(id: string): Promise { + const skill = await this.skillRepo.findById(id); + if (skill === null) throw new NotFoundError(`Skill not found: ${id}`); + return skill; + } + + async createSkill(input: unknown): Promise { + const data = CreateSkillSchema.parse(input); + + if (data.projectId !== undefined) { + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + } + if (data.agentId !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped skills require AgentRepository to be wired into SkillService'); + } + const agent = await this.agentRepo.findById(data.agentId); + if (agent === null) throw new NotFoundError(`Agent not found: ${data.agentId}`); + } + + const createData: { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + semver?: string; + } = { + name: data.name, + content: data.content, + }; + if (data.description !== undefined) createData.description = data.description; + if (data.files !== undefined) createData.files = data.files as Prisma.InputJsonValue; + if (data.metadata !== undefined) createData.metadata = data.metadata as Prisma.InputJsonValue; + if (data.projectId !== undefined) createData.projectId = data.projectId; + if (data.agentId !== undefined) createData.agentId = data.agentId; + if (data.priority !== undefined) createData.priority = data.priority; + if (data.semver !== undefined) createData.semver = data.semver; + + const skill = await this.skillRepo.create(createData); + + if (this.revisionService) { + this.recordSkillRevision(skill, skill.semver, 'created').catch(() => {}); + } + return skill; + } + + async updateSkill(id: string, input: unknown): Promise { + const data = UpdateSkillSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 }); + } + const existing = await this.getSkill(id); + + let newSemver = existing.semver; + const contentOrMetaChanged = + data.content !== undefined || + data.description !== undefined || + data.files !== undefined || + data.metadata !== undefined || + data.priority !== undefined; + + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (contentOrMetaChanged) { + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const updateData: { + content?: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + priority?: number; + semver?: string; + } = {}; + if (data.content !== undefined) updateData.content = data.content; + if (data.description !== undefined) updateData.description = data.description; + if (data.files !== undefined) updateData.files = data.files as Prisma.InputJsonValue; + if (data.metadata !== undefined) updateData.metadata = data.metadata as Prisma.InputJsonValue; + if (data.priority !== undefined) updateData.priority = data.priority; + if (newSemver !== existing.semver) updateData.semver = newSemver; + + const skill = await this.skillRepo.update(id, updateData); + + const shouldRecord = + contentOrMetaChanged || data.bump !== undefined || data.semver !== undefined; + if (this.revisionService && shouldRecord) { + this.recordSkillRevision(skill, newSemver, data.note ?? null).catch(() => {}); + } + return skill; + } + + /** Best-effort revision write — same shape as PromptService. */ + private async recordSkillRevision(skill: Skill, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const body: Record = { + content: skill.content, + description: skill.description, + priority: skill.priority, + files: skill.files, + metadata: skill.metadata, + }; + const { revision } = await this.revisionService.record({ + resourceType: 'skill', + resourceId: skill.id, + semver, + body, + ...(note !== null ? { note } : {}), + }); + await this.skillRepo.update(skill.id, { currentRevisionId: revision.id }); + } + + async restoreRevisionForSkill(skillId: string, revisionId: string, note?: string): Promise { + if (this.revisionService === null) { + throw new Error('Revision service not wired'); + } + const revision = await this.revisionService.getById(revisionId); + if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`); + if (revision.resourceType !== 'skill' || revision.resourceId !== skillId) { + throw Object.assign( + new Error('Revision does not belong to this skill'), + { statusCode: 400 }, + ); + } + const body = (revision.body ?? {}) as Record; + return this.updateSkill(skillId, { + content: typeof body['content'] === 'string' ? body['content'] : undefined, + description: typeof body['description'] === 'string' ? body['description'] : undefined, + priority: typeof body['priority'] === 'number' ? body['priority'] : undefined, + files: body['files'] as Record | undefined, + metadata: body['metadata'] as Record | undefined, + bump: 'patch', + note: note ?? `restored from revision ${revisionId}`, + }); + } + + async deleteSkill(id: string): Promise { + await this.getSkill(id); // 404 if missing + await this.skillRepo.delete(id); + } + + // ── Backup/restore helpers ── + + async upsertByName(data: Record): Promise { + const name = data['name'] as string; + let projectId: string | null = null; + let agentId: string | null = null; + + if (data['project'] !== undefined) { + const project = await this.projectRepo.findByName(data['project'] as string); + if (project === null) throw new NotFoundError(`Project not found: ${data['project']}`); + projectId = project.id; + } else if (data['projectId'] !== undefined) { + projectId = data['projectId'] as string; + } + + if (data['agent'] !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped skills require AgentRepository to be wired into SkillService'); + } + const agent = await this.agentRepo.findByName(data['agent'] as string); + if (agent === null) throw new NotFoundError(`Agent not found: ${data['agent']}`); + agentId = agent.id; + } else if (data['agentId'] !== undefined) { + agentId = data['agentId'] as string; + } + + if (projectId !== null && agentId !== null) { + throw Object.assign( + new Error('A skill may attach to a project XOR an agent, not both'), + { statusCode: 400 }, + ); + } + + const existing = agentId !== null + ? await this.skillRepo.findByNameAndAgent(name, agentId) + : await this.skillRepo.findByNameAndProject(name, projectId); + + if (existing !== null) { + const updateData: { + content?: string; + description?: string; + priority?: number; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + } = {}; + if (data['content'] !== undefined) updateData.content = data['content'] as string; + if (data['description'] !== undefined) updateData.description = data['description'] as string; + if (data['priority'] !== undefined) updateData.priority = data['priority'] as number; + if (data['files'] !== undefined) updateData.files = data['files'] as Prisma.InputJsonValue; + if (data['metadata'] !== undefined) updateData.metadata = data['metadata'] as Prisma.InputJsonValue; + if (Object.keys(updateData).length > 0) { + return this.skillRepo.update(existing.id, updateData); + } + return existing; + } + + const createData: { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + } = { + name, + content: (data['content'] as string) ?? '', + }; + if (data['description'] !== undefined) createData.description = data['description'] as string; + if (data['files'] !== undefined) createData.files = data['files'] as Prisma.InputJsonValue; + if (data['metadata'] !== undefined) createData.metadata = data['metadata'] as Prisma.InputJsonValue; + if (projectId !== null) createData.projectId = projectId; + if (agentId !== null) createData.agentId = agentId; + if (data['priority'] !== undefined) createData.priority = data['priority'] as number; + + return this.skillRepo.create(createData); + } + + async deleteByName(name: string): Promise { + const all = await this.skillRepo.findAll(); + const match = all.find((s) => s.name === name); + if (match === undefined) return; + await this.skillRepo.delete(match.id); + } + + /** + * Visibility for `mcpctl skills sync` (PR-5). Returns metadata only — + * no `files` or full `content` — so the diff path can quickly decide + * what's stale via contentHash + semver before fetching bodies. + */ + async getVisibleSkills(projectId?: string): Promise> { + const skills = await this.skillRepo.findAll(projectId); + const out: Array<{ + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; + }> = []; + for (const s of skills) { + let scope: 'project' | 'global' | 'agent' = 'global'; + if (s.projectId !== null) scope = 'project'; + else if (s.agentId !== null) scope = 'agent'; + // Compute contentHash on the fly from the body shape that + // `mcpctl skills sync` will write to disk. The server hashes the + // canonicalised JSON; the client hashes the same JSON shape it + // receives, and they match. (Cheap — sha256 of a few KB.) + const contentHash = ResourceRevisionService.hash({ + content: s.content, + description: s.description, + priority: s.priority, + files: s.files, + metadata: s.metadata, + }); + out.push({ + id: s.id, + name: s.name, + description: s.description, + semver: s.semver, + contentHash, + metadata: s.metadata, + scope, + }); + } + return out; + } +} diff --git a/src/mcpd/src/services/user.service.ts b/src/mcpd/src/services/user.service.ts index 81725fb..e8b2a4a 100644 --- a/src/mcpd/src/services/user.service.ts +++ b/src/mcpd/src/services/user.service.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcrypt'; import type { IUserRepository, SafeUser } from '../repositories/user.repository.js'; -import { CreateUserSchema } from '../validation/user.schema.js'; +import { CreateUserSchema, PasswordSchema } from '../validation/user.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; const SALT_ROUNDS = 10; @@ -54,6 +54,27 @@ export class UserService { await this.userRepo.delete(id); } + /** Verify a plaintext password against the stored hash for a user id. */ + async verifyPassword(id: string, password: string): Promise { + const user = await this.userRepo.findByIdWithHash(id); + if (user === null) { + throw new NotFoundError(`User not found: ${id}`); + } + // Locked accounts (e.g. system user with `!locked`) never verify. + if (!user.passwordHash || user.passwordHash.startsWith('!') || user.passwordHash.startsWith('__')) { + return false; + } + return bcrypt.compare(password, user.passwordHash); + } + + /** Hash + store a new password for a user. Throws NotFoundError if absent. */ + async setPassword(id: string, newPassword: string): Promise { + PasswordSchema.parse(newPassword); + await this.getById(id); // 404 if missing + const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); + await this.userRepo.update(id, { passwordHash }); + } + async count(): Promise { return this.userRepo.count(); } diff --git a/src/mcpd/src/services/virtual-llm.service.ts b/src/mcpd/src/services/virtual-llm.service.ts index 7a6a8b2..594435f 100644 --- a/src/mcpd/src/services/virtual-llm.service.ts +++ b/src/mcpd/src/services/virtual-llm.service.ts @@ -58,6 +58,12 @@ export interface RegisterProviderInput { * shares the `poolName` with siblings. */ poolName?: string; + /** + * v7: per-user RBAC scoping. When omitted, virtuals default to + * 'private' (visible only to the publishing user). Set explicitly + * to 'public' for org-wide sharing. + */ + visibility?: 'public' | 'private'; } export interface RegisterResult { @@ -134,7 +140,7 @@ export interface EnqueueInferOptions { } export interface IVirtualLlmService { - register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise; + register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise; heartbeat(providerSessionId: string): Promise; bindSession(providerSessionId: string, handle: VirtualSessionHandle): void; unbindSession(providerSessionId: string): Promise; @@ -193,7 +199,7 @@ export class VirtualLlmService implements IVirtualLlmService { private readonly resolveOwner: () => string = () => 'system', ) {} - async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise { + async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise { const sessionId = input.providerSessionId ?? randomUUID(); const now = new Date(); const llms: Llm[] = []; @@ -210,6 +216,12 @@ export class VirtualLlmService implements IVirtualLlmService { description: p.description ?? '', ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: virtuals default to private ownership. The publisher + // (passed through from the route's authenticated userId) + // owns the row; mcplocal's defaulting + the publisher's + // explicit override land here. + ownerId: input.ownerId ?? null, + visibility: p.visibility ?? 'private', kind: 'virtual', providerSessionId: sessionId, status: initialStatus, @@ -244,6 +256,11 @@ export class VirtualLlmService implements IVirtualLlmService { ...(p.description !== undefined ? { description: p.description } : {}), ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: only update visibility on sticky reconnect when the + // publisher explicitly sent it — operators may have flipped a + // virtual to public manually via `mcpctl edit llm`, and we + // don't want a routine reconnect to clobber that. + ...(p.visibility !== undefined ? { visibility: p.visibility } : {}), kind: 'virtual', providerSessionId: sessionId, status: initialStatus, diff --git a/src/mcpd/src/utils/semver.ts b/src/mcpd/src/utils/semver.ts new file mode 100644 index 0000000..b89f944 --- /dev/null +++ b/src/mcpd/src/utils/semver.ts @@ -0,0 +1,56 @@ +/** + * Tiny semver bumper for resource versions. mcpctl is the source of truth + * for prompts and skills; their versions are advisory rather than + * dependency-resolved, so we don't need a full semver library — just patch + * `0.1.0` → `0.1.1` on every save and let authors bump major/minor when + * something material changes. + * + * Anything that isn't a strict `MAJOR.MINOR.PATCH` (digits-only, three + * parts) is treated as invalid and replaced with `'0.1.0'`. We don't + * support pre-release / build-metadata suffixes for resources; if that + * ever becomes useful we can swap in `semver` from npm without changing + * call sites. + */ + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +export type BumpKind = 'major' | 'minor' | 'patch'; + +export function isValidSemver(s: string): boolean { + return SEMVER_RE.test(s); +} + +export function bumpSemver(current: string, kind: BumpKind): string { + const m = SEMVER_RE.exec(current); + if (m === null) { + // Caller passed something we can't parse — start over rather than + // silently corrupt. Prefer this to throwing because the call path + // (PromptService.update) would then propagate failure across the + // entire transaction including the body update. + return '0.1.0'; + } + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + switch (kind) { + case 'major': + return `${String(major + 1)}.0.0`; + case 'minor': + return `${String(major)}.${String(minor + 1)}.0`; + case 'patch': + return `${String(major)}.${String(minor)}.${String(patch + 1)}`; + } +} + +/** Compare a < b: returns -1, 0, +1 by major/minor/patch. Invalid → 0. */ +export function compareSemver(a: string, b: string): number { + const ma = SEMVER_RE.exec(a); + const mb = SEMVER_RE.exec(b); + if (ma === null || mb === null) return 0; + for (let i = 1; i <= 3; i++) { + const ai = Number(ma[i]); + const bi = Number(mb[i]); + if (ai !== bi) return ai < bi ? -1 : 1; + } + return 0; +} diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index dc1e56c..42787ad 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -16,9 +16,18 @@ export const CreatePromptSchema = z { message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] }, ); +const SEMVER_RE = /^\d+\.\d+\.\d+$/; + export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), priority: z.number().int().min(1).max(10).optional(), + // Semver controls (PR-2). At most one of `semver` and `bump` may be + // set; service layer rejects both. If neither is set, content changes + // auto-bump patch. + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + // Free-form note attached to the resulting ResourceRevision row. + note: z.string().max(500).optional(), // linkTarget intentionally excluded — links are immutable }); diff --git a/src/mcpd/src/validation/skill.schema.ts b/src/mcpd/src/validation/skill.schema.ts new file mode 100644 index 0000000..7dc9655 --- /dev/null +++ b/src/mcpd/src/validation/skill.schema.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * Typed Skill metadata. Stored opaquely as `Skill.metadata` Json in the + * database; validated app-layer when callers pass it through CreateSkill/ + * UpdateSkill. The fields below are the ones the sync command (PR-5) will + * actually act on: + * + * - `hooks` — declarative SessionStart / PreToolUse / PostToolUse + * entries that mcpctl skills sync registers in + * ~/.claude/settings.json with `_mcpctl_managed: true`. + * - `mcpServers` — upstream MCP server dependencies the skill needs; + * sync auto-attaches them to the project (corporate + * trust model — no consent prompt). + * - `postInstall` — relative path inside `files{}` to a script that + * sync runs as the user when the skill's contentHash + * first appears or changes. 60-s default timeout; + * audit event emitted back to mcpd. + * - `preUninstall` — symmetric to postInstall, runs on orphan removal. + * - `postInstallTimeoutSec` — per-skill override for the 60-s default. + * + * .passthrough() so unknown fields survive the round-trip — forward + * compatibility for follow-on metadata additions. + */ + +const ManagedHookEntrySchema = z.object({ + type: z.literal('command'), + command: z.string().min(1).max(4000), + timeout: z.number().int().min(1).max(3600).optional(), +}).passthrough(); + +const HooksSchema = z.object({ + PreToolUse: z.array(ManagedHookEntrySchema).optional(), + PostToolUse: z.array(ManagedHookEntrySchema).optional(), + SessionStart: z.array(ManagedHookEntrySchema).optional(), + Stop: z.array(ManagedHookEntrySchema).optional(), + SubagentStop: z.array(ManagedHookEntrySchema).optional(), + Notification: z.array(ManagedHookEntrySchema).optional(), +}).strict().optional(); + +const McpServerDepSchema = z.object({ + name: z.string().regex(NAME_RE), + fromTemplate: z.string().min(1), + project: z.string().regex(NAME_RE).optional(), +}).strict(); + +export const SkillMetadataSchema = z.object({ + hooks: HooksSchema, + mcpServers: z.array(McpServerDepSchema).optional(), + postInstall: z.string().min(1).max(500).optional(), + preUninstall: z.string().min(1).max(500).optional(), + postInstallTimeoutSec: z.number().int().min(1).max(600).optional(), +}).passthrough(); + +export const CreateSkillSchema = z + .object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(200_000), + description: z.string().max(500).optional(), + files: z.record(z.string()).optional(), + metadata: SkillMetadataSchema.optional(), + projectId: z.string().optional(), + agentId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + }) + .refine( + (data) => !(data.projectId !== undefined && data.agentId !== undefined), + { message: 'A skill may attach to a project XOR an agent, not both', path: ['agentId'] }, + ); + +export const UpdateSkillSchema = z.object({ + content: z.string().min(1).max(200_000).optional(), + description: z.string().max(500).optional(), + files: z.record(z.string()).optional(), + metadata: SkillMetadataSchema.optional(), + priority: z.number().int().min(1).max(10).optional(), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + note: z.string().max(500).optional(), +}); + +export type CreateSkillInput = z.infer; +export type UpdateSkillInput = z.infer; +export type SkillMetadata = z.infer; diff --git a/src/mcpd/src/validation/user.schema.ts b/src/mcpd/src/validation/user.schema.ts index 3db8be4..fc2f203 100644 --- a/src/mcpd/src/validation/user.schema.ts +++ b/src/mcpd/src/validation/user.schema.ts @@ -1,14 +1,28 @@ import { z } from 'zod'; +/** Shared password rules — reused by create, self-change, and admin-reset paths. */ +export const PasswordSchema = z.string().min(8).max(128); + export const CreateUserSchema = z.object({ email: z.string().email(), - password: z.string().min(8).max(128), + password: PasswordSchema, name: z.string().max(100).optional(), }); export const UpdateUserSchema = z.object({ name: z.string().max(100).optional(), - password: z.string().min(8).max(128).optional(), + password: PasswordSchema.optional(), +}); + +/** Self-service change: requires the current password as proof. */ +export const ChangeOwnPasswordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: PasswordSchema, +}); + +/** Admin reset of another user: no current password needed. */ +export const ResetPasswordSchema = z.object({ + newPassword: PasswordSchema, }); export type CreateUserInput = z.infer; diff --git a/src/mcpd/tests/auth-bootstrap.test.ts b/src/mcpd/tests/auth-bootstrap.test.ts index 051bff6..8a043ec 100644 --- a/src/mcpd/tests/auth-bootstrap.test.ts +++ b/src/mcpd/tests/auth-bootstrap.test.ts @@ -92,6 +92,7 @@ interface MockDeps { getByName: ReturnType; update: ReturnType; delete: ReturnType; + upsertByName: ReturnType; }; rbacService: { canAccess: ReturnType; @@ -131,6 +132,7 @@ function createMockDeps(): MockDeps { getByName: vi.fn(async () => null), update: vi.fn(async () => makeRbacDef()), delete: vi.fn(async () => {}), + upsertByName: vi.fn(async () => makeRbacDef()), }, rbacService: { canAccess: vi.fn(async () => false), @@ -223,6 +225,13 @@ describe('Auth Bootstrap', () => { // Verify auto-login was called expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123'); + + // Verify the admin also got the default self password-change permission + expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({ + name: 'self-user-1', + subjects: [{ kind: 'User', name: 'admin@example.com' }], + roleBindings: [{ role: 'run', action: 'set-own-password' }], + }); }); it('passes name when provided', async () => { diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index 88fc73e..c19b4bd 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -334,20 +334,93 @@ describe('InstanceService', () => { expect(instanceRepo.create).not.toHaveBeenCalled(); }); - it('cleans up ERROR instances and creates replacements', async () => { + it('retries ERROR instances in-place when their backoff has elapsed (no delete, no new row)', async () => { const server = makeServer({ id: 'srv-1', replicas: 1 }); vi.mocked(serverRepo.findAll).mockResolvedValue([server]); vi.mocked(serverRepo.findById).mockResolvedValue(server); + // ERROR instance with no nextRetryAt → retry is due immediately. vi.mocked(instanceRepo.findAll).mockResolvedValue([ makeInstance({ id: 'inst-dead', serverId: 'srv-1', status: 'ERROR', containerId: 'ctr-dead' }), ]); const result = await service.reconcileAll(); - // Should delete ERROR instance and create a new one + // Retry-in-place semantics: don't delete the row, don't create a + // replacement. attemptCount needs to live on the same row so the + // backoff schedule can actually escalate. + expect(instanceRepo.delete).not.toHaveBeenCalled(); + expect(instanceRepo.create).not.toHaveBeenCalled(); + // retryInstance flips the row STARTING before attemptStart runs. + expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-dead', 'STARTING', expect.anything()); expect(result.reconciled).toBe(1); - expect(instanceRepo.delete).toHaveBeenCalledWith('inst-dead'); - expect(instanceRepo.create).toHaveBeenCalled(); + }); + + it('leaves ERROR instances alone while their nextRetryAt is in the future', async () => { + const server = makeServer({ id: 'srv-1', replicas: 1 }); + vi.mocked(serverRepo.findAll).mockResolvedValue([server]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + const futureRetry = new Date(Date.now() + 60_000).toISOString(); + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ + id: 'inst-waiting', + serverId: 'srv-1', + status: 'ERROR', + metadata: { nextRetryAt: futureRetry, attemptCount: 2 }, + }), + ]); + + const result = await service.reconcileAll(); + + // Within the backoff window the reconciler must not delete the row, + // not retry it, and not spawn a replacement (counting it against + // the replica budget is what prevents tight create-fail-create churn). + expect(instanceRepo.delete).not.toHaveBeenCalled(); + expect(instanceRepo.create).not.toHaveBeenCalled(); + expect(orchestrator.createContainer).not.toHaveBeenCalled(); + expect(result.reconciled).toBe(0); + }); + + it('escalates the backoff: attemptCount + nextRetryAt persist on retry failures', async () => { + const server = makeServer({ id: 'srv-1', replicas: 1 }); + vi.mocked(serverRepo.findAll).mockResolvedValue([server]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + + // Fail container creation so attemptStart goes down the markInstanceError path. + vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('boom')); + + // Existing ERROR instance with attemptCount=2 (so the next failure + // produces attemptCount=3, still inside the fast-retry window). + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ + id: 'inst-1', + serverId: 'srv-1', + status: 'ERROR', + metadata: { error: 'previous failure', attemptCount: 2, nextRetryAt: new Date(Date.now() - 1000).toISOString() }, + }), + ]); + // retryInstance refreshes via findById; let it return the same row. + vi.mocked(instanceRepo.findById).mockImplementation(async () => makeInstance({ + id: 'inst-1', + serverId: 'srv-1', + status: 'STARTING', + metadata: { error: 'previous failure', attemptCount: 2, nextRetryAt: new Date(Date.now() - 1000).toISOString() }, + })); + + await service.reconcileAll(); + + // Look at the last updateStatus call — it should be the ERROR transition + // with attemptCount bumped to 3. + const errorCalls = vi.mocked(instanceRepo.updateStatus).mock.calls.filter( + (c) => c[1] === 'ERROR', + ); + expect(errorCalls.length).toBeGreaterThan(0); + const lastErrorCall = errorCalls[errorCalls.length - 1]!; + const meta = (lastErrorCall[2] as { metadata?: Record } | undefined)?.metadata; + expect(meta).toBeDefined(); + expect((meta as Record)['attemptCount']).toBe(3); + expect((meta as Record)['nextRetryAt']).toBeTypeOf('string'); + // Reason should reference the boom we threw. + expect(String((meta as Record)['error'])).toContain('boom'); }); it('reconciles multiple servers independently', async () => { diff --git a/src/mcpd/tests/prompt-routes.test.ts b/src/mcpd/tests/prompt-routes.test.ts index 42a483c..9282327 100644 --- a/src/mcpd/tests/prompt-routes.test.ts +++ b/src/mcpd/tests/prompt-routes.test.ts @@ -17,10 +17,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -316,9 +319,11 @@ describe('Prompt routes', () => { payload: { content: 'new content', projectId: 'proj-evil' }, }); - // Should succeed but ignore projectId — UpdatePromptSchema strips it + // Should succeed but ignore projectId — UpdatePromptSchema strips it. + // PR-2: a content change auto-bumps the patch number, so the update + // call also carries the new semver. expect(res.statusCode).toBe(200); - expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' }); + expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content', semver: '0.1.1' }); // projectId must NOT be in the update call const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1]; expect(updateArg).not.toHaveProperty('projectId'); diff --git a/src/mcpd/tests/services/health-probe.test.ts b/src/mcpd/tests/services/health-probe.test.ts index 9c00b10..072bef9 100644 --- a/src/mcpd/tests/services/health-probe.test.ts +++ b/src/mcpd/tests/services/health-probe.test.ts @@ -192,25 +192,28 @@ describe('HealthProbeRunner', () => { expect(serverRepo.findById).not.toHaveBeenCalled(); }); - it('probes STDIO instance with exec and marks healthy on success', async () => { + it('probes STDIO instance via mcpProxyService and marks healthy on success', async () => { const instance = makeInstance(); const server = makeServer(); vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 0, - stdout: 'OK', - stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, + result: { content: [{ type: 'text', text: 'ok' }] }, }); await runner.tick(); - expect(orchestrator.execInContainer).toHaveBeenCalledWith( - 'container-abc', - expect.arrayContaining(['node', '-e']), - expect.objectContaining({ timeoutMs: 10000 }), - ); + // STDIO readiness now goes through the proxy (the live container), + // not via docker-exec into a synthetic spawn — see comment on + // probeReadinessViaProxy for why. + expect(orchestrator.execInContainer).not.toHaveBeenCalled(); + expect(mcpProxyService.execute).toHaveBeenCalledWith({ + serverId: 'srv-1', + method: 'tools/call', + params: { name: 'list_datasources', arguments: {} }, + }); expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', @@ -225,6 +228,57 @@ describe('HealthProbeRunner', () => { ); }); + it('marks unhealthy when proxy returns a JSON-RPC error (e.g. broken-secret auth failure)', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'get_me', intervalSeconds: 0, failureThreshold: 1 } as McpServer['healthCheck'], + }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, + error: { code: -32603, message: 'token is required' }, + }); + + await runner.tick(); + + expect(instanceRepo.updateStatus).toHaveBeenCalledWith( + 'inst-1', + 'RUNNING', + expect.objectContaining({ + healthStatus: 'unhealthy', + events: expect.arrayContaining([ + expect.objectContaining({ type: 'Warning', message: expect.stringContaining('token is required') }), + ]), + }), + ); + }); + + it('marks unhealthy when proxy returns a tool-level error in result.isError', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'get_me', intervalSeconds: 0, failureThreshold: 1 } as McpServer['healthCheck'], + }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, + result: { isError: true, content: [{ type: 'text', text: 'auth failed: token is required' }] }, + }); + + await runner.tick(); + + const events = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]?.events as Array<{ message: string }> | undefined; + expect(events?.[events.length - 1]?.message).toContain('auth failed'); + expect(instanceRepo.updateStatus).toHaveBeenCalledWith( + 'inst-1', + 'RUNNING', + expect.objectContaining({ healthStatus: 'unhealthy' }), + ); + }); + it('marks unhealthy after failureThreshold consecutive failures', async () => { const instance = makeInstance(); const healthCheck: HealthCheckSpec = { @@ -237,10 +291,9 @@ describe('HealthProbeRunner', () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 1, - stdout: 'ERROR:connection refused', - stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, + error: { code: -32603, message: 'connection refused' }, }); // First failure → degraded @@ -274,15 +327,15 @@ describe('HealthProbeRunner', () => { vi.mocked(serverRepo.findById).mockResolvedValue(server); // Two failures - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 1, stdout: 'ERROR:fail', stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'fail' }, }); await runner.tick(); await runner.tick(); // Then success — should reset to healthy - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 0, stdout: 'OK', stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, result: {}, }); await runner.tick(); @@ -290,13 +343,16 @@ describe('HealthProbeRunner', () => { expect(lastCall?.[2]).toEqual(expect.objectContaining({ healthStatus: 'healthy' })); }); - it('handles exec timeout as failure', async () => { + it('handles probe timeout as failure', async () => { const instance = makeInstance(); - const server = makeServer(); + const server = makeServer({ + healthCheck: { tool: 'list_datasources', intervalSeconds: 0, timeoutSeconds: 0.05, failureThreshold: 3 } as unknown as McpServer['healthCheck'], + }); vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); - vi.mocked(orchestrator.execInContainer).mockRejectedValue(new Error('Exec timed out after 10000ms')); + // Hang forever — the probe's internal deadline should fire instead. + vi.mocked(mcpProxyService.execute).mockImplementation(() => new Promise(() => { /* never resolves */ })); await runner.tick(); @@ -323,8 +379,8 @@ describe('HealthProbeRunner', () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 0, stdout: 'OK', stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, result: {}, }); await runner.tick(); @@ -343,17 +399,17 @@ describe('HealthProbeRunner', () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 0, stdout: 'OK', stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, result: {}, }); // First tick: should probe await runner.tick(); - expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1); + expect(mcpProxyService.execute).toHaveBeenCalledTimes(1); // Second tick immediately: should skip (300s interval not elapsed) await runner.tick(); - expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1); + expect(mcpProxyService.execute).toHaveBeenCalledTimes(1); }); it('cleans up probe states for removed instances', async () => { @@ -364,9 +420,12 @@ describe('HealthProbeRunner', () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, result: {}, + }); await runner.tick(); - expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1); + expect(mcpProxyService.execute).toHaveBeenCalledTimes(1); // Instance removed vi.mocked(instanceRepo.findAll).mockResolvedValue([]); @@ -375,7 +434,7 @@ describe('HealthProbeRunner', () => { // Re-add same instance — should probe again (state was cleaned) vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); await runner.tick(); - expect(orchestrator.execInContainer).toHaveBeenCalledTimes(2); + expect(mcpProxyService.execute).toHaveBeenCalledTimes(2); }); it('skips STDIO instances without containerId', async () => { @@ -397,8 +456,8 @@ describe('HealthProbeRunner', () => { arguments: {}, }; - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 0, stdout: 'OK', stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, result: {}, }); const result = await runner.probeInstance(instance, server, healthCheck); @@ -407,15 +466,14 @@ describe('HealthProbeRunner', () => { expect(result.message).toBe('ok'); }); - it('handles STDIO exec failure with error message', async () => { + it('surfaces upstream JSON-RPC error message verbatim', async () => { const instance = makeInstance(); const server = makeServer(); const healthCheck: HealthCheckSpec = { tool: 'list_datasources', arguments: {} }; - vi.mocked(orchestrator.execInContainer).mockResolvedValue({ - exitCode: 1, - stdout: 'ERROR:ECONNREFUSED 10.0.0.1:3000', - stderr: '', + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', id: 1, + error: { code: -32603, message: 'ECONNREFUSED 10.0.0.1:3000' }, }); const result = await runner.probeInstance(instance, server, healthCheck); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts index 4bb550d..bd1515e 100644 --- a/src/mcpd/tests/services/prompt-service.test.ts +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -11,10 +11,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -175,7 +178,9 @@ describe('PromptService', () => { it('should update prompt content', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.updatePrompt('prompt-1', { content: 'updated' }); - expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' }); + // Auto-patch bump on content change (PR-2): updatePrompt now also + // emits the new semver in the same update call. + expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated', semver: '0.1.1' }); }); it('should throw for missing prompt', async () => { diff --git a/src/mcpd/tests/system-prompt-validation.test.ts b/src/mcpd/tests/system-prompt-validation.test.ts index ac23082..328a34e 100644 --- a/src/mcpd/tests/system-prompt-validation.test.ts +++ b/src/mcpd/tests/system-prompt-validation.test.ts @@ -101,10 +101,13 @@ describe('System Prompt Validation', () => { }); describe('getSystemPromptNames', () => { - it('includes all 11 system prompts (5 gate + 6 LLM)', () => { + it('includes all 12 system prompts (6 gate + 6 LLM)', () => { const names = getSystemPromptNames(); expect(names).toContain('gate-instructions'); expect(names).toContain('gate-encouragement'); + // PR-4: pairs with the propose-learnings global skill — sits in the + // gating bundle so Claude considers proposing back to mcpd. + expect(names).toContain('gate-encouragement-propose'); expect(names).toContain('gate-intercept-preamble'); expect(names).toContain('gate-session-active'); expect(names).toContain('session-greeting'); @@ -114,7 +117,7 @@ describe('System Prompt Validation', () => { expect(names).toContain('llm-gate-context-selector'); expect(names).toContain('llm-summarize'); expect(names).toContain('llm-paginate-titles'); - expect(names.length).toBe(11); + expect(names.length).toBe(12); }); }); diff --git a/src/mcpd/tests/users-password.test.ts b/src/mcpd/tests/users-password.test.ts new file mode 100644 index 0000000..c25e704 --- /dev/null +++ b/src/mcpd/tests/users-password.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { registerUserRoutes } from '../src/routes/users.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; + +/** + * Unit tests for the password endpoints. RBAC/auth is enforced by global hooks + * in main.ts (not registerUserRoutes), so here we set request.userId via a test + * preHandler from the `x-test-user` header to drive the route logic directly. + */ + +let app: FastifyInstance; + +function makeUser(over?: Partial<{ id: string; email: string }>) { + return { id: 'user-1', email: 'me@example.com', name: null, role: 'user', provider: 'local', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...over }; +} + +function makeDeps() { + return { + userService: { + list: vi.fn(async () => []), + getById: vi.fn(async (id: string) => makeUser({ id })), + getByEmail: vi.fn(async (email: string) => makeUser({ email, id: 'user-2' })), + create: vi.fn(async () => makeUser({ id: 'new-user' })), + delete: vi.fn(async () => {}), + verifyPassword: vi.fn(async () => true), + setPassword: vi.fn(async () => {}), + }, + rbacDefinitionService: { + upsertByName: vi.fn(async () => ({})), + }, + prisma: { + secret: { findUnique: vi.fn(async () => null) }, // no settings secret → default ON + }, + }; +} + +type Deps = ReturnType; + +async function createApp(deps: Deps): Promise { + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + // Emulate the global auth hook: set userId from a test header when present. + app.addHook('preHandler', async (request) => { + const u = request.headers['x-test-user']; + if (typeof u === 'string') request.userId = u; + }); + registerUserRoutes(app, deps as unknown as Parameters[1]); + await app.ready(); + return app; +} + +afterEach(async () => { await app?.close(); }); + +describe('POST /api/v1/users/me/password (self-service)', () => { + let deps: Deps; + beforeEach(() => { deps = makeDeps(); }); + + it('changes password when current password verifies', async () => { + await createApp(deps); + const res = await app.inject({ + method: 'POST', url: '/api/v1/users/me/password', + headers: { 'x-test-user': 'user-1' }, + payload: { currentPassword: 'oldpass12', newPassword: 'newpass345' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true }); + expect(deps.userService.verifyPassword).toHaveBeenCalledWith('user-1', 'oldpass12'); + expect(deps.userService.setPassword).toHaveBeenCalledWith('user-1', 'newpass345'); + }); + + it('rejects with 401 when current password is wrong', async () => { + deps.userService.verifyPassword.mockResolvedValueOnce(false); + await createApp(deps); + const res = await app.inject({ + method: 'POST', url: '/api/v1/users/me/password', + headers: { 'x-test-user': 'user-1' }, + payload: { currentPassword: 'wrong', newPassword: 'newpass345' }, + }); + expect(res.statusCode).toBe(401); + expect(deps.userService.setPassword).not.toHaveBeenCalled(); + }); + + it('rejects with 401 when unauthenticated', async () => { + await createApp(deps); + const res = await app.inject({ + method: 'POST', url: '/api/v1/users/me/password', + payload: { currentPassword: 'x', newPassword: 'newpass345' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('rejects with 400 when new password too short', async () => { + await createApp(deps); + const res = await app.inject({ + method: 'POST', url: '/api/v1/users/me/password', + headers: { 'x-test-user': 'user-1' }, + payload: { currentPassword: 'oldpass12', newPassword: 'short' }, + }); + expect(res.statusCode).toBe(400); + expect(deps.userService.setPassword).not.toHaveBeenCalled(); + }); +}); + +describe('PUT /api/v1/users/:id/password (admin reset)', () => { + let deps: Deps; + beforeEach(() => { deps = makeDeps(); }); + + it('resets by id without requiring a current password', async () => { + await createApp(deps); + const res = await app.inject({ + method: 'PUT', url: '/api/v1/users/user-9/password', + payload: { newPassword: 'resetpass99' }, + }); + expect(res.statusCode).toBe(200); + expect(deps.userService.setPassword).toHaveBeenCalledWith('user-9', 'resetpass99'); + expect(deps.userService.verifyPassword).not.toHaveBeenCalled(); + }); + + it('resolves an email target to its id before resetting', async () => { + await createApp(deps); + const res = await app.inject({ + method: 'PUT', url: `/api/v1/users/${encodeURIComponent('other@example.com')}/password`, + payload: { newPassword: 'resetpass99' }, + }); + expect(res.statusCode).toBe(200); + expect(deps.userService.getByEmail).toHaveBeenCalledWith('other@example.com'); + expect(deps.userService.setPassword).toHaveBeenCalledWith('user-2', 'resetpass99'); + }); +}); + +describe('POST /api/v1/users — self-permission seeding', () => { + let deps: Deps; + beforeEach(() => { deps = makeDeps(); }); + + it('seeds the self password permission when the setting is ON (default)', async () => { + await createApp(deps); + const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } }); + expect(res.statusCode).toBe(201); + expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({ + name: 'self-new-user', + subjects: [{ kind: 'User', name: 'me@example.com' }], + roleBindings: [{ role: 'run', action: 'set-own-password' }], + }); + }); + + it('does NOT seed when the setting is disabled', async () => { + deps.prisma.secret.findUnique.mockResolvedValueOnce({ name: 'mcpctl-system-settings', data: { allowSelfPasswordChange: false } } as never); + await createApp(deps); + const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } }); + expect(res.statusCode).toBe(201); + expect(deps.rbacDefinitionService.upsertByName).not.toHaveBeenCalled(); + }); +}); diff --git a/src/mcpd/tests/utils/semver.test.ts b/src/mcpd/tests/utils/semver.test.ts new file mode 100644 index 0000000..088cae8 --- /dev/null +++ b/src/mcpd/tests/utils/semver.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { bumpSemver, compareSemver, isValidSemver } from '../../src/utils/semver.js'; + +describe('bumpSemver', () => { + it('bumps patch', () => { + expect(bumpSemver('0.1.0', 'patch')).toBe('0.1.1'); + expect(bumpSemver('1.2.3', 'patch')).toBe('1.2.4'); + }); + + it('bumps minor and resets patch', () => { + expect(bumpSemver('0.1.5', 'minor')).toBe('0.2.0'); + expect(bumpSemver('1.2.3', 'minor')).toBe('1.3.0'); + }); + + it('bumps major and resets minor + patch', () => { + expect(bumpSemver('0.1.5', 'major')).toBe('1.0.0'); + expect(bumpSemver('1.2.3', 'major')).toBe('2.0.0'); + }); + + it('falls back to 0.1.0 on invalid input', () => { + expect(bumpSemver('not-a-semver', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0.0-beta', 'patch')).toBe('0.1.0'); + expect(bumpSemver('', 'patch')).toBe('0.1.0'); + }); +}); + +describe('compareSemver', () => { + it('returns 0 for equal', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a < b at any field', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); + expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); + }); + + it('returns +1 when a > b at any field', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + expect(compareSemver('1.3.0', '1.2.3')).toBe(1); + expect(compareSemver('2.0.0', '1.2.3')).toBe(1); + }); + + it('compares numerically (10 > 9, not lex)', () => { + expect(compareSemver('0.10.0', '0.9.0')).toBe(1); + expect(compareSemver('0.9.0', '0.10.0')).toBe(-1); + }); + + it('returns 0 for invalid input rather than throwing', () => { + expect(compareSemver('bad', '1.0.0')).toBe(0); + expect(compareSemver('1.0.0', 'bad')).toBe(0); + }); +}); + +describe('isValidSemver', () => { + it('accepts MAJOR.MINOR.PATCH digits', () => { + expect(isValidSemver('0.0.0')).toBe(true); + expect(isValidSemver('1.2.3')).toBe(true); + expect(isValidSemver('999.999.999')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isValidSemver('1.2')).toBe(false); + expect(isValidSemver('1.2.3.4')).toBe(false); + expect(isValidSemver('v1.2.3')).toBe(false); + expect(isValidSemver('1.2.3-beta')).toBe(false); + expect(isValidSemver('')).toBe(false); + }); +}); diff --git a/src/mcpd/tests/virtual-llm-routes.test.ts b/src/mcpd/tests/virtual-llm-routes.test.ts index a4477bc..1e4c47f 100644 --- a/src/mcpd/tests/virtual-llm-routes.test.ts +++ b/src/mcpd/tests/virtual-llm-routes.test.ts @@ -75,6 +75,7 @@ describe('POST /api/v1/llms/_provider-register', () => { expect(register).toHaveBeenCalledWith({ providerSessionId: 'sess-xyz', providers: [{ name: 'vllm-local', type: 'openai', model: 'm', tier: 'fast', extraConfig: { gpu: 1 } }], + ownerId: 'system', }); expect(res.json()).toMatchObject({ providerSessionId: 'sess-xyz' }); }); diff --git a/src/mcpd/tests/visibility-filter.test.ts b/src/mcpd/tests/visibility-filter.test.ts new file mode 100644 index 0000000..8758e9b --- /dev/null +++ b/src/mcpd/tests/visibility-filter.test.ts @@ -0,0 +1,105 @@ +/** + * v7 Stage 1 — pure-function tests for the visibility predicate. + * Lives separately from the service-level tests because the predicate + * is the single source of truth for "can user X see this row" and is + * called from both LlmService.list and AgentService.list. We exercise + * every branch of the decision tree to lock the semantics in. + */ +import { describe, it, expect } from 'vitest'; +import { isLlmVisibleTo, type Viewer } from '../src/services/llm.service.js'; +import { isAgentVisibleTo, type AgentViewer } from '../src/services/agent.service.js'; + +const llmRow = (overrides: { name?: string; ownerId?: string | null; visibility?: string } = {}): { name: string; ownerId: string | null; visibility: string } => ({ + name: 'vllm-alice', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +const agentRow = (overrides: { name?: string; ownerId?: string; visibility?: string } = {}): { name: string; ownerId: string; visibility: string } => ({ + name: 'reviewer', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +describe('isLlmVisibleTo (v7)', () => { + it('null viewer skips the filter — internal callers see everything', () => { + // Cron sweeps, audit collectors, and tests without a request + // context get a null viewer. The visibility filter is then a + // no-op, which matches the pre-v7 behavior of those code paths. + expect(isLlmVisibleTo(llmRow(), null)).toBe(true); + }); + + it('public rows are visible to anyone with the resource grant', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('wildcard viewer (admin) sees private rows owned by others', () => { + const v: Viewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('owner sees their own private row', () => { + const v: Viewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without name-scoped grant cannot see a private row', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see a private row', () => { + // alice published vllm-alice as private; alice ran + // `mcpctl create rbac binding view:llms:vllm-alice --user bob`, + // so bob now sees the row in his list output. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['vllm-alice']) }; + expect(isLlmVisibleTo(llmRow({ name: 'vllm-alice', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('treats null ownerId as no-owner (legacy rows pre-v7 backfill stay visible if public)', () => { + // The migration sets visibility='public' for legacy rows, so they + // pass the public-visibility check before the ownerId branch is + // ever reached. A row with NULL ownerId AND visibility='private' + // is unreachable via normal flows, but we still want the predicate + // to behave: no owner + bob viewing = not visible. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ ownerId: null, visibility: 'private' }), v)).toBe(false); + }); +}); + +describe('isAgentVisibleTo (v7)', () => { + // Same shape as Llm; agents always have a non-null ownerId because + // `Agent.ownerId` is required, so we don't need the legacy-null + // branch test. + it('null viewer = visible (internal calls bypass filter)', () => { + expect(isAgentVisibleTo(agentRow(), null)).toBe(true); + }); + + it('public agents visible to anyone with resource grant', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('owner sees own private agent', () => { + const v: AgentViewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without grant blocked from private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['reviewer']) }; + expect(isAgentVisibleTo(agentRow({ name: 'reviewer', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('wildcard viewer sees private agent owned by another user', () => { + const v: AgentViewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); +}); diff --git a/src/mcpd/tests/yaml-serializer.test.ts b/src/mcpd/tests/yaml-serializer.test.ts index 7f54fc3..a7831c8 100644 --- a/src/mcpd/tests/yaml-serializer.test.ts +++ b/src/mcpd/tests/yaml-serializer.test.ts @@ -228,6 +228,7 @@ describe('APPLY_ORDER', () => { }); it('has all backup kinds', () => { - expect(APPLY_ORDER).toHaveLength(8); + // PR-3: bumped from 8 → 9 with the addition of `skill`. + expect(APPLY_ORDER).toHaveLength(9); }); }); diff --git a/src/mcplocal/src/http/config.ts b/src/mcplocal/src/http/config.ts index 1dbe8ec..dec4ad3 100644 --- a/src/mcplocal/src/http/config.ts +++ b/src/mcplocal/src/http/config.ts @@ -115,6 +115,14 @@ export interface LlmProviderFileEntry { * logical pool that auto-grows as more workers come online. */ poolName?: string; + /** + * v7: per-user RBAC scoping. mcplocal-published virtuals default to + * 'private' on register — the publishing user owns the row and other + * users don't see it without an explicit `view:llms:` grant. + * Set to 'public' here to opt into org-wide sharing for this + * provider. + */ + visibility?: 'public' | 'private'; } export type WakeRecipe = @@ -146,6 +154,8 @@ export interface AgentFileEntry { project?: string; defaultParams?: Record; extras?: Record; + /** v7: see LlmProviderFileEntry.visibility — same default ('private'). */ + visibility?: 'public' | 'private'; } /** diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 3dc842a..892b95f 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -233,6 +233,10 @@ async function maybeStartVirtualLlmRegistrar( if (entry.wake !== undefined) item.wake = entry.wake; if (entry.poolName !== undefined) item.poolName = entry.poolName; if (wireName !== provider.name) item.publishName = wireName; + // v7: pass visibility through; registrar already defaults to + // 'private' when omitted, and the per-provider override flows + // straight through to the register payload. + if (entry.visibility !== undefined) item.visibility = entry.visibility; published.push(item); } // v3: forward locally-declared agents alongside the providers. We @@ -255,6 +259,7 @@ async function maybeStartVirtualLlmRegistrar( if (a.project !== undefined) item.project = a.project; if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams; if (a.extras !== undefined) item.extras = a.extras; + if (a.visibility !== undefined) item.visibility = a.visibility; publishedAgents.push(item); } diff --git a/src/mcplocal/src/providers/registrar.ts b/src/mcplocal/src/providers/registrar.ts index 5e2b17e..4069040 100644 --- a/src/mcplocal/src/providers/registrar.ts +++ b/src/mcplocal/src/providers/registrar.ts @@ -72,6 +72,14 @@ export interface RegistrarPublishedProvider { * `publishName ?? provider.name` everywhere. */ publishName?: string; + /** + * v7: per-user RBAC scoping. mcplocal-published virtuals default to + * 'private' (visible only to the publishing user) — workstations + * shouldn't broadcast their models org-wide unless explicitly + * shared. The publisher can override per provider with + * `"visibility": "public"` in their mcplocal config. + */ + visibility?: 'public' | 'private'; } /** @@ -88,6 +96,8 @@ export interface RegistrarPublishedAgent { project?: string; defaultParams?: Record; extras?: Record; + /** v7: per-user RBAC scoping, defaults to 'private' on register. */ + visibility?: 'public' | 'private'; } export interface RegistrarOptions { @@ -207,6 +217,10 @@ export class VirtualLlmRegistrar { ...(p.tier !== undefined ? { tier: p.tier } : {}), ...(p.description !== undefined ? { description: p.description } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: virtuals default to private. Operators who want their + // workstation model org-visible set "visibility": "public" per + // provider in mcplocal config. + visibility: p.visibility ?? 'private', initialStatus, }; })); @@ -224,6 +238,9 @@ export class VirtualLlmRegistrar { ...(a.project !== undefined ? { project: a.project } : {}), ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: forward visibility to mcpd. Defaults to 'private' for + // virtual agents on the server side when omitted. + visibility: a.visibility ?? 'private', })); } diff --git a/src/mcplocal/src/proxymodel/plugins/gate.ts b/src/mcplocal/src/proxymodel/plugins/gate.ts index 41e1af6..e1987d1 100644 --- a/src/mcplocal/src/proxymodel/plugins/gate.ts +++ b/src/mcplocal/src/proxymodel/plugins/gate.ts @@ -56,6 +56,12 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi ctx.registerTool(getProposeTool(), async (args, callCtx) => { return handleProposePrompt(args, callCtx); }); + + // PR-4: Register propose_skill alongside propose_prompt. Goes + // through the new /api/v1/proposals endpoint with resourceType='skill'. + ctx.registerTool(getProposeSkillTool(), async (args, callCtx) => { + return handleProposeSkill(args, callCtx); + }); }, async onSessionDestroy(ctx) { @@ -191,12 +197,40 @@ function getReadPromptsTool(): ToolDefinition { function getProposeTool(): ToolDefinition { return { name: 'propose_prompt', - description: 'Propose a new prompt for this project. Creates a pending request that must be approved by a user before becoming active.', + description: + 'Propose a piece of project-specific knowledge as a new prompt. ' + + 'Use when you discover a non-obvious convention, hidden constraint, ' + + 'or lesson learned that future sessions on this project would benefit ' + + 'from. The proposal enters a queue; a maintainer reviews it and ' + + 'approves or rejects. See the propose-learnings skill for guidance ' + + 'on what makes a good proposal and what NOT to propose.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Prompt name (lowercase alphanumeric with hyphens, e.g. "debug-guide")' }, - content: { type: 'string', description: 'Prompt content text' }, + content: { type: 'string', description: 'Prompt content text. Lead with the shape of the situation, not the resolution. Keep under 200 words.' }, + }, + required: ['name', 'content'], + }, + }; +} + +function getProposeSkillTool(): ToolDefinition { + return { + name: 'propose_skill', + description: + 'Propose a new Claude Code skill (a SKILL.md). Reserve for ' + + 'cross-cutting knowledge — debugging discipline, release hygiene, ' + + 'security review style — that would help across many projects, ' + + 'not just this one. Skills have a larger blast radius than prompts ' + + 'and are harder to scope; lean toward propose_prompt unless you ' + + 'have a clear cross-project reason. See the propose-learnings skill.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Skill name (lowercase alphanumeric with hyphens, e.g. "debug-discipline")' }, + content: { type: 'string', description: 'SKILL.md body. Markdown. The reviewer will see this as the canonical content of the skill.' }, + description: { type: 'string', description: 'One-line description shown in mcpctl get skills listings' }, }, required: ['name', 'content'], }, @@ -435,6 +469,48 @@ async function handleProposePrompt( } } +async function handleProposeSkill( + args: Record, + ctx: PluginSessionContext, +): Promise { + const name = args['name'] as string | undefined; + const content = args['content'] as string | undefined; + const description = typeof args['description'] === 'string' ? args['description'] : ''; + + if (!name || !content) { + throw new ToolError(-32602, 'Missing required arguments: name and content'); + } + + try { + // PR-4: Skills go through the new /api/v1/proposals endpoint + // (resourceType='skill'). The legacy /api/v1/projects/.../promptrequests + // path is prompt-only. + const body: Record = { + resourceType: 'skill', + name, + project: ctx.projectName, + body: { content, description, priority: 5, files: {}, metadata: {} }, + createdBySession: ctx.sessionId, + }; + await ctx.postToMcpd('/api/v1/proposals', body); + return { + content: [ + { + type: 'text', + text: + `Skill proposal "${name}" created successfully. ` + + `A maintainer will review it (mcpctl review next) and either ` + + `approve — at which point it becomes available to every machine ` + + `that runs mcpctl skills sync — or reject with a note. You will ` + + `not see the outcome in this session.`, + }, + ], + }; + } catch (err) { + throw new ToolError(-32603, `Failed to propose skill: ${err instanceof Error ? err.message : String(err)}`); + } +} + // ── gated intercept handler ── async function handleGatedIntercept( diff --git a/src/mcplocal/tests/smoke/passwd.smoke.test.ts b/src/mcplocal/tests/smoke/passwd.smoke.test.ts new file mode 100644 index 0000000..dab1a5d --- /dev/null +++ b/src/mcplocal/tests/smoke/passwd.smoke.test.ts @@ -0,0 +1,128 @@ +/** + * Smoke tests: `mcpctl passwd` end-to-end against live mcpd. + * + * Exercises the full password-change contract: + * 1. Admin creates a throwaway user (gets the default self-permission seeded). + * 2. That user self-changes their password (POST /users/me/password) with the + * correct current password → succeeds; new password logs in, old does not. + * 3. Wrong current password → 401, password unchanged. + * 4. Admin resets the user's password (PUT /users/:id/password) → new one logs in. + * 5. Cleanup: delete the throwaway user. + * + * Target: $MCPD_URL (default https://mcpctl.ad.itaz.eu). Admin token is read + * from the local mcpctl credentials file (the box running smoke tests is logged + * in as admin). If /healthz fails or no admin token is found, the suite skips. + * + * Run with: pnpm test:smoke + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const STAMP = Date.now().toString(36); +const EMAIL = `smoke-passwd-${STAMP}@example.com`; +const P1 = `Smoke-${STAMP}-aaa1`; +const P2 = `Smoke-${STAMP}-bbb2`; +const P3 = `Smoke-${STAMP}-ccc3`; + +interface Resp { status: number; body: string; json: () => T } + +function request(method: string, url: string, token?: string, payload?: unknown): Promise { + return new Promise((resolve, reject) => { + const u = new URL(url); + const driver = u.protocol === 'https:' ? https : http; + const data = payload === undefined ? undefined : JSON.stringify(payload); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(data)); } + const req = driver.request(url, { method, headers, timeout: 15_000 }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf-8'); + resolve({ status: res.statusCode ?? 0, body, json: () => JSON.parse(body) as T }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + if (data) req.write(data); + req.end(); + }); +} + +async function healthz(): Promise { + try { + const res = await request('GET', `${MCPD_URL.replace(/\/$/, '')}/healthz`); + return res.status === 200; + } catch { return false; } +} + +function adminToken(): string | undefined { + // The CLI stores credentials at ~/.mcpctl/credentials (see config/loader.ts). + for (const p of [join(homedir(), '.mcpctl', 'credentials'), join(homedir(), '.config', 'mcpctl', 'credentials')]) { + try { + const tok = (JSON.parse(readFileSync(p, 'utf-8')) as { token?: string }).token; + if (tok) return tok; + } catch { /* try next */ } + } + return undefined; +} + +async function login(email: string, password: string): Promise { + return request('POST', `${MCPD_URL}/api/v1/auth/login`, undefined, { email, password }); +} + +describe('passwd smoke', () => { + let token: string | undefined; + let ready = false; + let userId: string | undefined; + + beforeAll(async () => { + token = adminToken(); + const up = await healthz(); + ready = up && token !== undefined; + if (!ready) { + console.warn(`\n ○ passwd smoke: skipped — ${MCPD_URL}/healthz up=${up}, adminToken=${token !== undefined}.\n`); + return; + } + // Create the throwaway user (admin). + const res = await request('POST', `${MCPD_URL}/api/v1/users`, token, { email: EMAIL, password: P1 }); + expect([201, 409]).toContain(res.status); + const me = await request('GET', `${MCPD_URL}/api/v1/users/${encodeURIComponent(EMAIL)}`, token); + userId = me.json<{ id: string }>().id; + }); + + it('self-change succeeds with correct current password; new logs in, old does not', async () => { + if (!ready) return; + const userTok = (await login(EMAIL, P1)).json<{ token: string }>().token; + const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: P1, newPassword: P2 }); + expect(chg.status).toBe(200); + expect((await login(EMAIL, P2)).status).toBe(200); + expect((await login(EMAIL, P1)).status).toBe(401); + }); + + it('self-change with wrong current password is rejected (401)', async () => { + if (!ready) return; + const userTok = (await login(EMAIL, P2)).json<{ token: string }>().token; + const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: 'definitely-wrong', newPassword: P3 }); + expect(chg.status).toBe(401); + expect((await login(EMAIL, P2)).status).toBe(200); // unchanged + }); + + it('admin reset sets a new password without the current one', async () => { + if (!ready || !userId) return; + const reset = await request('PUT', `${MCPD_URL}/api/v1/users/${userId}/password`, token, { newPassword: P3 }); + expect(reset.status).toBe(200); + expect((await login(EMAIL, P3)).status).toBe(200); + }); + + it('cleanup: delete the throwaway user', async () => { + if (!ready || !userId) return; + const del = await request('DELETE', `${MCPD_URL}/api/v1/users/${userId}`, token); + expect([204, 404]).toContain(del.status); + }); +}); diff --git a/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts b/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts new file mode 100644 index 0000000..a41c0e4 --- /dev/null +++ b/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts @@ -0,0 +1,208 @@ +/** + * Smoke: v7 visibility round-trip. + * + * Publishes two virtual Llms via the registrar — one explicitly public, + * one explicitly private — and verifies the GET /api/v1/llms response + * carries the visibility + ownerId fields end-to-end. The cross-user + * filter (private rows hidden from non-owner non-admin) is fully + * covered by mcpd's visibility-filter unit tests; smoke only proves + * the new fields make the round-trip from registrar → mcpd → list + * payload without dropping or being defaulted away. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + VirtualLlmRegistrar, + type RegistrarPublishedProvider, +} from '../../src/providers/registrar.js'; +import type { LlmProvider, CompletionResult } from '../../src/providers/types.js'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const PUBLIC_NAME = `smoke-vis-public-${SUFFIX}`; +const PRIVATE_NAME = `smoke-vis-private-${SUFFIX}`; + +function makeFakeProvider(name: string): LlmProvider { + return { + name, + async complete(): Promise { + return { + content: 'ok', + toolCalls: [], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + finishReason: 'stop', + }; + }, + async listModels() { return []; }, + async isAvailable() { return true; }, + }; +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, + (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +function readToken(): string | null { + try { + const home = process.env.HOME ?? ''; + const path = `${home}/.mcpctl/credentials`; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs') as typeof import('node:fs'); + if (!fs.existsSync(path)) return null; + const raw = fs.readFileSync(path, 'utf-8'); + const parsed = JSON.parse(raw) as { token?: string }; + return parsed.token ?? null; + } catch { + return null; + } +} + +interface HttpResponse { status: number; body: string } + +function httpRequest(method: string, urlStr: string): Promise { + return new Promise((resolve, reject) => { + const tokenRaw = readToken(); + const parsed = new URL(urlStr); + const driver = parsed.protocol === 'https:' ? https : http; + const headers: Record = { + Accept: 'application/json', + ...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}), + }; + const req = driver.request({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method, + headers, + timeout: 30_000, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); }); + req.end(); + }); +} + +let mcpdUp = false; +let registrar: VirtualLlmRegistrar | null = null; +let tempDir: string; + +interface LlmListRow { + id: string; + name: string; + visibility?: 'public' | 'private'; + ownerId?: string | null; +} + +describe('virtual-LLM smoke — visibility (v7)', () => { + beforeAll(async () => { + mcpdUp = await healthz(MCPD_URL); + if (!mcpdUp) { + // eslint-disable-next-line no-console + console.warn(`\n ○ visibility smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + return; + } + if (readToken() === null) { + mcpdUp = false; + // eslint-disable-next-line no-console + console.warn('\n ○ visibility smoke: skipped — no ~/.mcpctl/credentials.\n'); + return; + } + tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-vis-smoke-')); + }, 20_000); + + afterAll(async () => { + if (registrar !== null) registrar.stop(); + if (tempDir !== undefined) rmSync(tempDir, { recursive: true, force: true }); + if (mcpdUp) { + const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`); + if (list.status === 200) { + const rows = JSON.parse(list.body) as LlmListRow[]; + for (const target of [PUBLIC_NAME, PRIVATE_NAME]) { + const row = rows.find((r) => r.name === target); + if (row !== undefined) { + await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`); + } + } + } + } + }); + + it('publishes one public + one private virtual Llm and the list payload reflects both', async () => { + if (!mcpdUp) return; + const token = readToken(); + if (token === null) return; + const published: RegistrarPublishedProvider[] = [ + { provider: makeFakeProvider(PUBLIC_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'public' }, + { provider: makeFakeProvider(PRIVATE_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'private' }, + ]; + registrar = new VirtualLlmRegistrar({ + mcpdUrl: MCPD_URL, + token, + publishedProviders: published, + sessionFilePath: join(tempDir, 'session'), + log: { info: () => {}, warn: () => {}, error: () => {} }, + heartbeatIntervalMs: 60_000, + }); + await registrar.start(); + expect(registrar.getSessionId()).not.toBeNull(); + await new Promise((r) => setTimeout(r, 400)); + + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`); + expect(res.status).toBe(200); + const rows = JSON.parse(res.body) as LlmListRow[]; + + const pub = rows.find((r) => r.name === PUBLIC_NAME); + expect(pub, `${PUBLIC_NAME} must be visible to its owner`).toBeDefined(); + expect(pub!.visibility).toBe('public'); + // ownerId is the auth principal that ran register; non-empty proves + // mcpd actually stamped it on the row (otherwise the v7 register + // path would have left it NULL = legacy public). + expect(typeof pub!.ownerId).toBe('string'); + expect((pub!.ownerId ?? '').length).toBeGreaterThan(0); + + const priv = rows.find((r) => r.name === PRIVATE_NAME); + expect(priv, `${PRIVATE_NAME} must be visible to its owner (visibility filter is owner-bypass)`).toBeDefined(); + expect(priv!.visibility).toBe('private'); + expect(typeof priv!.ownerId).toBe('string'); + expect((priv!.ownerId ?? '').length).toBeGreaterThan(0); + + // Same publisher, same session — both rows must share the same owner. + expect(priv!.ownerId).toBe(pub!.ownerId); + }, 30_000); + + it('GET /api/v1/llms/ returns the row to its owner without 404', async () => { + if (!mcpdUp) return; + // Owner is calling — visibility filter must let the row through. A + // 404 here would indicate the service-layer filter is wrongly hiding + // it from the very user who created it. + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms/${PRIVATE_NAME}`); + expect(res.status).toBe(200); + const row = JSON.parse(res.body) as LlmListRow; + expect(row.name).toBe(PRIVATE_NAME); + expect(row.visibility).toBe('private'); + }, 30_000); +}); diff --git a/src/web/package.json b/src/web/package.json index ce525d4..5950949 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -12,17 +12,26 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "diff": "^5.2.0", + "geist": "^1.5.1", + "lucide-react": "^0.487.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.7.0" + "react-router-dom": "^7.7.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { + "@tailwindcss/vite": "^4.1.16", "@testing-library/jest-dom": "^6.7.0", "@testing-library/react": "^16.3.0", + "@types/diff": "^5.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.1.0", "jsdom": "^28.0.0", + "tailwindcss": "^4.1.16", "vite": "^7.2.0" } } diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index 7981fa7..e62c9a1 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -9,6 +9,11 @@ import { ProjectPromptsPage } from './pages/ProjectPrompts'; import { AgentsPage } from './pages/Agents'; import { AgentDetailPage } from './pages/AgentDetail'; import { PersonalityDetailPage } from './pages/PersonalityDetail'; +import { DashboardPage } from './pages/Dashboard'; +import { SkillsPage } from './pages/Skills'; +import { SkillDetailPage } from './pages/SkillDetail'; +import { ProposalsPage } from './pages/Proposals'; +import { ProposalDetailPage } from './pages/ProposalDetail'; export function App(): React.JSX.Element { const [tokenPresent, setTokenPresent] = useState(getToken() !== null); @@ -28,13 +33,19 @@ export function App(): React.JSX.Element { }> - } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> + {/* PR-6: Skills + Proposals UI. */} + } /> + } /> + } /> + } /> + } /> diff --git a/src/web/src/api.ts b/src/web/src/api.ts index 9581938..7b89dee 100644 --- a/src/web/src/api.ts +++ b/src/web/src/api.ts @@ -95,6 +95,72 @@ export interface Personality { promptCount: number; } +// PR-3: Skill resource. Mirrors Prompt with the addition of multi-file +// bundles (`files`) and typed metadata (`hooks`, `mcpServers`, +// `postInstall`, …). +export interface Skill { + id: string; + name: string; + description: string; + content: string; + files: Record; + metadata: Record; + projectId: string | null; + agentId: string | null; + priority: number; + semver: string; + currentRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +export interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'global' | 'project' | 'agent'; +} + +// PR-2: ResourceProposal — generic propose/approve/reject queue. +// Replaces PromptRequest in the new path. +export interface Proposal { + id: string; + resourceType: 'prompt' | 'skill'; + name: string; + body: Record; + projectId: string | null; + agentId: string | null; + createdBySession: string | null; + createdByUserId: string | null; + status: 'pending' | 'approved' | 'rejected'; + reviewerNote: string; + approvedRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +// PR-2: ResourceRevision — append-only audit log keyed by +// (resourceType, resourceId). +export interface Revision { + id: string; + resourceType: 'prompt' | 'skill'; + resourceId: string; + semver: string; + contentHash: string; + body: Record; + authorUserId: string | null; + authorSessionId: string | null; + note: string; + createdAt: string; +} + export interface PersonalityPrompt { promptId: string; promptName: string; diff --git a/src/web/src/components/Diff.tsx b/src/web/src/components/Diff.tsx new file mode 100644 index 0000000..9a44eab --- /dev/null +++ b/src/web/src/components/Diff.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { diffLines } from 'diff'; +import { cn } from '../lib/utils'; + +/** + * Unified-diff renderer — line-by-line color-coded display. Powers the + * proposal review and revision-history pages. We use `diff.diffLines` + * (text-line granularity) rather than `diff.createPatch` because we + * want to render the diff as styled DOM, not as plain monospace text. + */ +export function Diff({ + before, + after, + className, +}: { + before: string; + after: string; + className?: string; +}): React.JSX.Element { + const parts = React.useMemo(() => diffLines(before, after), [before, after]); + + return ( +
+      {parts.map((part, i) => {
+        const color = part.added
+          ? 'text-(--color-success)'
+          : part.removed
+            ? 'text-(--color-danger)'
+            : 'text-(--color-fg-muted)';
+        const prefix = part.added ? '+ ' : part.removed ? '- ' : '  ';
+        const lines = part.value.split('\n');
+        // diffLines returns trailing newlines as separate lines; drop the
+        // empty tail so we don't render dead rows.
+        const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
+        return (
+          
+            {trimmed.map((line, j) => (
+              
+                {prefix}
+                {line}
+              
+            ))}
+          
+        );
+      })}
+    
+ ); +} diff --git a/src/web/src/components/Layout.tsx b/src/web/src/components/Layout.tsx index b951f9e..3cb7737 100644 --- a/src/web/src/components/Layout.tsx +++ b/src/web/src/components/Layout.tsx @@ -1,80 +1,115 @@ import * as React from 'react'; import { NavLink, Outlet } from 'react-router-dom'; -import { clearToken } from '../api'; +import { LogOut, FolderKanban, Bot, Sparkles, Inbox, LayoutDashboard } from 'lucide-react'; + +import { api, clearToken, type Proposal } from '../api'; +import { Badge } from './ui/badge'; +import { cn } from '../lib/utils'; /** - * Top-of-page nav + outlet. Terminal-style dark theme so the UI feels - * adjacent to the CLI rather than a separate product. + * Sidebar layout. Pending-proposals badge polls every 30 s so reviewers + * see a queue building up without having to refresh the page. */ export function Layout(): React.JSX.Element { + const [pendingCount, setPendingCount] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + async function poll(): Promise { + try { + const proposals = await api.get('/api/v1/proposals?status=pending'); + if (!cancelled) setPendingCount(proposals.length); + } catch { + if (!cancelled) setPendingCount(null); + } + } + void poll(); + const id = setInterval(poll, 30_000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + return ( -
-
-
mcpctl · prompt editor
- -
-
+
+ + +
); } -function navStyle({ isActive }: { isActive: boolean }): React.CSSProperties { - return { - color: isActive ? '#58a6ff' : '#c9d1d9', - textDecoration: 'none', - padding: '6px 12px', - borderBottom: isActive ? '2px solid #58a6ff' : '2px solid transparent', - }; +function NavItem({ + to, + icon: Icon, + children, + badge, +}: { + to: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + badge?: number | null; +}): React.JSX.Element { + return ( + + cn( + 'flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm transition-colors', + isActive + ? 'bg-(--color-surface-hi) text-(--color-fg) font-medium' + : 'text-(--color-fg-muted) hover:bg-(--color-surface-hi) hover:text-(--color-fg)', + ) + } + > + + + {children} + + {typeof badge === 'number' && badge > 0 && ( + + {badge} + + )} + + ); } - -const styles: Record = { - shell: { - minHeight: '100vh', - display: 'flex', - flexDirection: 'column', - }, - header: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '12px 24px', - background: '#161b22', - borderBottom: '1px solid #30363d', - }, - brand: { - fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', - fontWeight: 700, - fontSize: 16, - }, - dim: { color: '#7d8590', fontWeight: 400 }, - nav: { - display: 'flex', - gap: 8, - alignItems: 'center', - }, - logout: { - background: 'transparent', - color: '#c9d1d9', - border: '1px solid #30363d', - padding: '4px 12px', - borderRadius: 4, - cursor: 'pointer', - marginLeft: 12, - }, - main: { - flex: 1, - padding: 24, - overflowY: 'auto', - }, -}; diff --git a/src/web/src/components/ui/badge.tsx b/src/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..01904a5 --- /dev/null +++ b/src/web/src/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium border', + { + variants: { + variant: { + default: + 'border-(--color-border) bg-(--color-surface) text-(--color-fg-muted)', + info: + 'border-(--color-primary)/30 bg-(--color-primary)/15 text-(--color-primary)', + success: + 'border-(--color-success)/30 bg-(--color-success-bg) text-(--color-success)', + warning: + 'border-(--color-warning)/30 bg-(--color-warning-bg) text-(--color-warning)', + danger: + 'border-(--color-danger)/30 bg-(--color-danger-bg) text-(--color-danger)', + outline: + 'border-(--color-border) text-(--color-fg)', + }, + }, + defaultVariants: { variant: 'default' }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export const Badge = React.forwardRef( + ({ className, variant, ...props }, ref) => ( + + ), +); +Badge.displayName = 'Badge'; diff --git a/src/web/src/components/ui/button.tsx b/src/web/src/components/ui/button.tsx new file mode 100644 index 0000000..1f6ddbf --- /dev/null +++ b/src/web/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-canvas) [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + primary: + 'bg-(--color-primary) text-(--color-primary-fg) hover:bg-(--color-primary-hover)', + secondary: + 'border border-(--color-border) bg-(--color-surface) text-(--color-fg) hover:bg-(--color-surface-hi)', + ghost: + 'text-(--color-fg) hover:bg-(--color-surface) hover:text-(--color-fg)', + danger: + 'bg-(--color-danger-bg) text-(--color-danger) border border-(--color-danger)/40 hover:bg-(--color-danger) hover:text-(--color-canvas)', + link: + 'text-(--color-primary) underline-offset-4 hover:underline', + }, + size: { + sm: 'h-8 px-3 text-sm', + md: 'h-9 px-4 text-sm', + lg: 'h-10 px-6 text-base', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +