diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
deleted file mode 100644
index c0599d5..0000000
--- a/.agents/plugins/marketplace.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "caplets",
- "interface": {
- "displayName": "Caplets"
- },
- "plugins": [
- {
- "name": "caplets",
- "source": {
- "source": "local",
- "path": "./plugins/caplets"
- },
- "policy": {
- "installation": "AVAILABLE",
- "authentication": "ON_INSTALL"
- },
- "category": "Developer Tools"
- }
- ]
-}
diff --git a/.caplets/.gitignore b/.caplets/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/.caplets/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/.changeset/remote-attach-agent-integrations.md b/.changeset/remote-attach-agent-integrations.md
new file mode 100644
index 0000000..65b5674
--- /dev/null
+++ b/.changeset/remote-attach-agent-integrations.md
@@ -0,0 +1,8 @@
+---
+"@caplets/core": minor
+"caplets": minor
+"@caplets/opencode": minor
+"@caplets/pi": minor
+---
+
+Make `caplets attach` the remote-backed MCP server command, add Cloud-aware `CAPLETS_MODE` resolution, keep OpenCode and Pi on the shared resolver, and remove Codex/Claude plugin artifacts in favor of manual MCP configuration.
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
deleted file mode 100644
index b90d2ff..0000000
--- a/.claude-plugin/marketplace.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "caplets",
- "owner": {
- "name": "Spirit-Led Software LLC",
- "url": "https://github.com/spiritledsoftware"
- },
- "plugins": [
- {
- "name": "caplets",
- "source": "./plugins/caplets",
- "description": "Expose configured Caplets as progressive-disclosure tools in Claude Code.",
- "category": "Developer Tools",
- "author": {
- "name": "Spirit-Led Software LLC",
- "url": "https://github.com/spiritledsoftware",
- "email": "ianpascoe@spiritledsoftware.com"
- },
- "homepage": "https://github.com/spiritledsoftware/caplets"
- }
- ]
-}
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 0246cf9..0000000
--- a/.env.example
+++ /dev/null
@@ -1,6 +0,0 @@
-ALCHEMY_PASSWORD=
-ALCHEMY_STATE_TOKEN=
-
-CLOUDFLARE_API_TOKEN=
-CLOUDFLARE_ACCOUNT_ID=
-CLOUDFLARE_EMAIL=
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 0561586..9d82c4d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -44,6 +44,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run quality gates
+ run: pnpm verify
+
+ - name: Deploy landing page
run: pnpm run alchemy:deploy
env:
ALCHEMY_STAGE: prod
diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml
index d8729ea..dc833ce 100644
--- a/.github/workflows/pr-preview-deploy.yml
+++ b/.github/workflows/pr-preview-deploy.yml
@@ -48,6 +48,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Run quality gates
+ if: ${{ github.event.action != 'closed' }}
+ run: pnpm verify
+
- name: Deploy preview
if: ${{ github.event.action != 'closed' }}
run: pnpm run alchemy:deploy
diff --git a/.gitignore b/.gitignore
index 7c7430c..d7aba94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ benchmark-results/
# Turbo
.turbo/
+.astro/
# Pi
.pi-lens/
diff --git a/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md b/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md
new file mode 100644
index 0000000..ede93b7
--- /dev/null
+++ b/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md
@@ -0,0 +1,108 @@
+---
+target: cloud ui
+total_score: 22
+p0_count: 1
+p1_count: 2
+timestamp: 2026-05-31T12-08-49Z
+slug: apps-cloud-ui-src-routes-workspace-tsx
+---
+
+# Caplets Cloud UI Critique
+
+## Design Health Score
+
+| # | Heuristic | Score | Key Issue |
+| --------- | ------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Visibility of System Status | 2 | The live app shows an error state, but successful workspace health is split across status, presence, receipts, and audit without a clear current state summary. |
+| 2 | Match System / Real World | 3 | The product language is credible for MCP and Caplets users, but terms such as local presence, sandbox leases, and implicit apply arrive before enough operational framing. |
+| 3 | User Control and Freedom | 2 | Primary actions are mostly disabled or copy-only, and there is no visible way to retry, reconnect, authorize, revoke, or inspect details from the error/current states. |
+| 4 | Consistency and Standards | 3 | Components are visually consistent and restrained, though every panel uses the same eyebrow/title/card rhythm. |
+| 5 | Error Prevention | 2 | The UI explains OAuth-only and local presence concepts, but does not prevent dead-end states such as disabled Add connector and Run caplets doctor buttons. |
+| 6 | Recognition Rather Than Recall | 2 | Users must infer how Endpoint, Connectors, Runtime, Presence, Receipts, and Audit relate to the setup path. |
+| 7 | Flexibility and Efficiency | 2 | The dashboard lacks compact drill-downs, filters, copyable commands beyond the endpoint, or direct remediation actions for power users. |
+| 8 | Aesthetic and Minimalist Design | 3 | The palette and density fit Caplets, but the grid background and repeated panel pattern flatten the page into a static spec sheet. |
+| 9 | Error Recovery | 1 | The live error exposes a browser implementation message and gives no retry, fallback, or useful next step. |
+| 10 | Help and Documentation | 2 | Copy is precise, but contextual help is too sparse for high-risk setup concepts like project-bound execution and apply receipts. |
+| **Total** | | **22/40** | **Needs focused product polish** |
+
+## Anti-Patterns Verdict
+
+**LLM assessment**: This does not look like generic AI SaaS. It avoids hero metrics, gradient text, glass, huge rounded cards, and neon developer theater. The slop risk is subtler: repeated panel headers, disabled buttons, and a dashboard made from similarly weighted boxes make the workspace feel mocked rather than operated.
+
+**Deterministic scan**: CLI detector over `apps/cloud-ui/src` returned `[]`. Browser overlay on the live error state reported one `hero-eyebrow-chip` finding for `Workspace unavailable` above `Could not load workspace`. That is a fair warning because the error card inherits the same label treatment used elsewhere, making failure feel like another marketing/info panel instead of a recovery state.
+
+**Visual overlays**: Injection succeeded in the browser session after the live app loaded, and the console reported the eyebrow-chip issue on the error page. The overlay could only evaluate the error state because the app failed before the workspace UI rendered.
+
+## Overall Impression
+
+The intended direction is right: quiet, technical, dense, and specific to Caplets Cloud. The problem is that the running UI currently fails before users can reach it, and the designed workspace state does not yet behave like a control plane. It communicates the product thesis, but it does not yet help a developer complete setup, diagnose availability, or recover from failure.
+
+## What's Working
+
+- The visual register is aligned with Caplets: warm light surfaces, restrained ember, compact type, thin borders, and no theatrical dark-mode or generic SaaS polish.
+- The copy has real product proof: `3 visible Caplets`, `106 hidden downstream tools`, OAuth-only hosted MCP, local presence, and sync/apply receipts are concrete.
+- The information model includes the right control-plane objects: endpoint, connector catalog, tool surface report, runtime status, local presence, receipts, and audit trail.
+
+## Priority Issues
+
+### [P0] Live workspace route fails before showing the dashboard
+
+**Why it matters**: A user sees `Failed to execute fetch on Window: Illegal invocation` instead of the workspace. That destroys trust and prevents evaluation of every other design decision.
+
+**Fix**: Bind or wrap the default fetch implementation in `CloudApiClient` instead of storing the unbound browser `fetch`, then render a product-safe error only for real API failures. Add retry and a fallback diagnostic line that says which workspace/API URL failed.
+
+**Suggested command**: `$impeccable harden cloud ui`
+
+### [P1] Error recovery is raw, non-actionable, and visually under-prioritized
+
+**Why it matters**: Cloud setup failures are expected: auth, workspace creation, API reachability, local presence, connector auth. The current error state exposes implementation text and gives no path forward.
+
+**Fix**: Replace raw exception copy with a recovery panel: failed target, retry button, sign in/authorize action when relevant, workspace slug, API base URL, and a compact details disclosure for developer diagnostics.
+
+**Suggested command**: `$impeccable harden cloud ui`
+
+### [P1] The successful workspace state reads like a static product proof, not an operational workflow
+
+**Why it matters**: The page proves the thesis but does not guide the user through the next action. Disabled Add connector and Run caplets doctor controls signal unfinished product rather than unavailable state.
+
+**Fix**: Promote a setup/status rail above the panels: Connect MCP client, Add or authorize connector, Start local presence, Verify tool surface. Disabled actions need reasons and adjacent enabled alternatives, such as copy CLI command or open OAuth flow.
+
+**Suggested command**: `$impeccable onboard cloud ui`
+
+### [P2] Panel hierarchy is too even
+
+**Why it matters**: Endpoint, connectors, surface report, runtime, presence, receipts, and audit all compete at similar weight. Users cannot quickly tell what needs attention now.
+
+**Fix**: Make the endpoint/current health area the primary operational header, use a compact two-column health summary, and demote receipts/audit into tabs or collapsible inspection sections. Keep connectors as the main editable surface.
+
+**Suggested command**: `$impeccable layout cloud ui`
+
+### [P2] State cues rely too much on color and terse labels
+
+**Why it matters**: `Ready`, `Needs OAuth`, `Local required`, `presence required`, and colored dots are meaningful only after the user already understands the system.
+
+**Fix**: Pair every status with an icon/shape and a one-line action or reason. Example: `Needs OAuth: authorize Sourcegraph to expose this Caplet`. Use warning styling for actual action-required states, not just a yellow dot.
+
+**Suggested command**: `$impeccable clarify cloud ui`
+
+## Persona Red Flags
+
+**Morgan, agent power user**: They want to connect the endpoint and verify what agents will see. They can copy the endpoint, but cannot inspect the exact capability cards, simulate a client session, filter hidden tools, or see why a project-bound Caplet is unavailable.
+
+**Riley, first-time cloud user**: They land on an implementation error in the live app. Even in the intended state, `local presence`, `project-bound remote execution`, and `implicit apply pending` appear without enough action framing. The next step is unclear.
+
+**Sam, security-conscious team lead**: OAuth-only is visible, and audit exists, but there is no obvious token/session revocation, connector credential boundary, local presence revocation, or policy blocker detail. Trust signals are present but not inspectable enough.
+
+## Minor Observations
+
+- The global grid background is tasteful but constant. On long product surfaces it adds visual texture without improving scanability.
+- The disabled buttons need `aria-describedby` reasons or inline explanatory text.
+- The sidebar nav has no active section state, so it works as a table of contents rather than workspace navigation.
+- The audit table is readable, but on mobile it becomes horizontal scroll. A stacked event list would be more usable below 640px.
+- `Tool Surface Report` has one duplicated concept: `106 hidden downstream tools` appears in both `dt` and `dd`.
+
+## Questions to Consider
+
+- What is the one setup state Caplets Cloud should make impossible to miss?
+- Should the first screen optimize for connecting an MCP client, authorizing connectors, or proving tool-surface reduction?
+- Which trust details need to be inspectable before a developer lets project-bound execution touch a local repo?
diff --git a/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md b/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md
new file mode 100644
index 0000000..117f870
--- /dev/null
+++ b/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md
@@ -0,0 +1,106 @@
+---
+target: cloud UI
+total_score: 22
+p0_count: 1
+p1_count: 2
+timestamp: 2026-05-31T12-57-16Z
+slug: apps-cloud-ui-src-routes-workspace-tsx
+---
+
+# Design Health Score
+
+| # | Heuristic | Score | Key Issue |
+| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| 1 | Visibility of System Status | 1 | The intended UI has status panels, but the live page is replaced by a Vite import error. |
+| 2 | Match System / Real World | 3 | Developer-tool concepts mostly map well, though `local presence`, `sync/apply receipts`, and `tool surface` need more user-facing framing. |
+| 3 | User Control and Freedom | 2 | Copy, retry, and anchor navigation exist, but there is no clear active route, current step control, or way back from blocked setup states. |
+| 4 | Consistency and Standards | 3 | The component vocabulary is restrained and consistent, but the repeated panel-header pattern flattens sections. |
+| 5 | Error Prevention | 1 | Missing modules block rendering, and status copy can imply health even while required setup is incomplete. |
+| 6 | Recognition Rather Than Recall | 3 | The setup path, metrics, and command callouts reduce recall, but several commands are detached from their exact next action. |
+| 7 | Flexibility and Efficiency | 2 | Sidebar anchors and copy buttons help, but there are no power-user shortcuts, filters, or compact inspection affordances. |
+| 8 | Aesthetic and Minimalist Design | 3 | Quiet, usable product styling, with some panel sameness and cool-token drift from the stated design system. |
+| 9 | Error Recovery | 2 | Source includes a recovery panel, retry, and diagnostics, but the actual browser error bypasses it entirely. |
+| 10 | Help and Documentation | 2 | Inline instructions exist, but the UI lacks contextual help for local presence, connector authorization, and audit interpretation. |
+| **Total** | | **22/40** | **Promising, currently blocked** |
+
+# Anti-Patterns Verdict
+
+**LLM assessment**: This does not read as instant AI slop. It has a credible developer-tool shell, restrained borders, compact typography, and useful non-color status shapes. The weak spots are product-specific: too many equal panels, repeated eyebrow-plus-title headers, and a first screen that describes the system more than it drives the next task.
+
+**Deterministic scan**: `detect.mjs --json apps/cloud-ui/src/routes/workspace.tsx apps/cloud-ui/src/components apps/cloud-ui/src/styles/global.css` returned `[]`. No bundled detector findings.
+
+**Visual overlays**: No reliable user-visible overlay is available. The Vite page fails before the app renders because `src/routes/workspace.tsx` cannot resolve `../lib/cloud-api`; browser evidence is the Vite import-analysis overlay, not the intended UI.
+
+# Overall Impression
+
+The design direction is right for Caplets Cloud: calm, technical, inspectable. The app shell wants to be a focused cloud workspace dashboard, not a marketing page, and the source mostly honors that. The biggest opportunity is to make the first screen operational: one current blocker, one primary next action, then inspectable detail.
+
+# What's Working
+
+1. The visual system is product-appropriate. The sidebar, flat panels, tight radii, visible focus style, and reduced-motion handling support a serious developer tool.
+2. The source includes real state thinking: loading, error, empty, ready, connector status, local presence, sync receipts, runtime rows, and audit rows.
+3. Copy is mostly concrete. Labels like `Copy endpoint`, `Retry workspace load`, and `Open OAuth metadata` describe actions rather than vague confirmations.
+
+# Priority Issues
+
+**[P0] The cloud UI does not render**
+
+**Why it matters**: A user never reaches the workspace dashboard. Vite shows `[plugin:vite:import-analysis] Failed to resolve import "../lib/cloud-api" from "src/routes/workspace.tsx"` at `apps/cloud-ui/src/routes/workspace.tsx:16`.
+
+**Fix**: Restore or create `apps/cloud-ui/src/lib/cloud-api.ts` and `apps/cloud-ui/src/lib/mock-workspace.ts`, then make `pnpm --filter @caplets/cloud-ui typecheck` and the Vite route pass before any visual polish.
+
+**Suggested command**: `$impeccable harden cloud UI`
+
+**[P1] The first screen lacks a single next step**
+
+**Why it matters**: The UI presents endpoint copy, a four-step setup rail, connectors, runtime, local presence, receipts, and audit as peers. A first-time workspace owner has to infer which blocker matters now.
+
+**Fix**: Promote a state-driven "Next action" area directly under the header. When Sourcegraph needs OAuth, make that the primary action. When local presence is missing, make the doctor command the primary action. Move completed proof into compact secondary detail.
+
+**Suggested command**: `$impeccable layout cloud UI`
+
+**[P1] Sidebar health can contradict workspace blockers**
+
+**Why it matters**: The sidebar says `OAuth active` and `tool surface verified` while the setup rail can still show `Authorize connector` and `Start local presence` as needing action. That undermines trust in the status model.
+
+**Fix**: Replace the static sidebar health block with a summarized blocker count and the current highest-severity state. Add active section styling to the sidebar anchors so navigation communicates location.
+
+**Suggested command**: `$impeccable clarify cloud UI`
+
+**[P2] Panel hierarchy is too even**
+
+**Why it matters**: Connector catalog, tool surface, runtime, presence, receipts, and audit all use the same bordered panel language. The user cannot tell which panels are operational, which are proof, and which are historical.
+
+**Fix**: Create three section weights: primary task, live status, and audit/history. Give the task panel stronger action placement, make live status denser, and let audit/history become quieter table/detail content.
+
+**Suggested command**: `$impeccable distill cloud UI`
+
+**[P2] Design tokens drift from the documented warmth**
+
+**Why it matters**: `DESIGN.md` describes ember, parchment, charred ink, and warm technical surfaces. The CSS tokens use cool hue values for parchment, linen, paper, ash, and focus. The result risks reading as generic cool admin UI rather than Caplets' owned warm map language.
+
+**Fix**: Bring the CSS OKLCH hues back toward the documented palette, keep ember rare, and reserve cool tones only where they carry specific state meaning.
+
+**Suggested command**: `$impeccable colorize cloud UI`
+
+# Persona Red Flags
+
+**Alex, agent power user**: Alex cannot use the UI at all until the missing imports are fixed. Once rendered, the equal panel grid and missing active nav state slow scanning. The audit table has no filter or search, so repeated operational checks become manual.
+
+**Jordan, first-time connector setup user**: Jordan sees a slogan, endpoint, setup path, connector catalog, local presence, and audit trail, but no single "do this now" control. `Use connector setup to authorize Sourcegraph` is instructional text, not an actionable button.
+
+**Sam, security-conscious workspace admin**: Sam needs a trustworthy status summary. Static `OAuth active` sidebar copy conflicts with visible warning states, and the audit table is readable but lacks severity, actor, filtering, or export affordances.
+
+# Minor Observations
+
+- The loading state is a centered panel, not a skeleton, so it does not prepare the user for the workspace layout.
+- `Copy endpoint` succeeds through a screen-reader-only live region, but visual users get no visible confirmation.
+- `runtimeReasons[row.label]` can render empty explanatory text if a new runtime row label appears.
+- The `ConnectorList` status lookup is repeated several times per row; deriving it once would reduce fragility.
+- The mobile table conversion is thoughtful, but the audit rows need stronger labels or grouping when they become cards.
+
+# Questions To Consider
+
+1. What is the one action a workspace owner should take after opening this page when two blockers exist?
+2. Should Caplets Cloud sell the concept on this screen, or assume the user is already here to configure and verify?
+3. Which state should the sidebar summarize: authentication, connector readiness, local presence, or overall workspace health?
diff --git a/.lintstagedrc.json b/.lintstagedrc.json
index 69abf9b..ed97602 100644
--- a/.lintstagedrc.json
+++ b/.lintstagedrc.json
@@ -1,3 +1,4 @@
{
- "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"]
+ "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": "oxfmt --check",
+ "*.{js,jsx,ts,tsx,mjs,cjs}": "oxlint"
}
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 2f61360..17a8580 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
- "ignorePatterns": [".brv/"],
+ "ignorePatterns": ["**/.brv/**"],
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 627f137..5c839c4 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
- "ignorePatterns": [".brv/"],
+ "ignorePatterns": ["**/.brv/**"],
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
diff --git a/AGENTS.md b/AGENTS.md
index 84e2ebd..e8ce1a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Commands
-- Use `pnpm` only; the repo pins `pnpm@11.0.9` and requires Node `>=22` while CI runs Node 24.
+- Use `pnpm` only; the repo pins `pnpm@11.5.0` and requires Node `>=24`.
- Install with `pnpm install --frozen-lockfile` when matching CI.
- Full local gate and pre-push hook: `pnpm verify` (`format:check -> lint -> typecheck -> schema:check -> test -> benchmark:check -> build`).
- Fast focused checks: `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`.
@@ -36,3 +36,4 @@
- CI runs `pnpm verify` plus `pnpm changeset status --since=origin/main` on PRs unless the PR has the `no changeset` label.
- User-facing package changes usually need a changeset; current versioning is handled by Changesets and `pnpm version-packages`/`pnpm release`.
- Pre-commit only runs `pnpm lint-staged` (`oxfmt --check` and `oxlint` on staged JS/TS/config/docs files); pre-push runs the full `pnpm verify`.
+- Core Alchemy deploys the public landing page only from `apps/landing`; it does not deploy Cloud Worker or dashboard paths.
diff --git a/README.md b/README.md
index 2ac9e53..19e5d92 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-

+
Caplets
@@ -12,7 +12,7 @@

-

