Compare commits
2 Commits
feat/skill
...
feat/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56735a5290 | ||
|
|
e8c3803fac |
@@ -35,6 +35,9 @@ Key routing rules:
|
|||||||
- `project` — workspace grouping servers, prompts, agents
|
- `project` — workspace grouping servers, prompts, agents
|
||||||
- `llm` — server-managed LLM provider (api key + endpoint)
|
- `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-<name>/chat`). See `docs/agents.md`, `docs/chat.md`.
|
- `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-<name>/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
|
- `rbac` — access control bindings
|
||||||
- `mcptoken` — bearer credentials for HTTP-mode mcplocal
|
- `mcptoken` — bearer credentials for HTTP-mode mcplocal
|
||||||
|
|||||||
126
docs/proposals.md
Normal file
126
docs/proposals.md
Normal file
@@ -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 <id> # full detail
|
||||||
|
mcpctl review diff <id> # before/after diff
|
||||||
|
mcpctl review approve <id> # POST /proposals/:id/approve
|
||||||
|
mcpctl review reject <id> --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 <name>` 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.
|
||||||
130
docs/revisions.md
Normal file
130
docs/revisions.md
Normal file
@@ -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 <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diff
|
||||||
|
|
||||||
|
The HTTP API returns a unified-format diff:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/revisions/<id>/diff?against=<other-id|live>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <revision-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
214
docs/skills.md
Normal file
214
docs/skills.md
Normal file
@@ -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/<name>/`, where Claude
|
||||||
|
Code reads them natively.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ mcpd (Postgres) ──────────────────────────────┐
|
||||||
|
│ Skill rows (content + files{} + metadata) │
|
||||||
|
└────────────────┬───────────────────────────────┘
|
||||||
|
│ HTTP, hash-pinned diff
|
||||||
|
▼
|
||||||
|
┌─ ~/.claude/skills/<name>/ ─────────────────────┐
|
||||||
|
│ 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 `<name>` can exist at multiple scopes simultaneously. The two
|
||||||
|
unique constraints are `(name, projectId)` and `(name, agentId)`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcpctl create skill <name> \
|
||||||
|
[--project <name> | --agent <name>] \
|
||||||
|
--content / --content-file <path> \
|
||||||
|
[--description "<text>"] \
|
||||||
|
[--priority <1-10>] \
|
||||||
|
[--semver <X.Y.Z>] \
|
||||||
|
[--metadata-file <path>] \
|
||||||
|
[--files-dir <path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--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 <name>
|
||||||
|
|
||||||
|
# Edit + bump semver
|
||||||
|
mcpctl edit skill <name> --bump major|minor|patch --note "<message>"
|
||||||
|
|
||||||
|
# Edit + set explicit semver
|
||||||
|
mcpctl edit skill <name> --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 <name>
|
||||||
|
|
||||||
|
# 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 <name>` 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 `<targetDir>.mcpctl-staging-<pid>/`, then
|
||||||
|
the existing directory (if any) is renamed to
|
||||||
|
`<targetDir>.mcpctl-trash-<pid>`, 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
|
||||||
836
pnpm-lock.yaml
generated
836
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,17 +12,26 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@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": "^19.2.5",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@testing-library/jest-dom": "^6.7.0",
|
"@testing-library/jest-dom": "^6.7.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/diff": "^5.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
"vite": "^7.2.0"
|
"vite": "^7.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { ProjectPromptsPage } from './pages/ProjectPrompts';
|
|||||||
import { AgentsPage } from './pages/Agents';
|
import { AgentsPage } from './pages/Agents';
|
||||||
import { AgentDetailPage } from './pages/AgentDetail';
|
import { AgentDetailPage } from './pages/AgentDetail';
|
||||||
import { PersonalityDetailPage } from './pages/PersonalityDetail';
|
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 {
|
export function App(): React.JSX.Element {
|
||||||
const [tokenPresent, setTokenPresent] = useState(getToken() !== null);
|
const [tokenPresent, setTokenPresent] = useState(getToken() !== null);
|
||||||
@@ -28,13 +33,19 @@ export function App(): React.JSX.Element {
|
|||||||
<BrowserRouter basename="/ui">
|
<BrowserRouter basename="/ui">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/projects/:name/prompts" element={<ProjectPromptsPage />} />
|
<Route path="/projects/:name/prompts" element={<ProjectPromptsPage />} />
|
||||||
<Route path="/agents" element={<AgentsPage />} />
|
<Route path="/agents" element={<AgentsPage />} />
|
||||||
<Route path="/agents/:name" element={<AgentDetailPage />} />
|
<Route path="/agents/:name" element={<AgentDetailPage />} />
|
||||||
<Route path="/personalities/:id" element={<PersonalityDetailPage />} />
|
<Route path="/personalities/:id" element={<PersonalityDetailPage />} />
|
||||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
{/* PR-6: Skills + Proposals UI. */}
|
||||||
|
<Route path="/skills" element={<SkillsPage />} />
|
||||||
|
<Route path="/skills/:name" element={<SkillDetailPage />} />
|
||||||
|
<Route path="/proposals" element={<ProposalsPage />} />
|
||||||
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -95,6 +95,72 @@ export interface Personality {
|
|||||||
promptCount: number;
|
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<string, string>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
authorUserId: string | null;
|
||||||
|
authorSessionId: string | null;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonalityPrompt {
|
export interface PersonalityPrompt {
|
||||||
promptId: string;
|
promptId: string;
|
||||||
promptName: string;
|
promptName: string;
|
||||||
|
|||||||
53
src/web/src/components/Diff.tsx
Normal file
53
src/web/src/components/Diff.tsx
Normal file
@@ -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 (
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'overflow-x-auto rounded-md border border-(--color-border) bg-(--color-canvas) p-4 font-mono text-xs leading-relaxed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<span key={i} className={color}>
|
||||||
|
{trimmed.map((line, j) => (
|
||||||
|
<span key={j} className="block whitespace-pre-wrap">
|
||||||
|
{prefix}
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +1,115 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
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
|
* Sidebar layout. Pending-proposals badge polls every 30 s so reviewers
|
||||||
* adjacent to the CLI rather than a separate product.
|
* see a queue building up without having to refresh the page.
|
||||||
*/
|
*/
|
||||||
export function Layout(): React.JSX.Element {
|
export function Layout(): React.JSX.Element {
|
||||||
|
const [pendingCount, setPendingCount] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const proposals = await api.get<Proposal[]>('/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 (
|
return (
|
||||||
<div style={styles.shell}>
|
<div className="flex min-h-screen">
|
||||||
<header style={styles.header}>
|
<aside className="flex w-56 shrink-0 flex-col border-r border-(--color-border) bg-(--color-surface)">
|
||||||
<div style={styles.brand}>mcpctl <span style={styles.dim}>· prompt editor</span></div>
|
<div className="flex items-center gap-2 px-5 py-5">
|
||||||
<nav style={styles.nav}>
|
<span className="text-base font-bold tracking-tight">mcpctl</span>
|
||||||
<NavLink to="/projects" style={navStyle}>Projects</NavLink>
|
<span className="text-xs text-(--color-fg-muted)">UI</span>
|
||||||
<NavLink to="/agents" style={navStyle}>Agents</NavLink>
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 px-2 py-2">
|
||||||
|
<NavItem to="/dashboard" icon={LayoutDashboard}>
|
||||||
|
Dashboard
|
||||||
|
</NavItem>
|
||||||
|
<NavItem to="/projects" icon={FolderKanban}>
|
||||||
|
Projects
|
||||||
|
</NavItem>
|
||||||
|
<NavItem to="/agents" icon={Bot}>
|
||||||
|
Agents
|
||||||
|
</NavItem>
|
||||||
|
<NavItem to="/skills" icon={Sparkles}>
|
||||||
|
Skills
|
||||||
|
</NavItem>
|
||||||
|
<NavItem to="/proposals" icon={Inbox} badge={pendingCount}>
|
||||||
|
Proposals
|
||||||
|
</NavItem>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-(--color-border) p-2">
|
||||||
<button
|
<button
|
||||||
style={styles.logout}
|
onClick={() => {
|
||||||
onClick={() => { clearToken(); window.location.assign('/ui/'); }}
|
clearToken();
|
||||||
|
window.location.assign('/ui/');
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-(--color-fg-muted) transition-colors hover:bg-(--color-surface-hi) hover:text-(--color-fg)"
|
||||||
>
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
<main style={styles.main}>
|
|
||||||
|
<main className="flex-1 overflow-y-auto px-8 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function navStyle({ isActive }: { isActive: boolean }): React.CSSProperties {
|
function NavItem({
|
||||||
return {
|
to,
|
||||||
color: isActive ? '#58a6ff' : '#c9d1d9',
|
icon: Icon,
|
||||||
textDecoration: 'none',
|
children,
|
||||||
padding: '6px 12px',
|
badge,
|
||||||
borderBottom: isActive ? '2px solid #58a6ff' : '2px solid transparent',
|
}: {
|
||||||
};
|
to: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
badge?: number | null;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
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)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{typeof badge === 'number' && badge > 0 && (
|
||||||
|
<Badge variant="warning" className="px-1.5 py-0">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
37
src/web/src/components/ui/badge.tsx
Normal file
37
src/web/src/components/ui/badge.tsx
Normal file
@@ -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<HTMLSpanElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||||
|
({ className, variant, ...props }, ref) => (
|
||||||
|
<span ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Badge.displayName = 'Badge';
|
||||||
48
src/web/src/components/ui/button.tsx
Normal file
48
src/web/src/components/ui/button.tsx
Normal file
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
67
src/web/src/components/ui/card.tsx
Normal file
67
src/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-(--color-border) bg-(--color-surface) shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col gap-1.5 p-5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-(--color-fg-muted)', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-5 pt-0 gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
45
src/web/src/components/ui/input.tsx
Normal file
45
src/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-(--color-border) bg-(--color-canvas) px-3 py-1 text-sm text-(--color-fg) placeholder:text-(--color-fg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-24 w-full rounded-md border border-(--color-border) bg-(--color-canvas) px-3 py-2 text-sm text-(--color-fg) placeholder:text-(--color-fg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium uppercase tracking-wider text-(--color-fg-muted)',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Label.displayName = 'Label';
|
||||||
22
src/web/src/components/ui/separator.tsx
Normal file
22
src/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-(--color-border)',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/web/src/components/ui/tabs.tsx
Normal file
90
src/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny no-dep Tabs primitive. Doesn't need Radix for our use case —
|
||||||
|
* just tracks the active tab via state and re-renders the matching
|
||||||
|
* panel. ARIA roles are set so screen readers parse it as tabs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TabsContextValue {
|
||||||
|
value: string;
|
||||||
|
setValue: (v: string) => void;
|
||||||
|
}
|
||||||
|
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
defaultValue,
|
||||||
|
value: valueProp,
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (v: string) => void;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const [internal, setInternal] = React.useState(defaultValue ?? '');
|
||||||
|
const value = valueProp ?? internal;
|
||||||
|
const setValue = React.useCallback(
|
||||||
|
(v: string) => {
|
||||||
|
if (valueProp === undefined) setInternal(v);
|
||||||
|
onValueChange?.(v);
|
||||||
|
},
|
||||||
|
[valueProp, onValueChange],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, setValue }}>
|
||||||
|
<div className={cn('flex flex-col gap-3', className)}>{children}</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-start gap-1 rounded-md border border-(--color-border) bg-(--color-surface) p-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element {
|
||||||
|
const ctx = React.useContext(TabsContext);
|
||||||
|
if (!ctx) throw new Error('TabsTrigger must be used within Tabs');
|
||||||
|
const active = ctx.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => ctx.setValue(value)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-7 items-center justify-center rounded px-3 text-sm font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-(--color-canvas) text-(--color-fg) shadow-sm'
|
||||||
|
: 'text-(--color-fg-muted) hover:text-(--color-fg)',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element | null {
|
||||||
|
const ctx = React.useContext(TabsContext);
|
||||||
|
if (!ctx) throw new Error('TabsContent must be used within Tabs');
|
||||||
|
if (ctx.value !== value) return null;
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" className={cn('focus-visible:outline-none', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/web/src/hooks/usePolling.ts
Normal file
50
src/web/src/hooks/usePolling.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling hook with cancellation. Re-fetches `fn` every `intervalMs`
|
||||||
|
* until unmounted. Returns the latest data, error, and a setter to
|
||||||
|
* force-refresh on demand.
|
||||||
|
*/
|
||||||
|
export function usePolling<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
intervalMs: number,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
): { data: T | null; error: Error | null; loading: boolean; refetch: () => void } {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const v = await fn();
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(v);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err as Error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run();
|
||||||
|
const id = setInterval(() => { void run(); }, intervalMs);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [...deps, tick, intervalMs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
refetch: () => setTick((t) => t + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
94
src/web/src/index.css
Normal file
94
src/web/src/index.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* mcpctl design tokens. Dark-mode-only — this is an internal tool and
|
||||||
|
* adding light mode doubles QA surface for no clear user benefit.
|
||||||
|
*
|
||||||
|
* Color philosophy: a near-black canvas with a slightly lifted surface
|
||||||
|
* tier ("surface" / "surfaceHi") for cards. Borders are subtle (zinc-800
|
||||||
|
* range) so spatial structure comes from spacing, not lines. Accent
|
||||||
|
* colours are reserved for status: emerald = success/approved, red =
|
||||||
|
* danger/rejected, amber = pending, sky = primary action.
|
||||||
|
*
|
||||||
|
* Typography: Inter for UI, JetBrains Mono for IDs / code / monospace
|
||||||
|
* displays. Loaded via Google Fonts so production deploys don't need a
|
||||||
|
* separate CDN. (Could swap to a self-hosted geist later — the fallback
|
||||||
|
* stack reads identically.)
|
||||||
|
*
|
||||||
|
* NOTE: @import url(...) must come before any other rules. Tailwind's
|
||||||
|
* own @import directive is wired up after.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-canvas: oklch(0.16 0.005 270); /* near-black */
|
||||||
|
--color-surface: oklch(0.20 0.008 270); /* card bg */
|
||||||
|
--color-surface-hi: oklch(0.24 0.010 270); /* hover/lifted */
|
||||||
|
--color-border: oklch(0.30 0.010 270);
|
||||||
|
--color-border-strong: oklch(0.40 0.010 270);
|
||||||
|
--color-fg: oklch(0.92 0.005 270);
|
||||||
|
--color-fg-muted: oklch(0.65 0.010 270);
|
||||||
|
--color-fg-subtle: oklch(0.50 0.012 270);
|
||||||
|
|
||||||
|
--color-primary: oklch(0.74 0.16 240); /* sky-ish */
|
||||||
|
--color-primary-hover: oklch(0.78 0.16 240);
|
||||||
|
--color-primary-fg: oklch(0.16 0.005 270);
|
||||||
|
|
||||||
|
--color-success: oklch(0.72 0.18 145); /* emerald */
|
||||||
|
--color-success-bg: oklch(0.30 0.10 145);
|
||||||
|
--color-warning: oklch(0.80 0.16 80); /* amber */
|
||||||
|
--color-warning-bg: oklch(0.30 0.10 80);
|
||||||
|
--color-danger: oklch(0.70 0.20 25); /* red */
|
||||||
|
--color-danger-bg: oklch(0.30 0.12 25);
|
||||||
|
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-md: 0 4px 12px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monaco / code-y bits inherit the mono stack. */
|
||||||
|
code, pre, kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep focus visible — accessibility table stakes. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/web/src/lib/utils.ts
Normal file
11
src/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shadcn-style class-name helper. Merges Tailwind classes intelligently
|
||||||
|
* (later classes override earlier ones from the same group), and
|
||||||
|
* conditionally applies values via clsx semantics.
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
if (root === null) throw new Error('#root not found');
|
if (root === null) throw new Error('#root not found');
|
||||||
|
|||||||
133
src/web/src/pages/Dashboard.tsx
Normal file
133
src/web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Sparkles, Inbox, FolderKanban, Bot, ScrollText } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api, type Skill, type Proposal, type Project, type Agent } from '../api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At-a-glance home page. Counts come from the `/api/v1/<resource>`
|
||||||
|
* lists; pending proposals are highlighted with an amber badge to draw
|
||||||
|
* the reviewer in.
|
||||||
|
*/
|
||||||
|
export function DashboardPage(): React.JSX.Element {
|
||||||
|
const [counts, setCounts] = React.useState<{
|
||||||
|
skills: number;
|
||||||
|
proposals: { pending: number; approved: number; rejected: number };
|
||||||
|
projects: number;
|
||||||
|
agents: number;
|
||||||
|
prompts: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [skills, proposals, projects, agents, prompts] = await Promise.all([
|
||||||
|
api.get<Skill[]>('/api/v1/skills'),
|
||||||
|
api.get<Proposal[]>('/api/v1/proposals'),
|
||||||
|
api.get<Project[]>('/api/v1/projects'),
|
||||||
|
api.get<Agent[]>('/api/v1/agents'),
|
||||||
|
api.get<unknown[]>('/api/v1/prompts'),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setCounts({
|
||||||
|
skills: skills.length,
|
||||||
|
proposals: {
|
||||||
|
pending: proposals.filter((p) => p.status === 'pending').length,
|
||||||
|
approved: proposals.filter((p) => p.status === 'approved').length,
|
||||||
|
rejected: proposals.filter((p) => p.status === 'rejected').length,
|
||||||
|
},
|
||||||
|
projects: projects.length,
|
||||||
|
agents: agents.length,
|
||||||
|
prompts: prompts.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
|
||||||
|
if (counts === null) return <div className="text-(--color-fg-muted)">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-sm text-(--color-fg-muted)">
|
||||||
|
A glance at what's in mcpd. Numbers update on page load.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{counts.proposals.pending > 0 && (
|
||||||
|
<Link to="/proposals">
|
||||||
|
<Card className="border-(--color-warning)/40 bg-(--color-warning-bg)/30 transition-colors hover:bg-(--color-warning-bg)/50">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5 pt-5">
|
||||||
|
<Inbox className="size-6 text-(--color-warning)" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-(--color-fg)">
|
||||||
|
{counts.proposals.pending}{' '}
|
||||||
|
pending {counts.proposals.pending === 1 ? 'proposal' : 'proposals'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-(--color-fg-muted)">
|
||||||
|
Review the queue to approve or reject incoming changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="warning">Review</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<CountCard to="/skills" icon={Sparkles} label="Skills" value={counts.skills} />
|
||||||
|
<CountCard to="/projects" icon={FolderKanban} label="Projects" value={counts.projects} />
|
||||||
|
<CountCard to="/agents" icon={Bot} label="Agents" value={counts.agents} />
|
||||||
|
<CountCard to="/projects" icon={ScrollText} label="Prompts" value={counts.prompts} />
|
||||||
|
<CountCard
|
||||||
|
to="/proposals"
|
||||||
|
icon={Inbox}
|
||||||
|
label="Proposals"
|
||||||
|
value={counts.proposals.pending + counts.proposals.approved + counts.proposals.rejected}
|
||||||
|
subtitle={`${counts.proposals.pending} pending · ${counts.proposals.approved} approved · ${counts.proposals.rejected} rejected`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountCard({
|
||||||
|
to,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
subtitle?: string;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Link to={to} className="block">
|
||||||
|
<Card className="transition-colors hover:bg-(--color-surface-hi)">
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-(--color-fg-muted)">{label}</CardTitle>
|
||||||
|
<Icon className="size-4 text-(--color-fg-muted)" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="font-mono text-3xl font-semibold tabular-nums">{value}</div>
|
||||||
|
{subtitle !== undefined && (
|
||||||
|
<p className="mt-1 text-xs text-(--color-fg-muted)">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/web/src/pages/ProposalDetail.tsx
Normal file
173
src/web/src/pages/ProposalDetail.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Check, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api, type Proposal, type Skill } from '../api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Textarea, Label } from '../components/ui/input';
|
||||||
|
import { Diff } from '../components/Diff';
|
||||||
|
|
||||||
|
export function ProposalDetailPage(): React.JSX.Element {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [proposal, setProposal] = React.useState<Proposal | null>(null);
|
||||||
|
const [existing, setExisting] = React.useState<string | null>(null);
|
||||||
|
const [reason, setReason] = React.useState('');
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const p = await api.get<Proposal>(`/api/v1/proposals/${String(id)}`);
|
||||||
|
if (cancelled) return;
|
||||||
|
setProposal(p);
|
||||||
|
|
||||||
|
// Fetch existing resource (if any) for the diff.
|
||||||
|
const projectName = p.project?.name;
|
||||||
|
try {
|
||||||
|
if (p.resourceType === 'prompt') {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (projectName) params.set('project', projectName);
|
||||||
|
const list = await api.get<Array<{ name: string; content: string }>>(`/api/v1/prompts?${params.toString()}`);
|
||||||
|
const match = list.find((x) => x.name === p.name);
|
||||||
|
if (!cancelled) setExisting(match?.content ?? '');
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (projectName) params.set('project', projectName);
|
||||||
|
const list = await api.get<Skill[]>(`/api/v1/skills?${params.toString()}`);
|
||||||
|
const match = list.find((x) => x.name === p.name);
|
||||||
|
if (!cancelled) setExisting(match?.content ?? '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setExisting('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (error !== null) return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link to="/proposals" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
|
||||||
|
<ArrowLeft className="size-3.5" /> Proposals
|
||||||
|
</Link>
|
||||||
|
<div className="text-(--color-danger)">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (proposal === null) return <div className="text-(--color-fg-muted)">Loading…</div>;
|
||||||
|
|
||||||
|
const proposed = (proposal.body as { content?: string }).content ?? '';
|
||||||
|
const isPending = proposal.status === 'pending';
|
||||||
|
const willCreateNew = (existing ?? '').length === 0;
|
||||||
|
const scope = proposal.project?.name ?? proposal.agent?.name ?? 'global';
|
||||||
|
|
||||||
|
async function approve(): Promise<void> {
|
||||||
|
if (!proposal) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/proposals/${proposal.id}/approve`, {});
|
||||||
|
navigate('/proposals');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(): Promise<void> {
|
||||||
|
if (!proposal) return;
|
||||||
|
if (reason.trim().length === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/proposals/${proposal.id}/reject`, { reviewerNote: reason });
|
||||||
|
navigate('/proposals');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/proposals" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
|
||||||
|
<ArrowLeft className="size-3.5" /> Proposals
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="flex items-start justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="font-mono text-2xl font-semibold tracking-tight">{proposal.name}</h1>
|
||||||
|
<Badge variant="outline">{proposal.resourceType}</Badge>
|
||||||
|
<Badge variant={proposal.status === 'pending' ? 'warning' : proposal.status === 'approved' ? 'success' : 'danger'}>
|
||||||
|
{proposal.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-(--color-fg-subtle)">
|
||||||
|
<span>scope: {scope}</span>
|
||||||
|
<span>session: <code className="font-mono">{proposal.createdBySession ?? '—'}</code></span>
|
||||||
|
<span>created: {new Date(proposal.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{proposal.reviewerNote && (
|
||||||
|
<div className="rounded-md border border-(--color-border) bg-(--color-surface-hi) p-3 text-sm">
|
||||||
|
<span className="text-(--color-fg-muted)">Reviewer note:</span> {proposal.reviewerNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="primary" onClick={approve} disabled={busy}>
|
||||||
|
<Check className="size-4" />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
{willCreateNew ? `Would create a new ${proposal.resourceType}` : 'Diff against current'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{willCreateNew ? (
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-(--color-canvas) p-3 font-mono text-xs leading-relaxed">
|
||||||
|
{proposed}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<Diff before={existing ?? ''} after={proposed} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<Card className="border-(--color-danger)/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Reject</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reject-reason">Reviewer note (required)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reject-reason"
|
||||||
|
placeholder="Explain why this is being rejected so the proposer can learn from it."
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="danger" onClick={reject} disabled={busy || reason.trim().length === 0}>
|
||||||
|
<X className="size-4" />
|
||||||
|
Reject with note
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/web/src/pages/Proposals.tsx
Normal file
136
src/web/src/pages/Proposals.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Inbox, ScrollText, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api, type Proposal } from '../api';
|
||||||
|
import { Card, CardContent } from '../components/ui/card';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
|
||||||
|
|
||||||
|
export function ProposalsPage(): React.JSX.Element {
|
||||||
|
const [proposals, setProposals] = React.useState<Proposal[] | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [tab, setTab] = React.useState<'pending' | 'approved' | 'rejected'>('pending');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Proposal[]>('/api/v1/proposals');
|
||||||
|
if (!cancelled) setProposals(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
const id = setInterval(load, 30_000);
|
||||||
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
|
||||||
|
if (proposals === null) return <div className="text-(--color-fg-muted)">Loading proposals…</div>;
|
||||||
|
|
||||||
|
const pending = proposals.filter((p) => p.status === 'pending');
|
||||||
|
const approved = proposals.filter((p) => p.status === 'approved');
|
||||||
|
const rejected = proposals.filter((p) => p.status === 'rejected');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Proposals</h1>
|
||||||
|
<p className="text-sm text-(--color-fg-muted)">
|
||||||
|
Prompts and skills proposed by Claude sessions or human authors. Approve to materialise; reject to dismiss.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="pending">
|
||||||
|
Pending {pending.length > 0 && <span className="ml-1 text-(--color-warning)">({pending.length})</span>}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="approved">Approved ({approved.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="rejected">Rejected ({rejected.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pending">
|
||||||
|
<ProposalList list={pending} emptyText="No pending proposals." />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="approved">
|
||||||
|
<ProposalList list={approved} emptyText="No approved proposals yet." />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="rejected">
|
||||||
|
<ProposalList list={rejected} emptyText="No rejected proposals." />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProposalList({ list, emptyText }: { list: Proposal[]; emptyText: string }): React.JSX.Element {
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center gap-2 p-8 text-(--color-fg-muted)">
|
||||||
|
<Inbox className="size-4" /> {emptyText}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((p) => <ProposalRow key={p.id} proposal={p} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProposalRow({ proposal }: { proposal: Proposal }): React.JSX.Element {
|
||||||
|
const Icon = proposal.resourceType === 'skill' ? Sparkles : ScrollText;
|
||||||
|
const scope =
|
||||||
|
proposal.project?.name
|
||||||
|
? `project: ${proposal.project.name}`
|
||||||
|
: proposal.agent?.name
|
||||||
|
? `agent: ${proposal.agent.name}`
|
||||||
|
: 'global';
|
||||||
|
const statusVariant =
|
||||||
|
proposal.status === 'pending' ? 'warning' : proposal.status === 'approved' ? 'success' : 'danger';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/proposals/${proposal.id}`}>
|
||||||
|
<Card className="transition-colors hover:bg-(--color-surface-hi)">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className="size-4 shrink-0 text-(--color-fg-muted)" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-medium truncate">{proposal.name}</span>
|
||||||
|
<Badge variant="outline">{proposal.resourceType}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-(--color-fg-subtle)">
|
||||||
|
{scope} · session{' '}
|
||||||
|
<code className="font-mono">{(proposal.createdBySession ?? '—').slice(0, 8)}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-(--color-fg-subtle)">
|
||||||
|
{ageOf(proposal.createdAt)}
|
||||||
|
</span>
|
||||||
|
<Badge variant={statusVariant}>{proposal.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
return `${String(Math.floor(hr / 24))}d`;
|
||||||
|
}
|
||||||
185
src/web/src/pages/SkillDetail.tsx
Normal file
185
src/web/src/pages/SkillDetail.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, History } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api, type Skill, type Revision } from '../api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
|
||||||
|
import { Diff } from '../components/Diff';
|
||||||
|
|
||||||
|
export function SkillDetailPage(): React.JSX.Element {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const [skill, setSkill] = React.useState<Skill | null>(null);
|
||||||
|
const [revisions, setRevisions] = React.useState<Revision[] | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [tab, setTab] = React.useState('content');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const list = await api.get<Skill[]>('/api/v1/skills');
|
||||||
|
const match = list.find((s) => s.name === name);
|
||||||
|
if (!match) {
|
||||||
|
if (!cancelled) setError(`Skill "${String(name)}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const full = await api.get<Skill>(`/api/v1/skills/${match.id}`);
|
||||||
|
if (!cancelled) setSkill(full);
|
||||||
|
const revs = await api.get<Revision[]>(
|
||||||
|
`/api/v1/revisions?resourceType=skill&resourceId=${full.id}`,
|
||||||
|
);
|
||||||
|
if (!cancelled) setRevisions(revs);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
if (error !== null) return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link to="/skills" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
|
||||||
|
<ArrowLeft className="size-3.5" /> Skills
|
||||||
|
</Link>
|
||||||
|
<div className="text-(--color-danger)">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (skill === null) return <div className="text-(--color-fg-muted)">Loading…</div>;
|
||||||
|
|
||||||
|
const fileEntries = Object.entries(skill.files);
|
||||||
|
const metadataKeys = Object.keys(skill.metadata);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/skills" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
|
||||||
|
<ArrowLeft className="size-3.5" /> Skills
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="font-mono text-2xl font-semibold tracking-tight">{skill.name}</h1>
|
||||||
|
<Badge variant="info">v{skill.semver}</Badge>
|
||||||
|
</div>
|
||||||
|
{skill.description && (
|
||||||
|
<p className="text-sm text-(--color-fg-muted)">{skill.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 pt-1 text-xs text-(--color-fg-subtle)">
|
||||||
|
<span>id: <code className="font-mono">{skill.id}</code></span>
|
||||||
|
{skill.project?.name && <span>project: <code className="font-mono">{skill.project.name}</code></span>}
|
||||||
|
{skill.agent?.name && <span>agent: <code className="font-mono">{skill.agent.name}</code></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="content">SKILL.md</TabsTrigger>
|
||||||
|
{fileEntries.length > 0 && <TabsTrigger value="files">Files ({fileEntries.length})</TabsTrigger>}
|
||||||
|
{metadataKeys.length > 0 && <TabsTrigger value="metadata">Metadata</TabsTrigger>}
|
||||||
|
<TabsTrigger value="history">History ({revisions?.length ?? '…'})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="content">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-(--color-fg)">
|
||||||
|
{skill.content}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{fileEntries.length > 0 && (
|
||||||
|
<TabsContent value="files">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fileEntries.map(([path, content]) => (
|
||||||
|
<Card key={path}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm">{path}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="overflow-x-auto rounded bg-(--color-canvas) p-3 font-mono text-xs">{content}</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadataKeys.length > 0 && (
|
||||||
|
<TabsContent value="metadata">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<pre className="overflow-x-auto rounded bg-(--color-canvas) p-3 font-mono text-xs">
|
||||||
|
{JSON.stringify(skill.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TabsContent value="history">
|
||||||
|
<RevisionHistorySection revisions={revisions} skill={skill} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionHistorySection({
|
||||||
|
revisions,
|
||||||
|
skill,
|
||||||
|
}: {
|
||||||
|
revisions: Revision[] | null;
|
||||||
|
skill: Skill;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const [diffAgainst, setDiffAgainst] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
if (revisions === null) return <div className="text-(--color-fg-muted)">Loading history…</div>;
|
||||||
|
if (revisions.length === 0) {
|
||||||
|
return <Card><CardContent className="p-8 text-center text-(--color-fg-muted)">No revisions yet.</CardContent></Card>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = revisions.find((r) => r.id === diffAgainst);
|
||||||
|
const targetContent = (target?.body as { content?: string } | undefined)?.content ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{revisions.map((rev) => (
|
||||||
|
<Card key={rev.id} className="cursor-pointer transition-colors hover:bg-(--color-surface-hi)" onClick={() => setDiffAgainst(rev.id === diffAgainst ? null : rev.id)}>
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<History className="size-4 text-(--color-fg-muted)" />
|
||||||
|
<Badge variant={rev.id === diffAgainst ? 'info' : 'outline'}>v{rev.semver}</Badge>
|
||||||
|
{rev.note && <span className="text-sm text-(--color-fg-muted)">{rev.note}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-(--color-fg-subtle)">
|
||||||
|
{new Date(rev.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{target && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
Diff: v{target.semver} ↔ live (v{skill.semver})
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDiffAgainst(null)}>Close</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Diff before={targetContent} after={skill.content} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/web/src/pages/Skills.tsx
Normal file
119
src/web/src/pages/Skills.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Sparkles, FolderKanban, Bot, Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api, type Skill } from '../api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
|
||||||
|
export function SkillsPage(): React.JSX.Element {
|
||||||
|
const [skills, setSkills] = React.useState<Skill[] | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Skill[]>('/api/v1/skills');
|
||||||
|
if (!cancelled) setSkills(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
|
||||||
|
if (skills === null) return <div className="text-(--color-fg-muted)">Loading skills…</div>;
|
||||||
|
|
||||||
|
const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const globals = sorted.filter((s) => s.projectId === null && s.agentId === null);
|
||||||
|
const scoped = sorted.filter((s) => s.projectId !== null || s.agentId !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Skills</h1>
|
||||||
|
<p className="text-sm text-(--color-fg-muted)">
|
||||||
|
Materialised onto every dev box by <code className="font-mono text-xs">mcpctl skills sync</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-(--color-fg-muted)">
|
||||||
|
{sorted.length} {sorted.length === 1 ? 'skill' : 'skills'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-(--color-fg-muted)">
|
||||||
|
No skills defined yet. Create one with{' '}
|
||||||
|
<code className="rounded bg-(--color-surface-hi) px-1 py-0.5 font-mono text-xs">
|
||||||
|
mcpctl create skill {'<name>'}
|
||||||
|
</code>
|
||||||
|
.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globals.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-(--color-fg-muted)">
|
||||||
|
Global
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{globals.map((s) => <SkillCard key={s.id} skill={s} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scoped.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-(--color-fg-muted)">
|
||||||
|
Project- and agent-scoped
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{scoped.map((s) => <SkillCard key={s.id} skill={s} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCard({ skill }: { skill: Skill }): React.JSX.Element {
|
||||||
|
const ScopeIcon =
|
||||||
|
skill.projectId !== null ? FolderKanban : skill.agentId !== null ? Bot : Globe;
|
||||||
|
const scopeLabel =
|
||||||
|
skill.project?.name
|
||||||
|
? `project: ${skill.project.name}`
|
||||||
|
: skill.agent?.name
|
||||||
|
? `agent: ${skill.agent.name}`
|
||||||
|
: 'global';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/skills/${encodeURIComponent(skill.name)}`}>
|
||||||
|
<Card className="h-full transition-colors hover:bg-(--color-surface-hi)">
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="font-mono text-sm">
|
||||||
|
<Sparkles className="mr-1.5 inline size-3.5 text-(--color-primary)" />
|
||||||
|
{skill.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="info">v{skill.semver}</Badge>
|
||||||
|
</div>
|
||||||
|
{skill.description && (
|
||||||
|
<p className="text-sm text-(--color-fg-muted) line-clamp-2">{skill.description}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-(--color-fg-subtle)">
|
||||||
|
<ScopeIcon className="size-3" />
|
||||||
|
<span>{scopeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite config for the @mcpctl/web prompt editor.
|
* Vite config for the @mcpctl/web prompt editor.
|
||||||
@@ -16,7 +17,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
const apiTarget = process.env['MCPCTL_API_URL'] ?? 'https://mcpctl.ad.itaz.eu';
|
const apiTarget = process.env['MCPCTL_API_URL'] ?? 'https://mcpctl.ad.itaz.eu';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
base: '/ui/',
|
base: '/ui/',
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user