feat(plugins): make CCAM installable straight from a Claude Code plugin
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.
- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
build them) and kept honest by scripts/check-mcp-build.js (content hash,
not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
docs/MCP.md, docs/CLI.md, docs/HOOKS.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-agent-monitor-plugins",
|
||||
"description": "Official plugin marketplace for Claude Code Agent Monitor — 10 plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
||||
"description": "Official plugin marketplace for Claude Code Agent Monitor — the `ccam` plugin installs the dashboard itself (hooks, server, CLI, MCP), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
||||
"owner": {
|
||||
"name": "smartgift",
|
||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||
@@ -8,6 +8,12 @@
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ccam",
|
||||
"source": "./",
|
||||
"description": "The dashboard itself. Installs the Claude Code event hooks, boots the local server, puts the `ccam` CLI on PATH and connects the MCP tools — no checkout and no `npm run setup`. Install this one first; the ten focused plugins below all read from the API it provides.",
|
||||
"tags": ["dashboard", "monitoring", "hooks", "mcp", "cli"]
|
||||
},
|
||||
{
|
||||
"name": "ccam-analytics",
|
||||
"path": "plugins/ccam-analytics",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "ccam",
|
||||
"description": "Claude Code Agent Monitor — the full local-first dashboard as a plugin. Installs the event hooks, boots the dashboard server, puts the `ccam` CLI on PATH, and connects the MCP tools. No checkout, no npm run setup.",
|
||||
"author": {
|
||||
"name": "Nguyễn Ngọc Trí Vĩ",
|
||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||
},
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "mcp", "claude-code"],
|
||||
"commands": [
|
||||
"./plugins/ccam/commands/ccam-doctor.md",
|
||||
"./plugins/ccam/commands/ccam-update.md",
|
||||
"./plugins/ccam/commands/ccam-open.md"
|
||||
],
|
||||
"mcpServers": "./plugins/ccam/.mcp.json",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Stop",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SubagentStop",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Notification",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Checking the CCAM dashboard..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionStart",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionEnd",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" UserPromptSubmit",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
# Port to listen on (default: 4820)
|
||||
# DASHBOARD_PORT=4820
|
||||
|
||||
# Directory the built client is served from in production (default: client/dist).
|
||||
# The `ccam` plugin install points this at ~/.claude/agent-dashboard/runtime/client-dist
|
||||
# because the plugin cache directory is read-only and replaced on every update.
|
||||
# DASHBOARD_CLIENT_DIST=
|
||||
|
||||
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
|
||||
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
|
||||
# server reads transcripts, exports all data, and can spawn `claude`, so binding
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@ jspm_packages/
|
||||
dist/
|
||||
build/
|
||||
client/dist/
|
||||
mcp/build/
|
||||
# mcp/build/ is deliberately COMMITTED: Claude Code starts a plugin's MCP
|
||||
# servers the moment the session opens, so the artifact has to exist before any
|
||||
# bootstrap could build it. Freshness is enforced by scripts/check-mcp-build.js.
|
||||
!mcp/build/
|
||||
desktop/out/
|
||||
desktop/release/
|
||||
desktop/assets/icon.iconset/
|
||||
|
||||
+10
-1
@@ -36,7 +36,16 @@ else
|
||||
echo "🎨 No staged files to format."
|
||||
fi
|
||||
|
||||
# ── 2. Run tests — commit is blocked unless all pass ────────────────────────
|
||||
# ── 2. Committed MCP build must match mcp/src ───────────────────────────────
|
||||
# mcp/build/ is committed because a plugin's MCP server starts before any
|
||||
# bootstrap could build it. Only enforced when mcp/src is part of this commit,
|
||||
# so unrelated commits are not blocked by a stale artifact.
|
||||
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^mcp/src/'; then
|
||||
echo "🔌 Checking the committed MCP build against mcp/src..."
|
||||
node scripts/check-mcp-build.js
|
||||
fi
|
||||
|
||||
# ── 3. Run tests — commit is blocked unless all pass ────────────────────────
|
||||
# Each suite is retried once on failure. The full run executes dozens of test
|
||||
# files concurrently, each starting its own server (plus the CLI suite's
|
||||
# spawned child processes with hard kill timeouts), so a loaded machine can
|
||||
|
||||
@@ -1118,6 +1118,29 @@ flowchart TD
|
||||
|
||||
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
|
||||
|
||||
### Plugin installs declare the same hooks instead
|
||||
|
||||
When the dashboard is installed as the `ccam` Claude Code plugin, the eight hook
|
||||
entries come from the inline `hooks` block in `.claude-plugin/plugin.json`
|
||||
(each running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`), and
|
||||
`install-hooks.js` is not used at all. Both at once would POST every event twice
|
||||
— events carry no id, so ingest cannot deduplicate them, and every token and
|
||||
cost figure would double. Two guards keep that from happening silently:
|
||||
|
||||
- `scripts/plugin-bootstrap.js` strips `hook-handler.js` entries out of
|
||||
`~/.claude/settings.json` on session start (backing the file up as
|
||||
`settings.json.ccam-bak` first) and logs what it removed.
|
||||
- `install-hooks.js` warns when the plugin runtime state exists, and
|
||||
`/ccam-doctor` reports any surviving duplicates as a `FAIL`.
|
||||
|
||||
`scripts/plugin-bootstrap.js` also owns the rest of the plugin's runtime: the
|
||||
Node >= 22.5 gate (`node:sqlite`), an atomic `mkdir` lock, the dependency
|
||||
install into `~/.claude/agent-dashboard/runtime/`, the `~/.local/bin/ccam`
|
||||
launcher, and the detached server spawn with `NODE_PATH` and
|
||||
`DASHBOARD_CLIENT_DIST` pointed at that runtime directory. It never writes into
|
||||
the plugin cache, which Claude Code garbage-collects and replaces on every
|
||||
update. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
---
|
||||
|
||||
## Import Pipeline
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
||||
- `mcp/`: local MCP server exposing dashboard operations as tools.
|
||||
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
||||
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||
|
||||
## Lanes
|
||||
|
||||
@@ -49,6 +50,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
||||
- Server tests: `npm run test:server`
|
||||
- Client tests: `npm run test:client`
|
||||
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
|
||||
- MCP build freshness: `npm run mcp:check-build` (must pass whenever `mcp/src` changes)
|
||||
- MCP typecheck: `npm run mcp:typecheck`
|
||||
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
|
||||
|
||||
@@ -71,3 +73,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
||||
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
||||
- Use project skills from `.claude/skills/` for repeatable workflows.
|
||||
- Use `.claude/agents/` subagents for focused review or investigation passes.
|
||||
- **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted).
|
||||
|
||||
+21
@@ -2,6 +2,27 @@
|
||||
|
||||
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history, running in a container, and using the native desktop app (macOS & Windows).
|
||||
|
||||
## Fastest path — install it as a Claude Code plugin
|
||||
|
||||
If all you want is a working dashboard, skip every step below. From Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||
```
|
||||
|
||||
The next session start installs the hooks, boots the server, puts the `ccam` CLI
|
||||
on PATH and connects the MCP tools — no clone, no `npm run setup`, no
|
||||
`npm run install-hooks`, no manual `npm start`. Run `/ccam-doctor` to check the
|
||||
result and `/ccam-open` to build the UI and get the URL.
|
||||
|
||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
Follow the steps below instead when you want a checkout — to develop the
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|
||||
@@ -26,7 +26,25 @@ are verified on — node 25 currently breaks 6 server tests through a
|
||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||
change.
|
||||
|
||||
## Install and run
|
||||
## Install as a Claude Code plugin
|
||||
|
||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
`npm run setup`:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||
```
|
||||
|
||||
The first session start installs the hooks, boots the server, puts `ccam` on
|
||||
PATH and connects the MCP tools; it runs detached, so the session never waits on
|
||||
it. `/ccam-doctor` reports the state, `/ccam-open` builds the UI and prints the
|
||||
URL, `/ccam-update` refreshes after a plugin update. This path needs Node
|
||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||
Details, including what to delete on uninstall:
|
||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root, client and vscode-extension dependencies
|
||||
@@ -42,8 +60,11 @@ Development, with hot reload:
|
||||
npm run dev # server on :4820, Vite client on :5173
|
||||
```
|
||||
|
||||
`DASHBOARD_PORT` overrides the port. `postinstall` writes the Claude Code hook
|
||||
entries that feed the dashboard.
|
||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||
that feed the dashboard — do not run it when the `ccam` plugin is installed, or
|
||||
every event is counted twice.
|
||||
|
||||
## The CLI
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ Container-specific behavior:
|
||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `ccam` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
|
||||
@@ -61,6 +61,14 @@ ccam help
|
||||
|
||||
If linking needed elevated permissions in your environment, setup still succeeds and prints a hint — run `npm link` once from the repo root yourself, or invoke the CLI directly with `node bin/ccam.js <command>`.
|
||||
|
||||
### With a plugin install (no checkout)
|
||||
|
||||
The `ccam` plugin's bootstrap writes a launcher to `~/.local/bin/ccam` on
|
||||
session start — a small script rather than a symlink into the plugin cache,
|
||||
which is replaced on every plugin update. It never overwrites a `ccam` it did
|
||||
not write, so a linked checkout keeps winning. If `~/.local/bin` is not on your
|
||||
PATH, `/ccam-doctor` says so and prints the exact `export PATH=...` line to add.
|
||||
|
||||
## Server Discovery
|
||||
|
||||
The CLI finds your running dashboard the same way the Claude Code hook handler does:
|
||||
|
||||
@@ -155,6 +155,17 @@ graph TB
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Skip this entirely when the `ccam` plugin is installed.** The plugin declares
|
||||
> the same eight hooks itself (inline in `.claude-plugin/plugin.json`, each
|
||||
> running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`). Running both means
|
||||
> every event is POSTed twice — events carry no id, so ingest cannot deduplicate
|
||||
> them and every token and cost figure doubles. `scripts/plugin-bootstrap.js`
|
||||
> removes checkout-installed entries on session start (backup:
|
||||
> `~/.claude/settings.json.ccam-bak`), `install-hooks.js` warns when it detects
|
||||
> a plugin install, and `/ccam-doctor` reports any duplicates that remain. See
|
||||
> [PLUGINS.md](PLUGINS.md).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Hooks are a host-side step.** Claude Code runs on your host, so the hook
|
||||
> command must reference a `hook-handler.js` path that exists on the **host**.
|
||||
|
||||
+25
-2
@@ -135,13 +135,35 @@ graph TB
|
||||
# Install MCP server dependencies
|
||||
npm run mcp:install
|
||||
|
||||
# Build MCP server
|
||||
# Build MCP server (also stamps mcp/build/.srchash)
|
||||
npm run mcp:build
|
||||
|
||||
# Test MCP server
|
||||
npm run mcp:start
|
||||
```
|
||||
|
||||
Installing the `ccam` plugin needs none of this: it ships the built server and
|
||||
wires it up itself (see [PLUGINS.md](PLUGINS.md)).
|
||||
|
||||
### Why `mcp/build/` is committed
|
||||
|
||||
Claude Code starts a plugin's MCP servers the moment a session opens and offers
|
||||
no "not ready yet, retry" state, so an async bootstrap cannot win that race. The
|
||||
build artifact is therefore committed, and `plugins/ccam/.mcp.json` points at
|
||||
`${CLAUDE_PLUGIN_ROOT}/mcp/build/index.js`.
|
||||
|
||||
The cost is drift, so freshness is enforced by content hash — `mcp/src` plus the
|
||||
MCP manifests and tsconfig are hashed into `mcp/build/.srchash`:
|
||||
|
||||
```bash
|
||||
npm run mcp:check-build # fails when mcp/build is stale or unstamped
|
||||
```
|
||||
|
||||
`npm run mcp:build` re-stamps it, the pre-commit hook runs the check whenever
|
||||
`mcp/src` is part of the commit, and `/ccam-doctor` reports it. Modification
|
||||
times are deliberately not used: a fresh clone stamps every file at checkout
|
||||
time in arbitrary order.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
@@ -157,7 +179,8 @@ mcp/
|
||||
│ │ └── stats.ts # Statistics tools
|
||||
│ └── types.ts # TypeScript type definitions
|
||||
│
|
||||
├── dist/ # Compiled JavaScript (gitignored)
|
||||
├── build/ # Compiled JavaScript (COMMITTED — see above)
|
||||
│ └── .srchash # hash of mcp/src the build was produced from
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
|
||||
+126
-14
@@ -1,6 +1,6 @@
|
||||
# Claude Code Agent Monitor — Plugin Marketplace
|
||||
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. **10 plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`ccam`** plugin _is_ the dashboard — hooks, server, `ccam` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
|
||||
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
|
||||
|
||||
@@ -12,19 +12,43 @@ Every plugin is powered by the local Agent Monitor REST API at `http://localhost
|
||||
claude plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
```
|
||||
|
||||
### Install a plugin
|
||||
### Install the dashboard itself
|
||||
|
||||
```bash
|
||||
claude plugin install ccam-analytics@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-cost-guard@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-productivity@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-devtools@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-insights@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-sessions@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-workflows@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-quality@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-config@smartgift-claude-code-monitor
|
||||
claude plugin install ccam-dashboard@smartgift-claude-code-monitor
|
||||
claude plugin install ccam@claude-code-agent-monitor-plugins
|
||||
```
|
||||
|
||||
That is the whole install: no clone, no `npm run setup`, no `npm run install-hooks`, no manual `npm start`. See [The `ccam` plugin](#the-ccam-plugin) below for what it does on first session start.
|
||||
|
||||
### Or via the Smartgift skills marketplace
|
||||
|
||||
`ccam` is also listed as a standalone entry in
|
||||
[`smartgift-claude-skills`](https://git.smartgift.io.vn/Smartgift-AI/smartgift-claude-skills)
|
||||
— its `source` still points at this repo's `main` branch, so the two entries
|
||||
install identically:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/smartgift-claude-skills.git
|
||||
claude plugin install ccam@sg
|
||||
```
|
||||
|
||||
Pick whichever marketplace you already have added; installing `ccam` from both
|
||||
at once is redundant but harmless (Claude Code treats it as one plugin per
|
||||
marketplace name, not per source).
|
||||
|
||||
### Install a focused plugin
|
||||
|
||||
```bash
|
||||
claude plugin install ccam-analytics@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-cost-guard@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-productivity@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-devtools@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-insights@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-sessions@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-workflows@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-quality@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-config@claude-code-agent-monitor-plugins
|
||||
claude plugin install ccam-dashboard@claude-code-agent-monitor-plugins
|
||||
```
|
||||
|
||||
### Or install locally during development
|
||||
@@ -37,11 +61,99 @@ claude --plugin-dir plugins/ccam-analytics
|
||||
## Prerequisites
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
- Agent Monitor dashboard running at `http://localhost:4820` (see [SETUP.md](../SETUP.md))
|
||||
- Hooks installed: `npm run setup` from the Agent Monitor project
|
||||
- Node **>= 22.5** when installing via the `ccam` plugin. A plugin install has no
|
||||
native `better-sqlite3`, so the server stores data through `node:sqlite`, which
|
||||
landed in 22.5. The bootstrap refuses with one line on anything older instead
|
||||
of letting the server crash. (A checkout install still works on Node >= 20.)
|
||||
- The Agent Monitor dashboard reachable at `http://localhost:4820` — the `ccam`
|
||||
plugin starts it for you; a checkout starts it with `npm start` (see
|
||||
[SETUP.md](../SETUP.md))
|
||||
|
||||
Skills and commands are invoked as `/ccam-<plugin>:<name>`. Agents are dispatched automatically by Claude Code (or named explicitly).
|
||||
|
||||
## The `ccam` plugin
|
||||
|
||||
The marketplace's root entry (`"source": "./"`) is the entire repository, so
|
||||
`server/`, `client/`, `mcp/`, `scripts/` and `bin/ccam.js` all land under
|
||||
`${CLAUDE_PLUGIN_ROOT}` when Claude Code caches it. That is what makes a
|
||||
checkout unnecessary.
|
||||
|
||||
### What it installs
|
||||
|
||||
| Component | Where it comes from |
|
||||
|---|---|
|
||||
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
|
||||
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
||||
| The `ccam` CLI | a launcher written to `~/.local/bin/ccam` |
|
||||
| The MCP tools | `plugins/ccam/.mcp.json`, pointing at the committed `mcp/build/index.js` |
|
||||
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
||||
| `/ccam-doctor`, `/ccam-update`, `/ccam-open` | `plugins/ccam/commands/` |
|
||||
|
||||
Because the plugin ships the hooks itself, `npm run install-hooks` is not needed
|
||||
for plugin users — and must not be run alongside it. Events carry no id, so two
|
||||
handlers mean every token and cost figure is counted twice. The bootstrap
|
||||
removes the older checkout-installed entries automatically (backing
|
||||
`~/.claude/settings.json` up as `settings.json.ccam-bak` first), and
|
||||
`/ccam-doctor` reports the state.
|
||||
|
||||
### First session start
|
||||
|
||||
`scripts/plugin-bootstrap.js` runs from `SessionStart`. It returns within
|
||||
milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
2. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
3. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Legacy hook cleanup** — see above.
|
||||
6. **CLI** — write the `~/.local/bin/ccam` launcher, never clobbering a `ccam` the bootstrap did not write.
|
||||
7. **UI build** — `client/` is copied into the runtime dir and built there (`npm install && npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (API and MCP still work, and `/ccam-open` can retry) but does mean `/run` 404s until it's fixed.
|
||||
8. **Server** — spawn `server/index.js` detached, with `NODE_PATH` at the runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime `client-dist`.
|
||||
9. **Record state** — `runtime/state.json`.
|
||||
|
||||
The first run takes a few minutes — most of it is the UI build, which is what
|
||||
makes client-only routes (`http://localhost:4820/run`, and every other page)
|
||||
work the moment `claude` starts, with no manual `/ccam-open` step. Progress
|
||||
goes to `~/.claude/agent-dashboard/runtime/bootstrap.log`; `npm run build`'s own
|
||||
output goes to `client-build.log` next to it; the server's own output goes to
|
||||
`server.log`.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, MCP build freshness, UI bundle |
|
||||
| `/ccam-update` | Reinstall dependencies and restart the server against the current plugin version (`plugin-bootstrap.js --force`) |
|
||||
| `/ccam-open` | Build the UI bundle if missing, then print the dashboard URL |
|
||||
|
||||
### Where things live
|
||||
|
||||
| Thing | Location | Survives a plugin update |
|
||||
|---|---|---|
|
||||
| SQLite DB, transcripts | `~/.claude/agent-dashboard/` | yes |
|
||||
| `node_modules`, `client-dist`, logs, lock, `state.json` | `~/.claude/agent-dashboard/runtime/` | yes (re-verified on every session start) |
|
||||
| Source, hooks, `mcp/build` | the plugin cache version directory | no — re-bootstrapped |
|
||||
|
||||
The bootstrap deliberately does **not** set `DASHBOARD_DATA_DIR`: leaving the
|
||||
default keeps a plugin-run server and a developer's `npm run dev` server on one
|
||||
data directory, where `ingestGroupKey` already deduplicates hook ingest to a
|
||||
single port.
|
||||
|
||||
### Uninstalling
|
||||
|
||||
`claude plugin uninstall ccam@claude-code-agent-monitor-plugins` removes the hooks
|
||||
and the cached source. It does not touch the state the plugin created outside
|
||||
the cache — remove those by hand if you want a clean machine:
|
||||
|
||||
```bash
|
||||
kill "$(node -e 'console.log(JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.claude/.agent-dashboard.json","utf8")).servers[0].pid)')"
|
||||
rm -rf ~/.claude/agent-dashboard/runtime # deps, UI bundle, logs, state
|
||||
rm -f ~/.local/bin/ccam # the CLI launcher
|
||||
# ~/.claude/agent-dashboard/ still holds the database — delete it only if you
|
||||
# want the recorded history gone too.
|
||||
```
|
||||
|
||||
## Available Plugins
|
||||
|
||||
### 1. `ccam-analytics` — Analytics & Monitoring
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Plugin-first install — CCAM usable straight from a Claude Code plugin install
|
||||
|
||||
Date: 2026-08-10
|
||||
Status: approved (design)
|
||||
|
||||
## Goal
|
||||
|
||||
On a machine with nothing but Claude Code installed, these two commands must
|
||||
leave the user with a working CCAM — hooks flowing, server up, dashboard
|
||||
reachable, `ccam` CLI on PATH, MCP tools connected:
|
||||
|
||||
```
|
||||
/plugin marketplace add <ccam repo>
|
||||
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||
```
|
||||
|
||||
No repo clone, no `npm run setup`, no `npm run install-hooks`, no manual
|
||||
`npm start`. This mirrors how `claude-mem` installs.
|
||||
|
||||
## Why the current state does not do this
|
||||
|
||||
- The only plugin with a server connection, `ccam-dashboard`, declares its MCP
|
||||
server as `../../mcp/build/index.js` (`plugins/ccam-dashboard/.mcp.json`).
|
||||
Plugins installed from a marketplace subdir are cached as **that subdir
|
||||
only** — verified against `~/.claude/plugins/cache/claude-plugins-official/superpowers/6.2.0/`,
|
||||
which contains the plugin directory's contents and nothing above it. The
|
||||
relative path escapes the cached tree and resolves to nothing.
|
||||
- Every plugin's hooks and MCP config assume a dashboard is already listening
|
||||
on `localhost:4820`. Nothing starts one.
|
||||
- Hook installation today mutates `~/.claude/settings.json` through
|
||||
`scripts/install-hooks.js`, which requires a checkout to point at.
|
||||
|
||||
## Approach: declare the whole repo as one plugin
|
||||
|
||||
Add a **root-level** plugin named `ccam` with `"source": "."` in
|
||||
`.claude-plugin/marketplace.json`. Claude Code then caches the *entire repo*
|
||||
into `~/.claude/plugins/cache/<marketplace>/ccam/<version>/` — verified against
|
||||
`~/.claude/plugins/cache/caveman/`, whose cached tree contains the full source
|
||||
repo (`src/`, `tests/`, `benchmarks/`), because that marketplace declares its
|
||||
plugin with `"source": "./"`.
|
||||
|
||||
So `server/`, `client/`, `mcp/`, `scripts/` and `bin/ccam.js` are all present
|
||||
under `${CLAUDE_PLUGIN_ROOT}` the moment the plugin is installed. No second
|
||||
clone, no credentials beyond the marketplace add itself, no committed copy of
|
||||
the server, no dependency on undocumented cache internals beyond the documented
|
||||
`${CLAUDE_PLUGIN_ROOT}` variable.
|
||||
|
||||
The ten existing subdirectory plugins stay exactly as they are.
|
||||
|
||||
Repo working tree is 23 MB — an acceptable cache footprint.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Root plugin manifest — `.claude-plugin/plugin.json`
|
||||
|
||||
New file, alongside the existing `marketplace.json`. Declares name, metadata,
|
||||
inline `hooks`, and paths to commands. Hooks are inlined in `plugin.json`
|
||||
(as `caveman` does) rather than in a root `hooks/hooks.json`, so the repo root
|
||||
gains no new top-level directories.
|
||||
|
||||
`.claude-plugin/marketplace.json` gains one entry:
|
||||
|
||||
```json
|
||||
{ "name": "ccam", "source": ".", "description": "...", "tags": [...] }
|
||||
```
|
||||
|
||||
### 2. Hooks — inline in `plugin.json`
|
||||
|
||||
All eight hook types currently installed by `scripts/install-hooks.js`
|
||||
(`install-hooks.js:86,93`):
|
||||
|
||||
- with `"matcher": "*"` — `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`
|
||||
- without a matcher — `SessionStart`, `SessionEnd`, `UserPromptSubmit`
|
||||
|
||||
Each runs:
|
||||
|
||||
```
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js" <HookType>
|
||||
```
|
||||
|
||||
`scripts/hook-handler.js` is used unchanged: it is dependency-free (`node:http`
|
||||
only), resolves ports via `server/lib/server-info.js` — present in the cached
|
||||
tree — falls back to 4820, and already fails silently so a not-yet-booted
|
||||
server never blocks Claude Code.
|
||||
|
||||
`SessionStart` additionally runs the bootstrap (below).
|
||||
|
||||
Consequence: `npm run install-hooks` becomes unnecessary for plugin users, and
|
||||
uninstalling the plugin removes the hooks cleanly. It stays supported for
|
||||
checkout users.
|
||||
|
||||
### 3. Bootstrap — `scripts/plugin-bootstrap.js` (new)
|
||||
|
||||
Runs from the `SessionStart` hook. Never blocks a session.
|
||||
|
||||
Runtime state lives under `~/.claude/agent-dashboard/runtime/`, reusing the
|
||||
existing data-dir convention (`server/lib/claude-home.js:37`, where the SQLite
|
||||
DB already lives) rather than inventing a new directory. `/.ccam/` at the repo
|
||||
root is an unrelated lane profile and must not be confused with it.
|
||||
|
||||
Sequence:
|
||||
|
||||
1. **Fast path.** Read `runtime/state.json`. If the recorded plugin version
|
||||
matches and a live server is found via `~/.claude/.agent-dashboard.json`
|
||||
(the existing PID-checked discovery file), exit in milliseconds.
|
||||
2. **Node version gate.** `server/db.js` `require`s `better-sqlite3` — which is
|
||||
*not* in `dependencies` — and falls back to `node:sqlite`, available only on
|
||||
Node >= 22.5. On anything older the server cannot start at all. Refuse with
|
||||
one clear line rather than letting the server crash.
|
||||
3. **Lock.** `runtime/.bootstrap.lock` created with an atomic `mkdir`, holding
|
||||
the PID. A lock whose PID is dead, or older than 10 minutes, is stale and
|
||||
reclaimed. Prevents concurrent sessions racing an install or double-spawning
|
||||
the server.
|
||||
4. **Install.** `npm install --omit=dev --ignore-scripts` with the dependency
|
||||
tree written to `runtime/node_modules`, never into the plugin cache
|
||||
directory. Two reasons: `~/.claude/plugins/cache/` shows GC machinery
|
||||
(`.in_use` markers, `.last_inuse_sweep`), and every plugin update creates a
|
||||
fresh version directory, discarding anything installed into the old one.
|
||||
`--ignore-scripts` is required because `scripts/postinstall.js` otherwise
|
||||
runs a full `client/` install, pulling the whole Vite dev toolchain.
|
||||
5. **Legacy hook cleanup.** If `~/.claude/settings.json` still carries CCAM hook
|
||||
entries from a previous `npm run install-hooks`, remove them and print one
|
||||
line. Without this, every event is POSTed twice and token/cost figures
|
||||
double. Detection reuses `isOurEntry()` from `scripts/install-hooks.js`.
|
||||
6. **CLI on PATH.** Symlink `bin/ccam.js` to `~/.local/bin/ccam`. Skills and
|
||||
commands throughout the repo (`ccam stage`, `ship-feature-lane`) shell out
|
||||
to `ccam` and fail without it.
|
||||
7. **Start server.** Spawn `server/index.js` detached, `stdio: "ignore"`,
|
||||
`unref()`, with `NODE_PATH` pointed at `runtime/node_modules`. The hook
|
||||
returns immediately; the first install (1–3 minutes) proceeds in the
|
||||
background and prints progress to a log under `runtime/`.
|
||||
8. **Record state.** Write `runtime/state.json`.
|
||||
|
||||
The client UI bundle is *not* built here. Hooks, the API and MCP do not need
|
||||
it; it is built lazily by `/ccam-open`.
|
||||
|
||||
### 4. Client dist relocation — `server/index.js`
|
||||
|
||||
`server/index.js:156` hardcodes `path.join(__dirname, "..", "client", "dist")`.
|
||||
Since the plugin cache tree must be treated as read-only, add a
|
||||
`DASHBOARD_CLIENT_DIST` env override, defaulting to the current path so
|
||||
checkout behavior is unchanged.
|
||||
|
||||
The bootstrap always starts the server with `DASHBOARD_CLIENT_DIST` set to
|
||||
`runtime/client-dist/`, whether or not a bundle is there yet — a missing
|
||||
directory serves the API fine and only the UI route 404s. `/ccam-open` builds
|
||||
into that same directory, so no server restart is needed to pick the bundle up.
|
||||
|
||||
### 5. MCP — commit `mcp/build/`
|
||||
|
||||
Claude Code starts a plugin's MCP servers as the session opens and offers no
|
||||
"not ready yet, retry" state, so an async bootstrap cannot win that race.
|
||||
`mcp/build/` is therefore committed (removed from `.gitignore` and `mcp/.gitignore`)
|
||||
and the root `.mcp.json` points at `${CLAUDE_PLUGIN_ROOT}/mcp/build/index.js`
|
||||
with `CCAM_DASHBOARD_URL` defaulted to `http://localhost:4820`.
|
||||
|
||||
Cost: the build artifact must stay in sync with `mcp/src`. A pre-commit check
|
||||
fails when `mcp/src` is newer than `mcp/build`.
|
||||
|
||||
The existing `plugins/ccam-dashboard/.mcp.json` keeps its broken relative path
|
||||
fixed to `${CLAUDE_PLUGIN_ROOT}` form as part of this work, so the subdir plugin
|
||||
is not left in a knowingly broken state.
|
||||
|
||||
### 6. Commands — `plugins/ccam/commands/`
|
||||
|
||||
The plugin root is the repo root, so `plugin.json` declares an explicit
|
||||
`"commands": "./plugins/ccam/commands"` path. This keeps the new command files
|
||||
inside `plugins/`, next to the ten existing plugins, instead of adding a
|
||||
top-level `commands/` directory to the repo root.
|
||||
|
||||
- `/ccam-doctor` — bootstrap state, Node version, server liveness, DB path,
|
||||
duplicate-hook detection, CLI symlink, MCP build freshness.
|
||||
- `/ccam-update` — refresh dependencies and restart the server after a plugin
|
||||
update.
|
||||
- `/ccam-open` — lazily build the client bundle if missing, then open the
|
||||
dashboard.
|
||||
|
||||
## Data and lifecycle
|
||||
|
||||
| Thing | Location | Survives plugin update |
|
||||
|---|---|---|
|
||||
| SQLite DB, transcripts | `~/.claude/agent-dashboard/` (unchanged) | yes |
|
||||
| node_modules, client dist, logs, lock, state | `~/.claude/agent-dashboard/runtime/` | yes (re-verified each boot) |
|
||||
| Source, hooks, MCP build | plugin cache version dir | no — re-bootstrapped |
|
||||
|
||||
Bootstrap must not set `DASHBOARD_DATA_DIR`. Leaving it at the default keeps a
|
||||
plugin-run server and a developer's `npm run dev` server on the same data
|
||||
directory, where `ingestGroupKey` in `server/lib/server-info.js` already
|
||||
deduplicates hook ingest to a single port.
|
||||
|
||||
## Failure modes
|
||||
|
||||
**High severity**
|
||||
|
||||
- *SessionStart blocks the session.* The first install takes minutes. Mitigated
|
||||
by full detachment (`detached`, `stdio: "ignore"`, `unref()`) and an
|
||||
immediate hook return. Verified by timing the hook on a cold `HOME`.
|
||||
- *Duplicate hooks double-count cost.* Events carry no id, so ingest cannot
|
||||
deduplicate. Mitigated by step 5 of the bootstrap plus a `/ccam-doctor` check.
|
||||
- *MCP unavailable at session start.* Resolved by committing `mcp/build/`.
|
||||
|
||||
**Medium severity**
|
||||
|
||||
- *Two sessions spawn two servers* → `EADDRINUSE`. Mitigated by the lock, with
|
||||
a stale-lock timeout so a crashed bootstrap does not wedge every later
|
||||
session.
|
||||
- *`ccam` not on PATH* when `~/.local/bin` is absent from the user's PATH.
|
||||
`/ccam-doctor` reports it and prints the line to add.
|
||||
- *Windows.* `npm.cmd` resolution (already handled in `scripts/postinstall.js`
|
||||
via `shell: true`), detached spawn semantics, and `mkdir` locking are the
|
||||
least-tested paths.
|
||||
|
||||
**Low severity**
|
||||
|
||||
- Node 20 users are refused rather than broken; the message names the required
|
||||
version.
|
||||
- 23 MB cache footprint.
|
||||
- Marketplace mixing one root-source plugin with ten subdir plugins is
|
||||
unverified in this combination; it is the first thing the plan verifies, and
|
||||
the fallback is a separate marketplace file for the root plugin.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Install the local marketplace and confirm
|
||||
`~/.claude/plugins/cache/<mp>/ccam/*/server/` exists — this gates everything
|
||||
else.
|
||||
2. `npm run test:server` for the `DASHBOARD_CLIENT_DIST` override.
|
||||
3. Unit tests for bootstrap: fast path, stale-lock reclaim, Node version gate,
|
||||
legacy-hook detection — all with injected paths, no real `$HOME` writes.
|
||||
4. Cold-machine smoke: `HOME=$(mktemp -d)` with no checkout on PATH, install the
|
||||
plugin, wait for bootstrap, `curl /api/health`, fire one synthetic hook, and
|
||||
assert the event lands in the DB.
|
||||
5. `npm run mcp:typecheck && npm run mcp:build` for the committed artifact.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Publishing to npm.
|
||||
- Docker-based bootstrap.
|
||||
- Changing the ten existing subdir plugins beyond the `.mcp.json` path fix.
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
build/
|
||||
# build/ is committed on purpose — see the root .gitignore and
|
||||
# scripts/check-mcp-build.js.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
2e9282b25c6d10d236ad0fd91bceeebf9a5c08cdbea74f1c780c1bae5303ca81
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @file dashboard-api-client.ts
|
||||
* @description Client for making API requests to the MCP dashboard. This client provides methods for sending HTTP requests (GET, POST, PUT, PATCH, DELETE) to the dashboard's API endpoints, with built-in support for retries on transient errors, request timeouts, and error handling. The client constructs URLs based on a base URL from the configuration and allows for query parameters and request bodies. It also defines a custom ApiError class for consistent error representation across the application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../core/logger.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ApiError` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `DashboardApiClient` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ApiError**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **DashboardApiClient**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
/**
|
||||
* Error type for every failed dashboard API call — non-2xx responses,
|
||||
* timeouts, and network failures all normalize to this shape.
|
||||
* {@link errorResult} surfaces `code`/`status`/`details` to the MCP client
|
||||
* instead of collapsing to a generic internal error.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status;
|
||||
/** Forwarded from the dashboard's error envelope, a synthesized
|
||||
* `HTTP_<status>`, or this client's own code (`INVALID_PATH`, `TIMEOUT`,
|
||||
* `REQUEST_FAILED`, `UNREACHABLE_STATE`). */
|
||||
code;
|
||||
details;
|
||||
constructor(message, options = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
this.code = options.code;
|
||||
this.details = options.details;
|
||||
}
|
||||
}
|
||||
/** True for a DOM/Node `AbortError` from {@link DashboardApiClient.request}'s
|
||||
* per-attempt timeout controller. */
|
||||
function isAbortError(error) {
|
||||
return (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError");
|
||||
}
|
||||
/** Statuses treated as transient/retryable: 408, 429, or any 5xx. */
|
||||
function isRetryableStatus(status) {
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
}
|
||||
/**
|
||||
* Thin HTTP client every MCP tool handler uses to reach the dashboard's
|
||||
* local Express API — the sole network boundary of the server. Requests
|
||||
* resolve against `config.dashboardBaseUrl` and are restricted to `/api/*`
|
||||
* (see {@link buildUrl}).
|
||||
*
|
||||
* **Retry semantics**: only GET/DELETE mark themselves `idempotent`, so only
|
||||
* they retry automatically — `config.retryCount` extra attempts (default 2)
|
||||
* on a timeout or HTTP 408/429/5xx, each retry waiting
|
||||
* `config.retryBackoffMs * 2^(attempt-1)` (default 250ms, 500ms, ...,
|
||||
* exponential, no jitter). POST/PUT/PATCH are never retried, even for the
|
||||
* same transient statuses — a duplicated write is worse than one surfaced
|
||||
* failure.
|
||||
*/
|
||||
export class DashboardApiClient {
|
||||
config;
|
||||
logger;
|
||||
constructor(config, logger) {
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
}
|
||||
/** GET — idempotent, eligible for automatic retry. */
|
||||
async get(path, options = {}) {
|
||||
return this.request("GET", path, { ...options, idempotent: true });
|
||||
}
|
||||
/** POST — never retried; used for creates and mutation-gated actions. */
|
||||
async post(path, options = {}) {
|
||||
return this.request("POST", path, options);
|
||||
}
|
||||
/** PUT — full upsert semantics (e.g. pricing rules); never retried. */
|
||||
async put(path, options = {}) {
|
||||
return this.request("PUT", path, options);
|
||||
}
|
||||
/** PATCH — partial update; never retried. */
|
||||
async patch(path, options = {}) {
|
||||
return this.request("PATCH", path, options);
|
||||
}
|
||||
/** DELETE — idempotent, eligible for automatic retry. */
|
||||
async delete(path, options = {}) {
|
||||
return this.request("DELETE", path, options);
|
||||
}
|
||||
/**
|
||||
* Resolves `path` against the dashboard base URL and applies query
|
||||
* params, enforcing that only `/api/*` paths can ever be requested — a
|
||||
* hard client-side allowlist independent of the dashboard's own routing.
|
||||
* @throws {ApiError} code `INVALID_PATH` if the resolved pathname doesn't
|
||||
* start with `/api/`.
|
||||
*/
|
||||
buildUrl(path, query) {
|
||||
const url = new URL(path, this.config.dashboardBaseUrl);
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
throw new ApiError(`Invalid path "${path}". MCP client can only call /api/* endpoints.`, {
|
||||
code: "INVALID_PATH",
|
||||
});
|
||||
}
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
/**
|
||||
* Core request implementation shared by all five methods. Each attempt
|
||||
* gets its own {@link AbortController} armed with `config.requestTimeoutMs`
|
||||
* and best-effort JSON-parses the response (see {@link tryParseJson}).
|
||||
* `maxAttempts` is `config.retryCount + 1` when `options.idempotent`,
|
||||
* else `1`. On error, {@link shouldRetry} decides whether to back off and
|
||||
* loop or fall through to normalization: a non-ok response becomes an
|
||||
* {@link ApiError} via {@link toApiError}; an abort becomes `TIMEOUT`; any
|
||||
* other throw becomes `REQUEST_FAILED`.
|
||||
* @throws {ApiError} on any non-2xx response, timeout, or network failure
|
||||
* surviving the retry loop.
|
||||
*/
|
||||
async request(method, path, options) {
|
||||
const maxAttempts = options.idempotent ? this.config.retryCount + 1 : 1;
|
||||
const url = this.buildUrl(path, options.query);
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), this.config.requestTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const rawBody = await response.text();
|
||||
const body = rawBody ? this.tryParseJson(rawBody) : null;
|
||||
if (!response.ok) {
|
||||
throw this.toApiError(method, url, response.status, body ?? rawBody);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
catch (error) {
|
||||
if (this.shouldRetry(error, attempt, maxAttempts)) {
|
||||
const backoffMs = this.config.retryBackoffMs * Math.pow(2, attempt - 1);
|
||||
this.logger.warn("Transient API error, retrying", {
|
||||
method,
|
||||
path: url.toString(),
|
||||
attempt,
|
||||
maxAttempts,
|
||||
backoffMs,
|
||||
error: this.getErrorMessage(error),
|
||||
});
|
||||
await sleep(backoffMs);
|
||||
continue;
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
throw new ApiError(`Request timed out after ${this.config.requestTimeoutMs}ms: ${method} ${url.pathname}`, { code: "TIMEOUT" });
|
||||
}
|
||||
throw new ApiError(`Request failed: ${method} ${url.pathname}`, {
|
||||
code: "REQUEST_FAILED",
|
||||
details: this.getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw new ApiError("Unreachable request state", { code: "UNREACHABLE_STATE" });
|
||||
}
|
||||
/** Never retries on the last attempt; always retries an abort/timeout;
|
||||
* for an {@link ApiError} with a status, retries only if
|
||||
* {@link isRetryableStatus}; any other exception type is treated as
|
||||
* transient too. */
|
||||
shouldRetry(error, attempt, maxAttempts) {
|
||||
if (attempt >= maxAttempts)
|
||||
return false;
|
||||
if (isAbortError(error))
|
||||
return true;
|
||||
if (error instanceof ApiError && error.status !== undefined) {
|
||||
return isRetryableStatus(error.status);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/** Builds an {@link ApiError} from a non-ok response, preferring the
|
||||
* dashboard's `{ error: { code, message } }` envelope when present,
|
||||
* falling back to a generic `HTTP_<status>`. */
|
||||
toApiError(method, url, status, body) {
|
||||
const fallbackMessage = `${method} ${url.pathname} failed with HTTP ${status}`;
|
||||
if (body &&
|
||||
typeof body === "object" &&
|
||||
"error" in body &&
|
||||
body.error &&
|
||||
typeof body.error === "object" &&
|
||||
"message" in body.error) {
|
||||
const maybeCode = "code" in body.error && typeof body.error.code === "string" ? body.error.code : undefined;
|
||||
const maybeMessage = typeof body.error.message === "string" ? body.error.message : fallbackMessage;
|
||||
return new ApiError(maybeMessage, { status, code: maybeCode, details: body });
|
||||
}
|
||||
return new ApiError(fallbackMessage, { status, code: `HTTP_${status}`, details: body });
|
||||
}
|
||||
/** Parses `input` as JSON, returning the raw string unchanged if invalid. */
|
||||
tryParseJson(input) {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
/** Normalizes any thrown value to a loggable string message. */
|
||||
getErrorMessage(error) {
|
||||
if (error instanceof Error)
|
||||
return error.message;
|
||||
if (typeof error === "string")
|
||||
return error;
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @file app-config.ts
|
||||
* @description Module for loading and validating application configuration from environment variables. This module defines the AppConfig interface representing the configuration structure, along with functions to parse and validate individual configuration values such as booleans, integers, log levels, dashboard URLs, and transport modes. The loadConfig function aggregates all configuration values into a single AppConfig object, applying defaults and validation as needed. The module ensures that the application is configured correctly before it starts, providing clear error messages for invalid configurations.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `LogLevel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TransportMode` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AppConfig` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `loadConfig` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **LogLevel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TransportMode**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AppConfig**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **loadConfig**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
/** Allowlist of hostnames the dashboard URL may target: loopback addresses
|
||||
* plus the special Docker/Podman host-mapping names, so the MCP server can
|
||||
* run containerized and still reach a dashboard on the host. Anything else
|
||||
* is rejected by {@link parseDashboardUrl}. */
|
||||
const LOCAL_DASHBOARD_HOSTS = new Set([
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
"gateway.docker.internal",
|
||||
"host.containers.internal",
|
||||
]);
|
||||
const VALID_LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
|
||||
/** Parses `1/true/yes/on` / `0/false/no/off` (case-insensitive); anything
|
||||
* else, including `undefined`, resolves to `fallback`. */
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined)
|
||||
return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized))
|
||||
return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized))
|
||||
return false;
|
||||
return fallback;
|
||||
}
|
||||
/** Parses and clamps an integer env var into `[min, max]`; non-numeric or
|
||||
* missing input falls back to `fallback` rather than throwing. */
|
||||
function parseInteger(value, fallback, min, max) {
|
||||
if (value === undefined)
|
||||
return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed))
|
||||
return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
/** Normalizes `MCP_LOG_LEVEL`, falling back to `"info"`. */
|
||||
function parseLogLevel(value) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized && VALID_LOG_LEVELS.has(normalized) ? normalized : "info";
|
||||
}
|
||||
/**
|
||||
* Parses and validates `MCP_DASHBOARD_BASE_URL`. Unlike the other parsers
|
||||
* here, invalid input throws rather than falling back — an unsafe dashboard
|
||||
* target is startup-fatal, not something to paper over.
|
||||
* @throws {Error} on an invalid URL, a non-http(s) scheme, or a hostname
|
||||
* outside {@link LOCAL_DASHBOARD_HOSTS}.
|
||||
*/
|
||||
function parseDashboardUrl(raw) {
|
||||
const value = (raw ?? "http://127.0.0.1:4820").trim();
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
}
|
||||
catch {
|
||||
throw new Error(`Invalid MCP_DASHBOARD_BASE_URL: "${value}"`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`MCP_DASHBOARD_BASE_URL must use http or https, received protocol "${url.protocol}"`);
|
||||
}
|
||||
if (!LOCAL_DASHBOARD_HOSTS.has(url.hostname)) {
|
||||
throw new Error(`MCP_DASHBOARD_BASE_URL must target a local dashboard host (${Array.from(LOCAL_DASHBOARD_HOSTS).join(", ")}). Received hostname "${url.hostname}".`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
/** Normalizes `MCP_TRANSPORT`, falling back to `"stdio"`. This is only the
|
||||
* default — `index.ts`'s `resolveTransport` may override it with CLI flags. */
|
||||
function parseTransport(value) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "http" || normalized === "repl" || normalized === "stdio")
|
||||
return normalized;
|
||||
return "stdio";
|
||||
}
|
||||
/**
|
||||
* Reads and normalizes all `MCP_*` env vars into one {@link AppConfig}.
|
||||
* Called once at startup in `index.ts`; the result is treated as immutable.
|
||||
* @param env Defaults to `process.env`; injectable for tests.
|
||||
* @throws {Error} if `MCP_DASHBOARD_BASE_URL` is set but invalid/non-local.
|
||||
*/
|
||||
export function loadConfig(env = process.env) {
|
||||
return {
|
||||
serverName: env.MCP_SERVER_NAME?.trim() || "agent-dashboard-mcp",
|
||||
serverVersion: env.MCP_SERVER_VERSION?.trim() || "1.0.0",
|
||||
dashboardBaseUrl: parseDashboardUrl(env.MCP_DASHBOARD_BASE_URL),
|
||||
requestTimeoutMs: parseInteger(env.MCP_DASHBOARD_TIMEOUT_MS, 10_000, 500, 120_000),
|
||||
retryCount: parseInteger(env.MCP_DASHBOARD_RETRY_COUNT, 2, 0, 5),
|
||||
retryBackoffMs: parseInteger(env.MCP_DASHBOARD_RETRY_BACKOFF_MS, 250, 50, 10_000),
|
||||
allowMutations: parseBoolean(env.MCP_DASHBOARD_ALLOW_MUTATIONS, false),
|
||||
allowDestructive: parseBoolean(env.MCP_DASHBOARD_ALLOW_DESTRUCTIVE, false),
|
||||
logLevel: parseLogLevel(env.MCP_LOG_LEVEL),
|
||||
transport: parseTransport(env.MCP_TRANSPORT),
|
||||
httpPort: parseInteger(env.MCP_HTTP_PORT, 8819, 1, 65535),
|
||||
httpHost: env.MCP_HTTP_HOST?.trim() || "127.0.0.1",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file logger.ts
|
||||
* @description Logger class for the MCP application, responsible for logging messages in JSON format to stderr with different log levels (debug, info, warn, error). The logger respects a minimum log level configuration and includes timestamps in ISO format. Each log entry is a single line of JSON containing the timestamp, log level, message, and optional metadata. This structured logging approach allows for easy parsing and analysis of logs. The Logger class provides methods for each log level and a private method to handle the actual writing of log entries to stderr.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Logger` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Logger**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
/** Numeric severity ranking; higher is more severe. */
|
||||
const LEVEL_ORDER = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
/**
|
||||
* Structured JSON logger for the MCP process. Every entry is one
|
||||
* newline-terminated JSON object written to **stderr**, never stdout — for
|
||||
* the stdio transport, stdout is the MCP JSON-RPC channel, so logging there
|
||||
* would corrupt the protocol stream. One instance is shared process-wide via
|
||||
* {@link ToolContext} and {@link DashboardApiClient}.
|
||||
*/
|
||||
export class Logger {
|
||||
minLevel;
|
||||
/** @param minLevel Minimum severity written; lower calls are dropped.
|
||||
* Sourced from `AppConfig.logLevel` (`MCP_LOG_LEVEL`, default `"info"`). */
|
||||
constructor(minLevel) {
|
||||
this.minLevel = minLevel;
|
||||
}
|
||||
/** Per-call tracing, e.g. tool invocation start/completion; silent unless
|
||||
* `MCP_LOG_LEVEL=debug`. */
|
||||
debug(message, meta) {
|
||||
this.write("debug", message, meta);
|
||||
}
|
||||
/** Default-visible lifecycle events (server started, new session opened). */
|
||||
info(message, meta) {
|
||||
this.write("info", message, meta);
|
||||
}
|
||||
/** Recoverable/transient issues, e.g. a retried dashboard API request. */
|
||||
warn(message, meta) {
|
||||
this.write("warn", message, meta);
|
||||
}
|
||||
/** Aborted operations, e.g. a thrown tool handler or unhandled rejection. */
|
||||
error(message, meta) {
|
||||
this.write("error", message, meta);
|
||||
}
|
||||
/** Writes one entry if `level` meets {@link minLevel}; `meta` is included
|
||||
* only when non-empty. */
|
||||
write(level, message, meta) {
|
||||
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) {
|
||||
return;
|
||||
}
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...(meta && Object.keys(meta).length > 0 ? { meta } : {}),
|
||||
});
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file tool-registry.ts
|
||||
* @description Core functions for registering tools in the MCP server. This module defines the ToolRegistrar type, which is a function that can be used to register a tool with a name, description, input schema, and handler function. It also provides factory functions to create different types of registrars: one that registers tools directly with the MCP server and collects entries for REPL mode, and another that only collects entries without registering with the MCP server (for pure REPL mode). The registrars handle error logging and result formatting to ensure consistent behavior across different tool implementations.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./logger.js`
|
||||
* - `./tool-result.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolHandler` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolEntry` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createToolRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createDualRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createCollectorRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolHandler**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolEntry**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createToolRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createDualRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createCollectorRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { errorResult, jsonResult } from "./tool-result.js";
|
||||
/**
|
||||
* Creates a {@link ToolRegistrar} that registers each tool directly with a
|
||||
* live `McpServer`. Its handler wrapper is the one place that logs
|
||||
* `debug`-level start/completion (or `error` on failure), converts a
|
||||
* success into a `CallToolResult` via {@link jsonResult}, and catches any
|
||||
* thrown error — converting it via {@link errorResult} — so a failing call
|
||||
* always resolves rather than rejects the MCP request.
|
||||
*/
|
||||
export function createToolRegistrar(server, logger) {
|
||||
return (name, description, inputSchema, handler) => {
|
||||
server.registerTool(name, { description, inputSchema }, async (args) => {
|
||||
try {
|
||||
logger.debug("Tool invocation started", { tool: name });
|
||||
const result = await handler(args);
|
||||
logger.debug("Tool invocation completed", { tool: name });
|
||||
return jsonResult(name, result);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("Tool invocation failed", {
|
||||
tool: name,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return errorResult(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Registrar that also collects tool entries for REPL mode. Delegates to
|
||||
* {@link createToolRegistrar} and additionally pushes a plain
|
||||
* {@link ToolEntry}, so one call would both register a tool AND make it
|
||||
* directly invokable. Not currently used — `index.ts` builds REPL entries
|
||||
* via {@link createCollectorRegistrar} instead.
|
||||
*/
|
||||
export function createDualRegistrar(server, logger, collector) {
|
||||
const mcpRegistrar = createToolRegistrar(server, logger);
|
||||
return (name, description, inputSchema, handler) => {
|
||||
mcpRegistrar(name, description, inputSchema, handler);
|
||||
collector.push({ name, description, handler });
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Registrar that only collects (no MCP server, for pure REPL mode). Used by
|
||||
* `collectAllTools` to build the REPL tool list with no protocol overhead —
|
||||
* thrown errors propagate as real exceptions to the REPL's own try/catch.
|
||||
*/
|
||||
export function createCollectorRegistrar(collector) {
|
||||
return (name, description, _inputSchema, handler) => {
|
||||
collector.push({ name, description, handler });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @file tool-result.ts
|
||||
* @description Utility functions for formatting tool results in the MCP server. This module provides helper functions to create standardized result objects for successful tool calls (jsonResult) and error cases (errorResult). The jsonResult function formats the output with a title and pretty-printed JSON payload, while the errorResult function handles both known API errors and generic errors, ensuring that error information is consistently structured for the MCP client to display. These utilities help maintain a clear contract for tool handlers when returning results or errors.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `jsonResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `errorResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **jsonResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **errorResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { ApiError } from "../clients/dashboard-api-client.js";
|
||||
/**
|
||||
* Wraps a successful handler return value into the MCP `CallToolResult`
|
||||
* shape. Called only from {@link createToolRegistrar}'s handler wrapper.
|
||||
* The result is a single `text` block: the tool name as a title, then the
|
||||
* payload pretty-printed as JSON — a display convenience, not a
|
||||
* machine-readable envelope.
|
||||
*/
|
||||
export function jsonResult(title, payload) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${title}\n\n${JSON.stringify(payload, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Converts a thrown error into an `isError: true` `CallToolResult`, called
|
||||
* only from {@link createToolRegistrar}'s catch block so a failing tool
|
||||
* always resolves rather than rejects. An {@link ApiError} (raised by
|
||||
* {@link DashboardApiClient} for any non-2xx response, timeout, or network
|
||||
* failure) surfaces its own `code`/`status`/`details`; any other error
|
||||
* (including policy-guard failures) collapses to a generic `INTERNAL_ERROR`
|
||||
* with just the message.
|
||||
*/
|
||||
export function errorResult(error) {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
error: error.message,
|
||||
code: error.code ?? null,
|
||||
status: error.status ?? null,
|
||||
details: error.details ?? null,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
error: message,
|
||||
code: "INTERNAL_ERROR",
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description The main entry point for the MCP application, responsible for initializing the server, loading configuration, setting up logging, and starting the appropriate transport based on configuration or command-line arguments. The application supports multiple transport modes (stdio, http, repl) and includes graceful shutdown handling. It also collects tools and registers them with the server when using HTTP or REPL transports. The main function orchestrates the startup process and ensures that any unhandled errors are logged before exiting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./clients/dashboard-api-client.js`
|
||||
* - `./config/app-config.js`
|
||||
* - `./core/logger.js`
|
||||
* - `./server.js`
|
||||
* - `./transports/http-server.js`
|
||||
* - `./transports/repl.js`
|
||||
* - `./transports/tool-collector.js`
|
||||
* - `./ui/banner.js`
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { DashboardApiClient } from "./clients/dashboard-api-client.js";
|
||||
import { loadConfig } from "./config/app-config.js";
|
||||
import { Logger } from "./core/logger.js";
|
||||
import { buildServer } from "./server.js";
|
||||
import { startHttpServer } from "./transports/http-server.js";
|
||||
import { startRepl } from "./transports/repl.js";
|
||||
import { collectAllTools } from "./transports/tool-collector.js";
|
||||
import { printShutdown } from "./ui/banner.js";
|
||||
/**
|
||||
* Determines the final {@link TransportMode}, letting CLI flags override the
|
||||
* `MCP_TRANSPORT` env value passed as `env`. Priority: explicit
|
||||
* `--transport=<mode>`, then bare `--repl`/`--http`, then `env`. An
|
||||
* unrecognized `--transport=` value falls through rather than throwing.
|
||||
*/
|
||||
function resolveTransport(env) {
|
||||
const cliArg = process.argv.find((a) => a.startsWith("--transport="));
|
||||
if (cliArg) {
|
||||
const val = cliArg.split("=")[1]?.toLowerCase();
|
||||
if (val === "stdio" || val === "http" || val === "repl")
|
||||
return val;
|
||||
}
|
||||
if (process.argv.includes("--repl"))
|
||||
return "repl";
|
||||
if (process.argv.includes("--http"))
|
||||
return "http";
|
||||
return env;
|
||||
}
|
||||
/**
|
||||
* Process entry point. Loads config, resolves the transport, and starts one
|
||||
* of three modes: **stdio** (default) — one `McpServer` via
|
||||
* {@link buildServer} over `StdioServerTransport`, how an MCP host like
|
||||
* Claude Code talks to this process, no console UI since stdout is the
|
||||
* JSON-RPC channel; **http** — {@link startHttpServer} builds a fresh
|
||||
* `McpServer` per client session; **repl** — tags each
|
||||
* {@link collectAllTools} tool with its domain and hands off to
|
||||
* {@link startRepl}, which owns the lifecycle from there (this function
|
||||
* returns immediately, skipping the signal setup below).
|
||||
*
|
||||
* For stdio/http, installs `SIGINT`/`SIGTERM` handlers invoking the
|
||||
* transport's `shutdownFn`, plus `unhandledRejection`/`uncaughtException`
|
||||
* handlers logging via {@link Logger} — the latter sets `process.exitCode = 1`
|
||||
* without exiting immediately, letting in-flight work finish.
|
||||
*/
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const transport = resolveTransport(config.transport);
|
||||
const logger = new Logger(config.logLevel);
|
||||
const api = new DashboardApiClient(config, logger);
|
||||
let shutdownFn;
|
||||
// ── stdio mode (default, backward compatible) ───────────────
|
||||
if (transport === "stdio") {
|
||||
const server = buildServer(config, api, logger);
|
||||
const stdioTransport = new StdioServerTransport();
|
||||
await server.connect(stdioTransport);
|
||||
logger.info("Agent Dashboard MCP server started", {
|
||||
serverName: config.serverName,
|
||||
serverVersion: config.serverVersion,
|
||||
dashboardBaseUrl: config.dashboardBaseUrl.toString(),
|
||||
allowMutations: config.allowMutations,
|
||||
allowDestructive: config.allowDestructive,
|
||||
transport: "stdio",
|
||||
});
|
||||
shutdownFn = async () => {
|
||||
await stdioTransport.close?.();
|
||||
await server.close();
|
||||
};
|
||||
}
|
||||
// ── HTTP mode (SSE + Streamable HTTP) ───────────────────────
|
||||
else if (transport === "http") {
|
||||
const toolEntries = collectAllTools(config, api, logger);
|
||||
const { shutdown } = await startHttpServer(config, () => {
|
||||
const s = buildServer(config, api, logger);
|
||||
return s;
|
||||
}, logger, toolEntries.length);
|
||||
shutdownFn = shutdown;
|
||||
}
|
||||
// ── REPL mode (interactive CLI) ─────────────────────────────
|
||||
else if (transport === "repl") {
|
||||
const TOOL_DOMAINS = {
|
||||
dashboard_health_check: "observability",
|
||||
dashboard_get_stats: "observability",
|
||||
dashboard_get_analytics: "observability",
|
||||
dashboard_get_system_info: "observability",
|
||||
dashboard_export_data: "observability",
|
||||
dashboard_get_operational_snapshot: "observability",
|
||||
dashboard_list_sessions: "sessions",
|
||||
dashboard_get_session: "sessions",
|
||||
dashboard_create_session: "sessions",
|
||||
dashboard_update_session: "sessions",
|
||||
dashboard_list_agents: "agents",
|
||||
dashboard_get_agent: "agents",
|
||||
dashboard_create_agent: "agents",
|
||||
dashboard_update_agent: "agents",
|
||||
dashboard_list_events: "events",
|
||||
dashboard_ingest_hook_event: "events",
|
||||
dashboard_get_pricing_rules: "pricing",
|
||||
dashboard_get_total_cost: "pricing",
|
||||
dashboard_get_session_cost: "pricing",
|
||||
dashboard_upsert_pricing_rule: "pricing",
|
||||
dashboard_delete_pricing_rule: "pricing",
|
||||
dashboard_reset_pricing_defaults: "pricing",
|
||||
dashboard_cleanup_data: "maintenance",
|
||||
dashboard_reimport_history: "maintenance",
|
||||
dashboard_reinstall_hooks: "maintenance",
|
||||
dashboard_clear_all_data: "maintenance",
|
||||
};
|
||||
const toolEntries = collectAllTools(config, api, logger);
|
||||
const replTools = toolEntries.map((t) => ({
|
||||
...t,
|
||||
domain: TOOL_DOMAINS[t.name] ?? "unknown",
|
||||
}));
|
||||
await startRepl(config, api, logger, replTools);
|
||||
return; // REPL handles its own lifecycle
|
||||
}
|
||||
// ── Graceful shutdown ───────────────────────────────────────
|
||||
const onSignal = async (signal) => {
|
||||
logger.info(`Received ${signal}, shutting down`);
|
||||
if (transport !== "stdio")
|
||||
printShutdown();
|
||||
await shutdownFn?.();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", () => onSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error("Unhandled promise rejection", {
|
||||
reason: reason instanceof Error ? reason.message : String(reason),
|
||||
});
|
||||
});
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught exception", { error: error.message });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
// Top-level guard for startup failures (e.g. loadConfig() rejecting an
|
||||
// invalid MCP_DASHBOARD_BASE_URL). Hand-writes one Logger.error-shaped JSON
|
||||
// line to stderr, since no Logger instance may exist yet, then exits non-zero.
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error",
|
||||
message: "Fatal startup error",
|
||||
meta: { error: message },
|
||||
}, null, 2)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file tool-guards.ts
|
||||
* @description Guard functions to check if mutating and destructive tools are enabled based on the application configuration. These functions throw errors with informative messages if the required permissions are not granted, guiding developers to enable the necessary environment variables to use these tools. The assertMutationsEnabled function checks for general mutation permissions, while the assertDestructiveEnabled function checks for both mutation and destructive permissions, as well as validating a confirmation token to prevent accidental use of destructive tools.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `assertMutationsEnabled` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `assertDestructiveEnabled` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **assertMutationsEnabled**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **assertDestructiveEnabled**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
/**
|
||||
* Two policy tiers gate every write-capable tool, checked only here:
|
||||
* 1. **Mutations** (`config.allowMutations`, `MCP_DASHBOARD_ALLOW_MUTATIONS`)
|
||||
* — required by any create/update/reset/cleanup tool. Off by default, so
|
||||
* the server is read-only unless explicitly opted in.
|
||||
* 2. **Destructive** (`config.allowDestructive`, `MCP_DASHBOARD_ALLOW_DESTRUCTIVE`)
|
||||
* — a strictly higher tier on top of mutations, required only by
|
||||
* `dashboard_clear_all_data`.
|
||||
* Every write-tool handler calls one of these two functions first, before
|
||||
* any API call, so a disabled tier fails fast with no side effects.
|
||||
*/
|
||||
/**
|
||||
* Throws if mutating tools are disabled. Called first by every tool that
|
||||
* creates/updates/deletes/resets/cleans up dashboard state; read-only tools
|
||||
* (list/get/health/stats/analytics/export) never call this.
|
||||
* @throws {Error} naming `MCP_DASHBOARD_ALLOW_MUTATIONS=true` if
|
||||
* `config.allowMutations` is `false`.
|
||||
*/
|
||||
export function assertMutationsEnabled(config) {
|
||||
if (!config.allowMutations) {
|
||||
throw new Error("Mutating tools are disabled. Set MCP_DASHBOARD_ALLOW_MUTATIONS=true to enable them.");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Guards the single most dangerous tool in the server —
|
||||
* `dashboard_clear_all_data`, which deletes every session/agent/event/
|
||||
* token-usage row. A three-part gate checked in order: mutations, then the
|
||||
* destructive flag, then the confirmation token, so the common
|
||||
* misconfiguration (mutations off) always surfaces the more general error
|
||||
* first.
|
||||
* @param confirmationToken Must exactly equal `"CLEAR_ALL_DATA"` — a
|
||||
* deliberate, unguessable-by-accident confirmation, not a secret.
|
||||
* @throws {Error} if mutations are disabled, `config.allowDestructive` is
|
||||
* `false`, or the token doesn't match exactly.
|
||||
*/
|
||||
export function assertDestructiveEnabled(config, confirmationToken) {
|
||||
assertMutationsEnabled(config);
|
||||
if (!config.allowDestructive) {
|
||||
throw new Error("Destructive tools are disabled. Set MCP_DASHBOARD_ALLOW_DESTRUCTIVE=true to enable them.");
|
||||
}
|
||||
if (confirmationToken !== "CLEAR_ALL_DATA") {
|
||||
throw new Error('Invalid confirmation_token. Expected exact value: "CLEAR_ALL_DATA".');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file server.ts
|
||||
* @description Main entry point for building the MCP server. This module defines the buildServer function, which initializes a new MCP server instance with the provided configuration, API client, and logger. It also registers all tools by calling the registerAllTools function, which sets up the tool handlers for the server. The buildServer function returns the configured MCP server instance, ready to be started and handle incoming requests from the MCP client. This module serves as the central place for assembling the server components and ensuring that all necessary tools are registered before the server starts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./config/app-config.js`
|
||||
* - `./clients/dashboard-api-client.js`
|
||||
* - `./core/logger.js`
|
||||
* - `./tools/index.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `buildServer` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **buildServer**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { registerAllTools } from "./tools/index.js";
|
||||
/**
|
||||
* Constructs one fully-configured `McpServer` with every `dashboard_*` tool
|
||||
* registered. A factory, not a singleton: stdio calls it once, while the
|
||||
* HTTP transport calls it once per new client session (Streamable HTTP or
|
||||
* legacy SSE), giving each session isolated server state while sharing the
|
||||
* same {@link AppConfig}/{@link DashboardApiClient}.
|
||||
* @returns A new `McpServer` with all six tool domains registered, ready to
|
||||
* `connect()` to a transport.
|
||||
*/
|
||||
export function buildServer(config, api, logger) {
|
||||
const server = new McpServer({
|
||||
name: config.serverName,
|
||||
version: config.serverVersion,
|
||||
});
|
||||
registerAllTools({
|
||||
server,
|
||||
config,
|
||||
api,
|
||||
logger,
|
||||
});
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file agent-tools.ts
|
||||
* @description Defines and registers tools for managing agents in the dashboard, including listing agents with filters, retrieving agent details, creating new agents, and updating existing agents. Each tool includes input validation using Zod schemas and interacts with the dashboard API to perform the necessary operations. The tools also check for mutation permissions before allowing changes to agent data, ensuring that the application configuration is respected.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerAgentTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerAgentTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { AgentStatusSchema, JsonObjectSchema } from "../schemas.js";
|
||||
/**
|
||||
* Registers the four agent-management tools backing `/api/agents/*`. List/
|
||||
* get are unconditional reads; create/update call
|
||||
* {@link assertMutationsEnabled} first. Agents mirror Claude Code's own
|
||||
* main-agent/subagent model: one main agent plus zero or more subagents
|
||||
* (`type: "subagent"`, optional `subagent_type`, linked via `parent_agent_id`).
|
||||
*/
|
||||
export function registerAgentTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Policy: none. Input: limit (1-500, default 50), offset (default 0),
|
||||
// status/session_id (optional). Calls GET /api/agents?... — the dashboard
|
||||
// honors only ONE of status/session_id per call (session_id wins,
|
||||
// ignoring limit/offset), so passing both doesn't intersect-filter.
|
||||
// Output: { agents, limit, offset }, each agent's own cost attached (from
|
||||
// its metadata token buckets, not its session's total).
|
||||
register("dashboard_list_agents", "List agents with optional status/session filters and pagination.", {
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
session_id: z.string().min(1).max(256).optional(),
|
||||
}, async (args) => {
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
return api.get("/api/agents", {
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
status: args.status,
|
||||
session_id: args.session_id,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: none. Input: agent_id (required). Calls GET /api/agents/:id.
|
||||
// Output: { agent } — 404s (ApiError, NOT_FOUND) if missing; unlike
|
||||
// dashboard_list_agents, no per-agent cost is attached.
|
||||
register("dashboard_get_agent", "Get a single agent by ID.", {
|
||||
agent_id: z.string().min(1).max(256),
|
||||
}, async (args) => {
|
||||
const agentId = args.agent_id;
|
||||
return api.get(`/api/agents/${encodeURIComponent(agentId)}`);
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: id/session_id/name (required); type
|
||||
// (default "main"), subagent_type, status (default "waiting"), task,
|
||||
// parent_agent_id, metadata (all optional). Calls POST /api/agents.
|
||||
// Output: { agent, created } — an existing id returns as-is (created: false).
|
||||
register("dashboard_create_agent", "Create a new agent in a session.", {
|
||||
id: z.string().min(1).max(256),
|
||||
session_id: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(500),
|
||||
type: z.enum(["main", "subagent"]).optional(),
|
||||
subagent_type: z.string().max(128).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
task: z.string().max(5000).optional(),
|
||||
parent_agent_id: z.string().max(256).optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/agents", {
|
||||
body: {
|
||||
id: args.id,
|
||||
session_id: args.session_id,
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
subagent_type: args.subagent_type,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
parent_agent_id: args.parent_agent_id,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: agent_id (required);
|
||||
// name/status/task/current_tool/ended_at/metadata optional — current_tool
|
||||
// is nullable (explicitly clearable) and preserved when omitted entirely.
|
||||
// Calls PATCH /api/agents/:id. Output: { agent } — 404s if missing.
|
||||
register("dashboard_update_agent", "Update an existing agent's lifecycle state and metadata.", {
|
||||
agent_id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
task: z.string().max(5000).optional(),
|
||||
current_tool: z.string().max(256).nullable().optional(),
|
||||
ended_at: z.string().datetime().optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const agentId = args.agent_id;
|
||||
return api.patch(`/api/agents/${encodeURIComponent(agentId)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
current_tool: args.current_tool,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file event-tools.ts
|
||||
* @description Defines tools related to event management in the dashboard, including listing events with optional filters and ingesting hook events from Claude Code. The tools are registered with the tool registry and include input validation using Zod schemas. The event listing tool supports pagination and session filtering, while the hook event ingestion tool allows for adding new events into the dashboard pipeline, with a guard to ensure that mutations are enabled in the configuration.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerEventTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerEventTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { HookTypeSchema, JsonObjectSchema } from "../schemas.js";
|
||||
/**
|
||||
* Registers the two event-related tools: a read-only list and a mutation
|
||||
* that feeds the same ingestion pipeline the installed Claude Code hooks
|
||||
* use (`scripts/hook-handler.js` → `POST /api/hooks/event`) — the one domain
|
||||
* where a tool can inject data into the dashboard's real-time pipeline
|
||||
* (websocket broadcast + alert evaluation), useful for testing hook
|
||||
* behavior without a live Claude Code session.
|
||||
*/
|
||||
export function registerEventTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Policy: none. Input: limit (1-200, default 50), offset (default 0),
|
||||
// session_id (optional). Calls GET /api/events?limit&offset&session_id.
|
||||
// Output: paginated event rows, most recent first.
|
||||
register("dashboard_list_events", "List events with optional session filter and pagination.", {
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
session_id: z.string().min(1).max(256).optional(),
|
||||
}, async (args) => {
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
return api.get("/api/events", {
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
session_id: args.session_id,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: hook_type (one of the seven Claude
|
||||
// Code hook names); data (arbitrary JSON — MUST include session_id, which
|
||||
// the dashboard uses to target the session). Calls POST /api/hooks/event,
|
||||
// the same endpoint scripts/hook-handler.js posts to on every real hook
|
||||
// firing. Output: { ok: true, event }. Side effects: bumps the session's
|
||||
// updated_at, broadcasts "new_event" over websocket, fire-and-forget
|
||||
// evaluates alert rules (failures swallowed), and — only for
|
||||
// "SubagentStop" with a transcript_path — scans that session's subagent
|
||||
// JSONL files for tool calls not yet recorded as events (the only path
|
||||
// that attributes subagent tool_use to the right agent_id, since those
|
||||
// never fire their own hooks). Throws (ApiError, MISSING_SESSION) if data
|
||||
// has no session_id.
|
||||
register("dashboard_ingest_hook_event", "Ingest one Claude Code hook event into the dashboard pipeline.", {
|
||||
hook_type: HookTypeSchema,
|
||||
data: JsonObjectSchema,
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/hooks/event", {
|
||||
body: {
|
||||
hook_type: args.hook_type,
|
||||
data: args.data,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file maintenance-tools.ts
|
||||
* @description Defines a set of maintenance tools for the MCP dashboard, including functions to clean up stale sessions, re-import legacy data, reinstall hooks, and clear all data. These tools are registered with the MCP server and include appropriate guards to ensure that mutating and destructive actions are only performed when explicitly allowed in the configuration. The tools interact with the MCP server's API to perform the necessary maintenance tasks, providing a way for administrators to manage the dashboard's data and settings effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerMaintenanceTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerMaintenanceTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertDestructiveEnabled, assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
/**
|
||||
* Registers four administrative tools against `/api/settings/*`. All four
|
||||
* require mutations; `dashboard_clear_all_data` additionally requires the
|
||||
* destructive tier plus an exact confirmation token, since it's the only
|
||||
* irreversible one (cleanup only touches stale/old rows; reimport and
|
||||
* reinstall-hooks are idempotent, repeatable operations).
|
||||
*/
|
||||
export function registerMaintenanceTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Policy: MUTATIONS required (checked before the "at least one field"
|
||||
// validation below). Input: abandon_hours (1 to 24*365) and/or purge_days
|
||||
// (1-3650) — at least one required. Calls POST /api/settings/cleanup.
|
||||
// abandon_hours marks "active" sessions with no recent events as
|
||||
// "abandoned" (completing lingering agents); purge_days permanently
|
||||
// deletes terminal sessions (+ agents/events) older than N days. Output:
|
||||
// { abandoned, purged_sessions, purged_events, purged_agents } counts.
|
||||
register("dashboard_cleanup_data", "Maintenance: abandon stale sessions and/or purge old completed data.", {
|
||||
abandon_hours: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(24 * 365)
|
||||
.optional(),
|
||||
purge_days: z.number().int().min(1).max(3650).optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const abandonHours = args.abandon_hours;
|
||||
const purgeDays = args.purge_days;
|
||||
if (abandonHours === undefined && purgeDays === undefined) {
|
||||
throw new Error("At least one field is required: abandon_hours or purge_days.");
|
||||
}
|
||||
return api.post("/api/settings/cleanup", {
|
||||
body: {
|
||||
abandon_hours: abandonHours,
|
||||
purge_days: purgeDays,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reimport, invoking
|
||||
// scripts/import-history.js against ~/.claude session-history JSONL files
|
||||
// — useful for backfilling sessions that predate hook installation or
|
||||
// recovering after a reset. Output: { ok: true, ...result }. Throws
|
||||
// (ApiError, IMPORT_FAILED) if the import script itself throws.
|
||||
register("dashboard_reimport_history", "Re-import legacy Claude sessions from ~/.claude into the local dashboard database.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reimport");
|
||||
});
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reinstall-hooks,
|
||||
// invoking scripts/install-hooks.js to (re)write the seven hook entries
|
||||
// (PreToolUse/PostToolUse/Stop/SubagentStop/Notification/SessionStart/
|
||||
// SessionEnd) into ~/.claude/settings.json, overwriting any existing
|
||||
// config. Output: { ok, hooks } — same shape as dashboard_get_system_info.
|
||||
register("dashboard_reinstall_hooks", "Reinstall Claude Code hooks in ~/.claude/settings.json.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reinstall-hooks");
|
||||
});
|
||||
// Policy: DESTRUCTIVE required — the strictest gate in the server. Input:
|
||||
// confirmation_token, must exactly equal "CLEAR_ALL_DATA". Calls
|
||||
// POST /api/settings/clear-data, irreversibly deleting every row from
|
||||
// sessions, agents, events, token_usage, alert_events, and
|
||||
// webhook_deliveries — but preserving alert rules, webhook targets, and
|
||||
// pricing rules (user configuration, not activity data). Output:
|
||||
// { ok: true, cleared } with pre-deletion row counts. No undo; the only
|
||||
// tool gated by MCP_DASHBOARD_ALLOW_DESTRUCTIVE.
|
||||
register("dashboard_clear_all_data", "Delete all tracked sessions, agents, events, and token usage. Highly destructive.", {
|
||||
confirmation_token: z.string().min(1),
|
||||
}, async (args) => {
|
||||
const confirmationToken = args.confirmation_token;
|
||||
assertDestructiveEnabled(config, confirmationToken);
|
||||
return api.post("/api/settings/clear-data");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @file observability-tools.ts
|
||||
* @description Tool registration for observability-related tools in the MCP server. This module defines a set of tools that interact with the Agent Dashboard API to provide health checks, stats, analytics, system information, data export, and operational snapshots. These tools enable users to monitor and analyze the performance and usage of their agents and sessions through the dashboard. Each tool is registered with a name, description, input schema (if applicable), and an asynchronous handler function that makes API calls to retrieve the necessary data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../types/tool-context.js`
|
||||
* - `../../core/tool-registry.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerObservabilityTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerObservabilityTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
/**
|
||||
* Registers the six read-only observability tools. None call
|
||||
* {@link assertMutationsEnabled}/{@link assertDestructiveEnabled} — all are
|
||||
* plain GETs, always available regardless of policy flags.
|
||||
* `dashboard_get_operational_snapshot` is the only one fanning out to
|
||||
* multiple endpoints in parallel rather than proxying a single one.
|
||||
*/
|
||||
export function registerObservabilityTools(context) {
|
||||
const { api, logger, server } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Calls GET /api/health. Output: dashboard liveness payload — a fast
|
||||
// pre-flight check, since every other tool needs the dashboard running at
|
||||
// config.dashboardBaseUrl or it fails with an ApiError network/timeout.
|
||||
register("dashboard_health_check", "Check health of the local Agent Dashboard API.", {}, async () => api.get("/api/health"));
|
||||
// Calls GET /api/stats. Output: session/agent counts by status,
|
||||
// events-today, and live websocket connection count.
|
||||
register("dashboard_get_stats", "Get dashboard overview stats including session/agent counts and websocket connections.", {}, async () => api.get("/api/stats"));
|
||||
// Calls GET /api/analytics. Output: token totals/cost, per-tool usage
|
||||
// counts, daily event/session counts, agent type distribution, and
|
||||
// event-type breakdown — backs the dashboard's Analytics page.
|
||||
register("dashboard_get_analytics", "Get analytics summary including token totals, usage trends, and distributions.", {}, async () => api.get("/api/analytics"));
|
||||
// Calls GET /api/settings/info. Output: SQLite path/size/counts/pragmas,
|
||||
// recent ingestion load (5/15/60 min), Claude Code hook install status,
|
||||
// and Node/OS process info (uptime, memory, cpu, ws connections).
|
||||
register("dashboard_get_system_info", "Get system info, DB stats, and hook installation status.", {}, async () => api.get("/api/settings/info"));
|
||||
// Calls GET /api/settings/export. Output: the full dashboard dataset —
|
||||
// sessions, agents, events, token_usage, pricing rules — same payload the
|
||||
// UI's "Export Data" button downloads (its attachment header has no
|
||||
// effect on this client).
|
||||
register("dashboard_export_data", "Export complete dashboard data payload (sessions, agents, events, tokens, pricing).", {}, async () => api.get("/api/settings/export"));
|
||||
// Input: three optional per-section limits, each defaulted below. Fans
|
||||
// out via Promise.all to GET /api/stats, /api/analytics, /api/events,
|
||||
// /api/sessions?status=active, and /api/agents queried twice
|
||||
// (status=working, status=connected — the dashboard filters one status
|
||||
// per call). Output: one combined { stats, analytics, recent_events,
|
||||
// active_sessions, active_agents: {working, connected}, generated_at }.
|
||||
register("dashboard_get_operational_snapshot", "Get a high-signal operational snapshot combining stats, analytics, active sessions, active agents, and recent events.", {
|
||||
recent_events_limit: z.number().int().min(1).max(50).optional(),
|
||||
active_sessions_limit: z.number().int().min(1).max(100).optional(),
|
||||
active_agents_limit: z.number().int().min(1).max(200).optional(),
|
||||
}, async (args) => {
|
||||
const eventsLimit = args.recent_events_limit ?? 20;
|
||||
const sessionsLimit = args.active_sessions_limit ?? 25;
|
||||
const agentsLimit = args.active_agents_limit ?? 100;
|
||||
const [stats, analytics, recentEvents, activeSessions, workingAgents, connectedAgents] = await Promise.all([
|
||||
api.get("/api/stats"),
|
||||
api.get("/api/analytics"),
|
||||
api.get("/api/events", { query: { limit: eventsLimit, offset: 0 } }),
|
||||
api.get("/api/sessions", {
|
||||
query: { status: "active", limit: sessionsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", {
|
||||
query: { status: "working", limit: agentsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", {
|
||||
query: { status: "connected", limit: agentsLimit, offset: 0 },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
stats,
|
||||
analytics,
|
||||
recent_events: recentEvents,
|
||||
active_sessions: activeSessions,
|
||||
active_agents: {
|
||||
working: workingAgents,
|
||||
connected: connectedAgents,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file pricing-tools.ts
|
||||
* @description Tool registration for pricing-related functionalities in the dashboard. This includes tools for retrieving pricing rules and calculating total costs based on usage. The tools interact with the backend API to fetch the necessary data and perform calculations as needed. The file also includes input validation using Zod schemas to ensure that the tool arguments are correctly formatted before processing. These tools are essential for providing users with insights into their costs and helping them manage their usage effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerPricingTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerPricingTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
/**
|
||||
* Registers six tools covering `/api/pricing/*` plus the pricing-adjacent
|
||||
* `/api/settings/reset-pricing`. Reads are always available; writes
|
||||
* (upsert/delete/reset) require {@link assertMutationsEnabled}. Costs are
|
||||
* priced as of the usage date (session start date), not today's rate, so
|
||||
* historical costs stay correct across a promotional-rate cutover.
|
||||
*/
|
||||
export function registerPricingTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Policy: none. Calls GET /api/pricing. Output: all model_pricing rows
|
||||
// (model_pattern, display_name, per-million-token rates).
|
||||
register("dashboard_get_pricing_rules", "List all model pricing rules used for cost calculations.", {}, async () => api.get("/api/pricing"));
|
||||
// Policy: none. Calls GET /api/pricing/cost. Output: aggregate cost/token
|
||||
// totals across all sessions plus a per-day daily_costs breakdown, each
|
||||
// day priced at the rate effective on that date.
|
||||
register("dashboard_get_total_cost", "Get total model usage cost across all tracked sessions.", {}, async () => api.get("/api/pricing/cost"));
|
||||
// Policy: none. Input: session_id (required). Calls
|
||||
// GET /api/pricing/cost/:sessionId. Output: cost/token breakdown for that
|
||||
// session, priced as of its start date.
|
||||
register("dashboard_get_session_cost", "Get model usage cost breakdown for one session.", {
|
||||
session_id: z.string().min(1).max(256),
|
||||
}, async (args) => {
|
||||
const sessionId = args.session_id;
|
||||
return api.get(`/api/pricing/cost/${encodeURIComponent(sessionId)}`);
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: model_pattern + display_name
|
||||
// (required); input/output/cache_read/cache_write rates (optional,
|
||||
// defaulted to 0 here). Calls PUT /api/pricing — a true `INSERT ...
|
||||
// ON CONFLICT DO UPDATE` upsert (unlike sessions/agents' create-if-absent):
|
||||
// an existing rule is fully overwritten. CAUTION: cache_write_1h_per_mtok/
|
||||
// fast_input_per_mtok/fast_output_per_mtok aren't exposed here, so
|
||||
// upserting an existing rule silently zeroes those columns. Time-limited
|
||||
// intro_* rates are untouched (server only rewrites them when an intro_*
|
||||
// field is sent). Output: the upserted rule.
|
||||
register("dashboard_upsert_pricing_rule", "Create or update a pricing rule.", {
|
||||
model_pattern: z.string().min(1).max(256),
|
||||
display_name: z.string().min(1).max(256),
|
||||
input_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
output_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
cache_read_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
cache_write_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.put("/api/pricing", {
|
||||
body: {
|
||||
model_pattern: args.model_pattern,
|
||||
display_name: args.display_name,
|
||||
input_per_mtok: args.input_per_mtok ?? 0,
|
||||
output_per_mtok: args.output_per_mtok ?? 0,
|
||||
cache_read_per_mtok: args.cache_read_per_mtok ?? 0,
|
||||
cache_write_per_mtok: args.cache_write_per_mtok ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: model_pattern (exact match). Calls
|
||||
// DELETE /api/pricing/:model_pattern. Output: { ok: true }. Throws
|
||||
// (ApiError, NOT_FOUND) if no rule matches.
|
||||
register("dashboard_delete_pricing_rule", "Delete one pricing rule by exact model_pattern.", {
|
||||
model_pattern: z.string().min(1).max(256),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.delete(`/api/pricing/${encodeURIComponent(args.model_pattern)}`);
|
||||
});
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reset-pricing,
|
||||
// which deletes ALL rules (including custom ones) and reseeds the
|
||||
// built-in defaults, then re-applies any active intro-rate promos so they
|
||||
// aren't lost. Output: { ok: true, pricing: [...] } — the reseeded list.
|
||||
register("dashboard_reset_pricing_defaults", "Reset pricing rules to dashboard defaults.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reset-pricing");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file remote-tools.ts
|
||||
* @description MCP tools for Remote Data Sources — list configured SSH sources
|
||||
* and trigger on-demand syncs so agents can operate remotes without the UI/CLI.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
/**
|
||||
* Registers remote-source tools against `/api/remote-sources/*`.
|
||||
* List is read-only; sync tools require the mutations policy gate.
|
||||
*/
|
||||
export function registerRemoteTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
register("dashboard_list_remote_sources", "List configured Remote Data Sources (SSH machines) with status and last sync.", {}, async () => api.get("/api/remote-sources"));
|
||||
register("dashboard_sync_remote_source", "Trigger an immediate SSH pull+import for one Remote Data Source by id.", {
|
||||
source_id: z.string().min(1).describe("Remote source id (src_…)"),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const id = encodeURIComponent(args.source_id);
|
||||
return api.post(`/api/remote-sources/${id}/sync`);
|
||||
});
|
||||
register("dashboard_sync_all_remote_sources", "Trigger an immediate SSH pull+import for every enabled Remote Data Source.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/remote-sources/sync-all");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file session-tools.ts
|
||||
* @description Defines and registers tools for managing sessions in the dashboard, including listing sessions with optional filters, retrieving session details, creating new sessions, and updating existing sessions. Each tool includes input validation using Zod schemas and interacts with the dashboard API to perform the necessary operations. The tools also check for mutation permissions before allowing changes to session data, ensuring that the application configuration is respected.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerSessionTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerSessionTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { SessionStatusSchema, JsonObjectSchema } from "../schemas.js";
|
||||
/**
|
||||
* Registers the four session-management tools backing `/api/sessions/*`.
|
||||
* List/get are read-only; create/update both call
|
||||
* {@link assertMutationsEnabled} first. None are gated by the
|
||||
* destructive-tools flag.
|
||||
*/
|
||||
export function registerSessionTools(context) {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
// Policy: none. Input: limit (1-200, default 50), offset (default 0),
|
||||
// status (optional; omitted means all). Calls
|
||||
// GET /api/sessions?limit&offset&status. Output: { sessions, total, limit,
|
||||
// offset }.
|
||||
register("dashboard_list_sessions", "List sessions with optional status filter and pagination.", {
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
status: SessionStatusSchema.optional(),
|
||||
}, async (args) => {
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
const status = args.status;
|
||||
return api.get("/api/sessions", { query: { limit, offset, status } });
|
||||
});
|
||||
// Policy: none. Input: session_id (required). Calls
|
||||
// GET /api/sessions/:id. Output: { session, agents, events, workflows } —
|
||||
// agents carry their own cost (from agent.metadata token buckets),
|
||||
// workflows are any Workflow-tool runs launched in this session. 404s
|
||||
// (ApiError, NOT_FOUND) if missing.
|
||||
register("dashboard_get_session", "Get one session with its full agents list and event timeline.", {
|
||||
session_id: z.string().min(1).max(256),
|
||||
}, async (args) => {
|
||||
const sessionId = args.session_id;
|
||||
return api.get(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: id (required); name/cwd/model/
|
||||
// metadata (optional). Calls POST /api/sessions. Output: { session,
|
||||
// created } — an existing id returns as-is (created: false), matching how
|
||||
// the hook pipeline lazily creates sessions without erroring on a
|
||||
// duplicate id; a new session starts as "active".
|
||||
register("dashboard_create_session", "Create a new session record if it does not already exist.", {
|
||||
id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
cwd: z.string().max(2048).optional(),
|
||||
model: z.string().max(256).optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/sessions", {
|
||||
body: {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
cwd: args.cwd,
|
||||
model: args.model,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Policy: MUTATIONS required. Input: session_id (required);
|
||||
// name/status/ended_at/metadata (optional; ended_at is ISO-8601). Calls
|
||||
// PATCH /api/sessions/:id. Output: the updated session record.
|
||||
register("dashboard_update_session", "Update session metadata or lifecycle status.", {
|
||||
session_id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
status: SessionStatusSchema.optional(),
|
||||
ended_at: z.string().datetime().optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const sessionId = args.session_id;
|
||||
return api.patch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Main entry point for registering all tools in the MCP application. This module imports and registers tools from various domains, including observability, session management, agent management, event handling, pricing, and maintenance. The registerAllTools function takes a ToolContext as an argument and calls the respective registration functions for each domain to ensure that all tools are properly set up and available for use within the application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../types/tool-context.js`
|
||||
* - `./domains/observability-tools.js`
|
||||
* - `./domains/session-tools.js`
|
||||
* - `./domains/agent-tools.js`
|
||||
* - `./domains/event-tools.js`
|
||||
* - `./domains/pricing-tools.js`
|
||||
* - `./domains/maintenance-tools.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerAllTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerAllTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { registerObservabilityTools } from "./domains/observability-tools.js";
|
||||
import { registerSessionTools } from "./domains/session-tools.js";
|
||||
import { registerAgentTools } from "./domains/agent-tools.js";
|
||||
import { registerEventTools } from "./domains/event-tools.js";
|
||||
import { registerPricingTools } from "./domains/pricing-tools.js";
|
||||
import { registerMaintenanceTools } from "./domains/maintenance-tools.js";
|
||||
import { registerRemoteTools } from "./domains/remote-tools.js";
|
||||
/**
|
||||
* Registers all 29 `dashboard_*` tools with the given {@link ToolContext} in
|
||||
* one call. `server.ts`'s `buildServer` calls this per `McpServer` instance;
|
||||
* `transports/tool-collector.ts`'s `collectAllTools` independently
|
||||
* re-implements the same registrations for REPL mode (no live server), so
|
||||
* the two files must be kept in sync by hand when a tool changes.
|
||||
*/
|
||||
export function registerAllTools(context) {
|
||||
registerObservabilityTools(context);
|
||||
registerSessionTools(context);
|
||||
registerAgentTools(context);
|
||||
registerEventTools(context);
|
||||
registerPricingTools(context);
|
||||
registerMaintenanceTools(context);
|
||||
registerRemoteTools(context);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file schemas.ts
|
||||
* @description Defines common Zod schemas used across different tools in the MCP application, including enumerations for session status, agent status, and hook types, as well as a generic JSON object schema. These schemas are used for input validation in various tools that manage sessions, agents, events, and hooks within the dashboard. By centralizing these schemas, we ensure consistency and reusability across the codebase.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionStatusSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentStatusSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `HookTypeSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `JsonObjectSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionStatusSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AgentStatusSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **HookTypeSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **JsonObjectSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { z } from "zod";
|
||||
/** Session lifecycle states, mirroring the dashboard's `sessions.status`
|
||||
* column. Used by `dashboard_list_sessions`'s `status` filter and
|
||||
* `dashboard_update_session`'s `status` field. Only `"active"` sessions are
|
||||
* eligible for `dashboard_cleanup_data`'s `abandon_hours`; only terminal
|
||||
* states are eligible for its `purge_days`. */
|
||||
export const SessionStatusSchema = z.enum(["active", "completed", "error", "abandoned"]);
|
||||
/** Agent lifecycle states, mirroring `agents.status`. Used by
|
||||
* `dashboard_list_agents`'s `status` filter and `dashboard_create_agent`/
|
||||
* `dashboard_update_agent`'s `status` field; new agents default to
|
||||
* `"waiting"` server-side when omitted. */
|
||||
export const AgentStatusSchema = z.enum(["working", "waiting", "completed", "error"]);
|
||||
/** The seven Claude Code hook lifecycle events the dashboard's ingestion
|
||||
* pipeline understands, matching the hook names Claude Code invokes (wired
|
||||
* into `~/.claude/settings.json` by `scripts/install-hooks.js`). Used only
|
||||
* by `dashboard_ingest_hook_event`'s `hook_type` field — every real hook
|
||||
* firing posts one of these via `scripts/hook-handler.js`. */
|
||||
export const HookTypeSchema = z.enum([
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
]);
|
||||
/** Permissive arbitrary-JSON-object schema, used for the free-form
|
||||
* `metadata` field on session/agent tools and the hook `data` payload in
|
||||
* `dashboard_ingest_hook_event`, whose actual shape varies by `hook_type`
|
||||
* and is validated by the dashboard server itself, not this MCP layer. */
|
||||
export const JsonObjectSchema = z.record(z.unknown());
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file http-server.ts
|
||||
* @description Implements the HTTP server transport for the MCP server, supporting both the newer Streamable HTTP protocol and the legacy SSE-based protocol. The server handles incoming requests, manages active sessions, and routes messages to the appropriate transport handlers. It also includes a health check endpoint and integrates with the MCP server instance to facilitate communication with connected clients. The module provides a shutdown function to gracefully close all active transports and the HTTP server itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../ui/banner.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `startHttpServer` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **startHttpServer**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { printBanner, printServerInfo, printReady, printShutdown } from "../ui/banner.js";
|
||||
import * as c from "../ui/colors.js";
|
||||
/**
|
||||
* Starts the HTTP transport, exposing the current Streamable HTTP protocol
|
||||
* (2025-11-25) and the legacy HTTP+SSE protocol (2024-11-05) side by side on
|
||||
* one Express app. Unlike stdio (one `McpServer` for the whole process),
|
||||
* **every new client session gets its own freshly-built `McpServer`** via
|
||||
* `buildServerFn`, isolated from other sessions but sharing the same
|
||||
* {@link AppConfig}/`DashboardApiClient`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - `GET /health` — liveness/uptime/session-count probe for this MCP
|
||||
* process, distinct from `dashboard_health_check` (which checks the
|
||||
* dashboard itself).
|
||||
* - `ALL /mcp` — Streamable HTTP: a POST `initialize` with no
|
||||
* `mcp-session-id` starts a new session; later requests must carry that
|
||||
* header and route to the matching transport, rejected with a JSON-RPC
|
||||
* `-32000` error on a protocol mismatch.
|
||||
* - `GET /sse` — legacy SSE: a long-lived stream, one `SSEServerTransport` +
|
||||
* `McpServer` pair per connection.
|
||||
* - `POST /messages?sessionId=...` — legacy SSE's client-to-server companion
|
||||
* endpoint (SSE itself is server-to-client only).
|
||||
*
|
||||
* On successful bind, prints the banner/info panel/endpoint table to
|
||||
* stdout — this transport owns stdout, unlike stdio's protocol stream.
|
||||
* @returns The Express `app` and a `shutdown` closing every tracked
|
||||
* transport before the HTTP server itself.
|
||||
*/
|
||||
export async function startHttpServer(config, buildServerFn, logger, toolCount) {
|
||||
const app = createMcpExpressApp({ host: config.httpHost });
|
||||
const transports = new Map();
|
||||
// ── Health endpoint ───────────────────────────────────────────
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
server: config.serverName,
|
||||
version: config.serverVersion,
|
||||
transport: "http",
|
||||
uptime: process.uptime(),
|
||||
activeSessions: transports.size,
|
||||
});
|
||||
});
|
||||
// ── Streamable HTTP (protocol version 2025-11-25) ─────────────
|
||||
app.all("/mcp", async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"];
|
||||
try {
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
const entry = transports.get(sessionId);
|
||||
if (entry.type !== "streamable") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Session uses a different transport protocol" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await entry.transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && isInitializeRequest(req.body)) {
|
||||
logger.info("New Streamable HTTP session");
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid)
|
||||
transports.delete(sid);
|
||||
logger.debug("Streamable HTTP session closed", { sessionId: sid });
|
||||
};
|
||||
const server = buildServerFn();
|
||||
await server.connect(transport);
|
||||
const sid = transport.sessionId ?? randomUUID();
|
||||
transports.set(sid, { transport, type: "streamable" });
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Bad Request: No valid session or initialization" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger.error("Streamable HTTP error", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// ── Legacy SSE transport (protocol version 2024-11-05) ────────
|
||||
app.get("/sse", async (_req, res) => {
|
||||
logger.info("New SSE session");
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
transports.set(transport.sessionId, { transport, type: "sse" });
|
||||
res.on("close", () => {
|
||||
transports.delete(transport.sessionId);
|
||||
logger.debug("SSE session closed", { sessionId: transport.sessionId });
|
||||
});
|
||||
const server = buildServerFn();
|
||||
await server.connect(transport);
|
||||
});
|
||||
app.post("/messages", async (req, res) => {
|
||||
const sessionId = req.query.sessionId;
|
||||
if (!sessionId || !transports.has(sessionId)) {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "No transport found for session" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = transports.get(sessionId);
|
||||
if (entry.type !== "sse") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Session uses a different transport protocol" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await entry.transport.handlePostMessage(req, res, req.body);
|
||||
});
|
||||
// ── Start listening ───────────────────────────────────────────
|
||||
printBanner();
|
||||
printServerInfo({
|
||||
transport: "http (sse + streamable)",
|
||||
version: config.serverVersion,
|
||||
dashboard: config.dashboardBaseUrl.toString(),
|
||||
port: config.httpPort,
|
||||
mutations: config.allowMutations,
|
||||
destructive: config.allowDestructive,
|
||||
tools: toolCount,
|
||||
});
|
||||
const httpServer = await new Promise((resolve, reject) => {
|
||||
const srv = app.listen(config.httpPort, config.httpHost, () => resolve(srv));
|
||||
srv.on("error", reject);
|
||||
});
|
||||
const endpoints = [
|
||||
["Streamable HTTP", `http://${config.httpHost}:${config.httpPort}/mcp`, "POST/GET/DELETE"],
|
||||
["Legacy SSE", `http://${config.httpHost}:${config.httpPort}/sse`, "GET"],
|
||||
["Legacy Messages", `http://${config.httpHost}:${config.httpPort}/messages`, "POST"],
|
||||
["Health", `http://${config.httpHost}:${config.httpPort}/health`, "GET"],
|
||||
];
|
||||
process.stdout.write(` ${c.bold(c.brightCyan("◆"))} ${c.bold(c.brightWhite("Endpoints"))}\n`);
|
||||
for (const [name, url, methods] of endpoints) {
|
||||
process.stdout.write(` ${c.dim(c.cyan("→"))} ${c.label(name.padEnd(20))} ${c.green(url)} ${c.muted(`[${methods}]`)}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
printReady("http");
|
||||
// ── Shutdown ──────────────────────────────────────────────────
|
||||
const shutdown = async () => {
|
||||
printShutdown();
|
||||
const closePromises = [];
|
||||
for (const [sid, entry] of transports) {
|
||||
logger.debug("Closing transport", { sessionId: sid });
|
||||
closePromises.push(entry.transport.close?.().catch((err) => {
|
||||
logger.error("Error closing transport", {
|
||||
sessionId: sid,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}) ?? Promise.resolve());
|
||||
}
|
||||
await Promise.allSettled(closePromises);
|
||||
transports.clear();
|
||||
await new Promise((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
logger.info("HTTP server stopped");
|
||||
};
|
||||
return { app, shutdown };
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @file repl.ts
|
||||
* @description Implements a REPL (Read-Eval-Print Loop) transport for the MCP server, allowing users to interact with the dashboard API and invoke registered tools directly from the command line. The REPL provides an interactive prompt with command history and tab completion for tool names and commands. It supports built-in commands for listing tools, showing configuration, and performing health checks, as well as invoking any registered tool with JSON or key=value arguments. The REPL is designed for ease of use and quick experimentation during development or debugging sessions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../ui/banner.js`
|
||||
* - `../ui/formatter.js`
|
||||
* - `../core/tool-registry.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `startRepl` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **startRepl**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ReplToolCollector**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createReplToolCollector**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import * as readline from "node:readline";
|
||||
import { printBanner, printServerInfo, printShutdown } from "../ui/banner.js";
|
||||
import * as c from "../ui/colors.js";
|
||||
import { formatToolResult, formatToolError, table, sectionHeader, badge, } from "../ui/formatter.js";
|
||||
/** Static `dashboard_<tool> -> domain` lookup mirroring `tools/domains/*.ts`
|
||||
* module boundaries, kept literal since `collectAllTools` has no domain
|
||||
* concept. Must be updated by hand alongside `index.ts`'s identical copy. */
|
||||
const TOOL_DOMAINS = {
|
||||
dashboard_health_check: "observability",
|
||||
dashboard_get_stats: "observability",
|
||||
dashboard_get_analytics: "observability",
|
||||
dashboard_get_system_info: "observability",
|
||||
dashboard_export_data: "observability",
|
||||
dashboard_get_operational_snapshot: "observability",
|
||||
dashboard_list_sessions: "sessions",
|
||||
dashboard_get_session: "sessions",
|
||||
dashboard_create_session: "sessions",
|
||||
dashboard_update_session: "sessions",
|
||||
dashboard_list_agents: "agents",
|
||||
dashboard_get_agent: "agents",
|
||||
dashboard_create_agent: "agents",
|
||||
dashboard_update_agent: "agents",
|
||||
dashboard_list_events: "events",
|
||||
dashboard_ingest_hook_event: "events",
|
||||
dashboard_get_pricing_rules: "pricing",
|
||||
dashboard_get_total_cost: "pricing",
|
||||
dashboard_get_session_cost: "pricing",
|
||||
dashboard_upsert_pricing_rule: "pricing",
|
||||
dashboard_delete_pricing_rule: "pricing",
|
||||
dashboard_reset_pricing_defaults: "pricing",
|
||||
dashboard_cleanup_data: "maintenance",
|
||||
dashboard_reimport_history: "maintenance",
|
||||
dashboard_reinstall_hooks: "maintenance",
|
||||
dashboard_clear_all_data: "maintenance",
|
||||
};
|
||||
const DOMAIN_COLORS = {
|
||||
observability: c.brightCyan,
|
||||
sessions: c.brightGreen,
|
||||
agents: c.brightMagenta,
|
||||
events: c.brightYellow,
|
||||
pricing: (t) => c.bold(c.yellow(t)),
|
||||
maintenance: c.brightRed,
|
||||
};
|
||||
/** Renders a `[domain]` badge in that domain's color, or muted if unknown. */
|
||||
function domainBadge(domain) {
|
||||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||||
return colorFn(`[${domain}]`);
|
||||
}
|
||||
/**
|
||||
* Starts the interactive REPL and owns the process lifecycle from here —
|
||||
* `index.ts` returns immediately, since `readline`'s `"close"` event is this
|
||||
* transport's shutdown path. Unlike stdio/http, it never constructs an
|
||||
* `McpServer`: `tools` (from `collectAllTools`) is a flat, directly-
|
||||
* invokable handler list, so typing a tool name calls its handler
|
||||
* in-process, subject to the same `AppConfig` policy flags.
|
||||
*/
|
||||
export async function startRepl(config, api, logger, tools) {
|
||||
printBanner();
|
||||
printServerInfo({
|
||||
transport: "repl (interactive)",
|
||||
version: config.serverVersion,
|
||||
dashboard: config.dashboardBaseUrl.toString(),
|
||||
mutations: config.allowMutations,
|
||||
destructive: config.allowDestructive,
|
||||
tools: tools.length,
|
||||
});
|
||||
process.stdout.write(` ${c.muted("Type")} ${c.accent("help")} ${c.muted("for commands,")} ${c.accent("tools")} ${c.muted("to list available tools,")} ${c.accent("exit")} ${c.muted("to quit.")}\n\n`);
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: ` ${c.bold(c.brightCyan("mcp"))}${c.dim(c.cyan("›"))} `,
|
||||
completer: (line) => {
|
||||
const allCompletions = [
|
||||
...tools.map((t) => t.name),
|
||||
"help",
|
||||
"tools",
|
||||
"domains",
|
||||
"exit",
|
||||
"quit",
|
||||
"clear",
|
||||
"health",
|
||||
"stats",
|
||||
"status",
|
||||
"config",
|
||||
];
|
||||
const hits = allCompletions.filter((cmd) => cmd.startsWith(line.trim()));
|
||||
return [hits.length ? hits : allCompletions, line];
|
||||
},
|
||||
});
|
||||
const toolMap = new Map();
|
||||
for (const t of tools)
|
||||
toolMap.set(t.name, t);
|
||||
rl.prompt();
|
||||
rl.on("line", async (line) => {
|
||||
const input = line.trim();
|
||||
if (!input) {
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handleCommand(input, config, api, tools, toolMap, logger);
|
||||
}
|
||||
catch (err) {
|
||||
process.stdout.write(` ${c.error("Error:")} ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
}
|
||||
rl.prompt();
|
||||
});
|
||||
rl.on("close", () => {
|
||||
printShutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Dispatches one entered line to a built-in command, or to
|
||||
* {@link invokeToolByName} if it matches a known tool name. Built-ins always
|
||||
* take precedence. `health`/`stats`/`status` are shortcuts invoking
|
||||
* `dashboard_health_check`/`dashboard_get_stats`/
|
||||
* `dashboard_get_operational_snapshot` with no arguments.
|
||||
*/
|
||||
async function handleCommand(input, config, _api, tools, toolMap, logger) {
|
||||
const [command, ...rest] = input.split(/\s+/);
|
||||
const argsRaw = rest.join(" ").trim();
|
||||
switch (command.toLowerCase()) {
|
||||
case "help":
|
||||
printHelp();
|
||||
return;
|
||||
case "tools":
|
||||
printToolList(tools, argsRaw || undefined);
|
||||
return;
|
||||
case "domains":
|
||||
printDomains(tools);
|
||||
return;
|
||||
case "health":
|
||||
await invokeToolByName("dashboard_health_check", {}, toolMap, logger);
|
||||
return;
|
||||
case "stats":
|
||||
await invokeToolByName("dashboard_get_stats", {}, toolMap, logger);
|
||||
return;
|
||||
case "status":
|
||||
await invokeToolByName("dashboard_get_operational_snapshot", {}, toolMap, logger);
|
||||
return;
|
||||
case "config":
|
||||
printConfig(config);
|
||||
return;
|
||||
case "clear":
|
||||
process.stdout.write("\x1b[2J\x1b[0;0H");
|
||||
return;
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
printShutdown();
|
||||
process.exit(0);
|
||||
default:
|
||||
if (toolMap.has(command)) {
|
||||
const args = parseArgs(argsRaw);
|
||||
await invokeToolByName(command, args, toolMap, logger);
|
||||
}
|
||||
else {
|
||||
process.stdout.write(` ${c.warn("?")} Unknown command: ${c.bold(c.brightWhite(command))} ${c.muted("— type 'help' for available commands")}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Invokes a tool handler by name directly (no MCP protocol), printing an
|
||||
* "Invoking..." line then the formatted result/error. This is the REPL's
|
||||
* own error boundary — a thrown error is caught/logged here, not converted
|
||||
* to a `CallToolResult`. Args pass through unvalidated (no Zod check). */
|
||||
async function invokeToolByName(name, args, toolMap, logger) {
|
||||
const tool = toolMap.get(name);
|
||||
if (!tool) {
|
||||
process.stdout.write(` ${c.error("✘")} Tool not found: ${c.bold(name)}\n`);
|
||||
return;
|
||||
}
|
||||
const domain = tool.domain;
|
||||
process.stdout.write(` ${c.dim(c.cyan("⟳"))} ${c.muted("Invoking")} ${c.bold(c.brightWhite(name))} ${domainBadge(domain)}${Object.keys(args).length > 0 ? " " + c.muted(JSON.stringify(args)) : ""}\n`);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await tool.handler(args);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
process.stdout.write(formatToolResult(name, result, elapsed) + "\n\n");
|
||||
}
|
||||
catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error("REPL tool invocation failed", { tool: name, error: msg });
|
||||
process.stdout.write(formatToolError(name, msg, elapsed) + "\n\n");
|
||||
}
|
||||
}
|
||||
/** Parses REPL tool args as a JSON object literal, or (if that fails)
|
||||
* space-separated `key=value` pairs with `true`/`false`/numeric coercion.
|
||||
* Not schema-aware. Empty input returns `{}`. */
|
||||
function parseArgs(raw) {
|
||||
if (!raw)
|
||||
return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
||||
return parsed;
|
||||
return {};
|
||||
}
|
||||
catch {
|
||||
// Try key=value pairs
|
||||
const args = {};
|
||||
const pairs = raw.match(/(\w+)=("(?:\\"|[^"])*"|\S+)/g);
|
||||
if (pairs) {
|
||||
for (const pair of pairs) {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
const key = pair.slice(0, eqIndex);
|
||||
let value = pair.slice(eqIndex + 1);
|
||||
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (value === "true")
|
||||
value = true;
|
||||
else if (value === "false")
|
||||
value = false;
|
||||
else if (!isNaN(Number(value)) && value !== "")
|
||||
value = Number(value);
|
||||
args[key] = value;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
}
|
||||
/** Prints the built-in command reference and example tool invocations. */
|
||||
function printHelp() {
|
||||
process.stdout.write(sectionHeader("Available Commands"));
|
||||
const commands = [
|
||||
["help", "Show this help message"],
|
||||
["tools [domain]", "List tools (optionally filtered by domain)"],
|
||||
["domains", "List all tool domains"],
|
||||
["health", "Quick dashboard health check"],
|
||||
["stats", "Dashboard overview statistics"],
|
||||
["status", "Full operational snapshot"],
|
||||
["config", "Show current configuration"],
|
||||
["clear", "Clear the screen"],
|
||||
["exit", "Quit the REPL"],
|
||||
["<tool_name> [json]", "Invoke a tool with optional JSON args"],
|
||||
["<tool_name> k=v ...", "Invoke a tool with key=value args"],
|
||||
];
|
||||
const maxCmd = Math.max(...commands.map(([cmd]) => cmd.length));
|
||||
for (const [cmd, desc] of commands) {
|
||||
process.stdout.write(` ${c.accent(cmd.padEnd(maxCmd + 2))} ${c.muted(desc)}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(sectionHeader("Examples"));
|
||||
process.stdout.write(` ${c.green('dashboard_list_sessions {"limit": 5}')}\n`);
|
||||
process.stdout.write(` ${c.green("dashboard_get_session session_id=abc123")}\n`);
|
||||
process.stdout.write(` ${c.green("dashboard_list_agents status=working limit=10")}\n\n`);
|
||||
}
|
||||
/** Prints a table of tools (name, domain, truncated description) for
|
||||
* `tools`/`tools <domain>` (case-insensitive domain match). */
|
||||
function printToolList(tools, domainFilter) {
|
||||
const filtered = domainFilter
|
||||
? tools.filter((t) => t.domain === domainFilter.toLowerCase())
|
||||
: tools;
|
||||
if (filtered.length === 0) {
|
||||
process.stdout.write(` ${c.warn("!")} No tools found${domainFilter ? ` for domain '${domainFilter}'` : ""}\n`);
|
||||
return;
|
||||
}
|
||||
const title = domainFilter ? `Tools — ${domainFilter}` : `All Tools (${filtered.length})`;
|
||||
process.stdout.write(sectionHeader(title));
|
||||
const rows = filtered.map((t) => ({
|
||||
name: t.name,
|
||||
domain: t.domain,
|
||||
description: t.description.length > 50 ? t.description.slice(0, 47) + "..." : t.description,
|
||||
}));
|
||||
process.stdout.write(table([
|
||||
{ key: "name", label: "Tool", width: 38, color: c.brightWhite },
|
||||
{
|
||||
key: "domain",
|
||||
label: "Domain",
|
||||
width: 14,
|
||||
color: (t) => {
|
||||
const fn = DOMAIN_COLORS[t] ?? c.muted;
|
||||
return fn(t);
|
||||
},
|
||||
},
|
||||
{ key: "description", label: "Description", width: 52, color: c.muted },
|
||||
], rows) + "\n\n");
|
||||
}
|
||||
/** Prints tool counts per domain (sorted) for the `domains` command. */
|
||||
function printDomains(tools) {
|
||||
const domainCounts = new Map();
|
||||
for (const t of tools) {
|
||||
domainCounts.set(t.domain, (domainCounts.get(t.domain) ?? 0) + 1);
|
||||
}
|
||||
process.stdout.write(sectionHeader("Tool Domains"));
|
||||
for (const [domain, count] of [...domainCounts.entries()].sort()) {
|
||||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||||
process.stdout.write(` ${colorFn("●")} ${c.bold(c.brightWhite(domain.padEnd(18)))} ${c.muted(`${count} tools`)}\n`);
|
||||
}
|
||||
process.stdout.write(`\n ${c.muted("Use")} ${c.accent("tools <domain>")} ${c.muted("to filter by domain.")}\n\n`);
|
||||
}
|
||||
/** Prints the resolved {@link AppConfig} for `config`, including the live
|
||||
* Mutations/Destructive policy state (warning color when enabled). */
|
||||
function printConfig(config) {
|
||||
process.stdout.write(sectionHeader("Configuration"));
|
||||
const pairs = [
|
||||
["Server Name", c.brightWhite(config.serverName)],
|
||||
["Version", c.brightCyan(config.serverVersion)],
|
||||
["Dashboard URL", c.green(config.dashboardBaseUrl.toString())],
|
||||
["Transport", c.accent(config.transport.toUpperCase())],
|
||||
["Timeout", c.muted(`${config.requestTimeoutMs}ms`)],
|
||||
["Retries", c.muted(String(config.retryCount))],
|
||||
["Retry Backoff", c.muted(`${config.retryBackoffMs}ms`)],
|
||||
["Mutations", config.allowMutations ? c.warn("ENABLED") : badge("disabled")],
|
||||
["Destructive", config.allowDestructive ? c.error("ENABLED") : badge("disabled")],
|
||||
["Log Level", c.muted(config.logLevel)],
|
||||
];
|
||||
for (const [k, v] of pairs) {
|
||||
process.stdout.write(` ${c.label(k.padEnd(18))} ${v}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
/** Constructs an empty {@link ReplToolCollector}, tagging each registered
|
||||
* tool via {@link TOOL_DOMAINS} (falling back to `"unknown"`). */
|
||||
export function createReplToolCollector() {
|
||||
const tools = [];
|
||||
return {
|
||||
tools,
|
||||
register(name, description, handler) {
|
||||
const domain = TOOL_DOMAINS[name] ?? "unknown";
|
||||
tools.push({ name, description, handler, domain });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @file tool-collector.ts
|
||||
* @description This module defines the collectAllTools function, which is responsible for collecting and registering all tool handlers available in the MCP application. The function takes the application configuration, a dashboard API client, and a logger as arguments, and returns an array of ToolEntry objects representing each registered tool. The tools cover various domains such as observability, session management, agent management, event handling, pricing, and maintenance. This collector is used in REPL mode to allow direct invocation of tools without requiring an MCP Server instance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../core/tool-registry.js`
|
||||
* - `../policy/tool-guards.js`
|
||||
* - `../tools/schemas.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `collectAllTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **collectAllTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import { createCollectorRegistrar, } from "../core/tool-registry.js";
|
||||
import { assertMutationsEnabled, assertDestructiveEnabled } from "../policy/tool-guards.js";
|
||||
import { z } from "zod";
|
||||
/**
|
||||
* Collect all tool handlers without requiring an MCP Server instance.
|
||||
* Used by REPL mode to invoke tools directly.
|
||||
*
|
||||
* A hand-maintained, server-less mirror of `tools/index.ts`'s
|
||||
* `registerAllTools`/`tools/domains/*.ts`: it re-declares the same 29
|
||||
* `dashboard_*` tools using {@link createCollectorRegistrar} instead of
|
||||
* {@link createToolRegistrar}, so no `McpServer` or MCP protocol overhead is
|
||||
* needed — the REPL calls handlers directly and renders results with its
|
||||
* own formatter. Since this duplicates rather than imports the domain
|
||||
* modules' definitions, a change to a tool's args/defaults/endpoint must be
|
||||
* mirrored here by hand. `index.ts`'s HTTP startup also calls this once,
|
||||
* purely for an accurate startup-banner tool count — each HTTP/SSE session
|
||||
* still gets its own protocol-registered tools via `buildServer`.
|
||||
* @param logger Unused here — {@link createCollectorRegistrar} doesn't wrap
|
||||
* handlers in logging, so errors propagate as real exceptions to the REPL.
|
||||
*/
|
||||
export function collectAllTools(config, api, logger) {
|
||||
const tools = [];
|
||||
const register = createCollectorRegistrar(tools);
|
||||
// ── Observability ───────────────────────────────────────────
|
||||
register("dashboard_health_check", "Check health of the local Agent Dashboard API.", {}, async () => api.get("/api/health"));
|
||||
register("dashboard_get_stats", "Get dashboard overview stats.", {}, async () => api.get("/api/stats"));
|
||||
register("dashboard_get_analytics", "Get analytics summary.", {}, async () => api.get("/api/analytics"));
|
||||
register("dashboard_get_system_info", "Get system info, DB stats, hook status.", {}, async () => api.get("/api/settings/info"));
|
||||
register("dashboard_export_data", "Export complete dashboard data payload.", {}, async () => api.get("/api/settings/export"));
|
||||
register("dashboard_get_operational_snapshot", "High-signal operational snapshot.", {
|
||||
recent_events_limit: z.number().int().min(1).max(50).optional(),
|
||||
active_sessions_limit: z.number().int().min(1).max(100).optional(),
|
||||
active_agents_limit: z.number().int().min(1).max(200).optional(),
|
||||
}, async (args) => {
|
||||
const eventsLimit = args.recent_events_limit ?? 20;
|
||||
const sessionsLimit = args.active_sessions_limit ?? 25;
|
||||
const agentsLimit = args.active_agents_limit ?? 100;
|
||||
const [stats, analytics, recentEvents, activeSessions, workingAgents, connectedAgents] = await Promise.all([
|
||||
api.get("/api/stats"),
|
||||
api.get("/api/analytics"),
|
||||
api.get("/api/events", { query: { limit: eventsLimit, offset: 0 } }),
|
||||
api.get("/api/sessions", {
|
||||
query: { status: "active", limit: sessionsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", { query: { status: "working", limit: agentsLimit, offset: 0 } }),
|
||||
api.get("/api/agents", { query: { status: "connected", limit: agentsLimit, offset: 0 } }),
|
||||
]);
|
||||
return {
|
||||
stats,
|
||||
analytics,
|
||||
recent_events: recentEvents,
|
||||
active_sessions: activeSessions,
|
||||
active_agents: { working: workingAgents, connected: connectedAgents },
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
// ── Sessions ────────────────────────────────────────────────
|
||||
register("dashboard_list_sessions", "List sessions with optional filter.", {}, async (args) => {
|
||||
return api.get("/api/sessions", {
|
||||
query: {
|
||||
limit: args.limit ?? 50,
|
||||
offset: args.offset ?? 0,
|
||||
status: args.status,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_get_session", "Get one session with agents and events.", {}, async (args) => {
|
||||
return api.get(`/api/sessions/${encodeURIComponent(args.session_id)}`);
|
||||
});
|
||||
register("dashboard_create_session", "Create a new session record.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/sessions", {
|
||||
body: {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
cwd: args.cwd,
|
||||
model: args.model,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_update_session", "Update session metadata or status.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.patch(`/api/sessions/${encodeURIComponent(args.session_id)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
// ── Agents ──────────────────────────────────────────────────
|
||||
register("dashboard_list_agents", "List agents with filters.", {}, async (args) => {
|
||||
return api.get("/api/agents", {
|
||||
query: {
|
||||
limit: args.limit ?? 50,
|
||||
offset: args.offset ?? 0,
|
||||
status: args.status,
|
||||
session_id: args.session_id,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_get_agent", "Get a single agent by ID.", {}, async (args) => {
|
||||
return api.get(`/api/agents/${encodeURIComponent(args.agent_id)}`);
|
||||
});
|
||||
register("dashboard_create_agent", "Create a new agent in a session.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/agents", {
|
||||
body: {
|
||||
id: args.id,
|
||||
session_id: args.session_id,
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
subagent_type: args.subagent_type,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
parent_agent_id: args.parent_agent_id,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_update_agent", "Update agent lifecycle state.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.patch(`/api/agents/${encodeURIComponent(args.agent_id)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
current_tool: args.current_tool,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
// ── Events ──────────────────────────────────────────────────
|
||||
register("dashboard_list_events", "List events with optional session filter.", {}, async (args) => {
|
||||
return api.get("/api/events", {
|
||||
query: {
|
||||
limit: args.limit ?? 50,
|
||||
offset: args.offset ?? 0,
|
||||
session_id: args.session_id,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_ingest_hook_event", "Ingest a Claude Code hook event.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/hooks/event", { body: { hook_type: args.hook_type, data: args.data } });
|
||||
});
|
||||
// ── Pricing ─────────────────────────────────────────────────
|
||||
register("dashboard_get_pricing_rules", "List all model pricing rules.", {}, async () => api.get("/api/pricing"));
|
||||
register("dashboard_get_total_cost", "Get total usage cost.", {}, async () => api.get("/api/pricing/cost"));
|
||||
register("dashboard_get_session_cost", "Get cost breakdown for one session.", {}, async (args) => {
|
||||
return api.get(`/api/pricing/cost/${encodeURIComponent(args.session_id)}`);
|
||||
});
|
||||
register("dashboard_upsert_pricing_rule", "Create or update a pricing rule.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.put("/api/pricing", {
|
||||
body: {
|
||||
model_pattern: args.model_pattern,
|
||||
display_name: args.display_name,
|
||||
input_per_mtok: args.input_per_mtok ?? 0,
|
||||
output_per_mtok: args.output_per_mtok ?? 0,
|
||||
cache_read_per_mtok: args.cache_read_per_mtok ?? 0,
|
||||
cache_write_per_mtok: args.cache_write_per_mtok ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_delete_pricing_rule", "Delete one pricing rule.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.delete(`/api/pricing/${encodeURIComponent(args.model_pattern)}`);
|
||||
});
|
||||
register("dashboard_reset_pricing_defaults", "Reset pricing rules to defaults.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reset-pricing");
|
||||
});
|
||||
// ── Maintenance ─────────────────────────────────────────────
|
||||
register("dashboard_cleanup_data", "Abandon stale sessions or purge old data.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const abandonHours = args.abandon_hours;
|
||||
const purgeDays = args.purge_days;
|
||||
if (!abandonHours && !purgeDays)
|
||||
throw new Error("At least one of abandon_hours or purge_days is required.");
|
||||
return api.post("/api/settings/cleanup", {
|
||||
body: { abandon_hours: abandonHours, purge_days: purgeDays },
|
||||
});
|
||||
});
|
||||
register("dashboard_reimport_history", "Re-import legacy Claude sessions.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reimport");
|
||||
});
|
||||
register("dashboard_reinstall_hooks", "Reinstall Claude Code hooks.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reinstall-hooks");
|
||||
});
|
||||
register("dashboard_clear_all_data", "Delete all data. Highly destructive.", {}, async (args) => {
|
||||
assertDestructiveEnabled(config, args.confirmation_token);
|
||||
return api.post("/api/settings/clear-data");
|
||||
});
|
||||
// ── Remote Data Sources ─────────────────────────────────────
|
||||
register("dashboard_list_remote_sources", "List configured Remote Data Sources (SSH machines).", {}, async () => api.get("/api/remote-sources"));
|
||||
register("dashboard_sync_remote_source", "Trigger an immediate SSH pull+import for one Remote Data Source.", {
|
||||
source_id: z.string().min(1),
|
||||
}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post(`/api/remote-sources/${encodeURIComponent(args.source_id)}/sync`);
|
||||
});
|
||||
register("dashboard_sync_all_remote_sources", "Trigger an immediate SSH pull+import for every enabled Remote Data Source.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/remote-sources/sync-all");
|
||||
});
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @file tool-context.ts
|
||||
* @description Defines the ToolContext interface, which encapsulates the necessary context for tool handlers in the MCP application. This context includes references to the MCP server instance, application configuration, dashboard API client, and logger. The ToolContext is passed to tool registration functions to provide them with access to these resources when defining and implementing tools. This design promotes modularity and separation of concerns by centralizing shared dependencies in a single context object.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolContext**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
export {};
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file banner.ts
|
||||
* @description Console startup UI for the MCP server's non-stdio transports (HTTP and REPL):
|
||||
* the ASCII-art wordmark, a boxed server-info panel (version, transport, dashboard URL, port,
|
||||
* tool count, mutation/destructive policy state), a "ready" line, and a shutdown message. The
|
||||
* stdio transport never calls any of these, since stdout there is the MCP JSON-RPC channel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `printBanner` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printServerInfo` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printReady` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printShutdown` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **printBanner**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printServerInfo**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printReady**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printShutdown**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import * as c from "./colors.js";
|
||||
/** ASCII-art wordmark rendered by {@link printBanner} with a color gradient. */
|
||||
const BANNER = `
|
||||
$$\\ $$\\ $$$$$$\\ $$$$$$$\\ $$$$$$$$\\ $$\\
|
||||
$$$\\ $$$ |$$ __$$\\ $$ __$$\\ \\__$$ __| $$ |
|
||||
$$$$\\ $$$$ |$$ / \\__|$$ | $$ | $$ | $$$$$$\\ $$$$$$\\ $$ | $$$$$$$\\
|
||||
$$\\$$\\$$ $$ |$$ | $$$$$$$ | $$ |$$ __$$\\ $$ __$$\\ $$ |$$ _____|
|
||||
$$ \\$$$ $$ |$$ | $$ ____/ $$ |$$ / $$ |$$ / $$ |$$ |\\$$$$$$\\
|
||||
$$ |\\$ /$$ |$$ | $$\\ $$ | $$ |$$ | $$ |$$ | $$ |$$ | \\____$$\\
|
||||
$$ | \\_/ $$ |\\$$$$$$ |$$ | $$ |\\$$$$$$ |\\$$$$$$ |$$ |$$$$$$$ |
|
||||
\\__| \\__| \\______/ \\__| \\__| \\______/ \\______/ \\__|\\_______/ `;
|
||||
/** Prints {@link BANNER} one line per gradient color (cyan to magenta).
|
||||
* Called at HTTP/REPL startup only. */
|
||||
export function printBanner() {
|
||||
const gradient = [c.brightCyan, c.cyan, c.brightBlue, c.blue, c.brightMagenta, c.magenta];
|
||||
const lines = BANNER.split("\n").filter((l) => l.length > 0);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const colorFn = gradient[Math.min(i, gradient.length - 1)];
|
||||
process.stdout.write(colorFn(lines[i]) + "\n");
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
/** Prints a boxed config summary beneath the banner, shared by HTTP (`port`
|
||||
* set) and REPL (`port` omitted). Mutations/Destructive rows mirror the
|
||||
* `policy/tool-guards.ts` flags, warning-colored when enabled. Ends with a
|
||||
* reminder that the dashboard must already be running at the printed URL. */
|
||||
export function printServerInfo(info) {
|
||||
const divider = c.dim(c.cyan("─".repeat(62)));
|
||||
const line = (label, value) => ` ${c.dim(c.cyan("│"))} ${c.label(label.padEnd(18))} ${value}`;
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(` ${c.dim(c.cyan("│"))} ${c.bold(c.brightWhite("Agent Dashboard MCP Server"))}\n`);
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(line("Version", c.brightCyan(info.version)) + "\n");
|
||||
process.stdout.write(line("Transport", c.accent(info.transport.toUpperCase())) + "\n");
|
||||
process.stdout.write(line("Dashboard API", c.green(info.dashboard)) + "\n");
|
||||
if (info.port !== undefined) {
|
||||
process.stdout.write(line("HTTP Port", c.brightYellow(String(info.port))) + "\n");
|
||||
}
|
||||
process.stdout.write(line("Tools Registered", c.brightWhite(String(info.tools))) + "\n");
|
||||
process.stdout.write(line("Mutations", info.mutations ? c.warn("ENABLED") : c.success("disabled")) + "\n");
|
||||
process.stdout.write(line("Destructive", info.destructive ? c.error("ENABLED") : c.success("disabled")) + "\n");
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(` ${c.dim(c.cyan("│"))} ${c.warn("⚠")} ${c.dim("Dashboard must be running at the URL above.")}\n`);
|
||||
process.stdout.write(` ${c.dim(c.cyan("│"))} ${c.dim(" Start it first:")} ${c.brightWhite("npm run dev")} ${c.dim("or")} ${c.brightWhite("npm start")}\n`);
|
||||
process.stdout.write(divider + "\n\n");
|
||||
}
|
||||
/** Prints "Server ready" once the HTTP server has bound to its port; not
|
||||
* used by the REPL transport. */
|
||||
export function printReady(transport) {
|
||||
const icon = "✔";
|
||||
process.stdout.write(` ${c.success(icon)} ${c.bold(c.brightWhite("Server ready"))} ${c.muted(`(${transport})`)}\n\n`);
|
||||
}
|
||||
/** Prints "Shutting down...". Called from HTTP/REPL shutdown paths and
|
||||
* `index.ts`'s SIGINT/SIGTERM handler; never from stdio. */
|
||||
export function printShutdown() {
|
||||
process.stdout.write(`\n ${c.warn("⏻")} ${c.bold(c.brightWhite("Shutting down..."))}\n`);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @file colors.ts
|
||||
* @description Provides utility functions for applying ANSI color codes to text in the terminal. This module defines a set of functions for styling text with various colors and modifiers such as bold, italic, underline, and strikethrough. It also includes support for 256-color mode and a function to strip ANSI codes from text. The color functions are designed to be composable, allowing for easy combination of styles. The module checks for color support in the terminal environment and gracefully degrades if colors are not supported.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `bold` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `dim` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `italic` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `underline` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `strikethrough` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `black` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `red` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `green` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `yellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `blue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `magenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `cyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `white` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `gray` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightRed` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightGreen` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightYellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightBlue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightMagenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightCyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightWhite` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgRed` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgGreen` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgYellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgBlue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgMagenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgCyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgWhite` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgGray` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `fg256` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bg256` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `reset` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `success` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `error` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `warn` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `info` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `muted` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `highlight` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `label` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - … plus 1 additional exports
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **bold**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **dim**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **italic**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **underline**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **strikethrough**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **black**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **red**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **green**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **yellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **blue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **magenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **cyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **white**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **gray**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightRed**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightGreen**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightYellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightBlue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightMagenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightCyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightWhite**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgRed**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgGreen**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgYellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgBlue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgMagenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgCyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgWhite**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgGray**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **fg256**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bg256**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **reset**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **stripAnsi**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **success**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **error**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **warn**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **info**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **muted**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **highlight**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **label**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **accent**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
/** Whether ANSI colors should be emitted: `NO_COLOR` always disables;
|
||||
* `FORCE_COLOR=0` disables, any other `FORCE_COLOR` enables regardless of
|
||||
* TTY; otherwise enabled only on an interactive stdout TTY. Computed once
|
||||
* at module load. */
|
||||
const isColorSupported = process.env.FORCE_COLOR !== "0" &&
|
||||
process.env.NO_COLOR === undefined &&
|
||||
(process.env.FORCE_COLOR !== undefined || (process.stdout.isTTY ?? false));
|
||||
/** Builds a styling function wrapping text in ANSI open/close codes, or an
|
||||
* identity function when colors are unsupported — every color/modifier
|
||||
* below is built with this, so disabling color no-ops all of them at once. */
|
||||
function wrap(open, close) {
|
||||
if (!isColorSupported)
|
||||
return (text) => text;
|
||||
return (text) => `\x1b[${open}m${text}\x1b[${close}m`;
|
||||
}
|
||||
// Modifiers
|
||||
export const bold = wrap("1", "22");
|
||||
export const dim = wrap("2", "22");
|
||||
export const italic = wrap("3", "23");
|
||||
export const underline = wrap("4", "24");
|
||||
export const strikethrough = wrap("9", "29");
|
||||
// Foreground colors
|
||||
export const black = wrap("30", "39");
|
||||
export const red = wrap("31", "39");
|
||||
export const green = wrap("32", "39");
|
||||
export const yellow = wrap("33", "39");
|
||||
export const blue = wrap("34", "39");
|
||||
export const magenta = wrap("35", "39");
|
||||
export const cyan = wrap("36", "39");
|
||||
export const white = wrap("37", "39");
|
||||
export const gray = wrap("90", "39");
|
||||
// Bright foreground colors
|
||||
export const brightRed = wrap("91", "39");
|
||||
export const brightGreen = wrap("92", "39");
|
||||
export const brightYellow = wrap("93", "39");
|
||||
export const brightBlue = wrap("94", "39");
|
||||
export const brightMagenta = wrap("95", "39");
|
||||
export const brightCyan = wrap("96", "39");
|
||||
export const brightWhite = wrap("97", "39");
|
||||
// Background colors
|
||||
export const bgRed = wrap("41", "49");
|
||||
export const bgGreen = wrap("42", "49");
|
||||
export const bgYellow = wrap("43", "49");
|
||||
export const bgBlue = wrap("44", "49");
|
||||
export const bgMagenta = wrap("45", "49");
|
||||
export const bgCyan = wrap("46", "49");
|
||||
export const bgWhite = wrap("47", "49");
|
||||
export const bgGray = wrap("100", "49");
|
||||
// 256-color support
|
||||
/** Foreground-color function for an xterm 256-color index; not currently
|
||||
* used by any composable style below. */
|
||||
export function fg256(code) {
|
||||
if (!isColorSupported)
|
||||
return (text) => text;
|
||||
return (text) => `\x1b[38;5;${code}m${text}\x1b[39m`;
|
||||
}
|
||||
/** Background-color function for an xterm 256-color index. */
|
||||
export function bg256(code) {
|
||||
if (!isColorSupported)
|
||||
return (text) => text;
|
||||
return (text) => `\x1b[48;5;${code}m${text}\x1b[49m`;
|
||||
}
|
||||
// Utility
|
||||
/** Raw ANSI "reset all styles" sequence, or `""` when colors are disabled. */
|
||||
export const reset = isColorSupported ? "\x1b[0m" : "";
|
||||
/** Strips ANSI SGR sequences from `text`. Used throughout `ui/formatter.ts`
|
||||
* to measure/pad colored strings by visible length, not byte length. */
|
||||
export function stripAnsi(text) {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
// Composable styles
|
||||
/** Semantic style aliases used throughout `ui/banner.ts`, `ui/formatter.ts`,
|
||||
* and `transports/repl.ts` so call sites express intent, not a specific color. */
|
||||
export const success = (t) => bold(green(t));
|
||||
export const error = (t) => bold(red(t));
|
||||
export const warn = (t) => bold(yellow(t));
|
||||
export const info = (t) => bold(cyan(t));
|
||||
export const muted = (t) => dim(gray(t));
|
||||
export const highlight = (t) => bold(brightMagenta(t));
|
||||
export const label = (t) => bold(brightWhite(t));
|
||||
export const accent = (t) => bold(brightCyan(t));
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @file formatter.ts
|
||||
* @description A collection of utility functions for formatting console output in the MCP application. This includes functions for creating boxed sections, tables, status badges, formatted tool results, and key-value lists. The formatting is designed to be visually appealing and informative when printed to the terminal, using colors and styles to enhance readability. These utilities are used across various tools and components in the MCP application to maintain a consistent look and feel in the console output.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `box` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `divider` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Column` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `table` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `badge` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `formatToolResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `formatToolError` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `keyValue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `sectionHeader` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SPINNER_FRAMES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `progressBar` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **box**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **divider**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Column**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **table**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **badge**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **formatToolResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **formatToolError**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **keyValue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **sectionHeader**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SPINNER_FRAMES**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **progressBar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
import * as c from "./colors.js";
|
||||
// ── Box drawing ───────────────────────────────────────────────
|
||||
const BOX_TL = "╭";
|
||||
const BOX_TR = "╮";
|
||||
const BOX_BL = "╰";
|
||||
const BOX_BR = "╯";
|
||||
const BOX_H = "─";
|
||||
const BOX_V = "│";
|
||||
/** Unused "tee" joints; not referenced by {@link box}. */
|
||||
const BOX_ML = "├";
|
||||
const BOX_MR = "┤";
|
||||
/** Right-pads `text` to `width` visible columns via {@link stripAnsi}. */
|
||||
function pad(text, width) {
|
||||
const visLen = c.stripAnsi(text).length;
|
||||
return text + " ".repeat(Math.max(0, width - visLen));
|
||||
}
|
||||
/** Renders `content` in a rounded-corner box with `title` in the top
|
||||
* border. Not currently called; kept as a general-purpose primitive. */
|
||||
export function box(title, content, width = 60) {
|
||||
const inner = width - 4;
|
||||
const titleLine = ` ${title} `;
|
||||
const topPad = inner - c.stripAnsi(titleLine).length;
|
||||
const lines = [];
|
||||
lines.push(c.dim(c.cyan(BOX_TL + BOX_H)) +
|
||||
c.bold(c.brightCyan(titleLine)) +
|
||||
c.dim(c.cyan(BOX_H.repeat(Math.max(0, topPad)) + BOX_TR)));
|
||||
for (const row of content.split("\n")) {
|
||||
lines.push(c.dim(c.cyan(BOX_V)) + " " + pad(row, inner) + " " + c.dim(c.cyan(BOX_V)));
|
||||
}
|
||||
lines.push(c.dim(c.cyan(BOX_BL + BOX_H.repeat(width - 2) + BOX_BR)));
|
||||
return lines.join("\n");
|
||||
}
|
||||
/** Plain horizontal rule; `repl.ts` imports this without calling it. */
|
||||
export function divider(width = 60) {
|
||||
return c.dim(c.cyan(BOX_H.repeat(width)));
|
||||
}
|
||||
/** Pads/aligns `text` to `width` visible columns per `align`. */
|
||||
function alignText(text, width, align = "left") {
|
||||
const len = c.stripAnsi(text).length;
|
||||
const diff = Math.max(0, width - len);
|
||||
if (align === "right")
|
||||
return " ".repeat(diff) + text;
|
||||
if (align === "center") {
|
||||
const left = Math.floor(diff / 2);
|
||||
return " ".repeat(left) + text + " ".repeat(diff - left);
|
||||
}
|
||||
return text + " ".repeat(diff);
|
||||
}
|
||||
/** Renders `rows` as an ASCII table; used by `repl.ts`'s `printToolList`. */
|
||||
export function table(columns, rows) {
|
||||
const colWidths = columns.map((col) => {
|
||||
if (col.width)
|
||||
return col.width;
|
||||
const headerLen = col.label.length;
|
||||
const maxDataLen = rows.reduce((max, row) => {
|
||||
const val = String(row[col.key] ?? "");
|
||||
return Math.max(max, val.length);
|
||||
}, 0);
|
||||
return Math.max(headerLen, maxDataLen) + 2;
|
||||
});
|
||||
const lines = [];
|
||||
// Header
|
||||
const headerParts = columns.map((col, i) => c.bold(c.brightWhite(alignText(col.label, colWidths[i], col.align))));
|
||||
lines.push(" " + headerParts.join(c.dim(c.cyan(" │ "))));
|
||||
// Separator
|
||||
const sep = colWidths.map((w) => BOX_H.repeat(w));
|
||||
lines.push(" " + c.dim(c.cyan(sep.join("─┼─"))));
|
||||
// Rows
|
||||
for (const row of rows) {
|
||||
const parts = columns.map((col, i) => {
|
||||
const raw = String(row[col.key] ?? "");
|
||||
const styled = col.color ? col.color(raw) : raw;
|
||||
return alignText(styled, colWidths[i], col.align);
|
||||
});
|
||||
lines.push(" " + parts.join(c.dim(c.cyan(" │ "))));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
// ── Status badges ─────────────────────────────────────────────
|
||||
/** Color mapping for {@link badge}. */
|
||||
const STATUS_COLORS = {
|
||||
active: c.success,
|
||||
completed: c.info,
|
||||
error: c.error,
|
||||
abandoned: c.warn,
|
||||
idle: c.muted,
|
||||
connected: c.info,
|
||||
working: (t) => c.bold(c.brightYellow(t)),
|
||||
ok: c.success,
|
||||
healthy: c.success,
|
||||
unhealthy: c.error,
|
||||
enabled: c.warn,
|
||||
disabled: c.success,
|
||||
};
|
||||
/** Renders `[STATUS]` colored via {@link STATUS_COLORS} (falls back to
|
||||
* muted). Used by `repl.ts`'s `printConfig`. */
|
||||
export function badge(status) {
|
||||
const colorFn = STATUS_COLORS[status.toLowerCase()] ?? c.muted;
|
||||
return colorFn(`[${status.toUpperCase()}]`);
|
||||
}
|
||||
// ── Tool result formatting ────────────────────────────────────
|
||||
/** Renders a successful REPL tool invocation: a header plus the result,
|
||||
* JSON-highlighted via {@link syntaxHighlight}; results over 30 lines are
|
||||
* truncated to 25 (display-only, doesn't affect the actual return value). */
|
||||
export function formatToolResult(name, data, durationMs) {
|
||||
const lines = [];
|
||||
const header = `${c.success("✔")} ${c.bold(c.brightWhite(name))} ${c.muted(`(${durationMs}ms)`)}`;
|
||||
lines.push(header);
|
||||
if (data === null || data === undefined) {
|
||||
lines.push(c.muted(" (no data)"));
|
||||
return lines.join("\n");
|
||||
}
|
||||
const json = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
||||
const jsonLines = json.split("\n");
|
||||
if (jsonLines.length <= 30) {
|
||||
lines.push(syntaxHighlight(json));
|
||||
}
|
||||
else {
|
||||
lines.push(syntaxHighlight(jsonLines.slice(0, 25).join("\n")));
|
||||
lines.push(c.muted(` ... +${jsonLines.length - 25} more lines`));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
/** Renders a failed REPL tool invocation; given only a plain message
|
||||
* string, unlike {@link errorResult}'s structured `ApiError` handling. */
|
||||
export function formatToolError(name, error, durationMs) {
|
||||
return (`${c.error("✘")} ${c.bold(c.brightWhite(name))} ${c.muted(`(${durationMs}ms)`)}\n` +
|
||||
` ${c.red(error)}`);
|
||||
}
|
||||
// ── JSON syntax highlighting ──────────────────────────────────
|
||||
/** Regex-based JSON token coloring; a display heuristic, not a real
|
||||
* tokenizer — safe since input is always `JSON.stringify` output. */
|
||||
function syntaxHighlight(json) {
|
||||
return json.replace(/("(?:\\.|[^"\\])*")\s*(:)?|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (_match, str, colon, bool, num) => {
|
||||
if (str) {
|
||||
if (colon)
|
||||
return c.cyan(str) + c.dim(":");
|
||||
return c.green(str);
|
||||
}
|
||||
if (bool)
|
||||
return c.brightMagenta(bool);
|
||||
if (num)
|
||||
return c.brightYellow(num);
|
||||
return _match;
|
||||
});
|
||||
}
|
||||
// ── Key-value list ────────────────────────────────────────────
|
||||
/** Renders an aligned label/value list. Not currently called — `repl.ts`'s
|
||||
* `printConfig` builds an equivalent layout inline. */
|
||||
export function keyValue(pairs, labelWidth = 20) {
|
||||
return pairs.map(([k, v]) => ` ${c.label(k.padEnd(labelWidth))} ${v}`).join("\n");
|
||||
}
|
||||
// ── Section header ────────────────────────────────────────────
|
||||
/** Renders a `◆ Title` heading used throughout `repl.ts`. */
|
||||
export function sectionHeader(title) {
|
||||
return `\n ${c.bold(c.brightCyan("◆"))} ${c.bold(c.brightWhite(title))}\n`;
|
||||
}
|
||||
// ── Spinner frames (for async operations) ─────────────────────
|
||||
/** Braille spinner animation frames; no current caller drives one. */
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
/** Renders a block progress bar with a percentage label, clamped to
|
||||
* `[0, 1]`. No current caller reports incremental progress. */
|
||||
export function progressBar(current, total, width = 30) {
|
||||
const pct = Math.min(1, Math.max(0, current / total));
|
||||
const filled = Math.round(pct * width);
|
||||
const empty = width - filled;
|
||||
const bar = c.brightCyan("█".repeat(filled)) + c.dim("░".repeat(empty));
|
||||
const label = c.muted(`${Math.round(pct * 100)}%`);
|
||||
return ` ${bar} ${label}`;
|
||||
}
|
||||
Generated
+11
-7
@@ -27,31 +27,35 @@
|
||||
},
|
||||
"..": {
|
||||
"name": "agent-dashboard",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"version": "1.4.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tar": "^7.4.3",
|
||||
"uuid": "^11.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccam": "bin/ccam.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hoangsonww"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^11.7.0"
|
||||
"better-sqlite3": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@
|
||||
"setup": "npm install && (cd client && npm install) && (cd vscode-extension && npm install) && npm run link-cli",
|
||||
"update:pull-setup": "git pull --ff-only && npm run setup",
|
||||
"mcp:install": "npm --prefix mcp install",
|
||||
"mcp:build": "npm --prefix mcp run build",
|
||||
"mcp:check-build": "node scripts/check-mcp-build.js",
|
||||
"mcp:build": "npm --prefix mcp run build && node scripts/check-mcp-build.js --write",
|
||||
"mcp:start": "npm --prefix mcp run start",
|
||||
"mcp:start:http": "npm --prefix mcp run start:http",
|
||||
"mcp:start:repl": "npm --prefix mcp run start:repl",
|
||||
|
||||
@@ -22,10 +22,12 @@ keep the dashboard running and observable. You query the dashboard API at
|
||||
data-backed output, and you guide the user through starting, restarting, and
|
||||
feeding data into the dashboard.
|
||||
|
||||
This plugin also ships a bundled MCP server (`ccam-dashboard`, configured in
|
||||
`.mcp.json` against `CCAM_DASHBOARD_URL=http://localhost:4820`). When the MCP
|
||||
server is connected, you have direct tool access to the same dashboard
|
||||
operations — mention this to the user as a faster alternative to raw `curl`.
|
||||
The MCP server (`ccam`, pointed at `CCAM_DASHBOARD_URL=http://localhost:4820`)
|
||||
ships with the root `ccam` plugin, not with this one — a marketplace subdirectory
|
||||
plugin is cached without `mcp/`, so it cannot carry the server binary. When that
|
||||
plugin is installed and the MCP server is connected, you have direct tool access
|
||||
to the same dashboard operations — mention it to the user as a faster
|
||||
alternative to raw `curl`.
|
||||
|
||||
## Available Data Sources
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ccam-dashboard": {
|
||||
"ccam": {
|
||||
"command": "node",
|
||||
"args": ["../../mcp/build/index.js"],
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/build/index.js"],
|
||||
"env": {
|
||||
"CCAM_DASHBOARD_URL": "http://localhost:4820"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Diagnose a plugin-installed CCAM — Node, bootstrap, server, hooks, CLI, MCP build
|
||||
---
|
||||
|
||||
Run the diagnostic and report what it found. Do not fix anything unless the user
|
||||
asks.
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-doctor.js"
|
||||
```
|
||||
|
||||
Print the output verbatim, then add at most three lines of guidance based on the
|
||||
`FAIL` and `WARN` rows:
|
||||
|
||||
- `FAIL Node` → the installed Node is too old for `node:sqlite`; the server
|
||||
cannot start until Node is upgraded.
|
||||
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
|
||||
Starting a new Claude Code session removes them automatically (the bootstrap
|
||||
backs the file up as `settings.json.ccam-bak` first).
|
||||
- `FAIL Server` or `FAIL Runtime deps` → run `/ccam-update`, then check
|
||||
`~/.claude/agent-dashboard/runtime/bootstrap.log` and `server.log`.
|
||||
- `FAIL MCP build` → `mcp/build/` is out of date; run `npm run mcp:build` in a
|
||||
checkout and commit it.
|
||||
- `WARN ccam CLI` → print the exact `export PATH=...` line from the output.
|
||||
- `WARN Dashboard UI` → run `/ccam-open` to build the bundle.
|
||||
|
||||
No preamble, no summary of things that passed.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: Build the dashboard UI if needed and print its URL
|
||||
---
|
||||
|
||||
Build the dashboard bundle if it is not there yet, then print the URL. The
|
||||
bootstrap already builds it on session start, so this is usually a no-op — use
|
||||
it to force a rebuild, or to finish the build if the bootstrap's own attempt
|
||||
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js"
|
||||
```
|
||||
|
||||
The first run installs the client toolchain and takes a few minutes; later runs
|
||||
print the URL immediately. No server restart is needed — the server already
|
||||
serves from that directory.
|
||||
|
||||
Then help the user open it:
|
||||
|
||||
```bash
|
||||
uname -s
|
||||
```
|
||||
|
||||
- `Darwin` → `open <url>`
|
||||
- `Linux` → `xdg-open <url>`
|
||||
- otherwise → tell them to open the URL in a browser.
|
||||
|
||||
Keep the output to a few lines. Pass `--force` to the script only if the user
|
||||
asks for a rebuild.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Refresh CCAM's runtime dependencies and restart the dashboard server
|
||||
---
|
||||
|
||||
Reinstall the runtime dependencies and restart the dashboard server against the
|
||||
currently installed plugin version. Use this after a plugin update, or when
|
||||
`/ccam-doctor` reports missing dependencies or a dead server.
|
||||
|
||||
This stops the running dashboard server before starting the new one. Say so, then
|
||||
run:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js" --force
|
||||
```
|
||||
|
||||
It runs in the foreground and can take a few minutes on a cold cache. When it
|
||||
finishes, verify:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-doctor.js"
|
||||
```
|
||||
|
||||
Report the final state in a few lines. If the bootstrap failed, quote the
|
||||
decisive line from `~/.claude/agent-dashboard/runtime/bootstrap.log`.
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file check-mcp-build.js
|
||||
* @description `mcp/build/` is committed so a plugin install has a working MCP
|
||||
* server the instant the session opens — Claude Code starts plugin MCP servers
|
||||
* immediately and offers no "not ready yet" retry, so an async bootstrap cannot
|
||||
* win that race. The cost is that the artifact can drift from `mcp/src`.
|
||||
*
|
||||
* Freshness is tracked by hashing the CONTENT of `mcp/src` (plus the manifests
|
||||
* and tsconfig) into `mcp/build/.srchash`. Modification times are useless here:
|
||||
* a fresh clone or checkout stamps every file with the same time in arbitrary
|
||||
* order.
|
||||
*
|
||||
* Run by the pre-commit hook and by `/ccam-doctor`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const HASH_FILE = ".srchash";
|
||||
|
||||
/** Every file whose content the build depends on, in a stable order. */
|
||||
function sourceFiles(mcpDir) {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else files.push(full);
|
||||
}
|
||||
};
|
||||
walk(path.join(mcpDir, "src"));
|
||||
for (const f of ["package.json", "package-lock.json", "tsconfig.json"]) {
|
||||
const full = path.join(mcpDir, f);
|
||||
if (fs.existsSync(full)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sourceHash(root = path.resolve(__dirname, "..")) {
|
||||
const mcpDir = path.join(root, "mcp");
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of sourceFiles(mcpDir)) {
|
||||
h.update(path.relative(mcpDir, f).replace(/\\/g, "/"));
|
||||
h.update(fs.readFileSync(f));
|
||||
}
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ok:boolean, reason:string, expected:string, recorded:string|null}}
|
||||
*/
|
||||
function mcpBuildStatus(root = path.resolve(__dirname, "..")) {
|
||||
const buildDir = path.join(root, "mcp", "build");
|
||||
const expected = sourceHash(root);
|
||||
if (!fs.existsSync(path.join(buildDir, "index.js"))) {
|
||||
return { ok: false, reason: "mcp/build/index.js is missing", expected, recorded: null };
|
||||
}
|
||||
let recorded = null;
|
||||
try {
|
||||
recorded = fs.readFileSync(path.join(buildDir, HASH_FILE), "utf8").trim();
|
||||
} catch {
|
||||
return { ok: false, reason: "mcp/build has no recorded source hash", expected, recorded: null };
|
||||
}
|
||||
return recorded === expected
|
||||
? { ok: true, reason: "up to date", expected, recorded }
|
||||
: {
|
||||
ok: false,
|
||||
reason: "mcp/build is stale — mcp/src has changed since it was built",
|
||||
expected,
|
||||
recorded,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stamp the current source hash into the build directory. */
|
||||
function writeHash(root = path.resolve(__dirname, "..")) {
|
||||
const buildDir = path.join(root, "mcp", "build");
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(buildDir, HASH_FILE), sourceHash(root) + "\n", "utf8");
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv.includes("--write")) {
|
||||
writeHash();
|
||||
console.log("mcp/build/.srchash updated");
|
||||
} else {
|
||||
const status = mcpBuildStatus();
|
||||
if (status.ok) {
|
||||
console.log("mcp/build is up to date");
|
||||
} else {
|
||||
console.error(`${status.reason}\nRun: npm run mcp:build`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sourceHash, mcpBuildStatus, writeHash };
|
||||
@@ -14,6 +14,23 @@ const { getSettingsPath } = require("../server/lib/claude-home");
|
||||
const SETTINGS_PATH = getSettingsPath();
|
||||
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
|
||||
|
||||
/**
|
||||
* True when the plugin bootstrap has run on this machine — i.e. the `ccam`
|
||||
* plugin is (or was) installed and supplies its own hook entries.
|
||||
* Read-only and never throws; `plugin-bootstrap.js` is not required here to
|
||||
* keep this module free of a circular dependency.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function pluginBootstrapRan() {
|
||||
try {
|
||||
const { getDataDir } = require("../server/lib/claude-home");
|
||||
return fs.existsSync(path.join(getDataDir(), "runtime", "state.json"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function envFlag(name) {
|
||||
return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase());
|
||||
}
|
||||
@@ -126,6 +143,17 @@ function installHooks(silent = false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The `ccam` plugin installs the same eight hooks itself. Running both means
|
||||
// every event is POSTed twice and token/cost figures double — warn loudly
|
||||
// rather than silently double-count. (`/ccam-doctor` reports the same state.)
|
||||
if (!silent && pluginBootstrapRan()) {
|
||||
console.warn(
|
||||
"WARNING: the ccam plugin is installed and already provides these hooks.\n" +
|
||||
" Installing them again double-counts every event. Uninstall the\n" +
|
||||
" plugin first, or skip this step. Run /ccam-doctor to check."
|
||||
);
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (fs.existsSync(SETTINGS_PATH)) {
|
||||
try {
|
||||
@@ -176,4 +204,6 @@ if (require.main === module) {
|
||||
if (!installHooks(false)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
module.exports = { installHooks, isInsideContainer };
|
||||
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
|
||||
// a previous checkout install left behind (they would double-count events).
|
||||
module.exports = { installHooks, isInsideContainer, isOurEntry };
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-bootstrap.js
|
||||
* @description Makes CCAM usable straight from a Claude Code plugin install.
|
||||
* Runs from the plugin's `SessionStart` hook and must NEVER block a session:
|
||||
* the foreground pass only checks a fast path, then hands the real work to a
|
||||
* detached worker copy of itself.
|
||||
*
|
||||
* The worker installs runtime dependencies, removes hook entries left behind by
|
||||
* an older `npm run install-hooks` (they would double-count every event), puts
|
||||
* the `ccam` CLI on PATH, and starts the dashboard server detached.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agent-dashboard/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
|
||||
* on every plugin update.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
|
||||
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
|
||||
const { resolveAllDashboardPorts, getServerInfoPath } = require("../server/lib/server-info");
|
||||
const { isOurEntry } = require("./install-hooks");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
// node:sqlite — the only SQLite driver a `--omit=dev --ignore-scripts` install
|
||||
// leaves us with, since better-sqlite3 is not a runtime dependency and its
|
||||
// native addon would need a build step.
|
||||
const MIN_NODE = [22, 5, 0];
|
||||
const LOCK_STALE_MS = 10 * 60 * 1000;
|
||||
|
||||
function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function pluginVersion() {
|
||||
try {
|
||||
return require(path.join(PLUGIN_ROOT, "package.json")).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
|
||||
function statePath(rt = runtimeDir()) {
|
||||
return path.join(rt, "state.json");
|
||||
}
|
||||
|
||||
function readState(rt = runtimeDir()) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(statePath(rt), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state, rt = runtimeDir()) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(statePath(rt), JSON.stringify(state, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
/** A dashboard server that is actually listening (the discovery file is PID-checked). */
|
||||
function serverIsLive() {
|
||||
try {
|
||||
return resolveAllDashboardPorts().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
|
||||
function depsHash(root = PLUGIN_ROOT) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of ["package.json", "package-lock.json"]) {
|
||||
try {
|
||||
h.update(fs.readFileSync(path.join(root, f)));
|
||||
} catch {
|
||||
h.update(f); // absent counts as its own state
|
||||
}
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to do when the recorded state matches this plugin build AND a server
|
||||
* is already up. Deliberately cheap: this runs on every single SessionStart.
|
||||
*/
|
||||
function fastPathOk(rt = runtimeDir()) {
|
||||
const state = readState(rt);
|
||||
if (!state) return false;
|
||||
if (state.pluginVersion !== pluginVersion()) return false;
|
||||
// A plugin update lands in a NEW cache directory; the server still running
|
||||
// from the old one has to be replaced, so this is not a fast path.
|
||||
if (state.pluginRoot !== PLUGIN_ROOT) return false;
|
||||
if (state.depsHash !== depsHash()) return false;
|
||||
if (!fs.existsSync(path.join(rt, "node_modules"))) return false;
|
||||
return serverIsLive();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- node version gate */
|
||||
|
||||
function nodeVersionOk(version = process.versions.node) {
|
||||
const parts = String(version)
|
||||
.split(".")
|
||||
.map((n) => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < MIN_NODE.length; i++) {
|
||||
if (parts[i] > MIN_NODE[i]) return true;
|
||||
if (parts[i] < MIN_NODE[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- lock */
|
||||
|
||||
function lockPath(rt = runtimeDir()) {
|
||||
return path.join(rt, ".bootstrap.lock");
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function lockIsStale(dir, now = Date.now()) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(dir);
|
||||
} catch {
|
||||
return false; // gone already
|
||||
}
|
||||
if (now - stat.mtimeMs > LOCK_STALE_MS) return true;
|
||||
let pid = 0;
|
||||
try {
|
||||
pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10);
|
||||
} catch {
|
||||
return true; // lock dir without a readable pid is debris
|
||||
}
|
||||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic `mkdir` lock so two sessions starting at once cannot race the install
|
||||
* or spawn two servers (the second would die on EADDRINUSE). A lock whose owner
|
||||
* is dead, or older than LOCK_STALE_MS, is reclaimed.
|
||||
*
|
||||
* @returns {boolean} true when this process owns the lock
|
||||
*/
|
||||
function acquireLock(rt = runtimeDir()) {
|
||||
const dir = lockPath(rt);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8");
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") return false;
|
||||
if (!lockIsStale(dir)) return false;
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function releaseLock(rt = runtimeDir()) {
|
||||
try {
|
||||
fs.rmSync(lockPath(rt), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- legacy cleanup */
|
||||
|
||||
/**
|
||||
* Remove hook entries a previous `npm run install-hooks` wrote into
|
||||
* ~/.claude/settings.json. The plugin installs its own hooks, and events carry
|
||||
* no id — two handlers mean every token and cost figure is counted twice.
|
||||
* The original file is copied aside before any write.
|
||||
*
|
||||
* @returns {number} how many entries were removed
|
||||
*/
|
||||
function stripLegacyHooks(settingsPath = getSettingsPath()) {
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
return 0; // no settings file, or not ours to touch
|
||||
}
|
||||
if (!settings.hooks || typeof settings.hooks !== "object") return 0;
|
||||
|
||||
let removed = 0;
|
||||
for (const [type, entries] of Object.entries(settings.hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const kept = entries.filter((e) => !isOurEntry(e));
|
||||
removed += entries.length - kept.length;
|
||||
if (kept.length) settings.hooks[type] = kept;
|
||||
else delete settings.hooks[type];
|
||||
}
|
||||
if (!removed) return 0;
|
||||
|
||||
fs.copyFileSync(settingsPath, `${settingsPath}.ccam-bak`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
return removed;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ CLI on PATH */
|
||||
|
||||
function cliDir() {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a launcher for `ccam` into ~/.local/bin. A launcher rather than a
|
||||
* symlink into the plugin cache: that directory is replaced on every plugin
|
||||
* update, which would leave a dangling link until the next session.
|
||||
*
|
||||
* Never overwrites a `ccam` this bootstrap did not write — a developer's
|
||||
* `npm link`ed checkout CLI must keep winning.
|
||||
*
|
||||
* @returns {"written"|"exists"|"foreign"|"failed"}
|
||||
*/
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
||||
const marker = "# ccam-plugin-launcher";
|
||||
const target = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
|
||||
const body =
|
||||
process.platform === "win32"
|
||||
? `@rem ccam-plugin-launcher\r\n@node "${path.join(root, "bin", "ccam.js")}" %*\r\n`
|
||||
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "ccam.js")}" "$@"\n`;
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
if (!current.includes("ccam-plugin-launcher")) return "foreign";
|
||||
if (current === body) return "exists";
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
fs.chmodSync(target, 0o755);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- dependencies */
|
||||
|
||||
/**
|
||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||
* manifests are copied out of the (read-only) plugin cache so npm has a project
|
||||
* to install for.
|
||||
*/
|
||||
function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
for (const f of ["package.json", "package-lock.json"]) {
|
||||
const src = path.join(root, f);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const res = spawnSync(
|
||||
npm,
|
||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
||||
);
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ server */
|
||||
|
||||
/**
|
||||
* PIDs of dashboard servers recorded in the discovery file that are still
|
||||
* running. Reads the file directly because server-info exposes ports only.
|
||||
*
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function livePids() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
||||
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
||||
return servers.map((s) => s && s.pid).filter((pid) => isPidAlive(pid));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop running dashboard servers. Needed after a plugin update: the old server
|
||||
* runs from a cache directory Claude Code has already replaced, so it must be
|
||||
* retired before the new one can take the port.
|
||||
*
|
||||
* @returns {number} how many processes were signalled
|
||||
*/
|
||||
function stopDashboard() {
|
||||
let stopped = 0;
|
||||
for (const pid of livePids()) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
stopped++;
|
||||
} catch {
|
||||
/* already gone, or not ours to signal */
|
||||
}
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until no dashboard PID is alive, so the replacement server does not
|
||||
* race the old one for the port. Bounded — the worker is detached, but it must
|
||||
* not hang forever if a process refuses to die.
|
||||
*/
|
||||
function waitForPortsFree(timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
||||
while (livePids().length && Date.now() < deadline) {
|
||||
Atomics.wait(sleeper, 0, 0, 200);
|
||||
}
|
||||
return livePids().length === 0;
|
||||
}
|
||||
|
||||
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
||||
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFile, logFile],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
// The plugin cache is read-only; /ccam-open builds the bundle here.
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- worker run */
|
||||
|
||||
function log(rt, line) {
|
||||
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
||||
try {
|
||||
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real work. Runs detached from the SessionStart hook, so its only channel
|
||||
* is the log file; `/ccam-update` runs it in the foreground with `force`, which
|
||||
* reinstalls dependencies and restarts the server unconditionally.
|
||||
*
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
|
||||
*/
|
||||
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
if (!nodeVersionOk()) {
|
||||
log(
|
||||
rt,
|
||||
`Node ${process.versions.node} is too old — CCAM needs Node >= ${MIN_NODE.join(".")} ` +
|
||||
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
|
||||
);
|
||||
return "node-too-old";
|
||||
}
|
||||
|
||||
if (!acquireLock(rt)) {
|
||||
log(rt, "another bootstrap holds the lock — nothing to do");
|
||||
return "locked";
|
||||
}
|
||||
|
||||
try {
|
||||
const wantDeps = depsHash();
|
||||
const state = readState(rt) || {};
|
||||
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
|
||||
log(rt, "installing runtime dependencies (first run takes a few minutes)");
|
||||
if (!installDeps(rt)) {
|
||||
log(rt, "npm install failed — see the output above; run /ccam-doctor after fixing it");
|
||||
return "install-failed";
|
||||
}
|
||||
}
|
||||
|
||||
const removed = stripLegacyHooks();
|
||||
if (removed) {
|
||||
log(
|
||||
rt,
|
||||
`removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` +
|
||||
`(backup: ${getSettingsPath()}.ccam-bak) — the plugin installs its own`
|
||||
);
|
||||
}
|
||||
|
||||
log(rt, `ccam CLI launcher: ${linkCli()}`);
|
||||
|
||||
// Built eagerly (not lazily behind /ccam-open) so the dashboard, including
|
||||
// client-only routes like /run, works the instant `claude` is started —
|
||||
// same trigger as the dependency install above: missing, or this plugin
|
||||
// version has not built one yet.
|
||||
const { buildClient } = require("./plugin-open");
|
||||
const uiResult = buildClient({
|
||||
rt,
|
||||
root: PLUGIN_ROOT,
|
||||
force: force || state.depsHash !== wantDeps,
|
||||
logPath: path.join(rt, "client-build.log"),
|
||||
});
|
||||
if (uiResult === "failed") {
|
||||
log(rt, "dashboard UI build failed — API and MCP tools still work; run /ccam-open to retry");
|
||||
} else if (uiResult === "built") {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
|
||||
// After a plugin update the running server executes code from a cache
|
||||
// directory Claude Code has already discarded — retire it first.
|
||||
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
|
||||
if (movedRoot && stopDashboard()) {
|
||||
log(rt, "stopped the server started from the previous plugin version");
|
||||
waitForPortsFree();
|
||||
}
|
||||
|
||||
if (movedRoot || !serverIsLive()) {
|
||||
const pid = startDashboard(rt);
|
||||
log(rt, `started the dashboard server (pid ${pid})`);
|
||||
}
|
||||
|
||||
writeState(
|
||||
{
|
||||
pluginVersion: pluginVersion(),
|
||||
depsHash: wantDeps,
|
||||
pluginRoot: PLUGIN_ROOT,
|
||||
claudeHome: getClaudeHome(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
rt
|
||||
);
|
||||
return "ok";
|
||||
} finally {
|
||||
releaseLock(rt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground pass, invoked by the SessionStart hook. Returns within
|
||||
* milliseconds in the steady state, and otherwise hands off to a detached
|
||||
* worker so the session never waits on an install.
|
||||
*/
|
||||
function main() {
|
||||
if (fastPathOk()) return;
|
||||
const child = spawn(process.execPath, [__filename, "--worker"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const force = process.argv.includes("--force");
|
||||
if (force || process.argv.includes("--worker")) {
|
||||
// `--force` (from /ccam-update) runs in the foreground so the user sees the
|
||||
// npm output and the result.
|
||||
const result = bootstrap(runtimeDir(), { force });
|
||||
if (force) {
|
||||
console.log(result === "ok" ? "CCAM runtime refreshed." : `bootstrap: ${result}`);
|
||||
if (result !== "ok") process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
main();
|
||||
} catch {
|
||||
// A bootstrap failure must never break a Claude Code session.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
stripLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-doctor.js
|
||||
* @description Reports the health of a plugin-installed CCAM: Node version,
|
||||
* bootstrap state, runtime dependencies, server liveness, duplicate hook
|
||||
* entries (the one failure that silently doubles every token and cost figure),
|
||||
* the `ccam` CLI launcher and its PATH, and whether the committed MCP build
|
||||
* still matches `mcp/src`. Read-only — it diagnoses, it never repairs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const boot = require("./plugin-bootstrap");
|
||||
const { isOurEntry } = require("./install-hooks");
|
||||
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
||||
const { resolveAllDashboardPorts } = require("../server/lib/server-info");
|
||||
const { mcpBuildStatus } = require("./check-mcp-build");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
/** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */
|
||||
function diagnose() {
|
||||
const rt = boot.runtimeDir();
|
||||
const out = [];
|
||||
const add = (level, label, detail) => out.push({ level, label, detail });
|
||||
|
||||
add(
|
||||
boot.nodeVersionOk() ? "ok" : "fail",
|
||||
"Node",
|
||||
boot.nodeVersionOk()
|
||||
? `v${process.versions.node}`
|
||||
: `v${process.versions.node} — CCAM needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)`
|
||||
);
|
||||
|
||||
add("ok", "Plugin root", PLUGIN_ROOT);
|
||||
add("ok", "Data dir", getDataDir());
|
||||
|
||||
const state = boot.readState(rt);
|
||||
if (!state) {
|
||||
add(
|
||||
"warn",
|
||||
"Bootstrap",
|
||||
`no state recorded — start a new session, or check ${rt}/bootstrap.log`
|
||||
);
|
||||
} else {
|
||||
const moved = state.pluginRoot !== PLUGIN_ROOT;
|
||||
add(
|
||||
moved ? "warn" : "ok",
|
||||
"Bootstrap",
|
||||
moved
|
||||
? `recorded against a previous plugin version (${state.pluginRoot}) — run /ccam-update`
|
||||
: `last run ${state.updatedAt}`
|
||||
);
|
||||
}
|
||||
|
||||
const deps = fs.existsSync(path.join(rt, "node_modules"));
|
||||
add(
|
||||
deps ? "ok" : "fail",
|
||||
"Runtime deps",
|
||||
deps ? path.join(rt, "node_modules") : "missing — run /ccam-update"
|
||||
);
|
||||
|
||||
const ports = (() => {
|
||||
try {
|
||||
return resolveAllDashboardPorts();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
add(
|
||||
ports.length ? "ok" : "fail",
|
||||
"Server",
|
||||
ports.length
|
||||
? `listening on ${ports.map((p) => `http://localhost:${p}`).join(", ")}`
|
||||
: `not running — see ${path.join(rt, "server.log")}`
|
||||
);
|
||||
|
||||
const dup = countLegacyHookEntries();
|
||||
add(
|
||||
dup ? "fail" : "ok",
|
||||
"Hooks",
|
||||
dup
|
||||
? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` +
|
||||
`every event is counted twice. Remove them (a new session does it automatically).`
|
||||
: "provided by the plugin only"
|
||||
);
|
||||
|
||||
add(...cliStatus());
|
||||
|
||||
const mcp = mcpBuildStatus(PLUGIN_ROOT);
|
||||
add(
|
||||
mcp.ok ? "ok" : "fail",
|
||||
"MCP build",
|
||||
mcp.ok ? "matches mcp/src" : `${mcp.reason} — run npm run mcp:build`
|
||||
);
|
||||
|
||||
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
||||
add(
|
||||
fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn",
|
||||
"Dashboard UI",
|
||||
fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /ccam-open"
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function countLegacyHookEntries() {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
||||
if (!settings.hooks) return 0;
|
||||
return Object.values(settings.hooks)
|
||||
.filter(Array.isArray)
|
||||
.reduce((n, entries) => n + entries.filter(isOurEntry).length, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function cliStatus() {
|
||||
const dir = path.join(os.homedir(), ".local", "bin");
|
||||
const file = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
|
||||
if (!fs.existsSync(file)) return ["warn", "ccam CLI", `no launcher at ${file}`];
|
||||
const onPath = (process.env.PATH || "")
|
||||
.split(path.delimiter)
|
||||
.some((p) => path.resolve(p) === path.resolve(dir));
|
||||
return onPath
|
||||
? ["ok", "ccam CLI", file]
|
||||
: [
|
||||
"warn",
|
||||
"ccam CLI",
|
||||
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
|
||||
];
|
||||
}
|
||||
|
||||
function report() {
|
||||
const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" };
|
||||
const rows = diagnose();
|
||||
for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`);
|
||||
return rows.some((r) => r.level === "fail") ? 1 : 0;
|
||||
}
|
||||
|
||||
if (require.main === module) process.exitCode = report();
|
||||
|
||||
module.exports = { diagnose, report, countLegacyHookEntries };
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-open.js
|
||||
* @description Builds the dashboard UI bundle for a plugin install and prints
|
||||
* the dashboard URL. `scripts/plugin-bootstrap.js` calls `buildClient` eagerly
|
||||
* on session start, so client-only routes (e.g. `/run`) work the moment
|
||||
* `claude` is started, not just the API and MCP tools. `/ccam-open` re-runs
|
||||
* this standalone — a no-op when the bundle already matches — as a fallback
|
||||
* for `--force` rebuilds or a first build that failed during bootstrap.
|
||||
*
|
||||
* The client source is copied out of the (replaced-on-update) plugin cache into
|
||||
* the runtime dir before building, so neither the cache nor the checkout is
|
||||
* written to, and the output lands where the server already serves from
|
||||
* (`DASHBOARD_CLIENT_DIST`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const boot = require("./plugin-bootstrap");
|
||||
const { resolveDashboardPort } = require("../server/lib/server-info");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function run(cmd, args, cwd, stdio) {
|
||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bundle into `<runtime>/client-dist`. Idempotent: an existing bundle
|
||||
* is left alone unless `force` is set.
|
||||
*
|
||||
* `logPath`, when given, redirects npm's output there instead of inheriting the
|
||||
* caller's stdio — used when this runs inside the detached bootstrap worker,
|
||||
* whose own stdout is discarded, so a build failure is still debuggable.
|
||||
*
|
||||
* @returns {"ready"|"built"|"failed"}
|
||||
*/
|
||||
function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false, logPath } = {}) {
|
||||
const dist = path.join(rt, "client-dist");
|
||||
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
||||
|
||||
const logFd = logPath ? fs.openSync(logPath, "a") : null;
|
||||
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
||||
try {
|
||||
const src = path.join(rt, "client-src");
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(root, "client"), src, {
|
||||
recursive: true,
|
||||
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
|
||||
});
|
||||
|
||||
if (!run(NPM, ["install", "--no-audit", "--no-fund"], src, stdio)) return "failed";
|
||||
if (!run(NPM, ["run", "build"], src, stdio)) return "failed";
|
||||
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
|
||||
return "built";
|
||||
} finally {
|
||||
if (logFd !== null) fs.closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardUrl() {
|
||||
try {
|
||||
return `http://localhost:${resolveDashboardPort()}`;
|
||||
} catch {
|
||||
return "http://localhost:4820";
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const result = buildClient({ force: process.argv.includes("--force") });
|
||||
if (result === "failed") {
|
||||
console.error("Client build failed — the API and MCP tools still work.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
if (result === "built") console.log("Dashboard UI built.");
|
||||
console.log(dashboardUrl());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildClient, dashboardUrl };
|
||||
@@ -1461,6 +1461,7 @@ graph TB
|
||||
# Server configuration
|
||||
DASHBOARD_PORT=4820 # Server port
|
||||
NODE_ENV=production # Environment mode
|
||||
DASHBOARD_CLIENT_DIST= # Where the built UI is served from (default: ../client/dist)
|
||||
|
||||
# Network exposure & hardening (see server/lib/security.js)
|
||||
DASHBOARD_HOST=127.0.0.1 # Bind address; default loopback. Set 0.0.0.0 to widen (logs a warning)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Tests scripts/check-mcp-build.js — the freshness gate for the committed
|
||||
* mcp/build artifact. Content hashing is the whole point: mtimes are meaningless
|
||||
* after a clone, where every file is stamped at checkout time in arbitrary order.
|
||||
* Runs against synthetic trees, never the real mcp/.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { sourceHash, mcpBuildStatus, writeHash } = require("../../scripts/check-mcp-build");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcpbuild-"));
|
||||
const SRC = path.join(ROOT, "mcp", "src");
|
||||
const BUILD = path.join(ROOT, "mcp", "build");
|
||||
|
||||
function makeTree() {
|
||||
fs.rmSync(path.join(ROOT, "mcp"), { recursive: true, force: true });
|
||||
fs.mkdirSync(SRC, { recursive: true });
|
||||
fs.mkdirSync(BUILD, { recursive: true });
|
||||
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 1;\n");
|
||||
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x"}\n');
|
||||
fs.writeFileSync(path.join(BUILD, "index.js"), "exports.a = 1;\n");
|
||||
}
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("mcp build freshness", () => {
|
||||
beforeEach(makeTree);
|
||||
|
||||
it("fails when the build has no recorded hash", () => {
|
||||
const status = mcpBuildStatus(ROOT);
|
||||
assert.equal(status.ok, false);
|
||||
assert.match(status.reason, /no recorded source hash/);
|
||||
});
|
||||
|
||||
it("passes right after the hash is stamped", () => {
|
||||
writeHash(ROOT);
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, true);
|
||||
});
|
||||
|
||||
it("fails when a source file changes after the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 2;\n");
|
||||
const status = mcpBuildStatus(ROOT);
|
||||
assert.equal(status.ok, false);
|
||||
assert.match(status.reason, /stale/);
|
||||
});
|
||||
|
||||
it("fails when a source file is added after the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(SRC, "extra.ts"), "export const b = 1;\n");
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, false);
|
||||
});
|
||||
|
||||
it("fails when the build output is missing entirely", () => {
|
||||
writeHash(ROOT);
|
||||
fs.rmSync(path.join(BUILD, "index.js"));
|
||||
assert.match(mcpBuildStatus(ROOT).reason, /missing/);
|
||||
});
|
||||
|
||||
it("ignores modification times — only content counts", () => {
|
||||
const before = sourceHash(ROOT);
|
||||
const future = Date.now() / 1000 + 10_000;
|
||||
fs.utimesSync(path.join(SRC, "index.ts"), future, future);
|
||||
assert.equal(sourceHash(ROOT), before);
|
||||
});
|
||||
|
||||
it("tracks the manifest, so a dependency bump invalidates the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x","version":"2"}\n');
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the committed mcp/build in this repo", () => {
|
||||
it("matches mcp/src", () => {
|
||||
const status = mcpBuildStatus(path.resolve(__dirname, "..", ".."));
|
||||
assert.equal(status.ok, true, status.reason);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file Tests the DASHBOARD_CLIENT_DIST override in server/index.js. A plugin
|
||||
* install runs the server from a read-only plugin cache directory, so the
|
||||
* client bundle is served from the writable runtime dir instead of the
|
||||
* checkout's client/dist. Also asserts the API still answers when the
|
||||
* configured bundle directory does not exist yet (the normal state before
|
||||
* /ccam-open builds it).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-clientdist-"));
|
||||
const DIST = path.join(TMP, "client-dist");
|
||||
fs.mkdirSync(DIST);
|
||||
fs.writeFileSync(path.join(DIST, "index.html"), "<!doctype html><title>from-runtime</title>");
|
||||
|
||||
// Must be set BEFORE requiring the server: the data dir, discovery file and
|
||||
// the static mount are all resolved at startup.
|
||||
process.env.CLAUDE_HOME = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "test.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.DASHBOARD_CLIENT_DIST = DIST;
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function get(urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(new URL(urlPath, BASE), (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("DASHBOARD_CLIENT_DIST override", () => {
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (server) server.close();
|
||||
if (db) db.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
delete process.env.DASHBOARD_CLIENT_DIST;
|
||||
});
|
||||
|
||||
it("serves index.html from the configured directory", async () => {
|
||||
const res = await get("/");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /from-runtime/);
|
||||
});
|
||||
|
||||
it("keeps the API working alongside the override", async () => {
|
||||
const res = await get("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DASHBOARD_CLIENT_DIST pointing at a missing directory", () => {
|
||||
let srv;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
process.env.DASHBOARD_CLIENT_DIST = path.join(TMP, "not-built-yet");
|
||||
srv = await startServer(createApp(), 0);
|
||||
base = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (srv) srv.close();
|
||||
});
|
||||
|
||||
it("answers the API and only 404s the UI route", async () => {
|
||||
const prevBase = BASE;
|
||||
BASE = base;
|
||||
try {
|
||||
const health = await get("/api/health");
|
||||
assert.equal(health.status, 200);
|
||||
const ui = await get("/");
|
||||
assert.equal(ui.status, 404);
|
||||
} finally {
|
||||
BASE = prevBase;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @file Tests scripts/plugin-bootstrap.js — the SessionStart bootstrap that
|
||||
* makes a plugin install self-sufficient. Covers the fast path, the Node
|
||||
* version gate, stale-lock reclaim, legacy hook removal (the duplicate-hook
|
||||
* double-counting bug) and the CLI launcher's refusal to clobber a foreign
|
||||
* `ccam`. Every path is exercised against injected temp directories — nothing
|
||||
* here touches the real $HOME.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bootstrap-"));
|
||||
process.env.CLAUDE_HOME = TMP_HOME;
|
||||
delete process.env.DASHBOARD_DATA_DIR;
|
||||
|
||||
const boot = require("../../scripts/plugin-bootstrap");
|
||||
|
||||
const RT = path.join(TMP_HOME, "agent-dashboard", "runtime");
|
||||
|
||||
function resetRuntime() {
|
||||
fs.rmSync(RT, { recursive: true, force: true });
|
||||
fs.mkdirSync(RT, { recursive: true });
|
||||
}
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runtime location", () => {
|
||||
it("lives under the shared data dir, not the plugin cache", () => {
|
||||
assert.equal(boot.runtimeDir(), RT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node version gate", () => {
|
||||
it("refuses anything below 22.5 (node:sqlite is the only driver available)", () => {
|
||||
assert.equal(boot.nodeVersionOk("20.11.0"), false);
|
||||
assert.equal(boot.nodeVersionOk("22.4.1"), false);
|
||||
});
|
||||
|
||||
it("accepts 22.5 and newer", () => {
|
||||
assert.equal(boot.nodeVersionOk("22.5.0"), true);
|
||||
assert.equal(boot.nodeVersionOk("24.0.0"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fast path", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
it("is false with no recorded state", () => {
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
});
|
||||
|
||||
it("is false when the recorded plugin version is stale", () => {
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
boot.writeState({ pluginVersion: "0.0.0-old", depsHash: boot.depsHash() }, RT);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
});
|
||||
|
||||
it("is false after a plugin update moved the install to a new cache dir", () => {
|
||||
const pkg = require("../../package.json");
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
boot.writeState(
|
||||
{
|
||||
pluginVersion: pkg.version,
|
||||
depsHash: boot.depsHash(),
|
||||
pluginRoot: "/old/plugin/cache/dir",
|
||||
},
|
||||
RT
|
||||
);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
});
|
||||
|
||||
it("is false when node_modules is missing even if state matches", () => {
|
||||
const pkg = require("../../package.json");
|
||||
boot.writeState({ pluginVersion: pkg.version, depsHash: boot.depsHash() }, RT);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap lock", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
it("is exclusive while held", () => {
|
||||
assert.equal(boot.acquireLock(RT), true);
|
||||
assert.equal(boot.acquireLock(RT), false, "a live lock must not be reclaimed");
|
||||
boot.releaseLock(RT);
|
||||
assert.equal(fs.existsSync(boot.lockPath(RT)), false);
|
||||
});
|
||||
|
||||
it("reclaims a lock whose owner is dead", () => {
|
||||
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
||||
// PID 1 is alive but not ours; use an unused high pid instead.
|
||||
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), "999999", "utf8");
|
||||
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
||||
assert.equal(boot.acquireLock(RT), true);
|
||||
boot.releaseLock(RT);
|
||||
});
|
||||
|
||||
it("reclaims a lock older than the stale timeout even if its pid is alive", () => {
|
||||
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
||||
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), String(process.pid), "utf8");
|
||||
const old = Date.now() - boot.LOCK_STALE_MS - 1000;
|
||||
fs.utimesSync(boot.lockPath(RT), old / 1000, old / 1000);
|
||||
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
||||
assert.equal(boot.acquireLock(RT), true);
|
||||
boot.releaseLock(RT);
|
||||
});
|
||||
|
||||
it("treats a lock directory with no pid file as debris", () => {
|
||||
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
||||
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy hook cleanup", () => {
|
||||
const settings = path.join(TMP_HOME, "settings-legacy.json");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(settings, { force: true });
|
||||
fs.rmSync(`${settings}.ccam-bak`, { force: true });
|
||||
});
|
||||
|
||||
it("removes checkout-installed hook entries and backs the file up first", () => {
|
||||
fs.writeFileSync(
|
||||
settings,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
|
||||
],
|
||||
},
|
||||
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
|
||||
],
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: "command", command: "node /repo/scripts/hook-handler.js SessionStart" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
const removed = boot.stripLegacyHooks(settings);
|
||||
assert.equal(removed, 2);
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(settings, "utf8"));
|
||||
assert.equal(after.hooks.PreToolUse.length, 1, "unrelated hooks must survive");
|
||||
assert.match(JSON.stringify(after.hooks.PreToolUse), /other\/tool\.js/);
|
||||
assert.equal(after.hooks.SessionStart, undefined, "an emptied list is dropped");
|
||||
assert.ok(fs.existsSync(`${settings}.ccam-bak`), "must back up before writing");
|
||||
});
|
||||
|
||||
it("leaves a file with no CCAM hooks untouched and writes no backup", () => {
|
||||
fs.writeFileSync(settings, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
|
||||
assert.equal(boot.stripLegacyHooks(settings), 0);
|
||||
assert.equal(fs.existsSync(`${settings}.ccam-bak`), false);
|
||||
});
|
||||
|
||||
it("is a no-op when no settings file exists", () => {
|
||||
assert.equal(boot.stripLegacyHooks(path.join(TMP_HOME, "nope.json")), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ccam CLI launcher", () => {
|
||||
const bin = path.join(TMP_HOME, "bin");
|
||||
const target = path.join(bin, process.platform === "win32" ? "ccam.cmd" : "ccam");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes a launcher that points at the plugin's bin/ccam.js", () => {
|
||||
assert.equal(boot.linkCli(bin), "written");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /bin[/\\]ccam\.js/);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
boot.linkCli(bin);
|
||||
assert.equal(boot.linkCli(bin), "exists");
|
||||
});
|
||||
|
||||
it("never clobbers a ccam it did not write (a linked checkout keeps winning)", () => {
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.writeFileSync(target, '#!/bin/sh\nexec node /my/checkout/bin/ccam.js "$@"\n');
|
||||
assert.equal(boot.linkCli(bin), "foreign");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
|
||||
});
|
||||
});
|
||||
@@ -40,10 +40,18 @@ function listMd(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// The marketplace mixes two shapes: the root-source `ccam` plugin (the whole
|
||||
// repo — hooks, server, CLI, MCP) and ten subdirectory plugins. The root
|
||||
// plugin's commands live in plugins/ccam/, which is therefore NOT a plugin dir.
|
||||
const ROOT_PLUGIN = "ccam";
|
||||
|
||||
describe("plugin marketplace", () => {
|
||||
const marketplace = readJson(MARKETPLACE);
|
||||
const pluginDirs = listDirs(PLUGINS_DIR).sort();
|
||||
const entryNames = marketplace.plugins.map((p) => p.name).sort();
|
||||
const pluginDirs = listDirs(PLUGINS_DIR)
|
||||
.filter((d) => d !== ROOT_PLUGIN)
|
||||
.sort();
|
||||
const subdirEntries = marketplace.plugins.filter((p) => p.name !== ROOT_PLUGIN);
|
||||
const entryNames = subdirEntries.map((p) => p.name).sort();
|
||||
|
||||
it("marketplace.json has the required top-level shape", () => {
|
||||
assert.equal(typeof marketplace.name, "string");
|
||||
@@ -69,7 +77,7 @@ describe("plugin marketplace", () => {
|
||||
);
|
||||
});
|
||||
|
||||
for (const entry of marketplace.plugins) {
|
||||
for (const entry of subdirEntries) {
|
||||
describe(`entry: ${entry.name}`, () => {
|
||||
it("has name, path, description, tags", () => {
|
||||
assert.equal(typeof entry.name, "string");
|
||||
@@ -154,7 +162,7 @@ describe("plugin marketplace", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("contributes at least one skill or agent", () => {
|
||||
it("contributes at least one skill or agent (subdir plugins only)", () => {
|
||||
const skills = (() => {
|
||||
try {
|
||||
return listDirs(path.join(root, "skills")).length;
|
||||
@@ -167,4 +175,73 @@ describe("plugin marketplace", () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe(`root plugin: ${ROOT_PLUGIN}`, () => {
|
||||
const entry = marketplace.plugins.find((p) => p.name === ROOT_PLUGIN);
|
||||
const manifest = readJson(path.join(REPO_ROOT, ".claude-plugin", "plugin.json"));
|
||||
|
||||
it("is declared with the repo root as its source", () => {
|
||||
assert.ok(entry, "the root ccam entry is missing from marketplace.json");
|
||||
assert.equal(entry.source, "./");
|
||||
assert.equal(entry.path, undefined, "a root-source entry must not also carry a path");
|
||||
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0);
|
||||
});
|
||||
|
||||
it("has a manifest whose name matches the entry", () => {
|
||||
assert.equal(manifest.name, ROOT_PLUGIN);
|
||||
assert.ok(manifest.description.length > 20);
|
||||
assert.ok(manifest.author && manifest.author.name);
|
||||
assert.equal(typeof manifest.license, "string");
|
||||
});
|
||||
|
||||
it("wires every hook type through the plugin's own handler path", () => {
|
||||
const withMatcher = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification"];
|
||||
const withoutMatcher = ["SessionStart", "SessionEnd", "UserPromptSubmit"];
|
||||
for (const type of [...withMatcher, ...withoutMatcher]) {
|
||||
const entries = manifest.hooks[type];
|
||||
assert.ok(Array.isArray(entries) && entries.length, `hook ${type} is not declared`);
|
||||
const json = JSON.stringify(entries);
|
||||
assert.match(json, /hook-handler\.js/, `hook ${type} does not call the handler`);
|
||||
assert.match(
|
||||
json,
|
||||
/\$\{CLAUDE_PLUGIN_ROOT\}/,
|
||||
`hook ${type} must resolve through CLAUDE_PLUGIN_ROOT, not a checkout path`
|
||||
);
|
||||
if (withMatcher.includes(type)) {
|
||||
assert.equal(entries[0].matcher, "*", `hook ${type} needs a matcher`);
|
||||
} else {
|
||||
assert.equal(entries[0].matcher, undefined, `hook ${type} takes no matcher`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the bootstrap on SessionStart", () => {
|
||||
assert.match(JSON.stringify(manifest.hooks.SessionStart), /plugin-bootstrap\.js/);
|
||||
});
|
||||
|
||||
it("points at command files that exist and carry a description", () => {
|
||||
assert.ok(manifest.commands.length >= 3);
|
||||
for (const rel of manifest.commands) {
|
||||
const file = path.join(REPO_ROOT, rel);
|
||||
assert.ok(fs.existsSync(file), `${rel} does not exist`);
|
||||
const { frontmatter } = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
||||
assert.ok(frontmatter && frontmatter.description, `${rel} has no description`);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares an MCP config that exists and resolves through CLAUDE_PLUGIN_ROOT", () => {
|
||||
const mcpFile = path.join(REPO_ROOT, manifest.mcpServers);
|
||||
assert.ok(fs.existsSync(mcpFile), `${manifest.mcpServers} does not exist`);
|
||||
const mcp = readJson(mcpFile);
|
||||
const args = JSON.stringify(mcp.mcpServers);
|
||||
assert.match(args, /\$\{CLAUDE_PLUGIN_ROOT\}\/mcp\/build\/index\.js/);
|
||||
});
|
||||
|
||||
it("ships the built MCP server the config points at", () => {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(REPO_ROOT, "mcp", "build", "index.js")),
|
||||
"mcp/build/index.js must be committed — plugin MCP servers start before any bootstrap can build them"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -153,7 +153,13 @@ function startServer(app, port) {
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
if (isProduction) {
|
||||
const clientDist = path.join(__dirname, "..", "client", "dist");
|
||||
// The bundle normally sits in the checkout. A plugin install runs the
|
||||
// server out of a read-only plugin cache directory, so DASHBOARD_CLIENT_DIST
|
||||
// points it at the writable runtime dir instead (see
|
||||
// scripts/plugin-bootstrap.js). A missing directory is harmless: the API
|
||||
// serves fine and only the UI route 404s until the bundle is built.
|
||||
const clientDist =
|
||||
process.env.DASHBOARD_CLIENT_DIST || path.join(__dirname, "..", "client", "dist");
|
||||
// Cache policy designed to survive client rebuilds without forcing a hard
|
||||
// refresh:
|
||||
// - Hashed bundles under /assets/ never change for a given URL, so cache
|
||||
|
||||
Reference in New Issue
Block a user