+
@@ -47,7 +47,7 @@ In the deterministic benchmark, 106 flat tools became 3 top-level capabilities w
## Quick Start
-Caplets requires Node.js 22 or newer.
+Caplets requires Node.js 24 or newer.
```sh
npm install -g caplets
@@ -134,33 +134,85 @@ Completions include command names, options, common enum values, configured Caple
Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows.
-## Agent Plugins
+## Agent Integrations
Use Caplets as a normal MCP server everywhere, or install a native agent integration when
your coding agent supports one.
-| Agent | Install | What It Provides |
-| -------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
-| Any MCP client | Add `caplets serve` as a stdio MCP server | Universal progressive-disclosure gateway |
-| Claude Code | `claude plugin marketplace add spiritledsoftware/caplets && claude plugin install caplets@caplets` | Claude Code plugin metadata, MCP config, and shared skill guidance |
-| Codex | `codex plugin marketplace add spiritledsoftware/caplets`, then install `caplets` from Codex plugins | Codex plugin metadata, MCP config, and shared skill guidance |
-| OpenCode | Install [`@caplets/opencode`](packages/opencode/README.md) | Native `caplets_` tools and prompt guidance hooks |
-| Pi | Install [`@caplets/pi`](packages/pi/README.md) | Native `caplets_` tools with Pi prompt snippets/guidelines |
+| Agent | Install | What It Provides |
+| -------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
+| Any MCP client | Add `caplets serve` or `caplets attach` manually in MCP config | Universal progressive-disclosure gateway |
+| Claude Code | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway |
+| Codex | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway |
+| OpenCode | Install [`@caplets/opencode`](packages/opencode/README.md) | Native `caplets_` tools and prompt guidance hooks |
+| Pi | Install [`@caplets/pi`](packages/pi/README.md) | Native `caplets_` tools with Pi prompt snippets/guidelines |
-Codex and Claude Code plugins are plugin-native but MCP-backed. The installable plugin
-lives under `plugins/caplets/`, with agent-specific manifests in `.codex-plugin/` and
-`.claude-plugin/`, a shared `skills/` directory, and shared `mcp.json` config. The
-repo-level `.agents/plugins/marketplace.json` and `.claude-plugin/marketplace.json`
-files only advertise that installable plugin root.
+Manual local MCP config:
-The Claude Code and Codex commands install from this GitHub repository through each agent's
-plugin marketplace flow; users do not need to clone the repository manually. Plugin MCP
-configs run `caplets serve` directly, so install the Caplets CLI globally first.
+```json
+{
+ "mcpServers": {
+ "caplets": {
+ "command": "caplets",
+ "args": ["serve"]
+ }
+ }
+}
+```
+
+Manual remote or Cloud MCP config:
+
+```json
+{
+ "mcpServers": {
+ "caplets": {
+ "command": "caplets",
+ "args": ["attach"]
+ }
+ }
+}
+```
+
+For Caplets Cloud, authenticate once and set the remote selection environment for the agent:
+
+```sh
+caplets cloud auth login
+export CAPLETS_MODE=cloud
+export CAPLETS_REMOTE_URL=https://cloud.caplets.dev
+```
+
+For a self-hosted remote:
+
+```sh
+export CAPLETS_MODE=remote
+export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets
+export CAPLETS_REMOTE_TOKEN=...
+```
+
+## Core Alchemy
+
+Core Alchemy deploys the public landing page from `apps/landing`. It does not deploy the private Cloud Worker or Cloud dashboard; those belong to the nested Cloud repository.
### Remote Caplets service
OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly.
+Hosted Caplets Cloud uses browser-mediated Cloud Auth:
+
+```sh
+caplets cloud auth login --workspace personal
+caplets cloud auth status
+caplets cloud auth workspaces
+caplets cloud auth switch team
+caplets cloud auth logout
+```
+
+Cloud Auth stores one Selected Workspace locally. `caplets attach --workspace ` must match that saved workspace; switch explicitly before attaching another hosted workspace. Self-hosted remotes continue to use `CAPLETS_REMOTE_URL`, `CAPLETS_REMOTE_TOKEN`, or Basic Auth credentials.
+
+Access tokens are short-lived. The CLI refreshes expired hosted credentials before attach, persists the rotated refresh token returned by Cloud, and treats revoked refresh credentials as a fresh-login requirement. `caplets cloud auth logout` clears local credentials; Cloud logout revokes the refresh credential family so rotated refresh tokens stop working together.
+
+Use `caplets attach --once` for a finite Project Binding smoke test, or `caplets attach` to run a remote-backed MCP server over stdio or HTTP with Project Binding and local overlay.
+
Start a local HTTP service. `--path` is the service base path; Caplets mounts MCP,
control, and health endpoints underneath it:
diff --git a/alchemy.run.ts b/alchemy.run.ts
index ed65db3..efe5d07 100644
--- a/alchemy.run.ts
+++ b/alchemy.run.ts
@@ -3,18 +3,14 @@ import { Astro } from "alchemy/cloudflare";
import { GitHubComment } from "alchemy/github";
import { CloudflareStateStore } from "alchemy/state";
-const globalBaseDomain = "caplets.dev";
+import { buildAlchemyDomains } from "./infra/alchemy-domains.ts";
const app = await alchemy("caplets", {
stateStore: (scope) => new CloudflareStateStore(scope),
password: process.env.ALCHEMY_PASSWORD!,
});
-const baseDomain =
- app.stage === "prod" ? globalBaseDomain : `${app.stage}.preview.${globalBaseDomain}`;
-
-const landingPageDomain = baseDomain;
-const landingPageUrl = `https://${landingPageDomain}`;
+const { landingPageDomain, landingPageUrl } = buildAlchemyDomains(app.stage, { local: app.local });
export const landingPage = await Astro("landing-page", {
cwd: "apps/landing",
dev: {
diff --git a/apps/landing/package.json b/apps/landing/package.json
index f57b542..6842c48 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -15,10 +15,13 @@
"@astrojs/check": "^0.9.9",
"@hugeicons/core-free-icons": "4.2.0",
"@tailwindcss/vite": "^4.3.0",
- "astro": "^6.4.2",
+ "astro": "^6.4.3",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
},
+ "devDependencies": {
+ "vite": "^7.3.5"
+ },
"engines": {
"node": ">=22.12.0"
}
diff --git a/caplets/ast-grep/CAPLET.md b/caplets/ast-grep/CAPLET.md
index 3b5ff0f..8110f23 100644
--- a/caplets/ast-grep/CAPLET.md
+++ b/caplets/ast-grep/CAPLET.md
@@ -6,9 +6,21 @@ tags:
- mcp
- code
- search
+setup:
+ commands:
+ - label: Install ast-grep MCP
+ command: npm
+ args: ["install", "-g", "ast-grep-mcp"]
+ timeoutMs: 120000
+ maxOutputBytes: 200000
+ verify:
+ - label: Check ast-grep MCP
+ command: ast-grep-mcp
+ args: ["--help"]
+ timeoutMs: 10000
+ maxOutputBytes: 20000
mcpServer:
- command: npx
- args: [-y, ast-grep-mcp]
+ command: ast-grep-mcp
---
# ast-grep MCP
@@ -17,6 +29,12 @@ Use this Caplet to expose ast-grep's structural search, scan, rule testing, rewr
The manifest uses the full `ast-grep-mcp` MCP server.
+## Setup
+
+This Caplet installs `ast-grep-mcp` globally with npm, then verifies the installed binary with
+`ast-grep-mcp --help`. Setup is explicit because hosted and local stdio runtimes need a stable
+binary instead of running package-manager downloads during each MCP startup.
+
## Safety
Read-only search, scan, and normal test actions set `readOnlyHint: true`. Apply-all rewrite, snapshot-update, and scaffolding actions set `destructiveHint: true` because they can modify files.
diff --git a/caplets/context7/CAPLET.md b/caplets/context7/CAPLET.md
index 6668d04..341b19d 100644
--- a/caplets/context7/CAPLET.md
+++ b/caplets/context7/CAPLET.md
@@ -7,11 +7,21 @@ tags:
- libraries
- frameworks
- api-reference
+setup:
+ commands:
+ - label: Install Context7 MCP
+ command: npm
+ args: ["install", "-g", "@upstash/context7-mcp"]
+ timeoutMs: 120000
+ maxOutputBytes: 200000
+ verify:
+ - label: Check Context7 MCP
+ command: context7-mcp
+ args: ["--help"]
+ timeoutMs: 10000
+ maxOutputBytes: 20000
mcpServer:
- command: npx
- args:
- - -y
- - "@upstash/context7-mcp"
+ command: context7-mcp
---
# Context7 Documentation
@@ -31,3 +41,9 @@ documentation before writing code or giving technical instructions.
- Prefer primary documentation over snippets when implementation risk is high.
- Record the library or package name clearly before searching.
- Do not use this as a substitute for project-local types and tests.
+
+## Setup
+
+This Caplet installs `@upstash/context7-mcp` globally with npm, then verifies the installed
+`context7-mcp` binary with `--help`. Setup is explicit so hosted and local stdio runtimes start a
+known binary without running `npx` package downloads on every agent session.
diff --git a/caplets/playwright/CAPLET.md b/caplets/playwright/CAPLET.md
index 058838e..d692073 100644
--- a/caplets/playwright/CAPLET.md
+++ b/caplets/playwright/CAPLET.md
@@ -7,11 +7,27 @@ tags:
- testing
- mcp
- frontend
+setup:
+ commands:
+ - label: Install Playwright MCP
+ command: npm
+ args: ["install", "-g", "@playwright/mcp@0.0.75"]
+ timeoutMs: 120000
+ maxOutputBytes: 200000
+ - label: Install Chromium browser
+ command: npx
+ args: ["playwright", "install", "chromium"]
+ timeoutMs: 180000
+ maxOutputBytes: 200000
+ verify:
+ - label: Check Playwright MCP
+ command: playwright-mcp
+ args: ["--help"]
+ timeoutMs: 10000
+ maxOutputBytes: 20000
mcpServer:
- command: npx
+ command: playwright-mcp
args:
- - -y
- - "@playwright/mcp@0.0.75"
- --headless
---
@@ -29,7 +45,10 @@ visual inspection, or end-to-end testing workflows.
## Setup
-This Caplet starts Playwright MCP with `npx -y @playwright/mcp@0.0.75 --headless`.
+This Caplet installs `@playwright/mcp@0.0.75` globally with npm, installs the Chromium browser
+runtime with `npx playwright install chromium`, then verifies `playwright-mcp --help`. Setup is
+explicit because browser automation needs both a stable MCP binary and a browser runtime before the
+hosted or local stdio server starts.
Remove `--headless`, or set `PLAYWRIGHT_MCP_HEADLESS=false` in a custom MCP
environment, to use a visible browser. For advanced settings, create a
diff --git a/plugins/caplets/assets/icon.png b/docs/assets/caplets-icon.png
similarity index 100%
rename from plugins/caplets/assets/icon.png
rename to docs/assets/caplets-icon.png
diff --git a/docs/native-integrations.md b/docs/native-integrations.md
new file mode 100644
index 0000000..0ce55c5
--- /dev/null
+++ b/docs/native-integrations.md
@@ -0,0 +1,26 @@
+# Native Integrations And Project Binding
+
+Native integrations use the same Project Binding vocabulary as the CLI.
+
+Explicit remote mode is eager. If a user configures a remote service and the remote Project Binding path cannot start, the integration fails hard so the caller sees the configuration problem.
+
+Auto or configured hosted behavior is lazy. The native integration can start local Caplets immediately, then attach hosted Project Binding metadata when the remote side becomes available. When the lazy path fails, local Caplets remain available and the warning points to `caplets doctor`.
+
+## Remote Selection
+
+OpenCode and Pi use the same resolver as `caplets attach`.
+
+- `CAPLETS_MODE=local` exposes local/user/project Caplets only.
+- `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and connects to a self-hosted Caplets service.
+- `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses saved `caplets cloud auth login` credentials.
+- `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, and no remote URL as local.
+
+Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace.
+
+Native metadata should expose:
+
+- auth mode: `hosted_cloud`, `self_hosted_remote`, or `unconfigured`
+- Selected Workspace ID or slug when hosted Cloud Auth is active
+- Binding Session state
+- Project Binding endpoint
+- last recovery command
diff --git a/docs/plans/2026-05-21-mcp-resources-prompts.md b/docs/plans/2026-05-21-mcp-resources-prompts.md
index 5462346..397e1aa 100644
--- a/docs/plans/2026-05-21-mcp-resources-prompts.md
+++ b/docs/plans/2026-05-21-mcp-resources-prompts.md
@@ -1,5 +1,7 @@
# MCP Resources and Prompts Implementation Plan
+> Superseded for remote/server environment naming: current client integrations use `CAPLETS_REMOTE_*`, while the process hosting `caplets serve --transport http` uses `CAPLETS_SERVER_*`. This plan remains historical implementation context.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add MCP resource, resource-template, prompt, and completion support to MCP-backed Caplets while keeping non-MCP backends tool-only.
diff --git a/docs/plans/2026-05-29-cli-integration-setup.md b/docs/plans/2026-05-29-cli-integration-setup.md
index 39df2d3..4eac40e 100644
--- a/docs/plans/2026-05-29-cli-integration-setup.md
+++ b/docs/plans/2026-05-29-cli-integration-setup.md
@@ -1,5 +1,7 @@
# CLI Integration Setup Implementation Plan
+> Superseded for remote/server environment naming: current client integrations use `CAPLETS_REMOTE_*`, while the process hosting `caplets serve --transport http` uses `CAPLETS_SERVER_*`. This plan remains historical implementation context.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a quick `caplets setup` CLI command that actually installs or configures supported agent integrations, with `--dry-run` available for preview.
diff --git a/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md b/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md
new file mode 100644
index 0000000..85ceaa2
--- /dev/null
+++ b/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md
@@ -0,0 +1,1738 @@
+# Remote Attach And Agent Integrations Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `caplets serve` local-only, make `caplets attach` the remote-backed MCP server for self-hosted and Cloud upstreams, keep OpenCode/Pi on the shared resolver, and remove Codex/Claude native plugin artifacts.
+
+**Architecture:** Consolidate remote selection in Core around `CAPLETS_MODE` and `CAPLETS_REMOTE_*`, then reuse that resolver from the attach command and native integrations. `serve` keeps the existing local `CapletsEngine` path, while `attach` creates a remote-plus-local-overlay MCP surface and starts Project Binding automatically for Cloud selections.
+
+**Tech Stack:** TypeScript, Commander, Vitest, MCP SDK stdio and Streamable HTTP transports, Hono HTTP server, existing Cloud Auth store/client, existing Project Binding session manager, OpenCode and Pi native integration packages, pnpm.
+
+---
+
+## Source Spec
+
+- Root design spec: `/Users/ianpascoe/src/caplets-mono/docs/superpowers/specs/2026-06-04-remote-attach-and-agent-integration-design.md`
+- Core plan location follows `core/AGENTS.md`: `/Users/ianpascoe/src/caplets-mono/core/docs/plans/`
+
+## File Structure
+
+- Modify `packages/core/src/remote/options.ts`: expand mode parsing to `auto | local | remote | cloud`, add Cloud URL detection, and keep self-hosted auth resolution separate from Cloud Auth.
+- Add `packages/core/src/remote/selection.ts`: resolve a full upstream selection for commands and integrations, including self-hosted remote options, Cloud credentials, refresh, selected workspace, and Project Binding metadata.
+- Modify `packages/core/src/project-binding/attach.ts`: consume the shared selection helper for one-shot Project Binding probes and long-running sessions; stop treating saved Cloud Auth as an implicit remote when no remote URL or Cloud mode is selected.
+- Add `packages/core/src/attach/options.ts`: resolve `caplets attach` server options, including `--transport stdio|http`, HTTP bind/auth options, and the remote selection.
+- Add `packages/core/src/attach/server.ts`: start the remote-backed MCP server for stdio and HTTP transports and own the attach server lifecycle.
+- Add `packages/core/src/serve/native-session.ts`: register MCP tools from a `NativeCapletsService` so `attach` can expose the remote-plus-local-overlay surface without inventing a second merge path.
+- Modify `packages/core/src/serve/http.ts`: extract the session creation seam so HTTP serving can use either local `CapletsMcpSession` or native-service-backed sessions.
+- Modify `packages/core/src/serve/index.ts`: export reusable serve helpers used by attach.
+- Modify `packages/core/src/cli.ts`: change `caplets attach` from binding-only default to MCP server default; keep `--once` as the Project Binding smoke path.
+- Modify `packages/core/src/native/options.ts`: use the shared resolver and expose `local | remote | cloud` semantics to OpenCode/Pi.
+- Modify `packages/core/src/native/service.ts`: start Cloud Project Binding from saved Cloud Auth in Cloud mode and preserve local overlay precedence.
+- Modify `packages/core/src/native/remote.ts`: adjust auth failure guidance for self-hosted vs Cloud modes.
+- Modify `packages/core/src/native.ts`: export new resolver and option types.
+- Modify `packages/core/test/remote-options.test.ts`: cover `CAPLETS_MODE=cloud`, auto Cloud detection, invalid mode/URL combinations, and self-hosted auth.
+- Add `packages/core/test/remote-selection.test.ts`: cover saved Cloud Auth loading, refresh, workspace matching, token precedence, and no-implicit-cloud behavior.
+- Modify `packages/core/test/attach-cli.test.ts`: cover attach server option parsing, `--once` Project Binding smoke behavior, local-mode rejection, and Cloud mode errors.
+- Add `packages/core/test/attach-server.test.ts`: cover stdio/http attach server delegation using a fake native service and fake serve transports.
+- Modify `packages/core/test/cloud-auth-refresh-attach.test.ts`: assert refresh occurs only through explicit Cloud selection.
+- Modify `packages/core/test/native-options.test.ts`: cover Cloud mode and auto Cloud detection for native integrations.
+- Modify `packages/core/test/native-remote.test.ts`: cover Cloud Project Binding startup and fallback semantics.
+- Modify `packages/core/test/agent-plugins.test.ts`: invert plugin assertions so Codex/Claude plugin artifacts are absent and manual MCP docs are present.
+- Delete `plugins/caplets/.codex-plugin/plugin.json`.
+- Delete `plugins/caplets/.claude-plugin/plugin.json`.
+- Delete `plugins/caplets/mcp.json`.
+- Delete `plugins/caplets/skills/caplets/SKILL.md`.
+- Delete `plugins/caplets/assets/icon.png` only if no package README or landing asset still references it; otherwise move the asset to a non-plugin docs asset path in the same task.
+- Delete `plugins/caplets/` after required children are removed.
+- Delete `.agents/plugins/marketplace.json`.
+- Delete `.claude-plugin/marketplace.json`.
+- Delete `scripts/sync-plugin-versions.ts`.
+- Modify `package.json`: remove `scripts/sync-plugin-versions.ts` from `version-packages`.
+- Modify `README.md`: replace Codex/Claude plugin install docs with manual MCP config for `serve` and `attach`.
+- Modify `packages/cli/README.md` if it contains plugin install guidance.
+- Modify `docs/native-integrations.md`: document OpenCode/Pi `CAPLETS_MODE` and `CAPLETS_REMOTE_*` behavior.
+- Modify `packages/opencode/README.md`: document local, self-hosted, and Cloud env flows.
+- Modify `packages/pi/README.md`: document local, self-hosted, and Cloud env/settings flows.
+- Add a Changesets entry for `@caplets/core`, `caplets`, `@caplets/opencode`, and `@caplets/pi`.
+
+---
+
+## Task 1: Shared Remote Mode Semantics
+
+**Files:**
+
+- Modify: `packages/core/src/remote/options.ts`
+- Modify: `packages/core/test/remote-options.test.ts`
+
+- [ ] **Step 1: Add failing resolver tests**
+
+Append these cases to `packages/core/test/remote-options.test.ts`:
+
+```ts
+it("supports explicit cloud mode with a Caplets Cloud URL", () => {
+ expect(
+ resolveRemoteMode(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ },
+ ),
+ ).toEqual({ mode: "cloud" });
+});
+
+it("detects cloud mode in auto from CAPLETS_REMOTE_URL", () => {
+ expect(resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://cloud.caplets.dev" })).toEqual({
+ mode: "cloud",
+ });
+});
+
+it("keeps non-Cloud CAPLETS_REMOTE_URL in self-hosted remote mode", () => {
+ expect(
+ resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" }),
+ ).toEqual({
+ mode: "remote",
+ });
+});
+
+it("rejects explicit cloud mode with a non-Cloud URL", () => {
+ expect(() =>
+ resolveRemoteMode(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets",
+ },
+ ),
+ ).toThrow(/CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud/u);
+});
+
+it("rejects explicit cloud mode without CAPLETS_REMOTE_URL", () => {
+ expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "cloud" })).toThrow(
+ /CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL/u,
+ );
+});
+
+it("parses cloud as a valid CAPLETS_MODE value", () => {
+ expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "sidecar" })).toThrow(
+ /Expected CAPLETS_MODE to be auto, local, remote, or cloud/u,
+ );
+});
+```
+
+- [ ] **Step 2: Run resolver tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/remote-options.test.ts
+```
+
+Expected: FAIL because `cloud` is not accepted and auto always maps any remote URL to `remote`.
+
+- [ ] **Step 3: Implement mode expansion**
+
+In `packages/core/src/remote/options.ts`, change the mode type and parser:
+
+```ts
+export type CapletsRemoteMode = "local" | "remote" | "cloud";
+
+export function resolveRemoteMode(
+ input: CapletsRemoteModeInput = {},
+ env: CapletsRemoteEnv = process.env,
+): { mode: CapletsRemoteMode } {
+ const mode = parseCapletsMode(input.mode ?? env.CAPLETS_MODE ?? "auto");
+ if (mode === "local") return { mode: "local" };
+
+ const rawUrl =
+ nonEmpty(input.remoteUrl, "remoteUrl") ??
+ nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL");
+
+ if (mode === "remote") {
+ if (rawUrl === undefined) {
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "CAPLETS_MODE=remote requires CAPLETS_REMOTE_URL or remoteUrl.",
+ );
+ }
+ return { mode: "remote" };
+ }
+
+ if (mode === "cloud") {
+ if (rawUrl === undefined) {
+ throw new CapletsError("REQUEST_INVALID", "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL.");
+ }
+ if (!isCapletsCloudUrl(rawUrl)) {
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud.",
+ );
+ }
+ return { mode: "cloud" };
+ }
+
+ if (rawUrl === undefined) return { mode: "local" };
+ return isCapletsCloudUrl(rawUrl) ? { mode: "cloud" } : { mode: "remote" };
+}
+
+export function isCapletsCloudUrl(value: string): boolean {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return false;
+ }
+ const host = url.hostname.toLowerCase();
+ return host === "cloud.caplets.dev" || host.endsWith(".preview.caplets.dev");
+}
+
+function parseCapletsMode(value: string): "auto" | CapletsRemoteMode {
+ if (value === "auto" || value === "local" || value === "remote" || value === "cloud") {
+ return value;
+ }
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ `Expected CAPLETS_MODE to be auto, local, remote, or cloud, got ${value}`,
+ );
+}
+```
+
+- [ ] **Step 4: Run resolver tests to verify green**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/remote-options.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/remote/options.ts packages/core/test/remote-options.test.ts
+git commit -m "feat(core): resolve cloud remote mode"
+```
+
+---
+
+## Task 2: Full Upstream Selection With Cloud Auth
+
+**Files:**
+
+- Add: `packages/core/src/remote/selection.ts`
+- Modify: `packages/core/src/project-binding/attach.ts`
+- Add: `packages/core/test/remote-selection.test.ts`
+- Modify: `packages/core/test/cloud-auth-refresh-attach.test.ts`
+
+- [ ] **Step 1: Add failing selection tests**
+
+Create `packages/core/test/remote-selection.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { CloudAuthStore } from "../src/cloud-auth/store";
+import { resolveRemoteSelection } from "../src/remote/selection";
+import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth";
+
+describe("resolveRemoteSelection", () => {
+ it("rejects attach selection in local mode", async () => {
+ await expect(resolveRemoteSelection({}, { CAPLETS_MODE: "local" })).rejects.toThrow(
+ /caplets attach requires a remote upstream; use caplets serve for local-only MCP/u,
+ );
+ });
+
+ it("rejects auto mode without a remote URL for attach", async () => {
+ await expect(resolveRemoteSelection({}, {})).rejects.toThrow(/CAPLETS_REMOTE_URL/u);
+ });
+
+ it("resolves self-hosted remote auth from CAPLETS_REMOTE variables", async () => {
+ await expect(
+ resolveRemoteSelection(
+ {},
+ {
+ CAPLETS_MODE: "remote",
+ CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets",
+ CAPLETS_REMOTE_TOKEN: "remote-token",
+ },
+ ),
+ ).resolves.toMatchObject({
+ kind: "self_hosted_remote",
+ remote: {
+ baseUrl: new URL("https://caplets.example.com/caplets"),
+ auth: { type: "bearer", token: "remote-token" },
+ },
+ });
+ });
+
+ it("uses saved Cloud Auth in cloud mode and ignores self-hosted token vars", async () => {
+ const path = tempCloudAuthPath();
+ await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" }));
+
+ const resolved = await resolveRemoteSelection(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ CAPLETS_REMOTE_TOKEN: "self-hosted-token",
+ CAPLETS_CLOUD_AUTH_PATH: path,
+ },
+ );
+
+ expect(resolved).toMatchObject({
+ kind: "hosted_cloud",
+ selectedWorkspace: "personal",
+ remote: {
+ baseUrl: new URL("https://cloud.caplets.dev"),
+ auth: { type: "bearer", token: "cloud-access" },
+ },
+ });
+ });
+
+ it("refreshes expired Cloud credentials before returning the upstream", async () => {
+ const path = tempCloudAuthPath();
+ await new CloudAuthStore({ path }).save(
+ hostedCredentials({
+ accessToken: "old-access",
+ refreshToken: "old-refresh",
+ expiresAt: "2026-06-03T00:00:00.000Z",
+ }),
+ );
+
+ const resolved = await resolveRemoteSelection(
+ {
+ fetch: async (url, init) => {
+ expect(String(url)).toBe("https://cloud.caplets.dev/api/cloud-client/refresh");
+ expect(JSON.parse(String(init?.body))).toEqual({ refreshToken: "old-refresh" });
+ return Response.json({
+ status: "authenticated",
+ cloudUrl: "https://cloud.caplets.dev",
+ workspaceId: "workspace_personal",
+ workspaceSlug: "personal",
+ accessToken: "new-access",
+ refreshToken: "new-refresh",
+ expiresAt: "2999-01-01T00:00:00.000Z",
+ scope: ["project_binding:read", "project_binding:write"],
+ tokenType: "Bearer",
+ credentialFamilyId: "family_123",
+ });
+ },
+ },
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ CAPLETS_CLOUD_AUTH_PATH: path,
+ },
+ );
+
+ expect(resolved.remote.auth).toEqual({ type: "bearer", token: "new-access" });
+ });
+
+ it("requires Cloud Auth when cloud mode is selected", async () => {
+ await expect(
+ resolveRemoteSelection(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ },
+ ),
+ ).rejects.toMatchObject({
+ projectBindingCode: "cloud_auth_required",
+ });
+ });
+});
+```
+
+- [ ] **Step 2: Run selection tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/remote-selection.test.ts
+```
+
+Expected: FAIL because `remote/selection.ts` does not exist.
+
+- [ ] **Step 3: Implement `resolveRemoteSelection`**
+
+Create `packages/core/src/remote/selection.ts` with these exports and behavior:
+
+```ts
+import { CloudAuthClient } from "../cloud-auth/client";
+import { CloudAuthStore, type CloudAuthCredentials } from "../cloud-auth/store";
+import { projectBindingError } from "../project-binding/errors";
+import { resolveCapletsRemote, resolveRemoteMode, type ResolvedCapletsRemote } from "./options";
+
+export type RemoteSelectionInput = {
+ remoteUrl?: string;
+ user?: string;
+ password?: string;
+ token?: string;
+ workspace?: string;
+ fetch?: typeof fetch;
+ requireUpstream?: boolean;
+};
+
+export type ResolvedRemoteSelection =
+ | {
+ kind: "self_hosted_remote";
+ remote: ResolvedCapletsRemote;
+ }
+ | {
+ kind: "hosted_cloud";
+ remote: ResolvedCapletsRemote;
+ selectedWorkspace: string;
+ credentials: CloudAuthCredentials;
+ cloudPresence: {
+ url: URL;
+ accessToken: string;
+ workspaceId: string;
+ };
+ };
+
+export async function resolveRemoteSelection(
+ input: RemoteSelectionInput = {},
+ env: Record = process.env,
+): Promise {
+ const mode = resolveRemoteMode({ mode: env.CAPLETS_MODE, remoteUrl: input.remoteUrl }, env);
+ if (mode.mode === "local") {
+ throw new Error(
+ "caplets attach requires a remote upstream; use caplets serve for local-only MCP.",
+ );
+ }
+
+ if (mode.mode === "remote") {
+ return {
+ kind: "self_hosted_remote",
+ remote: resolveCapletsRemote(
+ {
+ url: input.remoteUrl,
+ user: input.user,
+ password: input.password,
+ token: input.token,
+ workspace: input.workspace,
+ fetch: input.fetch,
+ },
+ env,
+ ),
+ };
+ }
+
+ const store = new CloudAuthStore({ env });
+ let credentials = await store.load();
+ if (!credentials?.accessToken) throw projectBindingError("cloud_auth_required");
+
+ if (credentialsNeedRefresh(credentials)) {
+ if (!credentials.refreshToken) throw projectBindingError("cloud_auth_required");
+ const refreshed = await new CloudAuthClient({
+ cloudUrl: credentials.cloudUrl,
+ ...(input.fetch ? { fetch: input.fetch } : {}),
+ }).refresh({ refreshToken: credentials.refreshToken });
+ credentials = {
+ ...credentials,
+ ...refreshed,
+ refreshToken: refreshed.refreshToken ?? credentials.refreshToken,
+ createdAt: credentials.createdAt,
+ lastRefreshAt: new Date().toISOString(),
+ };
+ await store.save(credentials);
+ }
+
+ const selectedWorkspace = credentials.workspaceSlug ?? credentials.workspaceId;
+ if (
+ input.workspace &&
+ input.workspace !== credentials.workspaceId &&
+ input.workspace !== credentials.workspaceSlug
+ ) {
+ throw projectBindingError(
+ "workspace_switch_required",
+ `Requested workspace ${input.workspace} differs from saved Selected Workspace ${selectedWorkspace}.`,
+ );
+ }
+
+ const remoteUrl = input.remoteUrl ?? env.CAPLETS_REMOTE_URL ?? credentials.cloudUrl;
+ const remote = resolveCapletsRemote(
+ {
+ url: remoteUrl,
+ token: credentials.accessToken,
+ workspace: selectedWorkspace,
+ ...(input.fetch ? { fetch: input.fetch } : {}),
+ },
+ {},
+ );
+
+ return {
+ kind: "hosted_cloud",
+ remote,
+ selectedWorkspace,
+ credentials,
+ cloudPresence: {
+ url: new URL(remoteUrl),
+ accessToken: credentials.accessToken,
+ workspaceId: credentials.workspaceId,
+ },
+ };
+}
+
+function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean {
+ const expiresAt = Date.parse(credentials.expiresAt);
+ return Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60_000;
+}
+```
+
+During implementation, replace the plain `Error` local-mode failure with `CapletsError("REQUEST_INVALID", ...)` so JSON CLI handling remains structured.
+
+- [ ] **Step 4: Update Project Binding attach to use selection**
+
+In `packages/core/src/project-binding/attach.ts`:
+
+- Replace direct `resolveCapletsRemote(...)` calls with `await resolveRemoteSelection(...)`.
+- Remove `hasExplicitRemote(...)`; saved Cloud Auth must not implicitly select Cloud without `CAPLETS_MODE=cloud` or `CAPLETS_MODE=auto` plus a Cloud `CAPLETS_REMOTE_URL`.
+- Keep `authMode` values as `"self_hosted_remote"` and `"hosted_cloud"` from `selection.kind`.
+- Keep `selectedWorkspace` only for Cloud selections.
+
+The resolved return should be shaped like:
+
+```ts
+const selection = await resolveRemoteSelection(remoteInput, env);
+return {
+ projectRoot: raw.projectRoot ?? process.cwd(),
+ json: raw.json === true,
+ verbose: raw.verbose === true,
+ once: raw.once === true,
+ remote: selection.remote,
+ authMode: selection.kind,
+ ...(selection.kind === "hosted_cloud"
+ ? { selectedWorkspace: selection.selectedWorkspace }
+ : remoteInput.workspace
+ ? { selectedWorkspace: remoteInput.workspace }
+ : {}),
+};
+```
+
+- [ ] **Step 5: Update Cloud refresh attach tests**
+
+In `packages/core/test/cloud-auth-refresh-attach.test.ts`, update every attach call that expects Cloud Auth to pass:
+
+```ts
+{
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ CAPLETS_CLOUD_AUTH_PATH: path,
+}
+```
+
+Add one regression test:
+
+```ts
+it("does not implicitly use saved Cloud Auth without cloud mode or a Cloud remote URL", async () => {
+ const path = tempCloudAuthPath();
+ await new CloudAuthStore({ path }).save(hostedCredentials());
+
+ await expect(
+ attachProjectOnce({ projectRoot: "/repo" }, { CAPLETS_CLOUD_AUTH_PATH: path }),
+ ).rejects.toThrow(/CAPLETS_REMOTE_URL/u);
+});
+```
+
+- [ ] **Step 6: Run selection and attach tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/remote-selection.test.ts test/cloud-auth-refresh-attach.test.ts test/attach-cli.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/core/src/remote/selection.ts packages/core/src/project-binding/attach.ts packages/core/test/remote-selection.test.ts packages/core/test/cloud-auth-refresh-attach.test.ts packages/core/test/attach-cli.test.ts
+git commit -m "feat(core): select remote upstreams for attach"
+```
+
+---
+
+## Task 3: Native-Service-Backed MCP Sessions
+
+**Files:**
+
+- Add: `packages/core/src/serve/native-session.ts`
+- Modify: `packages/core/src/serve/index.ts`
+- Add: `packages/core/test/attach-server.test.ts`
+
+- [ ] **Step 1: Add failing session tests**
+
+Create `packages/core/test/attach-server.test.ts` with a fake MCP server:
+
+```ts
+import { describe, expect, it, vi } from "vitest";
+import { NativeCapletsMcpSession } from "../src/serve/native-session";
+
+describe("NativeCapletsMcpSession", () => {
+ it("registers tools from a native Caplets service", async () => {
+ const registered = new Map();
+ const server = {
+ registerTool: vi.fn((name: string, definition: unknown, callback: unknown) => {
+ registered.set(name, { definition, callback });
+ return { remove: vi.fn(), update: vi.fn() };
+ }),
+ connect: vi.fn(async () => undefined),
+ close: vi.fn(async () => undefined),
+ };
+ const service = {
+ listTools: () => [
+ {
+ caplet: "remote-alpha",
+ toolName: "caplets_remote_alpha",
+ title: "Remote Alpha",
+ description: "Remote alpha tool",
+ promptGuidance: [],
+ inputSchema: {
+ type: "object",
+ properties: { operation: { type: "string", enum: ["inspect"] } },
+ },
+ operationNames: ["inspect"],
+ },
+ ],
+ execute: vi.fn(async () => ({ ok: true })),
+ reload: vi.fn(async () => true),
+ onToolsChanged: vi.fn(() => () => undefined),
+ close: vi.fn(async () => undefined),
+ };
+
+ const session = new NativeCapletsMcpSession(service, { server });
+
+ expect([...registered.keys()]).toEqual(["remote-alpha"]);
+ const tool = registered.get("remote-alpha") as {
+ callback: (request: unknown) => Promise;
+ };
+ await expect(tool.callback({ operation: "inspect" })).resolves.toEqual({ ok: true });
+ expect(service.execute).toHaveBeenCalledWith("remote-alpha", { operation: "inspect" });
+ await session.close();
+ expect(service.close).toHaveBeenCalledOnce();
+ });
+
+ it("updates registered tools when the native service changes", () => {
+ let listener: ((tools: unknown[]) => void) | undefined;
+ const removed = vi.fn();
+ const updates: unknown[] = [];
+ const server = {
+ registerTool: vi.fn((_name: string, _definition: unknown, _callback: unknown) => ({
+ remove: removed,
+ update: (definition: unknown) => updates.push(definition),
+ })),
+ connect: vi.fn(async () => undefined),
+ close: vi.fn(async () => undefined),
+ };
+ const service = {
+ listTools: () => [
+ { caplet: "alpha", title: "Alpha", description: "Alpha", promptGuidance: [] },
+ ],
+ execute: vi.fn(async () => ({})),
+ reload: vi.fn(async () => true),
+ onToolsChanged: vi.fn((nextListener: (tools: unknown[]) => void) => {
+ listener = nextListener;
+ return () => undefined;
+ }),
+ close: vi.fn(async () => undefined),
+ };
+
+ new NativeCapletsMcpSession(service as never, { server });
+ listener?.([{ caplet: "beta", title: "Beta", description: "Beta", promptGuidance: [] }]);
+
+ expect(removed).toHaveBeenCalledOnce();
+ expect(server.registerTool).toHaveBeenCalledWith(
+ "beta",
+ expect.objectContaining({ title: "Beta" }),
+ expect.any(Function),
+ );
+ });
+});
+```
+
+- [ ] **Step 2: Run session tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-server.test.ts
+```
+
+Expected: FAIL because `serve/native-session.ts` does not exist.
+
+- [ ] **Step 3: Implement native MCP session**
+
+Create `packages/core/src/serve/native-session.ts`:
+
+```ts
+import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp";
+import type { Transport } from "@modelcontextprotocol/sdk/shared/transport";
+import { version as packageJsonVersion } from "../../package.json";
+import type { NativeCapletsService, NativeCapletTool } from "../native/service";
+
+export type NativeToolServer = Pick;
+
+export type NativeCapletsMcpSessionOptions = {
+ server?: NativeToolServer;
+};
+
+export class NativeCapletsMcpSession {
+ readonly server: NativeToolServer;
+ private readonly tools = new Map();
+ private readonly unsubscribe: () => void;
+ private closed = false;
+
+ constructor(
+ private readonly service: NativeCapletsService,
+ options: NativeCapletsMcpSessionOptions = {},
+ ) {
+ this.server =
+ options.server ??
+ new McpServer({
+ name: "caplets",
+ version: packageJsonVersion,
+ });
+ this.unsubscribe = service.onToolsChanged((tools) => this.reconcileTools(tools));
+ this.reconcileTools(service.listTools());
+ }
+
+ async connect(transport: Transport): Promise {
+ await this.server.connect(transport);
+ }
+
+ async close(): Promise {
+ if (this.closed) return;
+ this.closed = true;
+ this.unsubscribe();
+ this.tools.clear();
+ await this.server.close();
+ await this.service.close();
+ }
+
+ private reconcileTools(next: NativeCapletTool[]): void {
+ const enabled = new Map(next.map((tool) => [tool.caplet, tool]));
+ for (const [id, registered] of this.tools) {
+ const tool = enabled.get(id);
+ if (!tool) {
+ registered.remove();
+ this.tools.delete(id);
+ continue;
+ }
+ registered.update(this.definition(tool));
+ }
+ for (const tool of enabled.values()) {
+ if (!this.tools.has(tool.caplet)) {
+ this.tools.set(
+ tool.caplet,
+ this.server.registerTool(tool.caplet, this.definition(tool), async (request) =>
+ this.service.execute(tool.caplet, request),
+ ),
+ );
+ }
+ }
+ }
+
+ private definition(tool: NativeCapletTool) {
+ return {
+ title: tool.title,
+ description: tool.description,
+ inputSchema: tool.inputSchema,
+ };
+ }
+}
+```
+
+During implementation, verify the installed MCP SDK overload. If `registerTool` requires Zod `paramsSchema` instead of raw JSON Schema, convert missing or raw schemas through the existing generated Caplets schema for remote tools and document the lossless raw schema follow-up in the commit body.
+
+- [ ] **Step 4: Export the session**
+
+In `packages/core/src/serve/index.ts`, export:
+
+```ts
+export { NativeCapletsMcpSession } from "./native-session";
+export type { NativeCapletsMcpSessionOptions, NativeToolServer } from "./native-session";
+```
+
+- [ ] **Step 5: Run session tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-server.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/serve/native-session.ts packages/core/src/serve/index.ts packages/core/test/attach-server.test.ts
+git commit -m "feat(core): expose native caplets over mcp"
+```
+
+---
+
+## Task 4: `caplets attach` MCP Server
+
+**Files:**
+
+- Add: `packages/core/src/attach/options.ts`
+- Add: `packages/core/src/attach/server.ts`
+- Modify: `packages/core/src/cli.ts`
+- Modify: `packages/core/test/attach-cli.test.ts`
+- Modify: `packages/core/test/attach-server.test.ts`
+
+- [ ] **Step 1: Add failing CLI contract tests**
+
+In `packages/core/test/attach-cli.test.ts`, update help expectations:
+
+```ts
+expect(out.join("")).toContain("Start a remote-backed Caplets MCP server.");
+expect(out.join("")).toContain("--transport ");
+expect(out.join("")).toContain("--once");
+```
+
+Add tests:
+
+```ts
+it("runs attach as a stdio MCP server by default", async () => {
+ const served: unknown[] = [];
+ await runCli(["attach"], {
+ env: {
+ CAPLETS_MODE: "remote",
+ CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets",
+ },
+ attachServe: async (options) => {
+ served.push(options);
+ },
+ } as never);
+
+ expect(served).toHaveLength(1);
+ expect(served[0]).toMatchObject({
+ transport: "stdio",
+ selection: { kind: "self_hosted_remote" },
+ });
+});
+
+it("rejects attach server in local mode", async () => {
+ await expect(
+ runCli(["attach"], {
+ env: { CAPLETS_MODE: "local" },
+ attachServe: async () => undefined,
+ } as never),
+ ).rejects.toThrow(/use caplets serve for local-only MCP/u);
+});
+
+it("keeps attach --once as the finite Project Binding smoke path", async () => {
+ const out: string[] = [];
+ await runCli(["attach", "--once", "--remote-url", "https://caplets.example.com/caplets"], {
+ fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }),
+ writeOut: (value) => out.push(value),
+ });
+ expect(out.join("")).toContain("Project Binding available at");
+});
+```
+
+The `attachServe` seam is intentionally test-only and mirrors the existing `serve` seam.
+
+- [ ] **Step 2: Run attach CLI tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-cli.test.ts
+```
+
+Expected: FAIL because `attach` does not accept `--transport` and defaults to Project Binding session behavior.
+
+- [ ] **Step 3: Implement attach option resolution**
+
+Create `packages/core/src/attach/options.ts`:
+
+```ts
+import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "../serve/options";
+import {
+ resolveRemoteSelection,
+ type RemoteSelectionInput,
+ type ResolvedRemoteSelection,
+} from "../remote/selection";
+
+export type RawAttachServeOptions = RemoteSelectionInput &
+ RawServeOptions & {
+ projectRoot?: string;
+ };
+
+export type AttachServeOptions = ServeOptions & {
+ projectRoot: string;
+ selection: ResolvedRemoteSelection;
+};
+
+export async function resolveAttachServeOptions(
+ raw: RawAttachServeOptions = {},
+ env: Record = process.env,
+): Promise {
+ const selection = await resolveRemoteSelection(raw, env);
+ const serve = resolveServeOptions(raw, env);
+ return {
+ ...serve,
+ projectRoot: raw.projectRoot ?? process.cwd(),
+ selection,
+ };
+}
+```
+
+- [ ] **Step 4: Implement attach server lifecycle**
+
+Create `packages/core/src/attach/server.ts`:
+
+```ts
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
+import { createNativeCapletsService } from "../native/service";
+import { NativeCapletsMcpSession } from "../serve/native-session";
+import { serveHttpWithSessionFactory } from "../serve/http";
+import type { AttachServeOptions } from "./options";
+
+export type AttachServeIo = {
+ writeErr?: (value: string) => void;
+};
+
+export async function attachResolvedCaplets(
+ options: AttachServeOptions,
+ io: AttachServeIo = {},
+): Promise {
+ const service = createNativeCapletsService({
+ mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote",
+ server: {
+ url: options.selection.remote.baseUrl.toString(),
+ fetch: options.selection.remote.fetch,
+ },
+ remote: {
+ fetch: options.selection.remote.fetch,
+ cloud:
+ options.selection.kind === "hosted_cloud"
+ ? {
+ url: options.selection.cloudPresence.url.toString(),
+ accessToken: options.selection.cloudPresence.accessToken,
+ workspaceId: options.selection.cloudPresence.workspaceId,
+ projectRoot: options.projectRoot,
+ }
+ : undefined,
+ },
+ ...(io.writeErr ? { writeErr: io.writeErr } : {}),
+ });
+ await service.reload();
+
+ if (options.transport === "stdio") {
+ const session = new NativeCapletsMcpSession(service);
+ await session.connect(new StdioServerTransport());
+ return;
+ }
+
+ await serveHttpWithSessionFactory(
+ options,
+ () => new NativeCapletsMcpSession(service),
+ io.writeErr,
+ );
+}
+```
+
+During implementation, copy credentials and request headers from `selection.remote.requestInit` into the native remote client options. The snippet above shows the lifecycle shape; the final code must preserve Bearer and Basic Auth headers.
+
+- [ ] **Step 5: Extract HTTP session factory**
+
+In `packages/core/src/serve/http.ts`, extract the existing `createHttpSession(...)` path behind a function that can accept either:
+
+```ts
+type HttpMcpSessionFactory = () => {
+ connect(transport: StreamableHTTPTransport): Promise;
+ close(): Promise;
+};
+```
+
+Export:
+
+```ts
+export async function serveHttpWithSessionFactory(
+ options: HttpServeOptions,
+ createSession: HttpMcpSessionFactory,
+ writeErr?: (value: string) => void,
+): Promise;
+```
+
+Keep `serveHttp(...)` behavior unchanged by calling the new helper with a factory that creates the existing local `CapletsMcpSession`.
+
+- [ ] **Step 6: Wire CLI default attach behavior**
+
+In `packages/core/src/cli.ts`:
+
+- Add `attachServe?: (options: AttachServeOptions) => Promise` to `CliIO`.
+- Add attach options `--transport`, `--host`, `--port`, `--path`, `--user`, `--password`, `--token`, `--allow-unauthenticated-http`, and `--trust-proxy`.
+- Keep `--once` on the existing `attachProjectOnce(...)` path.
+- For non-`--once`, call `resolveAttachServeOptions(...)`, then `io.attachServe ?? attachResolvedCaplets`.
+- Change description to `Start a remote-backed Caplets MCP server.`
+
+- [ ] **Step 7: Run attach tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/attach-server.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/core/src/attach/options.ts packages/core/src/attach/server.ts packages/core/src/serve/http.ts packages/core/src/cli.ts packages/core/test/attach-cli.test.ts packages/core/test/attach-server.test.ts
+git commit -m "feat(core): serve remote mcp with attach"
+```
+
+---
+
+## Task 5: Native Integration Cloud Mode
+
+**Files:**
+
+- Modify: `packages/core/src/native/options.ts`
+- Modify: `packages/core/src/native/service.ts`
+- Modify: `packages/core/src/native/remote.ts`
+- Modify: `packages/core/src/native.ts`
+- Modify: `packages/core/test/native-options.test.ts`
+- Modify: `packages/core/test/native-remote.test.ts`
+
+- [ ] **Step 1: Add failing native option tests**
+
+Append to `packages/core/test/native-options.test.ts`:
+
+```ts
+it("uses cloud mode in auto when CAPLETS_REMOTE_URL points at Caplets Cloud", () => {
+ expect(
+ resolveNativeCapletsServiceOptions(
+ {},
+ {
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ },
+ ),
+ ).toMatchObject({
+ mode: "cloud",
+ remote: {
+ url: new URL("https://cloud.caplets.dev/mcp"),
+ },
+ });
+});
+
+it("uses cloud mode when CAPLETS_MODE=cloud is explicit", () => {
+ expect(
+ resolveNativeCapletsServiceOptions(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ },
+ ),
+ ).toMatchObject({ mode: "cloud" });
+});
+
+it("rejects CAPLETS_MODE=cloud with a self-hosted remote URL", () => {
+ expect(() =>
+ resolveNativeCapletsServiceOptions(
+ {},
+ {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets",
+ },
+ ),
+ ).toThrow(/Caplets Cloud/u);
+});
+```
+
+- [ ] **Step 2: Add failing native Cloud service tests**
+
+Append to `packages/core/test/native-remote.test.ts`:
+
+```ts
+it("starts Cloud Project Binding when native service runs in cloud mode", async () => {
+ const path = tempCloudAuthPath();
+ await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" }));
+ const factory = vi.fn(() => client([{ name: "remote", description: "Remote" }]).api);
+
+ const service = createNativeCapletsService({
+ mode: "cloud",
+ server: { url: "https://cloud.caplets.dev" },
+ remoteClientFactory: factory,
+ projectConfigPath: tempProjectConfigWithTool("local"),
+ } as never);
+
+ await service.reload();
+ expect(service.listTools().map((tool) => tool.caplet)).toContain("remote");
+ await service.close();
+});
+```
+
+Use existing test helpers in this file for local overlay config, or add a small helper that writes `.caplets/config.json` into a temp directory and returns the path.
+
+- [ ] **Step 3: Run native tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts
+```
+
+Expected: FAIL because native mode only supports `local | remote`.
+
+- [ ] **Step 4: Update native option types**
+
+In `packages/core/src/native/options.ts`:
+
+- Change `type CapletsMode = "auto" | "local" | "remote";` to include `"cloud"`.
+- Let `ResolvedNativeCapletsServiceOptions` use `mode: "remote" | "cloud"` for remote-backed services.
+- Call `resolveRemoteMode(...)` from Task 1.
+- For Cloud mode, return the MCP URL and request headers needed for Cloud Auth resolution but do not require `CAPLETS_CLOUD_TOKEN` env vars.
+
+The final union should be:
+
+```ts
+export type ResolvedNativeCapletsServiceOptions =
+ | { mode: "local" }
+ | {
+ mode: "remote" | "cloud";
+ remote: {
+ url: URL;
+ auth: NativeRemoteAuthOptions;
+ pollIntervalMs: number;
+ requestInit: RequestInit;
+ cloud?: ResolvedNativeCloudPresenceOptions;
+ fetch?: typeof fetch;
+ };
+ };
+```
+
+- [ ] **Step 5: Load saved Cloud Auth for native Cloud mode**
+
+In `packages/core/src/native/service.ts`, update `createNativeCapletsService(...)`:
+
+- Treat `resolved.mode === "cloud"` the same as remote-backed for composition.
+- Before creating the SDK remote client, use the shared selection helper or a native equivalent to load saved Cloud Auth credentials.
+- Populate `resolved.remote.requestInit` with `Authorization: Bearer `.
+- Populate `resolved.remote.cloud` with Cloud Project Binding presence options.
+- Preserve existing behavior where explicit self-hosted remote mode fails hard, while auto Cloud fallback can warn and return local only when the remote setup cannot initialize.
+
+Keep local overlay precedence unchanged:
+
+```ts
+const localIds = new Set(localTools.map((tool) => tool.caplet));
+return [...remoteTools.filter((tool) => !localIds.has(tool.caplet)), ...localTools];
+```
+
+- [ ] **Step 6: Update remote auth error copy**
+
+In `packages/core/src/native/remote.ts`, make `remoteAuthError(...)` accept an auth kind:
+
+```ts
+function remoteAuthError(kind: "self_hosted_remote" | "hosted_cloud"): CapletsError {
+ return new CapletsError(
+ "AUTH_FAILED",
+ kind === "hosted_cloud"
+ ? "Caplets Cloud authentication failed; run caplets cloud auth login."
+ : "Remote Caplets authentication failed; check CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.",
+ );
+}
+```
+
+Thread the kind from native service creation into `RemoteNativeCapletsService`.
+
+- [ ] **Step 7: Run native tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/core/src/native/options.ts packages/core/src/native/service.ts packages/core/src/native/remote.ts packages/core/src/native.ts packages/core/test/native-options.test.ts packages/core/test/native-remote.test.ts
+git commit -m "feat(core): drive native integrations from cloud mode"
+```
+
+---
+
+## Task 6: OpenCode And Pi Docs/Config Validation
+
+**Files:**
+
+- Modify: `packages/opencode/src/index.ts`
+- Modify: `packages/opencode/test/opencode.test.ts`
+- Modify: `packages/opencode/README.md`
+- Modify: `packages/pi/src/index.ts`
+- Modify: `packages/pi/test/pi.test.ts`
+- Modify: `packages/pi/README.md`
+- Modify: `docs/native-integrations.md`
+
+- [ ] **Step 1: Add integration config tests**
+
+In `packages/opencode/test/opencode.test.ts`, add:
+
+```ts
+it("passes cloud mode config into the native service", async () => {
+ vi.resetModules();
+ const nativeMocks = {
+ createNativeCapletsService: vi.fn(() => ({
+ listTools: () => [],
+ execute: vi.fn(async () => ({})),
+ reload: vi.fn(async () => true),
+ onToolsChanged: vi.fn(() => () => {}),
+ close: vi.fn(async () => {}),
+ })),
+ registerNativeCapletsProcessCleanup: vi.fn(),
+ };
+ vi.doMock("@caplets/core/native", () => nativeMocks);
+ const plugin = (await import("../src/index")).default;
+
+ await plugin(
+ {} as never,
+ { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } as never,
+ );
+
+ expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({
+ mode: "cloud",
+ server: { url: "https://cloud.caplets.dev" },
+ });
+});
+```
+
+In `packages/pi/test/pi.test.ts`, add a settings extraction case:
+
+```ts
+it("loads cloud mode from Pi settings", async () => {
+ fsMocks.readFile.mockImplementation(async (path: string) =>
+ path.includes(".pi/agent/settings.json")
+ ? JSON.stringify({ caplets: { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } })
+ : Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
+ );
+
+ await capletsPiExtension(mockPiApi());
+
+ expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mode: "cloud",
+ server: { url: "https://cloud.caplets.dev" },
+ }),
+ );
+});
+```
+
+- [ ] **Step 2: Run package tests to verify red or confirm no code change needed**
+
+Run:
+
+```bash
+pnpm --filter @caplets/opencode test
+pnpm --filter @caplets/pi test
+```
+
+Expected: PASS if existing config plumbing already accepts `"cloud"` after Task 5 type changes; otherwise FAIL on narrow type validation and fix in Step 3.
+
+- [ ] **Step 3: Confirm config types accept cloud mode**
+
+In `packages/opencode/src/index.ts` and `packages/pi/src/index.ts`, keep the current config shape but ensure the imported `NativeCapletsServiceOptions` type includes `mode: "cloud"` after Task 5:
+
+```ts
+export type CapletsOpenCodeConfig = Pick;
+type PiNativeCapletsOptions = Pick;
+```
+
+No runtime special case should be added for Cloud.
+
+- [ ] **Step 4: Update native integration docs**
+
+In `docs/native-integrations.md`, add this contract:
+
+```md
+## Remote Selection
+
+OpenCode and Pi use the same resolver as `caplets attach`.
+
+- `CAPLETS_MODE=local` exposes local/user/project Caplets only.
+- `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and connects to a self-hosted Caplets service.
+- `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses saved `caplets cloud auth login` credentials.
+- `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, and no remote URL as local.
+
+Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace.
+```
+
+In both integration READMEs, include the three copyable env examples:
+
+```bash
+CAPLETS_MODE=local opencode
+CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets opencode
+CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode
+```
+
+Use `pi` in the Pi README examples.
+
+- [ ] **Step 5: Run package tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/opencode test
+pnpm --filter @caplets/pi test
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts packages/opencode/README.md packages/pi/src/index.ts packages/pi/test/pi.test.ts packages/pi/README.md docs/native-integrations.md
+git commit -m "docs(core): document native cloud mode"
+```
+
+---
+
+## Task 7: Remove Codex And Claude Plugin Artifacts
+
+**Files:**
+
+- Delete: `plugins/caplets/.codex-plugin/plugin.json`
+- Delete: `plugins/caplets/.claude-plugin/plugin.json`
+- Delete: `plugins/caplets/mcp.json`
+- Delete: `plugins/caplets/skills/caplets/SKILL.md`
+- Delete: `plugins/caplets/assets/icon.png` or move it to a non-plugin asset path if still referenced
+- Delete: `.agents/plugins/marketplace.json`
+- Delete: `.claude-plugin/marketplace.json`
+- Delete: `scripts/sync-plugin-versions.ts`
+- Modify: `package.json`
+- Modify: `packages/core/test/agent-plugins.test.ts`
+
+- [ ] **Step 1: Invert plugin artifact tests**
+
+Replace `packages/core/test/agent-plugins.test.ts` with contract tests that assert absence:
+
+```ts
+import { existsSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+
+describe("Codex and Claude manual MCP setup", () => {
+ it("does not ship native Codex or Claude plugin artifacts", () => {
+ for (const removedPath of [
+ "plugins/caplets",
+ ".agents/plugins/marketplace.json",
+ ".claude-plugin/marketplace.json",
+ "scripts/sync-plugin-versions.ts",
+ ]) {
+ expect(existsSync(path.join(repoRoot, removedPath)), removedPath).toBe(false);
+ }
+ });
+
+ it("documents manual MCP config for Codex and Claude users", async () => {
+ const readme = await readFile(path.join(repoRoot, "README.md"), "utf8");
+ expect(readme).toContain('"command": "caplets"');
+ expect(readme).toContain('"args": ["serve"]');
+ expect(readme).toContain('"args": ["attach"]');
+ expect(readme).not.toMatch(/plugin marketplace add|plugin install caplets@caplets/u);
+ });
+
+ it("does not keep version-package plugin sync wiring", async () => {
+ const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as {
+ scripts: Record;
+ };
+ expect(packageJson.scripts["version-packages"] ?? "").not.toContain("sync-plugin-versions");
+ });
+});
+```
+
+- [ ] **Step 2: Run plugin tests to verify red**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/agent-plugins.test.ts
+```
+
+Expected: FAIL because plugin artifacts still exist and README still references plugin install flows.
+
+- [ ] **Step 3: Delete plugin artifacts**
+
+Delete these paths:
+
+```bash
+git rm -r plugins/caplets .agents/plugins/marketplace.json .claude-plugin/marketplace.json scripts/sync-plugin-versions.ts
+```
+
+If `git rm -r plugins/caplets` fails because the icon is referenced by `README.md`, move the image first:
+
+```bash
+mkdir -p docs/assets
+git mv plugins/caplets/assets/icon.png docs/assets/caplets-icon.png
+git rm -r plugins/caplets .agents/plugins/marketplace.json .claude-plugin/marketplace.json scripts/sync-plugin-versions.ts
+```
+
+Then update the README image path from `plugins/caplets/assets/icon.png` to `docs/assets/caplets-icon.png`.
+
+- [ ] **Step 4: Remove version sync wiring**
+
+In `package.json`, remove `scripts/sync-plugin-versions.ts` from the `version-packages` script. The final script must still run Changesets and formatting for remaining generated files:
+
+```json
+"version-packages": "changeset version && oxlint --fix --quiet && oxfmt --write ."
+```
+
+If the current script has additional non-plugin commands, preserve them and remove only the plugin sync command.
+
+- [ ] **Step 5: Run plugin tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/agent-plugins.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add package.json packages/core/test/agent-plugins.test.ts README.md docs/assets/caplets-icon.png
+git add -u plugins .agents .claude-plugin scripts
+git commit -m "refactor(core): remove codex and claude plugins"
+```
+
+---
+
+## Task 8: Manual MCP Documentation
+
+**Files:**
+
+- Modify: `README.md`
+- Modify: `packages/cli/README.md`
+- Modify: `packages/core/test/agent-plugins.test.ts`
+
+- [ ] **Step 1: Add docs contract assertions**
+
+Extend `packages/core/test/agent-plugins.test.ts`:
+
+```ts
+it("documents serve for local MCP and attach for remote MCP", async () => {
+ const readme = await readFile(path.join(repoRoot, "README.md"), "utf8");
+ expect(readme).toContain("caplets serve");
+ expect(readme).toContain("caplets attach");
+ expect(readme).toContain("CAPLETS_MODE=cloud");
+ expect(readme).toContain("CAPLETS_REMOTE_URL=https://cloud.caplets.dev");
+ expect(readme).toContain("CAPLETS_MODE=remote");
+});
+```
+
+- [ ] **Step 2: Update top-level Core README integration table**
+
+In `README.md`, replace the existing agent integration table rows for Claude and Codex with:
+
+```md
+| Codex | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway |
+| Claude Code | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway |
+```
+
+Add the manual MCP examples exactly:
+
+```json
+{
+ "mcpServers": {
+ "caplets": {
+ "command": "caplets",
+ "args": ["serve"]
+ }
+ }
+}
+```
+
+```json
+{
+ "mcpServers": {
+ "caplets": {
+ "command": "caplets",
+ "args": ["attach"]
+ }
+ }
+}
+```
+
+Add Cloud env setup:
+
+```bash
+caplets cloud auth login
+export CAPLETS_MODE=cloud
+export CAPLETS_REMOTE_URL=https://cloud.caplets.dev
+```
+
+Add self-hosted env setup:
+
+```bash
+export CAPLETS_MODE=remote
+export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets
+export CAPLETS_REMOTE_TOKEN=...
+```
+
+- [ ] **Step 3: Update CLI README**
+
+If `packages/cli/README.md` exists and references plugin installs, replace those paragraphs with the same manual MCP examples from Step 2. If it does not contain plugin guidance, add a short `Manual MCP setup` section with the two JSON examples.
+
+- [ ] **Step 4: Run docs contract tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/agent-plugins.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md packages/cli/README.md packages/core/test/agent-plugins.test.ts
+git commit -m "docs(core): prefer manual mcp setup"
+```
+
+---
+
+## Task 9: CLI Error Handling And Regression Matrix
+
+**Files:**
+
+- Modify: `packages/core/test/attach-cli.test.ts`
+- Modify: `packages/core/test/remote-options.test.ts`
+- Modify: `packages/core/test/native-options.test.ts`
+- Modify: `packages/core/src/cli.ts`
+- Modify: `packages/core/src/remote/selection.ts`
+
+- [ ] **Step 1: Add explicit error tests**
+
+Add these cases across the listed test files:
+
+```ts
+it("prints JSON error for attach --once when cloud auth is missing", async () => {
+ const out: string[] = [];
+ let exitCode = 0;
+ await runCli(["attach", "--once", "--json"], {
+ env: {
+ CAPLETS_MODE: "cloud",
+ CAPLETS_REMOTE_URL: "https://cloud.caplets.dev",
+ },
+ writeOut: (value) => out.push(value),
+ setExitCode: (code) => {
+ exitCode = code;
+ },
+ });
+
+ expect(exitCode).toBe(1);
+ expect(JSON.parse(out.join(""))).toMatchObject({
+ error: {
+ code: "cloud_auth_required",
+ recoveryCommand: "caplets cloud auth login",
+ },
+ });
+});
+
+it("references CAPLETS_REMOTE_TOKEN or Basic Auth vars for self-hosted auth failures", async () => {
+ await expect(
+ resolveCapletsRemote(
+ { user: "caplets" },
+ { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" },
+ ),
+ ).toThrow(/CAPLETS_REMOTE_PASSWORD/u);
+});
+```
+
+- [ ] **Step 2: Run focused regression tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/remote-options.test.ts test/native-options.test.ts
+```
+
+Expected: FAIL for any missing structured error handling.
+
+- [ ] **Step 3: Normalize errors**
+
+In `packages/core/src/remote/selection.ts`, throw `CapletsError` or `ProjectBindingError` for every user-facing failure:
+
+- local mode for attach: `REQUEST_INVALID`
+- missing remote URL for attach: `REQUEST_INVALID`
+- missing Cloud Auth: existing `projectBindingError("cloud_auth_required")`
+- workspace mismatch: existing `projectBindingError("workspace_switch_required")`
+- Cloud URL mismatch: `REQUEST_INVALID`
+
+In `packages/core/src/cli.ts`, ensure the `--once --json` handler serializes these errors consistently with the existing Project Binding JSON branch.
+
+- [ ] **Step 4: Run regression tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/remote-options.test.ts test/native-options.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/cli.ts packages/core/src/remote/selection.ts packages/core/test/attach-cli.test.ts packages/core/test/remote-options.test.ts packages/core/test/native-options.test.ts
+git commit -m "fix(core): clarify attach remote errors"
+```
+
+---
+
+## Task 10: Changeset And Full Verification
+
+**Files:**
+
+- Add: `.changeset/.md`
+- Modify: any generated schema or docs files changed by package scripts
+
+- [ ] **Step 1: Add Changesets entry**
+
+Create a changeset:
+
+```bash
+pnpm changeset
+```
+
+Use this content:
+
+```md
+---
+"@caplets/core": minor
+"caplets": minor
+"@caplets/opencode": minor
+"@caplets/pi": minor
+---
+
+Make `caplets attach` the remote-backed MCP server command, add Cloud-aware `CAPLETS_MODE` resolution, keep OpenCode and Pi on the shared resolver, and remove Codex/Claude plugin artifacts in favor of manual MCP configuration.
+```
+
+- [ ] **Step 2: Run formatting**
+
+Run:
+
+```bash
+pnpm format:check
+```
+
+Expected: PASS. If it fails only on changed files, run `pnpm format` and re-run `pnpm format:check`.
+
+- [ ] **Step 3: Run Core focused tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/core test -- test/remote-options.test.ts test/remote-selection.test.ts test/attach-cli.test.ts test/attach-server.test.ts test/cloud-auth-refresh-attach.test.ts test/native-options.test.ts test/native-remote.test.ts test/agent-plugins.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run integration package tests**
+
+Run:
+
+```bash
+pnpm --filter @caplets/opencode test
+pnpm --filter @caplets/pi test
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run Core verification**
+
+Run:
+
+```bash
+pnpm core:verify
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Run root coordination verification**
+
+Run:
+
+```bash
+pnpm verify
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Manual smoke commands**
+
+Build first:
+
+```bash
+pnpm core:build
+```
+
+Smoke local serve help:
+
+```bash
+node core/packages/cli/dist/index.js serve --help
+```
+
+Expected output includes `Serve configured Caplets as an MCP server.` and does not mention Cloud Auth.
+
+Smoke attach help:
+
+```bash
+node core/packages/cli/dist/index.js attach --help
+```
+
+Expected output includes `Start a remote-backed Caplets MCP server.`, `--transport `, `--once`, and `--remote-url `.
+
+Smoke local-mode attach rejection:
+
+```bash
+CAPLETS_MODE=local node core/packages/cli/dist/index.js attach --transport stdio
+```
+
+Expected: exits non-zero with `use caplets serve for local-only MCP`.
+
+- [ ] **Step 8: Final commit**
+
+```bash
+git add .
+git commit -m "chore(core): verify remote attach integration"
+```
+
+If every previous task already committed all files and the working tree is clean, skip the final commit and record the verification commands in the final response.
+
+---
+
+## Self-Review Checklist
+
+- [ ] `caplets serve` remains local-only for stdio and HTTP and does not consult Cloud Auth.
+- [ ] `caplets attach` starts an MCP server by default and supports stdio and HTTP transports.
+- [ ] `caplets attach --once` remains a finite Project Binding smoke path.
+- [ ] `CAPLETS_MODE=local` rejects attach and points users to `caplets serve`.
+- [ ] `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and uses self-hosted token or Basic Auth.
+- [ ] `CAPLETS_MODE=cloud` requires a Cloud URL and saved `caplets cloud auth login` credentials.
+- [ ] `CAPLETS_MODE=auto` detects Cloud from `CAPLETS_REMOTE_URL`, detects self-hosted remotes from non-Cloud URLs, and falls back to local only for native integrations with no remote URL.
+- [ ] Cloud mode ignores self-hosted `CAPLETS_REMOTE_TOKEN` and uses saved Cloud Auth.
+- [ ] Cloud mode refreshes expired saved credentials before attach/native remote use.
+- [ ] Cloud mode starts Project Binding automatically and preserves local/project overlay precedence.
+- [ ] OpenCode and Pi remain installed native integrations and require no Cloud-specific plugin manifest.
+- [ ] Codex and Claude plugin marketplace metadata, bundled MCP config, and shared plugin skill are removed.
+- [ ] Codex and Claude docs show manual MCP config for both `serve` and `attach`.
+- [ ] Focused tests, package tests, `pnpm core:verify`, and root `pnpm verify` pass.
diff --git a/docs/project-binding.md b/docs/project-binding.md
new file mode 100644
index 0000000..29883e3
--- /dev/null
+++ b/docs/project-binding.md
@@ -0,0 +1,55 @@
+# Project Binding
+
+Project Binding connects one local project root to a remote Caplets runtime so project-bound tools can run against the same files the user is editing.
+
+## Attach Modes
+
+`caplets attach --once` validates Cloud Auth or self-hosted remote credentials, bootstraps `.caplets/.gitignore`, checks the Project Binding endpoint, runs sync preflight when the project root exists, and exits.
+
+`caplets attach` opens a foreground Binding Session. It reports state events, keeps the Project Binding lease alive, and exits only when interrupted or when the session reaches a terminal state.
+
+Hosted Cloud uses `caplets cloud auth login` and a Selected Workspace. Self-hosted remotes use `CAPLETS_REMOTE_URL` plus `CAPLETS_REMOTE_TOKEN` or Basic Auth credentials. Passing `--workspace` must match the saved Selected Workspace; use `caplets cloud auth switch ` for an explicit switch.
+
+## Binding Session Loop
+
+Foreground attach creates a server-side Binding Session through the Project Binding control API, opens the control WebSocket, emits normalized JSON events, sends periodic heartbeats, and sends a remote session-end request when interrupted. A single reconnectable WebSocket close emits a `reconnecting` event before retrying the same Binding Session.
+
+`caplets attach --once` remains finite. It only probes the HTTP equivalent of the WebSocket endpoint and accepts the `websocket_upgrade_required` response as proof that the endpoint is reachable.
+
+Hosted attach refreshes expired local Cloud Auth credentials before creating the Binding Session. If the saved refresh credential has been revoked, attach fails closed and asks the user to log in again.
+
+## States
+
+Binding Session states are:
+
+- `not_attached`
+- `attaching`
+- `syncing`
+- `ready`
+- `degraded`
+- `blocked`
+- `offline`
+- `cleaning_up`
+- `ended`
+- `expired`
+
+Terminal states include a reason with a stable code, message, optional request ID, and recovery command.
+
+## Sync Safety
+
+Sync filtering applies in this order:
+
+- Caplets hard denylist, including `.git`, `node_modules`, `.venv`, caches, build outputs, archives, private keys, and unsafe env files.
+- `.gitignore`.
+- `.capletsignore`.
+
+Safe env templates remain allowed: `.env.example`, `.env.sample`, and `.env.template`.
+
+Hosted defaults are Free 25 MiB per file and 250 MiB per project, Plus 100 MiB and 1 GiB, Pro 250 MiB and 5 GiB, and Enterprise policy-controlled. Self-hosted defaults to 250 MiB and 5 GiB.
+
+## Recovery
+
+- `cloud_auth_required`: `caplets cloud auth login`
+- `workspace_switch_required`: `caplets cloud auth switch `
+- `sync_size_limit_exceeded`: add exclusions to `.capletsignore` or upgrade the workspace plan
+- `endpoint_unavailable`: `caplets doctor`
diff --git a/infra/alchemy-domains.ts b/infra/alchemy-domains.ts
new file mode 100644
index 0000000..bb4c388
--- /dev/null
+++ b/infra/alchemy-domains.ts
@@ -0,0 +1,22 @@
+const globalBaseDomain = "caplets.dev";
+
+export interface AlchemyDomains {
+ baseDomain: string;
+ landingPageDomain: string;
+ landingPageUrl: string;
+}
+
+export function buildAlchemyDomains(
+ stage: string,
+ { local = false }: { local?: boolean } = {},
+): AlchemyDomains {
+ const baseDomain = stage === "prod" ? globalBaseDomain : `${stage}.preview.${globalBaseDomain}`;
+ const landingPageDomain = baseDomain;
+ const landingPageUrl = local ? `http://localhost:4321` : `https://${landingPageDomain}`;
+
+ return {
+ baseDomain,
+ landingPageDomain,
+ landingPageUrl,
+ };
+}
diff --git a/scripts/alchemy-fetch-compat.test.ts b/infra/alchemy-fetch-compat.test.ts
similarity index 93%
rename from scripts/alchemy-fetch-compat.test.ts
rename to infra/alchemy-fetch-compat.test.ts
index 446dd42..55b94f1 100644
--- a/scripts/alchemy-fetch-compat.test.ts
+++ b/infra/alchemy-fetch-compat.test.ts
@@ -7,7 +7,7 @@ test("Alchemy fetch compatibility shim removes userland undici dispatcher before
return new Response("ok");
};
- await import("./alchemy-fetch-compat");
+ await import("./alchemy-fetch-compat.js");
const response = await globalThis.fetch("https://example.test", {
dispatcher: { dispatch() {} },
diff --git a/scripts/alchemy-fetch-compat.ts b/infra/alchemy-fetch-compat.ts
similarity index 100%
rename from scripts/alchemy-fetch-compat.ts
rename to infra/alchemy-fetch-compat.ts
diff --git a/infra/alchemy-runner.test.ts b/infra/alchemy-runner.test.ts
new file mode 100644
index 0000000..dc03509
--- /dev/null
+++ b/infra/alchemy-runner.test.ts
@@ -0,0 +1,39 @@
+import { expect, test } from "vitest";
+import { fileURLToPath } from "node:url";
+
+import { buildAlchemyDomains } from "./alchemy-domains.js";
+import { buildNodeOptions } from "./alchemy-runner.js";
+
+const shimPath = fileURLToPath(new URL("./alchemy-fetch-compat.ts", import.meta.url));
+
+test("Alchemy runner injects fetch shim through NODE_OPTIONS for child processes", () => {
+ expect(buildNodeOptions(undefined)).toBe(`--import=${shimPath}`);
+});
+
+test("Alchemy runner preserves existing NODE_OPTIONS after the fetch shim", () => {
+ expect(buildNodeOptions("--trace-warnings")).toBe(`--import=${shimPath} --trace-warnings`);
+});
+
+test("Alchemy runner keeps fetch compatibility shim for cloud deployments", () => {
+ expect(buildNodeOptions()).toContain("alchemy-fetch-compat");
+});
+
+test.each([
+ {
+ landingPageDomain: "caplets.dev",
+ stage: "prod",
+ },
+ {
+ landingPageDomain: "branch.preview.caplets.dev",
+ stage: "branch",
+ },
+ {
+ landingPageDomain: "dev.preview.caplets.dev",
+ stage: "dev",
+ },
+])("derives matching domains for $stage", ({ landingPageDomain, stage }) => {
+ expect(buildAlchemyDomains(stage)).toMatchObject({
+ landingPageDomain,
+ landingPageUrl: `https://${landingPageDomain}`,
+ });
+});
diff --git a/scripts/alchemy-runner.ts b/infra/alchemy-runner.ts
similarity index 100%
rename from scripts/alchemy-runner.ts
rename to infra/alchemy-runner.ts
diff --git a/mise.toml b/mise.toml
index 6ea5a7e..bbf696b 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,2 +1,3 @@
[tools]
node = "24"
+pnpm = "11.5.0"
diff --git a/output/playwright/cloud-ui-critique.png b/output/playwright/cloud-ui-critique.png
new file mode 100644
index 0000000..a398c92
Binary files /dev/null and b/output/playwright/cloud-ui-critique.png differ
diff --git a/package.json b/package.json
index 0917b59..a00a7a1 100644
--- a/package.json
+++ b/package.json
@@ -1,20 +1,21 @@
{
- "name": "@caplets/monorepo",
+ "name": "@caplets/core-mono",
"private": true,
"type": "module",
"scripts": {
- "alchemy:dev": "tsx ./scripts/alchemy-runner.ts dev",
- "alchemy:deploy": "tsx ./scripts/alchemy-runner.ts deploy",
- "alchemy:destroy": "tsx ./scripts/alchemy-runner.ts destroy",
- "clean": "turbo clean",
- "build": "turbo build",
- "build:watch": "turbo build:watch",
+ "alchemy:deploy": "tsx ./infra/alchemy-runner.ts deploy",
+ "alchemy:destroy": "tsx ./infra/alchemy-runner.ts destroy",
+ "alchemy:dev": "tsx ./infra/alchemy-runner.ts dev",
"benchmark": "pnpm --filter @caplets/benchmarks benchmark",
"benchmark:check": "pnpm --filter @caplets/benchmarks benchmark:check",
"benchmark:live": "pnpm --filter @caplets/benchmarks benchmark:live",
"benchmark:live:opencode": "pnpm --filter @caplets/benchmarks benchmark:live:opencode",
"benchmark:live:pi": "pnpm --filter @caplets/benchmarks benchmark:live:pi",
+ "build": "turbo build",
+ "build:watch": "turbo build:watch",
"changeset": "changeset",
+ "clean": "turbo clean",
+ "clean-install": "rm -rf **/node_modules pnpm-lock.yaml && pnpm install",
"dev": "tsx ./scripts/dev.ts",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
@@ -25,30 +26,30 @@
"release": "turbo build && changeset publish",
"schema:check": "tsx ./scripts/generate-config-schema.ts --check",
"schema:generate": "tsx ./scripts/generate-config-schema.ts",
- "typecheck": "tsgo --noEmit && turbo typecheck",
"test": "vitest run",
+ "typecheck": "tsgo --noEmit && turbo typecheck",
"verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm benchmark:check && pnpm build",
- "version-packages": "changeset version && tsx scripts/sync-plugin-versions.ts && oxfmt plugins/caplets/.codex-plugin/plugin.json plugins/caplets/.claude-plugin/plugin.json"
+ "version-packages": "changeset version && oxlint --fix --quiet && oxfmt --write ."
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
- "@cloudflare/workers-types": "^4.20260529.1",
+ "@cloudflare/workers-types": "^4.20260603.1",
"@types/node": "^25.9.1",
- "@typescript/native-preview": "7.0.0-dev.20260527.2",
- "alchemy": "0.93.9",
+ "@typescript/native-preview": "7.0.0-dev.20260603.1",
+ "alchemy": "0.93.10",
"husky": "^9.1.7",
- "lint-staged": "^17.0.5",
- "oxfmt": "^0.52.0",
- "oxlint": "^1.67.0",
+ "lint-staged": "^17.0.7",
+ "oxfmt": "^0.53.0",
+ "oxlint": "^1.68.0",
"prettier-plugin-astro": "^0.14.1",
"rolldown": "^1.0.3",
- "tsx": "^4.22.3",
+ "tsx": "^4.22.4",
"turbo": "^2.9.16",
"typescript": "^6.0.3",
- "vitest": "^4.1.7"
+ "vitest": "^4.1.8"
},
"engines": {
- "node": ">=22"
+ "node": ">=24"
},
- "packageManager": "pnpm@11.4.0"
+ "packageManager": "pnpm@11.5.0"
}
diff --git a/packages/benchmarks/package.json b/packages/benchmarks/package.json
index 91a13dc..e16919a 100644
--- a/packages/benchmarks/package.json
+++ b/packages/benchmarks/package.json
@@ -17,9 +17,9 @@
},
"devDependencies": {
"@types/node": "^25.9.1",
- "@typescript/native-preview": "7.0.0-dev.20260527.2",
+ "@typescript/native-preview": "7.0.0-dev.20260603.1",
"caplets": "workspace:*",
"typescript": "^6.0.3",
- "vitest": "^4.1.7"
+ "vitest": "^4.1.8"
}
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 8979eb3..7bd4449 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -46,10 +46,10 @@
},
"devDependencies": {
"@types/node": "^25.9.1",
- "@typescript/native-preview": "7.0.0-dev.20260527.2",
+ "@typescript/native-preview": "7.0.0-dev.20260603.1",
"rolldown": "^1.0.3",
"typescript": "^6.0.3",
- "vitest": "^4.1.7"
+ "vitest": "^4.1.8"
},
"engines": {
"node": ">=22"
diff --git a/packages/core/package.json b/packages/core/package.json
index 380184e..02283f4 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -38,6 +38,22 @@
"./native": {
"types": "./dist/native.d.ts",
"default": "./dist/native.js"
+ },
+ "./caplet-source": {
+ "types": "./dist/caplet-source/index.d.ts",
+ "default": "./dist/caplet-source.js"
+ },
+ "./caplet-source/filesystem": {
+ "types": "./dist/caplet-source/filesystem.d.ts",
+ "default": "./dist/caplet-source/filesystem.js"
+ },
+ "./runtime-plan": {
+ "types": "./dist/runtime-plan/index.d.ts",
+ "default": "./dist/runtime-plan.js"
+ },
+ "./config-runtime": {
+ "types": "./dist/config-runtime.d.ts",
+ "default": "./dist/config-runtime.js"
}
},
"publishConfig": {
@@ -57,7 +73,7 @@
"@hono/node-server": "^2.0.4",
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^15.0.0",
- "graphql": "^16.14.0",
+ "graphql": "^16.14.1",
"hono": "^4.12.23",
"vfile": "^6.0.3",
"vfile-matter": "^5.0.1",
@@ -66,10 +82,10 @@
},
"devDependencies": {
"@types/node": "^25.9.1",
- "@typescript/native-preview": "7.0.0-dev.20260527.2",
+ "@typescript/native-preview": "7.0.0-dev.20260603.1",
"rolldown": "^1.0.3",
"typescript": "^6.0.3",
- "vitest": "^4.1.7"
+ "vitest": "^4.1.8"
},
"engines": {
"node": ">=22"
diff --git a/packages/core/rolldown.config.ts b/packages/core/rolldown.config.ts
index b3d04df..9ed8b39 100644
--- a/packages/core/rolldown.config.ts
+++ b/packages/core/rolldown.config.ts
@@ -1,14 +1,29 @@
import { defineConfig } from "rolldown";
-export default defineConfig({
- input: {
- index: "src/index.ts",
- "generated-tool-input-schema": "src/generated-tool-input-schema.ts",
- native: "src/native.ts",
+export default defineConfig([
+ {
+ input: {
+ index: "src/index.ts",
+ "caplet-source/filesystem": "src/caplet-source/filesystem.ts",
+ "config-runtime": "src/config-runtime.ts",
+ "generated-tool-input-schema": "src/generated-tool-input-schema.ts",
+ native: "src/native.ts",
+ },
+ output: {
+ dir: "./dist",
+ format: "esm",
+ },
+ platform: "node",
},
- output: {
- dir: "./dist",
- format: "esm",
+ {
+ input: {
+ "caplet-source": "src/caplet-source/index.ts",
+ "runtime-plan": "src/runtime-plan/index.ts",
+ },
+ output: {
+ dir: "./dist",
+ format: "esm",
+ },
+ platform: "browser",
},
- platform: "node",
-});
+]);
diff --git a/packages/core/src/attach/options.ts b/packages/core/src/attach/options.ts
new file mode 100644
index 0000000..22a2108
--- /dev/null
+++ b/packages/core/src/attach/options.ts
@@ -0,0 +1,29 @@
+import {
+ resolveRemoteSelection,
+ type RemoteSelectionInput,
+ type ResolvedRemoteSelection,
+} from "../remote/selection";
+import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "../serve/options";
+
+export type RawAttachServeOptions = RemoteSelectionInput &
+ RawServeOptions & {
+ projectRoot?: string;
+ };
+
+export type AttachServeOptions = ServeOptions & {
+ projectRoot: string;
+ selection: ResolvedRemoteSelection;
+};
+
+export async function resolveAttachServeOptions(
+ raw: RawAttachServeOptions = {},
+ env: Record = process.env,
+): Promise {
+ const selection = await resolveRemoteSelection(raw, env);
+ const serve = resolveServeOptions(raw, env);
+ return {
+ ...serve,
+ projectRoot: raw.projectRoot ?? process.cwd(),
+ selection,
+ };
+}
diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts
new file mode 100644
index 0000000..c1faeca
--- /dev/null
+++ b/packages/core/src/attach/server.ts
@@ -0,0 +1,77 @@
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
+import type { CapletsRemoteAuth } from "../remote/options";
+import { createSdkRemoteCapletsClient } from "../native/remote";
+import { createNativeCapletsService } from "../native/service";
+import type { NativeRemoteAuthOptions } from "../native/options";
+import { serveHttpWithSessionFactory } from "../serve/http";
+import { NativeCapletsMcpSession } from "../serve/native-session";
+import type { AttachServeOptions } from "./options";
+
+export type AttachServeIo = {
+ writeErr?: (value: string) => void;
+};
+
+export async function attachResolvedCaplets(
+ options: AttachServeOptions,
+ io: AttachServeIo = {},
+): Promise {
+ if (options.transport === "stdio") {
+ const service = createAttachNativeService(options, io);
+ const session = new NativeCapletsMcpSession(service);
+ await service.reload();
+ await session.connect(new StdioServerTransport());
+ return;
+ }
+
+ await serveHttpWithSessionFactory(
+ options,
+ async () => {
+ const service = createAttachNativeService(options, io);
+ await service.reload();
+ return new NativeCapletsMcpSession(service);
+ },
+ io.writeErr,
+ );
+}
+
+function createAttachNativeService(options: AttachServeOptions, io: AttachServeIo) {
+ return createNativeCapletsService({
+ mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote",
+ server: {
+ url: options.selection.remote.baseUrl.toString(),
+ ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}),
+ },
+ remote: {
+ ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}),
+ ...(options.selection.kind === "hosted_cloud"
+ ? {
+ cloud: {
+ url: options.selection.cloudPresence.url.toString(),
+ accessToken: options.selection.cloudPresence.accessToken,
+ workspaceId: options.selection.cloudPresence.workspaceId,
+ projectRoot: options.projectRoot,
+ },
+ }
+ : {}),
+ },
+ remoteClientFactory: (resolved) =>
+ createSdkRemoteCapletsClient({
+ ...resolved,
+ requestInit: options.selection.remote.requestInit,
+ auth: nativeAuthFromRemoteAuth(options.selection.remote.auth),
+ url: options.selection.remote.mcpUrl,
+ ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}),
+ }),
+ ...(io.writeErr ? { writeErr: io.writeErr } : {}),
+ });
+}
+
+function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions {
+ if (auth.type === "basic") {
+ return { enabled: true, user: auth.user, password: auth.password };
+ }
+ if (auth.type === "none") {
+ return { enabled: false, user: auth.user };
+ }
+ return { enabled: false, user: "caplets" };
+}
diff --git a/packages/core/src/caplet-files-bundle.ts b/packages/core/src/caplet-files-bundle.ts
new file mode 100644
index 0000000..a07988a
--- /dev/null
+++ b/packages/core/src/caplet-files-bundle.ts
@@ -0,0 +1,1158 @@
+import { parse as parseYaml } from "yaml";
+import { z } from "zod";
+import {
+ FORBIDDEN_HEADERS,
+ HEADER_NAME_PATTERN,
+ HTTP_BASE_URL_PATTERN,
+ SERVER_ID_PATTERN,
+ isAllowedHttpBaseUrl,
+ isAllowedRemoteUrl,
+ isUrl,
+ validateHttpActionHeaders,
+} from "./config/validation";
+import { CapletsError, redactSecrets } from "./errors";
+import { nestedSchema, schemaPath } from "./schema-utils";
+
+const MAX_CAPLET_FILE_BYTES = 128 * 1024;
+const MAX_CAPLET_BODY_CHARS = 64 * 1024;
+
+const capletRemoteAuthSchema = z
+ .discriminatedUnion("type", [
+ z.object({ type: z.literal("none") }).strict(),
+ z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(),
+ z
+ .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) })
+ .strict(),
+ z
+ .object({
+ type: z.literal("oauth2"),
+ authorizationUrl: z.string().min(1).optional(),
+ tokenUrl: z.string().min(1).optional(),
+ issuer: z.string().min(1).optional(),
+ resourceMetadataUrl: z.string().min(1).optional(),
+ authorizationServerMetadataUrl: z.string().min(1).optional(),
+ openidConfigurationUrl: z.string().min(1).optional(),
+ clientMetadataUrl: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ redirectUri: z.string().min(1).optional(),
+ })
+ .strict(),
+ z
+ .object({
+ type: z.literal("oidc"),
+ authorizationUrl: z.string().min(1).optional(),
+ tokenUrl: z.string().min(1).optional(),
+ issuer: z.string().min(1).optional(),
+ resourceMetadataUrl: z.string().min(1).optional(),
+ authorizationServerMetadataUrl: z.string().min(1).optional(),
+ openidConfigurationUrl: z.string().min(1).optional(),
+ clientMetadataUrl: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ redirectUri: z.string().min(1).optional(),
+ })
+ .strict(),
+ ])
+ .describe("Authentication settings for a remote MCP server.");
+
+const capletSetupCommandSchema = z
+ .object({
+ label: z.string().min(1).describe("Human-readable setup or verification step label."),
+ command: z.string().min(1).describe("Executable command to spawn without a shell."),
+ args: z.array(z.string()).optional().describe("Arguments passed to the command."),
+ env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."),
+ cwd: z.string().min(1).optional().describe("Working directory for this command."),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ })
+ .strict();
+
+const capletSetupSchema = z
+ .object({
+ commands: z.array(capletSetupCommandSchema).optional(),
+ verify: z.array(capletSetupCommandSchema).optional(),
+ })
+ .strict()
+ .refine(
+ (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0,
+ "setup must define at least one command or verify step",
+ )
+ .describe("Optional explicit setup and verification metadata for this Caplet.");
+
+const capletProjectBindingSchema = z
+ .object({ required: z.literal(true) })
+ .strict()
+ .describe("Project Binding requirements for Caplets that need an attached project.");
+
+const capletRuntimeFeatureSchema = z.enum(["docker", "browser"]);
+
+const capletRuntimeRequirementsSchema = z
+ .object({
+ features: z
+ .array(capletRuntimeFeatureSchema)
+ .refine(
+ (features) => new Set(features).size === features.length,
+ "runtime.features must not contain duplicate feature names",
+ )
+ .optional(),
+ resources: z
+ .object({
+ class: z.enum(["standard", "large", "heavy"]).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .describe("Runtime feature and resource requirements for hosted execution.");
+
+const capletEndpointAuthSchema = z
+ .discriminatedUnion("type", [
+ z.object({ type: z.literal("none") }).strict(),
+ z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(),
+ z
+ .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) })
+ .strict(),
+ z
+ .object({
+ type: z.literal("oauth2"),
+ authorizationUrl: z.string().min(1).optional(),
+ tokenUrl: z.string().min(1).optional(),
+ issuer: z.string().min(1).optional(),
+ resourceMetadataUrl: z.string().min(1).optional(),
+ authorizationServerMetadataUrl: z.string().min(1).optional(),
+ openidConfigurationUrl: z.string().min(1).optional(),
+ clientMetadataUrl: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ redirectUri: z.string().min(1).optional(),
+ })
+ .strict(),
+ z
+ .object({
+ type: z.literal("oidc"),
+ authorizationUrl: z.string().min(1).optional(),
+ tokenUrl: z.string().min(1).optional(),
+ issuer: z.string().min(1).optional(),
+ resourceMetadataUrl: z.string().min(1).optional(),
+ authorizationServerMetadataUrl: z.string().min(1).optional(),
+ openidConfigurationUrl: z.string().min(1).optional(),
+ clientMetadataUrl: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ redirectUri: z.string().min(1).optional(),
+ })
+ .strict(),
+ ])
+ .describe("Authentication settings for an OpenAPI or GraphQL endpoint.");
+
+const capletMcpServerSchema = z
+ .object({
+ transport: z
+ .enum(["stdio", "http", "sse"])
+ .optional()
+ .describe("Downstream MCP transport. Defaults to stdio when command is present."),
+ command: z.string().min(1).optional().describe("Executable command for stdio servers."),
+ args: z.array(z.string()).optional().describe("Arguments passed to the stdio command."),
+ env: z
+ .record(z.string(), z.string())
+ .optional()
+ .describe("Environment variables for stdio servers. Supports ${VAR} and $env:VAR."),
+ cwd: z.string().min(1).optional().describe("Working directory for stdio servers."),
+ url: z.string().min(1).optional().describe("Remote MCP server URL for http or sse transport."),
+ auth: capletRemoteAuthSchema.optional(),
+ startupTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for starting or checking a downstream server."),
+ callTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for downstream tool calls."),
+ toolCacheTtlMs: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe("Milliseconds downstream tool metadata stays fresh. Set 0 to refresh every time."),
+ disabled: z
+ .boolean()
+ .optional()
+ .describe("When true, omit this Caplet from discovery and do not start its MCP server."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict()
+ .superRefine((server, ctx) => {
+ const effectiveTransport = server.transport ?? (server.command ? "stdio" : undefined);
+ const hasStdio = Boolean(server.command);
+ const hasRemote = Boolean(server.url);
+ if (hasStdio === hasRemote) {
+ ctx.addIssue({
+ code: "custom",
+ message: "mcpServer must define exactly one connection shape: command or url",
+ });
+ }
+
+ if (effectiveTransport === "stdio" && !server.command) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["command"],
+ message: "stdio servers require command",
+ });
+ }
+
+ if ((effectiveTransport === "http" || effectiveTransport === "sse") && !server.url) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["url"],
+ message: "remote servers require url",
+ });
+ }
+
+ if (server.url && !hasEnvReference(server.url) && !isAllowedRemoteUrl(server.url)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["url"],
+ message: "remote url must use https except loopback development urls",
+ });
+ }
+
+ if (server.auth?.type === "oauth2" || server.auth?.type === "oidc") {
+ for (const field of [
+ "authorizationUrl",
+ "tokenUrl",
+ "issuer",
+ "clientMetadataUrl",
+ "redirectUri",
+ ] as const) {
+ const value = server.auth[field];
+ if (value && !hasEnvReference(value) && !isUrl(value)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["auth", field],
+ message: `${field} must be a URL or environment reference`,
+ });
+ }
+ }
+ }
+
+ if (server.auth?.type === "headers") {
+ for (const headerName of Object.keys(server.auth.headers)) {
+ const normalized = headerName.toLowerCase();
+ if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["auth", "headers", headerName],
+ message: `header ${headerName} is not allowed`,
+ });
+ }
+ }
+ }
+ });
+
+const capletOpenApiEndpointSchema = z
+ .object({
+ specPath: z.string().min(1).optional().describe("Local OpenAPI specification path."),
+ specUrl: z.string().min(1).optional().describe("Remote OpenAPI specification URL."),
+ baseUrl: z.string().min(1).optional().describe("Override base URL for OpenAPI requests."),
+ auth: capletEndpointAuthSchema.describe(
+ 'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.',
+ ),
+ requestTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for OpenAPI HTTP requests."),
+ operationCacheTtlMs: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe(
+ "Milliseconds OpenAPI operation metadata stays fresh. Set 0 to refresh every time.",
+ ),
+ disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict()
+ .superRefine((endpoint, ctx) => {
+ if (Boolean(endpoint.specPath) === Boolean(endpoint.specUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ message: "openapiEndpoint must define exactly one spec source: specPath or specUrl",
+ });
+ }
+ if (
+ endpoint.specUrl &&
+ !hasEnvReference(endpoint.specUrl) &&
+ !isAllowedRemoteUrl(endpoint.specUrl)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["specUrl"],
+ message: "OpenAPI specUrl must use https except loopback development urls",
+ });
+ }
+ if (
+ endpoint.baseUrl &&
+ !hasEnvReference(endpoint.baseUrl) &&
+ !isAllowedRemoteUrl(endpoint.baseUrl)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["baseUrl"],
+ message: "OpenAPI baseUrl must use https except loopback development urls",
+ });
+ }
+ validateEndpointAuthHeaders(endpoint.auth, ctx);
+ });
+
+const capletGraphQlOperationSchema = z
+ .object({
+ document: z.string().min(1).optional().describe("Inline GraphQL operation document."),
+ documentPath: z.string().min(1).optional().describe("Path to a GraphQL operation document."),
+ operationName: z.string().min(1).optional().describe("Operation name to execute."),
+ description: z.string().min(1).optional().describe("Operation capability description."),
+ })
+ .strict()
+ .superRefine((operation, ctx) => {
+ if (Boolean(operation.document) === Boolean(operation.documentPath)) {
+ ctx.addIssue({
+ code: "custom",
+ message:
+ "GraphQL operation must define exactly one document source: document or documentPath",
+ });
+ }
+ });
+
+const capletGraphQlEndpointSchema = z
+ .object({
+ endpointUrl: z.string().min(1).describe("GraphQL HTTP endpoint URL."),
+ schemaPath: z.string().min(1).optional().describe("Local GraphQL SDL or introspection path."),
+ schemaUrl: z.string().min(1).optional().describe("Remote GraphQL SDL or introspection URL."),
+ introspection: z
+ .literal(true)
+ .optional()
+ .describe("Load schema through endpoint introspection."),
+ operations: z
+ .record(z.string().regex(SERVER_ID_PATTERN), capletGraphQlOperationSchema)
+ .optional()
+ .describe("Configured GraphQL operations keyed by stable tool name."),
+ auth: capletEndpointAuthSchema.describe(
+ 'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.',
+ ),
+ requestTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for GraphQL HTTP requests."),
+ operationCacheTtlMs: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe(
+ "Milliseconds GraphQL operation metadata stays fresh. Set 0 to refresh every time.",
+ ),
+ selectionDepth: z
+ .number()
+ .int()
+ .positive()
+ .max(5)
+ .optional()
+ .describe("Maximum depth for auto-generated GraphQL selection sets."),
+ disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict()
+ .superRefine((endpoint, ctx) => {
+ const sourceCount =
+ Number(Boolean(endpoint.schemaPath)) +
+ Number(Boolean(endpoint.schemaUrl)) +
+ Number(endpoint.introspection === true);
+ if (sourceCount !== 1) {
+ ctx.addIssue({
+ code: "custom",
+ message:
+ "graphqlEndpoint must define exactly one schema source: schemaPath, schemaUrl, or introspection",
+ });
+ }
+ if (
+ endpoint.endpointUrl &&
+ !hasEnvReference(endpoint.endpointUrl) &&
+ !isAllowedRemoteUrl(endpoint.endpointUrl)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["endpointUrl"],
+ message: "GraphQL endpointUrl must use https except loopback development urls",
+ });
+ }
+ if (
+ endpoint.schemaUrl &&
+ !hasEnvReference(endpoint.schemaUrl) &&
+ !isAllowedRemoteUrl(endpoint.schemaUrl)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["schemaUrl"],
+ message: "GraphQL schemaUrl must use https except loopback development urls",
+ });
+ }
+ validateEndpointAuthHeaders(endpoint.auth, ctx);
+ });
+
+const httpScalarMappingSchema = z.record(
+ z.string(),
+ z.union([z.string(), z.number(), z.boolean()]),
+);
+
+const capletHttpActionSchema = z
+ .object({
+ method: z
+ .enum(["GET", "POST", "PUT", "PATCH", "DELETE"])
+ .describe("HTTP method used for this action."),
+ path: z
+ .string()
+ .min(1)
+ .regex(/^\//, "HTTP action path must start with /")
+ .describe("URL path appended to the HTTP API baseUrl.")
+ .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //")
+ .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"),
+ description: z.string().min(1).optional().describe("Action capability description."),
+ inputSchema: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .describe("JSON Schema for call_tool arguments."),
+ query: httpScalarMappingSchema.optional().describe("Query parameter mapping."),
+ headers: httpScalarMappingSchema.optional().describe("Request header mapping."),
+ jsonBody: z.unknown().optional().describe("JSON request body mapping."),
+ })
+ .strict()
+ .superRefine((action, ctx) => {
+ if (action.method === "GET" && action.jsonBody !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["jsonBody"],
+ message: "HTTP GET actions must not define jsonBody",
+ });
+ }
+ });
+
+const capletHttpApiSchema = z
+ .object({
+ baseUrl: z
+ .string()
+ .min(1)
+ .regex(
+ HTTP_BASE_URL_PATTERN,
+ "HTTP API baseUrl must not include credentials, query, or fragment",
+ )
+ .describe("Base URL for HTTP action requests."),
+ auth: capletEndpointAuthSchema.describe(
+ 'Explicit HTTP API request auth config. Use {"type":"none"} for public APIs.',
+ ),
+ actions: z
+ .record(z.string().regex(SERVER_ID_PATTERN), capletHttpActionSchema)
+ .refine(
+ (actions) => Object.keys(actions).length > 0,
+ "HTTP API must define at least one action",
+ )
+ .describe("Configured HTTP actions keyed by stable tool name."),
+ requestTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for HTTP action requests."),
+ maxResponseBytes: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Maximum HTTP action response body bytes to read."),
+ disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict()
+ .superRefine((api, ctx) => {
+ if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["baseUrl"],
+ message:
+ "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment",
+ });
+ }
+ validateEndpointAuthHeaders(api.auth, ctx);
+ for (const [actionName, action] of Object.entries(api.actions)) {
+ if (action.headers) {
+ validateHttpActionHeaders(action.headers, ctx, ["actions", actionName, "headers"]);
+ }
+ }
+ });
+
+const capletCliToolOutputSchema = z
+ .object({
+ type: z.enum(["text", "json"]).optional(),
+ })
+ .strict();
+
+const capletCliToolAnnotationsSchema = z
+ .object({
+ readOnlyHint: z.boolean().optional(),
+ destructiveHint: z.boolean().optional(),
+ idempotentHint: z.boolean().optional(),
+ openWorldHint: z.boolean().optional(),
+ })
+ .strict();
+
+const capletCliToolActionSchema = z
+ .object({
+ description: z.string().min(1).optional().describe("Action capability description."),
+ inputSchema: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .describe("JSON Schema for call_tool arguments."),
+ outputSchema: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .describe("JSON Schema for structuredContent returned by this action."),
+ command: z.string().min(1).describe("Executable command to spawn without a shell."),
+ args: z.array(z.string()).optional().describe("Arguments passed to the command."),
+ env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."),
+ cwd: z.string().min(1).optional().describe("Working directory for this action."),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ output: capletCliToolOutputSchema.optional(),
+ annotations: capletCliToolAnnotationsSchema.optional(),
+ })
+ .strict();
+
+const capletCliToolsSchema = z
+ .object({
+ actions: z
+ .record(z.string().regex(SERVER_ID_PATTERN), capletCliToolActionSchema)
+ .refine(
+ (actions) => Object.keys(actions).length > 0,
+ "CLI tools backend must define at least one action",
+ )
+ .describe("Configured CLI actions keyed by stable tool name."),
+ cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."),
+ env: z.record(z.string(), z.string()).optional().describe("Default environment variables."),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict();
+
+const capletSetSchema = z
+ .object({
+ configPath: z.string().min(1).optional().describe("Child Caplets config.json path."),
+ capletsRoot: z.string().min(1).optional().describe("Child Markdown Caplets root directory."),
+ defaultSearchLimit: z.number().int().positive().optional(),
+ maxSearchLimit: z.number().int().positive().max(50).optional(),
+ toolCacheTtlMs: z.number().int().nonnegative().optional(),
+ disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ })
+ .strict()
+ .superRefine((set, ctx) => {
+ if (!set.configPath && !set.capletsRoot) {
+ ctx.addIssue({
+ code: "custom",
+ message: "capletSet must define at least one source: configPath or capletsRoot",
+ });
+ }
+ if (
+ set.defaultSearchLimit !== undefined &&
+ set.maxSearchLimit !== undefined &&
+ set.defaultSearchLimit > set.maxSearchLimit
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["defaultSearchLimit"],
+ message: "defaultSearchLimit must be <= maxSearchLimit",
+ });
+ }
+ });
+
+export const capletFileSchema = z
+ .object({
+ $schema: z
+ .string()
+ .url()
+ .optional()
+ .describe("Optional JSON Schema URL for editor validation."),
+ name: z.string().trim().min(1).max(80).describe("Human-readable Caplet display name."),
+ description: z
+ .string()
+ .describe("Compact capability description shown before the full Caplet card is disclosed.")
+ .refine(
+ (value) => value.trim().length >= 10,
+ "description must contain at least 10 non-whitespace characters",
+ )
+ .refine((value) => value.length <= 1500, "description must be at most 1500 characters"),
+ tags: z
+ .array(z.string().trim().min(1).max(80))
+ .optional()
+ .describe("Optional tags for grouping or searching Caplets."),
+ setup: capletSetupSchema.optional(),
+ projectBinding: capletProjectBindingSchema.optional(),
+ runtime: capletRuntimeRequirementsSchema.optional(),
+ mcpServer: capletMcpServerSchema
+ .describe("MCP server backend configuration for this Caplet.")
+ .optional(),
+ openapiEndpoint: capletOpenApiEndpointSchema
+ .describe("OpenAPI endpoint backend configuration for this Caplet.")
+ .optional(),
+ graphqlEndpoint: capletGraphQlEndpointSchema
+ .describe("GraphQL endpoint backend configuration for this Caplet.")
+ .optional(),
+ httpApi: capletHttpApiSchema
+ .describe("HTTP API backend configuration for this Caplet.")
+ .optional(),
+ cliTools: capletCliToolsSchema
+ .describe("CLI tools backend configuration for this Caplet.")
+ .optional(),
+ capletSet: capletSetSchema
+ .describe("Nested Caplet collection backend configuration for this Caplet.")
+ .optional(),
+ })
+ .strict()
+ .superRefine((frontmatter, ctx) => {
+ const backendCount =
+ Number(Boolean(frontmatter.mcpServer)) +
+ Number(Boolean(frontmatter.openapiEndpoint)) +
+ Number(Boolean(frontmatter.graphqlEndpoint)) +
+ Number(Boolean(frontmatter.httpApi)) +
+ Number(Boolean(frontmatter.cliTools)) +
+ Number(Boolean(frontmatter.capletSet));
+ if (backendCount !== 1) {
+ ctx.addIssue({
+ code: "custom",
+ message:
+ "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, httpApi, cliTools, or capletSet",
+ });
+ }
+ });
+
+type CapletFileFrontmatter = z.infer;
+
+export function capletJsonSchema(): unknown {
+ return patchCapletJsonSchema({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json",
+ title: "Caplet file frontmatter",
+ description: "YAML frontmatter schema for a Markdown Caplet file.",
+ ...z.toJSONSchema(capletFileSchema, { io: "input" }),
+ });
+}
+
+export type CapletFileConfig = {
+ mcpServers?: Record;
+ openapiEndpoints?: Record;
+ graphqlEndpoints?: Record;
+ httpApis?: Record;
+ cliTools?: Record;
+ capletSets?: Record;
+};
+
+export type CapletFileLoadResult = {
+ config: CapletFileConfig;
+ paths: Record;
+};
+
+export type CapletFileMapInput = {
+ files: Array<{ path: string; content: string }>;
+};
+
+export type CapletFileWarning = {
+ path?: string | undefined;
+ message: string;
+};
+
+export type BestEffortCapletFileLoadResult = CapletFileLoadResult & {
+ warnings: CapletFileWarning[];
+};
+
+export function loadCapletFilesFromMap(
+ input: CapletFileMapInput,
+): CapletFileLoadResult | undefined {
+ const files = new Map();
+ for (const file of input.files) {
+ const path = normalizeMapPath(file.path);
+ if (files.has(path)) {
+ throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet file path ${path}`);
+ }
+ files.set(path, file.content);
+ }
+
+ return buildCapletFileLoadResultFromEntries(
+ "in-memory bundle",
+ discoverCapletFileMapCandidates([...files.keys()]),
+ (path) => {
+ const content = files.get(path);
+ if (content === undefined) {
+ throw new CapletsError("CONFIG_INVALID", `Caplet file at ${path} was not found`);
+ }
+ return readCapletFileContent(path, content, mapDirname(path), normalizeBundleLocalPath);
+ },
+ );
+}
+
+export function buildCapletFileLoadResultFromEntries(
+ root: string,
+ candidates: Array<{ id: string; path: string }>,
+ readConfig: (path: string) => unknown,
+ warnings?: CapletFileWarning[],
+): BestEffortCapletFileLoadResult | undefined {
+ const servers: Record = {};
+ const openapiEndpoints: Record = {};
+ const graphqlEndpoints: Record = {};
+ const httpApis: Record = {};
+ const cliTools: Record = {};
+ const capletSets: Record = {};
+ const paths: Record = {};
+
+ function hasId(id: string): boolean {
+ return Boolean(
+ servers[id] ||
+ openapiEndpoints[id] ||
+ graphqlEndpoints[id] ||
+ httpApis[id] ||
+ cliTools[id] ||
+ capletSets[id],
+ );
+ }
+
+ for (const candidate of candidates) {
+ if (hasId(candidate.id)) {
+ const message = `Duplicate Caplet ID ${candidate.id} under ${root}`;
+ if (!warnings) {
+ throw new CapletsError("CONFIG_INVALID", message);
+ }
+ warnings.push({
+ path: candidate.path,
+ message: `${message}; skipping duplicate at ${candidate.path}`,
+ });
+ continue;
+ }
+
+ let config: unknown;
+ try {
+ config = readConfig(candidate.path);
+ } catch (error) {
+ if (!warnings) {
+ throw error;
+ }
+ warnings.push({
+ path: candidate.path,
+ message: `Skipping invalid Caplet file at ${candidate.path}: ${errorMessage(error)}`,
+ });
+ continue;
+ }
+
+ paths[candidate.id] = candidate.path;
+ if (isPlainObject(config) && config.backend === "openapi") {
+ const { backend: _backend, ...endpoint } = config;
+ openapiEndpoints[candidate.id] = endpoint;
+ } else if (isPlainObject(config) && config.backend === "graphql") {
+ const { backend: _backend, ...endpoint } = config;
+ graphqlEndpoints[candidate.id] = endpoint;
+ } else if (isPlainObject(config) && config.backend === "http") {
+ const { backend: _backend, ...endpoint } = config;
+ httpApis[candidate.id] = endpoint;
+ } else if (isPlainObject(config) && config.backend === "cli") {
+ const { backend: _backend, ...endpoint } = config;
+ cliTools[candidate.id] = endpoint;
+ } else if (isPlainObject(config) && config.backend === "caplets") {
+ const { backend: _backend, ...endpoint } = config;
+ capletSets[candidate.id] = endpoint;
+ } else {
+ servers[candidate.id] = config;
+ }
+ }
+
+ const hasServers = Object.keys(servers).length > 0;
+ const hasOpenApi = Object.keys(openapiEndpoints).length > 0;
+ const hasGraphQl = Object.keys(graphqlEndpoints).length > 0;
+ const hasHttpApis = Object.keys(httpApis).length > 0;
+ const hasCliTools = Object.keys(cliTools).length > 0;
+ const hasCapletSets = Object.keys(capletSets).length > 0;
+ const config = {
+ ...(hasServers ? { mcpServers: servers } : {}),
+ ...(hasOpenApi ? { openapiEndpoints } : {}),
+ ...(hasGraphQl ? { graphqlEndpoints } : {}),
+ ...(hasHttpApis ? { httpApis } : {}),
+ ...(hasCliTools ? { cliTools } : {}),
+ ...(hasCapletSets ? { capletSets } : {}),
+ };
+ const hasConfig = Object.keys(config).length > 0;
+ if (!hasConfig && warnings?.length === 0) {
+ return undefined;
+ }
+
+ return { config, paths, warnings: warnings ?? [] };
+}
+
+function discoverCapletFileMapCandidates(paths: string[]): Array<{ id: string; path: string }> {
+ const sorted = [...paths].sort((left, right) => left.localeCompare(right));
+ const candidates: Array<{ id: string; path: string; isDirectoryCaplet: boolean }> = [];
+ for (const path of sorted) {
+ const segments = path.split("/");
+ const fileName = segments.at(-1);
+ if (!fileName) {
+ continue;
+ }
+ if (fileName === "CAPLET.md" && segments.length > 1) {
+ candidates.push({ id: segments.at(-2) ?? "CAPLET", path, isDirectoryCaplet: true });
+ continue;
+ }
+ if (segments.length === 1 && extname(fileName).toLowerCase() === ".md") {
+ candidates.push({
+ id: basename(fileName, extname(fileName)),
+ path,
+ isDirectoryCaplet: false,
+ });
+ }
+ }
+
+ return candidates.map(({ id, path }) => {
+ validateCapletId(id, path);
+ return { id, path };
+ });
+}
+
+export function readCapletFileContent(
+ path: string,
+ text: string,
+ baseDir: string,
+ normalizePath: (value: string | undefined, baseDir: string) => string | undefined,
+): unknown {
+ if (byteLength(text) > MAX_CAPLET_FILE_BYTES) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} exceeds the ${MAX_CAPLET_FILE_BYTES} byte limit`,
+ );
+ }
+ const { frontmatter, body } = parseFrontmatter(text, path);
+ if (body.length > MAX_CAPLET_BODY_CHARS) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} body exceeds the ${MAX_CAPLET_BODY_CHARS} character limit`,
+ );
+ }
+ const parsed = capletFileSchema.safeParse(frontmatter);
+ if (!parsed.success) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} has invalid frontmatter`,
+ parsed.error.issues,
+ );
+ }
+
+ return capletToServerConfig(parsed.data, body, baseDir, normalizePath);
+}
+
+function capletToServerConfig(
+ frontmatter: CapletFileFrontmatter,
+ body: string,
+ baseDir: string,
+ normalizePath: (value: string | undefined, baseDir: string) => string | undefined,
+): unknown {
+ if (frontmatter.openapiEndpoint) {
+ return {
+ ...frontmatter.openapiEndpoint,
+ specPath: normalizePath(frontmatter.openapiEndpoint.specPath, baseDir),
+ backend: "openapi",
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+ }
+
+ if (frontmatter.graphqlEndpoint) {
+ return {
+ ...frontmatter.graphqlEndpoint,
+ schemaPath: normalizePath(frontmatter.graphqlEndpoint.schemaPath, baseDir),
+ operations: normalizeGraphQlOperations(
+ frontmatter.graphqlEndpoint.operations,
+ baseDir,
+ normalizePath,
+ ),
+ backend: "graphql",
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+ }
+
+ if (frontmatter.httpApi) {
+ return {
+ ...frontmatter.httpApi,
+ backend: "http",
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+ }
+
+ if (frontmatter.cliTools) {
+ return {
+ ...frontmatter.cliTools,
+ cwd: normalizePath(frontmatter.cliTools.cwd, baseDir),
+ actions: normalizeCliToolActions(frontmatter.cliTools.actions, baseDir, normalizePath),
+ backend: "cli",
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+ }
+
+ if (frontmatter.capletSet) {
+ return {
+ ...frontmatter.capletSet,
+ configPath: normalizePath(frontmatter.capletSet.configPath, baseDir),
+ capletsRoot: normalizePath(frontmatter.capletSet.capletsRoot, baseDir),
+ backend: "caplets",
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+ }
+
+ return {
+ ...frontmatter.mcpServer!,
+ name: frontmatter.name,
+ description: frontmatter.description,
+ ...sharedCapletFields(frontmatter),
+ body,
+ };
+}
+
+function sharedCapletFields(frontmatter: CapletFileFrontmatter): Record {
+ return {
+ ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
+ ...(frontmatter.setup ? { setup: frontmatter.setup } : {}),
+ ...(frontmatter.projectBinding ? { projectBinding: frontmatter.projectBinding } : {}),
+ ...(frontmatter.runtime ? { runtime: frontmatter.runtime } : {}),
+ };
+}
+
+function normalizeCliToolActions(
+ actions: z.infer["actions"],
+ baseDir: string,
+ normalizePath: (value: string | undefined, baseDir: string) => string | undefined,
+): z.infer["actions"] {
+ return Object.fromEntries(
+ Object.entries(actions).map(([name, action]) => [
+ name,
+ {
+ ...action,
+ cwd: normalizePath(action.cwd, baseDir) as string | undefined,
+ },
+ ]),
+ );
+}
+
+function normalizeGraphQlOperations(
+ operations: z.infer["operations"],
+ baseDir: string,
+ normalizePath: (value: string | undefined, baseDir: string) => string | undefined,
+): z.infer["operations"] {
+ if (!operations) {
+ return undefined;
+ }
+ return Object.fromEntries(
+ Object.entries(operations).map(([name, operation]) => [
+ name,
+ {
+ ...operation,
+ documentPath: normalizePath(operation.documentPath, baseDir),
+ },
+ ]),
+ );
+}
+
+export function normalizeBundleLocalPath(
+ value: string | undefined,
+ baseDir: string,
+): string | undefined {
+ if (!value || isMapAbsolutePath(value) || hasEnvReference(value)) {
+ return value;
+ }
+ const parts = [...(baseDir ? baseDir.split("/") : []), ...value.split("/")];
+ const normalized: string[] = [];
+ for (const part of parts) {
+ if (!part || part === ".") {
+ continue;
+ }
+ if (part === "..") {
+ normalized.pop();
+ continue;
+ }
+ normalized.push(part);
+ }
+ return normalized.join("/");
+}
+
+export function normalizeMapPath(path: string): string {
+ const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, "");
+ if (!normalized || normalized.startsWith("/") || normalized.includes("/../")) {
+ throw new CapletsError("CONFIG_INVALID", `Invalid Caplet file path ${path}`);
+ }
+ return normalized;
+}
+
+export function mapDirname(path: string): string {
+ return path.split("/").slice(0, -1).join("/");
+}
+
+function basename(path: string, suffix = ""): string {
+ const name = path.split("/").filter(Boolean).at(-1) ?? path;
+ return suffix && name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
+}
+
+function extname(path: string): string {
+ const name = basename(path);
+ const index = name.lastIndexOf(".");
+ return index > 0 ? name.slice(index) : "";
+}
+
+function isMapAbsolutePath(value: string): boolean {
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value);
+}
+
+function byteLength(value: string): number {
+ return new TextEncoder().encode(value).byteLength;
+}
+
+function validateEndpointAuthHeaders(
+ auth: z.infer | undefined,
+ ctx: z.RefinementCtx,
+): void {
+ if (auth?.type !== "headers") {
+ return;
+ }
+ for (const headerName of Object.keys(auth.headers)) {
+ const normalized = headerName.toLowerCase();
+ if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["auth", "headers", headerName],
+ message: `header ${headerName} is not allowed`,
+ });
+ }
+ }
+}
+
+function parseFrontmatter(text: string, path: string): { frontmatter: unknown; body: string } {
+ if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} must start with fenced YAML frontmatter`,
+ );
+ }
+
+ try {
+ const newline = text.startsWith("---\r\n") ? "\r\n" : "\n";
+ const fence = `${newline}---`;
+ const fenceIndex = text.indexOf(fence, 3);
+ if (fenceIndex < 0) {
+ throw new Error("missing closing frontmatter fence");
+ }
+
+ const frontmatterText = text.slice(3 + newline.length, fenceIndex);
+ const afterFenceIndex = fenceIndex + fence.length;
+ const bodyStart =
+ text.slice(afterFenceIndex, afterFenceIndex + 2) === "\r\n"
+ ? afterFenceIndex + 2
+ : text.slice(afterFenceIndex, afterFenceIndex + 1) === "\n"
+ ? afterFenceIndex + 1
+ : afterFenceIndex;
+ const frontmatter = parseYaml(frontmatterText);
+ if (!isPlainObject(frontmatter) || Object.keys(frontmatter).length === 0) {
+ throw new Error("empty frontmatter");
+ }
+ return {
+ frontmatter,
+ body: text.slice(bodyStart),
+ };
+ } catch (error) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} has invalid YAML frontmatter`,
+ redactSecrets(error),
+ );
+ }
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+export function validateCapletId(id: string, path: string): void {
+ if (!SERVER_ID_PATTERN.test(id)) {
+ throw new CapletsError(
+ "CONFIG_INVALID",
+ `Caplet file at ${path} derives invalid ID ${id}; ID must match ^[a-zA-Z0-9_-]{1,64}$`,
+ );
+ }
+}
+
+export function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function hasEnvReference(value: string): boolean {
+ return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value);
+}
+
+function patchCapletJsonSchema(schema: T): T {
+ const httpApiProperties = schemaPath>(schema, [
+ "properties",
+ "httpApi",
+ "properties",
+ ]);
+ const actions = nestedSchema>(httpApiProperties, "actions");
+ if (actions) {
+ actions.minProperties = 1;
+ }
+ const baseUrl = nestedSchema>(httpApiProperties, "baseUrl");
+ if (baseUrl) {
+ baseUrl.format = "uri";
+ }
+ const cliToolsProperties = schemaPath>(schema, [
+ "properties",
+ "cliTools",
+ "properties",
+ ]);
+ const cliActions = nestedSchema>(cliToolsProperties, "actions");
+ if (cliActions) {
+ cliActions.minProperties = 1;
+ }
+ return schema;
+}
diff --git a/packages/core/src/caplet-files.ts b/packages/core/src/caplet-files.ts
index 51209af..1652c80 100644
--- a/packages/core/src/caplet-files.ts
+++ b/packages/core/src/caplet-files.ts
@@ -1,631 +1,28 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, dirname, extname, isAbsolute, join } from "node:path";
-import { VFile } from "vfile";
-import { matter as parseMatter } from "vfile-matter";
-import { z } from "zod";
+import { CapletsError } from "./errors";
+export { capletFileSchema, capletJsonSchema, loadCapletFilesFromMap } from "./caplet-files-bundle";
+export type {
+ BestEffortCapletFileLoadResult,
+ CapletFileConfig,
+ CapletFileLoadResult,
+ CapletFileMapInput,
+ CapletFileWarning,
+} from "./caplet-files-bundle";
import {
- FORBIDDEN_HEADERS,
- HEADER_NAME_PATTERN,
- HTTP_BASE_URL_PATTERN,
- SERVER_ID_PATTERN,
- isAllowedHttpBaseUrl,
- isAllowedRemoteUrl,
- isUrl,
- validateHttpActionHeaders,
-} from "./config/validation";
-import { CapletsError, redactSecrets } from "./errors";
-import { nestedSchema, schemaPath } from "./schema-utils";
+ buildCapletFileLoadResultFromEntries,
+ errorMessage,
+ readCapletFileContent,
+ validateCapletId,
+} from "./caplet-files-bundle";
+import type {
+ BestEffortCapletFileLoadResult,
+ CapletFileConfig,
+ CapletFileLoadResult,
+ CapletFileWarning,
+} from "./caplet-files-bundle";
const MAX_CAPLET_FILE_BYTES = 128 * 1024;
-const MAX_CAPLET_BODY_CHARS = 64 * 1024;
-
-const capletRemoteAuthSchema = z
- .discriminatedUnion("type", [
- z.object({ type: z.literal("none") }).strict(),
- z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(),
- z
- .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) })
- .strict(),
- z
- .object({
- type: z.literal("oauth2"),
- authorizationUrl: z.string().min(1).optional(),
- tokenUrl: z.string().min(1).optional(),
- issuer: z.string().min(1).optional(),
- resourceMetadataUrl: z.string().min(1).optional(),
- authorizationServerMetadataUrl: z.string().min(1).optional(),
- openidConfigurationUrl: z.string().min(1).optional(),
- clientMetadataUrl: z.string().min(1).optional(),
- clientId: z.string().min(1).optional(),
- clientSecret: z.string().min(1).optional(),
- scopes: z.array(z.string().min(1)).optional(),
- redirectUri: z.string().min(1).optional(),
- })
- .strict(),
- z
- .object({
- type: z.literal("oidc"),
- authorizationUrl: z.string().min(1).optional(),
- tokenUrl: z.string().min(1).optional(),
- issuer: z.string().min(1).optional(),
- resourceMetadataUrl: z.string().min(1).optional(),
- authorizationServerMetadataUrl: z.string().min(1).optional(),
- openidConfigurationUrl: z.string().min(1).optional(),
- clientMetadataUrl: z.string().min(1).optional(),
- clientId: z.string().min(1).optional(),
- clientSecret: z.string().min(1).optional(),
- scopes: z.array(z.string().min(1)).optional(),
- redirectUri: z.string().min(1).optional(),
- })
- .strict(),
- ])
- .describe("Authentication settings for a remote MCP server.");
-
-const capletEndpointAuthSchema = z
- .discriminatedUnion("type", [
- z.object({ type: z.literal("none") }).strict(),
- z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(),
- z
- .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) })
- .strict(),
- z
- .object({
- type: z.literal("oauth2"),
- authorizationUrl: z.string().min(1).optional(),
- tokenUrl: z.string().min(1).optional(),
- issuer: z.string().min(1).optional(),
- resourceMetadataUrl: z.string().min(1).optional(),
- authorizationServerMetadataUrl: z.string().min(1).optional(),
- openidConfigurationUrl: z.string().min(1).optional(),
- clientMetadataUrl: z.string().min(1).optional(),
- clientId: z.string().min(1).optional(),
- clientSecret: z.string().min(1).optional(),
- scopes: z.array(z.string().min(1)).optional(),
- redirectUri: z.string().min(1).optional(),
- })
- .strict(),
- z
- .object({
- type: z.literal("oidc"),
- authorizationUrl: z.string().min(1).optional(),
- tokenUrl: z.string().min(1).optional(),
- issuer: z.string().min(1).optional(),
- resourceMetadataUrl: z.string().min(1).optional(),
- authorizationServerMetadataUrl: z.string().min(1).optional(),
- openidConfigurationUrl: z.string().min(1).optional(),
- clientMetadataUrl: z.string().min(1).optional(),
- clientId: z.string().min(1).optional(),
- clientSecret: z.string().min(1).optional(),
- scopes: z.array(z.string().min(1)).optional(),
- redirectUri: z.string().min(1).optional(),
- })
- .strict(),
- ])
- .describe("Authentication settings for an OpenAPI or GraphQL endpoint.");
-
-const capletMcpServerSchema = z
- .object({
- transport: z
- .enum(["stdio", "http", "sse"])
- .optional()
- .describe("Downstream MCP transport. Defaults to stdio when command is present."),
- command: z.string().min(1).optional().describe("Executable command for stdio servers."),
- args: z.array(z.string()).optional().describe("Arguments passed to the stdio command."),
- env: z
- .record(z.string(), z.string())
- .optional()
- .describe("Environment variables for stdio servers. Supports ${VAR} and $env:VAR."),
- cwd: z.string().min(1).optional().describe("Working directory for stdio servers."),
- url: z.string().min(1).optional().describe("Remote MCP server URL for http or sse transport."),
- auth: capletRemoteAuthSchema.optional(),
- startupTimeoutMs: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for starting or checking a downstream server."),
- callTimeoutMs: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for downstream tool calls."),
- toolCacheTtlMs: z
- .number()
- .int()
- .nonnegative()
- .optional()
- .describe("Milliseconds downstream tool metadata stays fresh. Set 0 to refresh every time."),
- disabled: z
- .boolean()
- .optional()
- .describe("When true, omit this Caplet from discovery and do not start its MCP server."),
- })
- .strict()
- .superRefine((server, ctx) => {
- const effectiveTransport = server.transport ?? (server.command ? "stdio" : undefined);
- const hasStdio = Boolean(server.command);
- const hasRemote = Boolean(server.url);
- if (hasStdio === hasRemote) {
- ctx.addIssue({
- code: "custom",
- message: "mcpServer must define exactly one connection shape: command or url",
- });
- }
-
- if (effectiveTransport === "stdio" && !server.command) {
- ctx.addIssue({
- code: "custom",
- path: ["command"],
- message: "stdio servers require command",
- });
- }
-
- if ((effectiveTransport === "http" || effectiveTransport === "sse") && !server.url) {
- ctx.addIssue({
- code: "custom",
- path: ["url"],
- message: "remote servers require url",
- });
- }
-
- if (server.url && !hasEnvReference(server.url) && !isAllowedRemoteUrl(server.url)) {
- ctx.addIssue({
- code: "custom",
- path: ["url"],
- message: "remote url must use https except loopback development urls",
- });
- }
-
- if (server.auth?.type === "oauth2" || server.auth?.type === "oidc") {
- for (const field of [
- "authorizationUrl",
- "tokenUrl",
- "issuer",
- "clientMetadataUrl",
- "redirectUri",
- ] as const) {
- const value = server.auth[field];
- if (value && !hasEnvReference(value) && !isUrl(value)) {
- ctx.addIssue({
- code: "custom",
- path: ["auth", field],
- message: `${field} must be a URL or environment reference`,
- });
- }
- }
- }
-
- if (server.auth?.type === "headers") {
- for (const headerName of Object.keys(server.auth.headers)) {
- const normalized = headerName.toLowerCase();
- if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) {
- ctx.addIssue({
- code: "custom",
- path: ["auth", "headers", headerName],
- message: `header ${headerName} is not allowed`,
- });
- }
- }
- }
- });
-
-const capletOpenApiEndpointSchema = z
- .object({
- specPath: z.string().min(1).optional().describe("Local OpenAPI specification path."),
- specUrl: z.string().min(1).optional().describe("Remote OpenAPI specification URL."),
- baseUrl: z.string().min(1).optional().describe("Override base URL for OpenAPI requests."),
- auth: capletEndpointAuthSchema.describe(
- 'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.',
- ),
- requestTimeoutMs: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for OpenAPI HTTP requests."),
- operationCacheTtlMs: z
- .number()
- .int()
- .nonnegative()
- .optional()
- .describe(
- "Milliseconds OpenAPI operation metadata stays fresh. Set 0 to refresh every time.",
- ),
- disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
- })
- .strict()
- .superRefine((endpoint, ctx) => {
- if (Boolean(endpoint.specPath) === Boolean(endpoint.specUrl)) {
- ctx.addIssue({
- code: "custom",
- message: "openapiEndpoint must define exactly one spec source: specPath or specUrl",
- });
- }
- if (
- endpoint.specUrl &&
- !hasEnvReference(endpoint.specUrl) &&
- !isAllowedRemoteUrl(endpoint.specUrl)
- ) {
- ctx.addIssue({
- code: "custom",
- path: ["specUrl"],
- message: "OpenAPI specUrl must use https except loopback development urls",
- });
- }
- if (
- endpoint.baseUrl &&
- !hasEnvReference(endpoint.baseUrl) &&
- !isAllowedRemoteUrl(endpoint.baseUrl)
- ) {
- ctx.addIssue({
- code: "custom",
- path: ["baseUrl"],
- message: "OpenAPI baseUrl must use https except loopback development urls",
- });
- }
- validateEndpointAuthHeaders(endpoint.auth, ctx);
- });
-
-const capletGraphQlOperationSchema = z
- .object({
- document: z.string().min(1).optional().describe("Inline GraphQL operation document."),
- documentPath: z.string().min(1).optional().describe("Path to a GraphQL operation document."),
- operationName: z.string().min(1).optional().describe("Operation name to execute."),
- description: z.string().min(1).optional().describe("Operation capability description."),
- })
- .strict()
- .superRefine((operation, ctx) => {
- if (Boolean(operation.document) === Boolean(operation.documentPath)) {
- ctx.addIssue({
- code: "custom",
- message:
- "GraphQL operation must define exactly one document source: document or documentPath",
- });
- }
- });
-
-const capletGraphQlEndpointSchema = z
- .object({
- endpointUrl: z.string().min(1).describe("GraphQL HTTP endpoint URL."),
- schemaPath: z.string().min(1).optional().describe("Local GraphQL SDL or introspection path."),
- schemaUrl: z.string().min(1).optional().describe("Remote GraphQL SDL or introspection URL."),
- introspection: z
- .literal(true)
- .optional()
- .describe("Load schema through endpoint introspection."),
- operations: z
- .record(z.string().regex(SERVER_ID_PATTERN), capletGraphQlOperationSchema)
- .optional()
- .describe("Configured GraphQL operations keyed by stable tool name."),
- auth: capletEndpointAuthSchema.describe(
- 'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.',
- ),
- requestTimeoutMs: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for GraphQL HTTP requests."),
- operationCacheTtlMs: z
- .number()
- .int()
- .nonnegative()
- .optional()
- .describe(
- "Milliseconds GraphQL operation metadata stays fresh. Set 0 to refresh every time.",
- ),
- selectionDepth: z
- .number()
- .int()
- .positive()
- .max(5)
- .optional()
- .describe("Maximum depth for auto-generated GraphQL selection sets."),
- disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
- })
- .strict()
- .superRefine((endpoint, ctx) => {
- const sourceCount =
- Number(Boolean(endpoint.schemaPath)) +
- Number(Boolean(endpoint.schemaUrl)) +
- Number(endpoint.introspection === true);
- if (sourceCount !== 1) {
- ctx.addIssue({
- code: "custom",
- message:
- "graphqlEndpoint must define exactly one schema source: schemaPath, schemaUrl, or introspection",
- });
- }
- if (
- endpoint.endpointUrl &&
- !hasEnvReference(endpoint.endpointUrl) &&
- !isAllowedRemoteUrl(endpoint.endpointUrl)
- ) {
- ctx.addIssue({
- code: "custom",
- path: ["endpointUrl"],
- message: "GraphQL endpointUrl must use https except loopback development urls",
- });
- }
- if (
- endpoint.schemaUrl &&
- !hasEnvReference(endpoint.schemaUrl) &&
- !isAllowedRemoteUrl(endpoint.schemaUrl)
- ) {
- ctx.addIssue({
- code: "custom",
- path: ["schemaUrl"],
- message: "GraphQL schemaUrl must use https except loopback development urls",
- });
- }
- validateEndpointAuthHeaders(endpoint.auth, ctx);
- });
-
-const httpScalarMappingSchema = z.record(
- z.string(),
- z.union([z.string(), z.number(), z.boolean()]),
-);
-
-const capletHttpActionSchema = z
- .object({
- method: z
- .enum(["GET", "POST", "PUT", "PATCH", "DELETE"])
- .describe("HTTP method used for this action."),
- path: z
- .string()
- .min(1)
- .regex(/^\//, "HTTP action path must start with /")
- .describe("URL path appended to the HTTP API baseUrl.")
- .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //")
- .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"),
- description: z.string().min(1).optional().describe("Action capability description."),
- inputSchema: z
- .record(z.string(), z.unknown())
- .optional()
- .describe("JSON Schema for call_tool arguments."),
- query: httpScalarMappingSchema.optional().describe("Query parameter mapping."),
- headers: httpScalarMappingSchema.optional().describe("Request header mapping."),
- jsonBody: z.unknown().optional().describe("JSON request body mapping."),
- })
- .strict()
- .superRefine((action, ctx) => {
- if (action.method === "GET" && action.jsonBody !== undefined) {
- ctx.addIssue({
- code: "custom",
- path: ["jsonBody"],
- message: "HTTP GET actions must not define jsonBody",
- });
- }
- });
-
-const capletHttpApiSchema = z
- .object({
- baseUrl: z
- .string()
- .min(1)
- .regex(
- HTTP_BASE_URL_PATTERN,
- "HTTP API baseUrl must not include credentials, query, or fragment",
- )
- .describe("Base URL for HTTP action requests."),
- auth: capletEndpointAuthSchema.describe(
- 'Explicit HTTP API request auth config. Use {"type":"none"} for public APIs.',
- ),
- actions: z
- .record(z.string().regex(SERVER_ID_PATTERN), capletHttpActionSchema)
- .refine(
- (actions) => Object.keys(actions).length > 0,
- "HTTP API must define at least one action",
- )
- .describe("Configured HTTP actions keyed by stable tool name."),
- requestTimeoutMs: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for HTTP action requests."),
- maxResponseBytes: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Maximum HTTP action response body bytes to read."),
- disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
- })
- .strict()
- .superRefine((api, ctx) => {
- if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) {
- ctx.addIssue({
- code: "custom",
- path: ["baseUrl"],
- message:
- "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment",
- });
- }
- validateEndpointAuthHeaders(api.auth, ctx);
- for (const [actionName, action] of Object.entries(api.actions)) {
- if (action.headers) {
- validateHttpActionHeaders(action.headers, ctx, ["actions", actionName, "headers"]);
- }
- }
- });
-
-const capletCliToolOutputSchema = z
- .object({
- type: z.enum(["text", "json"]).optional(),
- })
- .strict();
-
-const capletCliToolAnnotationsSchema = z
- .object({
- readOnlyHint: z.boolean().optional(),
- destructiveHint: z.boolean().optional(),
- idempotentHint: z.boolean().optional(),
- openWorldHint: z.boolean().optional(),
- })
- .strict();
-
-const capletCliToolActionSchema = z
- .object({
- description: z.string().min(1).optional().describe("Action capability description."),
- inputSchema: z
- .record(z.string(), z.unknown())
- .optional()
- .describe("JSON Schema for call_tool arguments."),
- outputSchema: z
- .record(z.string(), z.unknown())
- .optional()
- .describe("JSON Schema for structuredContent returned by this action."),
- command: z.string().min(1).describe("Executable command to spawn without a shell."),
- args: z.array(z.string()).optional().describe("Arguments passed to the command."),
- env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."),
- cwd: z.string().min(1).optional().describe("Working directory for this action."),
- timeoutMs: z.number().int().positive().optional(),
- maxOutputBytes: z.number().int().positive().optional(),
- output: capletCliToolOutputSchema.optional(),
- annotations: capletCliToolAnnotationsSchema.optional(),
- })
- .strict();
-
-const capletCliToolsSchema = z
- .object({
- actions: z
- .record(z.string().regex(SERVER_ID_PATTERN), capletCliToolActionSchema)
- .refine(
- (actions) => Object.keys(actions).length > 0,
- "CLI tools backend must define at least one action",
- )
- .describe("Configured CLI actions keyed by stable tool name."),
- cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."),
- env: z.record(z.string(), z.string()).optional().describe("Default environment variables."),
- timeoutMs: z.number().int().positive().optional(),
- maxOutputBytes: z.number().int().positive().optional(),
- disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
- })
- .strict();
-
-const capletSetSchema = z
- .object({
- configPath: z.string().min(1).optional().describe("Child Caplets config.json path."),
- capletsRoot: z.string().min(1).optional().describe("Child Markdown Caplets root directory."),
- defaultSearchLimit: z.number().int().positive().optional(),
- maxSearchLimit: z.number().int().positive().max(50).optional(),
- toolCacheTtlMs: z.number().int().nonnegative().optional(),
- disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."),
- })
- .strict()
- .superRefine((set, ctx) => {
- if (!set.configPath && !set.capletsRoot) {
- ctx.addIssue({
- code: "custom",
- message: "capletSet must define at least one source: configPath or capletsRoot",
- });
- }
- if (
- set.defaultSearchLimit !== undefined &&
- set.maxSearchLimit !== undefined &&
- set.defaultSearchLimit > set.maxSearchLimit
- ) {
- ctx.addIssue({
- code: "custom",
- path: ["defaultSearchLimit"],
- message: "defaultSearchLimit must be <= maxSearchLimit",
- });
- }
- });
-
-export const capletFileSchema = z
- .object({
- $schema: z
- .string()
- .url()
- .optional()
- .describe("Optional JSON Schema URL for editor validation."),
- name: z.string().trim().min(1).max(80).describe("Human-readable Caplet display name."),
- description: z
- .string()
- .describe("Compact capability description shown before the full Caplet card is disclosed.")
- .refine(
- (value) => value.trim().length >= 10,
- "description must contain at least 10 non-whitespace characters",
- )
- .refine((value) => value.length <= 1500, "description must be at most 1500 characters"),
- tags: z
- .array(z.string().trim().min(1).max(80))
- .optional()
- .describe("Optional tags for grouping or searching Caplets."),
- mcpServer: capletMcpServerSchema
- .describe("MCP server backend configuration for this Caplet.")
- .optional(),
- openapiEndpoint: capletOpenApiEndpointSchema
- .describe("OpenAPI endpoint backend configuration for this Caplet.")
- .optional(),
- graphqlEndpoint: capletGraphQlEndpointSchema
- .describe("GraphQL endpoint backend configuration for this Caplet.")
- .optional(),
- httpApi: capletHttpApiSchema
- .describe("HTTP API backend configuration for this Caplet.")
- .optional(),
- cliTools: capletCliToolsSchema
- .describe("CLI tools backend configuration for this Caplet.")
- .optional(),
- capletSet: capletSetSchema
- .describe("Nested Caplet collection backend configuration for this Caplet.")
- .optional(),
- })
- .strict()
- .superRefine((frontmatter, ctx) => {
- const backendCount =
- Number(Boolean(frontmatter.mcpServer)) +
- Number(Boolean(frontmatter.openapiEndpoint)) +
- Number(Boolean(frontmatter.graphqlEndpoint)) +
- Number(Boolean(frontmatter.httpApi)) +
- Number(Boolean(frontmatter.cliTools)) +
- Number(Boolean(frontmatter.capletSet));
- if (backendCount !== 1) {
- ctx.addIssue({
- code: "custom",
- message:
- "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, httpApi, cliTools, or capletSet",
- });
- }
- });
-
-type CapletFileFrontmatter = z.infer;
-
-export function capletJsonSchema(): unknown {
- return patchCapletJsonSchema({
- $schema: "https://json-schema.org/draft/2020-12/schema",
- $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json",
- title: "Caplet file frontmatter",
- description: "YAML frontmatter schema for a Markdown Caplet file.",
- ...z.toJSONSchema(capletFileSchema, { io: "input" }),
- });
-}
-
-export type CapletFileConfig = {
- mcpServers?: Record;
- openapiEndpoints?: Record;
- graphqlEndpoints?: Record;
- httpApis?: Record;
- cliTools?: Record;
- capletSets?: Record;
-};
-
-export type CapletFileLoadResult = {
- config: CapletFileConfig;
- paths: Record;
-};
-
-export type CapletFileWarning = {
- path?: string | undefined;
- message: string;
-};
-
-export type BestEffortCapletFileLoadResult = CapletFileLoadResult & {
- warnings: CapletFileWarning[];
-};
export function loadCapletFiles(root: string): CapletFileConfig | undefined {
return loadCapletFilesWithPaths(root)?.config;
@@ -636,65 +33,9 @@ export function loadCapletFilesWithPaths(root: string): CapletFileLoadResult | u
return undefined;
}
- const servers: Record = {};
- const openapiEndpoints: Record = {};
- const graphqlEndpoints: Record = {};
- const httpApis: Record = {};
- const cliTools: Record = {};
- const capletSets: Record = {};
- const paths: Record = {};
- for (const candidate of discoverCapletFiles(root)) {
- if (
- servers[candidate.id] ||
- openapiEndpoints[candidate.id] ||
- graphqlEndpoints[candidate.id] ||
- httpApis[candidate.id] ||
- cliTools[candidate.id] ||
- capletSets[candidate.id]
- ) {
- throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet ID ${candidate.id} under ${root}`);
- }
- paths[candidate.id] = candidate.path;
- const config = readCapletFile(candidate.path);
- if (isPlainObject(config) && config.backend === "openapi") {
- const { backend: _backend, ...endpoint } = config;
- openapiEndpoints[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "graphql") {
- const { backend: _backend, ...endpoint } = config;
- graphqlEndpoints[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "http") {
- const { backend: _backend, ...endpoint } = config;
- httpApis[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "cli") {
- const { backend: _backend, ...endpoint } = config;
- cliTools[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "caplets") {
- const { backend: _backend, ...endpoint } = config;
- capletSets[candidate.id] = endpoint;
- } else {
- servers[candidate.id] = config;
- }
- }
-
- const hasServers = Object.keys(servers).length > 0;
- const hasOpenApi = Object.keys(openapiEndpoints).length > 0;
- const hasGraphQl = Object.keys(graphqlEndpoints).length > 0;
- const hasHttpApis = Object.keys(httpApis).length > 0;
- const hasCliTools = Object.keys(cliTools).length > 0;
- const hasCapletSets = Object.keys(capletSets).length > 0;
- return hasServers || hasOpenApi || hasGraphQl || hasHttpApis || hasCliTools || hasCapletSets
- ? {
- config: {
- ...(hasServers ? { mcpServers: servers } : {}),
- ...(hasOpenApi ? { openapiEndpoints } : {}),
- ...(hasGraphQl ? { graphqlEndpoints } : {}),
- ...(hasHttpApis ? { httpApis } : {}),
- ...(hasCliTools ? { cliTools } : {}),
- ...(hasCapletSets ? { capletSets } : {}),
- },
- paths,
- }
- : undefined;
+ return buildCapletFileLoadResultFromEntries(root, discoverCapletFiles(root), (path) =>
+ readCapletFile(path),
+ );
}
export function loadCapletFilesWithPathsBestEffort(
@@ -705,101 +46,12 @@ export function loadCapletFilesWithPathsBestEffort(
}
const warnings: CapletFileWarning[] = [];
- return buildCapletFileLoadResult(root, discoverCapletFilesBestEffort(root, warnings), warnings);
-}
-
-function buildCapletFileLoadResult(
- root: string,
- candidates: Array<{ id: string; path: string }>,
- warnings?: CapletFileWarning[],
-): BestEffortCapletFileLoadResult | undefined {
- const servers: Record = {};
- const openapiEndpoints: Record = {};
- const graphqlEndpoints: Record = {};
- const httpApis: Record = {};
- const cliTools: Record = {};
- const capletSets: Record = {};
- const paths: Record = {};
-
- function hasId(id: string): boolean {
- return Boolean(
- servers[id] ||
- openapiEndpoints[id] ||
- graphqlEndpoints[id] ||
- httpApis[id] ||
- cliTools[id] ||
- capletSets[id],
- );
- }
-
- for (const candidate of candidates) {
- if (hasId(candidate.id)) {
- const message = `Duplicate Caplet ID ${candidate.id} under ${root}`;
- if (!warnings) {
- throw new CapletsError("CONFIG_INVALID", message);
- }
- warnings.push({
- path: candidate.path,
- message: `${message}; skipping duplicate at ${candidate.path}`,
- });
- continue;
- }
-
- let config: unknown;
- try {
- config = readCapletFile(candidate.path);
- } catch (error) {
- if (!warnings) {
- throw error;
- }
- warnings.push({
- path: candidate.path,
- message: `Skipping invalid Caplet file at ${candidate.path}: ${errorMessage(error)}`,
- });
- continue;
- }
-
- paths[candidate.id] = candidate.path;
- if (isPlainObject(config) && config.backend === "openapi") {
- const { backend: _backend, ...endpoint } = config;
- openapiEndpoints[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "graphql") {
- const { backend: _backend, ...endpoint } = config;
- graphqlEndpoints[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "http") {
- const { backend: _backend, ...endpoint } = config;
- httpApis[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "cli") {
- const { backend: _backend, ...endpoint } = config;
- cliTools[candidate.id] = endpoint;
- } else if (isPlainObject(config) && config.backend === "caplets") {
- const { backend: _backend, ...endpoint } = config;
- capletSets[candidate.id] = endpoint;
- } else {
- servers[candidate.id] = config;
- }
- }
-
- const hasServers = Object.keys(servers).length > 0;
- const hasOpenApi = Object.keys(openapiEndpoints).length > 0;
- const hasGraphQl = Object.keys(graphqlEndpoints).length > 0;
- const hasHttpApis = Object.keys(httpApis).length > 0;
- const hasCliTools = Object.keys(cliTools).length > 0;
- const hasCapletSets = Object.keys(capletSets).length > 0;
- const config = {
- ...(hasServers ? { mcpServers: servers } : {}),
- ...(hasOpenApi ? { openapiEndpoints } : {}),
- ...(hasGraphQl ? { graphqlEndpoints } : {}),
- ...(hasHttpApis ? { httpApis } : {}),
- ...(hasCliTools ? { cliTools } : {}),
- ...(hasCapletSets ? { capletSets } : {}),
- };
- const hasConfig = Object.keys(config).length > 0;
- if (!hasConfig && warnings?.length === 0) {
- return undefined;
- }
-
- return { config, paths, warnings: warnings ?? [] };
+ return buildCapletFileLoadResultFromEntries(
+ root,
+ discoverCapletFilesBestEffort(root, warnings),
+ (path) => readCapletFile(path),
+ warnings,
+ );
}
export function discoverCapletFiles(root: string): Array<{ id: string; path: string }> {
@@ -913,10 +165,6 @@ function discoverCapletFilesBestEffort(
return Array.from(byId.values()).map(({ id, path }) => ({ id, path }));
}
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
-
function readCapletFile(path: string): unknown {
const stat = statSync(path);
if (stat.size > MAX_CAPLET_FILE_BYTES) {
@@ -926,138 +174,13 @@ function readCapletFile(path: string): unknown {
);
}
const text = readFileSync(path, "utf8");
- const { frontmatter, body } = parseFrontmatter(text, path);
- if (body.length > MAX_CAPLET_BODY_CHARS) {
- throw new CapletsError(
- "CONFIG_INVALID",
- `Caplet file at ${path} body exceeds the ${MAX_CAPLET_BODY_CHARS} character limit`,
- );
- }
- const parsed = capletFileSchema.safeParse(frontmatter);
- if (!parsed.success) {
- throw new CapletsError(
- "CONFIG_INVALID",
- `Caplet file at ${path} has invalid frontmatter`,
- parsed.error.issues,
- );
- }
-
- return capletToServerConfig(parsed.data, body, dirname(path));
+ return readCapletFileContent(path, text, dirname(path), normalizeLocalPath);
}
export function validateCapletFile(path: string): void {
readCapletFile(path);
}
-function capletToServerConfig(
- frontmatter: CapletFileFrontmatter,
- body: string,
- baseDir: string,
-): unknown {
- if (frontmatter.openapiEndpoint) {
- return {
- ...frontmatter.openapiEndpoint,
- specPath: normalizeLocalPath(frontmatter.openapiEndpoint.specPath, baseDir),
- backend: "openapi",
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
- }
-
- if (frontmatter.graphqlEndpoint) {
- return {
- ...frontmatter.graphqlEndpoint,
- schemaPath: normalizeLocalPath(frontmatter.graphqlEndpoint.schemaPath, baseDir),
- operations: normalizeGraphQlOperations(frontmatter.graphqlEndpoint.operations, baseDir),
- backend: "graphql",
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
- }
-
- if (frontmatter.httpApi) {
- return {
- ...frontmatter.httpApi,
- backend: "http",
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
- }
-
- if (frontmatter.cliTools) {
- return {
- ...frontmatter.cliTools,
- cwd: normalizeLocalPath(frontmatter.cliTools.cwd, baseDir),
- actions: normalizeCliToolActions(frontmatter.cliTools.actions, baseDir),
- backend: "cli",
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
- }
-
- if (frontmatter.capletSet) {
- return {
- ...frontmatter.capletSet,
- configPath: normalizeLocalPath(frontmatter.capletSet.configPath, baseDir),
- capletsRoot: normalizeLocalPath(frontmatter.capletSet.capletsRoot, baseDir),
- backend: "caplets",
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
- }
-
- return {
- ...frontmatter.mcpServer!,
- name: frontmatter.name,
- description: frontmatter.description,
- ...(frontmatter.tags ? { tags: frontmatter.tags } : {}),
- body,
- };
-}
-
-function normalizeCliToolActions(
- actions: z.infer["actions"],
- baseDir: string,
-): z.infer["actions"] {
- return Object.fromEntries(
- Object.entries(actions).map(([name, action]) => [
- name,
- {
- ...action,
- cwd: normalizeLocalPath(action.cwd, baseDir) as string | undefined,
- },
- ]),
- );
-}
-
-function normalizeGraphQlOperations(
- operations: z.infer["operations"],
- baseDir: string,
-): z.infer["operations"] {
- if (!operations) {
- return undefined;
- }
- return Object.fromEntries(
- Object.entries(operations).map(([name, operation]) => [
- name,
- {
- ...operation,
- documentPath: normalizeLocalPath(operation.documentPath, baseDir),
- },
- ]),
- );
-}
-
function normalizeLocalPath(value: string | undefined, baseDir: string): string | undefined {
if (!value || isAbsolute(value) || hasEnvReference(value)) {
return value;
@@ -1065,91 +188,6 @@ function normalizeLocalPath(value: string | undefined, baseDir: string): string
return join(baseDir, value);
}
-function validateEndpointAuthHeaders(
- auth: z.infer | undefined,
- ctx: z.RefinementCtx,
-): void {
- if (auth?.type !== "headers") {
- return;
- }
- for (const headerName of Object.keys(auth.headers)) {
- const normalized = headerName.toLowerCase();
- if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) {
- ctx.addIssue({
- code: "custom",
- path: ["auth", "headers", headerName],
- message: `header ${headerName} is not allowed`,
- });
- }
- }
-}
-
-function parseFrontmatter(text: string, path: string): { frontmatter: unknown; body: string } {
- if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) {
- throw new CapletsError(
- "CONFIG_INVALID",
- `Caplet file at ${path} must start with fenced YAML frontmatter`,
- );
- }
-
- try {
- const file = new VFile({ path, value: text });
- parseMatter(file, { strip: true });
- if (!isPlainObject(file.data.matter) || Object.keys(file.data.matter).length === 0) {
- throw new Error("empty frontmatter");
- }
- return {
- frontmatter: file.data.matter,
- body: String(file),
- };
- } catch (error) {
- throw new CapletsError(
- "CONFIG_INVALID",
- `Caplet file at ${path} has invalid YAML frontmatter`,
- redactSecrets(error),
- );
- }
-}
-
-function isPlainObject(value: unknown): value is Record {
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
-}
-
-function validateCapletId(id: string, path: string): void {
- if (!SERVER_ID_PATTERN.test(id)) {
- throw new CapletsError(
- "CONFIG_INVALID",
- `Caplet file at ${path} derives invalid ID ${id}; ID must match ^[a-zA-Z0-9_-]{1,64}$`,
- );
- }
-}
-
function hasEnvReference(value: string): boolean {
return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value);
}
-
-function patchCapletJsonSchema(schema: T): T {
- const httpApiProperties = schemaPath>(schema, [
- "properties",
- "httpApi",
- "properties",
- ]);
- const actions = nestedSchema>(httpApiProperties, "actions");
- if (actions) {
- actions.minProperties = 1;
- }
- const baseUrl = nestedSchema>(httpApiProperties, "baseUrl");
- if (baseUrl) {
- baseUrl.format = "uri";
- }
- const cliToolsProperties = schemaPath>(schema, [
- "properties",
- "cliTools",
- "properties",
- ]);
- const cliActions = nestedSchema>(cliToolsProperties, "actions");
- if (cliActions) {
- cliActions.minProperties = 1;
- }
- return schema;
-}
diff --git a/packages/core/src/caplet-source/bundle.ts b/packages/core/src/caplet-source/bundle.ts
new file mode 100644
index 0000000..6336db9
--- /dev/null
+++ b/packages/core/src/caplet-source/bundle.ts
@@ -0,0 +1,33 @@
+import { CapletsError } from "../errors";
+import type { CapletSource, CapletSourceFile } from "./types";
+import { normalizeCapletSourcePath } from "./types";
+
+export class BundleCapletSource implements CapletSource {
+ private readonly files: Map;
+
+ constructor(files: CapletSourceFile[]) {
+ this.files = new Map();
+ for (const file of files) {
+ const path = normalizeCapletSourcePath(file.path);
+ if (!path) {
+ throw new CapletsError("CONFIG_INVALID", `Invalid bundle file path ${file.path}`);
+ }
+ if (this.files.has(path)) {
+ throw new CapletsError("CONFIG_INVALID", `Duplicate bundle file path ${path}`);
+ }
+ this.files.set(path, { path, content: file.content });
+ }
+ }
+
+ async listFiles(): Promise {
+ return [...this.files.values()].sort((left, right) => left.path.localeCompare(right.path));
+ }
+
+ async readFile(path: string): Promise {
+ const normalized = normalizeCapletSourcePath(path);
+ if (!normalized) {
+ return undefined;
+ }
+ return this.files.get(normalized);
+ }
+}
diff --git a/packages/core/src/caplet-source/filesystem.ts b/packages/core/src/caplet-source/filesystem.ts
new file mode 100644
index 0000000..a84e224
--- /dev/null
+++ b/packages/core/src/caplet-source/filesystem.ts
@@ -0,0 +1,65 @@
+import { Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { isAbsolute, join, relative, resolve, sep } from "node:path";
+import type { CapletSource, CapletSourceFile } from "./types";
+import { normalizeCapletSourcePath } from "./types";
+
+export class FilesystemCapletSource implements CapletSource {
+ private readonly root: string;
+
+ constructor(root: string) {
+ this.root = resolve(root);
+ }
+
+ async listFiles(): Promise {
+ if (!existsSync(this.root) || !statSync(this.root).isDirectory()) {
+ return [];
+ }
+ return walkFiles(this.root, this.root).sort((left, right) =>
+ left.path.localeCompare(right.path),
+ );
+ }
+
+ async readFile(path: string): Promise {
+ const normalized = normalizeCapletSourcePath(path);
+ if (!normalized) {
+ return undefined;
+ }
+ const absolute = resolve(this.root, normalized);
+ if (
+ !isWithinRoot(this.root, absolute) ||
+ !existsSync(absolute) ||
+ !statSync(absolute).isFile()
+ ) {
+ return undefined;
+ }
+ return { path: normalized, content: readFileSync(absolute, "utf8") };
+ }
+}
+
+function walkFiles(root: string, dir: string): CapletSourceFile[] {
+ const files: CapletSourceFile[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(compareDirents)) {
+ const absolute = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...walkFiles(root, absolute));
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ const normalized = normalizeCapletSourcePath(relative(root, absolute));
+ if (normalized) {
+ files.push({ path: normalized, content: readFileSync(absolute, "utf8") });
+ }
+ }
+ return files;
+}
+
+function compareDirents(left: Dirent, right: Dirent): number {
+ return left.name.localeCompare(right.name);
+}
+
+function isWithinRoot(root: string, absolute: string): boolean {
+ const rel = relative(root, absolute);
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
+}
diff --git a/packages/core/src/caplet-source/index.ts b/packages/core/src/caplet-source/index.ts
new file mode 100644
index 0000000..4c37301
--- /dev/null
+++ b/packages/core/src/caplet-source/index.ts
@@ -0,0 +1,9 @@
+export { BundleCapletSource } from "./bundle";
+export { parseCapletSource } from "./parse";
+export type {
+ CapletSourceParseMessage,
+ CapletSourceParseResult,
+ CapletSourceReference,
+ ParsedCapletSourceCaplet,
+} from "./parse";
+export type { CapletSource, CapletSourceFile } from "./types";
diff --git a/packages/core/src/caplet-source/parse.ts b/packages/core/src/caplet-source/parse.ts
new file mode 100644
index 0000000..37bca58
--- /dev/null
+++ b/packages/core/src/caplet-source/parse.ts
@@ -0,0 +1,181 @@
+import { loadCapletFilesFromMap } from "../caplet-files-bundle";
+import { parseConfig, type CapletConfig, type CapletsConfig } from "../config-runtime";
+import { planCapletRuntimeRoutes, type CapletRuntimePlan } from "../runtime-plan";
+import type { CapletSource } from "./types";
+
+export type CapletSourceReference = {
+ path: string;
+ exists: boolean;
+};
+
+export type ParsedCapletSourceCaplet = {
+ id: string;
+ name: string;
+ description: string;
+ backend: CapletConfig["backend"];
+ sourcePath: string;
+ setupRequired: boolean;
+ authRequired: boolean;
+ projectBindingRequired: boolean;
+ runtime: CapletRuntimePlan["runtime"] & {
+ route: CapletRuntimePlan["route"];
+ setupTarget?: CapletRuntimePlan["setupTarget"] | undefined;
+ };
+ localReferences: CapletSourceReference[];
+ config: CapletConfig;
+};
+
+export type CapletSourceParseMessage = {
+ path?: string | undefined;
+ message: string;
+};
+
+export type CapletSourceParseResult = {
+ ok: boolean;
+ config?: CapletsConfig | undefined;
+ resolvedCaplets: ParsedCapletSourceCaplet[];
+ warnings: CapletSourceParseMessage[];
+ errors: CapletSourceParseMessage[];
+};
+
+export async function parseCapletSource(source: CapletSource): Promise {
+ const files = await source.listFiles();
+ let loaded: ReturnType;
+ try {
+ loaded = loadCapletFilesFromMap({ files });
+ } catch (error) {
+ return {
+ ok: false,
+ resolvedCaplets: [],
+ warnings: [],
+ errors: [{ message: errorMessage(error) }],
+ };
+ }
+
+ if (!loaded) {
+ return {
+ ok: false,
+ resolvedCaplets: [],
+ warnings: [],
+ errors: [
+ {
+ message:
+ "Caplet source must include at least one CAPLET.md or top-level Markdown Caplet file.",
+ },
+ ],
+ };
+ }
+
+ let config: CapletsConfig;
+ try {
+ config = parseConfig({ version: 1, ...loaded.config });
+ } catch (error) {
+ return {
+ ok: false,
+ resolvedCaplets: [],
+ warnings: [],
+ errors: [{ message: errorMessage(error) }],
+ };
+ }
+
+ const configCaplets = capletsFromConfig(config);
+ const plansById = new Map(
+ planCapletRuntimeRoutes(configCaplets, { deployment: "hosted" }).map((plan) => [plan.id, plan]),
+ );
+ const caplets = configCaplets.map((caplet) => {
+ const plan = plansById.get(caplet.server);
+ return {
+ id: caplet.server,
+ name: caplet.name,
+ description: caplet.description,
+ backend: caplet.backend,
+ sourcePath: loaded.paths[caplet.server] ?? "CAPLET.md",
+ setupRequired: Boolean(caplet.setup),
+ authRequired: authRequired("auth" in caplet ? caplet.auth : undefined),
+ projectBindingRequired: plan?.projectBindingRequired ?? false,
+ runtime: {
+ ...(plan?.runtime ?? {
+ features: [],
+ featureProvenance: [],
+ resources: { class: "standard", cpu: 2, memoryMb: 4096, diskMb: 8192 },
+ }),
+ route: plan?.route ?? "local_only",
+ ...(plan?.setupTarget === undefined ? {} : { setupTarget: plan.setupTarget }),
+ },
+ localReferences: localReferencePaths(caplet).map((path) => ({ path, exists: false })),
+ config: caplet,
+ };
+ });
+
+ for (const caplet of caplets) {
+ for (const reference of caplet.localReferences) {
+ reference.exists = Boolean(await source.readFile(reference.path));
+ }
+ }
+
+ const errors = caplets.flatMap((caplet) =>
+ caplet.localReferences
+ .filter((reference) => !reference.exists)
+ .map((reference) => ({
+ path: caplet.sourcePath,
+ message: `Referenced file ${reference.path} was not found.`,
+ })),
+ );
+
+ return {
+ ok: errors.length === 0,
+ config,
+ resolvedCaplets: errors.length === 0 ? caplets : [],
+ warnings: [],
+ errors,
+ };
+}
+
+function capletsFromConfig(config: CapletsConfig): CapletConfig[] {
+ return [
+ ...Object.values(config.mcpServers),
+ ...Object.values(config.openapiEndpoints),
+ ...Object.values(config.graphqlEndpoints),
+ ...Object.values(config.httpApis),
+ ...Object.values(config.cliTools),
+ ...Object.values(config.capletSets),
+ ];
+}
+
+function localReferencePaths(caplet: CapletConfig): string[] {
+ if (caplet.backend === "openapi") {
+ return filterLocalReferences([caplet.specPath]);
+ }
+ if (caplet.backend === "graphql") {
+ return filterLocalReferences([
+ caplet.schemaPath,
+ ...Object.values(caplet.operations ?? {}).map((operation) => operation.documentPath),
+ ]);
+ }
+ if (caplet.backend === "caplets") {
+ return filterLocalReferences([caplet.configPath]);
+ }
+ return [];
+}
+
+function filterLocalReferences(values: Array): string[] {
+ return values.filter(
+ (value): value is string =>
+ typeof value === "string" &&
+ value.length > 0 &&
+ !hasEnvReference(value) &&
+ !/^[a-z][a-z0-9+.-]*:/iu.test(value),
+ );
+}
+
+function authRequired(auth: unknown): boolean {
+ return auth !== null && typeof auth === "object" && "type" in auth && auth.type !== "none";
+}
+
+function hasEnvReference(value: string): boolean {
+ return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/u.test(value);
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/packages/core/src/caplet-source/types.ts b/packages/core/src/caplet-source/types.ts
new file mode 100644
index 0000000..b1e539a
--- /dev/null
+++ b/packages/core/src/caplet-source/types.ts
@@ -0,0 +1,33 @@
+export type CapletSourceFile = {
+ path: string;
+ content: string;
+};
+
+export type CapletSource = {
+ listFiles(): Promise;
+ readFile(path: string): Promise;
+};
+
+export function normalizeCapletSourcePath(path: string): string | undefined {
+ const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, "");
+ if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
+ return undefined;
+ }
+
+ const stack: string[] = [];
+ for (const segment of normalized.split("/")) {
+ if (!segment || segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ if (stack.length === 0) {
+ return undefined;
+ }
+ stack.pop();
+ continue;
+ }
+ stack.push(segment);
+ }
+
+ return stack.length > 0 ? stack.join("/") : undefined;
+}
diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts
index eba41cf..9aa0528 100644
--- a/packages/core/src/cli.ts
+++ b/packages/core/src/cli.ts
@@ -20,6 +20,7 @@ import {
} from "./cli/auth";
import { cliCommands } from "./cli/commands";
import { initConfig } from "./cli/init";
+import { doctorJsonReport, formatDoctorReport } from "./cli/doctor";
import {
completeCliWords,
completionScript,
@@ -27,6 +28,13 @@ import {
trailingSpaceCompletionToken,
type CompletionShell,
} from "./cli/completion";
+import { CloudAuthClient } from "./cloud-auth/client";
+import { openBrowserUrl } from "./cloud-auth/open-url";
+import {
+ CloudAuthStore,
+ redactedCloudAuthStatus,
+ type CloudAuthCredentials,
+} from "./cloud-auth/store";
import {
formatCapletList,
formatConfigPaths,
@@ -54,10 +62,26 @@ import {
} from "./config";
import { CapletsEngine } from "./engine";
import { CapletsError } from "./errors";
+import { resolveAttachServeOptions, type AttachServeOptions } from "./attach/options";
+import { attachResolvedCaplets } from "./attach/server";
+import { attachProjectOnce } from "./project-binding/attach";
+import { ProjectBindingError } from "./project-binding/errors";
+import type { ProjectBindingWebSocketFactory } from "./project-binding/transport";
import { RemoteControlClient } from "./remote-control/client";
import type { RemoteCliCommand } from "./remote-control/types";
import { resolveCapletsMode, resolveCapletsServer } from "./server/options";
-import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve";
+import {
+ daemonStatus,
+ disableDaemon,
+ enableDaemon,
+ resolveServeOptions,
+ restartDaemon,
+ serveResolvedCaplets,
+ startDaemon,
+ stopDaemon,
+ type ServeDaemonOperationOptions,
+ type ServeOptions,
+} from "./serve";
export { initConfig, starterConfig } from "./cli/init";
export { installCaplets, normalizeGitRepo } from "./cli/install";
@@ -74,10 +98,14 @@ type CliIO = {
writeErr?: (value: string) => void;
env?: NodeJS.ProcessEnv | Record;
fetch?: typeof fetch;
+ signal?: AbortSignal;
+ projectBindingWebSocketFactory?: ProjectBindingWebSocketFactory;
authDir?: string;
version?: string;
setExitCode?: (code: number) => void;
serve?: (options: ServeOptions) => Promise;
+ attachServe?: (options: AttachServeOptions) => Promise;
+ daemon?: ServeDaemonOperationOptions;
runSetupCommand?: SetupCommandRunner;
};
@@ -108,6 +136,110 @@ function normalizeCompletionWords(words: string[]): string[] {
return words.map((word) => (word === trailingSpaceCompletionToken ? "" : word));
}
+type ServeDaemonCommandOptions = {
+ transport?: string;
+ host?: string;
+ port?: string;
+ path?: string;
+ user?: string;
+ password?: string;
+ allowUnauthenticatedHttp?: boolean;
+ trustProxy?: boolean;
+ json?: boolean;
+};
+
+function addServeDaemonCommand(
+ parent: Command,
+ name: string,
+ description: string,
+ action: (options: ServeDaemonCommandOptions) => Promise,
+): void {
+ parent
+ .command(name)
+ .description(description)
+ .option("--transport ", "server transport: http")
+ .option("--host ", "HTTP bind host")
+ .option("--port ", "HTTP bind port")
+ .option("--path ", "HTTP service base path")
+ .option("--user ", "HTTP Basic Auth username")
+ .option("--password ", "HTTP Basic Auth password")
+ .option(
+ "--allow-unauthenticated-http",
+ "allow unauthenticated HTTP serving on non-loopback hosts",
+ )
+ .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy")
+ .option("--json", "print JSON output")
+ .action(function (this: Command, options: ServeDaemonCommandOptions) {
+ return action({ ...this.parent?.opts(), ...options });
+ });
+}
+
+function cloudAuthStore(
+ env: NodeJS.ProcessEnv | Record,
+): CloudAuthStore {
+ return new CloudAuthStore({ env });
+}
+
+function cloudAuthStatus(credentials: CloudAuthCredentials | undefined): Record {
+ return redactedCloudAuthStatus(credentials);
+}
+
+function isProjectBindingWebSocketUnavailable(error: unknown): boolean {
+ return (
+ error instanceof CapletsError &&
+ error.code === "SERVER_UNAVAILABLE" &&
+ error.message.includes("Project Binding WebSocket unavailable")
+ );
+}
+
+function isProjectBindingCliError(error: unknown): error is ProjectBindingError {
+ return error instanceof ProjectBindingError;
+}
+
+function serveRawOptions(options: ServeDaemonCommandOptions) {
+ return {
+ ...(options.transport !== undefined ? { transport: options.transport } : {}),
+ ...(options.host !== undefined ? { host: options.host } : {}),
+ ...(options.port !== undefined ? { port: options.port } : {}),
+ ...(options.path !== undefined ? { path: options.path } : {}),
+ ...(options.user !== undefined ? { user: options.user } : {}),
+ ...(options.password !== undefined ? { password: options.password } : {}),
+ ...(options.allowUnauthenticatedHttp !== undefined
+ ? { allowUnauthenticatedHttp: options.allowUnauthenticatedHttp }
+ : {}),
+ ...(options.trustProxy !== undefined ? { trustProxy: options.trustProxy } : {}),
+ };
+}
+
+async function waitForCloudLogin(
+ client: CloudAuthClient,
+ loginId: string,
+ env: NodeJS.ProcessEnv | Record,
+) {
+ const timeoutMs = numberEnv(env.CAPLETS_CLOUD_AUTH_TIMEOUT_MS, 120_000);
+ const intervalMs = numberEnv(env.CAPLETS_CLOUD_AUTH_POLL_INTERVAL_MS, 1_500);
+ const started = Date.now();
+ while (Date.now() - started <= timeoutMs) {
+ const result = await client.pollLogin(loginId);
+ if (result.status !== "pending" && result.status !== "workspace_selection_required") {
+ return result;
+ }
+ await sleep(intervalMs);
+ }
+ return { status: "expired" as const, message: "Cloud Auth login timed out." };
+}
+
+function numberEnv(value: string | undefined, fallback: number): number {
+ if (value === undefined) return fallback;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
+
+async function sleep(ms: number): Promise {
+ if (ms <= 0) return;
+ await new Promise((resolve) => setTimeout(resolve, ms));
+}
+
export function createProgram(io: CliIO = {}): Command {
const writeOut = io.writeOut ?? ((value: string) => process.stdout.write(value));
const writeErr = io.writeErr ?? ((value: string) => process.stderr.write(value));
@@ -196,7 +328,7 @@ export function createProgram(io: CliIO = {}): Command {
if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`);
});
- program
+ const serve = program
.command(cliCommands.serve)
.description("Serve configured Caplets as an MCP server.")
.option("--transport ", "server transport: stdio or http")
@@ -238,6 +370,389 @@ export function createProgram(io: CliIO = {}): Command {
},
);
+ const daemonOptions = (): ServeDaemonOperationOptions => ({
+ env,
+ ...io.daemon,
+ });
+
+ addServeDaemonCommand(
+ serve,
+ "start",
+ "Start the default Caplets HTTP daemon.",
+ async (options) => {
+ const result = await startDaemon(serveRawOptions(options), daemonOptions());
+ const serveConfig = result.status.config?.serve;
+ writeOut(
+ `Started Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`,
+ );
+ if (options.json) writeOut(`${JSON.stringify(result.status, null, 2)}\n`);
+ },
+ );
+ addServeDaemonCommand(serve, "stop", "Stop the default Caplets HTTP daemon.", async (options) => {
+ const result = await stopDaemon(daemonOptions());
+ if (options.json) {
+ writeOut(`${JSON.stringify(result.status, null, 2)}\n`);
+ return;
+ }
+ writeOut("Stopped Caplets HTTP daemon.\n");
+ });
+ addServeDaemonCommand(
+ serve,
+ "status",
+ "Show the default Caplets HTTP daemon status.",
+ async (options) => {
+ const status = await daemonStatus(daemonOptions());
+ if (options.json) {
+ writeOut(`${JSON.stringify(status, null, 2)}\n`);
+ return;
+ }
+ writeOut(
+ status.running
+ ? `Caplets HTTP daemon is running${status.pid ? ` (pid ${status.pid})` : ""}.\n`
+ : "Caplets HTTP daemon is stopped.\n",
+ );
+ },
+ );
+ addServeDaemonCommand(
+ serve,
+ "restart",
+ "Restart the default Caplets HTTP daemon.",
+ async (options) => {
+ const result = await restartDaemon(serveRawOptions(options), daemonOptions());
+ if (options.json) {
+ writeOut(`${JSON.stringify(result.status, null, 2)}\n`);
+ return;
+ }
+ const serveConfig = result.status.config?.serve;
+ writeOut(
+ `Restarted Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`,
+ );
+ },
+ );
+ addServeDaemonCommand(
+ serve,
+ "enable",
+ "Enable the default Caplets HTTP daemon at login.",
+ async (options) => {
+ const result = await enableDaemon(daemonOptions());
+ if (options.json) {
+ writeOut(`${JSON.stringify(result, null, 2)}\n`);
+ return;
+ }
+ writeOut(`Enabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`);
+ },
+ );
+ addServeDaemonCommand(
+ serve,
+ "disable",
+ "Disable the default Caplets HTTP daemon at login.",
+ async (options) => {
+ const result = await disableDaemon(daemonOptions());
+ if (options.json) {
+ writeOut(`${JSON.stringify(result, null, 2)}\n`);
+ return;
+ }
+ writeOut(`Disabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`);
+ },
+ );
+
+ program
+ .command(cliCommands.attach)
+ .description("Start a remote-backed Caplets MCP server.")
+ .option("--transport ", "server transport: stdio or http")
+ .option("--host ", "HTTP bind host")
+ .option("--port ", "HTTP bind port")
+ .option("--path ", "HTTP service base path")
+ .option("--remote-url ", "remote Caplets service base URL")
+ .option("--user ", "remote Basic Auth username")
+ .option("--password ", "remote Basic Auth password")
+ .option("--token ", "remote bearer token")
+ .option("--workspace ", "hosted Cloud workspace ID or slug")
+ .option(
+ "--allow-unauthenticated-http",
+ "allow unauthenticated HTTP serving on non-loopback hosts",
+ )
+ .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy")
+ .option("--json", "print JSON status events")
+ .option("--verbose", "print detailed attach diagnostics")
+ .option("--once", "validate Project Binding once and exit")
+ .option("--project-root ", "test-only project root override")
+ .action(
+ async (options: {
+ remoteUrl?: string;
+ transport?: string;
+ host?: string;
+ port?: string;
+ path?: string;
+ user?: string;
+ password?: string;
+ token?: string;
+ workspace?: string;
+ allowUnauthenticatedHttp?: boolean;
+ trustProxy?: boolean;
+ json?: boolean;
+ verbose?: boolean;
+ once?: boolean;
+ projectRoot?: string;
+ }) => {
+ try {
+ const attachOptions = { ...options, ...(io.fetch ? { fetch: io.fetch } : {}) };
+ if (!options.once) {
+ const resolved = await resolveAttachServeOptions(attachOptions, env);
+ await (
+ io.attachServe ??
+ ((serveOptions) => attachResolvedCaplets(serveOptions, { writeErr }))
+ )(resolved);
+ return;
+ }
+ const result = await attachProjectOnce(attachOptions, env);
+ if (options.json) {
+ writeOut(`${JSON.stringify(result, null, 2)}\n`);
+ return;
+ }
+ writeOut(`Project Binding available at ${result.webSocketUrl}.\n`);
+ } catch (error) {
+ if (options.json && isProjectBindingWebSocketUnavailable(error)) {
+ writeOut(
+ `${JSON.stringify(
+ {
+ ok: false,
+ error: {
+ code: "PROJECT_BINDING_WEBSOCKET_UNAVAILABLE",
+ message: error instanceof Error ? error.message : String(error),
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ setExitCode(1);
+ return;
+ }
+ if (options.json && isProjectBindingCliError(error)) {
+ writeOut(
+ `${JSON.stringify(
+ {
+ ok: false,
+ error: {
+ code: error.projectBindingCode,
+ message: error.message,
+ recoveryCommand: error.recoveryCommand,
+ requestId: error.requestId,
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ setExitCode(1);
+ return;
+ }
+ if (options.json && error instanceof CapletsError) {
+ writeOut(
+ `${JSON.stringify(
+ {
+ ok: false,
+ error: {
+ code: error.code,
+ message: error.message,
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ setExitCode(1);
+ return;
+ }
+ throw error;
+ }
+ },
+ );
+
+ const cloud = program.command(cliCommands.cloud).description("Manage hosted Caplets Cloud.");
+ const cloudAuth = cloud
+ .command("auth")
+ .description("Authenticate this Caplets client to hosted Caplets Cloud.");
+ cloudAuth
+ .command("login")
+ .description("Log in to hosted Caplets Cloud.")
+ .option("--cloud-url ", "hosted Caplets Cloud URL")
+ .option("--workspace ", "workspace ID or slug to select")
+ .option("--device-name ", "device label for this Cloud Auth credential")
+ .option("--no-open", "print the login URL without opening a browser")
+ .option("--json", "print JSON output")
+ .action(
+ async (options: {
+ cloudUrl?: string;
+ workspace?: string;
+ deviceName?: string;
+ open?: boolean;
+ json?: boolean;
+ }) => {
+ const cloudUrl = options.cloudUrl ?? env.CAPLETS_CLOUD_URL ?? "https://cloud.caplets.dev";
+ const client = new CloudAuthClient({ cloudUrl, ...(io.fetch ? { fetch: io.fetch } : {}) });
+ const started = await client.startLogin({
+ requestedWorkspace: options.workspace,
+ deviceName: options.deviceName ?? env.CAPLETS_DEVICE_NAME ?? "Caplets CLI",
+ });
+ if (options.open !== false) await openBrowserUrl(started.loginUrl);
+ if (!options.json) {
+ writeOut(`Open ${started.loginUrl}\n`);
+ writeOut(`Enter code ${started.userCode} if prompted.\n`);
+ }
+
+ const completed = await waitForCloudLogin(client, started.loginId, env);
+ if (completed.status !== "completed") {
+ throw new CapletsError("AUTH_FAILED", `Cloud Auth login ${completed.status}.`);
+ }
+ const exchanged = await client.exchangeToken({
+ loginId: started.loginId,
+ oneTimeCode: completed.oneTimeCode,
+ });
+ const now = new Date().toISOString();
+ const credentials: CloudAuthCredentials = {
+ version: 2,
+ cloudUrl: exchanged.cloudUrl,
+ workspaceId: exchanged.workspaceId,
+ ...(exchanged.workspaceSlug ? { workspaceSlug: exchanged.workspaceSlug } : {}),
+ accessToken: exchanged.accessToken,
+ refreshToken: exchanged.refreshToken ?? "",
+ expiresAt: exchanged.expiresAt,
+ scope: exchanged.scope,
+ tokenType: exchanged.tokenType,
+ credentialFamilyId: exchanged.credentialFamilyId,
+ deviceName: exchanged.deviceName ?? options.deviceName ?? "Caplets CLI",
+ createdAt: now,
+ lastRefreshAt: now,
+ };
+ await cloudAuthStore(env).save(credentials);
+ const status = cloudAuthStatus(credentials);
+ if (options.json) {
+ writeOut(`${JSON.stringify(status, null, 2)}\n`);
+ return;
+ }
+ writeOut(
+ `Authenticated to ${credentials.cloudUrl} as workspace ${credentials.workspaceSlug ?? credentials.workspaceId}.\n`,
+ );
+ },
+ );
+ cloudAuth
+ .command("status")
+ .description("Show hosted Caplets Cloud authentication status.")
+ .option("--json", "print JSON output")
+ .action(async (options: { json?: boolean }) => {
+ const credentials = await cloudAuthStore(env).load();
+ if (options.json) {
+ writeOut(`${JSON.stringify(cloudAuthStatus(credentials), null, 2)}\n`);
+ return;
+ }
+ writeOut(
+ credentials
+ ? `Authenticated to ${credentials.cloudUrl} as workspace ${credentials.workspaceSlug ?? credentials.workspaceId}.\n`
+ : "Not authenticated to hosted Caplets Cloud.\n",
+ );
+ });
+ cloudAuth
+ .command("logout")
+ .description("Log out of hosted Caplets Cloud.")
+ .action(async () => {
+ const store = cloudAuthStore(env);
+ const credentials = await store.load();
+ if (credentials?.refreshToken) {
+ await new CloudAuthClient({
+ cloudUrl: credentials.cloudUrl,
+ ...(io.fetch ? { fetch: io.fetch } : {}),
+ })
+ .logout(credentials.refreshToken)
+ .catch(() => undefined);
+ }
+ await store.clear();
+ writeOut("Logged out of hosted Caplets Cloud.\n");
+ });
+ cloudAuth
+ .command("workspaces")
+ .description("List hosted Caplets Cloud workspaces.")
+ .option("--json", "print JSON output")
+ .action(async (options: { json?: boolean }) => {
+ const credentials = await cloudAuthStore(env).load();
+ const workspaces = credentials?.accessToken
+ ? (
+ await new CloudAuthClient({
+ cloudUrl: credentials.cloudUrl,
+ ...(io.fetch ? { fetch: io.fetch } : {}),
+ })
+ .workspaces(credentials.accessToken)
+ .catch(() => ({
+ workspaces: [
+ {
+ workspaceId: credentials.workspaceId,
+ ...(credentials.workspaceSlug ? { slug: credentials.workspaceSlug } : {}),
+ },
+ ],
+ }))
+ ).workspaces.map((workspace) => ({
+ ...workspace,
+ selected:
+ workspace.workspaceId === credentials.workspaceId ||
+ workspace.slug === credentials.workspaceSlug,
+ }))
+ : [];
+ if (options.json) {
+ writeOut(`${JSON.stringify({ workspaces }, null, 2)}\n`);
+ return;
+ }
+ if (workspaces.length === 0) {
+ writeOut("No hosted Caplets Cloud workspaces available. Run caplets cloud auth login.\n");
+ return;
+ }
+ for (const workspace of workspaces) {
+ writeOut(`${workspace.selected ? "* " : " "}${workspace.slug ?? workspace.workspaceId}\n`);
+ }
+ });
+ cloudAuth
+ .command("switch")
+ .description("Switch the hosted Caplets Cloud Selected Workspace.")
+ .argument("", "workspace ID or slug")
+ .option("--json", "print JSON output")
+ .action(async (workspace: string, options: { json?: boolean }) => {
+ const store = cloudAuthStore(env);
+ const credentials = await store.load();
+ if (!credentials) {
+ throw new CapletsError("AUTH_REQUIRED", "Run caplets cloud auth login first.");
+ }
+ const client = new CloudAuthClient({
+ cloudUrl: credentials.cloudUrl,
+ ...(io.fetch ? { fetch: io.fetch } : {}),
+ });
+ const switched = await client.switchWorkspace({
+ accessToken: credentials.accessToken,
+ refreshToken: credentials.refreshToken,
+ workspace,
+ deviceName: credentials.deviceName,
+ });
+ const now = new Date().toISOString();
+ const next: CloudAuthCredentials = {
+ ...credentials,
+ workspaceId: switched.workspaceId,
+ workspaceSlug: switched.workspaceSlug,
+ accessToken: switched.accessToken,
+ refreshToken: switched.refreshToken ?? credentials.refreshToken,
+ expiresAt: switched.expiresAt,
+ scope: switched.scope,
+ tokenType: switched.tokenType,
+ credentialFamilyId: switched.credentialFamilyId,
+ lastRefreshAt: now,
+ selectedWorkspaceSwitchedAt: now,
+ };
+ await store.save(next);
+ if (options.json) {
+ writeOut(`${JSON.stringify(cloudAuthStatus(next), null, 2)}\n`);
+ return;
+ }
+ writeOut(`Selected workspace ${next.workspaceSlug ?? next.workspaceId}.\n`);
+ });
+
program
.command(cliCommands.init)
.description("Create a starter Caplets config file.")
@@ -271,6 +786,8 @@ export function createProgram(io: CliIO = {}): Command {
.option("--server-url ", "remote Caplets service base URL")
.option("--output ", "config path to write for generic MCP setup")
.option("--dry-run", "print actions without running commands or writing files")
+ .option("--yes", "approve Caplet setup commands for the exact current content hash")
+ .option("--target ", "Caplet setup target: local, remote, or cloud", parseSetupTarget)
.option("--format ", "output format: plain or json", parseSetupFormat)
.action(
async (
@@ -280,6 +797,8 @@ export function createProgram(io: CliIO = {}): Command {
serverUrl?: string;
output?: string;
dryRun?: boolean;
+ yes?: boolean;
+ target?: "local" | "remote" | "cloud";
format?: SetupFormat;
},
) => {
@@ -293,6 +812,18 @@ export function createProgram(io: CliIO = {}): Command {
},
);
+ program
+ .command(cliCommands.doctor)
+ .description("Diagnose Caplets local, remote, and project-sync configuration.")
+ .option("--json", "print JSON output")
+ .action(async (options: { json?: boolean }) => {
+ if (options.json) {
+ writeOut(`${JSON.stringify(await doctorJsonReport({ env }), null, 2)}\n`);
+ return;
+ }
+ writeOut(await formatDoctorReport({ env }));
+ });
+
program
.command(cliCommands.list)
.description("List configured Caplets.")
@@ -1368,6 +1899,11 @@ function parseSetupFormat(value: string): SetupFormat {
throw new CapletsError("REQUEST_INVALID", "setup format must be plain or json");
}
+function parseSetupTarget(value: string): "local" | "remote" | "cloud" {
+ if (value === "local" || value === "remote" || value === "cloud") return value;
+ throw new CapletsError("REQUEST_INVALID", "setup target must be local, remote, or cloud");
+}
+
function parseQualifiedTarget(
capletOrTarget: string,
toolArgument?: string | undefined,
diff --git a/packages/core/src/cli/add.ts b/packages/core/src/cli/add.ts
index fe371bb..5679809 100644
--- a/packages/core/src/cli/add.ts
+++ b/packages/core/src/cli/add.ts
@@ -5,6 +5,7 @@ import {
mkdirSync,
mkdtempSync,
openSync,
+ realpathSync,
rmSync,
writeFileSync,
writeSync,
@@ -373,14 +374,18 @@ function renderLocalPaths(fields: YamlField[], outputDir: string): YamlField[] {
}
function localPathRelativeToOutput(path: string, outputDir: string): string {
- const absolutePath = resolve(path);
- const rendered = relative(outputDir, resolve(path));
+ const absolutePath = displayPath(resolve(path));
+ const rendered = relative(outputDir, absolutePath);
if (rendered.startsWith("../..") || rendered.startsWith("..\\..")) {
return absolutePath;
}
return rendered === "" ? "." : rendered;
}
+function displayPath(path: string): string {
+ return path;
+}
+
function rejectUnsafeDestinationParents(path: string): void {
const parent = dirname(resolve(path));
const root = parse(parent).root;
@@ -394,6 +399,7 @@ function rejectUnsafeDestinationParents(path: string): void {
return;
}
if (stats.isSymbolicLink()) {
+ if (isDarwinSystemAliasSymlink(current)) continue;
throw new CapletsError(
"CONFIG_EXISTS",
`Output parent path ${current} is a symlink; remove it before writing`,
@@ -408,6 +414,16 @@ function rejectUnsafeDestinationParents(path: string): void {
}
}
+function isDarwinSystemAliasSymlink(path: string): boolean {
+ if (process.platform !== "darwin") return false;
+ if (path !== "/var" && path !== "/tmp") return false;
+ try {
+ return realpathSync(path) === `/private${path}`;
+ } catch {
+ return false;
+ }
+}
+
function lstatIfExists(path: string): ReturnType | undefined {
try {
return lstatSync(path);
diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts
index 5fe5535..17f96e6 100644
--- a/packages/core/src/cli/commands.ts
+++ b/packages/core/src/cli/commands.ts
@@ -5,8 +5,11 @@ export const cliCommands = {
completion: "completion",
completeHidden: "__complete",
serve: "serve",
+ attach: "attach",
+ cloud: "cloud",
init: "init",
setup: "setup",
+ doctor: "doctor",
list: "list",
install: "install",
add: "add",
@@ -30,8 +33,11 @@ export const cliCommands = {
export const topLevelCommandNames = [
cliCommands.serve,
+ cliCommands.attach,
+ cliCommands.cloud,
cliCommands.init,
cliCommands.setup,
+ cliCommands.doctor,
cliCommands.list,
cliCommands.install,
cliCommands.add,
@@ -57,8 +63,10 @@ export const topLevelCommandNames = [
export const cliSubcommands = {
[cliCommands.add]: ["cli", "mcp", "openapi", "graphql", "http"],
[cliCommands.auth]: ["login", "logout", "list"],
+ [cliCommands.cloud]: ["auth"],
[cliCommands.completion]: [...completionShells],
[cliCommands.config]: ["path", "paths"],
+ [cliCommands.serve]: ["start", "stop", "status", "restart", "enable", "disable"],
[cliCommands.setup]: ["codex", "claude-code", "opencode", "pi", "mcp-client"],
} as const satisfies Record;
diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts
new file mode 100644
index 0000000..83f9649
--- /dev/null
+++ b/packages/core/src/cli/doctor.ts
@@ -0,0 +1,153 @@
+import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root";
+import { CloudAuthStore, redactedCloudAuthStatus } from "../cloud-auth/store";
+import { projectBindingWorkspacePaths } from "../project-binding/workspaces";
+import { resolveCapletsRemote } from "../remote/options";
+import { resolveCapletsServer } from "../server/options";
+import type { MutagenProjectSyncDoctorData } from "../project-binding/mutagen";
+
+export type DoctorOptions = {
+ env?: NodeJS.ProcessEnv | Record;
+ cwd?: string;
+ syncStatus?: MutagenProjectSyncDoctorData;
+ cloudAuthStore?: CloudAuthStore;
+};
+
+export type DoctorJsonReport = {
+ server: Record;
+ remote: Record;
+ projectBinding: Record;
+ sync: Record;
+ daemon: Record;
+ cloudAuth: Record;
+};
+
+export async function doctorJsonReport(options: DoctorOptions = {}): Promise {
+ const env = options.env ?? process.env;
+ const root = findProjectRoot(options.cwd ?? process.cwd());
+ const projectFingerprint = fingerprintProjectRoot(root);
+ const paths = projectBindingWorkspacePaths(projectFingerprint, { env });
+ const server = resolveServerSection(env);
+ const remote = resolveRemoteSection(env);
+ const credentials = await (options.cloudAuthStore ?? new CloudAuthStore({ env })).load();
+
+ return {
+ server,
+ remote,
+ projectBinding: {
+ state: "not_attached",
+ projectRoot: root,
+ projectFingerprint,
+ workspacePath: paths.project,
+ authMode: credentials
+ ? "hosted_cloud"
+ : remote.configured
+ ? "self_hosted_remote"
+ : "unconfigured",
+ selectedWorkspace:
+ credentials?.workspaceSlug ?? credentials?.workspaceId ?? remote.workspace ?? null,
+ webSocketUrl: remote.webSocketUrl,
+ lease: null,
+ lastUpgradeError: null,
+ recoveryCommand:
+ credentials || remote.configured ? "caplets attach --once" : "caplets cloud auth login",
+ },
+ sync: {
+ state: options.syncStatus?.state ?? "idle",
+ diagnosticCode: options.syncStatus?.diagnosticCode ?? null,
+ mutagenBinary: options.syncStatus?.mutagenBinary ?? "mutagen",
+ mutagenVersion: options.syncStatus?.mutagenVersion ?? null,
+ lastCommand: options.syncStatus?.lastCommand ?? null,
+ },
+ daemon: {
+ configured: false,
+ running: false,
+ },
+ cloudAuth: redactedCloudAuthStatus(credentials),
+ };
+}
+
+export async function formatDoctorReport(options: DoctorOptions = {}): Promise {
+ const report = await doctorJsonReport(options);
+ const lines = [
+ "Server hosting",
+ ` Configured: ${yesNo(Boolean(report.server.configured))}`,
+ ...(report.server.configured ? [` Base URL: ${report.server.baseUrl}`] : []),
+ "",
+ "Remote client",
+ ` Configured: ${yesNo(Boolean(report.remote.configured))}`,
+ ...(report.remote.configured
+ ? [
+ ` MCP URL: ${report.remote.mcpUrl}`,
+ ` Control URL: ${report.remote.controlUrl}`,
+ ` Health URL: ${report.remote.healthUrl}`,
+ ` WebSocket URL: ${report.remote.webSocketUrl}`,
+ ` Auth: ${report.remote.auth}`,
+ ]
+ : []),
+ "",
+ "Project Binding",
+ ` State: ${report.projectBinding.state}`,
+ ` Project root: ${report.projectBinding.projectRoot}`,
+ ` Project fingerprint: ${report.projectBinding.projectFingerprint}`,
+ ` Workspace path: ${report.projectBinding.workspacePath}`,
+ ` Auth mode: ${report.projectBinding.authMode}`,
+ ` Selected Workspace: ${report.projectBinding.selectedWorkspace ?? "none"}`,
+ ` Binding Session: ${report.projectBinding.state}`,
+ ` Recovery: ${report.projectBinding.recoveryCommand}`,
+ "",
+ "Project sync",
+ ` State: ${report.sync.state}`,
+ ` Mutagen: ${report.sync.mutagenVersion ?? report.sync.mutagenBinary}`,
+ ...(report.sync.diagnosticCode ? [` Diagnostic: ${report.sync.diagnosticCode}`] : []),
+ "",
+ "Daemon",
+ ` Running: ${yesNo(Boolean(report.daemon.running))}`,
+ "",
+ "Cloud Auth",
+ ` Authenticated: ${yesNo(Boolean(report.cloudAuth.authenticated))}`,
+ ...(report.cloudAuth.cloudUrl ? [` Cloud URL: ${report.cloudAuth.cloudUrl}`] : []),
+ ...(report.cloudAuth.workspaceSlug || report.cloudAuth.workspaceId
+ ? [` Selected Workspace: ${report.cloudAuth.workspaceSlug ?? report.cloudAuth.workspaceId}`]
+ : []),
+ ];
+ return `${lines.join("\n")}\n`;
+}
+
+function resolveServerSection(env: NodeJS.ProcessEnv | Record) {
+ try {
+ const server = resolveCapletsServer({}, env);
+ return {
+ configured: true,
+ baseUrl: server.baseUrl.href,
+ mcpUrl: server.mcpUrl.href,
+ controlUrl: server.controlUrl.href,
+ healthUrl: server.healthUrl.href,
+ auth: server.auth.enabled ? "basic" : "none",
+ };
+ } catch {
+ return { configured: false };
+ }
+}
+
+function resolveRemoteSection(env: NodeJS.ProcessEnv | Record) {
+ try {
+ const remote = resolveCapletsRemote({}, env);
+ return {
+ configured: true,
+ baseUrl: remote.baseUrl.href,
+ mcpUrl: remote.mcpUrl.href,
+ controlUrl: remote.controlUrl.href,
+ healthUrl: remote.healthUrl.href,
+ webSocketUrl: remote.projectBindingWebSocketUrl.href,
+ auth: remote.auth.type,
+ tokenPresent: remote.auth.type === "bearer",
+ workspace: remote.workspace ?? null,
+ };
+ } catch {
+ return { configured: false };
+ }
+}
+
+function yesNo(value: boolean): string {
+ return value ? "yes" : "no";
+}
diff --git a/packages/core/src/cli/install.ts b/packages/core/src/cli/install.ts
index 847e4c8..a319cdd 100644
--- a/packages/core/src/cli/install.ts
+++ b/packages/core/src/cli/install.ts
@@ -197,6 +197,7 @@ function rejectUnsafeInstallParents(path: string): void {
return;
}
if (stats.isSymbolicLink()) {
+ if (isDarwinSystemAliasSymlink(current)) continue;
throw new CapletsError(
"CONFIG_EXISTS",
`Install destination parent ${current} is a symlink; remove it before installing`,
@@ -211,6 +212,16 @@ function rejectUnsafeInstallParents(path: string): void {
}
}
+function isDarwinSystemAliasSymlink(path: string): boolean {
+ if (process.platform !== "darwin") return false;
+ if (path !== "/var" && path !== "/tmp") return false;
+ try {
+ return realpathSync(path) === `/private${path}`;
+ } catch {
+ return false;
+ }
+}
+
function rejectUnsafeInstallDestination(plan: InstallPlan, force: boolean): void {
const stats = lstatIfExists(plan.destination);
if (!stats) {
diff --git a/packages/core/src/cli/setup-caplet.ts b/packages/core/src/cli/setup-caplet.ts
new file mode 100644
index 0000000..e4c0c4e
--- /dev/null
+++ b/packages/core/src/cli/setup-caplet.ts
@@ -0,0 +1,118 @@
+import type { CapletConfig } from "../config";
+import { loadConfig, resolveConfigPath, resolveProjectConfigPath } from "../config";
+import { CapletsError } from "../errors";
+import { capletSetupContentHash } from "../setup/hash";
+import { LocalSetupStore } from "../setup/local-store";
+import { runCapletSetup, type SetupSpawn } from "../setup/runner";
+import type { SetupActor, SetupTargetKind } from "../setup/types";
+
+export type CapletSetupCliOptions = {
+ yes?: boolean;
+ target?: SetupTargetKind;
+ remote?: boolean;
+ configPath?: string | undefined;
+ projectConfigPath?: string | undefined;
+ baseDir?: string | undefined;
+ spawn?: SetupSpawn;
+};
+
+export async function runCapletSetupCli(
+ capletId: string,
+ options: CapletSetupCliOptions = {},
+): Promise {
+ const targetKind = resolveSetupTarget(options);
+ if (targetKind === "hosted_sandbox") {
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "Cloud setup runs through the Caplets Cloud API, not the local CLI runner",
+ );
+ }
+
+ const configPath = options.configPath ?? resolveConfigPath();
+ const projectConfigPath = options.projectConfigPath ?? resolveProjectConfigPath();
+ const config = loadConfig(configPath, projectConfigPath);
+ const caplet = Object.values({
+ ...config.mcpServers,
+ ...config.openapiEndpoints,
+ ...config.graphqlEndpoints,
+ ...config.httpApis,
+ ...config.cliTools,
+ ...config.capletSets,
+ }).find((entry) => entry.server === capletId);
+ if (!caplet) throw new CapletsError("CONFIG_INVALID", `Unknown Caplet ID: ${capletId}`);
+ if (!caplet.setup || (!caplet.setup.commands?.length && !caplet.setup.verify?.length)) {
+ return `No setup metadata is defined for ${caplet.name} (${caplet.server}).\n`;
+ }
+
+ const contentHash = capletSetupContentHash(caplet as CapletConfig);
+ const projectFingerprint = "default";
+ const store = new LocalSetupStore(options.baseDir ? { baseDir: options.baseDir } : {});
+ const existingApproval = await store.getApproval(
+ projectFingerprint,
+ caplet.server,
+ contentHash,
+ targetKind,
+ );
+ const actor: SetupActor = options.yes ? "cli-yes" : "cli-interactive";
+ if (!existingApproval && !options.yes) {
+ return [
+ `Setup approval required for ${caplet.name} (${caplet.server}).`,
+ `Content hash: ${contentHash}`,
+ `Target: ${targetKind}`,
+ "",
+ "Commands:",
+ ...formatCommands(caplet.setup.commands ?? []),
+ "Verify:",
+ ...formatCommands(caplet.setup.verify ?? []),
+ "",
+ `Run caplets setup ${caplet.server} --yes to approve and execute these exact steps.`,
+ "",
+ ].join("\n");
+ }
+
+ if (options.yes && !existingApproval) {
+ await store.approve({
+ projectFingerprint,
+ capletId: caplet.server,
+ contentHash,
+ targetKind,
+ approvedAt: new Date().toISOString(),
+ actor,
+ });
+ }
+
+ const attempts = await runCapletSetup({
+ projectFingerprint,
+ capletId: caplet.server,
+ contentHash,
+ targetKind,
+ setup: caplet.setup,
+ actor,
+ approved: true,
+ store,
+ ...(options.spawn ? { spawn: options.spawn } : {}),
+ });
+ const failed = attempts.find((attempt) => attempt.status === "failed");
+ if (failed) {
+ throw new CapletsError(
+ "SERVER_UNAVAILABLE",
+ `Setup failed for ${caplet.server}: ${failed.commandLabel}`,
+ { attempts },
+ );
+ }
+ return `Completed setup for ${caplet.name} (${caplet.server}).\n`;
+}
+
+function resolveSetupTarget(options: CapletSetupCliOptions): SetupTargetKind {
+ if (options.target) return options.target;
+ return options.remote ? "remote_host" : "local_host";
+}
+
+function formatCommands(
+ commands: Array<{ label: string; command: string; args?: string[] | undefined }>,
+): string[] {
+ if (commands.length === 0) return [" none"];
+ return commands.map(
+ (command) => ` - ${command.label}: ${[command.command, ...(command.args ?? [])].join(" ")}`,
+ );
+}
diff --git a/packages/core/src/cli/setup.ts b/packages/core/src/cli/setup.ts
index 22e1e3d..0d3e06c 100644
--- a/packages/core/src/cli/setup.ts
+++ b/packages/core/src/cli/setup.ts
@@ -3,6 +3,8 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { promisify } from "node:util";
import { CapletsError } from "../errors";
+import { runCapletSetupCli } from "./setup-caplet";
+import { isSetupTargetKind, type SetupTargetKind } from "../setup/types";
const execFileAsync = promisify(execFile);
@@ -16,6 +18,7 @@ export const setupIntegrationIds = [
export type SetupIntegrationId = (typeof setupIntegrationIds)[number];
export type SetupFormat = "plain" | "json";
+export type SetupTargetOption = SetupTargetKind | "local" | "remote" | "cloud" | "hosted_worker";
export type SetupCommandResult = {
stdout: string;
@@ -32,6 +35,8 @@ export type SetupOptions = {
env?: NodeJS.ProcessEnv | Record;
format?: SetupFormat;
runCommand?: SetupCommandRunner;
+ yes?: boolean;
+ target?: SetupTargetOption;
};
type SetupAction =
@@ -49,6 +54,7 @@ type SetupResult = {
integration: SetupIntegrationId;
name: string;
mode: "local" | "remote";
+ targetKind: SetupTargetKind;
dryRun: boolean;
actions: SetupActionResult[];
nextSteps: string[];
@@ -84,6 +90,13 @@ export function formatSetupMenu(): string {
}
export async function runSetup(integration: string, options: SetupOptions = {}): Promise {
+ if (!setupIntegrationIds.includes(integration as SetupIntegrationId)) {
+ return await runCapletSetupCli(integration, {
+ ...(options.yes === undefined ? {} : { yes: options.yes }),
+ target: resolveSetupTargetKind(options),
+ ...(options.remote === undefined ? {} : { remote: options.remote }),
+ });
+ }
const result = await executeSetup(integration, options);
if (options.format === "json") return `${JSON.stringify(result, null, 2)}\n`;
return formatSetupResult(result);
@@ -138,6 +151,7 @@ async function executeSetup(integration: string, options: SetupOptions): Promise
integration: id,
name: definition.name,
mode: options.remote ? "remote" : "local",
+ targetKind: resolveSetupTargetKind(options),
dryRun: Boolean(options.dryRun),
actions,
nextSteps: definition.nextSteps,
@@ -345,7 +359,7 @@ async function defaultSetupCommandRunner(
function formatSetupResult(result: SetupResult): string {
const lines = [
- `${result.dryRun ? "Dry run" : "Completed"} ${result.name} setup (${result.mode})`,
+ `${result.dryRun ? "Dry run" : "Completed"} ${result.name} setup (${result.mode}, ${result.targetKind})`,
"",
];
for (const action of result.actions) {
@@ -368,3 +382,17 @@ function nonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
+
+function resolveSetupTargetKind(options: SetupOptions): SetupTargetKind {
+ if (options.target !== undefined) {
+ if (isSetupTargetKind(options.target)) return options.target;
+ if (options.target === "local") return "local_host";
+ if (options.target === "remote") return "remote_host";
+ if (options.target === "cloud" || options.target === "hosted_worker") return "hosted_sandbox";
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "setup target must be one of: local_host, remote_host, hosted_sandbox",
+ );
+ }
+ return options.remote ? "remote_host" : "local_host";
+}
diff --git a/packages/core/src/cloud-auth/client.ts b/packages/core/src/cloud-auth/client.ts
new file mode 100644
index 0000000..ae6c9c1
--- /dev/null
+++ b/packages/core/src/cloud-auth/client.ts
@@ -0,0 +1,181 @@
+import { CapletsError } from "../errors";
+import { redactCloudAuthSecrets, type CloudAuthRecovery, type CloudAuthErrorCode } from "./errors";
+import type {
+ CloudAuthLoginPollResult,
+ CloudAuthLoginStart,
+ CloudAuthTokenResponse,
+ CloudAuthWorkspace,
+} from "./types";
+
+export type CloudAuthClientOptions = {
+ cloudUrl: string;
+ fetch?: typeof fetch;
+};
+
+export type StartLoginInput = {
+ requestedWorkspace?: string | undefined;
+ deviceName?: string | undefined;
+ scope?: string[] | undefined;
+};
+
+export type ExchangeTokenInput = {
+ loginId: string;
+ oneTimeCode: string;
+};
+
+export type RefreshTokenInput = {
+ refreshToken: string;
+};
+
+export type CloudAuthClientCredentials = Required<
+ Pick<
+ CloudAuthTokenResponse,
+ "workspaceId" | "accessToken" | "expiresAt" | "tokenType" | "credentialFamilyId"
+ >
+> &
+ Pick & {
+ cloudUrl: string;
+ scope: string[];
+ redacted: Record;
+ };
+
+export class CloudAuthClient {
+ private readonly cloudUrl: URL;
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(options: CloudAuthClientOptions) {
+ this.cloudUrl = new URL(options.cloudUrl);
+ this.fetchImpl = options.fetch ?? fetch;
+ }
+
+ async startLogin(input: StartLoginInput = {}): Promise {
+ return await this.requestJson("/api/cloud-client/login/start", {
+ method: "POST",
+ body: JSON.stringify({
+ ...(input.requestedWorkspace ? { requestedWorkspace: input.requestedWorkspace } : {}),
+ ...(input.deviceName ? { deviceName: input.deviceName } : {}),
+ ...(input.scope ? { scope: input.scope } : {}),
+ }),
+ });
+ }
+
+ async pollLogin(loginId: string): Promise {
+ return await this.requestJson(`/api/cloud-client/login/${loginId}`);
+ }
+
+ async exchangeToken(input: ExchangeTokenInput): Promise {
+ const response = await this.requestJson("/api/cloud-client/token", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+ return normalizeCredentials(response, this.cloudUrl.origin);
+ }
+
+ async refresh(input: RefreshTokenInput): Promise {
+ const response = await this.requestJson("/api/cloud-client/refresh", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+ return normalizeCredentials(response, this.cloudUrl.origin);
+ }
+
+ async logout(refreshToken: string): Promise {
+ await this.requestJson("/api/cloud-client/logout", {
+ method: "POST",
+ body: JSON.stringify({ refreshToken }),
+ });
+ }
+
+ async workspaces(accessToken: string): Promise<{ workspaces: CloudAuthWorkspace[] }> {
+ return await this.requestJson<{ workspaces: CloudAuthWorkspace[] }>(
+ "/api/cloud-client/workspaces",
+ {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ },
+ );
+ }
+
+ async switchWorkspace(input: {
+ accessToken: string;
+ workspace: string;
+ refreshToken?: string | undefined;
+ deviceName?: string | undefined;
+ }): Promise {
+ const response = await this.requestJson("/api/cloud-client/switch", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${input.accessToken}` },
+ body: JSON.stringify({
+ workspace: input.workspace,
+ ...(input.refreshToken ? { refreshToken: input.refreshToken } : {}),
+ ...(input.deviceName ? { deviceName: input.deviceName } : {}),
+ }),
+ });
+ return normalizeCredentials(response, this.cloudUrl.origin);
+ }
+
+ private async requestJson(path: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers);
+ if (init.body !== undefined && !headers.has("content-type")) {
+ headers.set("content-type", "application/json");
+ }
+ const response = await this.fetchImpl(new URL(path, this.cloudUrl), { ...init, headers });
+ const requestId = response.headers.get("x-request-id") ?? undefined;
+ const body = (await response.json().catch(() => ({}))) as Record;
+ if (!response.ok) {
+ const code = typeof body.error === "string" ? body.error : "endpoint_unavailable";
+ const message =
+ typeof body.message === "string"
+ ? body.message
+ : `Cloud Auth request failed (${response.status}).`;
+ const recovery: CloudAuthRecovery = {
+ code: code as CloudAuthErrorCode,
+ message,
+ recoveryCommand:
+ code === "workspace_switch_required"
+ ? "caplets cloud auth switch "
+ : "caplets cloud auth login",
+ requestId,
+ };
+ throw new CapletsError("AUTH_FAILED", message, redactCloudAuthSecrets(recovery));
+ }
+ return { ...body, ...(requestId && !body.requestId ? { requestId } : {}) } as T;
+ }
+}
+
+function normalizeCredentials(
+ response: CloudAuthTokenResponse,
+ fallbackCloudUrl: string,
+): CloudAuthClientCredentials {
+ const scope = Array.isArray(response.scope)
+ ? response.scope.map(String)
+ : typeof response.scope === "string"
+ ? response.scope.split(/\s+/u).filter(Boolean)
+ : ["project_binding:read", "project_binding:write"];
+ const credentialFamilyId = response.credentialFamilyId ?? "cloud_client_credential_family";
+ const tokenType = response.tokenType ?? "Bearer";
+ const credentials: CloudAuthClientCredentials = {
+ cloudUrl: response.cloudUrl ?? fallbackCloudUrl,
+ workspaceId: response.workspaceId,
+ ...(response.workspaceSlug ? { workspaceSlug: response.workspaceSlug } : {}),
+ accessToken: response.accessToken,
+ ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}),
+ expiresAt: response.expiresAt,
+ scope,
+ tokenType,
+ credentialFamilyId,
+ ...(response.deviceName ? { deviceName: response.deviceName } : {}),
+ ...(response.requestId ? { requestId: response.requestId } : {}),
+ redacted: {
+ cloudUrl: response.cloudUrl ?? fallbackCloudUrl,
+ workspaceId: response.workspaceId,
+ ...(response.workspaceSlug ? { workspaceSlug: response.workspaceSlug } : {}),
+ expiresAt: response.expiresAt,
+ scope,
+ tokenType,
+ credentialFamilyId,
+ ...(response.deviceName ? { deviceName: response.deviceName } : {}),
+ ...(response.requestId ? { requestId: response.requestId } : {}),
+ },
+ };
+ return credentials;
+}
diff --git a/packages/core/src/cloud-auth/errors.ts b/packages/core/src/cloud-auth/errors.ts
new file mode 100644
index 0000000..0bb12f0
--- /dev/null
+++ b/packages/core/src/cloud-auth/errors.ts
@@ -0,0 +1,74 @@
+import { CapletsError } from "../errors";
+
+export type CloudAuthErrorCode =
+ | "cloud_auth_required"
+ | "cloud_auth_expired"
+ | "cloud_auth_revoked"
+ | "workspace_selection_required"
+ | "workspace_switch_required"
+ | "workspace_forbidden"
+ | "endpoint_unavailable";
+
+export type CloudAuthRecovery = {
+ code: CloudAuthErrorCode;
+ message: string;
+ recoveryCommand: string;
+ requestId?: string | undefined;
+};
+
+const SECRET_PATTERN =
+ /(cap_access_[a-z0-9._~+/=-]+|cap_refresh_[a-z0-9._~+/=-]+|one_time_code_[a-z0-9._~+/=-]+|Bearer\s+)[^\s"]*/giu;
+
+export function redactCloudAuthSecrets(value: unknown): unknown {
+ if (typeof value === "string") return value.replace(SECRET_PATTERN, "$1[REDACTED]");
+ if (Array.isArray(value)) return value.map((item) => redactCloudAuthSecrets(item));
+ if (value && typeof value === "object") {
+ const redacted: Record = {};
+ for (const [key, nested] of Object.entries(value)) {
+ redacted[key] = /token|secret|code|authorization|credential/i.test(key)
+ ? "[REDACTED]"
+ : redactCloudAuthSecrets(nested);
+ }
+ return redacted;
+ }
+ return value;
+}
+
+export function cloudAuthRecovery(code: CloudAuthErrorCode, detail?: string): CloudAuthRecovery {
+ const recoveryCommand =
+ code === "workspace_switch_required"
+ ? "caplets cloud auth switch "
+ : code === "workspace_selection_required"
+ ? "caplets cloud auth login --workspace "
+ : "caplets cloud auth login";
+ return {
+ code,
+ message: detail ?? defaultMessage(code),
+ recoveryCommand,
+ };
+}
+
+export function cloudAuthError(code: CloudAuthErrorCode, detail?: string): CapletsError {
+ const recovery = cloudAuthRecovery(code, detail);
+ return new CapletsError("AUTH_REQUIRED", recovery.message, recovery);
+}
+
+function defaultMessage(code: CloudAuthErrorCode): string {
+ switch (code) {
+ case "cloud_auth_expired":
+ return "Hosted Caplets Cloud credentials have expired.";
+ case "cloud_auth_revoked":
+ return "Hosted Caplets Cloud credentials were revoked.";
+ case "workspace_selection_required":
+ return "Select a hosted Caplets Cloud workspace before attaching this project.";
+ case "workspace_switch_required":
+ return "The requested workspace differs from the saved Selected Workspace.";
+ case "workspace_forbidden":
+ return "The saved Cloud Auth credential cannot access the requested workspace.";
+ case "endpoint_unavailable":
+ return "Hosted Caplets Cloud is unavailable.";
+ case "cloud_auth_required":
+ default:
+ return "Run caplets cloud auth login before using hosted Project Binding.";
+ }
+}
diff --git a/packages/core/src/cloud-auth/open-url.ts b/packages/core/src/cloud-auth/open-url.ts
new file mode 100644
index 0000000..aa793f8
--- /dev/null
+++ b/packages/core/src/cloud-auth/open-url.ts
@@ -0,0 +1,24 @@
+import { spawn } from "node:child_process";
+
+export type OpenUrlResult = {
+ opened: boolean;
+ command?: string | undefined;
+};
+
+export async function openBrowserUrl(
+ url: string,
+ options: { opener?: (url: string) => Promise | OpenUrlResult } = {},
+): Promise {
+ if (options.opener) return await options.opener(url);
+ const command =
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
+ return await new Promise((resolve) => {
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
+ child.once("error", () => resolve({ opened: false, command }));
+ child.once("spawn", () => {
+ child.unref();
+ resolve({ opened: true, command });
+ });
+ });
+}
diff --git a/packages/core/src/cloud-auth/store.ts b/packages/core/src/cloud-auth/store.ts
new file mode 100644
index 0000000..7e302de
--- /dev/null
+++ b/packages/core/src/cloud-auth/store.ts
@@ -0,0 +1,133 @@
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, posix, win32 } from "node:path";
+import { defaultConfigBaseDir } from "../config/paths";
+import type { RedactedCloudAuthStatus } from "./types";
+
+type CloudAuthPathEnv = Partial<
+ Record<"CAPLETS_CLOUD_AUTH_PATH" | "XDG_CONFIG_HOME" | "APPDATA", string>
+>;
+
+export type CloudAuthCredentials = {
+ version?: 1 | 2 | undefined;
+ cloudUrl: string;
+ workspaceId: string;
+ workspaceSlug?: string | undefined;
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: string;
+ scope?: string[] | undefined;
+ tokenType?: string | undefined;
+ credentialFamilyId?: string | undefined;
+ deviceName?: string | undefined;
+ createdAt?: string | undefined;
+ lastRefreshAt?: string | undefined;
+ selectedWorkspaceSwitchedAt?: string | undefined;
+};
+
+export type CloudAuthStoreOptions = {
+ path?: string;
+ env?: CloudAuthPathEnv;
+ home?: string;
+ platform?: NodeJS.Platform;
+};
+
+export class CloudAuthStore {
+ readonly path: string;
+
+ constructor(options: CloudAuthStoreOptions = {}) {
+ this.path = options.path ?? cloudAuthPath(options);
+ }
+
+ async load(): Promise {
+ if (!existsSync(this.path)) return undefined;
+ return migrateCredentials(JSON.parse(readFileSync(this.path, "utf8")));
+ }
+
+ async save(credentials: CloudAuthCredentials): Promise {
+ mkdirSync(dirname(this.path), { recursive: true });
+ writeFileSync(this.path, `${JSON.stringify(migrateCredentials(credentials), null, 2)}\n`, {
+ mode: 0o600,
+ });
+ }
+
+ async clear(): Promise {
+ rmSync(this.path, { force: true });
+ }
+}
+
+export function migrateCredentials(value: unknown): CloudAuthCredentials {
+ const record = isRecord(value) ? value : {};
+ const now = new Date().toISOString();
+ return {
+ version: 2,
+ cloudUrl: stringValue(record.cloudUrl) ?? "https://cloud.caplets.dev",
+ workspaceId: stringValue(record.workspaceId) ?? "",
+ ...(stringValue(record.workspaceSlug)
+ ? { workspaceSlug: stringValue(record.workspaceSlug) }
+ : {}),
+ accessToken: stringValue(record.accessToken) ?? "",
+ refreshToken: stringValue(record.refreshToken) ?? "",
+ expiresAt: stringValue(record.expiresAt) ?? now,
+ scope: arrayValue(record.scope) ?? ["project_binding:read", "project_binding:write"],
+ tokenType: stringValue(record.tokenType) ?? "Bearer",
+ credentialFamilyId: stringValue(record.credentialFamilyId) ?? "legacy_family",
+ deviceName: stringValue(record.deviceName) ?? "Caplets CLI",
+ createdAt: stringValue(record.createdAt) ?? now,
+ lastRefreshAt: stringValue(record.lastRefreshAt) ?? stringValue(record.createdAt) ?? now,
+ ...(stringValue(record.selectedWorkspaceSwitchedAt)
+ ? { selectedWorkspaceSwitchedAt: stringValue(record.selectedWorkspaceSwitchedAt) }
+ : {}),
+ };
+}
+
+export function redactedCloudAuthStatus(
+ credentials: CloudAuthCredentials | undefined,
+ now = new Date(),
+): RedactedCloudAuthStatus {
+ if (!credentials) return { authenticated: false, status: "unauthenticated" };
+ const expired = Number.isFinite(Date.parse(credentials.expiresAt))
+ ? Date.parse(credentials.expiresAt) <= now.getTime()
+ : false;
+ const refreshable = expired && Boolean(credentials.refreshToken);
+ return {
+ authenticated: !expired,
+ status: refreshable ? "refreshable" : expired ? "expired" : "authenticated",
+ cloudUrl: credentials.cloudUrl,
+ workspaceId: credentials.workspaceId,
+ ...(credentials.workspaceSlug ? { workspaceSlug: credentials.workspaceSlug } : {}),
+ expiresAt: credentials.expiresAt,
+ scope: credentials.scope,
+ tokenType: credentials.tokenType,
+ credentialFamilyId: credentials.credentialFamilyId,
+ deviceName: credentials.deviceName,
+ createdAt: credentials.createdAt,
+ lastRefreshAt: credentials.lastRefreshAt,
+ selectedWorkspaceSwitchedAt: credentials.selectedWorkspaceSwitchedAt,
+ };
+}
+
+export function cloudAuthPath(options: CloudAuthStoreOptions = {}): string {
+ const env = options.env ?? process.env;
+ if (env.CAPLETS_CLOUD_AUTH_PATH?.trim()) return env.CAPLETS_CLOUD_AUTH_PATH.trim();
+ const platform = options.platform ?? process.platform;
+ const home = options.home ?? homedir();
+ if (platform === "win32") {
+ return win32.join(defaultConfigBaseDir(env, home, platform), "Caplets", "cloud-auth.json");
+ }
+ return posix.join(defaultConfigBaseDir(env, home, platform), "caplets", "cloud-auth.json");
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value : undefined;
+}
+
+function arrayValue(value: unknown): string[] | undefined {
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
+ if (typeof value === "string") return value.split(/\s+/u).filter(Boolean);
+ return undefined;
+}
diff --git a/packages/core/src/cloud-auth/types.ts b/packages/core/src/cloud-auth/types.ts
new file mode 100644
index 0000000..d0f3139
--- /dev/null
+++ b/packages/core/src/cloud-auth/types.ts
@@ -0,0 +1,86 @@
+export const CLOUD_AUTH_STATES = [
+ "unauthenticated",
+ "login_pending",
+ "workspace_selection_required",
+ "authenticated",
+ "refreshable",
+ "switch_required",
+ "expired",
+ "revoked",
+] as const;
+
+export type CloudAuthState = (typeof CLOUD_AUTH_STATES)[number];
+
+export type CloudAuthScope = "project_binding:read" | "project_binding:write" | string;
+
+export type CloudAuthWorkspace = {
+ workspaceId: string;
+ slug?: string | undefined;
+ displayName?: string | undefined;
+ name?: string | undefined;
+ role?: string | undefined;
+ selected?: boolean | undefined;
+};
+
+export type CloudAuthLoginStart = {
+ loginId: string;
+ loginUrl: string;
+ userCode: string;
+ expiresAt: string;
+ requestId?: string | undefined;
+};
+
+export type CloudAuthLoginPollResult =
+ | {
+ status: "pending";
+ expiresAt?: string | undefined;
+ requestId?: string | undefined;
+ }
+ | {
+ status: "workspace_selection_required";
+ workspaces: CloudAuthWorkspace[];
+ expiresAt?: string | undefined;
+ requestId?: string | undefined;
+ }
+ | {
+ status: "completed";
+ selectedWorkspace?: Pick | undefined;
+ oneTimeCode: string;
+ requestId?: string | undefined;
+ }
+ | {
+ status: "expired" | "denied" | "consumed";
+ message?: string | undefined;
+ requestId?: string | undefined;
+ };
+
+export type CloudAuthTokenResponse = {
+ status?: "authenticated" | undefined;
+ cloudUrl?: string | undefined;
+ workspaceId: string;
+ workspaceSlug?: string | undefined;
+ accessToken: string;
+ refreshToken?: string | undefined;
+ expiresAt: string;
+ scope?: CloudAuthScope[] | string | undefined;
+ tokenType?: "Bearer" | string | undefined;
+ credentialFamilyId?: string | undefined;
+ deviceName?: string | undefined;
+ requestId?: string | undefined;
+};
+
+export type RedactedCloudAuthStatus = {
+ authenticated: boolean;
+ status: CloudAuthState;
+ cloudUrl?: string | undefined;
+ workspaceId?: string | undefined;
+ workspaceSlug?: string | undefined;
+ expiresAt?: string | undefined;
+ scope?: CloudAuthScope[] | undefined;
+ tokenType?: string | undefined;
+ credentialFamilyId?: string | undefined;
+ deviceName?: string | undefined;
+ createdAt?: string | undefined;
+ lastRefreshAt?: string | undefined;
+ selectedWorkspaceSwitchedAt?: string | undefined;
+};
diff --git a/packages/core/src/cloud/apply.ts b/packages/core/src/cloud/apply.ts
new file mode 100644
index 0000000..5463073
--- /dev/null
+++ b/packages/core/src/cloud/apply.ts
@@ -0,0 +1,111 @@
+import { createHash } from "node:crypto";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, relative, resolve } from "node:path";
+
+export type ApplyReceiptInput = {
+ projectFingerprint: string;
+ filesChanged: string[];
+ skipped: string[];
+ policyWarnings: string[];
+};
+
+export type ApplyReceipt = ApplyReceiptInput & {
+ status: "applied";
+};
+
+export type ApplyConflict = {
+ path: string;
+ kind: "content" | "delete_modify" | "binary";
+ message?: string;
+};
+
+export type RemoteFileChange = {
+ path: string;
+ baseSha256?: string;
+ content: string;
+};
+
+export function createApplyReceipt(input: ApplyReceiptInput): ApplyReceipt {
+ return { status: "applied", ...input };
+}
+
+export function classifyApplyResult(input: {
+ conflicts: ApplyConflict[];
+}):
+ | { status: "applied"; recoverable: false }
+ | { status: "apply_conflict"; recoverable: true; conflicts: ApplyConflict[] } {
+ if (input.conflicts.length === 0) return { status: "applied", recoverable: false };
+ return { status: "apply_conflict", recoverable: true, conflicts: input.conflicts };
+}
+
+export function applyRemoteFileChanges(
+ projectRoot: string,
+ changes: RemoteFileChange[],
+): ApplyReceipt | { status: "apply_conflict"; recoverable: true; conflicts: ApplyConflict[] } {
+ const root = resolve(projectRoot);
+ const realRoot = realpathSync(root);
+ const conflicts: ApplyConflict[] = [];
+ const writable: Array<{ path: string; absolutePath: string; content: string }> = [];
+
+ for (const change of changes) {
+ const absolutePath = resolve(root, change.path);
+ if (relative(root, absolutePath).startsWith("..")) {
+ conflicts.push({ path: change.path, kind: "content", message: "Path escapes project root." });
+ continue;
+ }
+ if (pathHasSymlink(root, realRoot, absolutePath)) {
+ conflicts.push({ path: change.path, kind: "content", message: "Path traverses a symlink." });
+ continue;
+ }
+ const current = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : "";
+ if (change.baseSha256 && sha256(current) !== change.baseSha256) {
+ conflicts.push({
+ path: change.path,
+ kind: "content",
+ message: "Local file changed since the remote sandbox base.",
+ });
+ continue;
+ }
+ writable.push({ path: change.path, absolutePath, content: change.content });
+ }
+
+ if (conflicts.length > 0) {
+ return { status: "apply_conflict", recoverable: true, conflicts };
+ }
+
+ for (const change of writable) {
+ mkdirSync(dirname(change.absolutePath), { recursive: true });
+ writeFileSync(change.absolutePath, change.content, "utf8");
+ }
+
+ return createApplyReceipt({
+ projectFingerprint: sha256(root),
+ filesChanged: writable.map((change) => change.path),
+ skipped: [],
+ policyWarnings: [],
+ });
+}
+
+export function sha256(value: string): string {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+function pathHasSymlink(root: string, realRoot: string, target: string): boolean {
+ let current = root;
+ for (const part of relative(root, target).split(/[\\/]+/u)) {
+ if (!part) continue;
+ current = resolve(current, part);
+ if (!existsSync(current)) continue;
+ if (lstatSync(current).isSymbolicLink()) return true;
+ const real = realpathSync(current);
+ if (relative(realRoot, real).startsWith("..")) return true;
+ }
+ return false;
+}
diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts
new file mode 100644
index 0000000..871538b
--- /dev/null
+++ b/packages/core/src/cloud/client.ts
@@ -0,0 +1,116 @@
+import type { ProjectSyncFile } from "./sync";
+
+export type CapletsCloudClientOptions = {
+ baseUrl: URL;
+ accessToken: string;
+ fetch?: typeof fetch;
+};
+
+export type RegisterPresenceInput = {
+ workspaceId: string;
+ projectRoot: string;
+ projectFingerprint: string;
+ allowedCapletIds: string[];
+ projectFiles?: ProjectSyncFile[] | undefined;
+ fallbackConsent?: "allow" | "deny" | undefined;
+};
+
+export type RegisterPresenceResult = {
+ presenceId: string;
+ expiresAt: string;
+};
+
+export type HeartbeatPresenceResult = RegisterPresenceResult;
+
+export class CapletsCloudClient {
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(private readonly options: CapletsCloudClientOptions) {
+ this.fetchImpl = options.fetch ?? fetch;
+ }
+
+ async registerPresence(input: RegisterPresenceInput): Promise {
+ const response = await this.fetchImpl(this.endpoint("api/project-bindings"), {
+ method: "POST",
+ headers: this.headers({ json: true }),
+ body: JSON.stringify({
+ workspaceId: input.workspaceId,
+ projectRoot: input.projectRoot,
+ projectFingerprint: input.projectFingerprint,
+ state: "ready",
+ syncState: "idle",
+ allowedCapletIds: input.allowedCapletIds,
+ fallbackConsent: input.fallbackConsent ?? "deny",
+ projectFiles: input.projectFiles ?? [],
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`Caplets Cloud Project Binding registration failed: HTTP ${response.status}`);
+ }
+ const body = (await response.json()) as { binding?: { bindingId?: string } };
+ return {
+ presenceId: body.binding?.bindingId ?? `${input.workspaceId}:${input.projectFingerprint}`,
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ };
+ }
+
+ async stopPresence(presenceId: string): Promise {
+ const response = await this.fetchImpl(
+ this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`),
+ {
+ method: "PATCH",
+ headers: this.headers({ json: true }),
+ body: JSON.stringify({ state: "offline" }),
+ },
+ );
+ if (!response.ok && response.status !== 404) {
+ throw new Error(`Caplets Cloud Project Binding stop failed: HTTP ${response.status}`);
+ }
+ }
+
+ async heartbeatPresence(presenceId: string): Promise {
+ const response = await this.fetchImpl(
+ this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`),
+ {
+ method: "PATCH",
+ headers: this.headers({ json: true }),
+ body: JSON.stringify({ state: "ready", syncState: "idle" }),
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Caplets Cloud Project Binding heartbeat failed: HTTP ${response.status}`);
+ }
+ return {
+ presenceId,
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ };
+ }
+
+ async updatePresenceCaplets(presenceId: string, allowedCapletIds: string[]): Promise {
+ const response = await this.fetchImpl(
+ this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`),
+ {
+ method: "PATCH",
+ headers: this.headers({ json: true }),
+ body: JSON.stringify({ allowedCapletIds }),
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Caplets Cloud Project Binding update failed: HTTP ${response.status}`);
+ }
+ }
+
+ private headers(options: { json?: boolean } = {}): Headers {
+ const headers = new Headers();
+ headers.set("authorization", `Bearer ${this.options.accessToken}`);
+ if (options.json) headers.set("content-type", "application/json");
+ return headers;
+ }
+
+ private endpoint(path: string): URL {
+ const url = new URL(this.options.baseUrl.href);
+ const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/u, "");
+ url.pathname = `${basePath}/${path.replace(/^\/+/u, "")}`;
+ return url;
+ }
+}
diff --git a/packages/core/src/cloud/presence.ts b/packages/core/src/cloud/presence.ts
new file mode 100644
index 0000000..3dfa096
--- /dev/null
+++ b/packages/core/src/cloud/presence.ts
@@ -0,0 +1,88 @@
+import type { CapletsCloudClient, RegisterPresenceInput } from "./client";
+
+type PresenceClient = Pick & {
+ heartbeatPresence?: (presenceId: string) => Promise;
+ stopPresence?: (presenceId: string) => Promise;
+ updatePresenceCaplets?: (presenceId: string, allowedCapletIds: string[]) => Promise;
+};
+
+export type ProjectBindingSessionManagerOptions = RegisterPresenceInput & {
+ client: PresenceClient;
+ heartbeatIntervalMs?: number;
+ setInterval?: typeof setInterval;
+ clearInterval?: typeof clearInterval;
+ onError?: (error: unknown) => void;
+};
+
+export class ProjectBindingSessionManager {
+ private presenceId: string | undefined;
+ private heartbeatTimer: ReturnType | undefined;
+ private startPromise: Promise | undefined;
+
+ constructor(private readonly options: ProjectBindingSessionManagerOptions) {}
+
+ async start(): Promise {
+ if (this.startPromise) {
+ return await this.startPromise;
+ }
+ this.startPromise = this.register();
+ return await this.startPromise;
+ }
+
+ private async register(): Promise {
+ const result = await this.options.client.registerPresence({
+ workspaceId: this.options.workspaceId,
+ projectRoot: this.options.projectRoot,
+ projectFingerprint: this.options.projectFingerprint,
+ allowedCapletIds: this.options.allowedCapletIds,
+ projectFiles: this.options.projectFiles,
+ fallbackConsent: this.options.fallbackConsent ?? "deny",
+ });
+ this.presenceId = result.presenceId;
+ this.startHeartbeat();
+ }
+
+ async close(): Promise {
+ await this.startPromise?.catch(() => undefined);
+ const presenceId = this.presenceId;
+ this.presenceId = undefined;
+ this.stopHeartbeat();
+ if (presenceId && this.options.client.stopPresence) {
+ await this.options.client.stopPresence(presenceId);
+ }
+ }
+
+ async updateAllowedCapletIds(allowedCapletIds: string[]): Promise {
+ await this.startPromise?.catch(() => undefined);
+ const presenceId = this.presenceId;
+ if (!presenceId || !this.options.client.updatePresenceCaplets) {
+ return;
+ }
+ await this.options.client.updatePresenceCaplets(presenceId, allowedCapletIds);
+ }
+
+ private startHeartbeat(): void {
+ if (!this.options.client.heartbeatPresence || this.options.heartbeatIntervalMs === undefined) {
+ return;
+ }
+ const setIntervalImpl = this.options.setInterval ?? setInterval;
+ this.heartbeatTimer = setIntervalImpl(() => {
+ const presenceId = this.presenceId;
+ if (!presenceId || !this.options.client.heartbeatPresence) return;
+ void this.options.client.heartbeatPresence(presenceId).catch((error) => {
+ this.options.onError?.(error);
+ });
+ }, this.options.heartbeatIntervalMs);
+ }
+
+ private stopHeartbeat(): void {
+ const timer = this.heartbeatTimer;
+ this.heartbeatTimer = undefined;
+ if (timer) {
+ (this.options.clearInterval ?? clearInterval)(timer);
+ }
+ }
+}
+
+export type LocalPresenceManagerOptions = ProjectBindingSessionManagerOptions;
+export const LocalPresenceManager = ProjectBindingSessionManager;
diff --git a/packages/core/src/cloud/project-root.ts b/packages/core/src/cloud/project-root.ts
new file mode 100644
index 0000000..4a51678
--- /dev/null
+++ b/packages/core/src/cloud/project-root.ts
@@ -0,0 +1,35 @@
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+
+const ROOT_MARKERS = [".caplets", ".git", "package.json", "pnpm-workspace.yaml"] as const;
+
+export function findProjectRoot(start = process.cwd()): string {
+ let current = resolve(start);
+ while (true) {
+ if (ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))) {
+ return current;
+ }
+ const parent = dirname(current);
+ if (parent === current) return resolve(start);
+ current = parent;
+ }
+}
+
+export function fingerprintProjectRoot(root: string): string {
+ const resolved = resolve(root);
+ const hash = createHash("sha256");
+ hash.update(resolved);
+ for (const marker of ROOT_MARKERS) {
+ const path = join(resolved, marker);
+ if (!existsSync(path)) continue;
+ hash.update(marker);
+ try {
+ const stat = statSync(path);
+ hash.update(stat.isDirectory() ? "directory" : readFileSync(path));
+ } catch {
+ hash.update("unreadable");
+ }
+ }
+ return `sha256:${hash.digest("hex")}`;
+}
diff --git a/packages/core/src/cloud/runtime-adapter.ts b/packages/core/src/cloud/runtime-adapter.ts
new file mode 100644
index 0000000..f98d959
--- /dev/null
+++ b/packages/core/src/cloud/runtime-adapter.ts
@@ -0,0 +1,178 @@
+import type { CapletConfig } from "../config";
+import { CapletsEngine } from "../engine";
+import { CapletsError } from "../errors";
+import { capletSetupContentHash } from "../setup/hash";
+import { LocalSetupStore } from "../setup/local-store";
+import { runCapletSetup } from "../setup/runner";
+import type { SetupActor, SetupAttempt, SetupPlan } from "../setup/types";
+
+export type CloudRuntimeAdapterOptions = {
+ configPath?: string;
+ projectConfigPath?: string;
+ authDir?: string;
+ runtimeId: string;
+ sandboxId?: string;
+ executionKind: "cloud" | "local-fallback";
+ setupStore?: LocalSetupStore;
+};
+
+export type CloudRuntimeAdapter = {
+ listTools(): Promise;
+ callTool(name: string, args: unknown): Promise;
+ checkBackend(capletId: string): Promise;
+ setupPlan(capletId: string): Promise;
+ runSetup(
+ capletId: string,
+ input: { approved: boolean; actor: SetupActor },
+ ): Promise;
+ close(): Promise;
+};
+
+export function createCloudRuntimeAdapter(
+ options: CloudRuntimeAdapterOptions,
+): CloudRuntimeAdapter {
+ return new DefaultCloudRuntimeAdapter(options);
+}
+
+class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter {
+ private readonly engine: CapletsEngine;
+ private readonly setupStore: LocalSetupStore;
+
+ constructor(private readonly options: CloudRuntimeAdapterOptions) {
+ this.engine = new CapletsEngine({
+ ...(options.configPath === undefined ? {} : { configPath: options.configPath }),
+ ...(options.projectConfigPath === undefined
+ ? {}
+ : { projectConfigPath: options.projectConfigPath }),
+ ...(options.authDir === undefined ? {} : { authDir: options.authDir }),
+ watch: false,
+ });
+ this.setupStore = options.setupStore ?? new LocalSetupStore();
+ }
+
+ async listTools(): Promise {
+ return {
+ tools: this.enabledCaplets().map((caplet) => ({
+ name: caplet.server,
+ title: caplet.name,
+ description: caplet.description,
+ inputSchema: { type: "object", additionalProperties: true },
+ _meta: { caplets: { execution: this.executionMetadata() } },
+ })),
+ };
+ }
+
+ async callTool(name: string, args: unknown): Promise {
+ const request =
+ isRecord(args) && typeof args.operation === "string"
+ ? args
+ : { operation: "call_tool", tool: name, arguments: isRecord(args) ? args : {} };
+ const result = await this.engine.execute(name, request);
+ return annotateExecution(result, this.executionMetadata());
+ }
+
+ async checkBackend(capletId: string): Promise {
+ return annotateExecution(
+ await this.engine.execute(capletId, { operation: "check_backend" }),
+ this.executionMetadata(),
+ );
+ }
+
+ async setupPlan(capletId: string): Promise {
+ const caplet = this.requireCaplet(capletId);
+ const contentHash = capletSetupContentHash(caplet);
+ const projectFingerprint = "hosted";
+ const targetKind = "hosted_sandbox";
+ const approved = Boolean(
+ await this.setupStore.getApproval(projectFingerprint, capletId, contentHash, targetKind),
+ );
+ return {
+ projectFingerprint,
+ capletId,
+ name: caplet.name,
+ contentHash,
+ targetKind,
+ setup: caplet.setup ?? {},
+ approved,
+ commands: caplet.setup?.commands ?? [],
+ verify: caplet.setup?.verify ?? [],
+ };
+ }
+
+ async runSetup(
+ capletId: string,
+ input: { approved: boolean; actor: SetupActor },
+ ): Promise {
+ const plan = await this.setupPlan(capletId);
+ if (input.approved && !plan.approved) {
+ await this.setupStore.approve({
+ projectFingerprint: plan.projectFingerprint,
+ capletId,
+ contentHash: plan.contentHash,
+ targetKind: plan.targetKind,
+ actor: input.actor,
+ approvedAt: new Date().toISOString(),
+ });
+ }
+ return await runCapletSetup({
+ capletId,
+ projectFingerprint: plan.projectFingerprint,
+ contentHash: plan.contentHash,
+ targetKind: plan.targetKind,
+ setup: plan.setup,
+ actor: input.actor,
+ approved: input.approved || plan.approved,
+ store: this.setupStore,
+ });
+ }
+
+ async close(): Promise {
+ await this.engine.close();
+ }
+
+ private enabledCaplets(): CapletConfig[] {
+ return Object.values({
+ ...this.engine.currentConfig().mcpServers,
+ ...this.engine.currentConfig().openapiEndpoints,
+ ...this.engine.currentConfig().graphqlEndpoints,
+ ...this.engine.currentConfig().httpApis,
+ ...this.engine.currentConfig().cliTools,
+ ...this.engine.currentConfig().capletSets,
+ }).filter((caplet) => !caplet.disabled);
+ }
+
+ private requireCaplet(capletId: string): CapletConfig {
+ const caplet = this.enabledCaplets().find((entry) => entry.server === capletId);
+ if (!caplet) throw new CapletsError("CONFIG_INVALID", `Unknown Caplet ID: ${capletId}`);
+ return caplet;
+ }
+
+ private executionMetadata() {
+ return {
+ kind: this.options.executionKind,
+ runtimeId: this.options.runtimeId,
+ ...(this.options.sandboxId ? { sandboxId: this.options.sandboxId } : {}),
+ ...(this.options.executionKind === "local-fallback" ? { fallback: true } : {}),
+ };
+ }
+}
+
+function annotateExecution(result: unknown, execution: Record): unknown {
+ if (!isRecord(result)) return result;
+ const meta = isRecord(result._meta) ? result._meta : {};
+ const caplets = isRecord(meta.caplets) ? meta.caplets : {};
+ return {
+ ...result,
+ _meta: {
+ ...meta,
+ caplets: {
+ ...caplets,
+ execution,
+ },
+ },
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/packages/core/src/cloud/runtime-http.ts b/packages/core/src/cloud/runtime-http.ts
new file mode 100644
index 0000000..ff30940
--- /dev/null
+++ b/packages/core/src/cloud/runtime-http.ts
@@ -0,0 +1,56 @@
+import { Hono } from "hono";
+import { createCloudRuntimeAdapter, type CloudRuntimeAdapterOptions } from "./runtime-adapter";
+
+export type RuntimeHttpOptions = CloudRuntimeAdapterOptions & {
+ token: string;
+};
+
+export function createRuntimeHttpApp(options: RuntimeHttpOptions): Hono {
+ const app = new Hono();
+ let adapter: ReturnType | undefined;
+ const runtimeAdapter = () => {
+ adapter ??= createCloudRuntimeAdapter(options);
+ return adapter;
+ };
+
+ app.use("/runtime/*", async (c, next) => {
+ const authorization = c.req.header("authorization") ?? "";
+ if (authorization !== `Bearer ${options.token}`) {
+ return c.json({ error: "unauthorized" }, 401);
+ }
+ await next();
+ });
+
+ app.get("/healthz", (c) => c.json({ status: "ok", runtimeId: options.runtimeId }));
+
+ app.post("/runtime/tools/list", async (c) => c.json(await runtimeAdapter().listTools()));
+
+ app.post("/runtime/tools/call", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as { name?: string; arguments?: unknown };
+ if (!body.name) return c.json({ error: "tool_name_required" }, 400);
+ return c.json(await runtimeAdapter().callTool(body.name, body.arguments ?? {}));
+ });
+
+ app.post("/runtime/caplets/:id/check", async (c) => {
+ return c.json(await runtimeAdapter().checkBackend(c.req.param("id")));
+ });
+
+ app.get("/runtime/caplets/:id/setup", async (c) => {
+ return c.json(await runtimeAdapter().setupPlan(c.req.param("id")));
+ });
+
+ app.post("/runtime/caplets/:id/setup/run", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as {
+ approved?: boolean;
+ actor?: "cli-interactive" | "cli-yes" | "ui" | "automation";
+ };
+ return c.json(
+ await runtimeAdapter().runSetup(c.req.param("id"), {
+ approved: body.approved === true,
+ actor: body.actor ?? "automation",
+ }),
+ );
+ });
+
+ return app;
+}
diff --git a/packages/core/src/cloud/sync.ts b/packages/core/src/cloud/sync.ts
new file mode 100644
index 0000000..129bf20
--- /dev/null
+++ b/packages/core/src/cloud/sync.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { buildProjectSyncManifest } from "../project-binding/sync-filter";
+
+export type ProjectSyncFile = {
+ path: string;
+ content: string;
+};
+
+export class ProjectSyncCoordinator {
+ private readonly queues = new Map>();
+
+ async runMutating(projectId: string, task: () => Promise): Promise {
+ const previous = this.queues.get(projectId) ?? Promise.resolve();
+ let release!: () => void;
+ const next = new Promise((resolve) => {
+ release = resolve;
+ });
+ const queued = previous.catch(() => undefined).then(() => next);
+ this.queues.set(projectId, queued);
+
+ await previous.catch(() => undefined);
+ try {
+ return await task();
+ } finally {
+ release();
+ if (this.queues.get(projectId) === queued) {
+ this.queues.delete(projectId);
+ }
+ }
+ }
+}
+
+export function projectSyncManifest(projectRoot: string): string[] {
+ return buildProjectSyncManifest({ projectRoot }).files.map((file) => file.relativePath);
+}
+
+export function projectSyncFiles(projectRoot: string): ProjectSyncFile[] {
+ return projectSyncManifest(projectRoot).map((path) => ({
+ path,
+ content: readFileSync(join(projectRoot, path), "utf8"),
+ }));
+}
diff --git a/packages/core/src/config-runtime.ts b/packages/core/src/config-runtime.ts
new file mode 100644
index 0000000..48cc63c
--- /dev/null
+++ b/packages/core/src/config-runtime.ts
@@ -0,0 +1,664 @@
+import { z } from "zod";
+import {
+ FORBIDDEN_HEADERS,
+ HEADER_NAME_PATTERN,
+ HTTP_BASE_URL_PATTERN,
+ SERVER_ID_PATTERN,
+ isAllowedHttpBaseUrl,
+ isAllowedRemoteUrl,
+ isUrl,
+ validateHttpActionHeaders,
+} from "./config/validation";
+import { CapletsError } from "./errors";
+
+export type RemoteAuthConfig =
+ | { type: "none" }
+ | { type: "bearer"; token: string }
+ | { type: "headers"; headers: Record }
+ | {
+ type: "oauth2" | "oidc";
+ authorizationUrl?: string | undefined;
+ tokenUrl?: string | undefined;
+ issuer?: string | undefined;
+ resourceMetadataUrl?: string | undefined;
+ authorizationServerMetadataUrl?: string | undefined;
+ openidConfigurationUrl?: string | undefined;
+ clientMetadataUrl?: string | undefined;
+ clientId?: string | undefined;
+ clientSecret?: string | undefined;
+ scopes?: string[] | undefined;
+ redirectUri?: string | undefined;
+ };
+
+export type CapletSetupCommandConfig = {
+ label: string;
+ command: string;
+ args?: string[] | undefined;
+ env?: Record | undefined;
+ cwd?: string | undefined;
+ timeoutMs?: number | undefined;
+ maxOutputBytes?: number | undefined;
+};
+
+export type CapletSetupConfig = {
+ commands?: CapletSetupCommandConfig[] | undefined;
+ verify?: CapletSetupCommandConfig[] | undefined;
+};
+
+export type ProjectBindingConfig = { required: true };
+export type RuntimeFeature = "docker" | "browser";
+export type RuntimeResourceClass = "standard" | "large" | "heavy";
+export type RuntimeRequirementsConfig = {
+ features?: RuntimeFeature[] | undefined;
+ resources?: { class?: RuntimeResourceClass | undefined } | undefined;
+};
+
+export type CapletServerConfig = CommonCapletConfig & {
+ backend: "mcp";
+ transport: "stdio" | "http" | "sse";
+ command?: string | undefined;
+ args?: string[] | undefined;
+ env?: Record | undefined;
+ cwd?: string | undefined;
+ url?: string | undefined;
+ auth?: RemoteAuthConfig | undefined;
+ startupTimeoutMs: number;
+ callTimeoutMs: number;
+ toolCacheTtlMs: number;
+};
+
+export type OpenApiAuthConfig = RemoteAuthConfig;
+
+export type OpenApiEndpointConfig = CommonCapletConfig & {
+ backend: "openapi";
+ specPath?: string | undefined;
+ specUrl?: string | undefined;
+ baseUrl?: string | undefined;
+ auth: OpenApiAuthConfig;
+ requestTimeoutMs: number;
+ operationCacheTtlMs: number;
+};
+
+export type GraphQlOperationConfig = {
+ document?: string | undefined;
+ documentPath?: string | undefined;
+ operationName?: string | undefined;
+ description?: string | undefined;
+};
+
+export type GraphQlEndpointConfig = CommonCapletConfig & {
+ backend: "graphql";
+ endpointUrl: string;
+ schemaPath?: string | undefined;
+ schemaUrl?: string | undefined;
+ introspection?: true | undefined;
+ operations?: Record | undefined;
+ auth: OpenApiAuthConfig;
+ requestTimeoutMs: number;
+ operationCacheTtlMs: number;
+ selectionDepth: number;
+};
+
+export type HttpActionConfig = {
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ path: string;
+ description?: string | undefined;
+ inputSchema?: Record | undefined;
+ outputSchema?: Record | undefined;
+ query?: Record | undefined;
+ headers?: Record | undefined;
+ jsonBody?: unknown;
+};
+
+export type HttpApiConfig = CommonCapletConfig & {
+ backend: "http";
+ baseUrl: string;
+ auth: OpenApiAuthConfig;
+ actions: Record;
+ requestTimeoutMs: number;
+ maxResponseBytes: number;
+};
+
+export type CliToolActionConfig = {
+ description?: string | undefined;
+ inputSchema?: Record | undefined;
+ outputSchema?: Record | undefined;
+ command: string;
+ args?: string[] | undefined;
+ env?: Record | undefined;
+ cwd?: string | undefined;
+ timeoutMs?: number | undefined;
+ maxOutputBytes?: number | undefined;
+ output?: { type: "text" | "json" } | undefined;
+ annotations?:
+ | {
+ readOnlyHint?: boolean | undefined;
+ destructiveHint?: boolean | undefined;
+ idempotentHint?: boolean | undefined;
+ openWorldHint?: boolean | undefined;
+ }
+ | undefined;
+};
+
+export type CliToolsConfig = CommonCapletConfig & {
+ backend: "cli";
+ actions: Record;
+ cwd?: string | undefined;
+ env?: Record | undefined;
+ timeoutMs: number;
+ maxOutputBytes: number;
+};
+
+export type CapletSetConfig = CommonCapletConfig & {
+ backend: "caplets";
+ configPath?: string | undefined;
+ capletsRoot?: string | undefined;
+ defaultSearchLimit: number;
+ maxSearchLimit: number;
+ toolCacheTtlMs: number;
+};
+
+export type CapletConfig =
+ | CapletServerConfig
+ | OpenApiEndpointConfig
+ | GraphQlEndpointConfig
+ | HttpApiConfig
+ | CliToolsConfig
+ | CapletSetConfig;
+
+export type CapletsConfig = {
+ version: 1;
+ options: {
+ defaultSearchLimit: number;
+ maxSearchLimit: number;
+ completion: {
+ discoveryTimeoutMs: number;
+ overallTimeoutMs: number;
+ cacheTtlMs: number;
+ negativeCacheTtlMs: number;
+ };
+ };
+ mcpServers: Record;
+ openapiEndpoints: Record;
+ graphqlEndpoints: Record;
+ httpApis: Record;
+ cliTools: Record;
+ capletSets: Record;
+};
+
+type CommonCapletConfig = {
+ server: string;
+ name: string;
+ description: string;
+ tags?: string[] | undefined;
+ body?: string | undefined;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
+ disabled: boolean;
+};
+
+const stringMapSchema = z.record(z.string(), z.string());
+const authSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("none") }).strict(),
+ z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(),
+ z.object({ type: z.literal("headers"), headers: stringMapSchema }).strict(),
+ oauthLikeSchema("oauth2"),
+ oauthLikeSchema("oidc"),
+]);
+const setupCommandSchema = z
+ .object({
+ label: z.string().min(1),
+ command: z.string().min(1),
+ args: z.array(z.string()).optional(),
+ env: stringMapSchema.optional(),
+ cwd: z.string().min(1).optional(),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ })
+ .strict();
+const setupSchema = z
+ .object({
+ commands: z.array(setupCommandSchema).optional(),
+ verify: z.array(setupCommandSchema).optional(),
+ })
+ .strict()
+ .refine(
+ (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0,
+ "setup must define at least one command or verify step",
+ );
+const projectBindingSchema = z.object({ required: z.literal(true) }).strict();
+const runtimeFeatureSchema = z.enum(["docker", "browser"]);
+const runtimeFeaturesSchema = z
+ .array(runtimeFeatureSchema)
+ .refine((features) => new Set(features).size === features.length, {
+ message: "runtime.features must not contain duplicate feature names",
+ });
+const runtimeRequirementsSchema = z
+ .object({
+ features: runtimeFeaturesSchema.optional(),
+ resources: z
+ .object({
+ class: z.enum(["standard", "large", "heavy"]).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict();
+const commonSchema = {
+ name: z.string().trim().min(1).max(80),
+ description: z
+ .string()
+ .refine(
+ (value) => value.trim().length >= 10,
+ "description must contain at least 10 non-whitespace characters",
+ )
+ .refine((value) => value.length <= 1500, "description must be at most 1500 characters"),
+ tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ body: z.string().optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
+ disabled: z.boolean().default(false),
+};
+const mcpServerSchema = z
+ .object({
+ ...commonSchema,
+ transport: z.enum(["stdio", "http", "sse"]).optional(),
+ command: z.string().min(1).optional(),
+ args: z.array(z.string()).optional(),
+ env: stringMapSchema.optional(),
+ cwd: z.string().min(1).optional(),
+ url: z.string().min(1).optional(),
+ auth: authSchema.optional(),
+ startupTimeoutMs: z.number().int().positive().default(10_000),
+ callTimeoutMs: z.number().int().positive().default(60_000),
+ toolCacheTtlMs: z.number().int().nonnegative().default(30_000),
+ })
+ .strict();
+const openApiEndpointSchema = z
+ .object({
+ ...commonSchema,
+ specPath: z.string().min(1).optional(),
+ specUrl: z.string().min(1).optional(),
+ baseUrl: z.string().min(1).optional(),
+ auth: authSchema,
+ requestTimeoutMs: z.number().int().positive().default(60_000),
+ operationCacheTtlMs: z.number().int().nonnegative().default(30_000),
+ })
+ .strict();
+const graphQlOperationSchema = z
+ .object({
+ document: z.string().min(1).optional(),
+ documentPath: z.string().min(1).optional(),
+ operationName: z.string().min(1).optional(),
+ description: z.string().min(1).optional(),
+ })
+ .strict()
+ .refine((operation) => Boolean(operation.document) !== Boolean(operation.documentPath), {
+ message: "GraphQL operation must define exactly one document source",
+ });
+const graphQlEndpointSchema = z
+ .object({
+ ...commonSchema,
+ endpointUrl: z.string().min(1),
+ schemaPath: z.string().min(1).optional(),
+ schemaUrl: z.string().min(1).optional(),
+ introspection: z.literal(true).optional(),
+ operations: z.record(z.string().regex(SERVER_ID_PATTERN), graphQlOperationSchema).optional(),
+ auth: authSchema,
+ requestTimeoutMs: z.number().int().positive().default(60_000),
+ operationCacheTtlMs: z.number().int().nonnegative().default(30_000),
+ selectionDepth: z.number().int().positive().max(5).default(2),
+ })
+ .strict();
+const scalarMapSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
+const httpActionSchema = z
+ .object({
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
+ path: z
+ .string()
+ .min(1)
+ .regex(/^\//, "HTTP action path must start with /")
+ .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //")
+ .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"),
+ description: z.string().min(1).optional(),
+ inputSchema: z.record(z.string(), z.unknown()).optional(),
+ outputSchema: z.record(z.string(), z.unknown()).optional(),
+ query: scalarMapSchema.optional(),
+ headers: scalarMapSchema.optional(),
+ jsonBody: z.unknown().optional(),
+ })
+ .strict()
+ .refine((action) => action.method !== "GET" || action.jsonBody === undefined, {
+ path: ["jsonBody"],
+ message: "HTTP GET actions must not define jsonBody",
+ });
+const httpApiSchema = z
+ .object({
+ ...commonSchema,
+ baseUrl: z
+ .string()
+ .min(1)
+ .regex(
+ HTTP_BASE_URL_PATTERN,
+ "HTTP API baseUrl must not include credentials, query, or fragment",
+ ),
+ auth: authSchema,
+ actions: z
+ .record(z.string().regex(SERVER_ID_PATTERN), httpActionSchema)
+ .refine(
+ (actions) => Object.keys(actions).length > 0,
+ "HTTP API must define at least one action",
+ ),
+ requestTimeoutMs: z.number().int().positive().default(60_000),
+ maxResponseBytes: z.number().int().positive().default(200_000),
+ })
+ .strict();
+const cliActionSchema = z
+ .object({
+ description: z.string().min(1).optional(),
+ inputSchema: z.record(z.string(), z.unknown()).optional(),
+ outputSchema: z.record(z.string(), z.unknown()).optional(),
+ command: z.string().min(1),
+ args: z.array(z.string()).optional(),
+ env: stringMapSchema.optional(),
+ cwd: z.string().min(1).optional(),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ output: z
+ .object({ type: z.enum(["text", "json"]).default("text") })
+ .strict()
+ .optional(),
+ annotations: z
+ .object({
+ readOnlyHint: z.boolean().optional(),
+ destructiveHint: z.boolean().optional(),
+ idempotentHint: z.boolean().optional(),
+ openWorldHint: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict();
+const cliToolsSchema = z
+ .object({
+ ...commonSchema,
+ actions: z
+ .record(z.string().regex(SERVER_ID_PATTERN), cliActionSchema)
+ .refine(
+ (actions) => Object.keys(actions).length > 0,
+ "CLI tools backend must define at least one action",
+ ),
+ cwd: z.string().min(1).optional(),
+ env: stringMapSchema.optional(),
+ timeoutMs: z.number().int().positive().default(60_000),
+ maxOutputBytes: z.number().int().positive().default(200_000),
+ })
+ .strict();
+const capletSetSchema = z
+ .object({
+ ...commonSchema,
+ configPath: z.string().min(1).optional(),
+ capletsRoot: z.string().min(1).optional(),
+ defaultSearchLimit: z.number().int().positive().default(20),
+ maxSearchLimit: z.number().int().positive().max(50).default(50),
+ toolCacheTtlMs: z.number().int().nonnegative().default(30_000),
+ })
+ .strict();
+
+const configSchema = z
+ .object({
+ version: z.literal(1).default(1),
+ defaultSearchLimit: z.number().int().positive().default(20),
+ maxSearchLimit: z.number().int().positive().max(50).default(50),
+ completion: z
+ .object({
+ discoveryTimeoutMs: z.number().int().positive().default(750),
+ overallTimeoutMs: z.number().int().positive().default(1500),
+ cacheTtlMs: z.number().int().nonnegative().default(300_000),
+ negativeCacheTtlMs: z.number().int().nonnegative().default(30_000),
+ })
+ .strict()
+ .default({
+ discoveryTimeoutMs: 750,
+ overallTimeoutMs: 1500,
+ cacheTtlMs: 300_000,
+ negativeCacheTtlMs: 30_000,
+ }),
+ mcpServers: z.record(z.string().regex(SERVER_ID_PATTERN), mcpServerSchema).default({}),
+ openapiEndpoints: z
+ .record(z.string().regex(SERVER_ID_PATTERN), openApiEndpointSchema)
+ .default({}),
+ graphqlEndpoints: z
+ .record(z.string().regex(SERVER_ID_PATTERN), graphQlEndpointSchema)
+ .default({}),
+ httpApis: z.record(z.string().regex(SERVER_ID_PATTERN), httpApiSchema).default({}),
+ cliTools: z.record(z.string().regex(SERVER_ID_PATTERN), cliToolsSchema).default({}),
+ capletSets: z.record(z.string().regex(SERVER_ID_PATTERN), capletSetSchema).default({}),
+ })
+ .strict()
+ .superRefine((config, ctx) => {
+ if (config.defaultSearchLimit > config.maxSearchLimit) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["defaultSearchLimit"],
+ message: "defaultSearchLimit must be <= maxSearchLimit",
+ });
+ }
+ validateBackends(config, ctx);
+ });
+
+export function parseConfig(input: unknown): CapletsConfig {
+ const parsed = configSchema.safeParse(input);
+ if (!parsed.success) {
+ throw new CapletsError("CONFIG_INVALID", "Caplets config is invalid", parsed.error.issues);
+ }
+ const config = parsed.data;
+ return {
+ version: 1,
+ options: {
+ defaultSearchLimit: config.defaultSearchLimit,
+ maxSearchLimit: config.maxSearchLimit,
+ completion: config.completion,
+ },
+ mcpServers: mapBackend(config.mcpServers, "mcp", (id, raw) => {
+ const server = raw as z.infer;
+ return {
+ ...server,
+ server: id,
+ transport: server.transport ?? (server.command ? "stdio" : "http"),
+ };
+ }),
+ openapiEndpoints: mapBackend(config.openapiEndpoints, "openapi"),
+ graphqlEndpoints: mapBackend(config.graphqlEndpoints, "graphql"),
+ httpApis: mapBackend(config.httpApis, "http"),
+ cliTools: mapBackend(config.cliTools, "cli"),
+ capletSets: mapBackend(config.capletSets, "caplets"),
+ };
+}
+
+function oauthLikeSchema(type: "oauth2" | "oidc") {
+ return z
+ .object({
+ type: z.literal(type),
+ authorizationUrl: z.string().min(1).optional(),
+ tokenUrl: z.string().min(1).optional(),
+ issuer: z.string().min(1).optional(),
+ resourceMetadataUrl: z.string().min(1).optional(),
+ authorizationServerMetadataUrl: z.string().min(1).optional(),
+ openidConfigurationUrl: z.string().min(1).optional(),
+ clientMetadataUrl: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ redirectUri: z.string().min(1).optional(),
+ })
+ .strict();
+}
+
+function validateBackends(config: z.infer, ctx: z.RefinementCtx): void {
+ for (const [server, raw] of Object.entries(config.mcpServers)) {
+ const effectiveTransport = raw.transport ?? (raw.command ? "stdio" : undefined);
+ const hasCommand = Boolean(raw.command);
+ const hasUrl = Boolean(raw.url);
+ if (hasCommand === hasUrl) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["mcpServers", server],
+ message: "MCP server must define exactly one connection shape: command or url",
+ });
+ }
+ if (effectiveTransport === "stdio" && !raw.command) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["mcpServers", server, "command"],
+ message: "stdio servers require command",
+ });
+ }
+ if ((effectiveTransport === "http" || effectiveTransport === "sse") && !raw.url) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["mcpServers", server, "url"],
+ message: "remote servers require url",
+ });
+ }
+ if (raw.url && !hasEnvReference(raw.url) && !isAllowedRemoteUrl(raw.url)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["mcpServers", server, "url"],
+ message: "remote url must use https except loopback development urls",
+ });
+ }
+ validateAuthHeaders(raw.auth, ctx, ["mcpServers", server, "auth"]);
+ }
+ for (const [server, raw] of Object.entries(config.openapiEndpoints)) {
+ if (Boolean(raw.specPath) === Boolean(raw.specUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["openapiEndpoints", server],
+ message: "OpenAPI endpoint must define exactly one spec source: specPath or specUrl",
+ });
+ }
+ if (raw.specUrl && !hasEnvReference(raw.specUrl) && !isAllowedRemoteUrl(raw.specUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["openapiEndpoints", server, "specUrl"],
+ message: "OpenAPI specUrl must use https except loopback development urls",
+ });
+ }
+ if (raw.baseUrl && !hasEnvReference(raw.baseUrl) && !isAllowedRemoteUrl(raw.baseUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["openapiEndpoints", server, "baseUrl"],
+ message: "OpenAPI baseUrl must use https except loopback development urls",
+ });
+ }
+ validateAuthHeaders(raw.auth, ctx, ["openapiEndpoints", server, "auth"]);
+ }
+ for (const [server, raw] of Object.entries(config.graphqlEndpoints)) {
+ const sourceCount =
+ Number(Boolean(raw.schemaPath)) +
+ Number(Boolean(raw.schemaUrl)) +
+ Number(raw.introspection === true);
+ if (sourceCount !== 1) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["graphqlEndpoints", server],
+ message: "GraphQL endpoint must define exactly one schema source",
+ });
+ }
+ if (
+ raw.endpointUrl &&
+ !hasEnvReference(raw.endpointUrl) &&
+ !isAllowedRemoteUrl(raw.endpointUrl)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["graphqlEndpoints", server, "endpointUrl"],
+ message: "GraphQL endpointUrl must use https except loopback development urls",
+ });
+ }
+ validateAuthHeaders(raw.auth, ctx, ["graphqlEndpoints", server, "auth"]);
+ }
+ for (const [server, raw] of Object.entries(config.httpApis)) {
+ if (raw.baseUrl && !hasEnvReference(raw.baseUrl) && !isAllowedHttpBaseUrl(raw.baseUrl)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["httpApis", server, "baseUrl"],
+ message:
+ "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment",
+ });
+ }
+ validateAuthHeaders(raw.auth, ctx, ["httpApis", server, "auth"]);
+ for (const [actionName, action] of Object.entries(raw.actions)) {
+ if (action.headers)
+ validateHttpActionHeaders(action.headers, ctx, [
+ "httpApis",
+ server,
+ "actions",
+ actionName,
+ "headers",
+ ]);
+ }
+ }
+ for (const [server, raw] of Object.entries(config.capletSets)) {
+ if (!raw.configPath && !raw.capletsRoot) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["capletSets", server],
+ message: "Caplet set must define at least one source: configPath or capletsRoot",
+ });
+ }
+ if (raw.defaultSearchLimit > raw.maxSearchLimit) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["capletSets", server, "defaultSearchLimit"],
+ message: "defaultSearchLimit must be <= maxSearchLimit",
+ });
+ }
+ }
+}
+
+function validateAuthHeaders(
+ auth: RemoteAuthConfig | undefined,
+ ctx: z.RefinementCtx,
+ path: Array,
+): void {
+ if (auth?.type !== "headers") return;
+ for (const headerName of Object.keys(auth.headers)) {
+ const normalized = headerName.toLowerCase();
+ if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) {
+ ctx.addIssue({
+ code: "custom",
+ path: [...path, "headers", headerName],
+ message: `header ${headerName} is not allowed`,
+ });
+ }
+ }
+}
+
+function mapBackend(
+ records: Record,
+ backend: B,
+ prepare?: (id: string, raw: object) => object,
+): Record> {
+ return Object.fromEntries(
+ Object.entries(records).map(([id, raw]) => [
+ id,
+ stripUndefined({
+ ...(prepare ? prepare(id, raw) : raw),
+ server: id,
+ backend,
+ }) as Extract,
+ ]),
+ );
+}
+
+function stripUndefined(value: Record): Record {
+ return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== undefined));
+}
+
+function hasEnvReference(value: string): boolean {
+ return /\$\{?[A-Z_][A-Z0-9_]*\}?|\$env:[A-Z_][A-Z0-9_]*/u.test(value);
+}
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index b165569..8babef2 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -66,6 +66,29 @@ export type RemoteAuthConfig =
redirectUri?: string | undefined;
};
+export type CapletSetupCommandConfig = {
+ label: string;
+ command: string;
+ args?: string[] | undefined;
+ env?: Record | undefined;
+ cwd?: string | undefined;
+ timeoutMs?: number | undefined;
+ maxOutputBytes?: number | undefined;
+};
+
+export type CapletSetupConfig = {
+ commands?: CapletSetupCommandConfig[] | undefined;
+ verify?: CapletSetupCommandConfig[] | undefined;
+};
+
+export type ProjectBindingConfig = { required: true };
+export type RuntimeFeature = "docker" | "browser";
+export type RuntimeResourceClass = "standard" | "large" | "heavy";
+export type RuntimeRequirementsConfig = {
+ features?: RuntimeFeature[] | undefined;
+ resources?: { class?: RuntimeResourceClass | undefined } | undefined;
+};
+
export type CapletServerConfig = {
server: string;
backend: "mcp";
@@ -84,6 +107,9 @@ export type CapletServerConfig = {
callTimeoutMs: number;
toolCacheTtlMs: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type OpenApiAuthConfig =
@@ -106,6 +132,9 @@ export type OpenApiEndpointConfig = {
requestTimeoutMs: number;
operationCacheTtlMs: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type GraphQlOperationConfig = {
@@ -132,6 +161,9 @@ export type GraphQlEndpointConfig = {
operationCacheTtlMs: number;
selectionDepth: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type HttpActionConfig = {
@@ -158,6 +190,9 @@ export type HttpApiConfig = {
requestTimeoutMs: number;
maxResponseBytes: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type CliToolOutputConfig = {
@@ -198,6 +233,9 @@ export type CliToolsConfig = {
timeoutMs: number;
maxOutputBytes: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type CapletSetConfig = {
@@ -213,6 +251,9 @@ export type CapletSetConfig = {
maxSearchLimit: number;
toolCacheTtlMs: number;
disabled: boolean;
+ setup?: CapletSetupConfig | undefined;
+ projectBinding?: ProjectBindingConfig | undefined;
+ runtime?: RuntimeRequirementsConfig | undefined;
};
export type CapletConfig =
@@ -360,6 +401,61 @@ const openApiAuthSchema = z
])
.describe("Authentication settings for an OpenAPI endpoint.");
+const setupCommandSchema = z
+ .object({
+ label: z.string().min(1).describe("Human-readable setup or verification step label."),
+ command: z.string().min(1).describe("Executable command to spawn without a shell."),
+ args: z.array(z.string()).optional().describe("Arguments passed to the command."),
+ env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."),
+ cwd: z.string().min(1).optional().describe("Working directory for this command."),
+ timeoutMs: z.number().int().positive().optional(),
+ maxOutputBytes: z.number().int().positive().optional(),
+ })
+ .strict();
+
+const setupSchema = z
+ .object({
+ commands: z.array(setupCommandSchema).optional(),
+ verify: z.array(setupCommandSchema).optional(),
+ })
+ .strict()
+ .refine(
+ (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0,
+ "setup must define at least one command or verify step",
+ );
+
+const projectBindingSchema = z
+ .object({
+ required: z.literal(true).describe("Requires Project Binding before this Caplet can run."),
+ })
+ .strict()
+ .describe("Project Binding requirements for Caplets that need an attached project.");
+
+const runtimeFeatureSchema = z.enum(["docker", "browser"]);
+const runtimeFeaturesSchema = z
+ .array(runtimeFeatureSchema)
+ .refine((features) => new Set(features).size === features.length, {
+ message: "runtime.features must not contain duplicate feature names",
+ })
+ .describe("Runtime features required by this Caplet.");
+
+const runtimeRequirementsSchema = z
+ .object({
+ features: runtimeFeaturesSchema.optional(),
+ resources: z
+ .object({
+ class: z
+ .enum(["standard", "large", "heavy"])
+ .optional()
+ .describe("Requested hosted sandbox resource class."),
+ })
+ .strict()
+ .optional()
+ .describe("Hosted sandbox resource requirements."),
+ })
+ .strict()
+ .describe("Runtime feature and resource requirements for hosted execution.");
+
const publicServerSchema = z
.object({
name: z.string().trim().min(1).max(80).describe("Human-readable server display name."),
@@ -385,6 +481,9 @@ const publicServerSchema = z
url: z.string().url().optional().describe("Remote MCP server URL for http or sse transport."),
auth: remoteAuthSchema.optional(),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
startupTimeoutMs: z
.number()
.int()
@@ -432,6 +531,9 @@ const publicOpenApiEndpointSchema = z
'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.',
),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
requestTimeoutMs: z
.number()
.int()
@@ -501,6 +603,9 @@ const publicGraphQlEndpointSchema = z
'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.',
),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
requestTimeoutMs: z
.number()
.int()
@@ -614,6 +719,9 @@ const publicHttpApiSchema = z
)
.describe("Configured HTTP actions keyed by stable tool name."),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
requestTimeoutMs: z
.number()
.int()
@@ -706,6 +814,9 @@ const publicCliToolsSchema = z
.optional()
.describe("Default environment variables for CLI actions."),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
timeoutMs: z
.number()
.int()
@@ -759,6 +870,9 @@ const publicCapletSetSchema = z
.default(30_000)
.describe("Milliseconds child Caplet metadata stays fresh. Set 0 to refresh every time."),
tags: z.array(z.string().trim().min(1).max(80)).optional(),
+ setup: setupSchema.optional(),
+ projectBinding: projectBindingSchema.optional(),
+ runtime: runtimeRequirementsSchema.optional(),
disabled: z.boolean().default(false).describe("When true, omit this Caplet set."),
})
.strict()
diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts
index 3c33c49..6a15072 100644
--- a/packages/core/src/config/paths.ts
+++ b/packages/core/src/config/paths.ts
@@ -95,7 +95,7 @@ export function resolveConfigPath(path?: string): string {
}
export function resolveProjectConfigPath(cwd = process.cwd()): string {
- return join(cwd, PROJECT_CONFIG_FILE);
+ return join(displayPath(cwd), PROJECT_CONFIG_FILE);
}
export function resolveCapletsRoot(configPath = resolveConfigPath()): string {
@@ -103,5 +103,9 @@ export function resolveCapletsRoot(configPath = resolveConfigPath()): string {
}
export function resolveProjectCapletsRoot(cwd = process.cwd()): string {
- return join(cwd, ".caplets");
+ return join(displayPath(cwd), ".caplets");
+}
+
+function displayPath(path: string): string {
+ return path;
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index bc092e8..d528526 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,8 +1,61 @@
export { CapletsRuntime } from "./runtime";
export { runCli, createProgram } from "./cli";
export { parseConfig, loadConfig } from "./config";
+export { BundleCapletSource, parseCapletSource } from "./caplet-source";
+export { FilesystemCapletSource } from "./caplet-source/filesystem";
+export {
+ classifyCapletRuntimeRoute,
+ planCapletRuntimeRoute,
+ planCapletRuntimeRoutes,
+} from "./runtime-plan";
+export type {
+ CapletSource,
+ CapletSourceFile,
+ CapletSourceParseMessage,
+ CapletSourceParseResult,
+ CapletSourceReference,
+ ParsedCapletSourceCaplet,
+} from "./caplet-source";
+export type {
+ CapletRuntimePlan,
+ RuntimePlanDeployment,
+ RuntimePlanOptions,
+ RuntimeRouteKind,
+ SetupTargetKind as RuntimePlanSetupTargetKind,
+} from "./runtime-plan";
export { capabilityDescription, ServerRegistry } from "./registry";
export { generatedToolInputSchema, handleServerTool } from "./tools";
+export type { CapletExecutionMetadata, CapletResultMetadata } from "./tools";
+export type { CapletSetupCommandConfig, CapletSetupConfig } from "./config";
+export { capletSetupContentHash, stableJson } from "./setup/hash";
+export { LocalSetupStore } from "./setup/local-store";
+export { runCapletSetup } from "./setup/runner";
+export { CloudAuthClient } from "./cloud-auth/client";
+export { openBrowserUrl } from "./cloud-auth/open-url";
+export {
+ CloudAuthStore,
+ cloudAuthPath,
+ migrateCredentials,
+ redactedCloudAuthStatus,
+} from "./cloud-auth/store";
+export type { CloudAuthCredentials, CloudAuthStoreOptions } from "./cloud-auth/store";
+export type {
+ CloudAuthLoginPollResult,
+ CloudAuthLoginStart,
+ CloudAuthScope,
+ CloudAuthState,
+ CloudAuthTokenResponse,
+ CloudAuthWorkspace,
+ RedactedCloudAuthStatus,
+} from "./cloud-auth/types";
+export type {
+ SetupActor,
+ SetupApproval,
+ SetupAttempt,
+ SetupAttemptStatus,
+ SetupPlan,
+ SetupTargetKind,
+} from "./setup/types";
export {
hasRenderableStructuredContent,
markdownCallToolResultContent,
@@ -12,3 +65,97 @@ export type { ResultMarkdownContext } from "./result-content";
export { serveCaplets, serveHttp, serveResolvedCaplets, serveStdio } from "./serve";
export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./serve";
+export { PROJECT_BINDING_STATES, PROJECT_BINDING_SYNC_STATES } from "./project-binding/types";
+export type {
+ BindingTerminalReason,
+ ProjectBindingLease,
+ ProjectBindingSetupReceipt,
+ ProjectBindingState,
+ ProjectBindingSyncState,
+ ProjectBindingWorkspaceMetadata,
+} from "./project-binding/types";
+export {
+ PROJECT_BINDING_ERROR_CODES,
+ ProjectBindingError,
+ projectBindingError,
+ projectBindingRecovery,
+} from "./project-binding/errors";
+export type { ProjectBindingErrorCode, ProjectBindingRecovery } from "./project-binding/errors";
+export { buildProjectSyncManifest } from "./project-binding/sync-filter";
+export type {
+ ProjectSyncExclusionSource,
+ ProjectSyncExclusionSummary,
+ ProjectSyncManifest,
+ ProjectSyncManifestFile,
+} from "./project-binding/sync-filter";
+export { DEFAULT_SYNC_LIMITS, enforceProjectSyncSizeLimits } from "./project-binding/sync-size";
+export type {
+ ProjectSyncLimits,
+ ProjectSyncSizeResult,
+ ProjectSyncTier,
+} from "./project-binding/sync-size";
+export {
+ PROJECT_BINDING_CONNECT_PATH,
+ PROJECT_BINDINGS_CONTROL_PATH,
+ projectBindingConnectPath,
+ projectBindingConnectUrl,
+ projectBindingStatusPath,
+ projectBindingStatusUrl,
+} from "./project-binding/routes";
+export {
+ attachProjectOnce,
+ attachProjectSession,
+ resolveAttachOptions,
+} from "./project-binding/attach";
+export type {
+ AttachSessionEvent,
+ RawAttachOptions,
+ ResolvedAttachOptions,
+} from "./project-binding/attach";
+export { runProjectBindingSession } from "./project-binding/session";
+export type {
+ ProjectBindingSessionEvent,
+ ProjectBindingSocketClientMessage,
+ ProjectBindingSocketServerMessage,
+ RunProjectBindingSessionInput,
+} from "./project-binding/session";
+export { defaultProjectBindingWebSocketFactory } from "./project-binding/transport";
+export type {
+ ProjectBindingWebSocket,
+ ProjectBindingWebSocketFactory,
+} from "./project-binding/transport";
+export {
+ ProjectBindingWorkspaceStore,
+ projectBindingWorkspacePaths,
+ projectBindingWorkspaceRoot,
+} from "./project-binding/workspaces";
+export type {
+ EnsureProjectBindingWorkspaceInput,
+ ProjectBindingCleanupResult,
+ ProjectBindingWorkspacePaths,
+ ProjectBindingWorkspaceRootOptions,
+ ProjectBindingWorkspaceStoreOptions,
+} from "./project-binding/workspaces";
+export {
+ ManagedMutagenProjectSync,
+ mutagenProjectSyncDoctorData,
+ mutagenSyncName,
+ parseMutagenVersionOutput as parseManagedMutagenVersionOutput,
+ planMutagenSyncCreateCommand,
+ planMutagenSyncListCommand,
+ planMutagenSyncTerminateCommand,
+ planMutagenVersionCommand,
+} from "./project-binding/mutagen";
+export type {
+ ManagedMutagenProjectSyncOptions,
+ ManagedSyncDiagnosticCode,
+ ManagedSyncState,
+ ManagedSyncStateSnapshot,
+ MutagenCommandPlan,
+ MutagenLastCommandStatus,
+ MutagenProcessResult,
+ MutagenProcessRunner,
+ MutagenProjectSyncBindingInput,
+ MutagenProjectSyncDoctorData,
+ StartMutagenProjectSyncInput,
+} from "./project-binding/mutagen";
diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts
index 9d90c81..f96b2e6 100644
--- a/packages/core/src/native/options.ts
+++ b/packages/core/src/native/options.ts
@@ -1,18 +1,28 @@
import { CapletsError } from "../errors";
+import { mcpUrlForBase, type CapletsServerEnv, type CapletsServerInput } from "../server/options";
import {
- mcpUrlForBase,
- resolveCapletsMode,
- resolveCapletsServer,
- type CapletsMode,
- type CapletsServerEnv,
- type CapletsServerInput,
-} from "../server/options";
+ resolveCapletsRemote,
+ resolveRemoteMode,
+ type CapletsRemoteAuth,
+ type CapletsRemoteEnv,
+} from "../remote/options";
+
+type CapletsMode = "auto" | "local" | "remote" | "cloud";
export type NativeCapletsMode = CapletsMode;
export type NativeRemoteCapletsOptions = {
pollIntervalMs?: number;
fetch?: typeof fetch;
+ cloud?: NativeCloudPresenceInput;
+};
+
+export type NativeCloudPresenceInput = {
+ url?: string;
+ accessToken?: string;
+ workspaceId?: string;
+ projectRoot?: string;
+ heartbeatIntervalMs?: number;
};
export type NativeCapletsServiceResolutionInput = {
@@ -21,7 +31,7 @@ export type NativeCapletsServiceResolutionInput = {
remote?: NativeRemoteCapletsOptions;
};
-export type NativeCapletsEnv = CapletsServerEnv;
+export type NativeCapletsEnv = CapletsServerEnv & CapletsRemoteEnv;
export type NativeRemoteAuthOptions =
| { enabled: false; user: string }
@@ -30,26 +40,28 @@ export type NativeRemoteAuthOptions =
export type ResolvedNativeCapletsServiceOptions =
| { mode: "local" }
| {
- mode: "remote";
+ mode: "remote" | "cloud";
remote: {
url: URL;
auth: NativeRemoteAuthOptions;
pollIntervalMs: number;
requestInit: RequestInit;
+ cloud?: ResolvedNativeCloudPresenceOptions;
fetch?: typeof fetch;
};
};
const DEFAULT_POLL_INTERVAL_MS = 30_000;
+const DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000;
export function resolveNativeCapletsServiceOptions(
input: NativeCapletsServiceResolutionInput = {},
env: NativeCapletsEnv = process.env,
): ResolvedNativeCapletsServiceOptions {
- const mode = resolveCapletsMode(
+ const mode = resolveRemoteMode(
{
...(input.mode ? { mode: input.mode } : {}),
- ...(input.server?.url ? { serverUrl: input.server.url } : {}),
+ ...(input.server?.url ? { remoteUrl: input.server.url } : {}),
},
env,
);
@@ -58,23 +70,48 @@ export function resolveNativeCapletsServiceOptions(
}
const serverFetch = input.remote?.fetch ?? input.server?.fetch;
- const server = resolveCapletsServer(
- { ...input.server, ...(serverFetch ? { fetch: serverFetch } : {}) },
- env,
- );
+ const serverInput = {
+ ...input.server,
+ ...(serverFetch ? { fetch: serverFetch } : {}),
+ };
+ const server =
+ mode.mode === "cloud"
+ ? resolveCapletsRemote(
+ {
+ url: input.server?.url ?? env.CAPLETS_REMOTE_URL ?? "",
+ ...(serverFetch ? { fetch: serverFetch } : {}),
+ },
+ {},
+ )
+ : resolveCapletsRemote(serverInput, env);
+ const cloud = resolveNativeCloudPresence(input.remote?.cloud, env);
return {
- mode: "remote",
+ mode: mode.mode,
remote: {
url: mcpUrlForBase(server.baseUrl),
- auth: server.auth,
+ auth: nativeAuthFromRemoteAuth(server.auth),
pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs),
- requestInit: server.requestInit,
+ requestInit:
+ mode.mode === "cloud" && cloud
+ ? { headers: { Authorization: `Bearer ${cloud.accessToken}` } }
+ : server.requestInit,
+ ...(cloud ? { cloud } : {}),
...(server.fetch ? { fetch: server.fetch } : {}),
},
};
}
+function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions {
+ if (auth.type === "basic") {
+ return { enabled: true, user: auth.user, password: auth.password };
+ }
+ if (auth.type === "none") {
+ return { enabled: false, user: auth.user };
+ }
+ return { enabled: false, user: "caplets" };
+}
+
function parsePollInterval(value: number | undefined): number {
if (value === undefined) {
return DEFAULT_POLL_INTERVAL_MS;
@@ -84,3 +121,51 @@ function parsePollInterval(value: number | undefined): number {
}
return value;
}
+
+export type ResolvedNativeCloudPresenceOptions = {
+ url: URL;
+ accessToken: string;
+ workspaceId: string;
+ projectRoot?: string;
+ heartbeatIntervalMs: number;
+};
+
+function resolveNativeCloudPresence(
+ input: NativeCloudPresenceInput | undefined,
+ env: NativeCapletsEnv,
+): ResolvedNativeCloudPresenceOptions | undefined {
+ const url = input?.url ?? env.CAPLETS_CLOUD_URL;
+ const accessToken = input?.accessToken ?? env.CAPLETS_CLOUD_TOKEN;
+ const workspaceId = input?.workspaceId ?? env.CAPLETS_CLOUD_WORKSPACE_ID;
+ if (!url && !accessToken && !workspaceId && !input?.projectRoot) {
+ return undefined;
+ }
+ if (!url || !accessToken || !workspaceId) {
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "Cloud presence requires CAPLETS_CLOUD_URL, CAPLETS_CLOUD_TOKEN, and CAPLETS_CLOUD_WORKSPACE_ID.",
+ );
+ }
+ return {
+ url: new URL(url),
+ accessToken,
+ workspaceId,
+ ...((input?.projectRoot ?? env.CAPLETS_PROJECT_ROOT)
+ ? { projectRoot: input?.projectRoot ?? env.CAPLETS_PROJECT_ROOT }
+ : {}),
+ heartbeatIntervalMs: parsePresenceHeartbeatInterval(input?.heartbeatIntervalMs),
+ };
+}
+
+function parsePresenceHeartbeatInterval(value: number | undefined): number {
+ if (value === undefined) {
+ return DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS;
+ }
+ if (!Number.isInteger(value) || value < 1_000) {
+ throw new CapletsError(
+ "REQUEST_INVALID",
+ "cloud.heartbeatIntervalMs must be an integer >= 1000.",
+ );
+ }
+ return value;
+}
diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts
index a6c77d2..4dd5397 100644
--- a/packages/core/src/native/remote.ts
+++ b/packages/core/src/native/remote.ts
@@ -27,13 +27,14 @@ export type RemoteCapletsClient = {
};
export type RemoteCapletsClientOptions = ResolvedNativeCapletsServiceOptions & {
- mode: "remote";
+ mode: "remote" | "cloud";
};
export type RemoteNativeCapletsServiceOptions = {
client: RemoteCapletsClient;
clientFactory?: () => RemoteCapletsClient;
pollIntervalMs: number;
+ authKind?: "self_hosted_remote" | "hosted_cloud";
writeErr?: (value: string) => void;
};
@@ -118,7 +119,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService {
return await this.client.callTool(capletId, request);
} catch (error) {
if (isAuthFailure(error)) {
- throw remoteAuthError();
+ throw remoteAuthError(this.options.authKind ?? "self_hosted_remote");
}
if (isSessionFailure(error)) {
if (!(await this.resetClient()) || this.closed) {
@@ -128,7 +129,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService {
return await this.client.callTool(capletId, request);
} catch (retryError) {
if (isAuthFailure(retryError)) {
- throw remoteAuthError();
+ throw remoteAuthError(this.options.authKind ?? "self_hosted_remote");
}
throw retryError;
}
@@ -273,10 +274,12 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
-function remoteAuthError(): CapletsError {
+function remoteAuthError(kind: "self_hosted_remote" | "hosted_cloud"): CapletsError {
return new CapletsError(
"AUTH_FAILED",
- "Remote Caplets authentication failed; check CAPLETS_SERVER_USER and CAPLETS_SERVER_PASSWORD.",
+ kind === "hosted_cloud"
+ ? "Caplets Cloud authentication failed; run caplets cloud auth login."
+ : "Remote Caplets authentication failed; check CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.",
);
}
diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts
index a9fc481..797aa14 100644
--- a/packages/core/src/native/service.ts
+++ b/packages/core/src/native/service.ts
@@ -1,5 +1,13 @@
import type { NativeCapletsServiceResolutionInput } from "./options";
-import { resolveNativeCapletsServiceOptions } from "./options";
+import {
+ resolveNativeCapletsServiceOptions,
+ type NativeRemoteAuthOptions,
+ type ResolvedNativeCloudPresenceOptions,
+} from "./options";
+import { CapletsCloudClient } from "../cloud/client";
+import { ProjectBindingSessionManager } from "../cloud/presence";
+import { projectSyncFiles } from "../cloud/sync";
+import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root";
import {
createSdkRemoteCapletsClient,
RemoteNativeCapletsService,
@@ -19,6 +27,12 @@ import {
type LocalOverlayConfigWithSources,
} from "../config";
import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema";
+import type { CapletsRemoteAuth } from "../remote/options";
+import { resolveRemoteSelection } from "../remote/selection";
+
+const REMOTE_PROJECT_BINDING_FALLBACK_WARNING =
+ "Remote project binding unavailable; using local Caplets only. Run caplets doctor for details.\n";
+let hasWarnedRemoteProjectBindingFallback = false;
export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & {
configPath?: string;
@@ -27,12 +41,7 @@ export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput &
watchDebounceMs?: number;
watch?: boolean;
writeErr?: (value: string) => void;
- remoteClientFactory?: (
- options: Extract<
- ReturnType,
- { mode: "remote" }
- >["remote"],
- ) => RemoteCapletsClient;
+ remoteClientFactory?: (options: ResolvedNativeRemoteOptions) => RemoteCapletsClient;
localServiceFactory?: (options: LocalNativeCapletsServiceOptions) => NativeCapletsService;
};
@@ -61,23 +70,14 @@ export function createNativeCapletsService(
): NativeCapletsService {
const resolved = resolveNativeCapletsServiceOptions(options);
if (resolved.mode === "remote") {
- const localOptions = {
- ...options,
- mode: "local",
- configLoader: createLocalOverlayConfigLoader(options),
- } satisfies LocalNativeCapletsServiceOptions;
- const local = (options.localServiceFactory ?? createDefaultNativeCapletsService)(localOptions);
+ const local = createLocalOverlayService(options);
try {
- const client = (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote);
- const remote = new RemoteNativeCapletsService({
- client,
- clientFactory: () =>
- (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote),
- pollIntervalMs: resolved.remote.pollIntervalMs,
- ...(options.writeErr ? { writeErr: options.writeErr } : {}),
- });
- return new CompositeNativeCapletsService(remote, local, options);
+ return createCompositeRemoteService(resolved.remote, local, options, "self_hosted_remote");
} catch (error) {
+ if (options.mode !== "remote") {
+ warnRemoteProjectBindingFallback(options);
+ return local;
+ }
void local.close().catch((closeError) => {
writeErr(
options,
@@ -87,9 +87,16 @@ export function createNativeCapletsService(
throw error;
}
}
+ if (resolved.mode === "cloud") {
+ return new CloudNativeCapletsService(options, resolved.remote);
+ }
return new DefaultNativeCapletsService(options);
}
+export function resetNativeProjectBindingFallbackWarningForTests(): void {
+ hasWarnedRemoteProjectBindingFallback = false;
+}
+
type LocalNativeCapletsServiceOptions = NativeCapletsServiceOptions & {
configLoader?: (configPath: string, projectConfigPath: string) => CapletsConfig;
};
@@ -143,6 +150,149 @@ function createDefaultNativeCapletsService(
return new DefaultNativeCapletsService(options);
}
+type ResolvedNativeRemoteOptions = Extract<
+ Exclude, { mode: "local" }>,
+ { remote: unknown }
+>["remote"];
+
+function createLocalOverlayService(options: NativeCapletsServiceOptions): NativeCapletsService {
+ const localOptions = {
+ ...options,
+ mode: "local",
+ configLoader: createLocalOverlayConfigLoader(options),
+ } satisfies LocalNativeCapletsServiceOptions;
+ return (options.localServiceFactory ?? createDefaultNativeCapletsService)(localOptions);
+}
+
+function createCompositeRemoteService(
+ remoteOptions: ResolvedNativeRemoteOptions,
+ local: NativeCapletsService,
+ options: NativeCapletsServiceOptions,
+ authKind: "self_hosted_remote" | "hosted_cloud",
+): NativeCapletsService {
+ const client = (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(remoteOptions);
+ const remote = new RemoteNativeCapletsService({
+ client,
+ clientFactory: () =>
+ (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(remoteOptions),
+ pollIntervalMs: remoteOptions.pollIntervalMs,
+ authKind,
+ ...(options.writeErr ? { writeErr: options.writeErr } : {}),
+ });
+ const presence = createProjectBindingSessionManager(remoteOptions.cloud, local, options);
+ return new CompositeNativeCapletsService(remote, local, options, presence);
+}
+
+class CloudNativeCapletsService implements NativeCapletsService {
+ private readonly local: NativeCapletsService;
+ private readonly listeners = new Set();
+ private delegate: NativeCapletsService | undefined;
+ private unsubscribeDelegate: (() => void) | undefined;
+ private closed = false;
+
+ constructor(
+ private readonly options: NativeCapletsServiceOptions,
+ private readonly baseRemote: ResolvedNativeRemoteOptions,
+ ) {
+ this.local = createLocalOverlayService(options);
+ }
+
+ listTools(): NativeCapletTool[] {
+ return this.delegate?.listTools() ?? this.local.listTools();
+ }
+
+ async execute(capletId: string, request: unknown): Promise {
+ return await (this.delegate ?? this.local).execute(capletId, request);
+ }
+
+ async reload(): Promise