diff --git a/.changeset/README.md b/.changeset/README.md index 8baceff..51c7abd 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -7,8 +7,8 @@ SPDX-License-Identifier: MIT This folder holds the FocusMCP CLI changesets. Every PR that changes user-facing behaviour must add a changeset via `pnpm changeset`. -- Mode: **single package** — `@focusmcp/cli` is published as one npm package. -- `access: public` — published to the public npm registry on the `@focusmcp/` scope. +- Mode: **single package** — `@focus-mcp/cli` is published as one npm package. +- `access: public` — published to the public npm registry on the `@focus-mcp/` scope. - `baseBranch: develop` — changesets are opened against `develop` and promoted to `main` at release time. Format: Markdown with frontmatter listing the package + bump level (patch / minor / major). diff --git a/.changeset/config.json b/.changeset/config.json index b31f437..38592cb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "develop", - "updateInternalDependencies": "patch", - "ignore": [] + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "develop", + "updateInternalDependencies": "patch", + "ignore": [] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e05b4f7 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "focus-mcp-cli", + "owner": { + "name": "FocusMCP contributors", + "url": "https://github.com/focus-mcp" + }, + "plugins": [ + { + "name": "focus-mcp", + "source": "./", + "description": "MCP orchestrator — compose atomic AI tool bricks on demand and cut context from 200k to ~2k tokens.", + "version": "1.1.0", + "category": "mcp", + "tags": ["mcp", "orchestrator", "context-engineering", "ai-tools", "bricks"] + } + ] +} diff --git a/.claude-plugin/marketplace.json.license b/.claude-plugin/marketplace.json.license new file mode 100644 index 0000000..c035c4e --- /dev/null +++ b/.claude-plugin/marketplace.json.license @@ -0,0 +1 @@ +{ "SPDX-FileCopyrightText": "2026 FocusMCP contributors", "SPDX-License-Identifier": "MIT" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..f7b5a2f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "focus-mcp", + "version": "1.1.0", + "description": "FocusMCP — MCP orchestrator that reduces AI context from 200k to ~2k tokens. Compose AI tools (bricks) on demand without polluting context.", + "author": { + "name": "FocusMCP contributors", + "url": "https://github.com/focus-mcp" + }, + "homepage": "https://github.com/focus-mcp/cli", + "repository": "https://github.com/focus-mcp/cli", + "license": "MIT", + "keywords": ["mcp", "tools", "orchestrator", "focus", "bricks"], + "mcpServers": { + "focus": { + "command": "npx", + "args": ["-y", "@focus-mcp/cli@latest", "start"] + } + } +} diff --git a/.claude-plugin/plugin.json.license b/.claude-plugin/plugin.json.license new file mode 100644 index 0000000..c035c4e --- /dev/null +++ b/.claude-plugin/plugin.json.license @@ -0,0 +1 @@ +{ "SPDX-FileCopyrightText": "2026 FocusMCP contributors", "SPDX-License-Identifier": "MIT" } diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..730c038 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Setup toolchain +description: | + Clone @focus-mcp/core as a sibling directory (../core), install and build it, + then set up pnpm + Node and install this repo's dependencies. + The CLI depends on `@focus-mcp/core` via a `file:../core/packages/core` path, + so the sibling must exist and have a built `dist/` before `pnpm install`. + +inputs: + core-ref: + description: Git ref (branch, tag, SHA) of focus-mcp/core to check out. + required: false + default: develop + +runs: + using: composite + steps: + - name: Checkout @focus-mcp/core (inside workspace — actions/checkout restriction) + uses: actions/checkout@v4 + with: + repository: focus-mcp/core + ref: ${{ inputs.core-ref }} + path: .core-sibling + fetch-depth: 1 + + # actions/checkout refuses paths outside the workspace; move it to the real sibling now. + - name: Move @focus-mcp/core to sibling location + shell: bash + run: | + mv "$GITHUB_WORKSPACE/.core-sibling" "$GITHUB_WORKSPACE/../core" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - name: Install @focus-mcp/core dependencies + shell: bash + working-directory: ../core + run: pnpm install --frozen-lockfile + + - name: Build @focus-mcp/core + shell: bash + working-directory: ../core + run: pnpm --filter "@focus-mcp/core" build + + - name: Install CLI dependencies + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/renovate.json b/.github/renovate.json index 7b9ec84..86160e3 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,39 +1,44 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - ":semanticCommits", - ":semanticCommitTypeAll(chore)", - "group:monorepos", - "group:recommended", - "schedule:weekly" - ], - "labels": ["dependencies"], - "rangeStrategy": "bump", - "lockFileMaintenance": { - "enabled": true, - "schedule": ["before 5am on monday"] - }, - "vulnerabilityAlerts": { - "labels": ["security"], - "schedule": ["at any time"] - }, - "packageRules": [ - { - "matchUpdateTypes": ["patch", "minor"], - "matchCurrentVersion": "!/^0/", - "automerge": true, - "automergeType": "branch" + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":semanticCommitTypeAll(chore)", + "group:monorepos", + "group:recommended", + "schedule:weekly" + ], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 5am on monday"] }, - { - "matchPackageNames": ["typescript", "@biomejs/biome", "vitest", "@modelcontextprotocol/sdk"], - "automerge": false + "vulnerabilityAlerts": { + "labels": ["security"], + "schedule": ["at any time"] }, - { - "matchDepTypes": ["devDependencies"], - "automerge": true - } - ], - "prHourlyLimit": 4, - "prConcurrentLimit": 10 + "packageRules": [ + { + "matchUpdateTypes": ["patch", "minor"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "branch" + }, + { + "matchPackageNames": [ + "typescript", + "@biomejs/biome", + "vitest", + "@modelcontextprotocol/sdk" + ], + "automerge": false + }, + { + "matchDepTypes": ["devDependencies"], + "automerge": true + } + ], + "prHourlyLimit": 4, + "prConcurrentLimit": 10 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19518a5..2f925f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,8 @@ jobs: name: Lint (Biome) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup - run: pnpm lint commitlint: @@ -35,43 +30,28 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - run: pnpm exec commitlint --config config/commitlint.config.js --from ${{ github.event.pull_request.base.sha }} --to HEAD --verbose typecheck: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup - run: pnpm typecheck test: name: Test + Coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup - run: pnpm test:coverage - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: always() with: name: coverage @@ -82,14 +62,14 @@ jobs: name: REUSE compliance runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: fsfe/reuse-action@v5 gitleaks: name: Gitleaks (secret scan) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install gitleaks @@ -104,15 +84,10 @@ jobs: runs-on: ubuntu-latest needs: [typecheck, test] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup - run: pnpm build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: always() with: name: dist diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000..45aadd1 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + issue_comment: + types: [created] + +jobs: + review: + if: | + (github.event_name == 'pull_request') || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + Review this pull request as a senior engineer. Post inline comments on issues you find. At the end, post a summary review with verdict (approve / request changes / comment). + + Focus areas (in order of priority): + 1. **Correctness** — does the code do what the PR description claims? Any obvious bugs, race conditions, or broken edge cases? + 2. **Security** — input validation, injection risks, unsafe shell/eval, secret leaks, unsafe deps. + 3. **Test coverage** — are new code paths tested? Any missing edge-case tests? + 4. **TypeScript strictness** — no `any`, proper types, `node:` protocol for stdlib imports. + 5. **Consistency** — matches surrounding patterns, naming conventions, file layout. + 6. **Docs** — public API changes reflected in README/AGENTS.md. + + Be terse and concrete. If the PR is clean, say "LGTM" and approve. Do not hedge. + Reject `--no-verify` and any bypasses of CI gates in the code. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 06cffcc..478547f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: matrix: language: [typescript] steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 + - uses: actions/checkout@v5 + - uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql-config.yml - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@v4 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml new file mode 100644 index 0000000..4d5cd45 --- /dev/null +++ b/.github/workflows/dev-publish.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Dev Publish + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + +concurrency: + group: dev-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-dev: + name: Publish @focus-mcp/cli@dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup + - uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + scope: '@focus-mcp' + - name: Compute dev version + id: version + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD) + DEV_NUM=$(git rev-list --count ${LAST_TAG}..HEAD) + BASE_VERSION=$(node -e "const p=require('./package.json'); console.log(p.version || '0.1.0')") + DEV_VERSION="${BASE_VERSION}-dev.${DEV_NUM}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "Dev version: ${DEV_VERSION}" + - name: Set dev version + run: npm version "${DEV_VERSION}" --no-git-tag-version + env: + DEV_VERSION: ${{ steps.version.outputs.version }} + - run: pnpm build + - name: Publish with dev tag + run: npm publish --tag dev --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 33b7e88..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: 2026 FocusMCP contributors -# SPDX-License-Identifier: MIT -# -# Release workflow — on push to main: -# 1. Runs Changesets to bump versions, update CHANGELOG.md and open a "Version Packages" PR. -# 2. When the Version Packages PR is merged, Changesets publishes `@focusmcp/cli` to npm -# via `pnpm release` (which maps to `changeset publish`). -# -# TODO: before the first release, add `NPM_TOKEN` (an automation token with publish -# permissions for the `@focusmcp` scope) to the repo secrets. -# Settings → Secrets and variables → Actions → New repository secret. - -name: Release - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - id-token: write - -concurrency: release-${{ github.ref }} - -jobs: - release: - name: Release via Changesets - runs-on: ubuntu-latest - outputs: - published: ${{ steps.changesets.outputs.published }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - registry-url: 'https://registry.npmjs.org' - - run: pnpm install --frozen-lockfile - - run: pnpm build - - id: changesets - uses: changesets/action@v1 - with: - publish: pnpm release - version: pnpm version - commit: 'chore(release): version @focusmcp/cli' - title: 'chore(release): version @focusmcp/cli' - createGithubReleases: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/stable-publish.yml b/.github/workflows/stable-publish.yml new file mode 100644 index 0000000..3d000c2 --- /dev/null +++ b/.github/workflows/stable-publish.yml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Stable Publish + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + +concurrency: + group: stable-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-stable: + name: Publish @focus-mcp/cli@latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup + - uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + scope: '@focus-mcp' + - run: pnpm build + - name: Publish with latest tag + run: npm publish --access public 2>&1 || echo " → skipped (already published?)" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.husky/pre-commit b/.husky/pre-commit index aca5dac..f4c82cf 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,5 +2,12 @@ # SPDX-FileCopyrightText: 2026 FocusMCP contributors # SPDX-License-Identifier: MIT +# Secret scanning +if command -v gitleaks >/dev/null 2>&1; then + gitleaks protect --staged --redact --verbose --config config/gitleaks.toml || exit $? +else + echo "⚠️ gitleaks not installed — install via https://github.com/gitleaks/gitleaks" +fi + # Lint + format staged files pnpm exec lint-staged --config config/lint-staged.config.js diff --git a/AGENTS.md b/AGENTS.md index 210fd03..6b24ffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,23 +5,74 @@ SPDX-License-Identifier: MIT # AGENTS.md -> Instructions for AI agents working on this repository (Claude Code, Cursor, Codex, Copilot, Gemini CLI, Aider, etc.). -> Format inspired by the emerging [agents.md](https://agentsmd.net/) convention. +> This file is the **single source of truth for AI agent behavior** on this project. +> It follows the [agents.md](https://agents.md) standard and is read by Claude Code, +> Cursor, Aider, GitHub Copilot, and any other AI coding tool. +> +> Humans, this file is for you too — it documents our conventions and expectations. ## Project -**FocusMCP CLI** — the primary entry point of FocusMCP. Fourth repo of the ecosystem (after `core`, `client` (frozen), `marketplace`). CLI-first pivot: no Tauri app for MVP — any AI client that speaks MCP can consume FocusMCP through this CLI. -Read [PRD.md](./PRD.md) for the complete CLI vision (commands, transport, distribution). +**FocusMCP CLI** — the primary entry point of FocusMCP. Published as `@focus-mcp/cli` on npm. +This repo is the **primary entry point**: a Node CLI that embeds `@focus-mcp/core` and speaks **stdio MCP** (via `@modelcontextprotocol/sdk`) to AI clients (Claude Code, Cursor, Codex, Gemini CLI…). +Read [VISION.md](./VISION.md) for the complete CLI vision (commands, transport, distribution). + +## Ecosystem + +| Repo | Status | Role | +|---|---|---| +| `focus-mcp/core` | active | TS monorepo lib — 3 pillars (Registry/EventBus/Router) + SDK/Validator/Marketplace resolver. Consumed via `file:../core/packages/core`. | +| `focus-mcp/cli` (here) | active | `@focus-mcp/cli` — stdio MCP, brick manager (`focus list/info/add/remove/search/catalog/browse`). Published on npm. | +| `focus-mcp/marketplace` | active | Official catalog + `bricks/*` + `modules/*`. | +| `focus-mcp/client` | **archived** | Former Tauri desktop app, frozen after CLI-first pivot. | + +## CLI-first architecture + +``` +AI client (Claude Code, Cursor, Codex, Gemini…) + │ stdio (JSON-RPC / MCP) + ▼ +@focus-mcp/cli (focus start) + ├─ @modelcontextprotocol/sdk StdioServerTransport + ├─ @focus-mcp/core (createFocusMcp) + │ Registry + EventBus + Router + bricks + └─ ~/.focus/center.json + ~/.focus/center.lock +``` + +`focus start` is the **only** way AI clients attach. Do not add HTTP as the default transport; do not bundle a UI. A separate `cli-manager` (Phase 2) will consume a future admin API if needed. + +**Distribution**: `npm install -g @focus-mcp/cli` or `npx @focus-mcp/cli start`. + +## Claude Code native plugin + +The repo ships a native Claude Code plugin in `.claude-plugin/plugin.json` (v1.1.0): + +```json +{ + "mcpServers": { + "focus": { + "command": "npx", + "args": ["-y", "@focus-mcp/cli@latest", "start"] + } + } +} +``` + +Install with two commands (works today): +``` +/plugin marketplace add focus-mcp/cli +/plugin install focus-mcp@focus-mcp-cli +``` ## Stack - **Node.js ≥ 22** (LTS), **pnpm ≥ 10**, **TypeScript 5.7+** strict - **ESM only** (`"type": "module"`) -- **Single package** — `@focusmcp/cli` published to npm under the `@focusmcp` scope -- Tests: **Vitest** +- **Single package** — `@focus-mcp/cli` published to npm under the `@focus-mcp` scope +- Tests: **Vitest** (coverage target: 100%; minimum absolute: 80%) - Lint/format: **Biome 2.x** - Build: **tsup** (ESM, Node 22 target, dts for the programmatic entry only) -- Changesets in **single-package** mode +- TUI: **ink** (React-based terminal UI for `focus browse`) ## File layout @@ -31,22 +82,63 @@ Source code lives in `src/`: - `src/bin/focus.ts` — the `focus` binary (shebang, `parseArgs` dispatch) - `src/commands/.ts` — pure functions, one per subcommand +- `src/commands/browse/` — interactive TUI (`focus browse`) built with ink - `src/center.ts` — parsers for `~/.focus/center.json` and `~/.focus/center.lock` - `src/index.ts` — programmatic API (re-exports only) +The Claude Code native plugin lives in `.claude-plugin/plugin.json` — it wires `focus start` as an MCP server. Install via the marketplace manifest in `.claude-plugin/marketplace.json` (see install instructions above). + +## CLI commands (v1.1.0 — all implemented) + +- `focus list` — list installed bricks (reads `~/.focus/center.json` + `center.lock`) +- `focus info ` — details for a brick +- `focus start` — launch stdio MCP via `@modelcontextprotocol/sdk` +- `focus add ` — install a brick from the catalog (npm) +- `focus remove ` — uninstall a brick +- `focus search ` — search the catalog +- `focus catalog` — display/manage catalog sources +- `focus browse` — **interactive TUI** (ink + React) — split left/right panel, help overlay (`?`), keyboard navigation, search `/`, install `i`, uninstall `u` + +## Infrastructure adapters + +``` +focus add + ├─ http-fetch-adapter → catalog.json (remote source URL) + ├─ catalog-store-adapter → local cache + brick resolution (~/.focus/) + └─ npm-installer-adapter → npm install @focus-mcp/ +``` + +## Critical dependency: `@focus-mcp/core` + +`@focus-mcp/core` is consumed via `file:../core/packages/core`. This means: + +- **Local dev**: `focus-mcp/core` must be cloned as a sibling of this repo (`../core`). +- **CI**: composite action `.github/actions/setup` clones core, builds it, then installs this repo. +- **npm publish**: `tsup --noExternal '@focus-mcp/core'` bundles core into dist — end users only install `@focus-mcp/cli`. + ## Non-negotiable rules -1. **Strict TDD** — write the test BEFORE the code (Red → Green → Refactor). Coverage ≥ 80 % global. +1. **Strict TDD** — write the test BEFORE the code (Red → Green → Refactor). Coverage ≥ 80% global (target: 100%). 2. **No `any`**, no untyped catch, no `!` non-null assertions. -3. **No `console.log` outside `src/bin/` and `src/commands/`.** The Biome override allows console in those two folders (they are the CLI surface); everywhere else, use structured logging via `@focusmcp/core`. +3. **No `console.log` outside `src/bin/` and `src/commands/`.** The Biome override allows console in those two folders (they are the CLI surface); everywhere else, use structured logging via `@focus-mcp/core`. 4. **SPDX header** in every source file: `SPDX-FileCopyrightText: 2026 FocusMCP contributors` + `SPDX-License-Identifier: MIT`. For JSON files (no comment support), create a sibling `.license` file (REUSE convention). 5. **Imports**: `node:` protocol (`import { parseArgs } from 'node:util'`). 6. **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. 7. **No unsolicited features** — stick strictly to the requested scope. 8. **stdio MCP is the canonical transport.** An HTTP admin API is Phase 2, gated behind an explicit flag; it is not the way AI clients attach. -9. **`@focusmcp/core` is a git dependency** (`github:focus-mcp/core`). Do not try to publish `@focusmcp/core` to npm from this repo. +9. **`@focus-mcp/core` is a file dependency** (`file:../core/packages/core`). Do not try to publish `@focus-mcp/core` to npm from this repo. It is bundled into the CLI dist via `tsup --noExternal`. 10. **Pure command functions.** Every `src/commands/.ts` exports a function that takes already-parsed state (or structured input) and returns the string to print — no I/O, no `process.exit`. The binary in `src/bin/focus.ts` is the only layer allowed to touch `process.*`, stdin/stdout, and the filesystem. +11. **npm scope is `@focus-mcp`** (with hyphen). Never write `@focusmcp` (no hyphen) in new code or docs. +12. **No dynamic code evaluation** — no `eval`, no dynamic `Function` constructor, no `vm.runInContext` unless absolutely unavoidable and reviewed. + +## GitHub Rulesets + +Every active repo in the FocusMCP org has two rulesets — do not modify without discussion: + +- **`main protection`** — targets `refs/heads/main` ONLY: `required_status_checks`, `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, `deletion`, `non_fast_forward`. **No `required_signatures`** (AI-assisted commits are not signed). +- **`develop protection`** — targets `refs/heads/develop` ONLY: `deletion`, `non_fast_forward`, `required_linear_history`, `pull_request` (no `code_quality` — this check is not available on non-default branches). +- **Known pitfall**: NEVER include `develop` in the targets of "main protection". ## Commands @@ -58,44 +150,50 @@ pnpm test:coverage # coverage + thresholds pnpm typecheck pnpm lint # Biome check pnpm lint:fix # Biome auto-fix -pnpm build # tsup -pnpm changeset # create a changeset before merging +pnpm build # tsup → dist/bin/focus.js + dist/index.js ``` -## CLI-first architecture +## Workflow for adding a feature -``` -AI client (Claude Code, Cursor, …) - │ stdio (JSON-RPC / MCP) - ▼ -@focusmcp/cli (focus start) - ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ @focusmcp/core (createFocusMcp) - │ Registry + EventBus + Router + bricks - └─ ~/.focus/center.json + ~/.focus/center.lock -``` +1. Read [VISION.md](./VISION.md) and this file +2. Feature branch from `develop` +3. Red → Green → Refactor +4. `pnpm test:coverage && pnpm typecheck && pnpm lint` +5. Conventional Commits +6. PR to `develop` — resolve all review threads before merge -`focus start` is the **only** way AI clients attach. Do not add HTTP as the default transport; do not bundle a UI. A separate `cli-manager` (Phase 2) will consume a future admin API if needed. +## Publishing -## Git-flow +Two workflows, no GitHub Packages: -- Working branch: **`develop`** (persistent, never deleted). -- Release: PR `develop → main`; `main` triggers `release.yml` (Changesets → npm publish). -- **Never `--delete-branch` on the develop→main PR.** +| Workflow | Trigger | Tag | Target | +|---|---|---|---| +| `dev-publish.yml` | push to `develop` | `dev` | npmjs.org | +| `stable-publish.yml` | push to `main` | `latest` | npmjs.org | + +Both require the `NPM_TOKEN` repo secret. No Changesets "Version Packages" PR is used. +To release: bump the version in `package.json` on `develop`, then merge to `main`. ## Security - **No secrets** in the code (gitleaks blocks in pre-commit and CI). -- **No `eval`**, no `new Function()`. -- Every external input (center.json, center.lock) is validated structurally before reaching `@focusmcp/core`. +- **No dynamic code evaluation** (see rule 12 above). +- Every external input (center.json, center.lock) is validated structurally before reaching `@focus-mcp/core`. +- The OS sandbox comes from the parent process (Claude Code spawns the CLI via stdio). +- EventBus guards (security layer 1) are intact, provided by `@focus-mcp/core`. +- For running unreviewed bricks: add `isolated-vm` in Phase 2 (not in MVP). -## Git remote +## Git-flow - **origin**: `git@github.com:focus-mcp/cli.git`. +- Working branch: **`develop`** (persistent, never deleted). +- Release: PR `develop → main`; `main` triggers `stable-publish.yml` (npm publish with `latest` tag). +- Dev snapshots: every push to `develop` triggers `dev-publish.yml` (npm publish with `dev` tag). +- **Never `--delete-branch` on the develop→main PR.** ## Documentation to read first -1. [PRD.md](./PRD.md) — vision, commands, roadmap +1. [VISION.md](./VISION.md) — CLI vision and principles 2. [CONTRIBUTING.md](./CONTRIBUTING.md) — contribution workflow -3. [../core/PRD.md](../core/PRD.md) — how the library the CLI embeds is shaped -4. [../marketplace/PRD.md](../marketplace/PRD.md) — where the catalogue the CLI resolves from comes from +3. [../core/VISION.md](../core/VISION.md) — how the library the CLI embeds is shaped +4. [../marketplace/VISION.md](../marketplace/VISION.md) — where the catalogue the CLI resolves from comes from diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..849bc12 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,86 @@ + + +# Architecture — @focus-mcp/cli + +## Overview + +`@focus-mcp/cli` is the primary Node CLI. It wraps `@focus-mcp/core`, adds I/O adapters for +filesystem/npm/http, and exposes an MCP stdio server + an interactive TUI. + +``` +AI client (Claude Code, Cursor, Codex, Gemini…) + │ JSON-RPC over stdio + ▼ +@focus-mcp/cli + ├─ @modelcontextprotocol/sdk StdioServerTransport + ├─ @focus-mcp/core (bundled via tsup) + │ ├─ Registry, EventBus, Router + │ └─ Loader, Marketplace resolver + ├─ Adapters (inject host I/O) + │ ├─ catalog-store-adapter → ~/.focus/catalogs.json + │ ├─ http-fetch-adapter → global fetch() + │ └─ npm-installer-adapter → child_process spawn + ├─ Commands (CLI surface) + │ ├─ list, info, add, remove, search, catalog + │ ├─ start (MCP server) + │ └─ browse (interactive TUI) + └─ TUI (ink + React) + ├─ Screens (Catalogs, Bricks, BrickDetails) + ├─ Hooks (useCatalogs, useBricks, useInstalled) + └─ Components (List, Breadcrumb, StatusBar, …) +``` + +## Directory layout + +``` +src/ +├── bin/focus.ts ← entry point, argv dispatch +├── commands/ ← one file per subcommand +│ ├── list.ts, info.ts, add.ts, remove.ts +│ ├── search.ts, catalog.ts, start.ts, browse.ts +├── adapters/ ← I/O implementations +│ ├── catalog-store-adapter.ts +│ ├── http-fetch-adapter.ts +│ └── npm-installer-adapter.ts +├── tui/ ← ink React app +│ ├── App.tsx, screens/, components/, hooks/ +└── center.ts ← ~/.focus/center.json state +``` + +## Key flows + +### `focus start` — MCP server + +1. Read `~/.focus/center.json` to know which bricks are enabled +2. Load each brick via `@focus-mcp/core` loader +3. Wrap `@modelcontextprotocol/sdk` StdioServerTransport +4. Route `tools/list` / `tools/call` through the core Router +5. Block until SIGINT/SIGTERM + +### `focus add ` + +1. Fetch enabled catalogs in parallel (`fetchAllCatalogs`) +2. Aggregate and find the brick (`findBrickAcrossCatalogs`) +3. Plan npm install (`planInstall`) +4. Execute: `npm install --prefix ~/.focus/bricks @focus-mcp/brick-@version` +5. Update `center.json` and `center.lock` + +### `focus browse` — Interactive TUI + +1. Render ink app (`App.tsx`) that routes between 3 screens +2. Each screen uses hooks to fetch data via `@focus-mcp/core` functions +3. Install/uninstall actions call the same `add`/`remove` command pipeline + +## Publish & distribution + +- Node 22+, TypeScript 5.7+ strict, ESM only +- `@focus-mcp/core` consumed as `file:../core/packages/core` (sibling clone) +- `tsup --noExternal '@focus-mcp/core'` bundles core into dist +- End users only install `@focus-mcp/cli`, no peer-dep management + +## Testing + +Vitest with mocked I/O adapters. 100% coverage on commands and center state parsers. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 075ae53..39f4e79 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -31,7 +31,7 @@ Examples of unacceptable behavior include: ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at **conduct@focusmcp.dev** or via [GitHub Security Advisories](https://github.com/focus-mcp/cli/security/advisories/new). All complaints will be reviewed and investigated promptly and fairly. ## Attribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d0b1d3..54588cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,32 @@ SPDX-License-Identifier: MIT Thanks for your interest in the FocusMCP CLI. This document describes **how to contribute a change** and the quality rules we enforce. +## AI-assisted contributions + +FocusMCP was largely built with Claude Code. We encourage and welcome AI-assisted PRs. + +**You don't need to hide it.** If Claude wrote the code, just say so in the PR description +(`Generated with Claude Code`, `Co-authored by GPT-4`, whatever's accurate). Bonus points +for including the prompt or the key instructions you used. + +**What we care about, regardless of who wrote it:** + +- ✅ Tests pass +- ✅ Types are strict (no `any`, no `@ts-ignore` without a comment) +- ✅ Lint is green (`pnpm lint`) +- ✅ Coverage ≥ 80% (100% on critical modules) +- ✅ Commit messages follow Conventional Commits +- ✅ PR has a clear description — "what, why, how to verify" +- ✅ You understand the diff and can discuss design during review + +**What gets you rejected:** + +- ❌ Obviously untested AI slop (generated code that doesn't run) +- ❌ PRs with no description, just "here's some code" +- ❌ Hidden AI use that makes review confusing + +We don't care if you used AI, we care if the PR is good. + ## Code of Conduct All contributors agree to follow the [Code of Conduct](./CODE_OF_CONDUCT.md). @@ -36,14 +62,25 @@ pnpm reuse # REUSE compliance (SPDX headers) 1. **Strict TDD** — tests first. Coverage ≥ 80 % global (the `vitest` config enforces this). 2. **No `any`**, no `!` non-null assertion, no untyped `catch`. -3. **No `console.*` outside `src/bin/` and `src/commands/`.** Use structured logging from `@focusmcp/core` everywhere else. +3. **No `console.*` outside `src/bin/` and `src/commands/`.** Use structured logging from `@focus-mcp/core` everywhere else. 4. **ESM only**, `node:` protocol for Node built-ins. 5. **SPDX headers** in every source file (`SPDX-FileCopyrightText: 2026 FocusMCP contributors` + `SPDX-License-Identifier: MIT`). For JSON files, add a sibling `.license` file (REUSE convention). 6. **Conventional Commits** — enforced by commitlint (`feat(list): ...`, `fix(info): ...`, `docs(readme): ...`). Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. -7. **Changeset required** on every PR that changes user-visible behaviour (`pnpm changeset`). +7. **npm scope is `@focus-mcp`** (with hyphen). Never write `@focusmcp` in new code, docs, or commit messages. 8. **Pure command functions.** Every `src/commands/.ts` exports a function that takes structured input and returns a string (or throws). Only `src/bin/focus.ts` is allowed to touch `process.*`, stdin/stdout, or the filesystem — this keeps the commands trivially testable. 9. **No scope creep.** Stick to the problem described in the linked issue. +## Publishing (for maintainers) + +Releases are handled by two GitHub Actions workflows — no Changesets "Version Packages" PR: + +| Workflow | Trigger | npm tag | +|---|---|---| +| `dev-publish.yml` | push to `develop` | `dev` | +| `stable-publish.yml` | push to `main` | `latest` | + +To cut a release: bump the version in `package.json` on `develop`, merge to `main`. + ## Commit sign-off / DCO By contributing you certify the [Developer Certificate of Origin](https://developercertificate.org/). Use `git commit --signoff` (`-s`) to add a `Signed-off-by` trailer. Signed commits (GPG/SSH) are strongly recommended. @@ -56,7 +93,7 @@ Maintainers check: - tests match the feature scope; - code style + typing (lint, typecheck); - coverage stayed ≥ 80 % after the change; -- docs updated if a user-facing command changed. +- docs updated if a user-facing command changed (including `focus browse` TUI if applicable). ## Security diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 2bea6d5..0000000 --- a/PRD.md +++ /dev/null @@ -1,195 +0,0 @@ - - -# FocusMCP CLI — Product Requirements Document - -> Périmètre : le **CLI officiel `@focusmcp/cli`** (repo `cli/`). -> Pour la lib `@focusmcp/core` : voir [`core/PRD.md`](../core/PRD.md). Pour le catalogue : voir [`marketplace/PRD.md`](../marketplace/PRD.md). Le client Tauri (repo `client/`) est **gelé** — pas de UI bundlée au MVP. - -## Vision (rappel) - -**FocusMCP** — Focaliser les agents AI sur l'essentiel. - -Le CLI est **le point d'entrée principal** de FocusMCP. Pivot CLI-first : tout client AI qui parle MCP (Claude Code, Cursor, Continue, etc.) consomme FocusMCP via ce CLI, spawné en sous-processus et piloté en stdio. **Comme `node` pour JavaScript, comme `docker` pour les conteneurs.** - -> **Single binary, stdio-first.** Le CLI fait une seule chose correctement : exposer les briques activées via MCP sur stdin/stdout. - ---- - -## Rôle du CLI dans l'écosystème - -Le repo `cli/` contient : - -1. **Le binaire `focus`** — publié sous `@focusmcp/cli` sur npm, consommé via `npx` ou `npm install -g`. -2. **Le transport stdio MCP** — `focus start` démarre un `StdioServerTransport` du SDK officiel MCP, routé vers le `createFocusMcp()` de `@focusmcp/core`. -3. **Le brick manager local** — `focus list`, `focus info`, `focus add`, `focus remove`, `focus update` opèrent sur `~/.focus/center.json` + `~/.focus/center.lock`. -4. **Le client marketplace** — résolution des briques depuis le catalogue officiel (et les catalogues tiers en P2). - -Le CLI **embarque `@focusmcp/core`**, il n'y a **pas** d'HTTP par défaut, et **pas** de UI bundlée (un `cli-manager` séparé existera en Phase 2 pour administrer le CLI à distance). - ---- - -## Architecture - -``` -AI client (Claude Code, Cursor, etc.) - │ stdio (JSON-RPC) - ▼ -@focusmcp/cli - ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ @focusmcp/core (createFocusMcp) - │ Registry + EventBus + Router + bricks - └─ center.json + center.lock (~/.focus/) -``` - -- **stdin/stdout** sont réservés au transport MCP (JSON-RPC framed). -- **stderr** reçoit les logs humains. -- Un signal `SIGINT`/`SIGTERM` flush les subscribers EventBus avant exit. - -### Format des fichiers d'état - -- `~/.focus/center.json` — déclaration utilisateur : - - ```json - { - "bricks": { - "official/echo": { "version": "^1.0.0", "enabled": true }, - "official/indexer": { "version": "^0.2.0", "enabled": true, "config": { "root": "/src" } } - } - } - ``` - -- `~/.focus/center.lock` — résolution machine : - - ```json - { - "official/echo": { - "version": "1.0.0", - "catalog_url": "https://marketplace.focusmcp.dev/catalog.json", - "catalog_id": "official", - "integrity": "sha256-abc", - "tarballUrl": "https://example.com/echo-1.0.0.tgz" - } - } - ``` - -Les deux fichiers sont parsés par `src/center.ts` — validation structurelle uniquement ; la sémantique (semver, catalog URL, signature) est gérée par `@focusmcp/core`. - ---- - -## Commandes - -| Groupe | Commande | Rôle | Statut | -|---|---|---|---| -| **Inspection** | `focus list` | Liste les briques déclarées | **P0** | -| | `focus info ` | Détails d'une brique | **P0** | -| | `focus status` | État du runtime (briques actives, erreurs) | P1 | -| | `focus logs [brick]` | Flux logs EventBus | P1 | -| **Transport** | `focus start` | Démarre MCP stdio | **P0** | -| | `focus start --http ` | Admin API HTTP pour `cli-manager` | P2 | -| **Gestion briques** | `focus add [@range]` | Ajoute + résout + écrit lock | P1 | -| | `focus remove ` | Retire + réécrit lock | P1 | -| | `focus update [name]` | Bump versions (tout ou une) | P1 | -| | `focus search ` | Recherche dans le catalogue | P1 | -| | `focus enable ` | `enabled: true` | P1 | -| | `focus disable ` | `enabled: false` | P1 | -| **Catalogues** | `focus catalog list` | Liste les catalogues configurés | P2 | -| | `focus catalog add ` | Ajoute un catalogue tiers | P2 | -| | `focus catalog remove ` | Retire un catalogue tiers | P2 | -| **Config** | `focus config set ` | Modifie `center.json` | P1 | -| | `focus config get ` | Lit `center.json` | P1 | - -Toutes les sous-commandes métier sont des **fonctions pures** (input structuré → string de sortie, ou throw). Seul `src/bin/focus.ts` touche `process.*`, stdin/stdout et le système de fichiers. - ---- - -## Distribution - -- **Package npm** : `@focusmcp/cli` sous le scope `@focusmcp` (org npm réservée). -- **Installation** : - - `npx @focusmcp/cli start` — one-shot, idéal pour Claude Code. - - `npm install -g @focusmcp/cli` — installation globale, `focus` dans le `$PATH`. -- **Publish** : via Changesets (single package mode) + `release.yml` sur push `main`. Secret `NPM_TOKEN` requis. -- **Provenance npm** activée (`publishConfig.provenance: true`) pour signer les tarballs via Sigstore. - ---- - -## Sécurité - -Trois couches de défense, empilées : - -1. **EventBus guards** (hérités de `@focusmcp/core`) — une brique ne peut émettre ni consommer que des événements déclarés dans son manifeste. Mismatch → fail fast au boot. -2. **Permissions utilisateur via `center.json`** — une brique désactivée (`enabled: false`) ne boote pas. `config` par brique est validé contre le manifeste avant forwarding. -3. **Sandbox du process parent** — Claude Code et Cursor sandboxent déjà les serveurs MCP stdio (FS restreint, réseau filtré). Le CLI **ne cherche pas** à s'en échapper. - -Parsers `center.json` / `center.lock` : validation structurelle stricte, rejet fail-fast de tout input malformé. - ---- - -## Roadmap - -### P0 — MVP (ce sprint) - -- [x] Scaffolding repo (structure, CI, REUSE, biome, changesets) -- [x] `focus list` + `focus info` + parsers `center.*` -- [ ] `focus start` — transport stdio MCP fonctionnel (raccorde `createFocusMcp` + `StdioServerTransport`) -- [ ] Publication `@focusmcp/cli@0.1.0` sur npm -- [ ] README + docs d'install pour Claude Code - -### P1 - -- [ ] `focus add` / `focus remove` / `focus update` / `focus search` -- [ ] `focus enable` / `focus disable` / `focus status` / `focus logs` -- [ ] `focus config get` / `focus config set` -- [ ] Admin API HTTP (transport secondaire, derrière un flag explicite) - -### P2 - -- [ ] `focus catalog add/remove/list` — catalogues tiers -- [ ] Hot-reload de briques (rechargement à chaud sans restart) -- [ ] Plugins CLI tiers (briques qui ajoutent des sous-commandes) -- [ ] Séparation `cli-manager` (UI admin qui attaque l'API HTTP) - ---- - -## Stack technique - -| Composant | Technologie | Rôle | -|---|---|---| -| Langage | **TypeScript strict** | Code source | -| Build | **tsup** | Bundling (ESM, Node 22, dts pour l'API programmatique) | -| Tests | **Vitest** | Unit (≥ 80 % coverage) | -| Transport MCP | **@modelcontextprotocol/sdk** | `StdioServerTransport` officiel | -| Lib FocusMCP | **@focusmcp/core** (git dep) | Registry + EventBus + Router | -| Parsing CLI | **node:util `parseArgs`** | Dispatch sous-commandes (pas de dep externe) | -| Lint | **Biome 2.x** | Style + qualité | -| License | **REUSE** | SPDX headers | -| CI | **GitHub Actions** | Lint, typecheck, test, build, REUSE, gitleaks, CodeQL | -| Publish | **Changesets** + npm provenance | Releases semver | - ---- - -## Décisions clés - -| Décision | Choix | Raison | -|---|---|---| -| **Entrée principale** | CLI (pas Tauri) | Pivot CLI-first : tout client MCP peut attacher ; pas de lock-in UI | -| **Transport primaire** | stdio MCP | Standard MCP ; Claude Code/Cursor spawnent des sous-processus stdio, pas des serveurs HTTP | -| **HTTP** | Phase 2, derrière un flag | Gardé pour `cli-manager`, pas exposé par défaut pour limiter la surface | -| **UI** | Pas bundlée | Séparation CLI ↔ UI ; `cli-manager` sera un repo dédié en P2 | -| **`@focusmcp/core`** | Git dependency | Core n'est pas publié sur npm au MVP — git dep évite un release coupling prématuré | -| **Changesets** | Single-package mode | `@focusmcp/cli` est un package unique ; `independent` n'a pas de sens ici | -| **npm org** | `@focusmcp` (+ squat `focus-mcp`) | Scope officiel réservé ; distribution via npm, pas GitHub Releases (contrairement aux briques) | -| **CLI parsing** | `node:util` `parseArgs` | Pas de dépendance externe (commander, yargs) — API Node stable suffit | -| **Coverage gate** | 80 % | Aligné sur marketplace/core ; `src/bin/` et `src/index.ts` sont exclus (surface fine, testée e2e) | - ---- - -## Inspirations - -- **npm CLI** — binaire unique, sous-commandes, lock file, registre central. -- **Docker CLI** — `docker run` en sous-processus spawné par un client higher-level. -- **Claude Code plugins** — format stdio MCP canonique pour l'intégration agent AI. -- **tsc / tsx** — distribution via npm + `npx` comme pattern standard. diff --git a/README.md b/README.md index ed2ae54..68e6f5d 100644 --- a/README.md +++ b/README.md @@ -3,91 +3,159 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# FocusMCP — CLI +# @focus-mcp/cli -> **The primary entry point of FocusMCP.** Spawn MCP over stdio, manage bricks, connect any AI client. -> -> [focusmcp.dev](https://focusmcp.dev) · [PRD](./PRD.md) · [Core](https://github.com/focus-mcp/core) · [Marketplace](https://github.com/focus-mcp/marketplace) +> Focus your AI agents on what matters. Reduce context from 200k to ~2k tokens. -`@focusmcp/cli` is the fourth pillar of FocusMCP (after `core`, `client` and `marketplace`). It is the **primary, canonical entry point** of FocusMCP — the same binary is invoked by AI clients (Claude Code, Cursor, etc.) to bring FocusMCP's bricks into any MCP-compatible agent. +[![npm](https://img.shields.io/npm/v/@focus-mcp/cli)](https://www.npmjs.com/package/@focus-mcp/cli) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) +[![CI](https://github.com/focus-mcp/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/focus-mcp/cli/actions/workflows/ci.yml) +![Built with Claude Code](https://img.shields.io/badge/built_with-Claude_Code-8A2BE2) -## Status +## What -Active development — pre-MVP. `focus list` and `focus info` are functional; `focus start` is a stub that will be completed in the next PR (stdio MCP transport). See [PRD.md](./PRD.md). +FocusMCP is an MCP (Model Context Protocol) orchestrator. Instead of giving your AI agent ALL your tools at once — polluting its context window — you compose **bricks**: atomic MCP modules that load on demand. + +- **68+ official bricks** covering files, code intel, git, shell, reasoning, search, and more +- **One CLI, one MCP server**, modular capabilities +- Works with **Claude Code, Cursor, Codex, Gemini CLI**, any MCP-compatible AI ## Install ```bash -# One-shot -npx @focusmcp/cli start - -# Or install globally -npm install -g @focusmcp/cli -focus --version +npm install -g @focus-mcp/cli ``` -Requires Node.js ≥ 22. +Or via the **Claude Code native plugin**: + +### Install for Claude Code -## Usage +**Option 1 — Two lines (works today)** +``` +/plugin marketplace add focus-mcp/cli +/plugin install focus-mcp@focus-mcp-cli +``` +**Option 2 — Manual MCP add** ```bash -focus help # print help -focus list # list the bricks declared in ~/.focus/center.json -focus info # show details for a single brick (requested + resolved version, catalog, config) -focus start # launch FocusMCP as an MCP server over stdio (attach from an AI client) +claude mcp add focus-mcp npx @focus-mcp/cli start ``` -### Wiring from Claude Code +*(Official single-liner `/plugin install focus-mcp@claude-plugins-official` coming once Anthropic accepts the submission.)* + +Requires **Node.js ≥ 22**. -Add FocusMCP as an MCP server in your Claude Code config: +## Quick start + +Add FocusMCP as an MCP server in your AI client config: ```json { - "mcpServers": { - "focusmcp": { - "command": "npx", - "args": ["-y", "@focusmcp/cli", "start"] + "mcpServers": { + "focus": { + "command": "npx", + "args": ["-y", "@focus-mcp/cli", "start"] + } } - } } ``` -The CLI inherits Claude Code's sandbox — stdin/stdout are reserved for the MCP protocol, stderr carries logs. +For **Claude Code** specifically, this is already wired via the native plugin above. + +Then browse and manage bricks: + +```bash +focus browse # Interactive TUI — browse, search, install/uninstall bricks +focus search git # Search the catalog for bricks matching "git" +focus add echo # Install the "echo" brick +focus list # Show all installed bricks +focus info echo # Show details for a specific brick +``` + +## Commands -## Layout +| Command | Description | +|---|---| +| `focus list` | List installed bricks (reads `~/.focus/center.json`) | +| `focus info ` | Show details for a brick (version, catalog, config) | +| `focus start` | Launch FocusMCP as an MCP server over stdio | +| `focus add ` | Install a brick from the catalog | +| `focus remove ` | Uninstall a brick | +| `focus search ` | Search the catalog | +| `focus catalog` | Show and manage catalog sources | +| `focus browse` | Interactive TUI browser (see below) | + +## Interactive TUI — `focus browse` + +`focus browse` opens a full-screen terminal interface to explore, search, and manage bricks without leaving your terminal. ``` -src/ - bin/focus.ts — CLI entry point (shebang, parseArgs dispatch) - commands/ - list.ts — `focus list` - info.ts — `focus info ` - start.ts — `focus start` (stub, stdio MCP coming next) - center.ts — parsers for ~/.focus/center.json and ~/.focus/center.lock - index.ts — programmatic API (empty for now) -config/ — vitest, biome (via root), commitlint, lint-staged, gitleaks -.github/ — CI, release, CodeQL, templates, renovate +┌─ Bricks (68) ────────────────┬─ echo ───────────────────────────────────┐ +│ > echo ✓ │ │ +│ indexer │ A simple echo brick for testing. │ +│ shell │ │ +│ git-log │ Version ^1.0.0 │ +│ web-search │ Source @focus-mcp/echo │ +│ … │ Status installed │ +│ │ │ +│ / search i install │ [i] Install [u] Uninstall │ +│ ↑↓ nav Enter open │ [?] Help │ +└──────────────────────────────┴──────────────────────────────────────────┘ ``` -## Scripts +**Keybindings:** -```bash -pnpm install -pnpm lint # Biome -pnpm typecheck # tsc --noEmit -pnpm test # Vitest -pnpm test:coverage # Vitest + coverage (≥ 80% gate) -pnpm build # tsup (dist/index.js + dist/bin/focus.js) -pnpm changeset # create a changeset before merging +| Key | Action | +|---|---| +| `↑` / `↓` | Navigate the brick list | +| `Enter` | Open brick details | +| `/` | Search / filter | +| `i` | Install selected brick | +| `u` | Uninstall selected brick | +| `?` | Toggle help overlay | +| `q` / `Esc` | Quit | + +## Architecture + +``` +AI client (Claude Code, Cursor, Codex, …) + │ stdio (JSON-RPC / MCP) + ▼ +@focus-mcp/cli (this package) + ├─ @modelcontextprotocol/sdk StdioServerTransport + ├─ @focus-mcp/core Registry + EventBus + Router + brick loader + └─ ~/.focus/center.json user brick declarations ``` -## Versioning & publishing +**Bricks** are atomic MCP modules. Each brick exposes exactly one domain of tools to the AI agent. You declare which bricks you want in `~/.focus/center.json`; FocusMCP loads them on demand when `focus start` is called. + +**Why not give the agent all tools at once?** Because a 200k-token context window filled with hundreds of tool descriptions leaves very little room for actual work. FocusMCP keeps the agent's context lean — ~2k tokens for the orchestrator itself — and loads domain-specific tools only when needed. + +## Links + +- **Marketplace**: +- **Core library**: +- **Official catalog**: +- **Website**: + +## AI-assisted development + +FocusMCP was built with heavy Claude Code assistance — its architecture, implementation, +docs, and tests have all been co-authored with AI. We embrace this openly because: + +1. **Transparency matters** — we'd rather disclose it than pretend otherwise +2. **AI tooling is the context** — we're building tools for AI agents, it makes sense to use them +3. **Quality over origin** — what matters is that the code is tested, reviewed, and working -`@focusmcp/cli` is a single npm package versioned via Changesets. `develop` is the base branch; merging a "Version Packages" PR on `main` triggers `release.yml`, which publishes to npm and creates a GitHub Release (requires the `NPM_TOKEN` repo secret). +**Your AI-assisted contributions are welcome.** We don't require you to hide the fact that +Claude, Copilot, Cursor, or any other tool helped you. What we do expect: -## Dependency on `@focusmcp/core` +- Tests pass, code is typed, lint is green +- You've read the diff and understand what the PR does +- Conventional Commits, clear PR description +- You can explain your design choices during review -`@focusmcp/core` is referenced as a **git dependency** (`github:focus-mcp/core`) because the monorepo that hosts it does not publish to npm at MVP. Local dev can swap the dep for a workspace link (`pnpm link ../core/packages/core`) — the CLI only uses the public API of `createFocusMcp`. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guidelines. ## License diff --git a/SECURITY.md b/SECURITY.md index 19ab23f..c757585 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT ## Supported versions -`@focusmcp/cli` is pre-MVP (`0.x`). No version is yet considered stable — we reserve the right to ship breaking changes in `0.y` releases. +`@focus-mcp/cli` is actively maintained. The current stable release is **v1.1.0** (`latest` tag on npm). Patch and minor releases within the `1.x` line receive security fixes. Older `0.x` versions are no longer supported. ## Reporting a vulnerability @@ -20,7 +20,7 @@ Send a private report via: Please include if possible: -- Affected version of `@focusmcp/cli` +- Affected version of `@focus-mcp/cli` - Description of the issue - Reproduction steps - Estimated impact @@ -39,7 +39,7 @@ We commit to: The CLI is typically **spawned as a subprocess** of an AI client (Claude Code, Cursor, etc.) and inherits the parent's sandbox. FocusMCP adds three layers on top of the host sandbox: -1. **EventBus guards** (in `@focusmcp/core`) — a brick can only emit / consume events it has declared in its manifest. Mismatches fail fast. +1. **EventBus guards** (in `@focus-mcp/core`) — a brick can only emit / consume events it has declared in its manifest. Mismatches fail fast. 2. **User permissions via `center.json`** — bricks are opt-in. A disabled brick never boots. Per-brick `config` is validated against the brick manifest before being forwarded. 3. **Parent-process sandbox** — Claude Code / Cursor already sandbox stdio MCP servers (limited filesystem + network). The CLI does not try to break out of that sandbox. @@ -49,7 +49,7 @@ Our security priorities: 1. **The `focus start` transport** — the stdio JSON-RPC handshake, request validation, and error shape. 2. **`center.json` / `center.lock` parsers** — untrusted JSON from disk; structural validation is our first line of defence. -3. **Brick resolution** — integrity (SRI hash) and source provenance before a brick is loaded by `@focusmcp/core`. +3. **Brick resolution** — integrity (SRI hash) and source provenance before a brick is loaded by `@focus-mcp/core`. 4. **The CI pipeline** — secret scanning, least-privilege workflow permissions, pinned actions. ## Project security practices diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..93d3623 --- /dev/null +++ b/VISION.md @@ -0,0 +1,34 @@ + + +# Vision — @focus-mcp/cli + +## The problem + +AI agents waste context on tools they don't use. Give them 100 tools, they waste tokens parsing schemas for 99 they don't need. From 200k tokens to 2k of what actually matters — that's the goal. + +## What we're building + +`@focus-mcp/cli` is the primary entry point of FocusMCP: a Node CLI that exposes a stdio MCP server to any AI client (Claude Code, Cursor, Codex, Gemini) and manages **bricks** — atomic MCP modules that load on demand. + +## What makes it different + +- **One server, many tools** — agents see a single MCP endpoint, but the tools behind it are composed dynamically +- **Marketplace-first** — 68+ official bricks, extensible via third-party catalogs +- **Interactive browser** — `focus browse` TUI to explore, install, and manage bricks without leaving the terminal +- **Plugin setup** — native Claude Code plugin via `/plugin marketplace add focus-mcp/cli` then `/plugin install focus-mcp@focus-mcp-cli` + +## Principles + +1. **Context is precious** — fewer tools in memory, more reasoning power available +2. **Install, don't configure** — bricks are npm packages, the catalog is discoverable +3. **User-first ergonomics** — `focus browse` ≈ `gh`, `lazygit`, ergonomic by default +4. **Open ecosystem** — anyone can publish a brick, host a catalog + +## Non-goals + +- Not a new MCP protocol — we implement the existing spec +- Not an agent — we focus existing ones +- Not a platform — the CLI is just the orchestrator, logic lives in bricks diff --git a/biome.json b/biome.json index dfcdee9..a01048d 100644 --- a/biome.json +++ b/biome.json @@ -1,91 +1,91 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": true, - "includes": [ - "**", - "!**/node_modules", - "!**/dist", - "!**/build", - "!**/coverage", - "!**/sbom.json", - "!**/pnpm-lock.yaml" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100, - "lineEnding": "lf" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noExcessiveCognitiveComplexity": { - "level": "error", - "options": { "maxAllowedComplexity": 15 } - }, - "useLiteralKeys": "off" - }, - "correctness": { - "noUnusedVariables": "error", - "noUnusedImports": "error", - "useExhaustiveDependencies": "error" - }, - "style": { - "useImportType": "error", - "useExportType": "error", - "useNodejsImportProtocol": "error", - "noNonNullAssertion": "error" - }, - "suspicious": { - "noConsole": "error", - "noExplicitAny": "error" - }, - "nursery": { - "noFloatingPromises": "error" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always" - } - }, - "json": { + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/build", + "!**/coverage", + "!**/sbom.json", + "!**/pnpm-lock.yaml" + ] + }, "formatter": { - "indentWidth": 2, - "trailingCommas": "none" - } - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "overrides": [ - { - "includes": ["src/bin/**", "src/commands/**"], - "linter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 100, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, "rules": { - "suspicious": { - "noConsole": "off" - } + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 15 } + }, + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "error" + }, + "style": { + "useImportType": "error", + "useExportType": "error", + "useNodejsImportProtocol": "error", + "noNonNullAssertion": "error" + }, + "suspicious": { + "noConsole": "error", + "noExplicitAny": "error" + }, + "nursery": { + "noFloatingPromises": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always" + } + }, + "json": { + "formatter": { + "indentWidth": 4, + "trailingCommas": "none" } - } - } - ] + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["src/bin/**", "src/commands/**"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } + } + ] } diff --git a/config/commitlint.config.js b/config/commitlint.config.js index 6f3140a..ecdd582 100644 --- a/config/commitlint.config.js +++ b/config/commitlint.config.js @@ -3,28 +3,29 @@ /** @type {import('@commitlint/types').UserConfig} */ export default { - extends: ['@commitlint/config-conventional'], - rules: { - 'type-enum': [ - 2, - 'always', - [ - 'feat', - 'fix', - 'docs', - 'style', - 'refactor', - 'perf', - 'test', - 'build', - 'ci', - 'chore', - 'revert', - ], - ], - 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], - 'header-max-length': [2, 'always', 100], - 'body-leading-blank': [2, 'always'], - 'footer-leading-blank': [2, 'always'], - }, + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'chore', + 'revert', + 'release', + ], + ], + 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], + 'header-max-length': [2, 'always', 100], + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [2, 'always'], + }, }; diff --git a/config/lint-staged.config.js b/config/lint-staged.config.js index c9b2479..6750a33 100644 --- a/config/lint-staged.config.js +++ b/config/lint-staged.config.js @@ -3,6 +3,6 @@ /** @type {import('lint-staged').Configuration} */ export default { - '*.{ts,tsx,js,jsx,json,md}': ['biome check --write --no-errors-on-unmatched'], - '*.{ts,tsx}': () => 'pnpm typecheck', + '*.{ts,tsx,js,jsx,json,md}': ['biome check --write --no-errors-on-unmatched'], + '*.{ts,tsx}': () => 'pnpm typecheck', }; diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 475fee5..5641c0b 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -8,34 +8,34 @@ import { defineConfig } from 'vitest/config'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ - test: { - globals: false, - environment: 'node', - root: projectRoot, - include: ['src/**/*.{test,spec}.ts'], - exclude: ['**/node_modules/**', '**/dist/**', '**/fixtures/**'], - reporters: ['default'], - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov', 'json-summary'], - reportsDirectory: resolve(projectRoot, 'coverage'), - include: ['src/**/*.ts'], - exclude: [ - '**/*.test.ts', - '**/*.spec.ts', - '**/*.d.ts', - '**/index.ts', - '**/bin/**', - '**/types/**', - '**/__tests__/**', - '**/fixtures/**', - ], - thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, - }, + test: { + globals: false, + environment: 'node', + root: projectRoot, + include: ['src/**/*.{test,spec}.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/fixtures/**'], + reporters: ['default'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + reportsDirectory: resolve(projectRoot, 'coverage'), + include: ['src/**/*.ts'], + exclude: [ + '**/*.test.ts', + '**/*.spec.ts', + '**/*.d.ts', + '**/index.ts', + '**/bin/**', + '**/types/**', + '**/__tests__/**', + '**/fixtures/**', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, }, - }, }); diff --git a/package.json b/package.json index b6b8908..fc63493 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,98 @@ { - "name": "@focusmcp/cli", - "version": "0.0.0", - "private": false, - "description": "FocusMCP CLI — the primary entry point of FocusMCP. Spawns MCP over stdio and manages bricks (list, info, start, add, remove, update).", - "license": "MIT", - "type": "module", - "engines": { - "node": ">=22.0.0", - "pnpm": ">=10.0.0" - }, - "packageManager": "pnpm@10.32.1", - "repository": { - "type": "git", - "url": "git+https://github.com/focus-mcp/cli.git" - }, - "homepage": "https://focusmcp.dev", - "bugs": { - "url": "https://github.com/focus-mcp/cli/issues" - }, - "keywords": [ - "mcp", - "model-context-protocol", - "focusmcp", - "cli", - "ai", - "claude-code" - ], - "bin": { - "focus": "./dist/bin/focus.js" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "name": "@focus-mcp/cli", + "version": "1.1.0", + "private": false, + "description": "Focus your AI agents on what matters. 68+ bricks, 1 MCP server, modular context — from 200k to 2k tokens. Works with Claude Code, Cursor, Codex.", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + }, + "packageManager": "pnpm@10.32.1", + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/cli.git" + }, + "homepage": "https://github.com/focus-mcp/cli#readme", + "bugs": { + "url": "https://github.com/focus-mcp/cli/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "llm", + "cli", + "claude-code", + "claude", + "cursor", + "codex", + "focus", + "bricks", + "orchestrator", + "tui", + "ink", + "context-engineering", + "agent-tools" + ], + "author": "FocusMCP contributors", + "bin": { + "focus": "./dist/bin/focus.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run --config config/vitest.config.ts", + "test:watch": "vitest --config config/vitest.config.ts", + "test:coverage": "vitest run --coverage --config config/vitest.config.ts", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "reuse": "reuse lint", + "changeset": "changeset", + "version": "changeset version", + "release": "changeset publish", + "prepare": "husky" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "ink": "^5.0.0", + "react": "^18.3.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.0", + "@changesets/cli": "^2.27.0", + "@commitlint/cli": "^19.6.0", + "@commitlint/config-conventional": "^19.6.0", + "@commitlint/types": "^19.8.1", + "@focus-mcp/core": "file:../core/packages/core", + "@types/node": "^22.10.0", + "@types/react": "^18.3.0", + "@vitest/coverage-v8": "^3.2.0", + "husky": "^9.1.0", + "lint-staged": "^15.2.0", + "tsup": "^8.3.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } - }, - "files": [ - "dist", - "README.md", - "LICENSE", - "CHANGELOG.md" - ], - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run --config config/vitest.config.ts", - "test:watch": "vitest --config config/vitest.config.ts", - "test:coverage": "vitest run --coverage --config config/vitest.config.ts", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "format": "biome format --write .", - "reuse": "reuse lint", - "changeset": "changeset", - "version": "changeset version", - "release": "changeset publish", - "prepare": "husky" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" - }, - "devDependencies": { - "@biomejs/biome": "^2.2.0", - "@changesets/cli": "^2.27.0", - "@focusmcp/core": "file:../core/packages/core", - "@commitlint/cli": "^19.6.0", - "@commitlint/config-conventional": "^19.6.0", - "@commitlint/types": "^19.8.1", - "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^3.2.0", - "husky": "^9.1.0", - "lint-staged": "^15.2.0", - "tsup": "^8.3.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "publishConfig": { - "access": "public", - "provenance": true - } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c209b34..aa04d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.0.0 version: 1.29.0(zod@4.3.6) + ink: + specifier: ^5.0.0 + version: 5.2.1(@types/react@18.3.28)(react@18.3.1) + react: + specifier: ^18.3.0 + version: 18.3.1 devDependencies: '@biomejs/biome': specifier: ^2.2.0 @@ -27,15 +33,18 @@ importers: '@commitlint/types': specifier: ^19.8.1 version: 19.8.1 - '@focusmcp/core': + '@focus-mcp/core': specifier: file:../core/packages/core version: file:../core/packages/core '@types/node': specifier: ^22.10.0 version: 22.19.17 + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 '@vitest/coverage-v8': specifier: ^3.2.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) husky: specifier: ^9.1.0 version: 9.1.7 @@ -44,16 +53,23 @@ importers: version: 15.5.2 tsup: specifier: ^8.3.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) + version: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) packages: + '@alcalzone/ansi-tokenize@0.1.3': + resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} + engines: {node: '>=14.13.1'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -424,7 +440,7 @@ packages: cpu: [x64] os: [win32] - '@focusmcp/core@file:../core/packages/core': + '@focus-mcp/core@file:../core/packages/core': resolution: {directory: ../core/packages/core, type: directory} '@hono/node-server@1.19.14': @@ -655,6 +671,12 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -764,6 +786,10 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -835,6 +861,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -847,6 +881,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -896,6 +934,10 @@ packages: engines: {node: '>=16'} hasBin: true + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -929,6 +971,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dargs@8.1.0: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} @@ -1015,6 +1060,9 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.0: + resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1027,6 +1075,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1160,6 +1212,9 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} @@ -1241,6 +1296,10 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1248,6 +1307,19 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ink@5.2.1: + resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + react-devtools-core: ^4.19.1 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -1279,6 +1351,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1431,6 +1508,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1482,6 +1563,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -1546,6 +1631,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -1603,6 +1692,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1717,6 +1810,16 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-reconciler@0.29.2: + resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -1741,6 +1844,13 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1767,6 +1877,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1810,6 +1923,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1844,6 +1960,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1980,6 +2100,15 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2094,6 +2223,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2109,6 +2242,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2130,6 +2275,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2140,6 +2288,11 @@ packages: snapshots: + '@alcalzone/ansi-tokenize@0.1.3': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2534,7 +2687,7 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@focusmcp/core@file:../core/packages/core': + '@focus-mcp/core@file:../core/packages/core': dependencies: '@opentelemetry/api': 1.9.1 @@ -2723,7 +2876,14 @@ snapshots: dependencies: undici-types: 6.21.0 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3))': + '@types/prop-types@15.7.15': {} + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2738,7 +2898,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) + vitest: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -2750,13 +2910,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -2843,6 +3003,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + auto-bind@5.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2916,6 +3078,12 @@ snapshots: dependencies: readdirp: 4.1.2 + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -2931,6 +3099,10 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2971,6 +3143,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + convert-to-spaces@2.0.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3002,6 +3176,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + dargs@8.1.0: {} debug@4.4.3: @@ -3063,6 +3239,8 @@ snapshots: dependencies: es-errors: 1.3.0 + es-toolkit@1.46.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -3096,6 +3274,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -3268,6 +3448,10 @@ snapshots: get-stream@8.0.1: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 @@ -3343,10 +3527,45 @@ snapshots: import-meta-resolve@4.2.0: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@4.1.1: {} + ink@5.2.1(@types/react@18.3.28)(react@18.3.1): + dependencies: + '@alcalzone/ansi-tokenize': 0.1.3 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 4.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.46.0 + indent-string: 5.0.0 + is-in-ci: 1.0.0 + patch-console: 2.0.0 + react: 18.3.1 + react-reconciler: 0.29.2(react@18.3.1) + scheduler: 0.23.2 + signal-exit: 3.0.7 + slice-ansi: 7.1.2 + stack-utils: 2.0.6 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + ws: 8.20.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 18.3.28 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -3367,6 +3586,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ci@1.0.0: {} + is-number@7.0.0: {} is-obj@2.0.0: {} @@ -3511,6 +3732,10 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -3552,6 +3777,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -3605,6 +3832,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -3658,6 +3889,8 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -3699,12 +3932,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.10 + tsx: 4.21.0 yaml: 2.8.3 postcss@8.5.10: @@ -3737,6 +3971,16 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-reconciler@0.29.2(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3754,6 +3998,13 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3810,6 +4061,10 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@7.7.4: {} send@1.2.1: @@ -3875,6 +4130,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slash@3.0.0: {} @@ -3902,6 +4159,10 @@ snapshots: sprintf-js@1.0.3: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -4005,7 +4266,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -4016,7 +4277,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.1 source-map: 0.7.6 @@ -4033,6 +4294,15 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@4.41.0: {} + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -4053,13 +4323,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3): + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -4074,7 +4344,7 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3): + vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -4086,13 +4356,14 @@ snapshots: '@types/node': 22.19.17 fsevents: 2.3.3 jiti: 2.6.1 + tsx: 4.21.0 yaml: 2.8.3 - vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3): + vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4110,8 +4381,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.17 @@ -4138,6 +4409,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -4158,6 +4433,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0: {} + y18n@5.0.8: {} yaml@2.8.3: {} @@ -4176,6 +4453,8 @@ snapshots: yocto-queue@1.2.2: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/adapters/catalog-store-adapter.test.ts b/src/adapters/catalog-store-adapter.test.ts new file mode 100644 index 0000000..5aaf12a --- /dev/null +++ b/src/adapters/catalog-store-adapter.test.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { FilesystemCatalogStoreAdapter } from './catalog-store-adapter.ts'; + +const FOCUS_DIR = join(homedir(), '.focus'); +const CATALOGS_PATH = join(FOCUS_DIR, 'catalogs.json'); + +describe('FilesystemCatalogStoreAdapter', () => { + let adapter: FilesystemCatalogStoreAdapter; + + beforeEach(() => { + adapter = new FilesystemCatalogStoreAdapter(); + vi.clearAllMocks(); + }); + + describe('readStore()', () => { + it('returns { sources: [] } when file does not exist (ENOENT)', async () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(err); + + const result = await adapter.readStore(); + + expect(result).toEqual({ sources: [] }); + }); + + it('parses and returns valid JSON content', async () => { + const data = { + sources: [ + { + url: 'https://example.com/catalog.json', + name: 'test', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }; + vi.mocked(readFile).mockResolvedValue(JSON.stringify(data)); + + const result = await adapter.readStore(); + + expect(result).toEqual(data); + expect(readFile).toHaveBeenCalledWith(CATALOGS_PATH, 'utf-8'); + }); + + it('re-throws errors that are not ENOENT', async () => { + const err = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + vi.mocked(readFile).mockRejectedValue(err); + + await expect(adapter.readStore()).rejects.toThrow('Permission denied'); + }); + + it('re-throws non-Error exceptions', async () => { + vi.mocked(readFile).mockRejectedValue('unexpected string error'); + + await expect(adapter.readStore()).rejects.toBe('unexpected string error'); + }); + }); + + describe('writeStore()', () => { + it('creates the directory and writes the file', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const data = { + sources: [ + { + url: 'https://example.com/catalog.json', + name: 'test', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }; + await adapter.writeStore(data); + + expect(mkdir).toHaveBeenCalledWith(FOCUS_DIR, { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + CATALOGS_PATH, + JSON.stringify(data, null, 4), + 'utf-8', + ); + }); + }); +}); diff --git a/src/adapters/catalog-store-adapter.ts b/src/adapters/catalog-store-adapter.ts new file mode 100644 index 0000000..f0ee0e6 --- /dev/null +++ b/src/adapters/catalog-store-adapter.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Filesystem implementation of CatalogStoreIO. + * + * Reads and writes the catalog source registry at ~/.focus/catalogs.json. + * Conforms to the CatalogStoreIO interface expected by @focus-mcp/core + * marketplace/catalog-store pure functions. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { CatalogStoreData, CatalogStoreIO } from '@focus-mcp/core'; + +export type { CatalogStoreData, CatalogStoreIO }; + +const FOCUS_DIR = join(homedir(), '.focus'); +const CATALOGS_PATH = join(FOCUS_DIR, 'catalogs.json'); + +export class FilesystemCatalogStoreAdapter implements CatalogStoreIO { + async readStore(): Promise { + try { + const raw = await readFile(CATALOGS_PATH, 'utf-8'); + return JSON.parse(raw) as unknown; + } catch (err: unknown) { + const isNotFound = + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'ENOENT'; + if (isNotFound) { + return { sources: [] }; + } + throw err; + } + } + + async writeStore(data: CatalogStoreData): Promise { + await mkdir(FOCUS_DIR, { recursive: true }); + await writeFile(CATALOGS_PATH, JSON.stringify(data, null, 4), 'utf-8'); + } +} diff --git a/src/adapters/http-fetch-adapter.test.ts b/src/adapters/http-fetch-adapter.test.ts new file mode 100644 index 0000000..f98ead8 --- /dev/null +++ b/src/adapters/http-fetch-adapter.test.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HttpFetchAdapter } from './http-fetch-adapter.ts'; + +describe('HttpFetchAdapter', () => { + let adapter: HttpFetchAdapter; + + beforeEach(() => { + adapter = new HttpFetchAdapter(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('fetchJson()', () => { + it('returns parsed JSON on a successful response', async () => { + const payload = { bricks: ['official/echo'] }; + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(payload), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse)); + + const result = await adapter.fetchJson('https://example.com/catalog.json'); + + expect(result).toEqual(payload); + expect(fetch).toHaveBeenCalledWith('https://example.com/catalog.json'); + }); + + it('throws an error when the response is not ok (404)', async () => { + const mockResponse = { + ok: false, + status: 404, + json: vi.fn(), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse)); + + await expect(adapter.fetchJson('https://example.com/missing.json')).rejects.toThrow( + 'HTTP 404 fetching https://example.com/missing.json', + ); + }); + + it('throws an error when the response is not ok (500)', async () => { + const mockResponse = { + ok: false, + status: 500, + json: vi.fn(), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse)); + + await expect(adapter.fetchJson('https://example.com/catalog.json')).rejects.toThrow( + 'HTTP 500 fetching https://example.com/catalog.json', + ); + }); + + it('propagates network errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + + await expect(adapter.fetchJson('https://example.com/catalog.json')).rejects.toThrow( + 'Network error', + ); + }); + }); +}); diff --git a/src/adapters/http-fetch-adapter.ts b/src/adapters/http-fetch-adapter.ts new file mode 100644 index 0000000..e04fb84 --- /dev/null +++ b/src/adapters/http-fetch-adapter.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Node.js (≥ 22) implementation of FetchIO using the global fetch API. + * + * Conforms to the FetchIO interface expected by @focus-mcp/core + * marketplace/catalog-fetcher pure functions. + */ + +export interface FetchIO { + fetchJson(url: string): Promise; +} + +export class HttpFetchAdapter implements FetchIO { + async fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status.toString()} fetching ${url}`); + } + return response.json() as Promise; + } +} diff --git a/src/adapters/npm-installer-adapter.test.ts b/src/adapters/npm-installer-adapter.test.ts new file mode 100644 index 0000000..67fdac7 --- /dev/null +++ b/src/adapters/npm-installer-adapter.test.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { EventEmitter } from 'node:events'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +import { spawn } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { NpmInstallerAdapter } from './npm-installer-adapter.ts'; + +const FOCUS_DIR = join(homedir(), '.focus'); +const CENTER_JSON_PATH = join(FOCUS_DIR, 'center.json'); +const CENTER_LOCK_PATH = join(FOCUS_DIR, 'center.lock'); +const BRICKS_DIR = join(FOCUS_DIR, 'bricks'); + +function makeChildProcess(exitCode: number | null = 0): EventEmitter { + const child = new EventEmitter(); + // Emit close asynchronously so the Promise can be set up first + setTimeout(() => { + child.emit('close', exitCode); + }, 0); + return child; +} + +describe('NpmInstallerAdapter', () => { + let adapter: NpmInstallerAdapter; + + beforeEach(() => { + adapter = new NpmInstallerAdapter(); + vi.clearAllMocks(); + }); + + describe('readCenterJson()', () => { + it('returns { bricks: {} } when file does not exist (ENOENT)', async () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(err); + + const result = await adapter.readCenterJson(); + + expect(result).toEqual({ bricks: {} }); + }); + + it('parses and returns valid JSON content', async () => { + const data = { bricks: { 'official/echo': { version: '^1.0.0', enabled: true } } }; + vi.mocked(readFile).mockResolvedValue(JSON.stringify(data)); + + const result = await adapter.readCenterJson(); + + expect(result).toEqual(data); + expect(readFile).toHaveBeenCalledWith(CENTER_JSON_PATH, 'utf-8'); + }); + + it('re-throws non-ENOENT errors', async () => { + const err = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + vi.mocked(readFile).mockRejectedValue(err); + + await expect(adapter.readCenterJson()).rejects.toThrow('Permission denied'); + }); + }); + + describe('readCenterLock()', () => { + it('returns { bricks: {} } when file does not exist (ENOENT)', async () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(err); + + const result = await adapter.readCenterLock(); + + expect(result).toEqual({ bricks: {} }); + }); + + it('parses and returns valid JSON content', async () => { + const lockData = { + bricks: { + 'official/echo': { + version: '1.0.0', + catalogUrl: 'https://example.com/catalog.json', + npmPackage: '@focus-mcp/brick-echo', + installedAt: '2026-01-01T00:00:00Z', + }, + }, + }; + vi.mocked(readFile).mockResolvedValue(JSON.stringify(lockData)); + + const result = await adapter.readCenterLock(); + + expect(result).toEqual(lockData); + expect(readFile).toHaveBeenCalledWith(CENTER_LOCK_PATH, 'utf-8'); + }); + + it('re-throws non-ENOENT errors', async () => { + const err = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + vi.mocked(readFile).mockRejectedValue(err); + + await expect(adapter.readCenterLock()).rejects.toThrow('Permission denied'); + }); + }); + + describe('writeCenterJson()', () => { + it('creates the directory and writes the file', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const data = { bricks: { 'official/echo': { version: '^1.0.0', enabled: true } } }; + await adapter.writeCenterJson(data); + + expect(mkdir).toHaveBeenCalledWith(FOCUS_DIR, { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + CENTER_JSON_PATH, + JSON.stringify(data, null, 4), + 'utf-8', + ); + }); + }); + + describe('writeCenterLock()', () => { + it('creates the directory and writes the file', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const lockData = { + bricks: { + 'official/echo': { + version: '1.0.0', + catalogUrl: 'https://example.com/catalog.json', + npmPackage: '@focus-mcp/brick-echo', + installedAt: '2026-01-01T00:00:00Z', + }, + }, + }; + await adapter.writeCenterLock(lockData); + + expect(mkdir).toHaveBeenCalledWith(FOCUS_DIR, { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + CENTER_LOCK_PATH, + JSON.stringify(lockData, null, 4), + 'utf-8', + ); + }); + }); + + describe('npmInstall()', () => { + it('calls spawn with correct install args', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(spawn).mockReturnValue( + makeChildProcess(0) as unknown as ReturnType, + ); + + await adapter.npmInstall('@focus-mcp/brick-echo', '1.0.0'); + + expect(mkdir).toHaveBeenCalledWith(BRICKS_DIR, { recursive: true }); + expect(spawn).toHaveBeenCalledWith( + 'npm', + ['install', '--prefix', BRICKS_DIR, '@focus-mcp/brick-echo@1.0.0'], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], shell: false }), + ); + }); + + it('calls spawn with registry arg when registry option provided', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(spawn).mockReturnValue( + makeChildProcess(0) as unknown as ReturnType, + ); + + await adapter.npmInstall('@focus-mcp/brick-echo', '1.0.0', { + registry: 'https://registry.example.com', + }); + + expect(spawn).toHaveBeenCalledWith( + 'npm', + [ + 'install', + '--prefix', + BRICKS_DIR, + '--registry', + 'https://registry.example.com', + '@focus-mcp/brick-echo@1.0.0', + ], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], shell: false }), + ); + }); + + it('rejects when spawn exits with non-zero code', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(spawn).mockReturnValue( + makeChildProcess(1) as unknown as ReturnType, + ); + + await expect(adapter.npmInstall('@focus-mcp/brick-echo', '1.0.0')).rejects.toThrow( + 'npm install: exited with code 1', + ); + }); + }); + + describe('npmUninstall()', () => { + it('calls spawn with correct uninstall args', async () => { + vi.mocked(spawn).mockReturnValue( + makeChildProcess(0) as unknown as ReturnType, + ); + + await adapter.npmUninstall('@focus-mcp/brick-echo'); + + expect(spawn).toHaveBeenCalledWith( + 'npm', + ['uninstall', '--prefix', BRICKS_DIR, '@focus-mcp/brick-echo'], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], shell: false }), + ); + }); + + it('calls spawn with registry arg when registry option provided', async () => { + vi.mocked(spawn).mockReturnValue( + makeChildProcess(0) as unknown as ReturnType, + ); + + await adapter.npmUninstall('@focus-mcp/brick-echo', { + registry: 'https://registry.example.com', + }); + + expect(spawn).toHaveBeenCalledWith( + 'npm', + [ + 'uninstall', + '--prefix', + BRICKS_DIR, + '--registry', + 'https://registry.example.com', + '@focus-mcp/brick-echo', + ], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], shell: false }), + ); + }); + + it('rejects when spawn exits with non-zero code', async () => { + vi.mocked(spawn).mockReturnValue( + makeChildProcess(2) as unknown as ReturnType, + ); + + await expect(adapter.npmUninstall('@focus-mcp/brick-echo')).rejects.toThrow( + 'npm uninstall: exited with code 2', + ); + }); + }); + + describe('runNpm error event', () => { + it('rejects when spawn emits an error event', async () => { + const child = new EventEmitter(); + const spawnError = new Error('spawn ENOENT'); + setTimeout(() => { + child.emit('error', spawnError); + }, 0); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + + await expect(adapter.npmInstall('@focus-mcp/brick-echo', '1.0.0')).rejects.toThrow( + 'spawn ENOENT', + ); + }); + }); +}); diff --git a/src/adapters/npm-installer-adapter.ts b/src/adapters/npm-installer-adapter.ts new file mode 100644 index 0000000..0a4b18f --- /dev/null +++ b/src/adapters/npm-installer-adapter.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Node.js implementation of InstallerIO using child_process and the + * ~/.focus/ filesystem layout. + * + * Conforms to the InstallerIO interface expected by @focus-mcp/core + * marketplace/installer pure functions. + */ + +import { spawn } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// ---------- local IO interface (mirrors core InstallerIO) ---------- + +export interface CenterEntry { + readonly version: string; + readonly enabled: boolean; + readonly config?: Record; +} + +export interface CenterLockEntry { + readonly version: string; + readonly catalogUrl: string; + readonly npmPackage: string; + readonly installedAt: string; +} + +export interface CenterJson { + readonly bricks: Record; +} + +export interface CenterLock { + readonly bricks: Record; +} + +export interface InstallerIO { + npmInstall(pkg: string, version: string, opts?: { registry?: string }): Promise; + npmUninstall(pkg: string, opts?: { registry?: string }): Promise; + writeCenterJson(data: CenterJson): Promise; + writeCenterLock(data: CenterLock): Promise; + readCenterJson(): Promise; + readCenterLock(): Promise; +} + +// ---------- paths ---------- + +const FOCUS_DIR = join(homedir(), '.focus'); +const CENTER_JSON_PATH = join(FOCUS_DIR, 'center.json'); +const CENTER_LOCK_PATH = join(FOCUS_DIR, 'center.lock'); +const BRICKS_DIR = join(FOCUS_DIR, 'bricks'); + +// ---------- helpers ---------- + +function runNpm(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('npm', args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + const msg = + stderr.trim().split('\n').slice(-3).join(' ') || + `exited with code ${String(code)}`; + reject(new Error(`npm ${args[0]}: ${msg}`)); + } + }); + child.on('error', reject); + }); +} + +// ---------- NpmInstallerAdapter ---------- + +export class NpmInstallerAdapter implements InstallerIO { + readonly #bricksDir: string; + + constructor(bricksDir: string = BRICKS_DIR) { + this.#bricksDir = bricksDir; + } + + async npmInstall(pkg: string, version: string, opts?: { registry?: string }): Promise { + await mkdir(this.#bricksDir, { recursive: true }); + const args = ['install', '--prefix', this.#bricksDir]; + if (opts?.registry !== undefined) { + args.push('--registry', opts.registry); + } + args.push(`${pkg}@${version}`); + await runNpm(args); + } + + async npmUninstall(pkg: string, opts?: { registry?: string }): Promise { + const args = ['uninstall', '--prefix', this.#bricksDir]; + if (opts?.registry !== undefined) { + args.push('--registry', opts.registry); + } + args.push(pkg); + await runNpm(args); + } + + async readCenterJson(): Promise { + try { + const raw = await readFile(CENTER_JSON_PATH, 'utf-8'); + return JSON.parse(raw) as unknown; + } catch (err: unknown) { + const isNotFound = + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'ENOENT'; + if (isNotFound) { + return { bricks: {} }; + } + throw err; + } + } + + async readCenterLock(): Promise { + try { + const raw = await readFile(CENTER_LOCK_PATH, 'utf-8'); + return JSON.parse(raw) as unknown; + } catch (err: unknown) { + const isNotFound = + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'ENOENT'; + if (isNotFound) { + return { bricks: {} }; + } + throw err; + } + } + + async writeCenterJson(data: CenterJson): Promise { + await mkdir(FOCUS_DIR, { recursive: true }); + await writeFile(CENTER_JSON_PATH, JSON.stringify(data, null, 4), 'utf-8'); + } + + async writeCenterLock(data: CenterLock): Promise { + await mkdir(FOCUS_DIR, { recursive: true }); + await writeFile(CENTER_LOCK_PATH, JSON.stringify(data, null, 4), 'utf-8'); + } +} diff --git a/src/bin/focus.ts b/src/bin/focus.ts index ee59359..f5b431f 100644 --- a/src/bin/focus.ts +++ b/src/bin/focus.ts @@ -14,8 +14,17 @@ */ import { parseArgs } from 'node:util'; +import { FilesystemCatalogStoreAdapter } from '../adapters/catalog-store-adapter.ts'; +import { HttpFetchAdapter } from '../adapters/http-fetch-adapter.ts'; +import { NpmInstallerAdapter } from '../adapters/npm-installer-adapter.ts'; +import { parseCenterJson, parseCenterLock } from '../center.ts'; +import { addCommand } from '../commands/add.ts'; +import { browseCommand } from '../commands/browse.ts'; +import { catalogCommand } from '../commands/catalog.ts'; import { infoCommand } from '../commands/info.ts'; import { listCommand } from '../commands/list.ts'; +import { removeCommand } from '../commands/remove.ts'; +import { searchCommand } from '../commands/search.ts'; import { startCommand } from '../commands/start.ts'; const HELP = `focus — FocusMCP CLI @@ -26,6 +35,11 @@ Usage: Commands: list List installed bricks (from ~/.focus/center.json) info Show details of a single brick + add Install a brick from the catalog + remove Uninstall a brick + search [query] Search bricks in the catalog + catalog Manage catalog sources (add|remove|list) + browse Interactive TUI to browse catalogs and bricks start Launch FocusMCP as a stdio MCP server (AI clients attach here) help Print this help @@ -35,68 +49,178 @@ Options: `; function printHelp(): void { - process.stdout.write(`${HELP}\n`); + process.stdout.write(`${HELP}\n`); } -async function main(argv: string[]): Promise { - const { positionals, values } = parseArgs({ - args: argv, - allowPositionals: true, - strict: false, - options: { - help: { type: 'boolean', short: 'h' }, - version: { type: 'boolean', short: 'v' }, - }, - }); - - if (values['version']) { - process.stdout.write('@focusmcp/cli 0.0.0\n'); - return 0; - } +// ---------- per-command handlers ---------- - const [command, ...rest] = positionals; +async function runList(): Promise { + const installer = new NpmInstallerAdapter(); + const rawCenter = await installer.readCenterJson(); + const rawLock = await installer.readCenterLock(); + const center = parseCenterJson(rawCenter); + const lock = parseCenterLock(rawLock); + process.stdout.write(`${listCommand({ center, lock })}\n`); + return 0; +} - if (!command || command === 'help' || values['help']) { - printHelp(); - return command ? 0 : 1; - } +async function runInfo(rest: string[]): Promise { + const name = rest[0]; + if (!name) { + process.stderr.write('error: `focus info ` requires a brick name.\n'); + return 1; + } + const installer = new NpmInstallerAdapter(); + const rawCenter = await installer.readCenterJson(); + const rawLock = await installer.readCenterLock(); + const center = parseCenterJson(rawCenter); + const lock = parseCenterLock(rawLock); + process.stdout.write(`${infoCommand({ name, center, lock })}\n`); + return 0; +} - switch (command) { - case 'list': { - // TODO: read ~/.focus/center.json + center.lock, parse, then call listCommand. - const output = listCommand({ center: { bricks: {} }, lock: {} }); - process.stdout.write(`${output}\n`); - return 0; +async function runAdd(rest: string[]): Promise { + const brickName = rest[0]; + if (!brickName) { + process.stderr.write('error: `focus add ` requires a brick name.\n'); + return 1; } - case 'info': { - const name = rest[0]; - if (!name) { - process.stderr.write('error: `focus info ` requires a brick name.\n'); + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; + const output = await addCommand({ brickName, io }); + process.stdout.write(`${output}\n`); + return 0; +} + +async function runRemove(rest: string[]): Promise { + const brickName = rest[0]; + if (!brickName) { + process.stderr.write('error: `focus remove ` requires a brick name.\n'); return 1; - } - // TODO: read ~/.focus/center.json + center.lock before calling. - const output = infoCommand({ name, center: { bricks: {} }, lock: {} }); - process.stdout.write(`${output}\n`); - return 0; } - case 'start': { - await startCommand(); - return 0; + const output = await removeCommand({ + brickName, + io: { installer: new NpmInstallerAdapter() }, + }); + process.stdout.write(`${output}\n`); + return 0; +} + +async function runSearch(rest: string[]): Promise { + const query = rest[0] ?? ''; + const io = { fetch: new HttpFetchAdapter(), store: new FilesystemCatalogStoreAdapter() }; + const result = await searchCommand({ query, io }); + for (const err of result.errors) { + process.stderr.write(`warning: ${err}\n`); + } + process.stdout.write(`${result.output}\n`); + return 0; +} + +async function runCatalog(rest: string[]): Promise { + const store = new FilesystemCatalogStoreAdapter(); + const sub = rest[0]; + + if (sub === 'add') { + const url = rest[1]; + const name = rest[2]; + if (!url || !name) { + process.stderr.write( + 'error: `focus catalog add ` requires a URL and a name.\n', + ); + return 1; + } + process.stdout.write( + `${await catalogCommand({ subcommand: 'add', url, name, io: { store } })}\n`, + ); + return 0; + } + + if (sub === 'remove') { + const url = rest[1]; + if (!url) { + process.stderr.write('error: `focus catalog remove ` requires a URL.\n'); + return 1; + } + process.stdout.write( + `${await catalogCommand({ subcommand: 'remove', url, io: { store } })}\n`, + ); + return 0; + } + + // default: list + process.stdout.write(`${await catalogCommand({ subcommand: 'list', io: { store } })}\n`); + return 0; +} + +// ---------- main ---------- + +async function main(argv: string[]): Promise { + const { positionals, values } = parseArgs({ + args: argv, + allowPositionals: true, + strict: false, + options: { + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + }, + }); + + if (values['version']) { + process.stdout.write( + `@focus-mcp/cli ${process.env['CLI_VERSION'] ?? '0.0.0'} (core ${process.env['CORE_VERSION'] ?? '0.0.0'})\n`, + ); + return 0; } - default: { - process.stderr.write(`error: unknown command "${command}"\n\n`); - printHelp(); - return 1; + + const [command] = positionals; + const commandIndex = argv.indexOf(command ?? ''); + const rest = commandIndex >= 0 ? argv.slice(commandIndex + 1) : []; + + if (!command || command === 'help' || values['help']) { + printHelp(); + return command ? 0 : 1; + } + + switch (command) { + case 'list': + return runList(); + case 'info': + return runInfo(rest); + case 'add': + return runAdd(rest); + case 'remove': + return runRemove(rest); + case 'search': + return runSearch(rest); + case 'catalog': + return runCatalog(rest); + case 'browse': + await browseCommand(); + return 0; + case 'start': { + await startCommand(rest); + // Keep the process alive until a signal terminates it + await new Promise(() => {}); + return 0; + } + default: { + process.stderr.write(`error: unknown command "${command}"\n\n`); + printHelp(); + return 1; + } } - } } main(process.argv.slice(2)) - .then((code) => { - process.exit(code); - }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`error: ${message}\n`); - process.exit(1); - }); + .then((code) => { + process.exit(code); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`error: ${message}\n`); + process.exit(1); + }); diff --git a/src/center.test.ts b/src/center.test.ts index 77fdb25..8254a57 100644 --- a/src/center.test.ts +++ b/src/center.test.ts @@ -5,112 +5,112 @@ import { describe, expect, it } from 'vitest'; import { parseCenterJson, parseCenterLock } from './center.ts'; describe('parseCenterJson', () => { - it('parses an empty bricks map', () => { - const result = parseCenterJson({ bricks: {} }); - expect(result.bricks).toEqual({}); - }); - - it('parses a single brick entry with required fields', () => { - const result = parseCenterJson({ - bricks: { - 'official/echo': { version: '^1.0.0', enabled: true }, - }, + it('parses an empty bricks map', () => { + const result = parseCenterJson({ bricks: {} }); + expect(result.bricks).toEqual({}); }); - expect(result.bricks['official/echo']).toEqual({ - version: '^1.0.0', - enabled: true, + + it('parses a single brick entry with required fields', () => { + const result = parseCenterJson({ + bricks: { + 'official/echo': { version: '^1.0.0', enabled: true }, + }, + }); + expect(result.bricks['official/echo']).toEqual({ + version: '^1.0.0', + enabled: true, + }); + }); + + it('parses an entry with a config object', () => { + const result = parseCenterJson({ + bricks: { + 'official/indexer': { + version: '^0.2.0', + enabled: true, + config: { root: '/src', depth: 3 }, + }, + }, + }); + expect(result.bricks['official/indexer']?.config).toEqual({ root: '/src', depth: 3 }); + }); + + it('rejects a non-object root', () => { + expect(() => parseCenterJson(null)).toThrow(/center\.json/i); + expect(() => parseCenterJson('bad')).toThrow(/center\.json/i); + }); + + it('rejects a bricks map that is not an object', () => { + expect(() => parseCenterJson({ bricks: [] })).toThrow(/bricks/i); + }); + + it('rejects an entry without `version`', () => { + expect(() => parseCenterJson({ bricks: { 'official/echo': { enabled: true } } })).toThrow( + /version/i, + ); + }); + + it('rejects an entry without `enabled`', () => { + expect(() => + parseCenterJson({ bricks: { 'official/echo': { version: '^1.0.0' } } }), + ).toThrow(/enabled/i); }); - }); - - it('parses an entry with a config object', () => { - const result = parseCenterJson({ - bricks: { - 'official/indexer': { - version: '^0.2.0', - enabled: true, - config: { root: '/src', depth: 3 }, - }, - }, + + it('rejects a non-object entry', () => { + expect(() => parseCenterJson({ bricks: { 'official/echo': 'bad' } })).toThrow( + /must be an object/i, + ); + }); + + it('rejects a non-object config', () => { + expect(() => + parseCenterJson({ + bricks: { 'official/echo': { version: '^1.0.0', enabled: true, config: 42 } }, + }), + ).toThrow(/config/i); }); - expect(result.bricks['official/indexer']?.config).toEqual({ root: '/src', depth: 3 }); - }); - - it('rejects a non-object root', () => { - expect(() => parseCenterJson(null)).toThrow(/center\.json/i); - expect(() => parseCenterJson('bad')).toThrow(/center\.json/i); - }); - - it('rejects a bricks map that is not an object', () => { - expect(() => parseCenterJson({ bricks: [] })).toThrow(/bricks/i); - }); - - it('rejects an entry without `version`', () => { - expect(() => parseCenterJson({ bricks: { 'official/echo': { enabled: true } } })).toThrow( - /version/i, - ); - }); - - it('rejects an entry without `enabled`', () => { - expect(() => parseCenterJson({ bricks: { 'official/echo': { version: '^1.0.0' } } })).toThrow( - /enabled/i, - ); - }); - - it('rejects a non-object entry', () => { - expect(() => parseCenterJson({ bricks: { 'official/echo': 'bad' } })).toThrow( - /must be an object/i, - ); - }); - - it('rejects a non-object config', () => { - expect(() => - parseCenterJson({ - bricks: { 'official/echo': { version: '^1.0.0', enabled: true, config: 42 } }, - }), - ).toThrow(/config/i); - }); }); describe('parseCenterLock', () => { - it('parses an empty lock', () => { - expect(parseCenterLock({})).toEqual({}); - }); + it('parses an empty lock', () => { + expect(parseCenterLock({})).toEqual({}); + }); + + it('parses a minimal entry', () => { + const result = parseCenterLock({ + 'official/echo': { version: '1.0.0' }, + }); + expect(result['official/echo']?.version).toBe('1.0.0'); + }); - it('parses a minimal entry', () => { - const result = parseCenterLock({ - 'official/echo': { version: '1.0.0' }, + it('parses a rich entry', () => { + const result = parseCenterLock({ + 'official/echo': { + version: '1.0.0', + catalog_url: 'https://marketplace.focusmcp.dev/catalog.json', + catalog_id: 'official', + integrity: 'sha256-abc', + tarballUrl: 'https://example.com/echo-1.0.0.tgz', + }, + }); + const entry = result['official/echo']; + expect(entry?.catalog_url).toBe('https://marketplace.focusmcp.dev/catalog.json'); + expect(entry?.catalog_id).toBe('official'); + expect(entry?.integrity).toBe('sha256-abc'); + expect(entry?.tarballUrl).toBe('https://example.com/echo-1.0.0.tgz'); }); - expect(result['official/echo']?.version).toBe('1.0.0'); - }); - - it('parses a rich entry', () => { - const result = parseCenterLock({ - 'official/echo': { - version: '1.0.0', - catalog_url: 'https://marketplace.focusmcp.dev/catalog.json', - catalog_id: 'official', - integrity: 'sha256-abc', - tarballUrl: 'https://example.com/echo-1.0.0.tgz', - }, + + it('rejects a non-object lock', () => { + expect(() => parseCenterLock(null)).toThrow(/center\.lock/i); + }); + + it('rejects a non-object entry', () => { + expect(() => parseCenterLock({ 'official/echo': 'bad' })).toThrow(/must be an object/i); + }); + + it('rejects an entry without a resolved version', () => { + expect(() => parseCenterLock({ 'official/echo': { integrity: 'sha256-x' } })).toThrow( + /version/i, + ); }); - const entry = result['official/echo']; - expect(entry?.catalog_url).toBe('https://marketplace.focusmcp.dev/catalog.json'); - expect(entry?.catalog_id).toBe('official'); - expect(entry?.integrity).toBe('sha256-abc'); - expect(entry?.tarballUrl).toBe('https://example.com/echo-1.0.0.tgz'); - }); - - it('rejects a non-object lock', () => { - expect(() => parseCenterLock(null)).toThrow(/center\.lock/i); - }); - - it('rejects a non-object entry', () => { - expect(() => parseCenterLock({ 'official/echo': 'bad' })).toThrow(/must be an object/i); - }); - - it('rejects an entry without a resolved version', () => { - expect(() => parseCenterLock({ 'official/echo': { integrity: 'sha256-x' } })).toThrow( - /version/i, - ); - }); }); diff --git a/src/center.ts b/src/center.ts index 5000b79..c211018 100644 --- a/src/center.ts +++ b/src/center.ts @@ -13,111 +13,115 @@ * * Both files live under `~/.focus/` and are read by every CLI command. * These parsers perform structural validation only — semver validity, - * catalog URLs, and signatures are checked by `@focusmcp/core`. + * catalog URLs, and signatures are checked by `@focus-mcp/core`. */ export interface CenterJsonEntry { - /** Semver range the user wants to pin to (e.g. `^1.0.0`). */ - version: string; - /** Whether the brick is currently active. */ - enabled: boolean; - /** Optional brick-specific configuration, forwarded to the brick at boot. */ - config?: Record; + /** Semver range the user wants to pin to (e.g. `^1.0.0`). */ + version: string; + /** Whether the brick is currently active. */ + enabled: boolean; + /** Optional brick-specific configuration, forwarded to the brick at boot. */ + config?: Record; } export interface CenterJson { - bricks: Record; + bricks: Record; } export interface CenterLockEntry { - /** The resolved version (exact semver, no range). */ - version: string; - /** URL of the catalog that resolved this brick, if tracked. */ - catalog_url?: string; - /** Identifier of the catalog (e.g. `official`). */ - catalog_id?: string; - /** SRI-style integrity hash of the tarball. */ - integrity?: string; - /** URL to download the tarball from. */ - tarballUrl?: string; + /** The resolved version (exact semver, no range). */ + version: string; + /** URL of the catalog that resolved this brick, if tracked. */ + catalog_url?: string; + /** Identifier of the catalog (e.g. `official`). */ + catalog_id?: string; + /** SRI-style integrity hash of the tarball. */ + integrity?: string; + /** URL to download the tarball from. */ + tarballUrl?: string; } export type CenterLock = Record; function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Parses a `center.json` payload. Throws on any structural violation. */ export function parseCenterJson(raw: unknown): CenterJson { - if (!isObject(raw)) { - throw new Error('Invalid center.json: root must be an object.'); - } - const bricksRaw = raw['bricks']; - if (!isObject(bricksRaw)) { - throw new Error('Invalid center.json: `bricks` must be an object.'); - } - - const bricks: Record = {}; - for (const [key, value] of Object.entries(bricksRaw)) { - if (!isObject(value)) { - throw new Error(`Invalid center.json entry for "${key}": must be an object.`); - } - const version = value['version']; - if (typeof version !== 'string' || version.length === 0) { - throw new Error(`Invalid center.json entry for "${key}": missing \`version\`.`); + if (!isObject(raw)) { + throw new Error('Invalid center.json: root must be an object.'); } - const enabled = value['enabled']; - if (typeof enabled !== 'boolean') { - throw new Error(`Invalid center.json entry for "${key}": missing \`enabled\`.`); + const bricksRaw = raw['bricks']; + if (!isObject(bricksRaw)) { + throw new Error('Invalid center.json: `bricks` must be an object.'); } - const entry: CenterJsonEntry = { version, enabled }; - const config = value['config']; - if (config !== undefined) { - if (!isObject(config)) { - throw new Error(`Invalid center.json entry for "${key}": \`config\` must be an object.`); - } - entry.config = config; + const bricks: Record = {}; + for (const [key, value] of Object.entries(bricksRaw)) { + if (!isObject(value)) { + throw new Error(`Invalid center.json entry for "${key}": must be an object.`); + } + const version = value['version']; + if (typeof version !== 'string' || version.length === 0) { + throw new Error(`Invalid center.json entry for "${key}": missing \`version\`.`); + } + const enabled = value['enabled']; + if (typeof enabled !== 'boolean') { + throw new Error(`Invalid center.json entry for "${key}": missing \`enabled\`.`); + } + + const entry: CenterJsonEntry = { version, enabled }; + const config = value['config']; + if (config !== undefined) { + if (!isObject(config)) { + throw new Error( + `Invalid center.json entry for "${key}": \`config\` must be an object.`, + ); + } + entry.config = config; + } + bricks[key] = entry; } - bricks[key] = entry; - } - return { bricks }; + return { bricks }; } /** * Parses a `center.lock` payload. Throws on any structural violation. */ export function parseCenterLock(raw: unknown): CenterLock { - if (!isObject(raw)) { - throw new Error('Invalid center.lock: root must be an object.'); - } - - const lock: CenterLock = {}; - for (const [key, value] of Object.entries(raw)) { - if (!isObject(value)) { - throw new Error(`Invalid center.lock entry for "${key}": must be an object.`); - } - const version = value['version']; - if (typeof version !== 'string' || version.length === 0) { - throw new Error(`Invalid center.lock entry for "${key}": missing resolved \`version\`.`); + if (!isObject(raw)) { + throw new Error('Invalid center.lock: root must be an object.'); } - const entry: CenterLockEntry = { version }; - const catalogUrl = value['catalog_url']; - if (typeof catalogUrl === 'string') entry.catalog_url = catalogUrl; - const catalogId = value['catalog_id']; - if (typeof catalogId === 'string') entry.catalog_id = catalogId; - const integrity = value['integrity']; - if (typeof integrity === 'string') entry.integrity = integrity; - const tarballUrl = value['tarballUrl']; - if (typeof tarballUrl === 'string') entry.tarballUrl = tarballUrl; + const lock: CenterLock = {}; + for (const [key, value] of Object.entries(raw)) { + if (!isObject(value)) { + throw new Error(`Invalid center.lock entry for "${key}": must be an object.`); + } + const version = value['version']; + if (typeof version !== 'string' || version.length === 0) { + throw new Error( + `Invalid center.lock entry for "${key}": missing resolved \`version\`.`, + ); + } + + const entry: CenterLockEntry = { version }; + const catalogUrl = value['catalog_url']; + if (typeof catalogUrl === 'string') entry.catalog_url = catalogUrl; + const catalogId = value['catalog_id']; + if (typeof catalogId === 'string') entry.catalog_id = catalogId; + const integrity = value['integrity']; + if (typeof integrity === 'string') entry.integrity = integrity; + const tarballUrl = value['tarballUrl']; + if (typeof tarballUrl === 'string') entry.tarballUrl = tarballUrl; - lock[key] = entry; - } + lock[key] = entry; + } - return lock; + return lock; } diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts new file mode 100644 index 0000000..60d7e5a --- /dev/null +++ b/src/commands/add.test.ts @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from 'vitest'; +import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; +import type { FetchIO } from '../adapters/http-fetch-adapter.ts'; +import type { InstallerIO } from '../adapters/npm-installer-adapter.ts'; + +// Re-export real implementations by default; individual tests can override parseCenterJson +const realCore = await vi.importActual('@focus-mcp/core'); + +vi.mock('@focus-mcp/core', async (importOriginal) => { + const real = await importOriginal(); + return { ...real }; +}); + +import { addCommand } from './add.ts'; + +// ---------- helpers ---------- + +const DEFAULT_URL = + 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; + +function makeFetchIO(fetchJsonImpl?: () => Promise): FetchIO { + return { + fetchJson: vi + .fn() + .mockImplementation( + fetchJsonImpl ?? (() => Promise.resolve(validCatalog([validBrick()]))), + ), + }; +} + +function makeStoreIO(sources?: unknown[]): CatalogStoreIO { + const payload = + sources !== undefined + ? { sources } + : { + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }; + return { + readStore: vi.fn().mockResolvedValue(payload), + writeStore: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeInstallerIO(overrides: Partial = {}): InstallerIO { + return { + npmInstall: vi.fn().mockResolvedValue(undefined), + npmUninstall: vi.fn().mockResolvedValue(undefined), + writeCenterJson: vi.fn().mockResolvedValue(undefined), + writeCenterLock: vi.fn().mockResolvedValue(undefined), + readCenterJson: vi.fn().mockResolvedValue({ bricks: {} }), + readCenterLock: vi.fn().mockResolvedValue({ bricks: {} }), + ...overrides, + }; +} + +function validCatalog(bricks: unknown[] = []) { + return { + name: 'Test Catalog', + owner: { name: 'FocusMCP' }, + updated: '2026-01-01', + bricks, + }; +} + +function validBrick(overrides: Partial> = {}): Record { + return { + name: 'echo', + version: '1.0.0', + description: 'Echo brick', + dependencies: [], + tools: [{ name: 'say', description: 'Echo text' }], + source: { type: 'npm', package: '@focus-mcp/brick-echo' }, + ...overrides, + }; +} + +// ---------- tests ---------- + +describe('addCommand', () => { + it('throws when brick name is empty', async () => { + const io = { fetch: makeFetchIO(), store: makeStoreIO(), installer: makeInstallerIO() }; + await expect(addCommand({ brickName: ' ', io })).rejects.toThrow(/must not be empty/i); + }); + + it('throws when no enabled catalog sources', async () => { + const io = { + fetch: makeFetchIO(), + store: makeStoreIO([ + { + url: 'https://example.com/catalog.json', + name: 'Disabled', + enabled: false, + addedAt: '2026-01-01T00:00:00Z', + }, + ]), + installer: makeInstallerIO(), + }; + await expect(addCommand({ brickName: 'echo', io })).rejects.toThrow( + /no enabled catalog sources/i, + ); + }); + + it('throws when brick is not found in any catalog', async () => { + const io = { + fetch: makeFetchIO(() => Promise.resolve(validCatalog([]))), + store: makeStoreIO(), + installer: makeInstallerIO(), + }; + await expect(addCommand({ brickName: 'ghost', io })).rejects.toThrow( + /not found in any catalog/i, + ); + }); + + it('reports already installed when brick is in center.json', async () => { + const io = { + fetch: makeFetchIO(), + store: makeStoreIO(), + installer: makeInstallerIO({ + readCenterJson: vi.fn().mockResolvedValue({ + bricks: { echo: { version: '1.0.0', enabled: true } }, + }), + readCenterLock: vi.fn().mockResolvedValue({ + bricks: { + echo: { + version: '1.0.0', + catalogUrl: DEFAULT_URL, + npmPackage: '@focus-mcp/brick-echo', + installedAt: '2026-01-01T00:00:00Z', + }, + }, + }), + }), + }; + + const result = await addCommand({ brickName: 'echo', io }); + expect(result).toMatch(/already installed/i); + expect(result).toMatch(/1\.0\.0/); + }); + + it('calls npmInstall and writes center state on success', async () => { + const installer = makeInstallerIO(); + const io = { fetch: makeFetchIO(), store: makeStoreIO(), installer }; + + const result = await addCommand({ brickName: 'echo', io }); + + expect(installer.npmInstall).toHaveBeenCalledOnce(); + expect(installer.writeCenterJson).toHaveBeenCalledOnce(); + expect(installer.writeCenterLock).toHaveBeenCalledOnce(); + expect(result).toMatch(/installed echo@1\.0\.0/i); + }); + + it('throws when all catalogs fail to fetch', async () => { + const io = { + fetch: { fetchJson: vi.fn().mockRejectedValue(new Error('network down')) }, + store: makeStoreIO(), + installer: makeInstallerIO(), + }; + await expect(addCommand({ brickName: 'echo', io })).rejects.toThrow( + /failed to fetch any catalog/i, + ); + }); + + it('falls back to default store when sources list is empty and installs successfully', async () => { + // lines 52-53: store.sources.length === 0 → createDefaultStore() + const installer = makeInstallerIO(); + const io = { + fetch: makeFetchIO(), + store: makeStoreIO([]), + installer, + }; + + const result = await addCommand({ brickName: 'echo', io }); + + expect(installer.npmInstall).toHaveBeenCalledOnce(); + expect(result).toMatch(/installed echo@1\.0\.0/i); + }); + + it('shows "unknown" version when installed brick entry has no version (line 84 fallback)', async () => { + // Override parseCenterJson to return a brick entry without a version field + // so that centerJson.bricks[brickName]?.version is undefined, hitting the ?? 'unknown' branch + const { default: core } = await import('@focus-mcp/core').then((m) => ({ default: m })); + vi.spyOn(core, 'parseCenterJson').mockReturnValue({ + bricks: { + echo: { enabled: true } as unknown as ReturnType< + typeof realCore.parseCenterJson + >['bricks'][string], + }, + }); + + const io = { + fetch: makeFetchIO(), + store: makeStoreIO(), + installer: makeInstallerIO({ + readCenterJson: vi.fn().mockResolvedValue({ bricks: { echo: {} } }), + readCenterLock: vi.fn().mockResolvedValue({ bricks: {} }), + }), + }; + + const result = await addCommand({ brickName: 'echo', io }); + expect(result).toMatch(/already installed/i); + expect(result).toMatch(/unknown/); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..d0fc199 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * focus add + * + * Fetches catalog sources, finds the brick, plans the npm install, executes it, + * and updates center.json + center.lock. + * Pure function: all I/O is injected via AddIO. + */ + +import { + aggregateCatalogs, + createDefaultStore, + executeInstall, + fetchAllCatalogs, + findBrickAcrossCatalogs, + getEnabledSources, + parseCatalogStore, + parseCenterJson, + parseCenterLock, + planInstall, +} from '@focus-mcp/core'; +import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; +import type { FetchIO } from '../adapters/http-fetch-adapter.ts'; +import type { InstallerIO } from '../adapters/npm-installer-adapter.ts'; + +export interface AddIO { + readonly fetch: FetchIO; + readonly store: CatalogStoreIO; + readonly installer: InstallerIO; +} + +export interface AddCommandInput { + readonly brickName: string; + readonly io: AddIO; +} + +/** + * Executes the add command. Returns a user-facing message describing what was + * installed or a clear error message when the brick cannot be found. + */ +export async function addCommand({ brickName, io }: AddCommandInput): Promise { + if (brickName.trim().length === 0) { + throw new Error('Brick name must not be empty.'); + } + + // Load catalog sources + const rawStore = await io.store.readStore(); + let store = parseCatalogStore(rawStore); + if (store.sources.length === 0) { + store = createDefaultStore(); + } + + const enabled = getEnabledSources(store); + if (enabled.length === 0) { + throw new Error('No enabled catalog sources. Use `focus catalog add `.'); + } + + const urls = enabled.map((s) => s.url); + const { results, errors: fetchErrors } = await fetchAllCatalogs(io.fetch, urls); + if (fetchErrors.length > 0 && results.length === 0) { + throw new Error( + `Failed to fetch any catalog: ${fetchErrors.map((e) => e.error).join('; ')}`, + ); + } + + const aggregated = aggregateCatalogs(results); + const brick = findBrickAcrossCatalogs(aggregated, brickName); + if (brick === undefined) { + throw new Error(`Brick "${brickName}" not found in any catalog.`); + } + + const plan = planInstall(brick, brick.catalogUrl); + + // Load existing center state + const rawCenter = await io.installer.readCenterJson(); + const rawLock = await io.installer.readCenterLock(); + const centerJson = parseCenterJson(rawCenter); + const centerLock = parseCenterLock(rawLock); + + // Check already installed + if (brickName in centerJson.bricks) { + return `Brick "${brickName}" is already installed (version ${centerJson.bricks[brickName]?.version ?? 'unknown'}). Use \`focus update\` to upgrade.`; + } + + await executeInstall(io.installer, plan, centerJson, centerLock); + + return `Installed ${brickName}@${plan.version} from ${plan.catalogUrl}`; +} diff --git a/src/commands/browse.test.ts b/src/commands/browse.test.ts new file mode 100644 index 0000000..d0b0818 --- /dev/null +++ b/src/commands/browse.test.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('ink', () => ({ + render: vi.fn().mockReturnValue({ waitUntilExit: vi.fn().mockResolvedValue(undefined) }), +})); + +vi.mock('react', () => ({ + default: { createElement: vi.fn().mockReturnValue(null) }, + createElement: vi.fn().mockReturnValue(null), +})); + +vi.mock('../tui/App.tsx', () => ({ + App: vi.fn(), +})); + +describe('browseCommand', () => { + it('calls render and awaits exit', async () => { + const { render } = await import('ink'); + const { browseCommand } = await import('./browse.ts'); + await browseCommand(); + expect(render).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/browse.ts b/src/commands/browse.ts new file mode 100644 index 0000000..4bb88fe --- /dev/null +++ b/src/commands/browse.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * focus browse + * + * Launches an interactive TUI to browse catalogs and bricks. + * Uses ink (React for terminals) to render the UI. + */ + +import { render } from 'ink'; +import React from 'react'; +import { App } from '../tui/App.tsx'; + +export async function browseCommand(): Promise { + const { waitUntilExit } = render(React.createElement(App)); + await waitUntilExit(); +} diff --git a/src/commands/catalog.test.ts b/src/commands/catalog.test.ts new file mode 100644 index 0000000..a03a49f --- /dev/null +++ b/src/commands/catalog.test.ts @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; +import { catalogCommand } from './catalog.ts'; + +// listSources override — used only for the "empty sources" branch test (lines 100-101) +let overrideListSources: (() => readonly unknown[]) | null = null; + +vi.mock('@focus-mcp/core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + listSources: (...args: Parameters) => { + if (overrideListSources !== null) { + return overrideListSources(); + } + return original.listSources(...args); + }, + }; +}); + +// ---------- helpers ---------- + +const DEFAULT_URL = + 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; +const EXTRA_URL = 'https://example.com/catalog.json'; + +function makeStoreIO(sourcesPayload: unknown = { sources: [] }): CatalogStoreIO { + return { + readStore: vi.fn().mockResolvedValue(sourcesPayload), + writeStore: vi.fn().mockResolvedValue(undefined), + }; +} + +function storeWithDefault() { + return makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); +} + +function storeWithExtra() { + return makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + { + url: EXTRA_URL, + name: 'Extra Catalog', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); +} + +// ---------- catalog list ---------- + +describe('catalogCommand list', () => { + it('shows "no catalog sources" when none are configured (empty sources)', async () => { + const store = makeStoreIO({ sources: [] }); + // Empty store falls back to default which has one source + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + // Falls back to default store + expect(result).toMatch(DEFAULT_URL); + }); + + it('falls back to default store when readStore returns invalid data (catch branch)', async () => { + // lines 54-55: parseCatalogStore throws → catch → createDefaultStore() + const store = makeStoreIO(null); + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + // Default store has the marketplace URL + expect(result).toMatch(DEFAULT_URL); + }); + + it('lists configured sources with name, url and status', async () => { + const store = storeWithDefault(); + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + expect(result).toMatch(/FocusMCP Marketplace/); + expect(result).toMatch(DEFAULT_URL); + expect(result).toMatch(/enabled/); + }); + + it('lists multiple sources', async () => { + const store = storeWithExtra(); + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + expect(result).toMatch(/FocusMCP Marketplace/); + expect(result).toMatch(/Extra Catalog/); + }); + + it('shows "disabled" status for disabled sources (line 104)', async () => { + // line 104: s.enabled ? 'enabled' : 'disabled' + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + { + url: EXTRA_URL, + name: 'Extra Catalog', + enabled: false, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + expect(result).toMatch(/disabled/); + expect(result).toMatch(/enabled/); + }); +}); + +// ---------- catalog add ---------- + +describe('catalogCommand add', () => { + it('throws when url is empty', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ subcommand: 'add', url: '', name: 'My Catalog', io: { store } }), + ).rejects.toThrow(/url must not be empty/i); + }); + + it('throws when name is empty', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ subcommand: 'add', url: EXTRA_URL, name: '', io: { store } }), + ).rejects.toThrow(/name must not be empty/i); + }); + + it('throws when the url already exists', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ + subcommand: 'add', + url: DEFAULT_URL, + name: 'Duplicate', + io: { store }, + }), + ).rejects.toThrow(/already exists/i); + }); + + it('writes the updated store and returns a success message', async () => { + const store = storeWithDefault(); + const result = await catalogCommand({ + subcommand: 'add', + url: EXTRA_URL, + name: 'Extra Catalog', + io: { store }, + }); + + expect(store.writeStore).toHaveBeenCalledOnce(); + expect(result).toMatch(/added catalog/i); + expect(result).toMatch(/Extra Catalog/); + expect(result).toMatch(EXTRA_URL); + }); + + it('includes the new source in the written store data', async () => { + const store = storeWithDefault(); + await catalogCommand({ + subcommand: 'add', + url: EXTRA_URL, + name: 'Extra Catalog', + io: { store }, + }); + + const written = (store.writeStore as ReturnType).mock.calls[0]?.[0] as { + sources: Array<{ url: string }>; + }; + expect(written.sources.some((s) => s.url === EXTRA_URL)).toBe(true); + }); +}); + +// ---------- catalog remove ---------- + +describe('catalogCommand remove', () => { + it('throws when url is empty', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ subcommand: 'remove', url: '', io: { store } }), + ).rejects.toThrow(/url must not be empty/i); + }); + + it('throws when trying to remove the default catalog', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ subcommand: 'remove', url: DEFAULT_URL, io: { store } }), + ).rejects.toThrow(/cannot remove the default/i); + }); + + it('throws when the source does not exist', async () => { + const store = storeWithDefault(); + await expect( + catalogCommand({ + subcommand: 'remove', + url: 'https://notexist.com/catalog.json', + io: { store }, + }), + ).rejects.toThrow(/not found/i); + }); + + it('writes the updated store without the removed source', async () => { + const store = storeWithExtra(); + const result = await catalogCommand({ + subcommand: 'remove', + url: EXTRA_URL, + io: { store }, + }); + + expect(store.writeStore).toHaveBeenCalledOnce(); + const written = (store.writeStore as ReturnType).mock.calls[0]?.[0] as { + sources: Array<{ url: string }>; + }; + expect(written.sources.some((s) => s.url === EXTRA_URL)).toBe(false); + expect(result).toMatch(/removed catalog/i); + expect(result).toMatch(EXTRA_URL); + }); +}); + +// ---------- catalogList empty-sources branch (lines 100-101) ---------- +// loadStore() always ensures at least one source via createDefaultStore(). +// The only way to reach the "No catalog sources configured." branch is to make +// listSources return [] — done via the module-level mock above. + +describe('catalogCommand list — no sources after listSources (lines 100-101)', () => { + beforeEach(() => { + overrideListSources = null; + }); + + afterEach(() => { + overrideListSources = null; + }); + + it('returns "No catalog sources configured." when listSources returns empty', async () => { + overrideListSources = () => []; + + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await catalogCommand({ subcommand: 'list', io: { store } }); + expect(result).toBe('No catalog sources configured.'); + }); +}); diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..8a5319e --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * focus catalog add|remove|list + * + * Manages the list of catalog source URLs stored at ~/.focus/catalogs.json. + * Pure function: all I/O is injected via CatalogCommandIO. + */ + +import { + addSource, + createDefaultStore, + listSources, + parseCatalogStore, + removeSource, +} from '@focus-mcp/core'; +import type { CatalogStoreData, CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; + +export type CatalogSubcommand = 'add' | 'remove' | 'list'; + +export interface CatalogCommandIO { + readonly store: CatalogStoreIO; +} + +export interface CatalogAddInput { + readonly subcommand: 'add'; + readonly url: string; + readonly name: string; + readonly io: CatalogCommandIO; +} + +export interface CatalogRemoveInput { + readonly subcommand: 'remove'; + readonly url: string; + readonly io: CatalogCommandIO; +} + +export interface CatalogListInput { + readonly subcommand: 'list'; + readonly io: CatalogCommandIO; +} + +export type CatalogCommandInput = CatalogAddInput | CatalogRemoveInput | CatalogListInput; + +// ---------- helpers ---------- + +async function loadStore(io: CatalogCommandIO): Promise { + const raw = await io.store.readStore(); + try { + const parsed = parseCatalogStore(raw); + return parsed.sources.length === 0 ? createDefaultStore() : parsed; + } catch { + return createDefaultStore(); + } +} + +// ---------- catalogCommand ---------- + +export async function catalogCommand(input: CatalogCommandInput): Promise { + if (input.subcommand === 'add') { + return catalogAdd(input); + } + if (input.subcommand === 'remove') { + return catalogRemove(input); + } + return catalogList(input); +} + +async function catalogAdd({ url, name, io }: CatalogAddInput): Promise { + if (url.trim().length === 0) { + throw new Error('Catalog URL must not be empty.'); + } + if (name.trim().length === 0) { + throw new Error('Catalog name must not be empty.'); + } + + const store = await loadStore({ store: io.store }); + const updated = addSource(store, url, name); + await io.store.writeStore(updated as CatalogStoreData); + return `Added catalog "${name}" (${url})`; +} + +async function catalogRemove({ url, io }: CatalogRemoveInput): Promise { + if (url.trim().length === 0) { + throw new Error('Catalog URL must not be empty.'); + } + + const store = await loadStore({ store: io.store }); + const updated = removeSource(store, url); + await io.store.writeStore(updated as CatalogStoreData); + return `Removed catalog ${url}`; +} + +async function catalogList({ io }: CatalogListInput): Promise { + const store = await loadStore({ store: io.store }); + const sources = listSources(store); + + if (sources.length === 0) { + return 'No catalog sources configured.'; + } + + const lines = sources.map((s) => { + const status = s.enabled ? 'enabled' : 'disabled'; + return `${s.name} ${s.url} [${status}]`; + }); + return lines.join('\n'); +} diff --git a/src/commands/info.test.ts b/src/commands/info.test.ts index 1273deb..4bbf621 100644 --- a/src/commands/info.test.ts +++ b/src/commands/info.test.ts @@ -6,84 +6,84 @@ import type { CenterJson, CenterLock } from '../center.ts'; import { infoCommand } from './info.ts'; describe('infoCommand', () => { - it('reports name, requested version, resolved version and status when the brick exists', () => { - const center: CenterJson = { - bricks: { - 'official/echo': { version: '^1.0.0', enabled: true }, - }, - }; - const lock: CenterLock = { - 'official/echo': { version: '1.0.0' }, - }; + it('reports name, requested version, resolved version and status when the brick exists', () => { + const center: CenterJson = { + bricks: { + 'official/echo': { version: '^1.0.0', enabled: true }, + }, + }; + const lock: CenterLock = { + 'official/echo': { version: '1.0.0' }, + }; - const output = infoCommand({ name: 'official/echo', center, lock }); - expect(output).toMatch(/official\/echo/); - expect(output).toMatch(/\^1\.0\.0/); - expect(output).toMatch(/1\.0\.0/); - expect(output).toMatch(/enabled/); - }); + const output = infoCommand({ name: 'official/echo', center, lock }); + expect(output).toMatch(/official\/echo/); + expect(output).toMatch(/\^1\.0\.0/); + expect(output).toMatch(/1\.0\.0/); + expect(output).toMatch(/enabled/); + }); - it('reports "unresolved" when the brick is declared but missing from the lock', () => { - const center: CenterJson = { - bricks: { - 'official/echo': { version: '^1.0.0', enabled: true }, - }, - }; - const output = infoCommand({ name: 'official/echo', center, lock: {} }); - expect(output).toMatch(/unresolved/i); - }); + it('reports "unresolved" when the brick is declared but missing from the lock', () => { + const center: CenterJson = { + bricks: { + 'official/echo': { version: '^1.0.0', enabled: true }, + }, + }; + const output = infoCommand({ name: 'official/echo', center, lock: {} }); + expect(output).toMatch(/unresolved/i); + }); - it('shows disabled when the brick is turned off', () => { - const center: CenterJson = { - bricks: { - 'official/echo': { version: '^1.0.0', enabled: false }, - }, - }; - const lock: CenterLock = { 'official/echo': { version: '1.0.0' } }; - const output = infoCommand({ name: 'official/echo', center, lock }); - expect(output).toMatch(/disabled/i); - }); + it('shows disabled when the brick is turned off', () => { + const center: CenterJson = { + bricks: { + 'official/echo': { version: '^1.0.0', enabled: false }, + }, + }; + const lock: CenterLock = { 'official/echo': { version: '1.0.0' } }; + const output = infoCommand({ name: 'official/echo', center, lock }); + expect(output).toMatch(/disabled/i); + }); - it('exposes the catalog, tarball and integrity metadata when available', () => { - const center: CenterJson = { - bricks: { - 'official/echo': { version: '^1.0.0', enabled: true }, - }, - }; - const lock: CenterLock = { - 'official/echo': { - version: '1.0.0', - catalog_id: 'official', - catalog_url: 'https://marketplace.focusmcp.dev/catalog.json', - tarballUrl: 'https://example.com/echo-1.0.0.tgz', - integrity: 'sha256-abc', - }, - }; - const output = infoCommand({ name: 'official/echo', center, lock }); - expect(output).toMatch(/official/); - expect(output).toMatch(/marketplace\.focusmcp\.dev/); - expect(output).toMatch(/echo-1\.0\.0\.tgz/); - expect(output).toMatch(/sha256-abc/); - }); + it('exposes the catalog, tarball and integrity metadata when available', () => { + const center: CenterJson = { + bricks: { + 'official/echo': { version: '^1.0.0', enabled: true }, + }, + }; + const lock: CenterLock = { + 'official/echo': { + version: '1.0.0', + catalog_id: 'official', + catalog_url: 'https://marketplace.focusmcp.dev/catalog.json', + tarballUrl: 'https://example.com/echo-1.0.0.tgz', + integrity: 'sha256-abc', + }, + }; + const output = infoCommand({ name: 'official/echo', center, lock }); + expect(output).toMatch(/official/); + expect(output).toMatch(/marketplace\.focusmcp\.dev/); + expect(output).toMatch(/echo-1\.0\.0\.tgz/); + expect(output).toMatch(/sha256-abc/); + }); - it('includes the serialized brick config when it is non-empty', () => { - const center: CenterJson = { - bricks: { - 'official/indexer': { - version: '^0.2.0', - enabled: true, - config: { root: '/src' }, - }, - }, - }; - const output = infoCommand({ name: 'official/indexer', center, lock: {} }); - expect(output).toMatch(/Config:/); - expect(output).toMatch(/"root": "\/src"/); - }); + it('includes the serialized brick config when it is non-empty', () => { + const center: CenterJson = { + bricks: { + 'official/indexer': { + version: '^0.2.0', + enabled: true, + config: { root: '/src' }, + }, + }, + }; + const output = infoCommand({ name: 'official/indexer', center, lock: {} }); + expect(output).toMatch(/Config:/); + expect(output).toMatch(/"root": "\/src"/); + }); - it('throws a clear error when the brick is not declared', () => { - expect(() => infoCommand({ name: 'official/ghost', center: { bricks: {} }, lock: {} })).toThrow( - /not declared/i, - ); - }); + it('throws a clear error when the brick is not declared', () => { + expect(() => + infoCommand({ name: 'official/ghost', center: { bricks: {} }, lock: {} }), + ).toThrow(/not declared/i); + }); }); diff --git a/src/commands/info.ts b/src/commands/info.ts index 68533b3..562dc1d 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -4,9 +4,9 @@ import type { CenterJson, CenterLock } from '../center.ts'; export interface InfoCommandInput { - name: string; - center: CenterJson; - lock: CenterLock; + name: string; + center: CenterJson; + lock: CenterLock; } /** @@ -15,35 +15,35 @@ export interface InfoCommandInput { * it into a non-zero exit code. */ export function infoCommand({ name, center, lock }: InfoCommandInput): string { - const entry = center.bricks[name]; - if (!entry) { - throw new Error(`Brick "${name}" is not declared in center.json.`); - } + const entry = center.bricks[name]; + if (!entry) { + throw new Error(`Brick "${name}" is not declared in center.json.`); + } - const resolved = lock[name]; - const lines: string[] = []; - lines.push(`Name: ${name}`); - lines.push(`Requested: ${entry.version}`); - lines.push(`Installed: ${resolved ? resolved.version : 'unresolved'}`); - lines.push(`Status: ${entry.enabled ? 'enabled' : 'disabled'}`); + const resolved = lock[name]; + const lines: string[] = []; + lines.push(`Name: ${name}`); + lines.push(`Requested: ${entry.version}`); + lines.push(`Installed: ${resolved ? resolved.version : 'unresolved'}`); + lines.push(`Status: ${entry.enabled ? 'enabled' : 'disabled'}`); - if (resolved?.catalog_id) { - lines.push(`Catalog: ${resolved.catalog_id}`); - } - if (resolved?.catalog_url) { - lines.push(`Catalog URL: ${resolved.catalog_url}`); - } - if (resolved?.tarballUrl) { - lines.push(`Tarball: ${resolved.tarballUrl}`); - } - if (resolved?.integrity) { - lines.push(`Integrity: ${resolved.integrity}`); - } + if (resolved?.catalog_id) { + lines.push(`Catalog: ${resolved.catalog_id}`); + } + if (resolved?.catalog_url) { + lines.push(`Catalog URL: ${resolved.catalog_url}`); + } + if (resolved?.tarballUrl) { + lines.push(`Tarball: ${resolved.tarballUrl}`); + } + if (resolved?.integrity) { + lines.push(`Integrity: ${resolved.integrity}`); + } - if (entry.config && Object.keys(entry.config).length > 0) { - lines.push('Config:'); - lines.push(JSON.stringify(entry.config, null, 2)); - } + if (entry.config && Object.keys(entry.config).length > 0) { + lines.push('Config:'); + lines.push(JSON.stringify(entry.config, null, 2)); + } - return lines.join('\n'); + return lines.join('\n'); } diff --git a/src/commands/list.test.ts b/src/commands/list.test.ts index 1e33f04..ff6806d 100644 --- a/src/commands/list.test.ts +++ b/src/commands/list.test.ts @@ -6,56 +6,56 @@ import type { CenterJson, CenterLock } from '../center.ts'; import { listCommand } from './list.ts'; describe('listCommand', () => { - it('reports no bricks when center.json is empty', () => { - const output = listCommand({ center: { bricks: {} }, lock: {} }); - expect(output).toMatch(/no bricks installed/i); - }); + it('reports no bricks when center.json is empty', () => { + const output = listCommand({ center: { bricks: {} }, lock: {} }); + expect(output).toMatch(/no bricks installed/i); + }); - it('lists installed bricks with their resolved versions and status', () => { - const center: CenterJson = { - bricks: { - 'official/echo': { version: '^1.0.0', enabled: true }, - 'official/indexer': { version: '^0.2.0', enabled: false }, - }, - }; - const lock: CenterLock = { - 'official/echo': { version: '1.0.0' }, - 'official/indexer': { version: '0.2.1' }, - }; + it('lists installed bricks with their resolved versions and status', () => { + const center: CenterJson = { + bricks: { + 'official/echo': { version: '^1.0.0', enabled: true }, + 'official/indexer': { version: '^0.2.0', enabled: false }, + }, + }; + const lock: CenterLock = { + 'official/echo': { version: '1.0.0' }, + 'official/indexer': { version: '0.2.1' }, + }; - const output = listCommand({ center, lock }); - expect(output).toMatch(/official\/echo/); - expect(output).toMatch(/1\.0\.0/); - expect(output).toMatch(/enabled/); - expect(output).toMatch(/official\/indexer/); - expect(output).toMatch(/0\.2\.1/); - expect(output).toMatch(/disabled/); - }); + const output = listCommand({ center, lock }); + expect(output).toMatch(/official\/echo/); + expect(output).toMatch(/1\.0\.0/); + expect(output).toMatch(/enabled/); + expect(output).toMatch(/official\/indexer/); + expect(output).toMatch(/0\.2\.1/); + expect(output).toMatch(/disabled/); + }); - it('flags bricks declared in center.json but missing from the lock', () => { - const output = listCommand({ - center: { bricks: { 'official/echo': { version: '^1.0.0', enabled: true } } }, - lock: {}, + it('flags bricks declared in center.json but missing from the lock', () => { + const output = listCommand({ + center: { bricks: { 'official/echo': { version: '^1.0.0', enabled: true } } }, + lock: {}, + }); + expect(output).toMatch(/unresolved/i); }); - expect(output).toMatch(/unresolved/i); - }); - it('sorts bricks alphabetically by key', () => { - const output = listCommand({ - center: { - bricks: { - 'official/zeta': { version: '^1.0.0', enabled: true }, - 'official/alpha': { version: '^1.0.0', enabled: true }, - }, - }, - lock: { - 'official/alpha': { version: '1.0.0' }, - 'official/zeta': { version: '1.0.0' }, - }, + it('sorts bricks alphabetically by key', () => { + const output = listCommand({ + center: { + bricks: { + 'official/zeta': { version: '^1.0.0', enabled: true }, + 'official/alpha': { version: '^1.0.0', enabled: true }, + }, + }, + lock: { + 'official/alpha': { version: '1.0.0' }, + 'official/zeta': { version: '1.0.0' }, + }, + }); + const alphaIdx = output.indexOf('official/alpha'); + const zetaIdx = output.indexOf('official/zeta'); + expect(alphaIdx).toBeGreaterThanOrEqual(0); + expect(zetaIdx).toBeGreaterThan(alphaIdx); }); - const alphaIdx = output.indexOf('official/alpha'); - const zetaIdx = output.indexOf('official/zeta'); - expect(alphaIdx).toBeGreaterThanOrEqual(0); - expect(zetaIdx).toBeGreaterThan(alphaIdx); - }); }); diff --git a/src/commands/list.ts b/src/commands/list.ts index b9615bb..d66bad8 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -4,8 +4,8 @@ import type { CenterJson, CenterLock } from '../center.ts'; export interface ListCommandInput { - center: CenterJson; - lock: CenterLock; + center: CenterJson; + lock: CenterLock; } /** @@ -14,19 +14,19 @@ export interface ListCommandInput { * returns the string that should be printed. */ export function listCommand({ center, lock }: ListCommandInput): string { - const entries = Object.entries(center.bricks); - if (entries.length === 0) { - return 'No bricks installed.'; - } + const entries = Object.entries(center.bricks); + if (entries.length === 0) { + return 'No bricks installed.'; + } - entries.sort(([a], [b]) => a.localeCompare(b)); + entries.sort(([a], [b]) => a.localeCompare(b)); - const lines: string[] = []; - for (const [key, entry] of entries) { - const resolved = lock[key]; - const resolvedVersion = resolved ? resolved.version : 'unresolved'; - const status = entry.enabled ? 'enabled' : 'disabled'; - lines.push(`${key} ${resolvedVersion} (wants ${entry.version}) [${status}]`); - } - return lines.join('\n'); + const lines: string[] = []; + for (const [key, entry] of entries) { + const resolved = lock[key]; + const resolvedVersion = resolved ? resolved.version : 'unresolved'; + const status = entry.enabled ? 'enabled' : 'disabled'; + lines.push(`${key} ${resolvedVersion} (wants ${entry.version}) [${status}]`); + } + return lines.join('\n'); } diff --git a/src/commands/remove.test.ts b/src/commands/remove.test.ts new file mode 100644 index 0000000..dfd0f30 --- /dev/null +++ b/src/commands/remove.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from 'vitest'; +import type { InstallerIO } from '../adapters/npm-installer-adapter.ts'; +import { removeCommand } from './remove.ts'; + +// ---------- helpers ---------- + +const DEFAULT_URL = + 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; + +function makeInstallerIO(overrides: Partial = {}): InstallerIO { + return { + npmInstall: vi.fn().mockResolvedValue(undefined), + npmUninstall: vi.fn().mockResolvedValue(undefined), + writeCenterJson: vi.fn().mockResolvedValue(undefined), + writeCenterLock: vi.fn().mockResolvedValue(undefined), + readCenterJson: vi.fn().mockResolvedValue({ + bricks: { + echo: { version: '1.0.0', enabled: true }, + }, + }), + readCenterLock: vi.fn().mockResolvedValue({ + bricks: { + echo: { + version: '1.0.0', + catalogUrl: DEFAULT_URL, + npmPackage: '@focus-mcp/brick-echo', + installedAt: '2026-01-01T00:00:00Z', + }, + }, + }), + ...overrides, + }; +} + +// ---------- tests ---------- + +describe('removeCommand', () => { + it('throws when brick name is empty', async () => { + const io = { installer: makeInstallerIO() }; + await expect(removeCommand({ brickName: '', io })).rejects.toThrow(/must not be empty/i); + }); + + it('throws when brick is not installed', async () => { + const installer = makeInstallerIO({ + readCenterJson: vi.fn().mockResolvedValue({ bricks: {} }), + readCenterLock: vi.fn().mockResolvedValue({ bricks: {} }), + }); + const io = { installer }; + await expect(removeCommand({ brickName: 'ghost', io })).rejects.toThrow(/not installed/i); + }); + + it('throws when lock entry is missing', async () => { + const installer = makeInstallerIO({ + readCenterLock: vi.fn().mockResolvedValue({ bricks: {} }), + }); + const io = { installer }; + await expect(removeCommand({ brickName: 'echo', io })).rejects.toThrow( + /lock entry not found/i, + ); + }); + + it('calls npmUninstall and writes updated center state on success', async () => { + const installer = makeInstallerIO(); + const io = { installer }; + + const result = await removeCommand({ brickName: 'echo', io }); + + expect(installer.npmUninstall).toHaveBeenCalledWith('@focus-mcp/brick-echo'); + expect(installer.writeCenterJson).toHaveBeenCalledOnce(); + expect(installer.writeCenterLock).toHaveBeenCalledOnce(); + expect(result).toMatch(/removed echo/i); + }); + + it('removes the brick entry from the written center.json', async () => { + const installer = makeInstallerIO(); + const io = { installer }; + + await removeCommand({ brickName: 'echo', io }); + + const writtenCenter = (installer.writeCenterJson as ReturnType).mock + .calls[0]?.[0] as { bricks: Record }; + expect(writtenCenter).toBeDefined(); + expect(writtenCenter.bricks['echo']).toBeUndefined(); + }); + + it('removes the brick entry from the written center.lock', async () => { + const installer = makeInstallerIO(); + const io = { installer }; + + await removeCommand({ brickName: 'echo', io }); + + const writtenLock = (installer.writeCenterLock as ReturnType).mock + .calls[0]?.[0] as { bricks: Record }; + expect(writtenLock).toBeDefined(); + expect(writtenLock.bricks['echo']).toBeUndefined(); + }); +}); diff --git a/src/commands/remove.ts b/src/commands/remove.ts new file mode 100644 index 0000000..e790ecc --- /dev/null +++ b/src/commands/remove.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * focus remove + * + * Plans removal by looking up the brick in center.json + center.lock, + * executes the npm uninstall, and updates both state files. + * Pure function: all I/O is injected via RemoveIO. + */ + +import { executeRemove, parseCenterJson, parseCenterLock, planRemove } from '@focus-mcp/core'; +import type { InstallerIO } from '../adapters/npm-installer-adapter.ts'; + +export interface RemoveIO { + readonly installer: InstallerIO; +} + +export interface RemoveCommandInput { + readonly brickName: string; + readonly io: RemoveIO; +} + +/** + * Executes the remove command. Returns a user-facing success message or + * throws with a clear error when the brick is not installed. + */ +export async function removeCommand({ brickName, io }: RemoveCommandInput): Promise { + if (brickName.trim().length === 0) { + throw new Error('Brick name must not be empty.'); + } + + const rawCenter = await io.installer.readCenterJson(); + const rawLock = await io.installer.readCenterLock(); + const centerJson = parseCenterJson(rawCenter); + const centerLock = parseCenterLock(rawLock); + + const { npmPackage } = planRemove(brickName, centerJson, centerLock); + + await executeRemove(io.installer, brickName, npmPackage, centerJson, centerLock); + + return `Removed ${brickName} (package: ${npmPackage})`; +} diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts new file mode 100644 index 0000000..0492544 --- /dev/null +++ b/src/commands/search.test.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from 'vitest'; +import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; +import type { FetchIO } from '../adapters/http-fetch-adapter.ts'; +import { searchCommand } from './search.ts'; + +// ---------- helpers ---------- + +const DEFAULT_URL = + 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; + +function makeFetchIO(overrides: Partial = {}): FetchIO { + return { + fetchJson: vi.fn().mockResolvedValue(validCatalog()), + ...overrides, + }; +} + +function makeStoreIO(sourcesPayload: unknown = { sources: [] }): CatalogStoreIO { + return { + readStore: vi.fn().mockResolvedValue(sourcesPayload), + writeStore: vi.fn().mockResolvedValue(undefined), + }; +} + +function validCatalog(bricks: unknown[] = []) { + return { + name: 'Test Catalog', + owner: { name: 'FocusMCP' }, + updated: '2026-01-01', + bricks, + }; +} + +function validBrick(overrides: Partial> = {}): Record { + return { + name: 'echo', + version: '1.0.0', + description: 'Echo brick for testing', + tags: ['utility'], + dependencies: [], + tools: [{ name: 'say', description: 'Echo text' }], + source: { type: 'npm', package: '@focus-mcp/brick-echo' }, + ...overrides, + }; +} + +// ---------- tests ---------- + +describe('searchCommand', () => { + it('uses the default catalog when store has no sources', async () => { + const fetch = makeFetchIO(); + const store = makeStoreIO({ sources: [] }); + + await searchCommand({ query: '', io: { fetch, store } }); + + expect(fetch.fetchJson).toHaveBeenCalledWith(DEFAULT_URL); + }); + + it('shows "no enabled sources" when all sources are disabled', async () => { + const fetch = makeFetchIO(); + const store = makeStoreIO({ + sources: [ + { + url: 'https://example.com/catalog.json', + name: 'Disabled', + enabled: false, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: 'anything', io: { fetch, store } }); + + expect(result.output).toMatch(/no enabled catalog sources/i); + expect(fetch.fetchJson).not.toHaveBeenCalled(); + }); + + it('returns all bricks when query is empty', async () => { + const fetch = makeFetchIO({ + fetchJson: vi + .fn() + .mockResolvedValue( + validCatalog([validBrick({ name: 'echo' }), validBrick({ name: 'indexer' })]), + ), + }); + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: '', io: { fetch, store } }); + + expect(result.output).toMatch(/echo/); + expect(result.output).toMatch(/indexer/); + }); + + it('filters bricks by query on name', async () => { + const fetch = makeFetchIO({ + fetchJson: vi + .fn() + .mockResolvedValue( + validCatalog([ + validBrick({ name: 'echo', description: 'Echo tool' }), + validBrick({ name: 'indexer', description: 'Index documents' }), + ]), + ), + }); + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: 'echo', io: { fetch, store } }); + + expect(result.output).toMatch(/echo/); + expect(result.output).not.toMatch(/indexer/); + }); + + it('returns "no bricks matching" when query yields nothing', async () => { + const fetch = makeFetchIO({ + fetchJson: vi.fn().mockResolvedValue(validCatalog([validBrick({ name: 'echo' })])), + }); + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: 'nonexistent', io: { fetch, store } }); + + expect(result.output).toMatch(/no bricks matching/i); + }); + + it('surfaces fetch errors as non-fatal', async () => { + const fetch = makeFetchIO({ + fetchJson: vi.fn().mockRejectedValue(new Error('network failure')), + }); + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: '', io: { fetch, store } }); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toMatch(/network failure/); + }); + + it('formats results with NAME / VERSION / CATALOG / DESCRIPTION columns', async () => { + const fetch = makeFetchIO({ + fetchJson: vi + .fn() + .mockResolvedValue( + validCatalog([ + validBrick({ name: 'echo', version: '2.1.0', description: 'An echo tool' }), + ]), + ), + }); + const store = makeStoreIO({ + sources: [ + { + url: DEFAULT_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + const result = await searchCommand({ query: '', io: { fetch, store } }); + + expect(result.output).toMatch(/NAME/); + expect(result.output).toMatch(/VERSION/); + expect(result.output).toMatch(/CATALOG/); + expect(result.output).toMatch(/DESCRIPTION/); + expect(result.output).toMatch(/2\.1\.0/); + expect(result.output).toMatch(/An echo tool/); + }); +}); diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..e447d7f --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * focus search + * + * Fetches all enabled catalog sources, aggregates bricks across them, + * then filters by the query and formats results as a table. + * Pure function: all I/O is injected via SearchIO. + */ + +import { + aggregateCatalogs, + createDefaultStore, + fetchAllCatalogs, + getEnabledSources, + parseCatalogStore, + searchBricks, +} from '@focus-mcp/core'; +import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; +import type { FetchIO } from '../adapters/http-fetch-adapter.ts'; + +export interface SearchIO { + readonly fetch: FetchIO; + readonly store: CatalogStoreIO; +} + +export interface SearchCommandInput { + readonly query: string; + readonly io: SearchIO; +} + +export interface SearchCommandResult { + readonly output: string; + readonly errors: readonly string[]; +} + +/** + * Executes the search command. Returns the formatted table string and any + * non-fatal fetch errors (one per catalog URL that could not be reached). + */ +export async function searchCommand({ + query, + io, +}: SearchCommandInput): Promise { + const rawStore = await io.store.readStore(); + let store = parseCatalogStore(rawStore); + + // If no sources are configured, initialise with the default. + if (store.sources.length === 0) { + store = createDefaultStore(); + } + + const enabled = getEnabledSources(store); + if (enabled.length === 0) { + return { output: 'No enabled catalog sources. Use `focus catalog add `.', errors: [] }; + } + + const urls = enabled.map((s) => s.url); + const { results, errors: fetchErrors } = await fetchAllCatalogs(io.fetch, urls); + + const aggregated = aggregateCatalogs(results); + const allErrors = [ + ...fetchErrors.map((e) => `${e.url}: ${e.error}`), + ...aggregated.errors.map((e) => `${e.url}: ${e.error}`), + ]; + + const trimmedQuery = query.trim(); + const found = + trimmedQuery.length === 0 ? aggregated.bricks : searchBricks(aggregated, trimmedQuery); + + if (found.length === 0) { + return { + output: + trimmedQuery.length === 0 + ? 'No bricks available.' + : `No bricks matching "${trimmedQuery}".`, + errors: allErrors, + }; + } + + const rows = found.map((b) => ({ + name: b.name, + version: b.version, + catalog: b.catalogName, + description: b.description, + })); + const lines = formatTable(rows); + return { output: lines.join('\n'), errors: allErrors }; +} + +// ---------- formatting ---------- + +interface Row { + readonly name: string; + readonly version: string; + readonly catalog: string; + readonly description: string; +} + +function formatTable(bricks: readonly Row[]): string[] { + const header: Row = { + name: 'NAME', + version: 'VERSION', + catalog: 'CATALOG', + description: 'DESCRIPTION', + }; + const rows = [header, ...bricks]; + + const nameW = Math.max(...rows.map((r) => r.name.length)); + const versionW = Math.max(...rows.map((r) => r.version.length)); + const catalogW = Math.max(...rows.map((r) => r.catalog.length)); + + return rows.map( + (r) => + `${r.name.padEnd(nameW)} ${r.version.padEnd(versionW)} ${r.catalog.padEnd(catalogW)} ${r.description}`, + ); +} diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts new file mode 100644 index 0000000..bedb1f2 --- /dev/null +++ b/src/commands/start.test.ts @@ -0,0 +1,2049 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use vi.hoisted so variables are available inside vi.mock factories (ESM hoisting) +const { + mockStop, + mockStart, + mockListTools, + mockCallTool, + mockConnect, + mockSetRequestHandler, + mockSendToolListChanged, + mockStreamableTransportCtor, + mockListen, + mockOnce, + mockHttpServer, + mockCreateServer, + lastTransportInstance, + mockLoadBricks, + mockReadFile, + mockGetBricks, + mockGetStatus, + mockGetBrick, + mockSetStatus, + mockUnregister, + mockRegister, + mockSearchCommand, + mockAddCommand, + mockRemoveCommand, + mockCatalogCommand, +} = vi.hoisted(() => { + const mockListen = vi.fn(); + const mockOnce = vi.fn(); + const mockHttpServer = { listen: mockListen, once: mockOnce }; + const mockCreateServer = vi.fn().mockReturnValue(mockHttpServer); + // Mutable container to capture the last created StreamableHTTPServerTransport instance + const lastTransportInstance: { current: { handleRequest: ReturnType } | null } = { + current: null, + }; + // The constructor mock — exposed so we can restore mockImplementation after vi.restoreAllMocks() + const mockStreamableTransportCtor = vi.fn(); + const mockLoadBricks = vi.fn().mockResolvedValue({ bricks: [], failures: [] }); + const mockReadFile = vi + .fn() + .mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + return { + mockStop: vi.fn().mockResolvedValue(undefined), + mockStart: vi.fn().mockResolvedValue(undefined), + mockListTools: vi.fn().mockReturnValue([]), + mockCallTool: vi.fn().mockResolvedValue({ content: [] }), + mockConnect: vi.fn().mockResolvedValue(undefined), + mockSetRequestHandler: vi.fn(), + mockSendToolListChanged: vi.fn().mockResolvedValue(undefined), + mockStreamableTransportCtor, + mockListen, + mockOnce, + mockHttpServer, + mockCreateServer, + lastTransportInstance, + mockLoadBricks, + mockReadFile, + mockGetBricks: vi.fn().mockReturnValue([]), + mockGetStatus: vi.fn().mockReturnValue('running'), + mockGetBrick: vi.fn().mockReturnValue(undefined), + mockSetStatus: vi.fn(), + mockUnregister: vi.fn(), + mockRegister: vi.fn(), + mockSearchCommand: vi.fn().mockResolvedValue({ output: 'search results', errors: [] }), + mockAddCommand: vi.fn().mockResolvedValue('installed ok'), + mockRemoveCommand: vi.fn().mockResolvedValue('removed ok'), + mockCatalogCommand: vi.fn().mockResolvedValue('catalog ok'), + }; +}); + +vi.mock('@focus-mcp/core', () => ({ + createFocusMcp: () => ({ + start: mockStart, + stop: mockStop, + router: { listTools: mockListTools, callTool: mockCallTool }, + registry: { + getBricks: mockGetBricks, + getStatus: mockGetStatus, + getBrick: mockGetBrick, + setStatus: mockSetStatus, + unregister: mockUnregister, + register: mockRegister, + }, + bus: {}, + }), + loadBricks: mockLoadBricks, +})); + +vi.mock('./search.ts', () => ({ searchCommand: mockSearchCommand })); +vi.mock('./add.ts', () => ({ addCommand: mockAddCommand })); +vi.mock('./remove.ts', () => ({ removeCommand: mockRemoveCommand })); +vi.mock('./catalog.ts', () => ({ catalogCommand: mockCatalogCommand })); + +vi.mock('../adapters/catalog-store-adapter.ts', () => ({ + FilesystemCatalogStoreAdapter: vi.fn().mockImplementation(() => ({})), +})); +vi.mock('../adapters/http-fetch-adapter.ts', () => ({ + HttpFetchAdapter: vi.fn().mockImplementation(() => ({})), +})); +vi.mock('../adapters/npm-installer-adapter.ts', () => ({ + NpmInstallerAdapter: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, +})); + +vi.mock('node:os', () => ({ + homedir: () => '/home/testuser', +})); + +vi.mock('../source/filesystem-source.ts', () => ({ + FilesystemBrickSource: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: class MockServer { + connect = mockConnect; + setRequestHandler = mockSetRequestHandler; + sendToolListChanged = mockSendToolListChanged; + }, +})); + +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ + StreamableHTTPServerTransport: mockStreamableTransportCtor, +})); + +vi.mock('@modelcontextprotocol/sdk/shared/transport.js', () => ({})); + +vi.mock('@modelcontextprotocol/sdk/types.js', () => ({ + ListToolsRequestSchema: 'ListToolsRequestSchema', + CallToolRequestSchema: 'CallToolRequestSchema', +})); + +vi.mock('node:http', () => ({ + createServer: mockCreateServer, +})); + +/** Re-apply the StreamableHTTPServerTransport mock implementation (lost after vi.restoreAllMocks) */ +function setupStreamableTransportMock(): void { + mockStreamableTransportCtor.mockImplementation(() => { + const instance = { handleRequest: vi.fn().mockResolvedValue(undefined) }; + lastTransportInstance.current = instance; + return instance; + }); +} + +describe('startCommand', () => { + beforeEach(() => { + vi.spyOn(process, 'once').mockReturnValue(process); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + mockStart.mockClear(); + mockStop.mockClear(); + mockConnect.mockClear(); + mockSetRequestHandler.mockClear(); + mockCallTool.mockReset(); + mockCallTool.mockResolvedValue({ content: [] }); + mockListTools.mockClear(); + lastTransportInstance.current = null; + mockListen.mockReset(); + mockOnce.mockReset(); + mockCreateServer.mockReset(); + mockCreateServer.mockReturnValue(mockHttpServer); + // Re-apply implementation in case vi.restoreAllMocks() cleared it + setupStreamableTransportMock(); + mockReadFile.mockReset(); + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockLoadBricks.mockReset(); + mockLoadBricks.mockResolvedValue({ bricks: [], failures: [] }); + mockGetBricks.mockReset(); + mockGetBricks.mockReturnValue([]); + mockGetStatus.mockReset(); + mockGetStatus.mockReturnValue('running'); + mockGetBrick.mockReset(); + mockGetBrick.mockReturnValue(undefined); + mockSetStatus.mockReset(); + mockUnregister.mockReset(); + mockRegister.mockReset(); + mockSendToolListChanged.mockReset(); + mockSendToolListChanged.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockLoadBricks.mockResolvedValue({ bricks: [], failures: [] }); + }); + + it('starts FocusMcp, connects transport and registers MCP handlers', async () => { + const { startCommand } = await import('./start.ts'); + // startCommand in stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockStart).toHaveBeenCalledOnce(); + expect(mockConnect).toHaveBeenCalledOnce(); + expect(mockSetRequestHandler).toHaveBeenCalledWith( + 'ListToolsRequestSchema', + expect.any(Function), + ); + expect(mockSetRequestHandler).toHaveBeenCalledWith( + 'CallToolRequestSchema', + expect.any(Function), + ); + + // The promise never resolves (infinite await), which is expected behaviour + void promise; + }); + + it('registers SIGINT and SIGTERM handlers', async () => { + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.once).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(process.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + void promise; + }); + + it('cleanup handler calls focusMcp.stop() and process.exit(0) on signal', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + // Capture handlers registered via process.once + const registeredHandlers: Array<[string, () => Promise]> = []; + // @ts-expect-error — mock overload for process.once signal handlers + vi.spyOn(process, 'once').mockImplementation((event: string, handler: unknown) => { + registeredHandlers.push([event, handler as () => Promise]); + return process; + }); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const sigintEntry = registeredHandlers.find(([ev]) => ev === 'SIGINT'); + if (!sigintEntry) throw new Error('SIGINT handler not registered'); + const cleanup = sigintEntry[1]; + + await cleanup(); + + expect(mockStop).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(0); + + void promise; + }); + + it('cleanup handler logs error to stderr when focusMcp.stop() throws', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + mockStop.mockRejectedValue(new Error('stop failed')); + + const registeredHandlers: Array<[string, () => Promise]> = []; + // @ts-expect-error — mock overload for process.once signal handlers + vi.spyOn(process, 'once').mockImplementation((event: string, handler: unknown) => { + registeredHandlers.push([event, handler as () => Promise]); + return process; + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const sigintEntry = registeredHandlers.find(([ev]) => ev === 'SIGINT'); + if (!sigintEntry) throw new Error('SIGINT handler not registered'); + const cleanup = sigintEntry[1]; + + await cleanup(); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Shutdown error: stop failed'), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + + void promise; + }); + + it('ListTools handler returns mapped tools from router', async () => { + mockListTools.mockReturnValue([ + { + name: 'echo_say', + description: 'Says something', + inputSchema: { type: 'object', properties: {} }, + }, + ]); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + // Find the ListTools handler (first setRequestHandler call) + const listToolsCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'ListToolsRequestSchema', + ); + if (!listToolsCall) throw new Error('ListTools handler not registered'); + + const handler = listToolsCall[1] as () => Promise<{ tools: unknown[] }>; + const result = await handler(); + + // Should include the brick tool + 11 internal tools + expect(result.tools).toEqual( + expect.arrayContaining([ + { + name: 'echo_say', + description: 'Says something', + inputSchema: { type: 'object', properties: {} }, + }, + expect.objectContaining({ name: 'focus_list' }), + expect.objectContaining({ name: 'focus_load' }), + expect.objectContaining({ name: 'focus_unload' }), + expect.objectContaining({ name: 'focus_reload' }), + expect.objectContaining({ name: 'focus_search' }), + expect.objectContaining({ name: 'focus_install' }), + expect.objectContaining({ name: 'focus_remove' }), + expect.objectContaining({ name: 'focus_update' }), + expect.objectContaining({ name: 'focus_catalog_add' }), + expect.objectContaining({ name: 'focus_catalog_list' }), + expect.objectContaining({ name: 'focus_catalog_remove' }), + ]), + ); + expect((result.tools as unknown[]).length).toBe(12); + + void promise; + }); + + it('CallTool handler dispatches to router.callTool and formats text content', async () => { + mockCallTool.mockResolvedValue({ + content: [{ type: 'text', text: 'hello' }], + }); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[] }>; + + const result = await handler({ params: { name: 'echo_say', arguments: { foo: 'bar' } } }); + + expect(mockCallTool).toHaveBeenCalledWith('echo_say', { foo: 'bar' }); + expect(result).toEqual({ + content: [{ type: 'text', text: 'hello' }], + }); + + void promise; + }); + + it('CallTool handler formats non-text content as JSON', async () => { + mockCallTool.mockResolvedValue({ + content: [{ type: 'json', data: { key: 'value' } }], + }); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[] }>; + + const result = await handler({ params: { name: 'some_tool', arguments: {} } }); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ key: 'value' }) }], + }); + + void promise; + }); + + it('CallTool handler returns isError: true when callTool throws an Error', async () => { + mockCallTool.mockRejectedValue(new Error('tool failed')); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[]; isError?: boolean }>; + + const result = await handler({ params: { name: 'bad_tool', arguments: {} } }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'tool failed' }], + isError: true, + }); + + void promise; + }); + + it('CallTool handler returns isError: true when callTool throws a non-Error', async () => { + mockCallTool.mockRejectedValue('string error'); + + const { startCommand } = await import('./start.ts'); + // stdio mode blocks forever — run without await + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[]; isError?: boolean }>; + + const result = await handler({ params: { name: 'bad_tool', arguments: {} } }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'string error' }], + isError: true, + }); + + void promise; + }); + + it('uses HTTP transport and creates HTTP server when --http flag is passed', async () => { + // Simulate server.listen calling the callback (resolves the Promise) + mockListen.mockImplementation((_port: number, cb: () => void) => { + cb(); + }); + // httpServer.once('error', reject) — just store it, never call reject + mockOnce.mockImplementation(() => {}); + + const { startCommand } = await import('./start.ts'); + + // startCommand will hang at `await new Promise(() => {})` — run without await + const promise = startCommand(['--http', '--port', '4000']); + + // Let microtasks settle so the async code inside startCommand runs + await new Promise((r) => setTimeout(r, 10)); + + expect(mockCreateServer).toHaveBeenCalledOnce(); + expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function)); + expect(process.stderr.write).toHaveBeenCalledWith( + 'FocusMCP MCP server listening on http://localhost:4000\n', + ); + + // The promise never resolves (infinite await), which is expected behaviour + void promise; + }); + + it('HTTP server handler reads body chunks and calls httpTransport.handleRequest', async () => { + mockListen.mockImplementation((_port: number, cb: () => void) => { + cb(); + }); + mockOnce.mockImplementation(() => {}); + + const { startCommand } = await import('./start.ts'); + startCommand(['--http', '--port', '4000']); + await new Promise((r) => setTimeout(r, 10)); + + // Retrieve the request handler passed to createServer + const call = mockCreateServer.mock.calls[0]; + if (!call) throw new Error('createServer not called'); + const requestHandler = call[0] as ( + req: AsyncIterable, + res: unknown, + ) => Promise; + + // Retrieve the transport instance captured during startCommand execution + const transport = lastTransportInstance.current; + expect(transport).not.toBeNull(); + + // Build a fake req that yields a JSON body + const body = JSON.stringify({ jsonrpc: '2.0', method: 'ping' }); + async function* fakeReq(): AsyncGenerator { + yield body; + } + const fakeRes = {}; + + await requestHandler(fakeReq() as unknown as AsyncIterable, fakeRes); + + if (!transport) throw new Error('transport not captured'); + expect(transport.handleRequest).toHaveBeenCalledWith( + expect.anything(), + fakeRes, + JSON.parse(body), + ); + }); + + it('HTTP server handler handles empty body and calls httpTransport.handleRequest with undefined', async () => { + mockListen.mockImplementation((_port: number, cb: () => void) => { + cb(); + }); + mockOnce.mockImplementation(() => {}); + + const { startCommand } = await import('./start.ts'); + startCommand(['--http', '--port', '4000']); + await new Promise((r) => setTimeout(r, 10)); + + const call2 = mockCreateServer.mock.calls[0]; + if (!call2) throw new Error('createServer not called'); + const requestHandler = call2[0] as ( + req: AsyncIterable, + res: unknown, + ) => Promise; + + const transport = lastTransportInstance.current; + expect(transport).not.toBeNull(); + + async function* emptyReq(): AsyncGenerator {} + const fakeRes = {}; + + await requestHandler(emptyReq() as unknown as AsyncIterable, fakeRes); + + if (!transport) throw new Error('transport not captured'); + expect(transport.handleRequest).toHaveBeenCalledWith(expect.anything(), fakeRes, undefined); + }); + + it('HTTP server handler returns 400 when body is invalid JSON', async () => { + mockListen.mockImplementation((_port: number, cb: () => void) => { + cb(); + }); + mockOnce.mockImplementation(() => {}); + + const { startCommand } = await import('./start.ts'); + startCommand(['--http', '--port', '4000']); + await new Promise((r) => setTimeout(r, 10)); + + const call = mockCreateServer.mock.calls[0]; + if (!call) throw new Error('createServer not called'); + const requestHandler = call[0] as ( + req: AsyncIterable, + res: { + writeHead: ReturnType; + end: ReturnType; + }, + ) => Promise; + + async function* invalidJsonReq(): AsyncGenerator { + yield 'not valid json {{{'; + } + const fakeRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + + await requestHandler(invalidJsonReq() as unknown as AsyncIterable, fakeRes); + + expect(fakeRes.writeHead).toHaveBeenCalledWith(400, { 'Content-Type': 'application/json' }); + expect(fakeRes.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Invalid JSON' })); + }); + + it('HTTP server handler returns 413 when body exceeds 1MB', async () => { + mockListen.mockImplementation((_port: number, cb: () => void) => { + cb(); + }); + mockOnce.mockImplementation(() => {}); + + const { startCommand } = await import('./start.ts'); + startCommand(['--http', '--port', '4000']); + await new Promise((r) => setTimeout(r, 10)); + + const call = mockCreateServer.mock.calls[0]; + if (!call) throw new Error('createServer not called'); + const requestHandler = call[0] as ( + req: AsyncIterable, + res: { + writeHead: ReturnType; + end: ReturnType; + }, + ) => Promise; + + // Generate a body larger than 1MB + const largeChunk = 'x'.repeat(1024 * 1024 + 1); + async function* largeReq(): AsyncGenerator { + yield largeChunk; + } + const fakeRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + + await requestHandler(largeReq() as unknown as AsyncIterable, fakeRes); + + expect(fakeRes.writeHead).toHaveBeenCalledWith(413, { 'Content-Type': 'application/json' }); + expect(fakeRes.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Payload too large' })); + }); + + it('logs stdio server started message in stdio mode', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.stderr.write).toHaveBeenCalledWith('FocusMCP stdio MCP server started\n'); + + void promise; + }); + + it('loadSingleBrick throws when loadBricks returns a failure', async () => { + mockGetBrick.mockReturnValue(undefined); + mockLoadBricks.mockResolvedValue({ + bricks: [], + failures: [{ name: 'my-brick', error: new Error('no brick loaded') }], + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'my-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('no brick loaded'); + + void promise; + }); + + it('loadSingleBrick throws when loadBricks returns 0 bricks and no failures', async () => { + mockGetBrick.mockReturnValue(undefined); + mockLoadBricks.mockResolvedValue({ bricks: [], failures: [] }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'ghost-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Failed to load'); + + void promise; + }); + + it('logs "starting with 0 bricks" when center.json does not exist', async () => { + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.stderr.write).toHaveBeenCalledWith( + 'No center.json found — starting with 0 bricks\n', + ); + + void promise; + }); + + it('logs "Failed to load bricks" using String(err) when center.json read throws a non-Error (line 88)', async () => { + // Throw a non-Error so the `String(err)` branch is hit + mockReadFile.mockRejectedValue('raw string rejection'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.stderr.write).toHaveBeenCalledWith( + 'Failed to load bricks: raw string rejection\n', + ); + + void promise; + }); + + it('logs "Failed to load bricks" when center.json read fails with non-ENOENT error (lines 87-90)', async () => { + const permError = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + mockReadFile.mockRejectedValue(permError); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.stderr.write).toHaveBeenCalledWith( + 'Failed to load bricks: Permission denied\n', + ); + + void promise; + }); + + it('throws when --port value is out of range (lines 58-59)', async () => { + const { startCommand } = await import('./start.ts'); + + await expect(startCommand(['--http', '--port', '99999'])).rejects.toThrow(/invalid port/i); + }); + + it('loads bricks from center.json and passes them to createFocusMcp', async () => { + const fakeBrick = { manifest: { name: 'test-brick' }, start: vi.fn(), stop: vi.fn() }; + mockLoadBricks.mockResolvedValue({ bricks: [fakeBrick], failures: [] }); + + const centerJson = JSON.stringify({ + bricks: { + 'catalog/test-brick': { version: '^1.0.0', enabled: true }, + }, + }); + mockReadFile.mockResolvedValue(centerJson); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockLoadBricks).toHaveBeenCalledOnce(); + expect(process.stderr.write).toHaveBeenCalledWith('Loaded 1 brick(s)\n'); + + void promise; + }); + + it('logs brick load failures without stopping', async () => { + const fakeBrick = { manifest: { name: 'ok-brick' }, start: vi.fn(), stop: vi.fn() }; + const failure = { name: 'catalog/bad-brick', error: new Error('load error') }; + mockLoadBricks.mockResolvedValue({ bricks: [fakeBrick], failures: [failure] }); + + const centerJson = JSON.stringify({ + bricks: { + 'catalog/ok-brick': { version: '^1.0.0', enabled: true }, + 'catalog/bad-brick': { version: '^1.0.0', enabled: true }, + }, + }); + mockReadFile.mockResolvedValue(centerJson); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(process.stderr.write).toHaveBeenCalledWith( + '⚠ Failed to load brick "catalog/bad-brick": load error\n', + ); + expect(process.stderr.write).toHaveBeenCalledWith('Loaded 1 brick(s)\n'); + + void promise; + }); + + it('uses FOCUSMCP_BRICKS_DIR env var when set', async () => { + const { FilesystemBrickSource } = await import('../source/filesystem-source.ts'); + const originalEnv = process.env['FOCUSMCP_BRICKS_DIR']; + + process.env['FOCUSMCP_BRICKS_DIR'] = '/custom/bricks/dir'; + + const centerJson = JSON.stringify({ bricks: {} }); + mockReadFile.mockResolvedValue(centerJson); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + expect(FilesystemBrickSource).toHaveBeenCalledWith( + expect.objectContaining({ bricksDir: '/custom/bricks/dir' }), + ); + + if (originalEnv === undefined) { + delete process.env['FOCUSMCP_BRICKS_DIR']; + } else { + process.env['FOCUSMCP_BRICKS_DIR'] = originalEnv; + } + + void promise; + }); + + describe('internal tools', () => { + it('focus_list returns "No bricks loaded." when registry is empty', async () => { + mockGetBricks.mockReturnValue([]); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[] }>; + + const result = await handler({ params: { name: 'focus_list', arguments: {} } }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'No bricks loaded.' }], + }); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_list returns brick names, statuses and tools when bricks are loaded', async () => { + mockGetBricks.mockReturnValue([ + { + manifest: { + name: 'echo', + tools: [{ name: 'echo_say', description: 'Say something' }], + }, + start: vi.fn(), + stop: vi.fn(), + }, + ]); + mockGetStatus.mockReturnValue('running'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }> }>; + + const result = await handler({ params: { name: 'focus_list', arguments: {} } }); + + expect(result.content[0]?.type).toBe('text'); + expect(result.content[0]?.text).toContain('echo'); + expect(result.content[0]?.text).toContain('running'); + expect(result.content[0]?.text).toContain('echo_say'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_load returns error when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ params: { name: 'focus_load', arguments: {} } }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_load returns error when brick is already loaded', async () => { + const fakeBrick = { + manifest: { name: 'echo', tools: [{ name: 'echo_say' }] }, + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + mockGetBrick.mockReturnValue(fakeBrick); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('already loaded'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_load loads a brick, registers, starts it and sends notification', async () => { + const fakeBrick = { + manifest: { name: 'echo', tools: [{ name: 'echo_say' }] }, + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + mockGetBrick.mockReturnValue(undefined); + mockLoadBricks.mockResolvedValue({ bricks: [fakeBrick], failures: [] }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(mockRegister).toHaveBeenCalledWith(fakeBrick); + expect(fakeBrick.start).toHaveBeenCalledOnce(); + expect(mockSetStatus).toHaveBeenCalledWith('echo', 'running'); + expect(mockSendToolListChanged).toHaveBeenCalledOnce(); + expect(result.content[0]?.text).toContain('echo'); + expect(result.content[0]?.text).toContain('echo_say'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_load returns error when loadSingleBrick fails', async () => { + mockGetBrick.mockReturnValue(undefined); + mockLoadBricks.mockResolvedValue({ + bricks: [], + failures: [{ name: 'echo', error: new Error('disk error') }], + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Failed to load'); + expect(result.content[0]?.text).toContain('disk error'); + + void promise; + }); + + it('focus_unload returns error when brick not found', async () => { + mockGetBrick.mockReturnValue(undefined); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'unknown-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('not found'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_unload stops and unregisters the brick when found', async () => { + const mockBrickStop = vi.fn().mockResolvedValue(undefined); + const fakeBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn(), + stop: mockBrickStop, + }; + mockGetBrick.mockReturnValue(fakeBrick); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(mockBrickStop).toHaveBeenCalledOnce(); + expect(mockSetStatus).toHaveBeenCalledWith('echo', 'stopped'); + expect(mockUnregister).toHaveBeenCalledWith('echo'); + expect(mockSendToolListChanged).toHaveBeenCalledOnce(); + expect(result.content[0]?.text).toContain('unloaded successfully'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_unload returns isError when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ params: { name: 'focus_unload', arguments: {} } }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + + void promise; + }); + + it('focus_unload returns isError when brick.stop() throws (lines 244-253)', async () => { + const mockBrickStopFail = vi.fn().mockRejectedValue(new Error('stop error')); + const existingBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn().mockResolvedValue(undefined), + stop: mockBrickStopFail, + }; + mockGetBrick.mockReturnValue(existingBrick); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Failed to unload'); + expect(result.content[0]?.text).toContain('stop error'); + + void promise; + }); + + it('focus_reload returns error when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ params: { name: 'focus_reload', arguments: {} } }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + + void promise; + }); + + it('focus_reload returns error when brick is not found', async () => { + mockGetBrick.mockReturnValue(undefined); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_reload', arguments: { name: 'unknown-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('not found'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_reload stops, reimports, restarts and sends notification', async () => { + const mockBrickStop = vi.fn().mockResolvedValue(undefined); + const existingBrick = { + manifest: { name: 'echo', tools: [{ name: 'echo_say' }] }, + start: vi.fn().mockResolvedValue(undefined), + stop: mockBrickStop, + }; + const newBrick = { + manifest: { name: 'echo', tools: [{ name: 'echo_say' }, { name: 'echo_shout' }] }, + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + mockGetBrick.mockReturnValue(existingBrick); + mockLoadBricks.mockResolvedValue({ bricks: [newBrick], failures: [] }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_reload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(mockBrickStop).toHaveBeenCalledOnce(); + expect(mockUnregister).toHaveBeenCalledWith('echo'); + expect(mockRegister).toHaveBeenCalledWith(newBrick); + expect(newBrick.start).toHaveBeenCalledOnce(); + expect(mockSetStatus).toHaveBeenCalledWith('echo', 'running'); + expect(mockSendToolListChanged).toHaveBeenCalledOnce(); + expect(result.content[0]?.text).toContain('reloaded'); + expect(result.content[0]?.text).toContain('echo_say'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_reload returns isError when reload throws (lines 289-299)', async () => { + const existingBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn().mockRejectedValue(new Error('brick start failed')), + stop: vi.fn().mockResolvedValue(undefined), + }; + mockGetBrick.mockReturnValue(existingBrick); + // loadSingleBrick returns a brick whose start() throws + const failingBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn().mockRejectedValue(new Error('brick start failed')), + stop: vi.fn().mockResolvedValue(undefined), + }; + mockLoadBricks.mockResolvedValue({ bricks: [failingBrick], failures: [] }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_reload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Failed to reload'); + expect(result.content[0]?.text).toContain('brick start failed'); + + void promise; + }); + + it('CallTool handler returns JSON-stringified result when callTool returns non-content value (lines 320-322)', async () => { + mockCallTool.mockResolvedValue('plain string result'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ params: { name: 'some_tool', arguments: {} } }); + + expect(result.content[0]?.type).toBe('text'); + expect(result.content[0]?.text).toBe(JSON.stringify('plain string result')); + + void promise; + }); + + it('focus_list shows "(no tools)" when a brick has no tools (line 161)', async () => { + mockGetBricks.mockReturnValue([ + { + manifest: { name: 'toolless-brick', tools: [] }, + start: vi.fn(), + stop: vi.fn(), + }, + ]); + mockGetStatus.mockReturnValue('running'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }> }>; + + const result = await handler({ params: { name: 'focus_list', arguments: {} } }); + + expect(result.content[0]?.text).toContain('(no tools)'); + + void promise; + }); + + it('focus_unload error path uses String(err) when stop() throws a non-Error (line 248)', async () => { + const mockBrickStopNonError = vi.fn().mockRejectedValue('non-error string'); + const existingBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn().mockResolvedValue(undefined), + stop: mockBrickStopNonError, + }; + mockGetBrick.mockReturnValue(existingBrick); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('non-error string'); + + void promise; + }); + + it('focus_reload error path uses String(err) when a non-Error is thrown (line 294)', async () => { + const mockBrickStop = vi.fn().mockResolvedValue(undefined); + const existingBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn().mockResolvedValue(undefined), + stop: mockBrickStop, + }; + mockGetBrick.mockReturnValue(existingBrick); + // loadSingleBrick throws a non-Error + mockLoadBricks.mockRejectedValue('non-error reload string'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_reload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('non-error reload string'); + + void promise; + }); + + it('CallTool handler uses empty object when args is undefined (line 304 ?? {})', async () => { + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: undefined }; + }) => Promise<{ content: unknown[] }>; + + // Call without arguments (undefined) to trigger the `args ?? {}` fallback + const result = await handler({ params: { name: 'echo_say' } }); + + expect(mockCallTool).toHaveBeenCalledWith('echo_say', {}); + expect(result.content[0]).toEqual({ type: 'text', text: 'ok' }); + + void promise; + }); + + it('focus_load error path uses String(err) when a non-Error is thrown (line 207)', async () => { + mockGetBrick.mockReturnValue(undefined); + // Make loadBricks throw a non-Error so the `String(err)` branch is hit + mockLoadBricks.mockRejectedValue('non-error string thrown'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('non-error string thrown'); + + void promise; + }); + + it('CallTool handler content mapping uses empty string fallback when text is undefined (line 315)', async () => { + // Return a text item with no text property to hit the `?? ''` branch + mockCallTool.mockResolvedValue({ + content: [{ type: 'text' }], + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }> }>; + + const result = await handler({ params: { name: 'echo_say', arguments: {} } }); + + expect(result.content[0]?.type).toBe('text'); + expect(result.content[0]?.text).toBe(''); + + void promise; + }); + + // ── Marketplace tools ────────────────────────────────────────────────── + + describe('focus_search', () => { + it('returns search results text on success', async () => { + mockSearchCommand.mockResolvedValue({ + output: 'brick-a 1.0.0 catalog', + errors: [], + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_search', arguments: { query: 'git' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toBe('brick-a 1.0.0 catalog'); + expect(mockSearchCommand).toHaveBeenCalledWith( + expect.objectContaining({ query: 'git' }), + ); + + void promise; + }); + + it('appends warnings when errors are present', async () => { + mockSearchCommand.mockResolvedValue({ + output: 'results', + errors: ['https://example.com: fetch failed'], + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_search', arguments: { query: '' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('Warnings:'); + expect(result.content[0]?.text).toContain('fetch failed'); + + void promise; + }); + + it('returns isError when query is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_search', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid query'); + + void promise; + }); + + it('returns isError when searchCommand throws', async () => { + mockSearchCommand.mockRejectedValue(new Error('network error')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_search', arguments: { query: 'git' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Search failed'); + expect(result.content[0]?.text).toContain('network error'); + + void promise; + }); + }); + + describe('focus_install', () => { + it('returns success message on install', async () => { + mockAddCommand.mockResolvedValue('Installed my-brick@1.0.0 from https://catalog'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_install', arguments: { name: 'my-brick' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('Installed my-brick'); + expect(mockAddCommand).toHaveBeenCalledWith( + expect.objectContaining({ brickName: 'my-brick' }), + ); + + void promise; + }); + + it('returns isError when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_install', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + + void promise; + }); + + it('returns isError when addCommand throws', async () => { + mockAddCommand.mockRejectedValue(new Error('not found in catalog')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_install', arguments: { name: 'ghost-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Install failed'); + expect(result.content[0]?.text).toContain('not found in catalog'); + + void promise; + }); + }); + + describe('focus_remove', () => { + it('returns success message on remove', async () => { + mockRemoveCommand.mockResolvedValue( + 'Removed my-brick (package: @focus-mcp/my-brick)', + ); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_remove', arguments: { name: 'my-brick' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('Removed my-brick'); + expect(mockRemoveCommand).toHaveBeenCalledWith( + expect.objectContaining({ brickName: 'my-brick' }), + ); + + void promise; + }); + + it('returns isError when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_remove', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + + void promise; + }); + + it('returns isError when removeCommand throws', async () => { + mockRemoveCommand.mockRejectedValue(new Error('brick not installed')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_remove', arguments: { name: 'ghost-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Remove failed'); + expect(result.content[0]?.text).toContain('brick not installed'); + + void promise; + }); + }); + + describe('focus_update', () => { + it('returns "not yet implemented" placeholder', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_update', arguments: {} }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('not yet implemented'); + + void promise; + }); + }); + + describe('focus_catalog_add', () => { + it('adds a catalog source and returns success', async () => { + mockCatalogCommand.mockResolvedValue( + 'Added catalog "catalog" (https://example.com/catalog.json)', + ); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { + name: 'focus_catalog_add', + arguments: { url: 'https://example.com/catalog.json' }, + }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('Added catalog'); + expect(mockCatalogCommand).toHaveBeenCalledWith( + expect.objectContaining({ + subcommand: 'add', + url: 'https://example.com/catalog.json', + }), + ); + + void promise; + }); + + it('returns isError when URL is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_catalog_add', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid URL'); + + void promise; + }); + + it('returns isError when catalogCommand throws', async () => { + mockCatalogCommand.mockRejectedValue(new Error('write failed')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { + name: 'focus_catalog_add', + arguments: { url: 'https://example.com/catalog.json' }, + }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Catalog add failed'); + expect(result.content[0]?.text).toContain('write failed'); + + void promise; + }); + }); + + describe('focus_catalog_list', () => { + it('returns list of catalog sources', async () => { + mockCatalogCommand.mockResolvedValue( + 'official https://catalog.focusmcp.dev/catalog.json [enabled]', + ); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_catalog_list', arguments: {} }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('official'); + expect(mockCatalogCommand).toHaveBeenCalledWith( + expect.objectContaining({ subcommand: 'list' }), + ); + + void promise; + }); + + it('returns isError when catalogCommand throws', async () => { + mockCatalogCommand.mockRejectedValue(new Error('read error')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_catalog_list', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Catalog list failed'); + expect(result.content[0]?.text).toContain('read error'); + + void promise; + }); + }); + + describe('focus_catalog_remove', () => { + it('removes a catalog source and returns success', async () => { + mockCatalogCommand.mockResolvedValue( + 'Removed catalog https://example.com/catalog.json', + ); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { + name: 'focus_catalog_remove', + arguments: { url: 'https://example.com/catalog.json' }, + }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('Removed catalog'); + expect(mockCatalogCommand).toHaveBeenCalledWith( + expect.objectContaining({ + subcommand: 'remove', + url: 'https://example.com/catalog.json', + }), + ); + + void promise; + }); + + it('returns isError when URL is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_catalog_remove', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid URL'); + + void promise; + }); + + it('returns isError when catalogCommand throws', async () => { + mockCatalogCommand.mockRejectedValue(new Error('remove error')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { + name: 'focus_catalog_remove', + arguments: { url: 'https://example.com/catalog.json' }, + }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Catalog remove failed'); + expect(result.content[0]?.text).toContain('remove error'); + + void promise; + }); + }); + }); + + it('cleanup handler uses String(err) when stop() throws a non-Error (line 341)', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + // Throw a non-Error so `String(err)` branch is hit in the cleanup handler + mockStop.mockRejectedValue('raw string error'); + + const registeredHandlers: Array<[string, () => Promise]> = []; + // @ts-expect-error — mock overload for process.once signal handlers + vi.spyOn(process, 'once').mockImplementation((event: string, handler: unknown) => { + registeredHandlers.push([event, handler as () => Promise]); + return process; + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const sigintEntry = registeredHandlers.find(([ev]) => ev === 'SIGINT'); + if (!sigintEntry) throw new Error('SIGINT handler not registered'); + const cleanup = sigintEntry[1]; + + await cleanup(); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Shutdown error: raw string error'), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + + void promise; + }); + + it('minimalLogger methods are callable and do nothing', async () => { + const { minimalLogger } = await import('./start.ts'); + // Each method is a no-op stub — call them to satisfy coverage + expect(() => minimalLogger.trace()).not.toThrow(); + expect(() => minimalLogger.debug()).not.toThrow(); + expect(() => minimalLogger.info()).not.toThrow(); + expect(() => minimalLogger.warn()).not.toThrow(); + expect(() => minimalLogger.error()).not.toThrow(); + }); +}); diff --git a/src/commands/start.ts b/src/commands/start.ts index 4cfaeab..a746731 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -1,24 +1,642 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -/** - * `focus start` — launches FocusMCP as a stdio MCP server. - * - * Stub implementation. The real implementation will: - * - * 1. Read `~/.focus/center.json` + `~/.focus/center.lock` via the parsers - * in `../center.ts`. - * 2. Call `createFocusMcp()` from `@focusmcp/core` with the resolved brick - * list, the EventBus guards, and user permissions. - * 3. Wire the returned router to a `StdioServerTransport` from - * `@modelcontextprotocol/sdk/server/stdio.js` so every `tools/*`, - * `resources/*`, and `prompts/*` JSON-RPC call lands on the router. - * 4. Stream logs to stderr (stdout is reserved for the MCP transport). - * 5. Handle SIGINT/SIGTERM to flush EventBus subscribers before exit. - * - * Until then the command fails explicitly so nobody mistakes the scaffolding - * for a working server. - */ -export async function startCommand(): Promise { - throw new Error('focus start not implemented yet — stdio MCP transport will land in the next PR'); +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import type { Brick } from '@focus-mcp/core'; +import { createFocusMcp, loadBricks } from '@focus-mcp/core'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { FilesystemCatalogStoreAdapter } from '../adapters/catalog-store-adapter.ts'; +import { HttpFetchAdapter } from '../adapters/http-fetch-adapter.ts'; +import { NpmInstallerAdapter } from '../adapters/npm-installer-adapter.ts'; +import { parseCenterJson } from '../center.ts'; +import { FilesystemBrickSource } from '../source/filesystem-source.ts'; +import { addCommand } from './add.ts'; +import { catalogCommand } from './catalog.ts'; +import { removeCommand } from './remove.ts'; +import { searchCommand } from './search.ts'; + +export const minimalLogger = { + trace() {}, + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +async function loadSingleBrick(brickName: string, bricksDir: string): Promise { + const source = new FilesystemBrickSource({ + centerJson: { bricks: { [brickName]: { version: '*', enabled: true } } }, + bricksDir, + }); + const result = await loadBricks({ source }); + if (result.failures.length > 0) { + throw result.failures[0]?.error; + } + const first = result.bricks[0]; + if (!first) { + throw new Error(`No brick loaded for "${brickName}"`); + } + return first; +} + +export async function startCommand(argv: string[] = []): Promise { + const { values } = parseArgs({ + args: argv, + allowPositionals: false, + strict: false, + options: { + http: { type: 'boolean', default: false }, + port: { type: 'string', default: '3000' }, + }, + }); + + const useHttp = values['http'] === true; + const port = Number(values['port']); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port: ${values['port']}. Must be 1-65535.`); + } + + const focusDir = join(homedir(), '.focus'); + let bricks: Brick[] = []; + const activeBricksDir = process.env['FOCUSMCP_BRICKS_DIR'] ?? join(focusDir, 'bricks'); + + try { + const raw = await readFile(join(focusDir, 'center.json'), 'utf-8'); + const centerJson = parseCenterJson(JSON.parse(raw)); + + const source = new FilesystemBrickSource({ centerJson, bricksDir: activeBricksDir }); + const result = await loadBricks({ source }); + + bricks = [...result.bricks]; + + for (const failure of result.failures) { + process.stderr.write( + `⚠ Failed to load brick "${failure.name}": ${failure.error.message}\n`, + ); + } + + process.stderr.write(`Loaded ${bricks.length} brick(s)\n`); + } catch (err: unknown) { + const isNotFound = + err instanceof Error && 'code' in err && (err as { code: string }).code === 'ENOENT'; + if (isNotFound) { + process.stderr.write('No center.json found — starting with 0 bricks\n'); + } else { + process.stderr.write( + `Failed to load bricks: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + const focusMcp = createFocusMcp({ bricks }); + await focusMcp.start(); + + const server = new Server( + { name: '@focus-mcp/cli', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + ...focusMcp.router.listTools().map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + { + name: 'focus_list', + description: 'List all loaded bricks and their tools', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'focus_load', + description: + 'Load (activate) an installed brick — its tools become available immediately', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to load' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_unload', + description: + 'Unload (deactivate) a running brick — its tools are removed immediately', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to unload' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_reload', + description: + 'Reload a brick — stop, reimport from disk, restart. Tools are updated immediately.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to reload' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_search', + description: 'Search the marketplace catalog for available bricks', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Search query' } }, + required: ['query'], + additionalProperties: false, + }, + }, + { + name: 'focus_install', + description: 'Install a brick from the marketplace catalog', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Brick name to install' }, + version: { type: 'string', description: 'Version to install (optional)' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_remove', + description: 'Remove an installed brick', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to remove' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_update', + description: 'Update an installed brick to the latest version', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Brick name to update (optional, updates all if omitted)', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'focus_catalog_add', + description: 'Add a catalog source URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Catalog source URL to add' }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + { + name: 'focus_catalog_list', + description: 'List all configured catalog sources', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'focus_catalog_remove', + description: 'Remove a catalog source URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Catalog source URL to remove' }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + ], + })); + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: internal tool dispatch with multiple branches + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + + // Internal tools — handled before dispatching to brick router + if (name === 'focus_list') { + const bricks = focusMcp.registry.getBricks(); + if (bricks.length === 0) { + return { content: [{ type: 'text' as const, text: 'No bricks loaded.' }] }; + } + const lines = bricks.map((b) => { + const status = focusMcp.registry.getStatus(b.manifest.name); + const toolNames = b.manifest.tools.map((t) => t.name).join(', ') || '(no tools)'; + return `- ${b.manifest.name} [${status}]: ${toolNames}`; + }); + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } + + if (name === 'focus_load') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + if (focusMcp.registry.getBrick(brickName)) { + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" is already loaded.`, + }, + ], + isError: true, + }; + } + try { + const brick = await loadSingleBrick(brickName, activeBricksDir); + focusMcp.registry.register(brick); + const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; + await brick.start(ctx); + focusMcp.registry.setStatus(brickName, 'running'); + await server.sendToolListChanged(); + const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" loaded. Tools: ${toolNames}`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to load "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_unload') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + const brick = focusMcp.registry.getBrick(brickName); + if (!brick) { + return { + content: [{ type: 'text' as const, text: `Brick "${brickName}" not found.` }], + isError: true, + }; + } + try { + await brick.stop(); + focusMcp.registry.setStatus(brickName, 'stopped'); + focusMcp.registry.unregister(brickName); + await server.sendToolListChanged(); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" unloaded successfully.`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to unload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_reload') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + const existing = focusMcp.registry.getBrick(brickName); + if (!existing) { + return { + content: [{ type: 'text' as const, text: `Brick "${brickName}" not found.` }], + isError: true, + }; + } + try { + await existing.stop(); + focusMcp.registry.unregister(brickName); + const brick = await loadSingleBrick(brickName, activeBricksDir); + focusMcp.registry.register(brick); + const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; + await brick.start(ctx); + focusMcp.registry.setStatus(brickName, 'running'); + await server.sendToolListChanged(); + const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" reloaded. Tools: ${toolNames}`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to reload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_search') { + const query = (args as Record)?.['query']; + if (typeof query !== 'string') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid query.' }], + isError: true, + }; + } + try { + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + }; + const result = await searchCommand({ query, io }); + const text = + result.errors.length > 0 + ? `${result.output}\n\nWarnings:\n${result.errors.join('\n')}` + : result.output; + return { content: [{ type: 'text' as const, text }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_install') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + try { + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; + const result = await addCommand({ brickName, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Install failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_remove') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + try { + const io = { installer: new NpmInstallerAdapter() }; + const result = await removeCommand({ brickName, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Remove failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_update') { + return { + content: [{ type: 'text' as const, text: 'focus_update: not yet implemented' }], + }; + } + + if (name === 'focus_catalog_add') { + const url = (args as Record)?.['url']; + if (typeof url !== 'string' || url.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], + isError: true, + }; + } + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + // Derive a name from the URL (last path segment without extension) + const urlName = + url + .split('/') + .filter(Boolean) + .pop() + ?.replace(/\.json$/i, '') ?? url; + const result = await catalogCommand({ + subcommand: 'add', + url, + name: urlName, + io, + }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog add failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_catalog_list') { + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + const result = await catalogCommand({ subcommand: 'list', io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog list failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'focus_catalog_remove') { + const url = (args as Record)?.['url']; + if (typeof url !== 'string' || url.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], + isError: true, + }; + } + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + const result = await catalogCommand({ subcommand: 'remove', url, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog remove failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + // Brick tools (existing dispatch) + try { + const result = await focusMcp.router.callTool(name, args ?? {}); + if ( + result && + typeof result === 'object' && + 'content' in result && + Array.isArray(result.content) + ) { + return { + content: result.content.map( + (item: { type: string; text?: string; data?: unknown }) => + item.type === 'text' + ? { type: 'text' as const, text: item.text ?? '' } + : { type: 'text' as const, text: JSON.stringify(item.data) }, + ), + }; + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: err instanceof Error ? err.message : String(err), + }, + ], + isError: true, + }; + } + }); + + const cleanup = async (): Promise => { + try { + await focusMcp.stop(); + } catch (err) { + process.stderr.write( + `Shutdown error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + process.exit(0); + }; + + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + + if (useHttp) { + const httpTransport = new StreamableHTTPServerTransport({}); + await server.connect(httpTransport as unknown as Transport); + + const MAX_BODY = 1024 * 1024; // 1MB + const httpServer = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk; + if (body.length > MAX_BODY) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + return; + } + } + let parsed: unknown; + try { + parsed = body.length > 0 ? JSON.parse(body) : undefined; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + await httpTransport.handleRequest(req, res, parsed); + }); + + await new Promise((resolve, reject) => { + httpServer.listen(port, () => { + process.stderr.write(`FocusMCP MCP server listening on http://localhost:${port}\n`); + resolve(); + }); + httpServer.once('error', reject); + }); + } else { + const transport = new StdioServerTransport(); + await server.connect(transport); + process.stderr.write('FocusMCP stdio MCP server started\n'); + } } diff --git a/src/source/filesystem-source.test.ts b/src/source/filesystem-source.test.ts new file mode 100644 index 0000000..3c80c3e --- /dev/null +++ b/src/source/filesystem-source.test.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadFile, mockAccess } = vi.hoisted(() => ({ + mockReadFile: vi.fn(), + mockAccess: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, + access: mockAccess, +})); + +describe('FilesystemBrickSource', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockAccess.mockReset(); + }); + + it('list() returns only enabled bricks', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const source = new FilesystemBrickSource({ + centerJson: { + bricks: { + 'catalog/brick-a': { version: '^1.0.0', enabled: true }, + 'catalog/brick-b': { version: '^2.0.0', enabled: false }, + 'catalog/brick-c': { version: '^3.0.0', enabled: true }, + }, + }, + bricksDir: '/fake/bricks', + }); + + const list = await source.list(); + + expect(list).toEqual(['catalog/brick-a', 'catalog/brick-c']); + }); + + it('list() returns empty array when no bricks are enabled', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const source = new FilesystemBrickSource({ + centerJson: { + bricks: { + 'catalog/brick-a': { version: '^1.0.0', enabled: false }, + }, + }, + bricksDir: '/fake/bricks', + }); + + const list = await source.list(); + + expect(list).toEqual([]); + }); + + it('readManifest() reads mcp-brick.json from the correct path', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const manifest = { name: 'brick-a', version: '1.0.0', tools: [] }; + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readManifest('catalog/brick-a'); + + expect(mockReadFile).toHaveBeenCalledWith('/fake/bricks/brick-a/mcp-brick.json', 'utf-8'); + expect(result).toEqual(manifest); + }); + + it('readManifest() uses the brick name directly when no catalog prefix', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const manifest = { name: 'brick-a', version: '1.0.0', tools: [] }; + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readManifest('brick-a'); + + expect(mockReadFile).toHaveBeenCalledWith('/fake/bricks/brick-a/mcp-brick.json', 'utf-8'); + expect(result).toEqual(manifest); + }); + + it('readManifest() throws when file is not found', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const error = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + mockReadFile.mockRejectedValue(error); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + await expect(source.readManifest('catalog/missing-brick')).rejects.toThrow('ENOENT'); + }); + + // ---------- safeBrickName edge cases (lines 17-18) ---------- + + it('readManifest() throws for empty brick name', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + await expect(source.readManifest('')).rejects.toThrow(/invalid brick name/i); + }); + + it('readManifest() throws for "." brick name', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + await expect(source.readManifest('.')).rejects.toThrow(/invalid brick name/i); + }); + + it('readManifest() throws for ".." brick name', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + await expect(source.readManifest('..')).rejects.toThrow(/invalid brick name/i); + }); + + // ---------- loadModule (lines 54-64) ---------- + + it('loadModule() calls access on the dist path first', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + mockAccess.mockResolvedValue(undefined); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + // import() will fail because the path is not a real module — that is expected + await expect(source.loadModule('brick-a')).rejects.toThrow(); + expect(mockAccess).toHaveBeenCalledWith('/fake/bricks/brick-a/dist/index.js'); + }); + + it('loadModule() falls back to src/index.ts when dist/index.js is not accessible', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + // import() will fail because the path is not a real module — that is expected + await expect(source.loadModule('brick-a')).rejects.toThrow(); + // access was called on dist path, then fell through to src path import + expect(mockAccess).toHaveBeenCalledWith('/fake/bricks/brick-a/dist/index.js'); + }); +}); diff --git a/src/source/filesystem-source.ts b/src/source/filesystem-source.ts new file mode 100644 index 0000000..7187167 --- /dev/null +++ b/src/source/filesystem-source.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { access, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import type { BrickSource } from '@focus-mcp/core'; +import type { CenterJson } from '../center.ts'; + +export interface FilesystemSourceOptions { + readonly centerJson: CenterJson; + readonly bricksDir: string; +} + +function safeBrickName(name: string): string { + // split('/') always produces a non-empty array, so pop() never returns undefined + const segment = name.split('/').pop() as string; + if (!segment || segment === '.' || segment === '..' || segment.includes('/')) { + throw new Error(`Invalid brick name: "${name}"`); + } + return segment; +} + +function safeBrickPath(bricksDir: string, brickName: string, ...rest: string[]): string { + return resolve(join(bricksDir, brickName, ...rest)); +} + +export class FilesystemBrickSource implements BrickSource { + readonly #centerJson: CenterJson; + readonly #bricksDir: string; + + constructor(options: FilesystemSourceOptions) { + this.#centerJson = options.centerJson; + this.#bricksDir = options.bricksDir; + } + + async list(): Promise { + return Object.entries(this.#centerJson.bricks) + .filter(([, entry]) => entry.enabled) + .map(([name]) => name); + } + + async readManifest(name: string): Promise { + const brickName = safeBrickName(name); + const manifestPath = safeBrickPath(this.#bricksDir, brickName, 'mcp-brick.json'); + const raw = await readFile(manifestPath, 'utf-8'); + return JSON.parse(raw); + } + + async loadModule(name: string): Promise { + const brickName = safeBrickName(name); + const distPath = safeBrickPath(this.#bricksDir, brickName, 'dist', 'index.js'); + const cacheBuster = `?t=${Date.now()}`; + try { + await access(distPath); + return import(`${distPath}${cacheBuster}`); + } catch { + const srcPath = safeBrickPath(this.#bricksDir, brickName, 'src', 'index.ts'); + return import(`${srcPath}${cacheBuster}`); + } + } +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 0000000..623308d --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * App — root TUI component. Routes between screens: + * catalogs → bricks → details + * Global keybindings: q to quit, Esc to navigate back, ? to toggle help. + */ + +import { Box, useInput } from 'ink'; +import { useState } from 'react'; +import { Breadcrumb } from './components/Breadcrumb.tsx'; +import { HelpOverlay } from './components/HelpOverlay.tsx'; +import { StatusBar } from './components/StatusBar.tsx'; +import { BrickDetailsScreen } from './screens/BrickDetailsScreen.tsx'; +import { BricksScreen } from './screens/BricksScreen.tsx'; +import { CatalogsScreen } from './screens/CatalogsScreen.tsx'; + +type Screen = + | { readonly type: 'catalogs' } + | { readonly type: 'bricks'; readonly catalogUrl?: string; readonly catalogName?: string } + | { + readonly type: 'details'; + readonly brickName: string; + readonly catalogUrl: string; + readonly catalogName?: string; + }; + +function buildBreadcrumb(screen: Screen): string[] { + if (screen.type === 'catalogs') return ['FocusMCP', 'Catalogs']; + if (screen.type === 'bricks') { + return ['FocusMCP', screen.catalogName ?? 'Bricks', 'Bricks']; + } + return ['FocusMCP', screen.catalogName ?? 'Bricks', screen.brickName]; +} + +function navigateBack(screen: Screen): Screen { + if (screen.type === 'bricks') return { type: 'catalogs' }; + if (screen.type === 'details') { + return { + type: 'bricks', + ...(screen.catalogUrl !== undefined ? { catalogUrl: screen.catalogUrl } : {}), + ...(screen.catalogName !== undefined ? { catalogName: screen.catalogName } : {}), + }; + } + return screen; +} + +function openBricksScreen(url: string | undefined, name: string | undefined): Screen { + if (url !== undefined) { + return { + type: 'bricks', + catalogUrl: url, + ...(name !== undefined ? { catalogName: name } : {}), + }; + } + return { type: 'bricks' }; +} + +export function App() { + const [screen, setScreen] = useState({ type: 'catalogs' }); + const [showHelp, setShowHelp] = useState(false); + + useInput((input, key) => { + if (input === 'q') process.exit(0); + if (input === '?') { + setShowHelp((v) => !v); + return; + } + if (key.escape) { + if (showHelp) { + setShowHelp(false); + } else { + setScreen(navigateBack(screen)); + } + } + }); + + const breadcrumb = buildBreadcrumb(screen); + + return ( + + + {screen.type === 'catalogs' && ( + setScreen(openBricksScreen(url, name))} /> + )} + {screen.type === 'bricks' && ( + + setScreen({ + type: 'details', + brickName: name, + catalogUrl: url, + ...(screen.catalogName !== undefined + ? { catalogName: screen.catalogName } + : {}), + }) + } + onBack={() => setScreen({ type: 'catalogs' })} + showHelp={showHelp} + /> + )} + {screen.type === 'details' && ( + setScreen(navigateBack(screen))} + showHelp={showHelp} + /> + )} + {showHelp && screen.type !== 'bricks' && } + + + ); +} diff --git a/src/tui/components/Breadcrumb.tsx b/src/tui/components/Breadcrumb.tsx new file mode 100644 index 0000000..c0d37e4 --- /dev/null +++ b/src/tui/components/Breadcrumb.tsx @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Breadcrumb — displays the current navigation path. + * Active (last) segment is highlighted in cyan. + * + * Examples: + * FocusMCP › Catalogs + * FocusMCP › FocusMCP Official › Bricks + * FocusMCP › FocusMCP Official › echo + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; + +interface BreadcrumbProps { + readonly segments: readonly string[]; +} + +export function Breadcrumb({ segments }: BreadcrumbProps): React.ReactElement { + return ( + + {segments.map((seg, i) => { + const isLast = i === segments.length - 1; + const isFirst = i === 0; + return ( + + {!isFirst && {' › '}} + {isLast ? ( + + {seg} + + ) : ( + {seg} + )} + + ); + })} + + ); +} diff --git a/src/tui/components/BrickPreview.tsx b/src/tui/components/BrickPreview.tsx new file mode 100644 index 0000000..6ae2c0c --- /dev/null +++ b/src/tui/components/BrickPreview.tsx @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * BrickPreview — right-panel showing details of the highlighted brick. + * Updates in real-time as the user navigates. + * Shows: name, version, description, tools (first 5), tags, install status. + */ + +import type { AggregatedBrick } from '@focus-mcp/core'; +import { Box, Text } from 'ink'; +import type React from 'react'; + +interface BrickPreviewProps { + readonly brick: AggregatedBrick | undefined; + readonly isInstalled: boolean; +} + +const MAX_TOOLS = 5; + +function InstallBadge({ isInstalled }: { readonly isInstalled: boolean }): React.ReactElement { + return isInstalled ? ( + {'● installed'} + ) : ( + {'○ not installed'} + ); +} + +function ToolsList({ + tools, +}: { + readonly tools: readonly { readonly name: string; readonly description: string }[]; +}): React.ReactElement { + const visible = tools.slice(0, MAX_TOOLS); + const remaining = tools.length - MAX_TOOLS; + return ( + + {visible.map((t) => ( + {` • ${t.name}`} + ))} + {remaining > 0 && {` + ${String(remaining)} more…`}} + + ); +} + +export function BrickPreview({ brick, isInstalled }: BrickPreviewProps): React.ReactElement { + if (brick === undefined) { + return ( + + Select a brick to preview + + ); + } + + return ( + + + + {brick.name} + + {`v${brick.version}`} + + + + + + {brick.description} + + {brick.tools.length > 0 && ( + + {'Tools:'} + + + )} + {brick.tags !== undefined && brick.tags.length > 0 && ( + + {'Tags: '} + {brick.tags.join(', ')} + + )} + + + {isInstalled ? '[u] uninstall [Enter] focus' : '[i] install [Enter] focus'} + + + + ); +} diff --git a/src/tui/components/HelpOverlay.tsx b/src/tui/components/HelpOverlay.tsx new file mode 100644 index 0000000..32badaa --- /dev/null +++ b/src/tui/components/HelpOverlay.tsx @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * HelpOverlay — modal overlay showing keyboard shortcuts for the current screen. + * Press ? or Esc to dismiss. + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; + +interface KeyBinding { + readonly key: string; + readonly label: string; +} + +const COMMON_BINDINGS: readonly KeyBinding[] = [ + { key: '↑↓', label: 'Navigate' }, + { key: 'q', label: 'Quit' }, + { key: '?', label: 'Toggle this help' }, + { key: 'Esc', label: 'Back' }, +]; + +const SCREEN_BINDINGS: Record = { + catalogs: [{ key: 'Enter', label: 'Open catalog' }], + bricks: [ + { key: 'Enter', label: 'Focus details' }, + { key: '/', label: 'Search' }, + { key: 'i', label: 'Install brick' }, + { key: 'u', label: 'Uninstall brick' }, + { key: 'PgUp/Dn', label: 'Page navigation' }, + ], + details: [ + { key: 'i', label: 'Install brick' }, + { key: 'u', label: 'Uninstall brick' }, + ], +}; + +interface HelpOverlayProps { + readonly screen: 'catalogs' | 'bricks' | 'details'; +} + +function KeyRow({ binding }: { readonly binding: KeyBinding }): React.ReactElement { + return ( + + {binding.key.padEnd(12)} + {binding.label} + + ); +} + +export function HelpOverlay({ screen }: HelpOverlayProps): React.ReactElement { + const screenBindings = SCREEN_BINDINGS[screen] ?? []; + const allBindings = [...screenBindings, ...COMMON_BINDINGS]; + + return ( + + + + Keyboard shortcuts + + + {allBindings.map((binding) => ( + + ))} + + ); +} diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx new file mode 100644 index 0000000..99f1163 --- /dev/null +++ b/src/tui/components/List.tsx @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * List — keyboard-navigable list component with viewport scrolling. + * ↑↓ to move cursor, Enter to select. + * Only renders items visible in the current viewport for readability. + * + * Cursor can be lifted to the parent via `cursor` + `onCursorChange` props. + */ + +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useState } from 'react'; + +interface ListItem { + readonly label: string; + readonly value: string; +} + +interface ListProps { + readonly items: ListItem[]; + readonly onSelect: (value: string) => void; + readonly pageSize?: number; + /** Controlled cursor index. When provided, the component is controlled. */ + readonly cursor?: number; + /** Called when the cursor changes. Required when `cursor` is provided. */ + readonly onCursorChange?: (index: number) => void; +} + +const DEFAULT_PAGE_SIZE = 15; + +export function List({ + items, + onSelect, + pageSize = DEFAULT_PAGE_SIZE, + cursor: controlledCursor, + onCursorChange, +}: ListProps): React.ReactElement { + const [internalCursor, setInternalCursor] = useState(0); + + const isControlled = controlledCursor !== undefined; + const cursor = isControlled ? controlledCursor : internalCursor; + + function moveCursor(next: number): void { + if (isControlled) { + onCursorChange?.(next); + } else { + setInternalCursor(next); + } + } + + useInput((_input, key) => { + if (key.upArrow) { + moveCursor(Math.max(0, cursor - 1)); + } else if (key.downArrow) { + moveCursor(Math.min(items.length - 1, cursor + 1)); + } else if (key.pageUp) { + moveCursor(Math.max(0, cursor - pageSize)); + } else if (key.pageDown) { + moveCursor(Math.min(items.length - 1, cursor + pageSize)); + } else if (key.return) { + const item = items[cursor]; + if (item !== undefined) { + onSelect(item.value); + } + } + }); + + if (items.length === 0) { + return (empty); + } + + // Compute viewport window around cursor + const half = Math.floor(pageSize / 2); + let start = Math.max(0, cursor - half); + const end = Math.min(items.length, start + pageSize); + start = Math.max(0, end - pageSize); + const visible = items.slice(start, end); + + return ( + + {start > 0 && ↑ {String(start)} more above} + {visible.map((item, i) => { + const absoluteIndex = start + i; + const isSelected = absoluteIndex === cursor; + const label = `${isSelected ? '> ' : ' '}${item.label}`; + return isSelected ? ( + + {label} + + ) : ( + {label} + ); + })} + {end < items.length && ↓ {String(items.length - end)} more below} + + + {String(cursor + 1)} / {String(items.length)} + + + + ); +} diff --git a/src/tui/components/SearchBar.tsx b/src/tui/components/SearchBar.tsx new file mode 100644 index 0000000..bef96fb --- /dev/null +++ b/src/tui/components/SearchBar.tsx @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * SearchBar — text input activated by / key. + * Enter to confirm, Esc to cancel. + */ + +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; + +interface SearchBarProps { + readonly query: string; + readonly onChange: (q: string) => void; + readonly onSubmit: () => void; + readonly onCancel: () => void; +} + +export function SearchBar({ + query, + onChange, + onSubmit, + onCancel, +}: SearchBarProps): React.ReactElement { + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + if (key.return) { + onSubmit(); + return; + } + if (key.backspace === true || key.delete === true) { + onChange(query.slice(0, -1)); + return; + } + if (input.length > 0 && key.ctrl !== true && key.meta !== true) { + onChange(query + input); + } + }); + + return ( + + {'/ '} + {query} + {'█'} + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx new file mode 100644 index 0000000..4d2df96 --- /dev/null +++ b/src/tui/components/StatusBar.tsx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * StatusBar — bottom bar with context-aware action hints. + * Shows different hints based on screen, install status, and current action. + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; + +type ActionState = 'idle' | 'installing' | 'uninstalling' | 'success' | 'error'; + +interface StatusBarProps { + readonly screen: 'catalogs' | 'bricks' | 'details'; + /** Whether the currently highlighted brick is installed (bricks/details screens). */ + readonly isInstalled?: boolean; + /** Name of the brick being acted on (for in-progress messages). */ + readonly activeBrickName?: string; + /** Current action state (for in-progress messages). */ + readonly actionState?: ActionState; +} + +function buildHint( + screen: 'catalogs' | 'bricks' | 'details', + isInstalled: boolean, + actionState: ActionState, + activeBrickName: string, +): string { + if (actionState === 'installing') { + return `Installing... @focus-mcp/brick-${activeBrickName}`; + } + if (actionState === 'uninstalling') { + return `Uninstalling... @focus-mcp/brick-${activeBrickName}`; + } + + if (screen === 'catalogs') { + return '↑↓ navigate Enter open q quit ? help'; + } + if (screen === 'bricks') { + const action = isInstalled ? '[u] uninstall' : '[i] install'; + return `${action} [Enter] details [/] search Esc back ? help`; + } + // details screen + const action = isInstalled ? '[u] uninstall' : '[i] install'; + return `${action} Esc back ? help`; +} + +export function StatusBar({ + screen, + isInstalled = false, + activeBrickName = '', + actionState = 'idle', +}: StatusBarProps): React.ReactElement { + const isActive = actionState === 'installing' || actionState === 'uninstalling'; + const hint = buildHint(screen, isInstalled, actionState, activeBrickName); + + return ( + + + {hint} + + + ); +} diff --git a/src/tui/hooks/useBricks.tsx b/src/tui/hooks/useBricks.tsx new file mode 100644 index 0000000..481ec2d --- /dev/null +++ b/src/tui/hooks/useBricks.tsx @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * useBricks — fetches bricks from a single catalog URL or all enabled catalogs. + * When catalogUrl is undefined, fetches and aggregates all enabled sources. + */ + +import type { AggregatedBrick } from '@focus-mcp/core'; +import { + aggregateCatalogs, + createDefaultStore, + fetchAllCatalogs, + getEnabledSources, + parseCatalogStore, +} from '@focus-mcp/core'; +import { useEffect, useState } from 'react'; +import { FilesystemCatalogStoreAdapter } from '../../adapters/catalog-store-adapter.ts'; +import { HttpFetchAdapter } from '../../adapters/http-fetch-adapter.ts'; + +async function resolveUrls( + catalogUrl: string | undefined, + storeIO: FilesystemCatalogStoreAdapter, +): Promise { + if (catalogUrl !== undefined) { + return [catalogUrl]; + } + const raw = await storeIO.readStore(); + let store = parseCatalogStore(raw); + if (store.sources.length === 0) { + store = createDefaultStore(); + } + return getEnabledSources(store).map((s) => s.url); +} + +export function useBricks(catalogUrl?: string): { + readonly bricks: AggregatedBrick[]; + readonly loading: boolean; + readonly error: string | null; +} { + const [bricks, setBricks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchIO = new HttpFetchAdapter(); + const storeIO = new FilesystemCatalogStoreAdapter(); + + const load = async (): Promise => { + try { + const urls = await resolveUrls(catalogUrl, storeIO); + const { results, errors: fetchErrors } = await fetchAllCatalogs(fetchIO, urls); + if (fetchErrors.length > 0 && results.length === 0) { + setError( + `Failed to fetch catalog(s): ${fetchErrors.map((e) => e.error).join(', ')}`, + ); + return; + } + const agg = aggregateCatalogs(results); + setBricks([...agg.bricks]); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + void load(); + }, [catalogUrl]); + + return { bricks, loading, error }; +} diff --git a/src/tui/hooks/useCatalogs.tsx b/src/tui/hooks/useCatalogs.tsx new file mode 100644 index 0000000..6ceff9a --- /dev/null +++ b/src/tui/hooks/useCatalogs.tsx @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * useCatalogs — loads registered catalog sources from ~/.focus/catalogs.json. + * Falls back to the default store if the file is missing or empty. + */ + +import type { CatalogSource } from '@focus-mcp/core'; +import { createDefaultStore, parseCatalogStore } from '@focus-mcp/core'; +import { useEffect, useState } from 'react'; +import { FilesystemCatalogStoreAdapter } from '../../adapters/catalog-store-adapter.ts'; + +export interface CatalogEntry extends CatalogSource { + readonly brickCount?: number; +} + +export function useCatalogs(): { + readonly catalogs: CatalogEntry[]; + readonly loading: boolean; + readonly error: string | null; +} { + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const adapter = new FilesystemCatalogStoreAdapter(); + adapter + .readStore() + .then((raw) => { + if (cancelled) return; + try { + const store = parseCatalogStore(raw); + const sources = + store.sources.length > 0 ? store.sources : createDefaultStore().sources; + setCatalogs([...sources]); + } catch { + setCatalogs([...createDefaultStore().sources]); + } + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setCatalogs([...createDefaultStore().sources]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return { catalogs, loading, error }; +} diff --git a/src/tui/hooks/useInstalled.tsx b/src/tui/hooks/useInstalled.tsx new file mode 100644 index 0000000..27ac8a5 --- /dev/null +++ b/src/tui/hooks/useInstalled.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * useInstalled — reads the set of installed brick names from ~/.focus/center.json. + * Exposes a `refresh()` method to trigger a reload. + */ + +import { parseCenterJson } from '@focus-mcp/core'; +import { useCallback, useEffect, useState } from 'react'; +import { NpmInstallerAdapter } from '../../adapters/npm-installer-adapter.ts'; + +export function useInstalled(): { + readonly installed: Set; + readonly loading: boolean; + readonly refresh: () => void; +} { + const [installed, setInstalled] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [version, setVersion] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: version is an intentional refresh trigger + useEffect(() => { + setLoading(true); + const adapter = new NpmInstallerAdapter(); + adapter + .readCenterJson() + .then((raw) => { + try { + const center = parseCenterJson(raw); + setInstalled(new Set(Object.keys(center.bricks))); + } catch { + setInstalled(new Set()); + } + }) + .catch(() => setInstalled(new Set())) + .finally(() => setLoading(false)); + }, [version]); + + const refresh = useCallback(() => setVersion((v) => v + 1), []); + + return { installed, loading, refresh }; +} diff --git a/src/tui/screens/BrickDetailsScreen.tsx b/src/tui/screens/BrickDetailsScreen.tsx new file mode 100644 index 0000000..4fbf3bb --- /dev/null +++ b/src/tui/screens/BrickDetailsScreen.tsx @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * BrickDetailsScreen — full information about a selected brick. + * Shows name, version, description, catalog, tags, tools, and install status. + * Supports i (install), u (uninstall), Esc (back). + * The help overlay is managed by App and passed via showHelp prop. + */ + +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useState } from 'react'; +import { FilesystemCatalogStoreAdapter } from '../../adapters/catalog-store-adapter.ts'; +import { HttpFetchAdapter } from '../../adapters/http-fetch-adapter.ts'; +import { NpmInstallerAdapter } from '../../adapters/npm-installer-adapter.ts'; +import { addCommand } from '../../commands/add.ts'; +import { removeCommand } from '../../commands/remove.ts'; +import { useBricks } from '../hooks/useBricks.tsx'; +import { useInstalled } from '../hooks/useInstalled.tsx'; + +interface BrickDetailsScreenProps { + readonly brickName: string; + readonly catalogUrl: string; + readonly onBack: () => void; + readonly showHelp: boolean; +} + +type ActionStatus = + | { state: 'idle' } + | { state: 'installing' } + | { state: 'uninstalling' } + | { state: 'success'; message: string } + | { state: 'error'; error: string }; + +const MAX_TOOLS_DETAIL = 10; + +function buildIO() { + return { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; +} + +export function BrickDetailsScreen({ + brickName, + catalogUrl, + onBack, + showHelp: _showHelp, +}: BrickDetailsScreenProps): React.ReactElement { + const { bricks, loading } = useBricks(catalogUrl); + const { installed, refresh } = useInstalled(); + const [actionStatus, setActionStatus] = useState({ state: 'idle' }); + + async function performInstall(): Promise { + setActionStatus({ state: 'installing' }); + try { + const message = await addCommand({ brickName, io: buildIO() }); + setActionStatus({ state: 'success', message }); + setTimeout(() => { + refresh(); + setActionStatus({ state: 'idle' }); + }, 1500); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + setActionStatus({ state: 'error', error }); + } + } + + async function performUninstall(): Promise { + setActionStatus({ state: 'uninstalling' }); + try { + const message = await removeCommand({ + brickName, + io: { installer: new NpmInstallerAdapter() }, + }); + setActionStatus({ state: 'success', message }); + setTimeout(() => { + refresh(); + setActionStatus({ state: 'idle' }); + }, 1500); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + setActionStatus({ state: 'error', error }); + } + } + + useInput((input, key) => { + if (key.escape) onBack(); + if (actionStatus.state !== 'idle') return; + const isInstalled = installed.has(brickName); + if (input === 'i' && !isInstalled) { + void performInstall(); + } + if (input === 'u' && isInstalled) { + void performUninstall(); + } + }); + + if (loading) return Loading...; + + const brick = bricks.find((b) => b.name === brickName); + if (brick === undefined) { + return ( + + {`Brick "${brickName}" not found.`} + Press Esc to go back + + ); + } + + const isInstalled = installed.has(brickName); + const visibleTools = brick.tools.slice(0, MAX_TOOLS_DETAIL); + const remainingTools = brick.tools.length - MAX_TOOLS_DETAIL; + + return ( + + + + {brick.name} + + {`v${brick.version}`} + {isInstalled ? ( + {'● installed'} + ) : ( + {'○ not installed'} + )} + + + {brick.description} + + + Catalog: + {brick.catalogUrl} + + {brick.tools.length > 0 && ( + + Tools: + {visibleTools.map((t) => ( + {` • ${t.name} — ${t.description}`} + ))} + {remainingTools > 0 && ( + {` + ${String(remainingTools)} more`} + )} + + )} + {brick.tags !== undefined && brick.tags.length > 0 && ( + + Tags: + {brick.tags.join(', ')} + + )} + {actionStatus.state === 'installing' && ( + + {`Installing... @focus-mcp/brick-${brickName}`} + + )} + {actionStatus.state === 'uninstalling' && ( + + {`Uninstalling... @focus-mcp/brick-${brickName}`} + + )} + {actionStatus.state === 'success' && ( + + {`✓ ${actionStatus.message}`} + + )} + {actionStatus.state === 'error' && ( + + {`✗ ${actionStatus.error}`} + + )} + + + {isInstalled + ? '[u] uninstall Esc back ? help' + : '[i] install Esc back ? help'} + + + + ); +} diff --git a/src/tui/screens/BricksScreen.tsx b/src/tui/screens/BricksScreen.tsx new file mode 100644 index 0000000..d61207d --- /dev/null +++ b/src/tui/screens/BricksScreen.tsx @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * BricksScreen — split-panel view: left list (60%) + right preview (40%). + * Real-time preview updates as user navigates. + * Supports / (search), i (install), u (uninstall), ? (help), Enter (focus details). + */ + +import type { AggregatedBrick } from '@focus-mcp/core'; +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useState } from 'react'; +import { FilesystemCatalogStoreAdapter } from '../../adapters/catalog-store-adapter.ts'; +import { HttpFetchAdapter } from '../../adapters/http-fetch-adapter.ts'; +import { NpmInstallerAdapter } from '../../adapters/npm-installer-adapter.ts'; +import { addCommand } from '../../commands/add.ts'; +import { removeCommand } from '../../commands/remove.ts'; +import { BrickPreview } from '../components/BrickPreview.tsx'; +import { HelpOverlay } from '../components/HelpOverlay.tsx'; +import { List } from '../components/List.tsx'; +import { SearchBar } from '../components/SearchBar.tsx'; +import { useBricks } from '../hooks/useBricks.tsx'; +import { useInstalled } from '../hooks/useInstalled.tsx'; + +interface BricksScreenProps { + readonly catalogUrl?: string; + readonly onOpen: (brickName: string, catalogUrl: string) => void; + readonly onBack: () => void; + readonly showHelp: boolean; +} + +type ActionStatus = + | { state: 'idle' } + | { state: 'installing'; brickName: string } + | { state: 'uninstalling'; brickName: string } + | { state: 'success'; message: string } + | { state: 'error'; error: string }; + +function buildIO() { + return { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; +} + +function filterBricks( + bricks: readonly T[], + query: string, +): T[] { + if (query.trim().length === 0) return [...bricks]; + const q = query.toLowerCase(); + return bricks.filter( + (b) => b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q), + ); +} + +function buildListItem(b: AggregatedBrick, isInstalled: boolean) { + const indicator = isInstalled ? '● installed' : '○ '; + const desc = b.description.length > 35 ? `${b.description.slice(0, 35)}…` : b.description; + return { + label: `${indicator} ${b.name.padEnd(22)} ${b.version.padEnd(7)} ${desc}`, + value: `${b.name}::${b.catalogUrl}`, + }; +} + +interface ActionFeedbackProps { + readonly actionStatus: ActionStatus; +} + +function ActionFeedback({ actionStatus }: ActionFeedbackProps): React.ReactElement | null { + if (actionStatus.state === 'installing') { + return ( + + {`Installing... @focus-mcp/brick-${actionStatus.brickName}`} + + ); + } + if (actionStatus.state === 'uninstalling') { + return ( + + {`Uninstalling... @focus-mcp/brick-${actionStatus.brickName}`} + + ); + } + if (actionStatus.state === 'success') { + return ( + + {`✓ ${actionStatus.message}`} + + ); + } + if (actionStatus.state === 'error') { + return ( + + {`✗ ${actionStatus.error}`} + + ); + } + return null; +} + +function parseListValue(value: string): { name: string; url: string } | undefined { + const sepIdx = value.indexOf('::'); + if (sepIdx === -1) return undefined; + const name = value.slice(0, sepIdx); + const url = value.slice(sepIdx + 2); + if (name.length === 0 || url.length === 0) return undefined; + return { name, url }; +} + +export function BricksScreen({ + catalogUrl, + onOpen, + onBack, + showHelp, +}: BricksScreenProps): React.ReactElement { + const [query, setQuery] = useState(''); + const [searching, setSearching] = useState(false); + const [cursor, setCursor] = useState(0); + const [actionStatus, setActionStatus] = useState({ state: 'idle' }); + const { bricks, loading, error } = useBricks(catalogUrl); + const { installed, refresh } = useInstalled(); + + const filtered = filterBricks(bricks, query); + const currentBrick = filtered[cursor]; + const currentIsInstalled = currentBrick !== undefined && installed.has(currentBrick.name); + + async function performInstall(brickName: string): Promise { + setActionStatus({ state: 'installing', brickName }); + try { + const message = await addCommand({ brickName, io: buildIO() }); + setActionStatus({ state: 'success', message }); + setTimeout(() => { + refresh(); + setActionStatus({ state: 'idle' }); + }, 1500); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + setActionStatus({ state: 'error', error: errMsg }); + } + } + + async function performUninstall(brickName: string): Promise { + setActionStatus({ state: 'uninstalling', brickName }); + try { + const message = await removeCommand({ + brickName, + io: { installer: new NpmInstallerAdapter() }, + }); + setActionStatus({ state: 'success', message }); + setTimeout(() => { + refresh(); + setActionStatus({ state: 'idle' }); + }, 1500); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + setActionStatus({ state: 'error', error: errMsg }); + } + } + + function handleBrickAction(input: string): void { + if (actionStatus.state !== 'idle' || currentBrick === undefined) return; + const isInstalled = installed.has(currentBrick.name); + if (input === 'i' && !isInstalled) void performInstall(currentBrick.name); + if (input === 'u' && isInstalled) void performUninstall(currentBrick.name); + } + + useInput((input, key) => { + if (searching) return; + if (input === '/') { + setSearching(true); + return; + } + if (key.escape) { + onBack(); + return; + } + handleBrickAction(input); + }); + + if (loading) return Loading bricks...; + if (error !== null) return {`Error: ${error}`}; + + const items = filtered.map((b) => buildListItem(b, installed.has(b.name))); + + return ( + + {searching && ( + setSearching(false)} + onCancel={() => { + setSearching(false); + setQuery(''); + }} + /> + )} + + + { + const parsed = parseListValue(value); + if (parsed !== undefined) onOpen(parsed.name, parsed.url); + }} + /> + + + {`${String(filtered.length)} brick(s)${query.length > 0 ? ` matching "${query}"` : ''}`} + + + + + + + {showHelp && } + + {currentIsInstalled ? '[u] uninstall' : '[i] install'} + {'[Enter] details [/] search Esc back ? help'} + + + ); +} diff --git a/src/tui/screens/CatalogsScreen.tsx b/src/tui/screens/CatalogsScreen.tsx new file mode 100644 index 0000000..186cbad --- /dev/null +++ b/src/tui/screens/CatalogsScreen.tsx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * CatalogsScreen — lists all registered catalog sources plus an aggregate view entry. + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; +import { List } from '../components/List.tsx'; +import { useCatalogs } from '../hooks/useCatalogs.tsx'; + +interface CatalogsScreenProps { + readonly onOpen: (catalogUrl?: string, catalogName?: string) => void; +} + +export function CatalogsScreen({ onOpen }: CatalogsScreenProps): React.ReactElement { + const { catalogs, loading, error } = useCatalogs(); + + if (loading) { + return Loading catalogs...; + } + + if (error !== null) { + return Error: {error}; + } + + const totalBricks = catalogs.reduce((sum, c) => sum + (c.brickCount ?? 0), 0); + + const items = [ + ...catalogs.map((c) => ({ + label: `${c.enabled ? '🟢' : '🔴'} ${c.name.padEnd(30)} ${String(c.brickCount ?? 0).padEnd(6)} bricks ${c.enabled ? 'active' : 'disabled'}`, + value: `${c.url}::${c.name}`, + })), + { + label: `📊 Aggregate view ${String(totalBricks).padEnd(6)} bricks all active`, + value: '__aggregate__', + }, + ]; + + return ( + + { + if (value === '__aggregate__') { + onOpen(undefined, 'All Catalogs'); + return; + } + const sepIdx = value.indexOf('::'); + if (sepIdx === -1) { + onOpen(value, undefined); + return; + } + const url = value.slice(0, sepIdx); + const name = value.slice(sepIdx + 2); + onOpen(url, name.length > 0 ? name : undefined); + }} + /> + + {`${String(catalogs.length)} catalog(s) registered`} + + + ); +} diff --git a/tsconfig.json b/tsconfig.json index f69e13e..a596b37 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,32 +1,34 @@ { - "$schema": "https://json.schemastore.org/tsconfig.json", - "compilerOptions": { - "target": "ES2023", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2023"], - "types": ["node"], + "$schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "jsx": "react-jsx", + "jsxImportSource": "react", - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "useUnknownInCatchVariables": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "allowImportingTsExtensions": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"], - "exclude": ["**/node_modules", "**/dist", "**/coverage"] + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["**/node_modules", "**/dist", "**/coverage"] } diff --git a/tsup.config.ts b/tsup.config.ts index 9cbf54a..ed258a5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,28 +1,42 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT +import { readFileSync } from 'node:fs'; import { defineConfig } from 'tsup'; +const cliPkg = JSON.parse(readFileSync('package.json', 'utf-8')) as { version: string }; +const corePkg = JSON.parse(readFileSync('../core/packages/core/package.json', 'utf-8')) as { + version: string; +}; + export default defineConfig({ - entry: { - index: 'src/index.ts', - 'bin/focus': 'src/bin/focus.ts', - }, - format: ['esm'], - target: 'node22', - platform: 'node', - // @focusmcp/core is consumed locally via a file: dep at build time. - // We bundle it into dist so the published tarball is self-contained - // and end users don't have to install @focusmcp/core themselves. - noExternal: ['@focusmcp/core'], - // Only the programmatic API emits .d.ts; the binary doesn't need types. - dts: { - entry: { index: 'src/index.ts' }, - }, - sourcemap: true, - clean: true, - splitting: false, - treeshake: true, - minify: false, - outDir: 'dist', + entry: { + index: 'src/index.ts', + 'bin/focus': 'src/bin/focus.ts', + }, + format: ['esm'], + target: 'node22', + platform: 'node', + esbuildOptions(options) { + options.jsx = 'automatic'; + options.jsxImportSource = 'react'; + }, + // @focus-mcp/core is consumed locally via a file: dep at build time. + // We bundle it into dist so the published tarball is self-contained + // and end users don't have to install @focus-mcp/core themselves. + noExternal: ['@focus-mcp/core'], + // Only the programmatic API emits .d.ts; the binary doesn't need types. + dts: { + entry: { index: 'src/index.ts' }, + }, + define: { + 'process.env.CLI_VERSION': JSON.stringify(cliPkg.version), + 'process.env.CORE_VERSION': JSON.stringify(corePkg.version), + }, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true, + minify: false, + outDir: 'dist', });