diff --git a/.changeset/calm-caplet-readme-contract.md b/.changeset/calm-caplet-readme-contract.md new file mode 100644 index 00000000..059cbd78 --- /dev/null +++ b/.changeset/calm-caplet-readme-contract.md @@ -0,0 +1,7 @@ +--- +"@caplets/core": minor +--- + +Make Caplet YAML frontmatter the sole runtime configuration contract while preserving the Markdown body as operator-facing README content for catalog rendering. Remove `body` from the exported runtime configuration types and backend projections, add semantic runtime fingerprints and no-op reload gating, and classify trusted README-only install changes as `content_updated` without setup approval or runtime churn. + +**Migration required:** `useWhen` and `avoidWhen` have been removed from Caplet and configured-action schemas. Move concise agent-facing capability context into `description`; move operator-only prerequisites, safety guidance, troubleshooting, and references into the Markdown body. Existing configuration that still declares either removed field is rejected. diff --git a/CONCEPTS.md b/CONCEPTS.md index 9f2fd85c..cbae3e7c 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -8,6 +8,12 @@ Shared domain vocabulary for this project -- entities, named processes, and stat A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations. +### Caplet File + +A portable Markdown artifact whose YAML frontmatter is the sole source of runtime-affecting Caplet configuration and whose body is an operator README for prerequisites, troubleshooting, safety, and Caplet-specific documentation. + +The frontmatter and body are independent projections with separate lifecycles: the body may be shared and rendered for humans but never enters runtime configuration or an agent capability surface. Other files in the Caplet bundle become runtime inputs only through explicit frontmatter references. + ### Caplets Exposure Projection The shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets. diff --git a/apps/catalog/src/data/official-catalog.json b/apps/catalog/src/data/official-catalog.json index 5c528a2b..7c3d2e4e 100644 --- a/apps/catalog/src/data/official-catalog.json +++ b/apps/catalog/src/data/official-catalog.json @@ -13,7 +13,7 @@ }, "sourcePath": "ast-grep/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: ast-grep\ndescription: Search, scan, test, rewrite, and scaffold ast-grep rules through curated MCP tools.\ntags:\n - mcp\n - code\n - search\ncatalog:\n icon: https://ast-grep.github.io/logo.svg\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, ast-grep-mcp@latest]\n---\n\n# AST Grep\n\nUse this Caplet when lexical search is too weak and the agent needs syntax-aware code search, rule testing, or controlled rewrites inside the bound repository.\n\n## First Workflow\n\n1. Start with read-only structural search or scan operations to prove the pattern matches the intended syntax.\n2. Inspect several matches before proposing a rewrite rule.\n3. Test rewrite rules against narrow targets before applying them broadly.\n4. Use scaffold operations only when the user wants durable ast-grep rules added to the project.\n\n## Operate Carefully\n\n- Project Binding is required because ast-grep reads and may rewrite files in the attached repository.\n- Treat apply-all rewrites, snapshot updates, and scaffolding as destructive; show the intended pattern and target scope before using them.\n- Prefer ordinary text search or LSP when the task is about names, references, or type information rather than syntax structure.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: ast-grep\ndescription: Search, scan, test, rewrite, and scaffold ast-grep rules through curated MCP tools.\ntags:\n - mcp\n - code\n - search\ncatalog:\n icon: https://ast-grep.github.io/logo.svg\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, ast-grep-mcp@latest]\n---\n\n# AST Grep\n\n## Safe Operation\n\n- Project Binding is required because ast-grep reads and may rewrite files in the attached repository.\n- Begin with read-only structural searches or scans, and inspect several matches to verify that a pattern selects the intended syntax.\n- Test rewrite rules against narrow targets before applying them broadly.\n- Treat apply-all rewrites, snapshot updates, and scaffolding as destructive operations. Review the pattern and target scope first.\n- Scaffolding is intended for durable ast-grep rules that belong in the project.\n", "icon": { "type": "url", "url": "https://ast-grep.github.io/logo.svg" @@ -23,7 +23,6 @@ "mcp", "search" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "required", @@ -65,7 +64,7 @@ }, "sourcePath": "aws/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: AWS\ndescription: Inspect and manage AWS accounts, Regions, services, resources, IAM-authorized operations, and AWS documentation through the managed AWS MCP Server.\ntags:\n - aws\n - cloud\n - infrastructure\n - iam\n - operations\ncatalog:\n icon: https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico\nsetup:\n verify:\n - label: Check AWS CLI identity\n command: aws\n args:\n - sts\n - get-caller-identity\n - label: Check uvx is available\n command: uvx\n args:\n - --version\nmcpServer:\n command: uvx\n args:\n - mcp-proxy-for-aws==1.6.2\n - https://aws-mcp.us-east-1.api.aws/mcp\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# AWS\n\nUse this Caplet when an agent needs live AWS account, Region, service, resource, IAM, operational, or AWS documentation context through the managed AWS MCP Server.\n\n## First Workflow\n\n1. Start by confirming the intended account, Region, profile, service, and resource identifiers before querying broad AWS state.\n2. Use documentation, skill, list, and describe operations to narrow ambiguous service behavior or resource matches before changing anything.\n3. Inspect existing resource state, IAM context, dependencies, tags, and CloudTrail or service evidence before proposing operational changes.\n4. For multi-account work, prefer named AWS profiles exposed through `AWS_MCP_PROXY_PROFILES`, and pass the intended profile on calls that support profile selection.\n5. Use explicit Region names in requests when the target Region matters, especially if the runtime was not started with `AWS_REGION`.\n6. Summarize the target account, Region, resource, and expected production effect before mutating resources.\n\n## Operate Carefully\n\n- AWS operations can affect production infrastructure, data, security boundaries, billing, and compliance posture. Prefer read-only inspection before writes.\n- Use least-privilege IAM roles, permission boundaries, and AWS MCP Server IAM condition keys where available.\n- Confirm destructive or high-impact targets before deleting, replacing, scaling, deploying, rotating credentials, changing IAM, modifying network policy, or changing data stores.\n- If credentials are missing or expired, refresh AWS CLI credentials and rerun the setup verification before retrying.\n- Avoid this Caplet when the task only needs local IaC or application files; use the project workspace and deployment tooling for local configuration state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: AWS\ndescription: Inspect and manage AWS accounts, Regions, services, resources, IAM-authorized operations, and AWS documentation through the managed AWS MCP Server.\ntags:\n - aws\n - cloud\n - infrastructure\n - iam\n - operations\ncatalog:\n icon: https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico\nsetup:\n verify:\n - label: Check AWS CLI identity\n command: aws\n args:\n - sts\n - get-caller-identity\n - label: Check uvx is available\n command: uvx\n args:\n - --version\nmcpServer:\n command: uvx\n args:\n - mcp-proxy-for-aws==1.6.2\n - https://aws-mcp.us-east-1.api.aws/mcp\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# AWS\n\n## Targeting and Prerequisites\n\n- Confirm the intended account, Region, profile, service, and resource identifiers before inspecting broad AWS state.\n- Documentation, skill, list, and describe operations can narrow ambiguous service behavior or resource matches.\n- For multi-account installations, named profiles are exposed through `AWS_MCP_PROXY_PROFILES`; select the intended profile on calls that support it.\n- Use explicit Region names when the target Region matters, especially when the runtime was not started with `AWS_REGION`.\n\n## Safe Operation\n\n- Inspect existing resource state, IAM context, dependencies, tags, and CloudTrail or service evidence before operational changes.\n- AWS operations can affect production infrastructure, data, security boundaries, billing, and compliance posture. Keep access read-only until a write is necessary.\n- Use least-privilege IAM roles, permission boundaries, and AWS MCP Server IAM condition keys where available.\n- Review the target account, Region, resource, and expected production effect before a mutation.\n- Confirm destructive or high-impact targets before deleting, replacing, scaling, deploying, rotating credentials, changing IAM, modifying network policy, or changing data stores.\n\n## Troubleshooting\n\nIf credentials are missing or expired, refresh the AWS CLI credentials and rerun the setup verification before retrying.\n", "icon": { "type": "url", "url": "https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico" @@ -77,7 +76,6 @@ "infrastructure", "operations" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -119,7 +117,7 @@ }, "sourcePath": "azure/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Azure\ndescription: Inspect and manage Azure resources, subscriptions, services, deployment state, and documentation through Microsoft's Azure MCP Server.\ntags:\n - azure\n - cloud\n - infrastructure\n - microsoft\n - operations\ncatalog:\n icon: https://azure.microsoft.com/favicon.ico\nsetup:\n verify:\n - label: Check Azure CLI account\n command: az\n args:\n - account\n - show\n - label: Check npx is available\n command: npx\n args:\n - --version\nmcpServer:\n command: npx\n args:\n - -y\n - \"@azure/mcp@latest\"\n - server\n - start\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Azure\n\nUse this Caplet when an agent needs live Azure subscription, resource group, service, deployment, monitoring, storage, database, identity, or documentation context through Microsoft's Azure MCP Server.\n\n## First Workflow\n\n1. Start by confirming the Azure tenant, subscription, resource group, Region, service namespace, and resource name before querying broadly.\n2. Use list, get, documentation, and diagnostic operations to narrow resource state before changing anything.\n3. Inspect dependencies, tags, identities, access controls, costs, deployment history, and monitoring evidence before proposing operational changes.\n4. Summarize the tenant, subscription, resource group, resource, and expected production effect before mutating resources.\n\n## Operate Carefully\n\n- Azure operations can affect production infrastructure, data, identity boundaries, billing, and compliance posture. Prefer read-only inspection before writes.\n- Authenticate with least-privilege Azure roles and the intended tenant before starting the server.\n- Confirm destructive or high-impact targets before deleting, scaling, redeploying, rotating credentials, changing RBAC, modifying networking, or changing data stores.\n- Avoid this Caplet when the task only needs local IaC or application files.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Azure\ndescription: Inspect and manage Azure resources, subscriptions, services, deployment state, and documentation through Microsoft's Azure MCP Server.\ntags:\n - azure\n - cloud\n - infrastructure\n - microsoft\n - operations\ncatalog:\n icon: https://azure.microsoft.com/favicon.ico\nsetup:\n verify:\n - label: Check Azure CLI account\n command: az\n args:\n - account\n - show\n - label: Check npx is available\n command: npx\n args:\n - --version\nmcpServer:\n command: npx\n args:\n - -y\n - \"@azure/mcp@latest\"\n - server\n - start\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Azure\n\n## Targeting and Prerequisites\n\n- Confirm the intended tenant, subscription, resource group, Region, service namespace, and resource name before inspecting broad Azure state.\n- Authenticate against the intended tenant with least-privilege Azure roles before starting the server.\n- List, get, documentation, and diagnostic operations can narrow ambiguous resource matches.\n\n## Safe Operation\n\n- Inspect dependencies, tags, identities, access controls, costs, deployment history, and monitoring evidence before operational changes.\n- Azure operations can affect production infrastructure, data, identity boundaries, billing, and compliance posture. Keep access read-only until a write is necessary.\n- Review the tenant, subscription, resource group, resource, and expected production effect before a mutation.\n- Confirm destructive or high-impact targets before deleting, scaling, redeploying, rotating credentials, changing RBAC, modifying networking, or changing data stores.\n", "icon": { "type": "url", "url": "https://azure.microsoft.com/favicon.ico" @@ -131,7 +129,6 @@ "microsoft", "operations" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -173,7 +170,7 @@ }, "sourcePath": "browser-use/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Browser Use\ndescription: Drive the user's real browser through Playwright MCP for local control.\ntags:\n - browser\n - playwright\n - mcp\ncatalog:\n icon: https://playwright.dev/img/playwright-logo.svg\nmcpServer:\n command: npx\n args:\n - -y\n - \"@playwright/mcp@latest\"\n - --extension\n---\n\n# Browser Use\n\nUse this Caplet when the agent needs the user's real local browser context: signed-in web apps, current tabs, extension-backed inspection, or browser workflows that a headless test browser cannot reproduce.\n\n## First Workflow\n\n1. Identify the target page, tab, or workflow before interacting.\n2. Read page state with navigation, screenshots, accessibility snapshots, or DOM inspection first.\n3. Keep interactions minimal and reversible until the user asks for a concrete action.\n4. Capture the evidence needed for the coding or debugging task, then stop.\n\n## Operate Carefully\n\n- Browser actions can sign in, submit forms, trigger purchases, or change account data in the user's real browser.\n- Do not enter credentials, approve payments, submit destructive forms, or change account settings without explicit user direction.\n- Prefer Playwright for isolated frontend testing; use this Caplet when the real browser environment matters.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Browser Use\ndescription: Drive the user's real browser through Playwright MCP for local control.\ntags:\n - browser\n - playwright\n - mcp\ncatalog:\n icon: https://playwright.dev/img/playwright-logo.svg\nmcpServer:\n command: npx\n args:\n - -y\n - \"@playwright/mcp@latest\"\n - --extension\n---\n\n# Browser Use\n\n## Targeting and Observation\n\n- The operator should identify the target page, tab, and intended outcome before interaction.\n- Navigation state, screenshots, accessibility snapshots, and DOM inspection provide an observation-first view of the page.\n- Initial interactions should remain minimal and reversible, with evidence collection bounded to the task.\n\n## Safe Operation\n\n- Actions occur in the user's real browser and can sign in, submit forms, trigger purchases, or change account data.\n- Credential entry, payment approval, destructive form submission, and account-setting changes require explicit user direction.\n", "icon": { "type": "url", "url": "https://playwright.dev/img/playwright-logo.svg" @@ -183,7 +180,6 @@ "mcp", "playwright" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -219,7 +215,7 @@ }, "sourcePath": "cloudflare/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Cloudflare\ndescription: Inspect and manage Cloudflare accounts, zones, DNS, Workers, security settings, caches, rules, and other resources through Cloudflare's hosted MCP server.\ntags:\n - cloudflare\n - dns\n - workers\n - security\n - infrastructure\ncatalog:\n icon: https://www.cloudflare.com/favicon.ico\nmcpServer:\n url: https://mcp.cloudflare.com/mcp\n auth:\n type: oauth2\n---\n\n# Cloudflare\n\nUse this Caplet when an agent needs live Cloudflare account or zone context, or needs to act on DNS, Workers, cache, rules, security, access, pages, images, logs, or other Cloudflare resources through Cloudflare's hosted MCP server.\n\n## First Workflow\n\n1. Start with the account ID, zone ID, domain, Worker name, rule ID, or other exact resource identifier when available.\n2. Read current resource state before proposing or applying changes, especially for DNS records, security rules, Workers routes, cache settings, and access policies.\n3. Use list and get operations to narrow ambiguous matches before creating, updating, deleting, purging, or deploying anything.\n4. Summarize the intended external effect and target resource before making mutating calls.\n\n## Operate Carefully\n\n- Cloudflare changes can affect production traffic, DNS resolution, security policy, cache behavior, and deployed code. Prefer read-only inspection first.\n- Keep OAuth access limited to the Cloudflare account and resources intended for the task.\n- Confirm destructive targets before deleting DNS records, rules, routes, keys, certificates, applications, Workers resources, or account-level settings.\n- Avoid this Caplet when the task only needs local Cloudflare project files; use the project workspace and Cloudflare tooling for local configuration state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Cloudflare\ndescription: Inspect and manage Cloudflare accounts, zones, DNS, Workers, security settings, caches, rules, and other resources through Cloudflare's hosted MCP server.\ntags:\n - cloudflare\n - dns\n - workers\n - security\n - infrastructure\ncatalog:\n icon: https://www.cloudflare.com/favicon.ico\nmcpServer:\n url: https://mcp.cloudflare.com/mcp\n auth:\n type: oauth2\n---\n\n# Cloudflare\n\n## Targeting and Prerequisites\n\n- Use the account ID, zone ID, domain, Worker name, rule ID, or another exact resource identifier when available.\n- List and get operations can narrow ambiguous matches before any create, update, delete, purge, or deployment.\n- OAuth access should be limited to the intended Cloudflare account and resources.\n\n## Safe Operation\n\n- Read current resource state before a change, especially for DNS records, security rules, Workers routes, cache settings, and access policies.\n- Cloudflare changes can affect production traffic, DNS resolution, security policy, cache behavior, and deployed code. Keep access read-only until a write is necessary.\n- Review the target resource and intended external effect before a mutation.\n- Confirm destructive targets before deleting DNS records, rules, routes, keys, certificates, applications, Workers resources, or account-level settings.\n", "icon": { "type": "url", "url": "https://www.cloudflare.com/favicon.ico" @@ -231,7 +227,6 @@ "security", "workers" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -267,7 +262,7 @@ }, "sourcePath": "coding-agent-toolkit/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Coding Agent Toolkit\ndescription: self-contained nested toolkit of high-value Caplets for coding agents.\ntags:\n - coding-agent\n - toolkit\n - caplets\ncatalog:\n icon: https://caplets.dev/icon.png\ncapletSet:\n capletsRoot: ./caplets\n---\n\n# Coding Agent Toolkit\n\nUse this CapletSet when the agent needs a compact default toolkit for coding work rather than a large bespoke integration list.\n\n## First Workflow\n\n1. Use repository and code-intelligence Caplets for local facts before making implementation claims.\n2. Use package, vulnerability, and documentation Caplets to verify external dependency assumptions.\n3. Use browser automation only when rendered behavior or live web context is part of the task.\n\n## Operate Carefully\n\n- This set is a convenience bundle; prefer a narrower individual Caplet when the user asks for a specific provider or capability.\n- Some child Caplets require Project Binding, setup, or local-control awareness. Inspect the child Caplet before using high-risk or project-bound capabilities.\n- Do not assume every child is available at runtime; availability depends on installation scope, setup state, and binding state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Coding Agent Toolkit\ndescription: self-contained nested toolkit of high-value Caplets for coding agents.\ntags:\n - coding-agent\n - toolkit\n - caplets\ncatalog:\n icon: https://caplets.dev/icon.png\ncapletSet:\n capletsRoot: ./caplets\n---\n\n# Coding Agent Toolkit\n\n## Bundle Contents\n\nThe set combines repository and code-intelligence capabilities with package, vulnerability, documentation, and browser tooling. Child availability depends on installation scope, setup state, and Project Binding state; not every child is necessarily available at runtime.\n\n## Setup and Safety\n\n- Some child Caplets require Project Binding, additional setup, or awareness of local-control risks.\n- Operators should review a child's own README and frontmatter before enabling project-bound or high-risk capabilities.\n- Browser children can interact with rendered or live web contexts and should be enabled only with the control surface appropriate to the installation.\n", "icon": { "type": "url", "url": "https://caplets.dev/icon.png" @@ -277,7 +272,6 @@ "coding-agent", "toolkit" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -306,13 +300,12 @@ }, "sourcePath": "computer-use/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Computer Use\ndescription: Control local desktop applications and windows through open-computer-use for explicit desktop automation workflows.\ntags:\n - computer-use\n - desktop\n - local-control\nmcpServer:\n command: npx\n args:\n - -y\n - open-computer-use@latest\n - mcp\n---\n\n# Computer Use\n\nUse this Caplet only when an agent needs explicit access to the local desktop, application windows, or GUI workflows that cannot be completed through APIs or CLI tools.\n\n## First Workflow\n\n1. Identify the target application, window, and desired outcome before interacting.\n2. Observe the screen and report the intended next action before changing state.\n3. Prefer menu/navigation/read actions before typing, clicking submit buttons, or changing settings.\n4. Stop after completing the narrow GUI step the user requested.\n\n## Operate Carefully\n\n- This is a high-risk local-control Caplet. It can operate real applications and expose private screen content.\n- Do not use it for credential entry, payment flows, destructive file operations, account settings, or irreversible actions without direct user instruction.\n- Prefer provider APIs, CLI tools, or browser automation when those can complete the task with a smaller control surface.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Computer Use\ndescription: Control local desktop applications and windows through open-computer-use for explicit desktop automation workflows.\ntags:\n - computer-use\n - desktop\n - local-control\nmcpServer:\n command: npx\n args:\n - -y\n - open-computer-use@latest\n - mcp\n---\n\n# Computer Use\n\n## Targeting and Observation\n\n- The operator should identify the target application, window, and intended outcome before interaction.\n- Screen observation should precede state changes, with the proposed next action made clear.\n- Menu, navigation, and read actions provide a safer starting point than typing, submitting, or changing settings.\n- Automation should stop once the requested narrow GUI operation is complete.\n\n## Safe Operation\n\n- This is a high-risk local-control capability that can operate real applications and expose private screen content.\n- Credential entry, payment flows, destructive file operations, account-setting changes, and other irreversible actions require direct user instruction.\n", "tags": [ "computer-use", "desktop", "local-control" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -348,7 +341,7 @@ }, "sourcePath": "context7/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Context7\ndescription: Fetch current library and framework documentation through Context7 before using version-sensitive APIs.\ntags:\n - docs\n - libraries\n - frameworks\n - api-reference\ncatalog:\n icon: https://context7.com/favicon.ico\nmcpServer:\n url: https://mcp.context7.com/mcp/oauth\n auth:\n type: oauth2\n---\n\n# Context7\n\nUse this Caplet when the agent needs current library, SDK, framework, CLI, or cloud-service documentation before writing code or giving technical instructions.\n\n## First Workflow\n\n1. Name the package, framework, SDK, or service as specifically as possible.\n2. Ask for the current API, config, migration, or example relevant to the task.\n3. Cross-check returned guidance against project-local versions, types, and tests before editing code.\n4. Cite or summarize only the documentation details needed for the implementation decision.\n\n## Operate Carefully\n\n- Prefer primary docs and version-specific examples over generic snippets when implementation risk is high.\n- Do not use documentation lookup as a substitute for reading the local codebase, lockfile, generated types, or failing tests.\n- Avoid broad documentation searches when a package name, version, or API symbol is already known.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Context7\ndescription: Fetch current library and framework documentation through Context7 before using version-sensitive APIs.\ntags:\n - docs\n - libraries\n - frameworks\n - api-reference\ncatalog:\n icon: https://context7.com/favicon.ico\nmcpServer:\n url: https://mcp.context7.com/mcp/oauth\n auth:\n type: oauth2\n---\n\n# Context7\n\n## Documentation Lookup\n\n- Results are most precise when the package, framework, SDK, service, and relevant version are identified.\n- A known API symbol supports a narrower lookup than a broad documentation search.\n- Current API, configuration, migration, and example material should be checked against the project's local versions, types, and tests.\n\n## Source Quality\n\n- Prefer primary documentation and version-specific examples over generic snippets when implementation risk is high.\n- Keep citations or summaries limited to the details that support the implementation decision.\n", "icon": { "type": "url", "url": "https://context7.com/favicon.ico" @@ -359,7 +352,6 @@ "frameworks", "libraries" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -395,7 +387,7 @@ }, "sourcePath": "datadog/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Datadog\ndescription: Query Datadog logs, metrics, traces, dashboards, monitors, incidents, services, events, notebooks, and observability insights through Datadog's managed MCP server.\ntags:\n - datadog\n - observability\n - logs\n - metrics\n - incidents\ncatalog:\n icon: https://www.datadoghq.com/favicon.ico\nmcpServer:\n url: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp\n auth:\n type: oauth2\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Datadog\n\nUse this Caplet when an agent needs live Datadog observability context for logs, metrics, traces, monitors, dashboards, incidents, hosts, services, events, notebooks, APM, or agent observability.\n\n## First Workflow\n\n1. Start by confirming the Datadog site, organization, service, environment, time window, tags, monitor, incident, trace ID, or dashboard target.\n2. Query narrow time ranges and tags first, then widen only when the first pass misses relevant evidence.\n3. Correlate logs, metrics, traces, deployment events, monitor status, and incidents before naming a cause.\n4. Add a `toolsets` query parameter after install when the workflow should expose only specific Datadog product areas.\n\n## Operate Carefully\n\n- Datadog evidence can include production telemetry, customer identifiers, incident details, and security signals. Summarize the signal without leaking sensitive payloads.\n- Confirm the Datadog site and endpoint host for non-US1 organizations before authenticating.\n- Prefer read-only investigation before changing monitors, dashboards, notebooks, incident state, or platform configuration.\n- Avoid this Caplet when the task only needs local log files or application instrumentation code.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Datadog\ndescription: Query Datadog logs, metrics, traces, dashboards, monitors, incidents, services, events, notebooks, and observability insights through Datadog's managed MCP server.\ntags:\n - datadog\n - observability\n - logs\n - metrics\n - incidents\ncatalog:\n icon: https://www.datadoghq.com/favicon.ico\nmcpServer:\n url: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp\n auth:\n type: oauth2\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Datadog\n\n## Targeting and Setup\n\n- Confirm the Datadog site, organization, service, environment, time window, tags, monitor, incident, trace ID, or dashboard target.\n- For non-US1 organizations, confirm the Datadog site and endpoint host before authentication.\n- The `toolsets` query parameter can be added after installation to expose only the required Datadog product areas.\n\n## Investigation and Safety\n\n- Begin with narrow time ranges and tags, widening only when the first pass misses relevant evidence.\n- Logs, metrics, traces, deployment events, monitor status, and incidents provide complementary evidence and should be correlated before a cause is assigned.\n- Production telemetry can include customer identifiers, incident details, and security signals. Summaries should omit sensitive payloads.\n- Keep investigation read-only until changes to monitors, dashboards, notebooks, incident state, or platform configuration are necessary.\n", "icon": { "type": "url", "url": "https://www.datadoghq.com/favicon.ico" @@ -407,7 +399,6 @@ "metrics", "observability" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -443,7 +434,7 @@ }, "sourcePath": "deepwiki/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: DeepWiki\ndescription: Query repository-focused documentation and codebase explanations through DeepWiki's MCP service.\ntags:\n - docs\n - code\n - mcp\ncatalog:\n icon: https://deepwiki.com/favicon.ico\nmcpServer:\n url: https://mcp.deepwiki.com/mcp\n---\n\n# DeepWiki\n\nUse this Caplet when the agent needs repository-level explanations or architecture context for an unfamiliar codebase before making implementation decisions.\n\n## First Workflow\n\n1. Ask about a specific repository, subsystem, file, or concept rather than the whole project.\n2. Use DeepWiki to build orientation, then verify critical claims against source code or official docs.\n3. Bring back concise architecture facts, terminology, and code pointers that affect the task.\n\n## Operate Carefully\n\n- Treat DeepWiki as a research and orientation source, not final proof for code changes.\n- Do not use it when the local repository is already available and direct code search or tests can answer the question.\n- Re-check version-sensitive details against the current upstream repository when correctness matters.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: DeepWiki\ndescription: Query repository-focused documentation and codebase explanations through DeepWiki's MCP service.\ntags:\n - docs\n - code\n - mcp\ncatalog:\n icon: https://deepwiki.com/favicon.ico\nmcpServer:\n url: https://mcp.deepwiki.com/mcp\n---\n\n# DeepWiki\n\n## Research guidance\n\nDeepWiki is most useful for a specific repository, subsystem, file, or concept. Focused questions produce more actionable architecture facts, terminology, and code pointers than broad requests about an entire project.\n\n## Verification\n\nDeepWiki provides orientation rather than final proof for code changes. Verify critical claims against source code or official documentation, and re-check version-sensitive details against the current upstream repository when correctness matters.\n", "icon": { "type": "url", "url": "https://deepwiki.com/favicon.ico" @@ -453,7 +444,6 @@ "docs", "mcp" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -482,7 +472,7 @@ }, "sourcePath": "github/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: GitHub\ndescription: Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows.\ntags:\n - code\n - github\n - pull-requests\n - issues\n - reviews\ncatalog:\n icon: https://github.githubassets.com/favicons/favicon.svg\nmcpServer:\n url: https://api.githubcopilot.com/mcp\n auth:\n type: bearer\n token: $vault:GH_TOKEN\n---\n\n# GitHub\n\nUse this Caplet when the agent needs live GitHub repository context or needs to act on issues, pull requests, branches, commits, or review feedback.\n\n## First Workflow\n\n1. Read the relevant repository, issue, pull request, branch, or commit before taking action.\n2. Narrow by repo, PR number, issue number, branch, label, or author whenever possible.\n3. For reviews, inspect changed files and relevant discussion before commenting.\n4. For issue creation or updates, draft concise content tied to the current implementation evidence.\n\n## Operate Carefully\n\n- Mutating operations can affect real repositories. Prefer read operations first.\n- Confirm target repository, branch, issue, or pull request before creating comments, labels, branches, or updates.\n- Do not expose token values, repository secrets, or private issue contents outside the intended conversation.\n- Prefer local Git and project files for workspace state; use GitHub for remote truth.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: GitHub\ndescription: Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows.\ntags:\n - code\n - github\n - pull-requests\n - issues\n - reviews\ncatalog:\n icon: https://github.githubassets.com/favicons/favicon.svg\nmcpServer:\n url: https://api.githubcopilot.com/mcp\n auth:\n type: bearer\n token: $vault:GH_TOKEN\n---\n\n# GitHub\n\n## Targeting remote work\n\nIdentify the repository and the relevant issue, pull request, branch, or commit before taking action. Narrow lookups by repository, PR number, issue number, branch, label, or author whenever possible. Review work should include the changed files and relevant discussion; issue drafts and updates should stay concise and grounded in current implementation evidence.\n\n## Safe operation\n\n- GitHub mutations affect real repositories. Read current remote state before writing.\n- Confirm the target repository, branch, issue, or pull request before creating comments, labels, branches, or updates.\n- Keep token values, repository secrets, and private issue contents within their intended access boundary.\n", "icon": { "type": "url", "url": "https://github.githubassets.com/favicons/favicon.svg" @@ -494,7 +484,6 @@ "pull-requests", "reviews" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -530,7 +519,7 @@ }, "sourcePath": "gmail/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Gmail\ndescription: Search, read, label, draft, and send Gmail messages through the Gmail API Discovery document.\ntags:\n - google\n - gmail\n - email\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://gmail.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - gmail.users.getProfile\n - gmail.users.labels.list\n - gmail.users.labels.get\n - gmail.users.labels.create\n - gmail.users.labels.patch\n - gmail.users.labels.update\n - gmail.users.messages.list\n - gmail.users.messages.get\n - gmail.users.messages.attachments.get\n - gmail.users.messages.modify\n - gmail.users.messages.send\n - gmail.users.threads.list\n - gmail.users.threads.get\n - gmail.users.threads.modify\n - gmail.users.drafts.list\n - gmail.users.drafts.get\n - gmail.users.drafts.create\n - gmail.users.drafts.update\n - gmail.users.drafts.send\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/gmail.modify\n---\n\n# Gmail\n\nUse this Caplet when an agent needs Gmail context for support, scheduling, customer communication, or inbox triage.\n\n## First Workflow\n\n1. Start with narrow searches by sender, subject, label, date range, or thread when possible.\n2. Read message metadata and thread context before retrieving full message bodies.\n3. Summarize only the details needed for the user's task.\n4. Draft replies before sending, and ask for explicit user intent before modifying labels or sending.\n\n## Operate Carefully\n\n- Email often contains private or regulated content. Keep queries narrow and summaries minimal.\n- Confirm recipients, thread IDs, labels, and draft contents before any write operation.\n- This Caplet does not expose Gmail settings, permanent deletion, trash/untrash, import/insert, watch, or forwarding operations; create a private variant if those are required.\n- Prefer a task or calendar integration when the work is only follow-up tracking and does not require email content.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Gmail\ndescription: Search, read, label, draft, and send Gmail messages through the Gmail API Discovery document.\ntags:\n - google\n - gmail\n - email\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://gmail.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - gmail.users.getProfile\n - gmail.users.labels.list\n - gmail.users.labels.get\n - gmail.users.labels.create\n - gmail.users.labels.patch\n - gmail.users.labels.update\n - gmail.users.messages.list\n - gmail.users.messages.get\n - gmail.users.messages.attachments.get\n - gmail.users.messages.modify\n - gmail.users.messages.send\n - gmail.users.threads.list\n - gmail.users.threads.get\n - gmail.users.threads.modify\n - gmail.users.drafts.list\n - gmail.users.drafts.get\n - gmail.users.drafts.create\n - gmail.users.drafts.update\n - gmail.users.drafts.send\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/gmail.modify\n---\n\n# Gmail\n\n## Reading mail\n\nUse narrow searches by sender, subject, label, date range, or thread. Inspect message metadata and thread context before retrieving full bodies, and limit summaries to the information the operator needs.\n\n## Safe operation and limits\n\n- Email can contain private or regulated content. Keep queries narrow and summaries minimal.\n- Before modifying labels or sending mail, confirm explicit operator intent, recipients, thread IDs, labels, and draft contents. Draft replies before sending.\n- This Caplet does not expose Gmail settings, permanent deletion, trash/untrash, import/insert, watch, or forwarding operations. Those operations require a private variant.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png" @@ -540,7 +529,6 @@ "gmail", "google" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -582,7 +570,7 @@ }, "sourcePath": "google-chat/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Chat\ndescription: Search spaces, read messages, send messages, and add reactions through the Google Chat API Discovery document.\ntags:\n - google\n - chat\n - messaging\n - collaboration\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/chat_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://chat.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - chat.spaces.list\n - chat.spaces.search\n - chat.spaces.get\n - chat.spaces.findDirectMessage\n - chat.spaces.findGroupChats\n - chat.spaces.members.list\n - chat.spaces.members.get\n - chat.spaces.messages.list\n - chat.spaces.messages.get\n - chat.media.download\n - chat.spaces.messages.create\n - chat.spaces.messages.reactions.create\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/chat.spaces.readonly\n - https://www.googleapis.com/auth/chat.memberships.readonly\n - https://www.googleapis.com/auth/chat.messages.readonly\n - https://www.googleapis.com/auth/chat.messages.create\n - https://www.googleapis.com/auth/chat.messages.reactions.create\n---\n\n# Google Chat\n\nUse this Caplet when an agent needs Google Chat context, space membership context, message history, or a deliberate outgoing Chat message.\n\n## First Workflow\n\n1. Start by finding the relevant space, direct message, or group chat.\n2. Read recent message history and membership context before summarizing or drafting a reply.\n3. Download media only when the attachment is necessary for the user's task.\n4. Send messages or add reactions only when the target space, thread, recipients, and message content are explicit.\n\n## Operate Carefully\n\n- Chat messages can contain sensitive internal discussion. Keep reads narrow and summarize only what the user needs.\n- Outgoing Chat messages are visible to real people. Draft first when intent or audience is ambiguous.\n- This Caplet does not expose message update/delete, space administration, custom emoji, availability, import, or organization-wide admin operations.\n- Prefer Gmail or a task system when the work is email-centric or durable task tracking rather than Chat collaboration.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Chat\ndescription: Search spaces, read messages, send messages, and add reactions through the Google Chat API Discovery document.\ntags:\n - google\n - chat\n - messaging\n - collaboration\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/chat_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://chat.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - chat.spaces.list\n - chat.spaces.search\n - chat.spaces.get\n - chat.spaces.findDirectMessage\n - chat.spaces.findGroupChats\n - chat.spaces.members.list\n - chat.spaces.members.get\n - chat.spaces.messages.list\n - chat.spaces.messages.get\n - chat.media.download\n - chat.spaces.messages.create\n - chat.spaces.messages.reactions.create\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/chat.spaces.readonly\n - https://www.googleapis.com/auth/chat.memberships.readonly\n - https://www.googleapis.com/auth/chat.messages.readonly\n - https://www.googleapis.com/auth/chat.messages.create\n - https://www.googleapis.com/auth/chat.messages.reactions.create\n---\n\n# Google Chat\n\n## Finding conversation context\n\nLocate the relevant space, direct message, or group chat first. Read recent history and membership context before preparing a summary or reply. Download media only when the attachment is necessary for the operator's task.\n\n## Safe operation and limits\n\n- Chat messages can contain sensitive internal discussion. Keep reads narrow and summaries limited to what the operator needs.\n- Outgoing messages and reactions are visible to real people. Confirm the target space, thread, recipients, and content; prepare a draft first when intent or audience is ambiguous.\n- This Caplet does not expose message update/delete, space administration, custom emoji, availability, import, or organization-wide admin operations.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/chat_2020q4_48dp.png" @@ -593,7 +581,6 @@ "google", "messaging" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -635,7 +622,7 @@ }, "sourcePath": "google-docs/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Docs\ndescription: Read, create, and edit Google Docs documents through the Google Docs API Discovery document.\ntags:\n - google\n - docs\n - documents\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/docs_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://docs.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - docs.documents.get\n - docs.documents.create\n - docs.documents.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/documents\n---\n\n# Google Docs\n\nUse this Caplet when an agent needs to inspect document structure, create a new Google Doc, or apply explicit content and formatting updates to a known document.\n\n## First Workflow\n\n1. Start from a document ID, document URL, or newly created document returned by `documents.create`.\n2. Use `documents.get` to inspect the current document structure before proposing edits.\n3. Group content and formatting changes into one `documents.batchUpdate` request when possible.\n4. Confirm the target document ID and intended changes before creating or updating documents.\n\n## Operate Carefully\n\n- Google Docs content can contain private, shared, or regulated information. Read only the document sections needed for the task.\n- `documents.batchUpdate` can change real document content and formatting. Prefer a planned set of requests over exploratory writes.\n- Use Google Drive for file search, folder placement, sharing, copying, moving, trashing, or deleting documents.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Docs\ndescription: Read, create, and edit Google Docs documents through the Google Docs API Discovery document.\ntags:\n - google\n - docs\n - documents\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/docs_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://docs.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - docs.documents.get\n - docs.documents.create\n - docs.documents.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/documents\n---\n\n# Google Docs\n\n## Document prerequisites\n\nStart with a document ID, document URL, or the newly created document returned by `documents.create`. Inspect current structure with `documents.get` before planning content or formatting changes. Confirm the target document ID and intended changes before creating or updating a document.\n\n## Safe updates\n\n- Google Docs content can contain private, shared, or regulated information. Read only the sections needed for the operator's task.\n- `documents.batchUpdate` changes live content and formatting. Group a deliberate set of content and formatting requests into one batch where practical rather than making exploratory writes.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/docs_2020q4_48dp.png" @@ -646,7 +633,6 @@ "google", "productivity" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -688,7 +674,7 @@ }, "sourcePath": "google-drive/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Drive\ndescription: Search, read, download, upload, and manage Google Drive files through the Drive API Discovery document.\ntags:\n - google\n - drive\n - files\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest\n includeOperations:\n - drive.files.list\n - drive.files.get\n - drive.files.export\n - drive.files.download\n - drive.files.create\n - drive.files.update\n - drive.files.copy\n - drive.files.delete\n - drive.files.generateIds\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Drive\n\nUse this Caplet when an agent needs Drive files as context or needs to create/update files with explicit user direction.\n\n## First Workflow\n\n1. Search accessible file metadata first by name, owner, MIME type, folder, modified time, or shared-drive context.\n2. Confirm the exact file ID before reading, downloading, updating, moving, trashing, or deleting.\n3. Read or download only the files needed for the task.\n4. Prefer creating a new file or draft copy before overwriting an existing shared document.\n\n## Operate Carefully\n\n- Drive files may contain private, shared, or regulated information. Keep reads narrow and summarize only what is needed.\n- This Caplet uses the restricted `drive.file` scope, so it is intended for files the app created or files the user explicitly opens or grants to the app.\n- It does not expose Drive-wide sharing, permissions, comments, approvals, shared-drive administration, or trash-emptying operations; create a private variant if those are required.\n- Prefer repository files when the user is asking about local project state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Drive\ndescription: Search, read, download, upload, and manage Google Drive files through the Drive API Discovery document.\ntags:\n - google\n - drive\n - files\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest\n includeOperations:\n - drive.files.list\n - drive.files.get\n - drive.files.export\n - drive.files.download\n - drive.files.create\n - drive.files.update\n - drive.files.copy\n - drive.files.delete\n - drive.files.generateIds\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Drive\n\n## File discovery\n\nSearch accessible metadata first by name, owner, MIME type, folder, modified time, or shared-drive context. Confirm the exact file ID before reading, downloading, updating, moving, trashing, or deleting. Read or download only the files needed for the operator's task, and consider creating a new file or draft copy before overwriting an existing shared document.\n\n## Access boundary and limits\n\n- Drive files can contain private, shared, or regulated information. Keep reads narrow and summaries limited to what is needed.\n- The restricted `drive.file` OAuth scope covers files the app created and files the user explicitly opens or grants to the app; it does not provide unrestricted Drive-wide access.\n- This Caplet does not expose Drive-wide sharing, permissions, comments, approvals, shared-drive administration, or trash-emptying operations. Those operations require a private variant.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png" @@ -698,7 +684,6 @@ "files", "google" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -740,7 +725,7 @@ }, "sourcePath": "google-forms/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Forms\ndescription: Read, create, edit, and inspect responses for Google Forms through the Forms API Discovery document.\ntags:\n - google\n - forms\n - surveys\n - responses\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/forms_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://forms.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - forms.forms.get\n - forms.forms.create\n - forms.forms.batchUpdate\n - forms.forms.responses.list\n - forms.forms.responses.get\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Forms\n\nUse this Caplet when an agent needs to inspect a form, create or revise form questions, or summarize submitted responses from a known Google Form.\n\n## First Workflow\n\n1. Start from a form ID, form URL, or newly created form.\n2. Read the form structure before changing titles, descriptions, questions, grading, or navigation.\n3. List responses only when response data is required, and narrow analysis to the fields relevant to the task.\n4. Confirm question IDs, item locations, and response-safety expectations before using `forms.batchUpdate`.\n\n## Operate Carefully\n\n- Forms and responses may contain private, educational, health, or customer information. Keep response reads and summaries minimal.\n- `forms.batchUpdate` can alter live collection instruments. Avoid casual edits to published forms.\n- This Caplet uses the restricted `drive.file` scope, so it is intended for Forms the app created or files the user explicitly opens or grants to the app.\n- It does not expose publish settings, watches, or deletion workflows; create a private variant if those are required.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Forms\ndescription: Read, create, edit, and inspect responses for Google Forms through the Forms API Discovery document.\ntags:\n - google\n - forms\n - surveys\n - responses\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/forms_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://forms.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - forms.forms.get\n - forms.forms.create\n - forms.forms.batchUpdate\n - forms.forms.responses.list\n - forms.forms.responses.get\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Forms\n\n## Form prerequisites\n\nStart with a form ID, form URL, or a newly created form. Read the form structure before changing titles, descriptions, questions, grading, or navigation. For response analysis, retrieve only the required responses and fields.\n\n## Safe updates and limits\n\n- Confirm question IDs, item locations, and response-safety expectations before using `forms.batchUpdate`.\n- Forms and responses can contain private educational, health, or customer information. Keep response reads and summaries minimal.\n- `forms.batchUpdate` can alter a live collection instrument; published forms should only receive deliberate, reviewed changes.\n- The restricted `drive.file` OAuth scope covers Forms the app created and files the user explicitly opens or grants to the app.\n- This Caplet does not expose publish settings, watches, or deletion workflows. Those operations require a private variant.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/forms_2020q4_48dp.png" @@ -751,7 +736,6 @@ "responses", "surveys" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -793,7 +777,7 @@ }, "sourcePath": "google-meet/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Meet\ndescription: Create Meet spaces and inspect Meet conference records, participants, recordings, transcripts, and smart notes through the Meet API Discovery document.\ntags:\n - google\n - meet\n - meetings\n - video\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/meet_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://meet.googleapis.com/$discovery/rest?version=v2\n includeOperations:\n - meet.spaces.create\n - meet.spaces.get\n - meet.spaces.patch\n - meet.spaces.endActiveConference\n - meet.conferenceRecords.list\n - meet.conferenceRecords.get\n - meet.conferenceRecords.participants.list\n - meet.conferenceRecords.participants.get\n - meet.conferenceRecords.recordings.list\n - meet.conferenceRecords.recordings.get\n - meet.conferenceRecords.transcripts.list\n - meet.conferenceRecords.transcripts.get\n - meet.conferenceRecords.transcripts.entries.list\n - meet.conferenceRecords.transcripts.entries.get\n - meet.conferenceRecords.smartNotes.list\n - meet.conferenceRecords.smartNotes.get\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/meetings.space.created\n - https://www.googleapis.com/auth/meetings.space.readonly\n---\n\n# Google Meet\n\nUse this Caplet when an agent needs to create an app-managed Meet space or inspect meeting records, participants, recordings, transcripts, or smart notes.\n\n## First Workflow\n\n1. Start from a Meet space, conference record, meeting code, or newly created space.\n2. Inspect the conference record and participant list before reading recordings, transcripts, or smart notes.\n3. Read transcript entries or smart notes only for the time range and topic needed by the user.\n4. Patch or end an active conference only for app-created spaces and only when the user intent is explicit.\n\n## Operate Carefully\n\n- Meeting records, transcripts, recordings, and smart notes can contain sensitive personal and business information. Keep reads narrow.\n- Creating, patching, or ending Meet spaces changes live collaboration state. Confirm meeting ownership and timing before mutating.\n- This Caplet avoids organization-wide Meet settings and only exposes app-created space management plus read-only meeting record inspection.\n- Prefer Calendar when the user needs scheduling, invites, guest lists, or event lifecycle management.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Meet\ndescription: Create Meet spaces and inspect Meet conference records, participants, recordings, transcripts, and smart notes through the Meet API Discovery document.\ntags:\n - google\n - meet\n - meetings\n - video\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/meet_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://meet.googleapis.com/$discovery/rest?version=v2\n includeOperations:\n - meet.spaces.create\n - meet.spaces.get\n - meet.spaces.patch\n - meet.spaces.endActiveConference\n - meet.conferenceRecords.list\n - meet.conferenceRecords.get\n - meet.conferenceRecords.participants.list\n - meet.conferenceRecords.participants.get\n - meet.conferenceRecords.recordings.list\n - meet.conferenceRecords.recordings.get\n - meet.conferenceRecords.transcripts.list\n - meet.conferenceRecords.transcripts.get\n - meet.conferenceRecords.transcripts.entries.list\n - meet.conferenceRecords.transcripts.entries.get\n - meet.conferenceRecords.smartNotes.list\n - meet.conferenceRecords.smartNotes.get\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/meetings.space.created\n - https://www.googleapis.com/auth/meetings.space.readonly\n---\n\n# Google Meet\n\n## Meeting prerequisites\n\nStart with a Meet space, conference record, meeting code, or newly created space. Inspect the conference record and participant list before accessing recordings, transcripts, or smart notes. Limit transcript entries and smart notes to the time range and topic the operator needs.\n\n## Safe operation and limits\n\n- Meeting records, transcripts, recordings, and smart notes can contain sensitive personal and business information. Keep reads narrow.\n- Creating, patching, or ending Meet spaces changes live collaboration state. Confirm meeting ownership, timing, and operator intent before a mutation.\n- Space mutations are limited to app-created spaces. The exposed meeting-record operations are read-only, and organization-wide Meet settings are not available.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/meet_2020q4_48dp.png" @@ -804,7 +788,6 @@ "meetings", "video" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -846,7 +829,7 @@ }, "sourcePath": "google-sheets/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Sheets\ndescription: Read, create, and update Google Sheets spreadsheets through the Sheets API Discovery document.\ntags:\n - google\n - sheets\n - spreadsheets\n - data\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/sheets_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://sheets.googleapis.com/$discovery/rest?version=v4\n includeOperations:\n - sheets.spreadsheets.get\n - sheets.spreadsheets.getByDataFilter\n - sheets.spreadsheets.create\n - sheets.spreadsheets.batchUpdate\n - sheets.spreadsheets.developerMetadata.search\n - sheets.spreadsheets.values.get\n - sheets.spreadsheets.values.batchGet\n - sheets.spreadsheets.values.batchGetByDataFilter\n - sheets.spreadsheets.values.update\n - sheets.spreadsheets.values.batchUpdate\n - sheets.spreadsheets.values.append\n - sheets.spreadsheets.values.clear\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Sheets\n\nUse this Caplet when an agent needs to inspect spreadsheet structure, read bounded ranges, append tabular data, or make explicit updates to a known Google Sheet.\n\n## First Workflow\n\n1. Start from a spreadsheet ID, spreadsheet URL, or newly created spreadsheet.\n2. Inspect sheet names, grid properties, named ranges, and developer metadata before reading large ranges.\n3. Read only the ranges needed for the task, using `values.get`, `values.batchGet`, or data filters.\n4. Confirm target sheet, range, value shape, and whether formulas should be preserved before updating, appending, or clearing cells.\n\n## Operate Carefully\n\n- Spreadsheets often contain private business data. Prefer narrow ranges and summaries over full-sheet reads.\n- `batchUpdate`, value updates, appends, and clears change live spreadsheet state. Treat clearing cells as destructive.\n- This Caplet uses the restricted `drive.file` scope, so it is intended for Sheets the app created or files the user explicitly opens or grants to the app.\n- Use Google Drive for locating, sharing, copying, moving, trashing, or deleting spreadsheet files.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Sheets\ndescription: Read, create, and update Google Sheets spreadsheets through the Sheets API Discovery document.\ntags:\n - google\n - sheets\n - spreadsheets\n - data\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/sheets_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://sheets.googleapis.com/$discovery/rest?version=v4\n includeOperations:\n - sheets.spreadsheets.get\n - sheets.spreadsheets.getByDataFilter\n - sheets.spreadsheets.create\n - sheets.spreadsheets.batchUpdate\n - sheets.spreadsheets.developerMetadata.search\n - sheets.spreadsheets.values.get\n - sheets.spreadsheets.values.batchGet\n - sheets.spreadsheets.values.batchGetByDataFilter\n - sheets.spreadsheets.values.update\n - sheets.spreadsheets.values.batchUpdate\n - sheets.spreadsheets.values.append\n - sheets.spreadsheets.values.clear\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Sheets\n\n## Spreadsheet prerequisites\n\nStart with a spreadsheet ID, spreadsheet URL, or newly created spreadsheet. Inspect sheet names, grid properties, named ranges, and developer metadata before reading large ranges. Retrieve only needed ranges with `values.get`, `values.batchGet`, or data filters.\n\n## Safe updates and access\n\n- Confirm the target sheet, range, value shape, and whether formulas must be preserved before updating, appending, or clearing cells.\n- Spreadsheets often contain private business data. Prefer bounded ranges and summaries over full-sheet reads.\n- `batchUpdate`, value updates, appends, and clears change live spreadsheet state. Clearing cells is destructive.\n- The restricted `drive.file` OAuth scope covers Sheets the app created and files the user explicitly opens or grants to the app.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/sheets_2020q4_48dp.png" @@ -857,7 +840,6 @@ "sheets", "spreadsheets" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -899,7 +881,7 @@ }, "sourcePath": "google-slides/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Slides\ndescription: Read, create, preview, and edit Google Slides presentations through the Slides API Discovery document.\ntags:\n - google\n - slides\n - presentations\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/slides_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://slides.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - slides.presentations.get\n - slides.presentations.pages.get\n - slides.presentations.pages.getThumbnail\n - slides.presentations.create\n - slides.presentations.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Slides\n\nUse this Caplet when an agent needs to inspect a deck, create a presentation, preview slides, or apply explicit layout and content changes to a known Google Slides file.\n\n## First Workflow\n\n1. Start from a presentation ID, presentation URL, or newly created presentation.\n2. Use `presentations.get` to inspect slide order, page element IDs, layouts, and existing text before planning edits.\n3. Use page reads or thumbnails when the user needs visual confirmation of a specific slide.\n4. Group text, image, layout, and styling changes into a deliberate `presentations.batchUpdate` request.\n\n## Operate Carefully\n\n- Presentations can contain private plans, customer material, or financial data. Inspect only the slides needed for the task.\n- `presentations.batchUpdate` changes live deck content and formatting. Confirm page IDs and element IDs before mutating.\n- This Caplet uses the restricted `drive.file` scope, so it is intended for Slides files the app created or files the user explicitly opens or grants to the app.\n- Use Google Drive for finding, sharing, copying, moving, trashing, or deleting presentation files.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Slides\ndescription: Read, create, preview, and edit Google Slides presentations through the Slides API Discovery document.\ntags:\n - google\n - slides\n - presentations\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/slides_2020q4_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://slides.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - slides.presentations.get\n - slides.presentations.pages.get\n - slides.presentations.pages.getThumbnail\n - slides.presentations.create\n - slides.presentations.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n---\n\n# Google Slides\n\n## Presentation prerequisites\n\nStart with a presentation ID, presentation URL, or newly created presentation. Use `presentations.get` to inspect slide order, page element IDs, layouts, and existing text. Page reads and thumbnails can provide visual confirmation of a specific slide.\n\n## Safe updates and access\n\n- Presentations can contain private plans, customer material, or financial data. Inspect only the slides needed for the operator's task.\n- `presentations.batchUpdate` changes live deck content and formatting. Confirm page and element IDs, then group text, image, layout, and styling changes into a deliberate batch.\n- The restricted `drive.file` OAuth scope covers Slides files the app created and files the user explicitly opens or grants to the app.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/slides_2020q4_48dp.png" @@ -910,7 +892,6 @@ "productivity", "slides" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -952,7 +933,7 @@ }, "sourcePath": "google-tasks/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Tasks\ndescription: Read, create, update, organize, and complete Google Tasks through the Google Tasks API Discovery document.\ntags:\n - google\n - tasks\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/tasks_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest\n includeOperations:\n - tasks.tasklists.list\n - tasks.tasklists.get\n - tasks.tasklists.insert\n - tasks.tasklists.patch\n - tasks.tasklists.update\n - tasks.tasks.list\n - tasks.tasks.get\n - tasks.tasks.insert\n - tasks.tasks.patch\n - tasks.tasks.update\n - tasks.tasks.move\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/tasks\n---\n\n# Google Tasks\n\nUse this Caplet when an agent needs to inspect or manage Google Tasks during planning, follow-up, or personal workflow coordination.\n\n## First Workflow\n\n1. List tasklists before choosing where work belongs.\n2. Search or list existing tasks before creating new ones to avoid duplicates.\n3. Confirm task title, notes, due date, parent task, and tasklist before creating or moving.\n4. Mark tasks complete only when the user or current workflow clearly confirms completion.\n\n## Operate Carefully\n\n- Task changes are user-visible workflow state. Read first and keep writes specific.\n- Do not infer deadlines or completion state from vague conversation.\n- This Caplet does not expose task deletion, tasklist deletion, or clear-completed operations; create a private variant if deletion workflows are required.\n- Prefer Linear or GitHub Issues for team-owned engineering work.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Tasks\ndescription: Read, create, update, organize, and complete Google Tasks through the Google Tasks API Discovery document.\ntags:\n - google\n - tasks\n - productivity\ncatalog:\n icon: https://www.gstatic.com/images/branding/product/2x/tasks_48dp.png\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest\n includeOperations:\n - tasks.tasklists.list\n - tasks.tasklists.get\n - tasks.tasklists.insert\n - tasks.tasklists.patch\n - tasks.tasklists.update\n - tasks.tasks.list\n - tasks.tasks.get\n - tasks.tasks.insert\n - tasks.tasks.patch\n - tasks.tasks.update\n - tasks.tasks.move\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/tasks\n---\n\n# Google Tasks\n\n## Task and tasklist handling\n\nList tasklists before choosing a destination. Search existing tasks before creating one to avoid duplicates. Before creating or moving a task, confirm its title, notes, due date, parent task, and tasklist.\n\n## Safe operation and limits\n\n- Task changes are user-visible workflow state. Read current state first and keep writes specific.\n- Do not infer deadlines or completion from ambiguous conversation. Mark a task complete only after the operator or the current workflow clearly confirms completion.\n- This Caplet does not expose task deletion, tasklist deletion, or clear-completed operations. Those operations require a private variant.\n", "icon": { "type": "url", "url": "https://www.gstatic.com/images/branding/product/2x/tasks_48dp.png" @@ -962,7 +943,6 @@ "productivity", "tasks" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1004,7 +984,7 @@ }, "sourcePath": "google-workspace/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Workspace\ndescription: Search, read, create, and update Gmail, Drive, Docs, Sheets, Slides, and Tasks through one Workspace capability suite.\ntags:\n - google\n - workspace\n - productivity\n - email\n - files\ncatalog:\n icon: https://workspace.google.com/favicon.ico\nuseWhen: Coordinate work across Google mail, files, documents, spreadsheets, presentations, and tasks.\navoidWhen: Use a focused Google Caplet when the task only needs one Workspace surface.\ngoogleDiscoveryApis:\n gmail:\n name: Gmail\n description: Search, read, label, draft, and send Gmail messages.\n discoveryUrl: https://gmail.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - gmail.users.getProfile\n - gmail.users.labels.list\n - gmail.users.labels.get\n - gmail.users.labels.create\n - gmail.users.labels.patch\n - gmail.users.labels.update\n - gmail.users.messages.list\n - gmail.users.messages.get\n - gmail.users.messages.attachments.get\n - gmail.users.messages.modify\n - gmail.users.messages.send\n - gmail.users.threads.list\n - gmail.users.threads.get\n - gmail.users.threads.modify\n - gmail.users.drafts.list\n - gmail.users.drafts.get\n - gmail.users.drafts.create\n - gmail.users.drafts.update\n - gmail.users.drafts.send\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/gmail.modify\n drive:\n name: Google Drive\n description: Search, read, download, upload, and manage Drive files.\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest\n includeOperations:\n - drive.files.list\n - drive.files.get\n - drive.files.export\n - drive.files.download\n - drive.files.create\n - drive.files.update\n - drive.files.copy\n - drive.files.delete\n - drive.files.generateIds\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n docs:\n name: Google Docs\n description: Read, create, and edit Google Docs documents.\n discoveryUrl: https://docs.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - docs.documents.get\n - docs.documents.create\n - docs.documents.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/documents\n sheets:\n name: Google Sheets\n description: Read, create, and update Google Sheets spreadsheets.\n discoveryUrl: https://sheets.googleapis.com/$discovery/rest?version=v4\n includeOperations:\n - sheets.spreadsheets.get\n - sheets.spreadsheets.getByDataFilter\n - sheets.spreadsheets.create\n - sheets.spreadsheets.batchUpdate\n - sheets.spreadsheets.developerMetadata.search\n - sheets.spreadsheets.values.get\n - sheets.spreadsheets.values.batchGet\n - sheets.spreadsheets.values.batchGetByDataFilter\n - sheets.spreadsheets.values.update\n - sheets.spreadsheets.values.batchUpdate\n - sheets.spreadsheets.values.append\n - sheets.spreadsheets.values.clear\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n slides:\n name: Google Slides\n description: Read, create, preview, and edit Google Slides presentations.\n discoveryUrl: https://slides.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - slides.presentations.get\n - slides.presentations.pages.get\n - slides.presentations.pages.getThumbnail\n - slides.presentations.create\n - slides.presentations.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n tasks:\n name: Google Tasks\n description: Read, create, update, organize, and complete Google Tasks.\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest\n includeOperations:\n - tasks.tasklists.list\n - tasks.tasklists.get\n - tasks.tasklists.insert\n - tasks.tasklists.patch\n - tasks.tasklists.update\n - tasks.tasks.list\n - tasks.tasks.get\n - tasks.tasks.insert\n - tasks.tasks.patch\n - tasks.tasks.update\n - tasks.tasks.move\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/tasks\n---\n\n# Google Workspace\n\nUse this Caplet when an agent needs to coordinate work across Gmail, Drive, Docs, Sheets, Slides, and Tasks from one installable Workspace capability.\n\n## First Workflow\n\n1. Start by identifying which Workspace surface owns the source of truth: mail, file metadata, document content, spreadsheet data, deck content, or task state.\n2. Search or inspect metadata before reading large content bodies.\n3. Use the child runtime handles deliberately: `google-workspace__gmail`, `google-workspace__drive`, `google-workspace__docs`, `google-workspace__sheets`, `google-workspace__slides`, or `google-workspace__tasks`.\n4. Prefer read-only inspection before creating, updating, sending, deleting, clearing, or completing anything.\n5. Confirm file IDs, document IDs, spreadsheet ranges, slide/page element IDs, message/thread IDs, labels, recipients, tasklists, and task IDs before mutating live state.\n\n## Operate Carefully\n\n- Workspace data often contains private, customer, employee, legal, financial, or regulated information. Keep reads narrow and summaries minimal.\n- Child auth scopes are intentionally separate so a private fork can remove surfaces or narrow scopes without changing the suite shape.\n- Drive, Sheets, and Slides use `drive.file`, so they are intended for files the app created or files the user explicitly opens or grants to the app.\n- Gmail write operations can label, draft, modify, or send messages. Draft first and confirm recipients and content before sending.\n- Docs, Sheets, and Slides update operations change live files. Inspect current structure and plan changes before issuing batch updates.\n- Tasks are user-visible workflow state. Do not infer deadlines or completion state from vague conversation.\n- Avoid this Caplet when the task only needs one focused Google surface; the individual Gmail, Drive, Docs, Sheets, Slides, and Tasks Caplets are simpler for single-surface work.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Workspace\ndescription: Search, read, create, and update Gmail, Drive, Docs, Sheets, Slides, and Tasks through one Workspace capability suite.\ntags:\n - google\n - workspace\n - productivity\n - email\n - files\ncatalog:\n icon: https://workspace.google.com/favicon.ico\ngoogleDiscoveryApis:\n gmail:\n name: Gmail\n description: Search, read, label, draft, and send Gmail messages.\n discoveryUrl: https://gmail.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - gmail.users.getProfile\n - gmail.users.labels.list\n - gmail.users.labels.get\n - gmail.users.labels.create\n - gmail.users.labels.patch\n - gmail.users.labels.update\n - gmail.users.messages.list\n - gmail.users.messages.get\n - gmail.users.messages.attachments.get\n - gmail.users.messages.modify\n - gmail.users.messages.send\n - gmail.users.threads.list\n - gmail.users.threads.get\n - gmail.users.threads.modify\n - gmail.users.drafts.list\n - gmail.users.drafts.get\n - gmail.users.drafts.create\n - gmail.users.drafts.update\n - gmail.users.drafts.send\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/gmail.modify\n drive:\n name: Google Drive\n description: Search, read, download, upload, and manage Drive files.\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest\n includeOperations:\n - drive.files.list\n - drive.files.get\n - drive.files.export\n - drive.files.download\n - drive.files.create\n - drive.files.update\n - drive.files.copy\n - drive.files.delete\n - drive.files.generateIds\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n docs:\n name: Google Docs\n description: Read, create, and edit Google Docs documents.\n discoveryUrl: https://docs.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - docs.documents.get\n - docs.documents.create\n - docs.documents.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/documents\n sheets:\n name: Google Sheets\n description: Read, create, and update Google Sheets spreadsheets.\n discoveryUrl: https://sheets.googleapis.com/$discovery/rest?version=v4\n includeOperations:\n - sheets.spreadsheets.get\n - sheets.spreadsheets.getByDataFilter\n - sheets.spreadsheets.create\n - sheets.spreadsheets.batchUpdate\n - sheets.spreadsheets.developerMetadata.search\n - sheets.spreadsheets.values.get\n - sheets.spreadsheets.values.batchGet\n - sheets.spreadsheets.values.batchGetByDataFilter\n - sheets.spreadsheets.values.update\n - sheets.spreadsheets.values.batchUpdate\n - sheets.spreadsheets.values.append\n - sheets.spreadsheets.values.clear\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n slides:\n name: Google Slides\n description: Read, create, preview, and edit Google Slides presentations.\n discoveryUrl: https://slides.googleapis.com/$discovery/rest?version=v1\n includeOperations:\n - slides.presentations.get\n - slides.presentations.pages.get\n - slides.presentations.pages.getThumbnail\n - slides.presentations.create\n - slides.presentations.batchUpdate\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/drive.file\n tasks:\n name: Google Tasks\n description: Read, create, update, organize, and complete Google Tasks.\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest\n includeOperations:\n - tasks.tasklists.list\n - tasks.tasklists.get\n - tasks.tasklists.insert\n - tasks.tasklists.patch\n - tasks.tasklists.update\n - tasks.tasks.list\n - tasks.tasks.get\n - tasks.tasks.insert\n - tasks.tasks.patch\n - tasks.tasks.update\n - tasks.tasks.move\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n scopes:\n - https://www.googleapis.com/auth/tasks\n---\n\n# Google Workspace\n\n## Suite operation\n\nIdentify which Workspace surface owns the source of truth: mail, file metadata, document content, spreadsheet data, deck content, or task state. Search or inspect metadata before reading large content bodies.\n\nThe suite exposes separate child handles: `google-workspace__gmail`, `google-workspace__drive`, `google-workspace__docs`, `google-workspace__sheets`, `google-workspace__slides`, and `google-workspace__tasks`. These names are operator reference; the frontmatter backend map remains the runtime authority.\n\nBefore changing live state, inspect the current resource and confirm the relevant file or document ID, spreadsheet range, slide or page element ID, message or thread ID, label, recipient, tasklist, or task ID.\n\n## Safety and access boundaries\n\n- Workspace data can contain private customer, employee, legal, financial, or regulated information. Keep reads narrow and summaries minimal.\n- Child OAuth scopes are intentionally separate so a private fork can remove surfaces or narrow scopes without changing the suite shape.\n- Drive, Sheets, and Slides use the restricted `drive.file` scope. They cover files the app created and files the user explicitly opens or grants to the app.\n- Gmail operations can label, draft, modify, or send messages. Confirm recipients and content before sending.\n- Docs, Sheets, and Slides batch updates change live files. Inspect current structure and plan changes before updating.\n- Tasks are user-visible workflow state. Do not infer deadlines or completion from ambiguous conversation.\n", "icon": { "type": "url", "url": "https://workspace.google.com/favicon.ico" @@ -1016,8 +996,6 @@ "productivity", "workspace" ], - "intendedTask": "Coordinate work across Google mail, files, documents, spreadsheets, presentations, and tasks.", - "avoidWhen": "Use a focused Google Caplet when the task only needs one Workspace surface.", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1121,7 +1099,7 @@ }, "sourcePath": "linear/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Linear\ndescription: Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state.\ntags:\n - planning\n - linear\n - issues\n - projects\n - triage\ncatalog:\n icon: https://linear.app/favicon.ico\nmcpServer:\n url: https://mcp.linear.app/mcp\n auth:\n type: oauth2\n---\n\n# Linear\n\nUse this Caplet when the agent needs live product planning context from Linear or needs to keep implementation work synchronized with issues, projects, and team workflows.\n\n## First Workflow\n\n1. Search by issue ID, team key, project, cycle, label, or assignee before using broad queries.\n2. Read the current issue, linked project, comments, and workflow state before planning or updating.\n3. Draft issue breakdowns or status comments from concrete implementation evidence.\n4. Write updates only after confirming the target issue and the intended team-visible effect.\n\n## Reference Files\n\n- [Workflows](./workflows.md): recommended lookup, planning, status update, and triage flows.\n\n## Operate Carefully\n\n- Linear issue updates are visible to teammates. Read first, then write deliberately.\n- Keep issue titles and comments concise; use links to detailed implementation artifacts when useful.\n- Avoid broad, noisy searches when a team key, issue ID, project, or label is available.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Linear\ndescription: Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state.\ntags:\n - planning\n - linear\n - issues\n - projects\n - triage\ncatalog:\n icon: https://linear.app/favicon.ico\nmcpServer:\n url: https://mcp.linear.app/mcp\n auth:\n type: oauth2\n---\n\n# Linear\n\n## Lookup and Updates\n\nNarrow lookups by issue ID, team key, project, cycle, label, or assignee avoid noisy results. Before an update, review the current issue, linked project, comments, and workflow state. Issue breakdowns and status comments should reflect concrete implementation evidence, and the target issue and intended team-visible effect should be confirmed before writing.\n\n## Reference\n\n- [Workflows](./workflows.md): lookup, planning, status update, and triage documentation.\n\n## Safe Operation\n\n- Linear issue updates are visible to teammates. Read the current state before writing deliberately.\n- Concise issue titles and comments are easier to follow; detailed implementation artifacts can remain linked.\n- Prefer a team key, issue ID, project, or label over a broad search when one is available.\n", "icon": { "type": "url", "url": "https://linear.app/favicon.ico" @@ -1133,7 +1111,6 @@ "projects", "triage" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1169,7 +1146,7 @@ }, "sourcePath": "lsp/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: LSP\ndescription: Language Server Protocol tools for project-aware code intelligence through language-server-mcp.\ntags:\n - mcp\n - code\n - lsp\n - language-server\n - diagnostics\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, language-server-mcp@latest]\n---\n\n# LSP\n\nUse this Caplet when the agent needs project-aware code intelligence from language servers: definitions, references, diagnostics, hover/type information, symbols, formatting, code actions, or rename edits.\n\n## First Workflow\n\n1. Start with diagnostics, hover/type information, or definition lookup for the exact file and symbol involved.\n2. Use references and symbols to understand blast radius before refactoring.\n3. Request code actions, formatting, or rename edits as proposals first; apply edits only when the target server and file scope are clear.\n4. Cross-check language-server results against tests and source when the answer affects behavior.\n\n## Project Context\n\nProject Binding is required because all useful LSP operations need a trustworthy bound project root for workspace-relative files, language-server startup, diagnostics, and edit containment.\n\nFor file-targeted tools, use paths that resolve inside the bound workspace. Include a `serverId` when more than one language server could handle the file or when applying edits.\n\n## Operate Carefully\n\n`language-server-mcp` defaults to conservative behavior for file modification and process execution:\n\n- Edit-producing tools return edits by default and do not write files unless `apply: true` is passed.\n- `apply: true` requires `serverId` when more than one matching LSP server would produce edits.\n- Applied edits are restricted to the workspace root unless the downstream server is configured with `security.allowExternalFiles: true`.\n- `workspace/executeCommand` is enabled by default, but can be disabled globally or restricted with per-server command allowlists.\n- LSP servers start lazily on first use and stop after an idle timeout by default.\n- Prefer ast-grep or text search for syntax-pattern searches that do not need language-server semantics.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: LSP\ndescription: Language Server Protocol tools for project-aware code intelligence through language-server-mcp.\ntags:\n - mcp\n - code\n - lsp\n - language-server\n - diagnostics\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, language-server-mcp@latest]\n---\n\n# LSP\n\n## Inspection and Edits\n\nDiagnostics, hover or type information, and definition lookup provide a focused starting point for a specific file and symbol. References and symbols show the blast radius of a refactor. Code actions, formatting, and rename operations can be reviewed as proposed edits before application, with source and tests used to validate behavior-sensitive conclusions.\n\n## Project Context\n\nProject Binding is required. LSP operations depend on a trustworthy bound project root for workspace-relative files, language-server startup, diagnostics, and edit containment.\n\nFile-targeted paths must resolve inside the bound workspace. A `serverId` disambiguates files handled by multiple language servers and is required for some applied edits.\n\n## Safe Operation and Lifecycle\n\n`language-server-mcp` uses conservative defaults for file modification and process execution:\n\n- Edit-producing tools return edits without writing unless `apply: true` is supplied.\n- When multiple matching LSP servers could produce edits, `apply: true` also requires `serverId`.\n- Applied edits remain inside the workspace root unless the downstream server enables `security.allowExternalFiles: true`.\n- `workspace/executeCommand` is enabled by default. Operators can disable it globally or restrict it with per-server command allowlists.\n- LSP servers start lazily on first use and stop after an idle timeout by default.\n", "tags": [ "code", "diagnostics", @@ -1177,7 +1154,6 @@ "lsp", "mcp" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "required", @@ -1219,7 +1195,7 @@ }, "sourcePath": "mongodb/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: MongoDB\ndescription: Inspect MongoDB databases, collections, schemas, indexes, queries, and Atlas resources through MongoDB's MCP server with read-only access by default.\ntags:\n - mongodb\n - atlas\n - database\n - queries\n - nosql\ncatalog:\n icon: https://www.mongodb.com/favicon.ico\nsetup:\n verify:\n - label: Check Node.js is available\n command: node\n args:\n - --version\n - label: Check npx is available\n command: npx\n args:\n - --version\nmcpServer:\n command: npx\n args:\n - -y\n - mongodb-mcp-server@latest\n - --readOnly\n env:\n MDB_MCP_CONNECTION_STRING: $vault:MDB_MCP_CONNECTION_STRING\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# MongoDB\n\nUse this Caplet when an agent needs MongoDB database, collection, schema, index, query, sample document, or Atlas operational context.\n\n## First Workflow\n\n1. Start by confirming the cluster, database, collection, Atlas project, environment, and read-only intent before querying data.\n2. Inspect schema samples, indexes, query plans, and collection metadata before recommending query or index changes.\n3. Keep result windows small and project only the fields needed to answer the question.\n4. Summarize proposed writes, index changes, Atlas actions, or migration steps before removing `--readOnly` or changing credentials.\n\n## Operate Carefully\n\n- The catalog entry starts MongoDB MCP with `--readOnly` and a Vault-backed connection string by default.\n- MongoDB data can contain production records, PII, secrets, and customer information. Avoid broad scans and redact sensitive fields in summaries.\n- For Atlas API workflows, configure the upstream server with least-privilege Atlas service account credentials instead of a database connection string.\n- Avoid this Caplet when the task only needs local ODM models, migrations, or application code.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: MongoDB\ndescription: Inspect MongoDB databases, collections, schemas, indexes, queries, and Atlas resources through MongoDB's MCP server with read-only access by default.\ntags:\n - mongodb\n - atlas\n - database\n - queries\n - nosql\ncatalog:\n icon: https://www.mongodb.com/favicon.ico\nsetup:\n verify:\n - label: Check Node.js is available\n command: node\n args:\n - --version\n - label: Check npx is available\n command: npx\n args:\n - --version\nmcpServer:\n command: npx\n args:\n - -y\n - mongodb-mcp-server@latest\n - --readOnly\n env:\n MDB_MCP_CONNECTION_STRING: $vault:MDB_MCP_CONNECTION_STRING\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# MongoDB\n\n## Query Scope\n\nBefore querying data, establish the cluster, database, collection, Atlas project, environment, and read-only intent. Schema samples, indexes, query plans, and collection metadata provide context for proposed query or index changes. Small result windows and projections limited to necessary fields reduce exposure.\n\n## Safe Operation\n\n- The catalog entry starts MongoDB MCP with `--readOnly` and a Vault-backed connection string by default.\n- Removing `--readOnly`, changing credentials, writing data, changing indexes, or performing Atlas actions requires review of the proposed target and effect.\n- MongoDB data can contain production records, PII, secrets, and customer information. Broad scans should be avoided, and sensitive fields should be redacted from summaries.\n- Atlas API access should use least-privilege Atlas service account credentials instead of a database connection string.\n", "icon": { "type": "url", "url": "https://www.mongodb.com/favicon.ico" @@ -1231,7 +1207,6 @@ "nosql", "queries" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1273,7 +1248,7 @@ }, "sourcePath": "neon/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Neon\ndescription: Inspect and manage Neon Postgres organizations, projects, branches, databases, roles, queries, and docs through Neon's hosted MCP server.\ntags:\n - neon\n - postgres\n - database\n - branches\n - sql\ncatalog:\n icon: https://neon.com/apple-touch-icon.png\nmcpServer:\n url: https://mcp.neon.tech/mcp\n auth:\n type: oauth2\n---\n\n# Neon\n\nUse this Caplet when an agent needs live Neon Postgres context for projects, branches, databases, roles, SQL queries, connection details, or Neon documentation.\n\n## First Workflow\n\n1. Start by confirming the Neon organization, project, branch, database, and role before querying state.\n2. Inspect branch, schema, migration, and query context before suggesting SQL or project changes.\n3. Scope the MCP URL after install with `projectId`, `readonly=true`, or `category` query parameters when the task has a narrow target.\n4. Use read-only analysis for query tuning, schema review, and branch discovery before executing SQL.\n5. Summarize the target branch, database, role, SQL, and expected data effect before mutating anything.\n\n## Operate Carefully\n\n- Neon recommends MCP usage for development and testing. Do not connect production databases or PII-bearing projects unless the operator has explicitly accepted that risk.\n- SQL and branch operations can alter data, credentials, costs, or application behavior. Confirm exact targets before writes.\n- Keep connection strings and role credentials out of summaries.\n- Avoid this Caplet when the task only needs local migration files, ORMs, or application code.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Neon\ndescription: Inspect and manage Neon Postgres organizations, projects, branches, databases, roles, queries, and docs through Neon's hosted MCP server.\ntags:\n - neon\n - postgres\n - database\n - branches\n - sql\ncatalog:\n icon: https://neon.com/apple-touch-icon.png\nmcpServer:\n url: https://mcp.neon.tech/mcp\n auth:\n type: oauth2\n---\n\n# Neon\n\n## Project and Query Scope\n\nEstablish the Neon organization, project, branch, database, and role before inspecting state. Branch, schema, migration, and query context should be reviewed before SQL or project changes.\n\nAfter installation, the MCP URL can be scoped with `projectId`, `readonly=true`, or `category` query parameters. Read-only analysis is appropriate for query tuning, schema review, and branch discovery.\n\n## Safe Operation\n\n- Neon recommends MCP usage for development and testing. Production databases or PII-bearing projects should be connected only after the operator explicitly accepts the risk.\n- SQL and branch operations can alter data, credentials, costs, or application behavior. The target branch, database, role, SQL, and expected data effect require confirmation before mutation.\n- Connection strings and role credentials must not appear in summaries.\n", "icon": { "type": "url", "url": "https://neon.com/apple-touch-icon.png" @@ -1285,7 +1260,6 @@ "postgres", "sql" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1321,7 +1295,7 @@ }, "sourcePath": "notion/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Notion\ndescription: Search, fetch, create, update, move, duplicate, and query Notion workspace pages, databases, views, and connected content through Notion's hosted MCP server.\ntags:\n - notion\n - docs\n - knowledge\n - tasks\n - workspace\ncatalog:\n icon: https://www.notion.so/images/favicon.ico\nmcpServer:\n url: https://mcp.notion.com/mcp\n auth:\n type: oauth2\n---\n\n# Notion\n\nUse this Caplet when an agent needs live Notion workspace context for pages, databases, data sources, views, tasks, docs, search, or workspace knowledge.\n\n## First Workflow\n\n1. Start with exact page URLs, database IDs, data source IDs, teamspace names, or search terms instead of broad workspace scans.\n2. Fetch the target page, database, view, or `self` context before creating or updating content.\n3. Inspect database properties, templates, and view filters before changing page properties, views, or data sources.\n4. Confirm the parent page, database, move target, duplicate target, and visible workspace effect before writes.\n\n## Operate Carefully\n\n- Notion MCP can read and write with the connected user's workspace access. Enable human confirmation for workflows that create, update, move, or duplicate content.\n- Treat search results and connected workspace content as potentially sensitive and vulnerable to prompt injection.\n- Keep private page content, customer data, and internal planning details out of unnecessary summaries.\n- Avoid this Caplet when the task only needs local Markdown files or static Notion API documentation.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Notion\ndescription: Search, fetch, create, update, move, duplicate, and query Notion workspace pages, databases, views, and connected content through Notion's hosted MCP server.\ntags:\n - notion\n - docs\n - knowledge\n - tasks\n - workspace\ncatalog:\n icon: https://www.notion.so/images/favicon.ico\nmcpServer:\n url: https://mcp.notion.com/mcp\n auth:\n type: oauth2\n---\n\n# Notion\n\n## Targeting and Inspection\n\nExact page URLs, database IDs, data source IDs, teamspace names, or focused search terms reduce unnecessary workspace scans. The target page, database, view, or `self` context should be fetched before content is created or updated. Database properties, templates, and view filters should be inspected before changing page properties, views, or data sources.\n\n## Safe Operation\n\n- Notion MCP reads and writes with the connected user's workspace access. Workflows that create, update, move, or duplicate content should require human confirmation.\n- Before a write, confirm the parent page, database, move or duplicate target, and visible workspace effect.\n- Search results and connected workspace content may be sensitive and may contain prompt-injection attempts.\n- Private page content, customer data, and internal planning details should be excluded from unnecessary summaries.\n", "icon": { "type": "url", "url": "https://www.notion.so/images/favicon.ico" @@ -1333,7 +1307,6 @@ "tasks", "workspace" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1369,7 +1342,7 @@ }, "sourcePath": "npm/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: npm Registry\ndescription: Query package metadata, versions, dist-tags, and search results from the public npm registry.\ntags:\n - openapi\n - npm\n - packages\n - code\ncatalog:\n icon: https://raw.githubusercontent.com/npm/logos/master/npm%20logo/npm-logo-red.svg\nopenapiEndpoint:\n specUrl: https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml\n auth:\n type: none\n---\n\n# npm Registry\n\nUse this Caplet when the agent needs public npm package facts before choosing dependencies, checking versions, comparing package health, or validating registry metadata.\n\n## First Workflow\n\n1. Use `get_dist_tags` or `get_package_version` when the package name and version are known.\n2. Use `get_package` when you need release history, maintainers, versions, and package metadata together.\n3. Use `search_packages` for discovery, then inspect exact packages before recommending one.\n4. Pair package facts with local lockfile and test evidence before changing dependencies.\n\n## Operate Carefully\n\n- Registry metadata can be stale relative to the local lockfile. Check the project dependency state before editing.\n- Package search ranking is not a safety signal. Inspect maintainers, versions, and vulnerability context before suggesting adoption.\n- Use OSV for vulnerability lookups; this Caplet provides package metadata, not a complete security review.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: npm Registry\ndescription: Query package metadata, versions, dist-tags, and search results from the public npm registry.\ntags:\n - openapi\n - npm\n - packages\n - code\ncatalog:\n icon: https://raw.githubusercontent.com/npm/logos/master/npm%20logo/npm-logo-red.svg\nopenapiEndpoint:\n specUrl: https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml\n auth:\n type: none\n---\n\n# npm Registry\n\n## Package Lookups\n\n- `get_dist_tags` and `get_package_version` provide focused lookups when the package name and version are known.\n- `get_package` returns release history, maintainers, versions, and package metadata together.\n- `search_packages` supports discovery, but candidate packages should be inspected directly before selection.\n- Registry facts should be cross-checked with the local lockfile and test evidence before dependency changes.\n\n## Limits and Safety\n\n- Registry metadata can be stale relative to the local lockfile, so the project's actual dependency state remains authoritative.\n- Package search ranking is not a safety signal. Maintainers, versions, and vulnerability context require separate inspection before adoption.\n- This Caplet provides package metadata, not a complete security review.\n", "icon": { "type": "url", "url": "https://raw.githubusercontent.com/npm/logos/master/npm%20logo/npm-logo-red.svg" @@ -1380,7 +1353,6 @@ "openapi", "packages" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1416,7 +1388,7 @@ }, "sourcePath": "osv/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: OSV Vulnerabilities\ndescription: Query OSV.dev vulnerability data through explicit HTTP actions.\ntags:\n - security\n - vulnerabilities\n - http\n - code\ncatalog:\n icon: https://osv.dev/favicon.ico\nhttpApi:\n baseUrl: https://api.osv.dev\n auth:\n type: none\n actions:\n query_package_version:\n description: Read-only OSV query for vulnerabilities affecting one package ecosystem/name/version tuple.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n name:\n type: string\n description: Package name, such as lodash, requests, or openssl.\n ecosystem:\n type: string\n description: OSV ecosystem, such as npm, PyPI, Maven, Go, crates.io, Packagist, RubyGems, NuGet, Debian, or Alpine.\n version:\n type: string\n description: Package version to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - name\n - ecosystem\n - version\n jsonBody:\n package:\n name: $input.name\n ecosystem: $input.ecosystem\n version: $input.version\n page_token: $input.page_token\n query_purl:\n description: Read-only OSV query for vulnerabilities affecting one package URL (purl).\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n purl:\n type: string\n description: Package URL, such as pkg:npm/lodash@4.17.20 or pkg:pypi/requests@2.19.0.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - purl\n jsonBody:\n package:\n purl: $input.purl\n page_token: $input.page_token\n query_commit:\n description: Read-only OSV query for vulnerabilities associated with one source commit hash.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n commit:\n type: string\n description: Source commit hash to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - commit\n jsonBody:\n commit: $input.commit\n page_token: $input.page_token\n query_batch:\n description: Read-only OSV batch query for multiple package, purl, commit, or version requests.\n method: POST\n path: /v1/querybatch\n inputSchema:\n type: object\n properties:\n queries:\n type: array\n description: OSV query objects accepted by /v1/querybatch.\n items:\n type: object\n additionalProperties: true\n required:\n - queries\n jsonBody:\n queries: $input.queries\n get_vulnerability:\n description: Read-only OSV lookup for one vulnerability record by OSV, CVE, or GHSA identifier.\n method: GET\n path: /v1/vulns/{id}\n inputSchema:\n type: object\n properties:\n id:\n type: string\n description: Vulnerability identifier, such as OSV-2020-744, CVE-2021-44228, or GHSA-jfh8-c2jp-5v3q.\n required:\n - id\n---\n\n# OSV Vulnerabilities\n\nUse this Caplet when the agent needs known vulnerability data for package versions, package URLs, source commits, or vulnerability identifiers.\n\n## First Workflow\n\n1. Prefer exact ecosystem, package name, and version when checking a dependency.\n2. Use purls when dependency tooling already produced normalized package URLs.\n3. Batch related dependency checks instead of issuing many single-package calls.\n4. Fetch the vulnerability record when an OSV, CVE, or GHSA ID needs explanation or remediation context.\n\n## Operate Carefully\n\n- OSV results are read-only and public, but absence of a result is not proof that a dependency is safe.\n- Match ecosystem names exactly, such as `npm`, `PyPI`, `Maven`, `Go`, `crates.io`, `Packagist`, `RubyGems`, `NuGet`, `Debian`, `Alpine`, or `OSS-Fuzz`.\n- Use package registry Caplets for release metadata and local project tooling for the actual installed version.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: OSV Vulnerabilities\ndescription: Query OSV.dev vulnerability data through explicit HTTP actions.\ntags:\n - security\n - vulnerabilities\n - http\n - code\ncatalog:\n icon: https://osv.dev/favicon.ico\nhttpApi:\n baseUrl: https://api.osv.dev\n auth:\n type: none\n actions:\n query_package_version:\n description: Read-only OSV query for vulnerabilities affecting one package ecosystem/name/version tuple.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n name:\n type: string\n description: Package name, such as lodash, requests, or openssl.\n ecosystem:\n type: string\n description: OSV ecosystem, such as npm, PyPI, Maven, Go, crates.io, Packagist, RubyGems, NuGet, Debian, or Alpine.\n version:\n type: string\n description: Package version to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - name\n - ecosystem\n - version\n jsonBody:\n package:\n name: $input.name\n ecosystem: $input.ecosystem\n version: $input.version\n page_token: $input.page_token\n query_purl:\n description: Read-only OSV query for vulnerabilities affecting one package URL (purl).\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n purl:\n type: string\n description: Package URL, such as pkg:npm/lodash@4.17.20 or pkg:pypi/requests@2.19.0.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - purl\n jsonBody:\n package:\n purl: $input.purl\n page_token: $input.page_token\n query_commit:\n description: Read-only OSV query for vulnerabilities associated with one source commit hash.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n commit:\n type: string\n description: Source commit hash to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - commit\n jsonBody:\n commit: $input.commit\n page_token: $input.page_token\n query_batch:\n description: Read-only OSV batch query for multiple package, purl, commit, or version requests.\n method: POST\n path: /v1/querybatch\n inputSchema:\n type: object\n properties:\n queries:\n type: array\n description: OSV query objects accepted by /v1/querybatch.\n items:\n type: object\n additionalProperties: true\n required:\n - queries\n jsonBody:\n queries: $input.queries\n get_vulnerability:\n description: Read-only OSV lookup for one vulnerability record by OSV, CVE, or GHSA identifier.\n method: GET\n path: /v1/vulns/{id}\n inputSchema:\n type: object\n properties:\n id:\n type: string\n description: Vulnerability identifier, such as OSV-2020-744, CVE-2021-44228, or GHSA-jfh8-c2jp-5v3q.\n required:\n - id\n---\n\n# OSV Vulnerabilities\n\n## Query Reference\n\n- An exact ecosystem, package name, and version provides the most specific dependency check.\n- Package URLs (purls) are suitable when dependency tooling has already produced normalized identifiers.\n- Related dependency checks can be submitted as a batch.\n- An OSV, CVE, or GHSA identifier can be used to retrieve the full vulnerability record and remediation context.\n\n## Limits\n\n- OSV results are read-only and public, but absence of a result is not proof that a dependency is safe.\n- Ecosystem names must match OSV exactly, including `npm`, `PyPI`, `Maven`, `Go`, `crates.io`, `Packagist`, `RubyGems`, `NuGet`, `Debian`, `Alpine`, and `OSS-Fuzz`.\n- The actual installed version should be verified with local project tooling.\n", "icon": { "type": "url", "url": "https://osv.dev/favicon.ico" @@ -1427,7 +1399,6 @@ "security", "vulnerabilities" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1463,7 +1434,7 @@ }, "sourcePath": "pagerduty/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PagerDuty\ndescription: Inspect PagerDuty incidents, services, schedules, escalation policies, event orchestrations, on-call context, and related operational state through PagerDuty's MCP server.\ntags:\n - pagerduty\n - incidents\n - on-call\n - services\n - operations\ncatalog:\n icon: https://www.pagerduty.com/favicon.ico\nsetup:\n verify:\n - label: Check uvx is available\n command: uvx\n args:\n - --version\nmcpServer:\n command: uvx\n args:\n - pagerduty-mcp\n env:\n PAGERDUTY_USER_API_KEY: $vault:PAGERDUTY_USER_API_KEY\n PAGERDUTY_API_HOST: https://api.pagerduty.com\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# PagerDuty\n\nUse this Caplet when an agent needs PagerDuty incident, service, schedule, escalation policy, event orchestration, on-call, or operational response context.\n\n## First Workflow\n\n1. Start by confirming the PagerDuty account, API host, incident ID, service, team, schedule, escalation policy, user, or time window.\n2. Inspect current incident state, responders, escalation policy, timeline, notes, alerts, and related service context before taking action.\n3. Use schedule and on-call lookups before proposing handoffs, overrides, or escalation changes.\n4. Summarize the target incident, service, user, schedule, and expected responder effect before mutating anything.\n\n## Operate Carefully\n\n- PagerDuty changes can page people, alter incident response, affect escalation, or change operational accountability. Prefer read-only inspection before writes.\n- The default catalog entry does not pass the upstream `--enable-write-tools` flag. Add it only when write operations are intentionally needed.\n- For EU accounts, update `PAGERDUTY_API_HOST` to the EU API host before starting the server.\n- Avoid this Caplet when the task only needs local runbooks or postmortem files.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PagerDuty\ndescription: Inspect PagerDuty incidents, services, schedules, escalation policies, event orchestrations, on-call context, and related operational state through PagerDuty's MCP server.\ntags:\n - pagerduty\n - incidents\n - on-call\n - services\n - operations\ncatalog:\n icon: https://www.pagerduty.com/favicon.ico\nsetup:\n verify:\n - label: Check uvx is available\n command: uvx\n args:\n - --version\nmcpServer:\n command: uvx\n args:\n - pagerduty-mcp\n env:\n PAGERDUTY_USER_API_KEY: $vault:PAGERDUTY_USER_API_KEY\n PAGERDUTY_API_HOST: https://api.pagerduty.com\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# PagerDuty\n\n## Operational Scope\n\nEstablish the PagerDuty account and API host together with the exact incident ID, service, team, schedule, escalation policy, user, or time window. Current incident state, responders, escalation policy, timeline, notes, alerts, and service context should be inspected before action. Schedule and on-call lookups provide context for handoffs, overrides, or escalation changes.\n\n## Safe Operation and Setup\n\n- PagerDuty changes can page people, alter incident response, affect escalation, or change operational accountability. Read-only inspection should precede writes, and the target incident, service, user, schedule, and expected responder effect require confirmation.\n- The default catalog entry does not pass the upstream `--enable-write-tools` flag. Operators should add it only when write operations are intentionally needed.\n- EU accounts require `PAGERDUTY_API_HOST` to be set to the EU API host before server startup.\n", "icon": { "type": "url", "url": "https://www.pagerduty.com/favicon.ico" @@ -1475,7 +1446,6 @@ "pagerduty", "services" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1517,7 +1487,7 @@ }, "sourcePath": "playwright/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Playwright\ndescription: Drive a browser through Playwright MCP for frontend testing, inspection, and automation workflows.\ntags:\n - browser\n - testing\n - mcp\n - frontend\ncatalog:\n icon: https://playwright.dev/img/playwright-logo.svg\nsetup:\n commands:\n - label: Install Playwright MCP\n command: npm\n args: [\"install\", \"-g\", \"@playwright/mcp@latest\"]\n timeoutMs: 120000\n maxOutputBytes: 200000\n - label: Install Chromium browser\n command: npx\n args: [\"-y\", \"playwright@latest\", \"install\", \"chromium\"]\n timeoutMs: 180000\n maxOutputBytes: 200000\n verify:\n - label: Check Playwright MCP\n command: playwright-mcp\n args: [\"--help\"]\n timeoutMs: 10000\n maxOutputBytes: 20000\nmcpServer:\n command: playwright-mcp\n args:\n - --headless\n---\n\n# Playwright\n\nUse this Caplet when the agent needs an isolated browser automation surface for frontend debugging, accessibility checks, visual inspection, or end-to-end testing workflows.\n\n## First Workflow\n\n1. Open the target local or preview URL and wait for the page to settle.\n2. Inspect visible state, accessibility tree, console errors, network behavior, or screenshots before acting.\n3. Reproduce the smallest user flow that proves or disproves the issue.\n4. Capture concise evidence for the code change or review.\n\n## Operate Carefully\n\n- This Caplet runs a browser runtime. Keep tests scoped to the target app and avoid unrelated browsing.\n- Prefer this over Browser Use for isolated test flows; use Browser Use only when the user's real signed-in browser context matters.\n- If browser setup is missing, treat the Caplet as unavailable until setup verification succeeds rather than improvising shell installs.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Playwright\ndescription: Drive a browser through Playwright MCP for frontend testing, inspection, and automation workflows.\ntags:\n - browser\n - testing\n - mcp\n - frontend\ncatalog:\n icon: https://playwright.dev/img/playwright-logo.svg\nsetup:\n commands:\n - label: Install Playwright MCP\n command: npm\n args: [\"install\", \"-g\", \"@playwright/mcp@latest\"]\n timeoutMs: 120000\n maxOutputBytes: 200000\n - label: Install Chromium browser\n command: npx\n args: [\"-y\", \"playwright@latest\", \"install\", \"chromium\"]\n timeoutMs: 180000\n maxOutputBytes: 200000\n verify:\n - label: Check Playwright MCP\n command: playwright-mcp\n args: [\"--help\"]\n timeoutMs: 10000\n maxOutputBytes: 20000\nmcpServer:\n command: playwright-mcp\n args:\n - --headless\n---\n\n# Playwright\n\n## Test Workflow\n\nA local or preview URL is the starting point for an isolated browser session. Visible state, the accessibility tree, console errors, network behavior, and screenshots provide evidence before interaction. The smallest reproducible user flow should prove or disprove the issue, with concise evidence retained for the resulting change or review.\n\n## Safe Operation\n\n- This Caplet runs an isolated browser runtime. Tests should remain scoped to the target application and avoid unrelated browsing.\n- If browser setup is missing, the Caplet remains unavailable until setup verification succeeds; operators should use the declared setup rather than improvising shell installation.\n", "icon": { "type": "url", "url": "https://playwright.dev/img/playwright-logo.svg" @@ -1528,7 +1498,6 @@ "mcp", "testing" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1570,7 +1539,7 @@ }, "sourcePath": "posthog/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PostHog\ndescription: Inspect PostHog analytics, feature flags, experiments, session replay, and product telemetry through PostHog's hosted MCP server.\ntags:\n - analytics\n - posthog\n - product\n - feature-flags\ncatalog:\n icon: https://posthog.com/icons/icon-192x192.png\nmcpServer:\n url: https://mcp.posthog.com/mcp\n auth:\n type: oauth2\n---\n\n# PostHog\n\nUse this Caplet when an agent needs product analytics or feature-flag context from PostHog before planning, debugging, or validating a change.\n\n## First Workflow\n\n1. Start from a concrete product question, feature flag, experiment, event, or time window.\n2. Read trends, funnels, retention, or HogQL results before drawing conclusions.\n3. Inspect feature flags, experiments, and rollout state before changing code that depends on them.\n4. Use session replay or event details only for the minimal debugging context needed.\n\n## Operate Carefully\n\nPostHog MCP includes mutating tools for flags, insights, dashboards, and other project state. Prefer read-only inspection first, review planned mutations, and keep OAuth access scoped to the PostHog organization and project you intend to expose.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PostHog\ndescription: Inspect PostHog analytics, feature flags, experiments, session replay, and product telemetry through PostHog's hosted MCP server.\ntags:\n - analytics\n - posthog\n - product\n - feature-flags\ncatalog:\n icon: https://posthog.com/icons/icon-192x192.png\nmcpServer:\n url: https://mcp.posthog.com/mcp\n auth:\n type: oauth2\n---\n\n# PostHog\n\n## Analysis Scope\n\nA concrete product question, feature flag, experiment, event, or time window keeps analysis bounded. Trends, funnels, retention, and HogQL results provide evidence before conclusions are drawn. Feature flags, experiments, and rollout state should be inspected before dependent code changes. Session replay and event details should be limited to the debugging context needed.\n\n## Safe Operation\n\nPostHog MCP includes mutating tools for flags, insights, dashboards, and other project state. Read-only inspection should precede mutation, planned changes should be reviewed, and OAuth access should remain scoped to the intended PostHog organization and project.\n", "icon": { "type": "url", "url": "https://posthog.com/icons/icon-192x192.png" @@ -1581,7 +1550,6 @@ "posthog", "product" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1617,7 +1585,7 @@ }, "sourcePath": "pypi/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PyPI Registry\ndescription: Query Python package metadata, releases, files, vulnerabilities, and Simple API project details from PyPI.\ntags:\n - openapi\n - pypi\n - python\n - packages\n - code\ncatalog:\n icon: https://pypi.org/static/images/logo-small.8998e9d1.svg\nopenapiEndpoint:\n specPath: ./pypi.openapi.yaml\n auth:\n type: none\n---\n\n# PyPI Registry\n\nUse this Caplet when the agent needs public PyPI package facts before choosing dependencies, checking versions, inspecting release files, or validating package metadata.\n\n## First Workflow\n\n1. Use `get_project` for current project metadata, release history, URLs, and vulnerability records included by PyPI.\n2. Use `get_release` when an exact version matters.\n3. Use `get_simple_project` when dependency tooling needs Simple API file links and hashes.\n4. Pair registry facts with the local lockfile, Python environment, and tests before changing dependencies.\n\n## Operate Carefully\n\n- PyPI metadata is read-only but not a full supply-chain assessment.\n- Use OSV for cross-ecosystem vulnerability checks and local tooling for the actually installed version.\n- Deprecated XML-RPC APIs are intentionally excluded; use these JSON endpoints for agent workflows.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PyPI Registry\ndescription: Query Python package metadata, releases, files, vulnerabilities, and Simple API project details from PyPI.\ntags:\n - openapi\n - pypi\n - python\n - packages\n - code\ncatalog:\n icon: https://pypi.org/static/images/logo-small.8998e9d1.svg\nopenapiEndpoint:\n specPath: ./pypi.openapi.yaml\n auth:\n type: none\n---\n\n# PyPI Registry\n\n## Package lookup\n\n- `get_project` returns current project metadata, release history, project URLs, and vulnerability records included by PyPI.\n- `get_release` provides details for an exact project version.\n- `get_simple_project` provides Simple API file links and hashes for dependency tooling.\n\nCross-check registry results against the repository lockfile, the active Python environment, and tests before changing dependencies.\n\n## Limits\n\nPyPI metadata is read-only, can become stale, and is not a complete supply-chain assessment. Verify the version that is actually installed locally. Deprecated XML-RPC APIs are intentionally excluded; the supported operations use PyPI's JSON and Simple API endpoints.\n", "icon": { "type": "url", "url": "https://pypi.org/static/images/logo-small.8998e9d1.svg" @@ -1629,7 +1597,6 @@ "pypi", "python" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1665,7 +1632,7 @@ }, "sourcePath": "repo-cli/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Repository CLI\ndescription: Inspect and run common local repository workflows through curated CLI tools.\ntags:\n - cli\n - code\ncatalog:\n icon: https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png\nprojectBinding:\n required: true\ncliTools:\n actions:\n git_status:\n description: Show concise Git working tree status.\n command: git\n args:\n - status\n - --short\n annotations:\n readOnlyHint: true\n git_current_branch:\n description: Print the current Git branch name.\n command: git\n args:\n - branch\n - --show-current\n annotations:\n readOnlyHint: true\n package_test:\n description: Run the repository test script with pnpm.\n command: pnpm\n args:\n - run\n - test\n timeoutMs: 120000\n---\n\n# Repository CLI\n\nUse this Caplet to expose a small, typed set of local repository commands without giving an agent arbitrary shell access.\n\nProject Binding is required because every command is meant to run against the attached repository. The bound root prevents the agent from accidentally treating an unrelated working directory as the target project.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Repository CLI\ndescription: Inspect and run common local repository workflows through curated CLI tools.\ntags:\n - cli\n - code\ncatalog:\n icon: https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png\nprojectBinding:\n required: true\ncliTools:\n actions:\n git_status:\n description: Show concise Git working tree status.\n command: git\n args:\n - status\n - --short\n annotations:\n readOnlyHint: true\n git_current_branch:\n description: Print the current Git branch name.\n command: git\n args:\n - branch\n - --show-current\n annotations:\n readOnlyHint: true\n package_test:\n description: Run the repository test script with pnpm.\n command: pnpm\n args:\n - run\n - test\n timeoutMs: 120000\n---\n\n# Repository CLI\n\n## Prerequisites\n\nRepository CLI requires Project Binding. Every command runs from the bound repository root so that an unrelated working directory cannot become the target project.\n\nThe bound environment must provide Git and pnpm. The repository must also define the pnpm `test` script used by `package_test`.\n\n## Available commands\n\n- `git_status` reports the concise working-tree status.\n- `git_current_branch` prints the current branch name.\n- `package_test` runs the repository's pnpm test script with a two-minute timeout.\n\n## Safety boundary\n\nThis Caplet exposes only the commands and fixed arguments declared in frontmatter; it is not arbitrary shell access. The Git commands are read-only. The test command may execute repository-defined scripts, so operators should review the bound project's test configuration and account for any services, credentials, or files those tests use.\n\nTo add or change a command, update the curated `cliTools.actions` declaration and keep its arguments, timeout, and read-only annotation explicit.\n\n## Troubleshooting\n\nIf a command targets the wrong directory, correct the Project Binding rather than adding path arguments. If a command is unavailable, verify the required executable and repository script in the bound environment.\n", "icon": { "type": "url", "url": "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" @@ -1674,7 +1641,6 @@ "cli", "code" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "ready", "projectBindingReadiness": "required", @@ -1722,7 +1688,7 @@ }, "sourcePath": "sentry/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sentry\ndescription: Inspect Sentry issues, events, traces, releases, and AI debugging context through Sentry's hosted MCP server.\ntags:\n - observability\n - sentry\n - errors\n - tracing\ncatalog:\n icon: https://sentry.io/favicon.ico\nmcpServer:\n url: https://mcp.sentry.dev/mcp\n auth:\n type: oauth2\n---\n\n# Sentry\n\nUse this Caplet when an agent needs live Sentry context while debugging production errors, investigating traces, or checking release health.\n\n## First Workflow\n\n1. Narrow by organization, project, environment, release, issue, trace, or time window before querying.\n2. Inspect issue frequency, recent events, stack traces, tags, breadcrumbs, and suspect commits before proposing fixes.\n3. Correlate deploys or releases with new errors when a regression is suspected.\n4. Bring back the smallest evidence set needed to guide code changes or triage.\n\n## Operate Carefully\n\nSentry data can contain user, request, and environment details. Ask for narrow projects and time windows, summarize only the needed debugging context, and review any mutating tool calls before applying changes to Sentry state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sentry\ndescription: Inspect Sentry issues, events, traces, releases, and AI debugging context through Sentry's hosted MCP server.\ntags:\n - observability\n - sentry\n - errors\n - tracing\ncatalog:\n icon: https://sentry.io/favicon.ico\nmcpServer:\n url: https://mcp.sentry.dev/mcp\n auth:\n type: oauth2\n---\n\n# Sentry\n\n## Investigation scope\n\nIdentify the organization, project, environment, release, issue, trace, and time window that bound the investigation. Narrow targets reduce noise and limit exposure of event data.\n\n## Evidence and diagnosis\n\nInspect issue frequency, recent events, stack traces, tags, breadcrumbs, and suspect commits before deciding on a fix. When a regression is suspected, correlate new errors with deployments or releases. Retain only the smallest evidence set needed for code changes or incident triage.\n\n## Safe operation\n\nSentry events can contain user, request, and environment details. Keep project and time-window access narrow, and summarize relevant debugging signals without reproducing unnecessary sensitive data. Review mutating operations and their target state before applying changes in Sentry.\n", "icon": { "type": "url", "url": "https://sentry.io/favicon.ico" @@ -1733,7 +1699,6 @@ "sentry", "tracing" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1769,7 +1734,7 @@ }, "sourcePath": "sourcegraph/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sourcegraph\ndescription: Search and inspect code across Sourcegraph using its MCP endpoint for repository-aware coding workflows.\ntags:\n - sourcegraph\n - code-search\n - mcp\ncatalog:\n icon: https://sourcegraph.com/.assets/img/sourcegraph-mark.svg\nmcpServer:\n url: https://sourcegraph.com/.api/mcp\n auth:\n type: oauth2\n---\n\n# Sourcegraph\n\nUse this Caplet when the agent needs broad code search, repository navigation, or cross-repository context from Sourcegraph.\n\n## First Workflow\n\n1. Start with a precise symbol, file path, package name, migration pattern, or repository filter.\n2. Inspect representative matches before generalizing across repositories.\n3. Use references and examples to guide local implementation, then verify against the target repo.\n4. Bring back code-search evidence with enough source context for review or planning.\n\n## Operate Carefully\n\n- Sourcegraph answers are only as current as the indexed repositories.\n- Do not use broad search as a substitute for reading the local repository when it is available.\n- For self-managed Sourcegraph, make sure the runtime is pointed at the intended host before using private code search.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sourcegraph\ndescription: Search and inspect code across Sourcegraph using its MCP endpoint for repository-aware coding workflows.\ntags:\n - sourcegraph\n - code-search\n - mcp\ncatalog:\n icon: https://sourcegraph.com/.assets/img/sourcegraph-mark.svg\nmcpServer:\n url: https://sourcegraph.com/.api/mcp\n auth:\n type: oauth2\n---\n\n# Sourcegraph\n\n## Search scope\n\nBuild queries around a precise symbol, file path, package name, migration pattern, or repository filter. Inspect representative matches before drawing conclusions across repositories, and retain enough surrounding source context for review.\n\n## Using search evidence\n\nSourcegraph references and examples can inform local implementation or planning, but they should be verified against the target repository. Results are only as current as the indexed revision.\n\n## Host and privacy boundary\n\nFor self-managed Sourcegraph, configure the runtime for the intended host before searching private code. Confirm that the connected instance and repository scope are appropriate for the code being investigated.\n", "icon": { "type": "url", "url": "https://sourcegraph.com/.assets/img/sourcegraph-mark.svg" @@ -1779,7 +1744,6 @@ "mcp", "sourcegraph" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1815,7 +1779,7 @@ }, "sourcePath": "stripe/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Stripe\ndescription: Inspect and manage Stripe accounts, API resources, documentation, reports, refunds, and payments through Stripe's hosted MCP server.\ntags:\n - stripe\n - payments\n - billing\n - finance\n - api\ncatalog:\n icon: https://stripe.com/favicon.ico\nmcpServer:\n url: https://mcp.stripe.com\n auth:\n type: oauth2\n---\n\n# Stripe\n\nUse this Caplet when an agent needs live Stripe context for payments, customers, subscriptions, invoices, refunds, reports, account settings, API behavior, or Stripe documentation.\n\n## First Workflow\n\n1. Start in the intended Stripe mode, account, and workspace context before reading or changing resources.\n2. Search documentation and API resource details before calling write operations or proposing integration code.\n3. Inspect exact resource IDs, amounts, currency, livemode status, and event history before acting.\n4. Summarize the customer, payment, invoice, subscription, refund, or report target before mutating anything.\n\n## Operate Carefully\n\n- Stripe operations can affect money movement, customer billing, disputes, accounting, and compliance. Prefer read-only inspection before writes.\n- Confirm test mode versus live mode explicitly before refunding, canceling, updating subscriptions, or changing account configuration.\n- Do not expose payment method details, customer PII, API keys, webhook secrets, or restricted report data in summaries.\n- Avoid this Caplet when the task only needs local SDK usage or static API documentation and no live account context.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Stripe\ndescription: Inspect and manage Stripe accounts, API resources, documentation, reports, refunds, and payments through Stripe's hosted MCP server.\ntags:\n - stripe\n - payments\n - billing\n - finance\n - api\ncatalog:\n icon: https://stripe.com/favicon.ico\nmcpServer:\n url: https://mcp.stripe.com\n auth:\n type: oauth2\n---\n\n# Stripe\n\n## Prerequisites\n\nConfirm the intended Stripe mode, account, and workspace before reading or changing resources. Resource checks should include exact IDs, amounts, currency, `livemode` status, and relevant event history.\n\n## Safe operation\n\nInspect Stripe documentation and current API resource state before writes or integration changes. Before mutating a customer, payment, invoice, subscription, refund, report, or account setting, review the exact target and expected result.\n\nStripe operations can affect money movement, customer billing, disputes, accounting, and compliance. Prefer read-only inspection, and explicitly confirm test mode versus live mode before refunds, cancellations, subscription updates, or account-configuration changes.\n\n## Sensitive data\n\nDo not reproduce payment method details, customer PII, API keys, webhook secrets, or restricted report data in logs or summaries.\n", "icon": { "type": "url", "url": "https://stripe.com/favicon.ico" @@ -1827,7 +1791,6 @@ "payments", "stripe" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1863,7 +1826,7 @@ }, "sourcePath": "supabase/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Supabase\ndescription: Inspect and manage Supabase projects, databases, schemas, branches, storage, edge functions, and docs through Supabase's hosted MCP server.\ntags:\n - supabase\n - postgres\n - database\n - backend\n - storage\ncatalog:\n icon: https://supabase.com/favicon/favicon-32x32.png\nmcpServer:\n url: https://mcp.supabase.com/mcp\n auth:\n type: oauth2\n---\n\n# Supabase\n\nUse this Caplet when an agent needs Supabase project, database, schema, branch, storage, edge function, auth, or documentation context.\n\n## First Workflow\n\n1. Start by confirming the Supabase organization, project reference, branch, and environment before querying project state.\n2. Prefer read-only discovery of schemas, tables, policies, migrations, functions, and storage buckets before making changes.\n3. Scope high-risk work to a specific project with the `project_ref` query parameter after install when possible.\n4. Use `read_only=true` or feature-group filtering on the MCP URL for investigation-only workflows.\n5. Summarize intended SQL, policy, migration, storage, or function changes before executing them.\n\n## Operate Carefully\n\n- Supabase's own guidance treats MCP access as best suited for development and testing. Do not connect production projects unless the operator has explicitly accepted the risk.\n- Database and auth policy changes can expose data or break applications. Review SQL, RLS policy effects, generated migrations, and branch targets carefully.\n- Avoid handling PII or secrets through agent-visible prompts and logs.\n- Avoid this Caplet when the task only needs local migration files or application code without live Supabase state.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Supabase\ndescription: Inspect and manage Supabase projects, databases, schemas, branches, storage, edge functions, and docs through Supabase's hosted MCP server.\ntags:\n - supabase\n - postgres\n - database\n - backend\n - storage\ncatalog:\n icon: https://supabase.com/favicon/favicon-32x32.png\nmcpServer:\n url: https://mcp.supabase.com/mcp\n auth:\n type: oauth2\n---\n\n# Supabase\n\n## Prerequisites and scope\n\nConfirm the Supabase organization, project reference, branch, and environment before accessing project state. After installation, the `project_ref` query parameter can scope access to a specific project. For investigation-only access, configure `read_only=true` or feature-group filtering on the MCP URL.\n\n## Safe operation\n\nInspect schemas, tables, policies, migrations, functions, and storage buckets before making changes. Review intended SQL, RLS policy effects, generated migrations, storage operations, function changes, and branch targets before execution.\n\nSupabase recommends MCP access primarily for development and testing. Connect a production project only after the operator has explicitly accepted the risk. Database and authentication policy changes can expose data or break applications.\n\n## Sensitive data\n\nKeep PII and secrets out of captured prompts and logs. Use narrow project, feature, and read-only scopes to reduce unnecessary data exposure.\n", "icon": { "type": "url", "url": "https://supabase.com/favicon/favicon-32x32.png" @@ -1875,7 +1838,6 @@ "storage", "supabase" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", @@ -1911,7 +1873,7 @@ }, "sourcePath": "terraform/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Terraform\ndescription: Inspect Terraform Registry providers, modules, policies, and optional HCP Terraform or Terraform Enterprise workspaces through HashiCorp's MCP server.\ntags:\n - terraform\n - infrastructure\n - iac\n - registry\n - hcp\ncatalog:\n icon: https://developer.hashicorp.com/favicon.ico\nsetup:\n verify:\n - label: Check Docker is available\n command: docker\n args:\n - --version\nmcpServer:\n command: docker\n args:\n - run\n - -i\n - --rm\n - hashicorp/terraform-mcp-server:1.0.0\n runtime:\n features:\n - docker\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Terraform\n\nUse this Caplet when an agent needs Terraform Registry context for providers, modules, policies, or HCP Terraform and Terraform Enterprise workspace context exposed to the server.\n\n## First Workflow\n\n1. Start with read-only Registry lookups for provider, module, resource, data source, and policy documentation.\n2. Confirm Terraform version, provider source, module source, workspace, organization, and backend assumptions before proposing changes.\n3. Use HCP Terraform or Terraform Enterprise operations only when the server runtime has been configured with the intended token and address.\n4. Review generated Terraform recommendations against project policy, security, cost, and compliance requirements before implementation.\n\n## Operate Carefully\n\n- Terraform recommendations can affect infrastructure, cost, data access, and compliance once applied. Treat generated plans as suggestions until reviewed against the project.\n- HCP Terraform and Terraform Enterprise tokens should be least-privilege and scoped to the intended organization or workspace.\n- The default catalog entry starts the public Registry-capable Docker server without checked-in HCP credentials.\n- Avoid this Caplet when the task only needs to edit local Terraform files without external provider, module, or workspace context.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Terraform\ndescription: Inspect Terraform Registry providers, modules, policies, and optional HCP Terraform or Terraform Enterprise workspaces through HashiCorp's MCP server.\ntags:\n - terraform\n - infrastructure\n - iac\n - registry\n - hcp\ncatalog:\n icon: https://developer.hashicorp.com/favicon.ico\nsetup:\n verify:\n - label: Check Docker is available\n command: docker\n args:\n - --version\nmcpServer:\n command: docker\n args:\n - run\n - -i\n - --rm\n - hashicorp/terraform-mcp-server:1.0.0\n runtime:\n features:\n - docker\n startupTimeoutMs: 100000\n callTimeoutMs: 300000\n---\n\n# Terraform\n\n## Prerequisites\n\nDocker must be available for the catalog runtime. Before relying on results, confirm the Terraform version, provider or module source, workspace, organization, and backend assumptions.\n\nThe default catalog entry starts the public Registry-capable Docker server without checked-in HCP Terraform credentials. HCP Terraform or Terraform Enterprise access requires the runtime to be configured with the intended address and a least-privilege token scoped to the required organization or workspace.\n\n## Registry and workspace use\n\nBegin with read-only Registry lookups for provider, module, resource, data source, and policy documentation. Treat generated Terraform recommendations and plans as review material rather than approved changes.\n\n## Safe operation\n\nTerraform changes can affect infrastructure, cost, data access, security, and compliance. Review recommendations and plans against project policy and the exact workspace before implementation or apply.\n", "icon": { "type": "url", "url": "https://developer.hashicorp.com/favicon.ico" @@ -1923,7 +1885,6 @@ "registry", "terraform" ], - "intendedTask": "unknown", "setupReadiness": "required", "authReadiness": "ready", "projectBindingReadiness": "ready", @@ -1965,7 +1926,7 @@ }, "sourcePath": "vercel/CAPLET.md", "trustLevel": "official", - "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Vercel\ndescription: Inspect and manage Vercel teams, projects, deployments, logs, and documentation through Vercel's hosted MCP server.\ntags:\n - vercel\n - deployments\n - hosting\n - frontend\n - logs\ncatalog:\n icon: https://assets.vercel.com/image/upload/q_auto/front/favicon/vercel/favicon.ico\nmcpServer:\n url: https://mcp.vercel.com\n auth:\n type: oauth2\n---\n\n# Vercel\n\nUse this Caplet when an agent needs live Vercel context for teams, projects, deployments, deployment logs, domains, environment configuration, or Vercel documentation.\n\n## First Workflow\n\n1. Start by identifying the Vercel team, project, deployment, branch, domain, or request ID before searching broadly.\n2. Inspect project and deployment state before using logs or docs to explain failures.\n3. Use deployment logs and build/runtime evidence to distinguish application errors from Vercel platform or configuration issues.\n4. Confirm the target team and project before changing domains, environment variables, deployment settings, or aliases.\n\n## Operate Carefully\n\n- Vercel changes can affect production traffic, secrets, previews, and custom domains. Prefer read-only inspection before writes.\n- Treat environment variables and build logs as sensitive; summarize the relevant signal without exposing secret values.\n- Avoid this Caplet when the task only needs local Next.js, frontend, or repo configuration analysis.\n", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Vercel\ndescription: Inspect and manage Vercel teams, projects, deployments, logs, and documentation through Vercel's hosted MCP server.\ntags:\n - vercel\n - deployments\n - hosting\n - frontend\n - logs\ncatalog:\n icon: https://assets.vercel.com/image/upload/q_auto/front/favicon/vercel/favicon.ico\nmcpServer:\n url: https://mcp.vercel.com\n auth:\n type: oauth2\n---\n\n# Vercel\n\n## Targeting\n\nIdentify the Vercel team, project, deployment, branch, domain, or request ID before searching. Confirm the target team and project before changing domains, environment variables, deployment settings, or aliases.\n\n## Investigation\n\nInspect project and deployment state before consulting logs or documentation. Deployment logs and build or runtime evidence can distinguish application failures from Vercel platform or configuration problems.\n\n## Safe operation\n\nVercel changes can affect production traffic, previews, secrets, and custom domains. Prefer read-only inspection before writes, and review the production target and expected traffic impact before mutation.\n\nEnvironment variables and build logs may contain sensitive data. Preserve only the relevant diagnostic signal and do not reproduce secret values.\n", "icon": { "type": "url", "url": "https://assets.vercel.com/image/upload/q_auto/front/favicon/vercel/favicon.ico" @@ -1977,7 +1938,6 @@ "logs", "vercel" ], - "intendedTask": "unknown", "setupReadiness": "ready", "authReadiness": "required", "projectBindingReadiness": "ready", diff --git a/apps/catalog/src/lib/ingest.ts b/apps/catalog/src/lib/ingest.ts index e946ddee..f16316f9 100644 --- a/apps/catalog/src/lib/ingest.ts +++ b/apps/catalog/src/lib/ingest.ts @@ -315,8 +315,6 @@ async function canonicalEntryForAcceptedSignal( resolvedRevision: signal.resolvedRevision, }), tags: catalogStringArrayFromFrontmatter(frontmatter.tags), - useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), - avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen), setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter), authRequired: catalogAuthRequiredFromFrontmatter(frontmatter), projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter), diff --git a/apps/catalog/test/ingest.test.ts b/apps/catalog/test/ingest.test.ts index af74352f..9ab86dea 100644 --- a/apps/catalog/test/ingest.test.ts +++ b/apps/catalog/test/ingest.test.ts @@ -164,7 +164,6 @@ describe("catalog install signal ingestion", () => { indexedContentHash: "sha256:abc", contentMarkdown: "# Deploy", tags: ["deploy"], - intendedTask: "Deploy projects.", setupReadiness: "ready", authReadiness: "ready", projectBindingReadiness: "ready", @@ -256,7 +255,6 @@ describe("catalog install signal ingestion", () => { indexedContentHash: "wrong", contentMarkdown: "# Deploy", tags: ["deploy"], - intendedTask: "Deploy projects.", setupReadiness: "ready", authReadiness: "ready", projectBindingReadiness: "ready", @@ -454,7 +452,6 @@ function submittedEntry(): CatalogEntry { trustLevel: "community", contentMarkdown: "# Deploy", tags: ["deploy"], - intendedTask: "Deploy projects.", setupReadiness: "ready", authReadiness: "ready", projectBindingReadiness: "ready", diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx index 259f4a62..dbdf8ea8 100644 --- a/apps/dashboard/src/components/DashboardApp.test.tsx +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -10,6 +10,7 @@ const { dashboardApi, setDashboardSession, toast } = vi.hoisted(() => ({ toast: { error: vi.fn(), success: vi.fn(), + warning: vi.fn(), }, })); @@ -22,7 +23,7 @@ vi.mock("@/lib/api", () => ({ vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null })); vi.mock("sonner", () => ({ toast })); -import { DashboardApp } from "./DashboardApp"; +import { DashboardApp, catalogMutationLabel } from "./DashboardApp"; type Deferred = { promise: Promise; @@ -143,6 +144,7 @@ beforeEach(() => { setDashboardSession.mockClear(); toast.error.mockClear(); toast.success.mockClear(); + toast.warning.mockClear(); window.history.replaceState({}, "", "/dashboard/vault"); window.matchMedia = vi.fn().mockReturnValue({ addEventListener: vi.fn(), @@ -164,6 +166,16 @@ afterEach(async () => { vi.restoreAllMocks(); }); +describe("catalog update presentation", () => { + it("distinguishes committed content, runtime, and no-op outcomes", () => { + expect(catalogMutationLabel({ installed: [{ status: "content_updated" }] })).toBe( + "Content updated", + ); + expect(catalogMutationLabel({ installed: [{ status: "updated" }] })).toBe("Updated"); + expect(catalogMutationLabel({ installed: [{ status: "noop" }] })).toBe("Already current"); + }); +}); + describe("Vault reveal races", () => { it("dismisses a parent-owned reveal confirmation when Vault unmounts", async () => { await mountVault(); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index 94b68edc..5ba20019 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -98,6 +98,35 @@ import { CatalogPage } from "@/components/catalog/CatalogPage"; const REVEAL_DURATION_SECONDS = EPHEMERAL_REVEAL_TTL_MS / 1_000; const ACTION_DISCARDED = Symbol("dashboard-action-discarded"); +type CatalogMutationStatus = "installed" | "restored" | "updated" | "content_updated" | "noop"; + +type CatalogMutationResult = { + installed?: Array<{ + status?: CatalogMutationStatus; + catalogIndexing?: { status?: string; reason?: string }; + }>; +}; +type DashboardAction = ( + label: string | ((result: unknown) => string), + callback: () => Promise, +) => Promise; +export function catalogMutationLabel(result: unknown): string { + const status = (result as CatalogMutationResult | undefined)?.installed?.[0]?.status; + if (status === "content_updated") return "Content updated"; + if (status === "noop") return "Already current"; + if (status === "restored") return "Restored"; + if (status === "installed") return "Installed"; + return "Updated"; +} + +function catalogIndexingUnavailable(result: unknown): boolean { + return Boolean( + (result as CatalogMutationResult | undefined)?.installed?.some( + (entry) => entry.catalogIndexing?.status === "unavailable", + ), + ); +} + type RouteKey = | "overview" | "access" @@ -426,12 +455,17 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou } } - async function action(label: string, callback: () => Promise) { + const action: DashboardAction = async (label, callback) => { try { const result = await callback(); if (result === ACTION_DISCARDED) return; const refreshed = await refresh(); - if (refreshed) toast.success(label); + if (refreshed) { + toast.success(typeof label === "function" ? label(result) : label); + if (catalogIndexingUnavailable(result)) { + toast.warning("Catalog indexing unavailable; the committed update is still installed."); + } + } } catch (error) { if (isDashboardUnauthorized(error)) { endDashboardSession(); @@ -439,7 +473,7 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou } toast.error(error instanceof Error ? error.message : String(error)); } - } + }; async function logout() { try { @@ -747,7 +781,7 @@ function Page({ data: DashboardData; loading: boolean; session: DashboardSession; - action: (label: string, callback: () => Promise) => Promise; + action: DashboardAction; }) { const { confirmTyped } = useActionConfirm(); if (route === "access") return ; @@ -1362,7 +1396,7 @@ function CapletsPage({ }: { data: DashboardData; loading: boolean; - action: (label: string, callback: () => Promise) => Promise; + action: DashboardAction; }) { const { confirmTyped } = useActionConfirm(); const caplets = data.caplets?.caplets ?? []; @@ -1399,8 +1433,8 @@ function CapletsPage({ )) ) return; - await action("Update requested", () => - dashboardApi("catalog/update", { + await action(catalogMutationLabel, () => + dashboardApi("catalog/update", { method: "POST", body: JSON.stringify({ capletId, diff --git a/apps/docs/src/content/docs/reference/caplet-files.mdx b/apps/docs/src/content/docs/reference/caplet-files.mdx index d45c059e..1ec14b93 100644 --- a/apps/docs/src/content/docs/reference/caplet-files.mdx +++ b/apps/docs/src/content/docs/reference/caplet-files.mdx @@ -5,7 +5,9 @@ description: "Generated reference for Markdown Caplet file frontmatter." {/* */} -Caplet files are Markdown files with YAML frontmatter. Store a single-file Caplet as `osv.md`, or use a folder with `CAPLET.md` when the capability needs nearby assets. +Caplet files are Markdown files with two independent projections. YAML frontmatter is the sole authority for runtime configuration. The Markdown body is a publishable human operator README rendered separately; it is never a runtime input, interpolation source, selection channel, Code Mode declaration, or agent context. Do not include secrets, credentials, private endpoints, customer data, or other sensitive operational material in the body. Store a single-file Caplet as `osv.md`, or use a folder with `CAPLET.md` when frontmatter references nearby runtime assets. + +`useWhen` and `avoidWhen` are no longer valid Caplet configuration fields. Put concise agent-facing capability context in `description`; put operator-only prerequisites, safety notes, troubleshooting, and references in the Markdown body. Canonical schema: [https://caplets.dev/caplet.schema.json](https://caplets.dev/caplet.schema.json) @@ -25,11 +27,29 @@ mcpServer: args: ["-y", "@modelcontextprotocol/server-everything"] --- -Use this Caplet when an agent needs the example MCP tools. +# Example Tools + +## Prerequisites and setup context + +Confirm Node.js and package-registry access before enabling this example. + +## Safe operation and limits + +This example server is intended for evaluation. Review its exposed tools and permissions before use. + +## Troubleshooting + +If startup fails, verify the local Node.js installation and package-registry connectivity. + +## References + +See the package documentation for supported tools and limits. ``` -Use `useWhen` and `avoidWhen` to guide agent selection, and add `setup` metadata when a -Caplet needs local commands before it can run. +Runtime actions, paths, environment and authentication values, setup metadata, exposure, +and concise agent-facing capability descriptions belong in frontmatter. Treat the body as +publishable content: reserve it for human prerequisites, safety, troubleshooting, and +references, and do not include secrets or sensitive operational material. Project-bound Caplet with setup: @@ -53,7 +73,23 @@ setup: args: ["test"] --- -Use this Caplet when an agent needs the current repository's local test signal. +# Repository Checks + +## Prerequisites and setup context + +Install the repository dependencies before running the configured checks. + +## Safe operation and limits + +Review the repository scripts before enabling them; they execute with the attached project's permissions. + +## Troubleshooting + +If a check cannot start, confirm the package manager and dependencies match the repository lockfile. + +## References + +The repository's `package.json` and lockfile document the available scripts and tool versions. ``` Provider suite with multiple runtime children: @@ -89,7 +125,23 @@ googleDiscoveryApis: - https://www.googleapis.com/auth/gmail.readonly --- -Use this Caplet when an agent needs Workspace context across mail and files. +# Google Workspace + +## Prerequisites and setup context + +Confirm the operator has approved the OAuth consent and scopes declared in frontmatter. + +## Safe operation and limits + +This suite exposes read-only message and file metadata operations. Handle returned workspace data according to organizational policy. + +## Troubleshooting + +If a child capability is unavailable, verify its discovery document and granted OAuth scope. + +## References + +Consult the Gmail and Drive API documentation for operation and quota details. ``` Installing the parent `google-workspace` copies the whole suite. Runtime handles are @@ -107,8 +159,6 @@ or `capletSets` only when composing or nesting another Caplets collection. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -200,8 +250,6 @@ Multiple MCP server backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | ### `openapiEndpoint` @@ -240,8 +288,6 @@ Multiple OpenAPI endpoint backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | ### `googleDiscoveryApi` @@ -284,8 +330,6 @@ Multiple Google Discovery API backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | ### `graphqlEndpoint` @@ -330,8 +374,6 @@ Multiple GraphQL endpoint backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | ### `httpApi` @@ -368,8 +410,6 @@ Multiple HTTP API backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | ### `cliTools` @@ -398,8 +438,6 @@ Plural form child fields: | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | Plural `cliTools` is recognized only when the value is a child-ID map. `actions` is reserved for the singular form and cannot be used as a plural child ID. @@ -438,6 +476,4 @@ Multiple nested Caplet collection backend configurations keyed by child ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | diff --git a/apps/docs/src/content/docs/reference/code-mode-api.mdx b/apps/docs/src/content/docs/reference/code-mode-api.mdx index ad91547b..a67cbf34 100644 --- a/apps/docs/src/content/docs/reference/code-mode-api.mdx +++ b/apps/docs/src/content/docs/reference/code-mode-api.mdx @@ -172,8 +172,6 @@ type CapletCard = { id: Id; name: string; description: string; - useWhen?: string; - avoidWhen?: string; tags?: string[]; backend?: unknown; }; diff --git a/apps/docs/src/content/docs/reference/config.mdx b/apps/docs/src/content/docs/reference/config.mdx index 7cfa8e3c..4d22834c 100644 --- a/apps/docs/src/content/docs/reference/config.mdx +++ b/apps/docs/src/content/docs/reference/config.mdx @@ -151,8 +151,6 @@ Downstream MCP servers keyed by stable server ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -176,8 +174,6 @@ OpenAPI endpoints keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -202,8 +198,6 @@ Google Discovery APIs keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -228,8 +222,6 @@ GraphQL endpoints keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -252,8 +244,6 @@ HTTP APIs keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -275,8 +265,6 @@ CLI tools keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | @@ -300,8 +288,6 @@ Nested Caplet collections keyed by stable Caplet ID. | `tags` | Optional | array | Optional tags for grouping or searching Caplets. | | `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | -| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | | `setup` | Optional | object | Optional setup and verification metadata. | | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | diff --git a/apps/docs/src/content/docs/vault.mdx b/apps/docs/src/content/docs/vault.mdx index 2215b9fd..b464dfae 100644 --- a/apps/docs/src/content/docs/vault.mdx +++ b/apps/docs/src/content/docs/vault.mdx @@ -39,9 +39,13 @@ $vault:GH_TOKEN ${vault:GH_TOKEN} ``` -Vault interpolation applies where runtime config values are resolved. Public metadata such -as Caplet names, descriptions, tags, and Markdown body text keeps the literal reference -text instead of resolving to a secret. +Vault interpolation applies only where supported runtime frontmatter or configuration values +are resolved. Presentation fields such as names, descriptions, and tags keep vault-looking +text literal. + +The Markdown body is a separately rendered human operator README, not runtime configuration +or agent context. Vault-looking text, paths, links, and other configuration-like literals in +the body are never interpolated and cannot define or override runtime values. ## Set a local value diff --git a/apps/landing/public/caplet.schema.json b/apps/landing/public/caplet.schema.json index 06170c4e..5c9db3a6 100644 --- a/apps/landing/public/caplet.schema.json +++ b/apps/landing/public/caplet.schema.json @@ -44,18 +44,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -957,18 +945,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1605,18 +1581,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2285,18 +2249,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2466,18 +2418,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -2772,18 +2712,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -3047,18 +2975,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -3380,18 +3296,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -3715,18 +3619,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -3869,18 +3761,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -4018,18 +3898,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -4210,18 +4078,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -4407,18 +4263,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -4717,18 +4561,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { diff --git a/apps/landing/public/config.schema.json b/apps/landing/public/config.schema.json index cad9d03a..25339fc1 100644 --- a/apps/landing/public/config.schema.json +++ b/apps/landing/public/config.schema.json @@ -434,18 +434,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -856,18 +844,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1287,18 +1263,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1543,18 +1507,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -1753,18 +1705,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2166,18 +2106,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -2269,18 +2197,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2490,18 +2406,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -2634,18 +2538,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2897,18 +2789,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { diff --git a/caplets/ast-grep/CAPLET.md b/caplets/ast-grep/CAPLET.md index 2b00bb80..09fc00db 100644 --- a/caplets/ast-grep/CAPLET.md +++ b/caplets/ast-grep/CAPLET.md @@ -17,17 +17,10 @@ mcpServer: # AST Grep -Use this Caplet when lexical search is too weak and the agent needs syntax-aware code search, rule testing, or controlled rewrites inside the bound repository. - -## First Workflow - -1. Start with read-only structural search or scan operations to prove the pattern matches the intended syntax. -2. Inspect several matches before proposing a rewrite rule. -3. Test rewrite rules against narrow targets before applying them broadly. -4. Use scaffold operations only when the user wants durable ast-grep rules added to the project. - -## Operate Carefully +## Safe Operation - Project Binding is required because ast-grep reads and may rewrite files in the attached repository. -- Treat apply-all rewrites, snapshot updates, and scaffolding as destructive; show the intended pattern and target scope before using them. -- Prefer ordinary text search or LSP when the task is about names, references, or type information rather than syntax structure. +- Begin with read-only structural searches or scans, and inspect several matches to verify that a pattern selects the intended syntax. +- Test rewrite rules against narrow targets before applying them broadly. +- Treat apply-all rewrites, snapshot updates, and scaffolding as destructive operations. Review the pattern and target scope first. +- Scaffolding is intended for durable ast-grep rules that belong in the project. diff --git a/caplets/aws/CAPLET.md b/caplets/aws/CAPLET.md index 5b969b46..583bc636 100644 --- a/caplets/aws/CAPLET.md +++ b/caplets/aws/CAPLET.md @@ -32,21 +32,21 @@ mcpServer: # AWS -Use this Caplet when an agent needs live AWS account, Region, service, resource, IAM, operational, or AWS documentation context through the managed AWS MCP Server. +## Targeting and Prerequisites -## First Workflow +- Confirm the intended account, Region, profile, service, and resource identifiers before inspecting broad AWS state. +- Documentation, skill, list, and describe operations can narrow ambiguous service behavior or resource matches. +- For multi-account installations, named profiles are exposed through `AWS_MCP_PROXY_PROFILES`; select the intended profile on calls that support it. +- Use explicit Region names when the target Region matters, especially when the runtime was not started with `AWS_REGION`. -1. Start by confirming the intended account, Region, profile, service, and resource identifiers before querying broad AWS state. -2. Use documentation, skill, list, and describe operations to narrow ambiguous service behavior or resource matches before changing anything. -3. Inspect existing resource state, IAM context, dependencies, tags, and CloudTrail or service evidence before proposing operational changes. -4. For multi-account work, prefer named AWS profiles exposed through `AWS_MCP_PROXY_PROFILES`, and pass the intended profile on calls that support profile selection. -5. Use explicit Region names in requests when the target Region matters, especially if the runtime was not started with `AWS_REGION`. -6. Summarize the target account, Region, resource, and expected production effect before mutating resources. +## Safe Operation -## Operate Carefully - -- AWS operations can affect production infrastructure, data, security boundaries, billing, and compliance posture. Prefer read-only inspection before writes. +- Inspect existing resource state, IAM context, dependencies, tags, and CloudTrail or service evidence before operational changes. +- AWS operations can affect production infrastructure, data, security boundaries, billing, and compliance posture. Keep access read-only until a write is necessary. - Use least-privilege IAM roles, permission boundaries, and AWS MCP Server IAM condition keys where available. +- Review the target account, Region, resource, and expected production effect before a mutation. - Confirm destructive or high-impact targets before deleting, replacing, scaling, deploying, rotating credentials, changing IAM, modifying network policy, or changing data stores. -- If credentials are missing or expired, refresh AWS CLI credentials and rerun the setup verification before retrying. -- Avoid this Caplet when the task only needs local IaC or application files; use the project workspace and deployment tooling for local configuration state. + +## Troubleshooting + +If credentials are missing or expired, refresh the AWS CLI credentials and rerun the setup verification before retrying. diff --git a/caplets/azure/CAPLET.md b/caplets/azure/CAPLET.md index 58010022..2e8c4f20 100644 --- a/caplets/azure/CAPLET.md +++ b/caplets/azure/CAPLET.md @@ -34,18 +34,15 @@ mcpServer: # Azure -Use this Caplet when an agent needs live Azure subscription, resource group, service, deployment, monitoring, storage, database, identity, or documentation context through Microsoft's Azure MCP Server. +## Targeting and Prerequisites -## First Workflow +- Confirm the intended tenant, subscription, resource group, Region, service namespace, and resource name before inspecting broad Azure state. +- Authenticate against the intended tenant with least-privilege Azure roles before starting the server. +- List, get, documentation, and diagnostic operations can narrow ambiguous resource matches. -1. Start by confirming the Azure tenant, subscription, resource group, Region, service namespace, and resource name before querying broadly. -2. Use list, get, documentation, and diagnostic operations to narrow resource state before changing anything. -3. Inspect dependencies, tags, identities, access controls, costs, deployment history, and monitoring evidence before proposing operational changes. -4. Summarize the tenant, subscription, resource group, resource, and expected production effect before mutating resources. +## Safe Operation -## Operate Carefully - -- Azure operations can affect production infrastructure, data, identity boundaries, billing, and compliance posture. Prefer read-only inspection before writes. -- Authenticate with least-privilege Azure roles and the intended tenant before starting the server. +- Inspect dependencies, tags, identities, access controls, costs, deployment history, and monitoring evidence before operational changes. +- Azure operations can affect production infrastructure, data, identity boundaries, billing, and compliance posture. Keep access read-only until a write is necessary. +- Review the tenant, subscription, resource group, resource, and expected production effect before a mutation. - Confirm destructive or high-impact targets before deleting, scaling, redeploying, rotating credentials, changing RBAC, modifying networking, or changing data stores. -- Avoid this Caplet when the task only needs local IaC or application files. diff --git a/caplets/browser-use/CAPLET.md b/caplets/browser-use/CAPLET.md index 32af59f5..18d4a801 100644 --- a/caplets/browser-use/CAPLET.md +++ b/caplets/browser-use/CAPLET.md @@ -18,17 +18,13 @@ mcpServer: # Browser Use -Use this Caplet when the agent needs the user's real local browser context: signed-in web apps, current tabs, extension-backed inspection, or browser workflows that a headless test browser cannot reproduce. +## Targeting and Observation -## First Workflow +- The operator should identify the target page, tab, and intended outcome before interaction. +- Navigation state, screenshots, accessibility snapshots, and DOM inspection provide an observation-first view of the page. +- Initial interactions should remain minimal and reversible, with evidence collection bounded to the task. -1. Identify the target page, tab, or workflow before interacting. -2. Read page state with navigation, screenshots, accessibility snapshots, or DOM inspection first. -3. Keep interactions minimal and reversible until the user asks for a concrete action. -4. Capture the evidence needed for the coding or debugging task, then stop. +## Safe Operation -## Operate Carefully - -- Browser actions can sign in, submit forms, trigger purchases, or change account data in the user's real browser. -- Do not enter credentials, approve payments, submit destructive forms, or change account settings without explicit user direction. -- Prefer Playwright for isolated frontend testing; use this Caplet when the real browser environment matters. +- Actions occur in the user's real browser and can sign in, submit forms, trigger purchases, or change account data. +- Credential entry, payment approval, destructive form submission, and account-setting changes require explicit user direction. diff --git a/caplets/cloudflare/CAPLET.md b/caplets/cloudflare/CAPLET.md index 0c443dfd..8b4fbdea 100644 --- a/caplets/cloudflare/CAPLET.md +++ b/caplets/cloudflare/CAPLET.md @@ -18,18 +18,15 @@ mcpServer: # Cloudflare -Use this Caplet when an agent needs live Cloudflare account or zone context, or needs to act on DNS, Workers, cache, rules, security, access, pages, images, logs, or other Cloudflare resources through Cloudflare's hosted MCP server. +## Targeting and Prerequisites -## First Workflow +- Use the account ID, zone ID, domain, Worker name, rule ID, or another exact resource identifier when available. +- List and get operations can narrow ambiguous matches before any create, update, delete, purge, or deployment. +- OAuth access should be limited to the intended Cloudflare account and resources. -1. Start with the account ID, zone ID, domain, Worker name, rule ID, or other exact resource identifier when available. -2. Read current resource state before proposing or applying changes, especially for DNS records, security rules, Workers routes, cache settings, and access policies. -3. Use list and get operations to narrow ambiguous matches before creating, updating, deleting, purging, or deploying anything. -4. Summarize the intended external effect and target resource before making mutating calls. +## Safe Operation -## Operate Carefully - -- Cloudflare changes can affect production traffic, DNS resolution, security policy, cache behavior, and deployed code. Prefer read-only inspection first. -- Keep OAuth access limited to the Cloudflare account and resources intended for the task. +- Read current resource state before a change, especially for DNS records, security rules, Workers routes, cache settings, and access policies. +- Cloudflare changes can affect production traffic, DNS resolution, security policy, cache behavior, and deployed code. Keep access read-only until a write is necessary. +- Review the target resource and intended external effect before a mutation. - Confirm destructive targets before deleting DNS records, rules, routes, keys, certificates, applications, Workers resources, or account-level settings. -- Avoid this Caplet when the task only needs local Cloudflare project files; use the project workspace and Cloudflare tooling for local configuration state. diff --git a/caplets/coding-agent-toolkit/CAPLET.md b/caplets/coding-agent-toolkit/CAPLET.md index ab02f083..c5efde38 100644 --- a/caplets/coding-agent-toolkit/CAPLET.md +++ b/caplets/coding-agent-toolkit/CAPLET.md @@ -14,16 +14,12 @@ capletSet: # Coding Agent Toolkit -Use this CapletSet when the agent needs a compact default toolkit for coding work rather than a large bespoke integration list. +## Bundle Contents -## First Workflow +The set combines repository and code-intelligence capabilities with package, vulnerability, documentation, and browser tooling. Child availability depends on installation scope, setup state, and Project Binding state; not every child is necessarily available at runtime. -1. Use repository and code-intelligence Caplets for local facts before making implementation claims. -2. Use package, vulnerability, and documentation Caplets to verify external dependency assumptions. -3. Use browser automation only when rendered behavior or live web context is part of the task. +## Setup and Safety -## Operate Carefully - -- This set is a convenience bundle; prefer a narrower individual Caplet when the user asks for a specific provider or capability. -- Some child Caplets require Project Binding, setup, or local-control awareness. Inspect the child Caplet before using high-risk or project-bound capabilities. -- Do not assume every child is available at runtime; availability depends on installation scope, setup state, and binding state. +- Some child Caplets require Project Binding, additional setup, or awareness of local-control risks. +- Operators should review a child's own README and frontmatter before enabling project-bound or high-risk capabilities. +- Browser children can interact with rendered or live web contexts and should be enabled only with the control surface appropriate to the installation. diff --git a/caplets/computer-use/CAPLET.md b/caplets/computer-use/CAPLET.md index 1a5d3eef..f63326b4 100644 --- a/caplets/computer-use/CAPLET.md +++ b/caplets/computer-use/CAPLET.md @@ -16,17 +16,14 @@ mcpServer: # Computer Use -Use this Caplet only when an agent needs explicit access to the local desktop, application windows, or GUI workflows that cannot be completed through APIs or CLI tools. +## Targeting and Observation -## First Workflow +- The operator should identify the target application, window, and intended outcome before interaction. +- Screen observation should precede state changes, with the proposed next action made clear. +- Menu, navigation, and read actions provide a safer starting point than typing, submitting, or changing settings. +- Automation should stop once the requested narrow GUI operation is complete. -1. Identify the target application, window, and desired outcome before interacting. -2. Observe the screen and report the intended next action before changing state. -3. Prefer menu/navigation/read actions before typing, clicking submit buttons, or changing settings. -4. Stop after completing the narrow GUI step the user requested. +## Safe Operation -## Operate Carefully - -- This is a high-risk local-control Caplet. It can operate real applications and expose private screen content. -- Do not use it for credential entry, payment flows, destructive file operations, account settings, or irreversible actions without direct user instruction. -- Prefer provider APIs, CLI tools, or browser automation when those can complete the task with a smaller control surface. +- This is a high-risk local-control capability that can operate real applications and expose private screen content. +- Credential entry, payment flows, destructive file operations, account-setting changes, and other irreversible actions require direct user instruction. diff --git a/caplets/context7/CAPLET.md b/caplets/context7/CAPLET.md index 443dcae7..7220a84b 100644 --- a/caplets/context7/CAPLET.md +++ b/caplets/context7/CAPLET.md @@ -17,17 +17,13 @@ mcpServer: # Context7 -Use this Caplet when the agent needs current library, SDK, framework, CLI, or cloud-service documentation before writing code or giving technical instructions. +## Documentation Lookup -## First Workflow +- Results are most precise when the package, framework, SDK, service, and relevant version are identified. +- A known API symbol supports a narrower lookup than a broad documentation search. +- Current API, configuration, migration, and example material should be checked against the project's local versions, types, and tests. -1. Name the package, framework, SDK, or service as specifically as possible. -2. Ask for the current API, config, migration, or example relevant to the task. -3. Cross-check returned guidance against project-local versions, types, and tests before editing code. -4. Cite or summarize only the documentation details needed for the implementation decision. +## Source Quality -## Operate Carefully - -- Prefer primary docs and version-specific examples over generic snippets when implementation risk is high. -- Do not use documentation lookup as a substitute for reading the local codebase, lockfile, generated types, or failing tests. -- Avoid broad documentation searches when a package name, version, or API symbol is already known. +- Prefer primary documentation and version-specific examples over generic snippets when implementation risk is high. +- Keep citations or summaries limited to the details that support the implementation decision. diff --git a/caplets/datadog/CAPLET.md b/caplets/datadog/CAPLET.md index 7904ab49..de1dc17d 100644 --- a/caplets/datadog/CAPLET.md +++ b/caplets/datadog/CAPLET.md @@ -20,18 +20,15 @@ mcpServer: # Datadog -Use this Caplet when an agent needs live Datadog observability context for logs, metrics, traces, monitors, dashboards, incidents, hosts, services, events, notebooks, APM, or agent observability. +## Targeting and Setup -## First Workflow +- Confirm the Datadog site, organization, service, environment, time window, tags, monitor, incident, trace ID, or dashboard target. +- For non-US1 organizations, confirm the Datadog site and endpoint host before authentication. +- The `toolsets` query parameter can be added after installation to expose only the required Datadog product areas. -1. Start by confirming the Datadog site, organization, service, environment, time window, tags, monitor, incident, trace ID, or dashboard target. -2. Query narrow time ranges and tags first, then widen only when the first pass misses relevant evidence. -3. Correlate logs, metrics, traces, deployment events, monitor status, and incidents before naming a cause. -4. Add a `toolsets` query parameter after install when the workflow should expose only specific Datadog product areas. +## Investigation and Safety -## Operate Carefully - -- Datadog evidence can include production telemetry, customer identifiers, incident details, and security signals. Summarize the signal without leaking sensitive payloads. -- Confirm the Datadog site and endpoint host for non-US1 organizations before authenticating. -- Prefer read-only investigation before changing monitors, dashboards, notebooks, incident state, or platform configuration. -- Avoid this Caplet when the task only needs local log files or application instrumentation code. +- Begin with narrow time ranges and tags, widening only when the first pass misses relevant evidence. +- Logs, metrics, traces, deployment events, monitor status, and incidents provide complementary evidence and should be correlated before a cause is assigned. +- Production telemetry can include customer identifiers, incident details, and security signals. Summaries should omit sensitive payloads. +- Keep investigation read-only until changes to monitors, dashboards, notebooks, incident state, or platform configuration are necessary. diff --git a/caplets/deepwiki/CAPLET.md b/caplets/deepwiki/CAPLET.md index 89888ffe..2ed0e528 100644 --- a/caplets/deepwiki/CAPLET.md +++ b/caplets/deepwiki/CAPLET.md @@ -14,16 +14,10 @@ mcpServer: # DeepWiki -Use this Caplet when the agent needs repository-level explanations or architecture context for an unfamiliar codebase before making implementation decisions. +## Research guidance -## First Workflow +DeepWiki is most useful for a specific repository, subsystem, file, or concept. Focused questions produce more actionable architecture facts, terminology, and code pointers than broad requests about an entire project. -1. Ask about a specific repository, subsystem, file, or concept rather than the whole project. -2. Use DeepWiki to build orientation, then verify critical claims against source code or official docs. -3. Bring back concise architecture facts, terminology, and code pointers that affect the task. +## Verification -## Operate Carefully - -- Treat DeepWiki as a research and orientation source, not final proof for code changes. -- Do not use it when the local repository is already available and direct code search or tests can answer the question. -- Re-check version-sensitive details against the current upstream repository when correctness matters. +DeepWiki provides orientation rather than final proof for code changes. Verify critical claims against source code or official documentation, and re-check version-sensitive details against the current upstream repository when correctness matters. diff --git a/caplets/github/CAPLET.md b/caplets/github/CAPLET.md index cc1281ff..3573a528 100644 --- a/caplets/github/CAPLET.md +++ b/caplets/github/CAPLET.md @@ -19,18 +19,12 @@ mcpServer: # GitHub -Use this Caplet when the agent needs live GitHub repository context or needs to act on issues, pull requests, branches, commits, or review feedback. +## Targeting remote work -## First Workflow +Identify the repository and the relevant issue, pull request, branch, or commit before taking action. Narrow lookups by repository, PR number, issue number, branch, label, or author whenever possible. Review work should include the changed files and relevant discussion; issue drafts and updates should stay concise and grounded in current implementation evidence. -1. Read the relevant repository, issue, pull request, branch, or commit before taking action. -2. Narrow by repo, PR number, issue number, branch, label, or author whenever possible. -3. For reviews, inspect changed files and relevant discussion before commenting. -4. For issue creation or updates, draft concise content tied to the current implementation evidence. +## Safe operation -## Operate Carefully - -- Mutating operations can affect real repositories. Prefer read operations first. -- Confirm target repository, branch, issue, or pull request before creating comments, labels, branches, or updates. -- Do not expose token values, repository secrets, or private issue contents outside the intended conversation. -- Prefer local Git and project files for workspace state; use GitHub for remote truth. +- GitHub mutations affect real repositories. Read current remote state before writing. +- Confirm the target repository, branch, issue, or pull request before creating comments, labels, branches, or updates. +- Keep token values, repository secrets, and private issue contents within their intended access boundary. diff --git a/caplets/gmail/CAPLET.md b/caplets/gmail/CAPLET.md index 9b675391..5fe3801e 100644 --- a/caplets/gmail/CAPLET.md +++ b/caplets/gmail/CAPLET.md @@ -41,18 +41,12 @@ googleDiscoveryApi: # Gmail -Use this Caplet when an agent needs Gmail context for support, scheduling, customer communication, or inbox triage. +## Reading mail -## First Workflow +Use narrow searches by sender, subject, label, date range, or thread. Inspect message metadata and thread context before retrieving full bodies, and limit summaries to the information the operator needs. -1. Start with narrow searches by sender, subject, label, date range, or thread when possible. -2. Read message metadata and thread context before retrieving full message bodies. -3. Summarize only the details needed for the user's task. -4. Draft replies before sending, and ask for explicit user intent before modifying labels or sending. +## Safe operation and limits -## Operate Carefully - -- Email often contains private or regulated content. Keep queries narrow and summaries minimal. -- Confirm recipients, thread IDs, labels, and draft contents before any write operation. -- This Caplet does not expose Gmail settings, permanent deletion, trash/untrash, import/insert, watch, or forwarding operations; create a private variant if those are required. -- Prefer a task or calendar integration when the work is only follow-up tracking and does not require email content. +- Email can contain private or regulated content. Keep queries narrow and summaries minimal. +- Before modifying labels or sending mail, confirm explicit operator intent, recipients, thread IDs, labels, and draft contents. Draft replies before sending. +- This Caplet does not expose Gmail settings, permanent deletion, trash/untrash, import/insert, watch, or forwarding operations. Those operations require a private variant. diff --git a/caplets/google-chat/CAPLET.md b/caplets/google-chat/CAPLET.md index cb54f09d..daa24506 100644 --- a/caplets/google-chat/CAPLET.md +++ b/caplets/google-chat/CAPLET.md @@ -39,18 +39,12 @@ googleDiscoveryApi: # Google Chat -Use this Caplet when an agent needs Google Chat context, space membership context, message history, or a deliberate outgoing Chat message. +## Finding conversation context -## First Workflow +Locate the relevant space, direct message, or group chat first. Read recent history and membership context before preparing a summary or reply. Download media only when the attachment is necessary for the operator's task. -1. Start by finding the relevant space, direct message, or group chat. -2. Read recent message history and membership context before summarizing or drafting a reply. -3. Download media only when the attachment is necessary for the user's task. -4. Send messages or add reactions only when the target space, thread, recipients, and message content are explicit. +## Safe operation and limits -## Operate Carefully - -- Chat messages can contain sensitive internal discussion. Keep reads narrow and summarize only what the user needs. -- Outgoing Chat messages are visible to real people. Draft first when intent or audience is ambiguous. +- Chat messages can contain sensitive internal discussion. Keep reads narrow and summaries limited to what the operator needs. +- Outgoing messages and reactions are visible to real people. Confirm the target space, thread, recipients, and content; prepare a draft first when intent or audience is ambiguous. - This Caplet does not expose message update/delete, space administration, custom emoji, availability, import, or organization-wide admin operations. -- Prefer Gmail or a task system when the work is email-centric or durable task tracking rather than Chat collaboration. diff --git a/caplets/google-docs/CAPLET.md b/caplets/google-docs/CAPLET.md index 11ddb7e2..b54f046e 100644 --- a/caplets/google-docs/CAPLET.md +++ b/caplets/google-docs/CAPLET.md @@ -26,17 +26,11 @@ googleDiscoveryApi: # Google Docs -Use this Caplet when an agent needs to inspect document structure, create a new Google Doc, or apply explicit content and formatting updates to a known document. +## Document prerequisites -## First Workflow +Start with a document ID, document URL, or the newly created document returned by `documents.create`. Inspect current structure with `documents.get` before planning content or formatting changes. Confirm the target document ID and intended changes before creating or updating a document. -1. Start from a document ID, document URL, or newly created document returned by `documents.create`. -2. Use `documents.get` to inspect the current document structure before proposing edits. -3. Group content and formatting changes into one `documents.batchUpdate` request when possible. -4. Confirm the target document ID and intended changes before creating or updating documents. +## Safe updates -## Operate Carefully - -- Google Docs content can contain private, shared, or regulated information. Read only the document sections needed for the task. -- `documents.batchUpdate` can change real document content and formatting. Prefer a planned set of requests over exploratory writes. -- Use Google Drive for file search, folder placement, sharing, copying, moving, trashing, or deleting documents. +- Google Docs content can contain private, shared, or regulated information. Read only the sections needed for the operator's task. +- `documents.batchUpdate` changes live content and formatting. Group a deliberate set of content and formatting requests into one batch where practical rather than making exploratory writes. diff --git a/caplets/google-drive/CAPLET.md b/caplets/google-drive/CAPLET.md index cd5dec6f..da656875 100644 --- a/caplets/google-drive/CAPLET.md +++ b/caplets/google-drive/CAPLET.md @@ -31,18 +31,12 @@ googleDiscoveryApi: # Google Drive -Use this Caplet when an agent needs Drive files as context or needs to create/update files with explicit user direction. +## File discovery -## First Workflow +Search accessible metadata first by name, owner, MIME type, folder, modified time, or shared-drive context. Confirm the exact file ID before reading, downloading, updating, moving, trashing, or deleting. Read or download only the files needed for the operator's task, and consider creating a new file or draft copy before overwriting an existing shared document. -1. Search accessible file metadata first by name, owner, MIME type, folder, modified time, or shared-drive context. -2. Confirm the exact file ID before reading, downloading, updating, moving, trashing, or deleting. -3. Read or download only the files needed for the task. -4. Prefer creating a new file or draft copy before overwriting an existing shared document. +## Access boundary and limits -## Operate Carefully - -- Drive files may contain private, shared, or regulated information. Keep reads narrow and summarize only what is needed. -- This Caplet uses the restricted `drive.file` scope, so it is intended for files the app created or files the user explicitly opens or grants to the app. -- It does not expose Drive-wide sharing, permissions, comments, approvals, shared-drive administration, or trash-emptying operations; create a private variant if those are required. -- Prefer repository files when the user is asking about local project state. +- Drive files can contain private, shared, or regulated information. Keep reads narrow and summaries limited to what is needed. +- The restricted `drive.file` OAuth scope covers files the app created and files the user explicitly opens or grants to the app; it does not provide unrestricted Drive-wide access. +- This Caplet does not expose Drive-wide sharing, permissions, comments, approvals, shared-drive administration, or trash-emptying operations. Those operations require a private variant. diff --git a/caplets/google-forms/CAPLET.md b/caplets/google-forms/CAPLET.md index 9d97a7e3..0fc1aed2 100644 --- a/caplets/google-forms/CAPLET.md +++ b/caplets/google-forms/CAPLET.md @@ -28,18 +28,14 @@ googleDiscoveryApi: # Google Forms -Use this Caplet when an agent needs to inspect a form, create or revise form questions, or summarize submitted responses from a known Google Form. +## Form prerequisites -## First Workflow +Start with a form ID, form URL, or a newly created form. Read the form structure before changing titles, descriptions, questions, grading, or navigation. For response analysis, retrieve only the required responses and fields. -1. Start from a form ID, form URL, or newly created form. -2. Read the form structure before changing titles, descriptions, questions, grading, or navigation. -3. List responses only when response data is required, and narrow analysis to the fields relevant to the task. -4. Confirm question IDs, item locations, and response-safety expectations before using `forms.batchUpdate`. +## Safe updates and limits -## Operate Carefully - -- Forms and responses may contain private, educational, health, or customer information. Keep response reads and summaries minimal. -- `forms.batchUpdate` can alter live collection instruments. Avoid casual edits to published forms. -- This Caplet uses the restricted `drive.file` scope, so it is intended for Forms the app created or files the user explicitly opens or grants to the app. -- It does not expose publish settings, watches, or deletion workflows; create a private variant if those are required. +- Confirm question IDs, item locations, and response-safety expectations before using `forms.batchUpdate`. +- Forms and responses can contain private educational, health, or customer information. Keep response reads and summaries minimal. +- `forms.batchUpdate` can alter a live collection instrument; published forms should only receive deliberate, reviewed changes. +- The restricted `drive.file` OAuth scope covers Forms the app created and files the user explicitly opens or grants to the app. +- This Caplet does not expose publish settings, watches, or deletion workflows. Those operations require a private variant. diff --git a/caplets/google-meet/CAPLET.md b/caplets/google-meet/CAPLET.md index 22a972c1..c0b2d804 100644 --- a/caplets/google-meet/CAPLET.md +++ b/caplets/google-meet/CAPLET.md @@ -40,18 +40,12 @@ googleDiscoveryApi: # Google Meet -Use this Caplet when an agent needs to create an app-managed Meet space or inspect meeting records, participants, recordings, transcripts, or smart notes. +## Meeting prerequisites -## First Workflow +Start with a Meet space, conference record, meeting code, or newly created space. Inspect the conference record and participant list before accessing recordings, transcripts, or smart notes. Limit transcript entries and smart notes to the time range and topic the operator needs. -1. Start from a Meet space, conference record, meeting code, or newly created space. -2. Inspect the conference record and participant list before reading recordings, transcripts, or smart notes. -3. Read transcript entries or smart notes only for the time range and topic needed by the user. -4. Patch or end an active conference only for app-created spaces and only when the user intent is explicit. - -## Operate Carefully +## Safe operation and limits - Meeting records, transcripts, recordings, and smart notes can contain sensitive personal and business information. Keep reads narrow. -- Creating, patching, or ending Meet spaces changes live collaboration state. Confirm meeting ownership and timing before mutating. -- This Caplet avoids organization-wide Meet settings and only exposes app-created space management plus read-only meeting record inspection. -- Prefer Calendar when the user needs scheduling, invites, guest lists, or event lifecycle management. +- Creating, patching, or ending Meet spaces changes live collaboration state. Confirm meeting ownership, timing, and operator intent before a mutation. +- Space mutations are limited to app-created spaces. The exposed meeting-record operations are read-only, and organization-wide Meet settings are not available. diff --git a/caplets/google-sheets/CAPLET.md b/caplets/google-sheets/CAPLET.md index b4d84be2..353ce313 100644 --- a/caplets/google-sheets/CAPLET.md +++ b/caplets/google-sheets/CAPLET.md @@ -35,18 +35,13 @@ googleDiscoveryApi: # Google Sheets -Use this Caplet when an agent needs to inspect spreadsheet structure, read bounded ranges, append tabular data, or make explicit updates to a known Google Sheet. +## Spreadsheet prerequisites -## First Workflow +Start with a spreadsheet ID, spreadsheet URL, or newly created spreadsheet. Inspect sheet names, grid properties, named ranges, and developer metadata before reading large ranges. Retrieve only needed ranges with `values.get`, `values.batchGet`, or data filters. -1. Start from a spreadsheet ID, spreadsheet URL, or newly created spreadsheet. -2. Inspect sheet names, grid properties, named ranges, and developer metadata before reading large ranges. -3. Read only the ranges needed for the task, using `values.get`, `values.batchGet`, or data filters. -4. Confirm target sheet, range, value shape, and whether formulas should be preserved before updating, appending, or clearing cells. +## Safe updates and access -## Operate Carefully - -- Spreadsheets often contain private business data. Prefer narrow ranges and summaries over full-sheet reads. -- `batchUpdate`, value updates, appends, and clears change live spreadsheet state. Treat clearing cells as destructive. -- This Caplet uses the restricted `drive.file` scope, so it is intended for Sheets the app created or files the user explicitly opens or grants to the app. -- Use Google Drive for locating, sharing, copying, moving, trashing, or deleting spreadsheet files. +- Confirm the target sheet, range, value shape, and whether formulas must be preserved before updating, appending, or clearing cells. +- Spreadsheets often contain private business data. Prefer bounded ranges and summaries over full-sheet reads. +- `batchUpdate`, value updates, appends, and clears change live spreadsheet state. Clearing cells is destructive. +- The restricted `drive.file` OAuth scope covers Sheets the app created and files the user explicitly opens or grants to the app. diff --git a/caplets/google-slides/CAPLET.md b/caplets/google-slides/CAPLET.md index 9d428e5e..9feeaf4c 100644 --- a/caplets/google-slides/CAPLET.md +++ b/caplets/google-slides/CAPLET.md @@ -28,18 +28,12 @@ googleDiscoveryApi: # Google Slides -Use this Caplet when an agent needs to inspect a deck, create a presentation, preview slides, or apply explicit layout and content changes to a known Google Slides file. +## Presentation prerequisites -## First Workflow +Start with a presentation ID, presentation URL, or newly created presentation. Use `presentations.get` to inspect slide order, page element IDs, layouts, and existing text. Page reads and thumbnails can provide visual confirmation of a specific slide. -1. Start from a presentation ID, presentation URL, or newly created presentation. -2. Use `presentations.get` to inspect slide order, page element IDs, layouts, and existing text before planning edits. -3. Use page reads or thumbnails when the user needs visual confirmation of a specific slide. -4. Group text, image, layout, and styling changes into a deliberate `presentations.batchUpdate` request. +## Safe updates and access -## Operate Carefully - -- Presentations can contain private plans, customer material, or financial data. Inspect only the slides needed for the task. -- `presentations.batchUpdate` changes live deck content and formatting. Confirm page IDs and element IDs before mutating. -- This Caplet uses the restricted `drive.file` scope, so it is intended for Slides files the app created or files the user explicitly opens or grants to the app. -- Use Google Drive for finding, sharing, copying, moving, trashing, or deleting presentation files. +- Presentations can contain private plans, customer material, or financial data. Inspect only the slides needed for the operator's task. +- `presentations.batchUpdate` changes live deck content and formatting. Confirm page and element IDs, then group text, image, layout, and styling changes into a deliberate batch. +- The restricted `drive.file` OAuth scope covers Slides files the app created and files the user explicitly opens or grants to the app. diff --git a/caplets/google-tasks/CAPLET.md b/caplets/google-tasks/CAPLET.md index e739bce2..79e51fc6 100644 --- a/caplets/google-tasks/CAPLET.md +++ b/caplets/google-tasks/CAPLET.md @@ -33,18 +33,12 @@ googleDiscoveryApi: # Google Tasks -Use this Caplet when an agent needs to inspect or manage Google Tasks during planning, follow-up, or personal workflow coordination. +## Task and tasklist handling -## First Workflow +List tasklists before choosing a destination. Search existing tasks before creating one to avoid duplicates. Before creating or moving a task, confirm its title, notes, due date, parent task, and tasklist. -1. List tasklists before choosing where work belongs. -2. Search or list existing tasks before creating new ones to avoid duplicates. -3. Confirm task title, notes, due date, parent task, and tasklist before creating or moving. -4. Mark tasks complete only when the user or current workflow clearly confirms completion. +## Safe operation and limits -## Operate Carefully - -- Task changes are user-visible workflow state. Read first and keep writes specific. -- Do not infer deadlines or completion state from vague conversation. -- This Caplet does not expose task deletion, tasklist deletion, or clear-completed operations; create a private variant if deletion workflows are required. -- Prefer Linear or GitHub Issues for team-owned engineering work. +- Task changes are user-visible workflow state. Read current state first and keep writes specific. +- Do not infer deadlines or completion from ambiguous conversation. Mark a task complete only after the operator or the current workflow clearly confirms completion. +- This Caplet does not expose task deletion, tasklist deletion, or clear-completed operations. Those operations require a private variant. diff --git a/caplets/google-workspace/CAPLET.md b/caplets/google-workspace/CAPLET.md index 58d22135..422b4eda 100644 --- a/caplets/google-workspace/CAPLET.md +++ b/caplets/google-workspace/CAPLET.md @@ -10,8 +10,6 @@ tags: - files catalog: icon: https://workspace.google.com/favicon.ico -useWhen: Coordinate work across Google mail, files, documents, spreadsheets, presentations, and tasks. -avoidWhen: Use a focused Google Caplet when the task only needs one Workspace surface. googleDiscoveryApis: gmail: name: Gmail @@ -148,22 +146,19 @@ googleDiscoveryApis: # Google Workspace -Use this Caplet when an agent needs to coordinate work across Gmail, Drive, Docs, Sheets, Slides, and Tasks from one installable Workspace capability. +## Suite operation -## First Workflow +Identify which Workspace surface owns the source of truth: mail, file metadata, document content, spreadsheet data, deck content, or task state. Search or inspect metadata before reading large content bodies. -1. Start by identifying which Workspace surface owns the source of truth: mail, file metadata, document content, spreadsheet data, deck content, or task state. -2. Search or inspect metadata before reading large content bodies. -3. Use the child runtime handles deliberately: `google-workspace__gmail`, `google-workspace__drive`, `google-workspace__docs`, `google-workspace__sheets`, `google-workspace__slides`, or `google-workspace__tasks`. -4. Prefer read-only inspection before creating, updating, sending, deleting, clearing, or completing anything. -5. Confirm file IDs, document IDs, spreadsheet ranges, slide/page element IDs, message/thread IDs, labels, recipients, tasklists, and task IDs before mutating live state. +The suite exposes separate child handles: `google-workspace__gmail`, `google-workspace__drive`, `google-workspace__docs`, `google-workspace__sheets`, `google-workspace__slides`, and `google-workspace__tasks`. These names are operator reference; the frontmatter backend map remains the runtime authority. -## Operate Carefully +Before changing live state, inspect the current resource and confirm the relevant file or document ID, spreadsheet range, slide or page element ID, message or thread ID, label, recipient, tasklist, or task ID. -- Workspace data often contains private, customer, employee, legal, financial, or regulated information. Keep reads narrow and summaries minimal. -- Child auth scopes are intentionally separate so a private fork can remove surfaces or narrow scopes without changing the suite shape. -- Drive, Sheets, and Slides use `drive.file`, so they are intended for files the app created or files the user explicitly opens or grants to the app. -- Gmail write operations can label, draft, modify, or send messages. Draft first and confirm recipients and content before sending. -- Docs, Sheets, and Slides update operations change live files. Inspect current structure and plan changes before issuing batch updates. -- Tasks are user-visible workflow state. Do not infer deadlines or completion state from vague conversation. -- Avoid this Caplet when the task only needs one focused Google surface; the individual Gmail, Drive, Docs, Sheets, Slides, and Tasks Caplets are simpler for single-surface work. +## Safety and access boundaries + +- Workspace data can contain private customer, employee, legal, financial, or regulated information. Keep reads narrow and summaries minimal. +- Child OAuth scopes are intentionally separate so a private fork can remove surfaces or narrow scopes without changing the suite shape. +- Drive, Sheets, and Slides use the restricted `drive.file` scope. They cover files the app created and files the user explicitly opens or grants to the app. +- Gmail operations can label, draft, modify, or send messages. Confirm recipients and content before sending. +- Docs, Sheets, and Slides batch updates change live files. Inspect current structure and plan changes before updating. +- Tasks are user-visible workflow state. Do not infer deadlines or completion from ambiguous conversation. diff --git a/caplets/linear/CAPLET.md b/caplets/linear/CAPLET.md index 7a433cc2..9ff6f37e 100644 --- a/caplets/linear/CAPLET.md +++ b/caplets/linear/CAPLET.md @@ -18,21 +18,16 @@ mcpServer: # Linear -Use this Caplet when the agent needs live product planning context from Linear or needs to keep implementation work synchronized with issues, projects, and team workflows. +## Lookup and Updates -## First Workflow +Narrow lookups by issue ID, team key, project, cycle, label, or assignee avoid noisy results. Before an update, review the current issue, linked project, comments, and workflow state. Issue breakdowns and status comments should reflect concrete implementation evidence, and the target issue and intended team-visible effect should be confirmed before writing. -1. Search by issue ID, team key, project, cycle, label, or assignee before using broad queries. -2. Read the current issue, linked project, comments, and workflow state before planning or updating. -3. Draft issue breakdowns or status comments from concrete implementation evidence. -4. Write updates only after confirming the target issue and the intended team-visible effect. +## Reference -## Reference Files +- [Workflows](./workflows.md): lookup, planning, status update, and triage documentation. -- [Workflows](./workflows.md): recommended lookup, planning, status update, and triage flows. +## Safe Operation -## Operate Carefully - -- Linear issue updates are visible to teammates. Read first, then write deliberately. -- Keep issue titles and comments concise; use links to detailed implementation artifacts when useful. -- Avoid broad, noisy searches when a team key, issue ID, project, or label is available. +- Linear issue updates are visible to teammates. Read the current state before writing deliberately. +- Concise issue titles and comments are easier to follow; detailed implementation artifacts can remain linked. +- Prefer a team key, issue ID, project, or label over a broad search when one is available. diff --git a/caplets/lsp/CAPLET.md b/caplets/lsp/CAPLET.md index 31d682c6..64a51258 100644 --- a/caplets/lsp/CAPLET.md +++ b/caplets/lsp/CAPLET.md @@ -17,28 +17,22 @@ mcpServer: # LSP -Use this Caplet when the agent needs project-aware code intelligence from language servers: definitions, references, diagnostics, hover/type information, symbols, formatting, code actions, or rename edits. +## Inspection and Edits -## First Workflow - -1. Start with diagnostics, hover/type information, or definition lookup for the exact file and symbol involved. -2. Use references and symbols to understand blast radius before refactoring. -3. Request code actions, formatting, or rename edits as proposals first; apply edits only when the target server and file scope are clear. -4. Cross-check language-server results against tests and source when the answer affects behavior. +Diagnostics, hover or type information, and definition lookup provide a focused starting point for a specific file and symbol. References and symbols show the blast radius of a refactor. Code actions, formatting, and rename operations can be reviewed as proposed edits before application, with source and tests used to validate behavior-sensitive conclusions. ## Project Context -Project Binding is required because all useful LSP operations need a trustworthy bound project root for workspace-relative files, language-server startup, diagnostics, and edit containment. +Project Binding is required. LSP operations depend on a trustworthy bound project root for workspace-relative files, language-server startup, diagnostics, and edit containment. -For file-targeted tools, use paths that resolve inside the bound workspace. Include a `serverId` when more than one language server could handle the file or when applying edits. +File-targeted paths must resolve inside the bound workspace. A `serverId` disambiguates files handled by multiple language servers and is required for some applied edits. -## Operate Carefully +## Safe Operation and Lifecycle -`language-server-mcp` defaults to conservative behavior for file modification and process execution: +`language-server-mcp` uses conservative defaults for file modification and process execution: -- Edit-producing tools return edits by default and do not write files unless `apply: true` is passed. -- `apply: true` requires `serverId` when more than one matching LSP server would produce edits. -- Applied edits are restricted to the workspace root unless the downstream server is configured with `security.allowExternalFiles: true`. -- `workspace/executeCommand` is enabled by default, but can be disabled globally or restricted with per-server command allowlists. +- Edit-producing tools return edits without writing unless `apply: true` is supplied. +- When multiple matching LSP servers could produce edits, `apply: true` also requires `serverId`. +- Applied edits remain inside the workspace root unless the downstream server enables `security.allowExternalFiles: true`. +- `workspace/executeCommand` is enabled by default. Operators can disable it globally or restrict it with per-server command allowlists. - LSP servers start lazily on first use and stop after an idle timeout by default. -- Prefer ast-grep or text search for syntax-pattern searches that do not need language-server semantics. diff --git a/caplets/mongodb/CAPLET.md b/caplets/mongodb/CAPLET.md index ac73dc6b..c9d40bd8 100644 --- a/caplets/mongodb/CAPLET.md +++ b/caplets/mongodb/CAPLET.md @@ -34,18 +34,13 @@ mcpServer: # MongoDB -Use this Caplet when an agent needs MongoDB database, collection, schema, index, query, sample document, or Atlas operational context. +## Query Scope -## First Workflow +Before querying data, establish the cluster, database, collection, Atlas project, environment, and read-only intent. Schema samples, indexes, query plans, and collection metadata provide context for proposed query or index changes. Small result windows and projections limited to necessary fields reduce exposure. -1. Start by confirming the cluster, database, collection, Atlas project, environment, and read-only intent before querying data. -2. Inspect schema samples, indexes, query plans, and collection metadata before recommending query or index changes. -3. Keep result windows small and project only the fields needed to answer the question. -4. Summarize proposed writes, index changes, Atlas actions, or migration steps before removing `--readOnly` or changing credentials. - -## Operate Carefully +## Safe Operation - The catalog entry starts MongoDB MCP with `--readOnly` and a Vault-backed connection string by default. -- MongoDB data can contain production records, PII, secrets, and customer information. Avoid broad scans and redact sensitive fields in summaries. -- For Atlas API workflows, configure the upstream server with least-privilege Atlas service account credentials instead of a database connection string. -- Avoid this Caplet when the task only needs local ODM models, migrations, or application code. +- Removing `--readOnly`, changing credentials, writing data, changing indexes, or performing Atlas actions requires review of the proposed target and effect. +- MongoDB data can contain production records, PII, secrets, and customer information. Broad scans should be avoided, and sensitive fields should be redacted from summaries. +- Atlas API access should use least-privilege Atlas service account credentials instead of a database connection string. diff --git a/caplets/neon/CAPLET.md b/caplets/neon/CAPLET.md index e41d42b7..225c5898 100644 --- a/caplets/neon/CAPLET.md +++ b/caplets/neon/CAPLET.md @@ -18,19 +18,14 @@ mcpServer: # Neon -Use this Caplet when an agent needs live Neon Postgres context for projects, branches, databases, roles, SQL queries, connection details, or Neon documentation. +## Project and Query Scope -## First Workflow +Establish the Neon organization, project, branch, database, and role before inspecting state. Branch, schema, migration, and query context should be reviewed before SQL or project changes. -1. Start by confirming the Neon organization, project, branch, database, and role before querying state. -2. Inspect branch, schema, migration, and query context before suggesting SQL or project changes. -3. Scope the MCP URL after install with `projectId`, `readonly=true`, or `category` query parameters when the task has a narrow target. -4. Use read-only analysis for query tuning, schema review, and branch discovery before executing SQL. -5. Summarize the target branch, database, role, SQL, and expected data effect before mutating anything. +After installation, the MCP URL can be scoped with `projectId`, `readonly=true`, or `category` query parameters. Read-only analysis is appropriate for query tuning, schema review, and branch discovery. -## Operate Carefully +## Safe Operation -- Neon recommends MCP usage for development and testing. Do not connect production databases or PII-bearing projects unless the operator has explicitly accepted that risk. -- SQL and branch operations can alter data, credentials, costs, or application behavior. Confirm exact targets before writes. -- Keep connection strings and role credentials out of summaries. -- Avoid this Caplet when the task only needs local migration files, ORMs, or application code. +- Neon recommends MCP usage for development and testing. Production databases or PII-bearing projects should be connected only after the operator explicitly accepts the risk. +- SQL and branch operations can alter data, credentials, costs, or application behavior. The target branch, database, role, SQL, and expected data effect require confirmation before mutation. +- Connection strings and role credentials must not appear in summaries. diff --git a/caplets/notion/CAPLET.md b/caplets/notion/CAPLET.md index 5d7d9b0c..e5d486db 100644 --- a/caplets/notion/CAPLET.md +++ b/caplets/notion/CAPLET.md @@ -18,18 +18,13 @@ mcpServer: # Notion -Use this Caplet when an agent needs live Notion workspace context for pages, databases, data sources, views, tasks, docs, search, or workspace knowledge. +## Targeting and Inspection -## First Workflow +Exact page URLs, database IDs, data source IDs, teamspace names, or focused search terms reduce unnecessary workspace scans. The target page, database, view, or `self` context should be fetched before content is created or updated. Database properties, templates, and view filters should be inspected before changing page properties, views, or data sources. -1. Start with exact page URLs, database IDs, data source IDs, teamspace names, or search terms instead of broad workspace scans. -2. Fetch the target page, database, view, or `self` context before creating or updating content. -3. Inspect database properties, templates, and view filters before changing page properties, views, or data sources. -4. Confirm the parent page, database, move target, duplicate target, and visible workspace effect before writes. +## Safe Operation -## Operate Carefully - -- Notion MCP can read and write with the connected user's workspace access. Enable human confirmation for workflows that create, update, move, or duplicate content. -- Treat search results and connected workspace content as potentially sensitive and vulnerable to prompt injection. -- Keep private page content, customer data, and internal planning details out of unnecessary summaries. -- Avoid this Caplet when the task only needs local Markdown files or static Notion API documentation. +- Notion MCP reads and writes with the connected user's workspace access. Workflows that create, update, move, or duplicate content should require human confirmation. +- Before a write, confirm the parent page, database, move or duplicate target, and visible workspace effect. +- Search results and connected workspace content may be sensitive and may contain prompt-injection attempts. +- Private page content, customer data, and internal planning details should be excluded from unnecessary summaries. diff --git a/caplets/npm/CAPLET.md b/caplets/npm/CAPLET.md index bb18dff1..34ff8dc1 100644 --- a/caplets/npm/CAPLET.md +++ b/caplets/npm/CAPLET.md @@ -17,17 +17,15 @@ openapiEndpoint: # npm Registry -Use this Caplet when the agent needs public npm package facts before choosing dependencies, checking versions, comparing package health, or validating registry metadata. +## Package Lookups -## First Workflow +- `get_dist_tags` and `get_package_version` provide focused lookups when the package name and version are known. +- `get_package` returns release history, maintainers, versions, and package metadata together. +- `search_packages` supports discovery, but candidate packages should be inspected directly before selection. +- Registry facts should be cross-checked with the local lockfile and test evidence before dependency changes. -1. Use `get_dist_tags` or `get_package_version` when the package name and version are known. -2. Use `get_package` when you need release history, maintainers, versions, and package metadata together. -3. Use `search_packages` for discovery, then inspect exact packages before recommending one. -4. Pair package facts with local lockfile and test evidence before changing dependencies. +## Limits and Safety -## Operate Carefully - -- Registry metadata can be stale relative to the local lockfile. Check the project dependency state before editing. -- Package search ranking is not a safety signal. Inspect maintainers, versions, and vulnerability context before suggesting adoption. -- Use OSV for vulnerability lookups; this Caplet provides package metadata, not a complete security review. +- Registry metadata can be stale relative to the local lockfile, so the project's actual dependency state remains authoritative. +- Package search ranking is not a safety signal. Maintainers, versions, and vulnerability context require separate inspection before adoption. +- This Caplet provides package metadata, not a complete security review. diff --git a/caplets/osv/CAPLET.md b/caplets/osv/CAPLET.md index 9c5ceeb7..4a0d1cff 100644 --- a/caplets/osv/CAPLET.md +++ b/caplets/osv/CAPLET.md @@ -113,17 +113,15 @@ httpApi: # OSV Vulnerabilities -Use this Caplet when the agent needs known vulnerability data for package versions, package URLs, source commits, or vulnerability identifiers. +## Query Reference -## First Workflow +- An exact ecosystem, package name, and version provides the most specific dependency check. +- Package URLs (purls) are suitable when dependency tooling has already produced normalized identifiers. +- Related dependency checks can be submitted as a batch. +- An OSV, CVE, or GHSA identifier can be used to retrieve the full vulnerability record and remediation context. -1. Prefer exact ecosystem, package name, and version when checking a dependency. -2. Use purls when dependency tooling already produced normalized package URLs. -3. Batch related dependency checks instead of issuing many single-package calls. -4. Fetch the vulnerability record when an OSV, CVE, or GHSA ID needs explanation or remediation context. - -## Operate Carefully +## Limits - OSV results are read-only and public, but absence of a result is not proof that a dependency is safe. -- Match ecosystem names exactly, such as `npm`, `PyPI`, `Maven`, `Go`, `crates.io`, `Packagist`, `RubyGems`, `NuGet`, `Debian`, `Alpine`, or `OSS-Fuzz`. -- Use package registry Caplets for release metadata and local project tooling for the actual installed version. +- Ecosystem names must match OSV exactly, including `npm`, `PyPI`, `Maven`, `Go`, `crates.io`, `Packagist`, `RubyGems`, `NuGet`, `Debian`, `Alpine`, and `OSS-Fuzz`. +- The actual installed version should be verified with local project tooling. diff --git a/caplets/pagerduty/CAPLET.md b/caplets/pagerduty/CAPLET.md index 7c7b15d0..a9c1bb0e 100644 --- a/caplets/pagerduty/CAPLET.md +++ b/caplets/pagerduty/CAPLET.md @@ -29,18 +29,12 @@ mcpServer: # PagerDuty -Use this Caplet when an agent needs PagerDuty incident, service, schedule, escalation policy, event orchestration, on-call, or operational response context. +## Operational Scope -## First Workflow +Establish the PagerDuty account and API host together with the exact incident ID, service, team, schedule, escalation policy, user, or time window. Current incident state, responders, escalation policy, timeline, notes, alerts, and service context should be inspected before action. Schedule and on-call lookups provide context for handoffs, overrides, or escalation changes. -1. Start by confirming the PagerDuty account, API host, incident ID, service, team, schedule, escalation policy, user, or time window. -2. Inspect current incident state, responders, escalation policy, timeline, notes, alerts, and related service context before taking action. -3. Use schedule and on-call lookups before proposing handoffs, overrides, or escalation changes. -4. Summarize the target incident, service, user, schedule, and expected responder effect before mutating anything. +## Safe Operation and Setup -## Operate Carefully - -- PagerDuty changes can page people, alter incident response, affect escalation, or change operational accountability. Prefer read-only inspection before writes. -- The default catalog entry does not pass the upstream `--enable-write-tools` flag. Add it only when write operations are intentionally needed. -- For EU accounts, update `PAGERDUTY_API_HOST` to the EU API host before starting the server. -- Avoid this Caplet when the task only needs local runbooks or postmortem files. +- PagerDuty changes can page people, alter incident response, affect escalation, or change operational accountability. Read-only inspection should precede writes, and the target incident, service, user, schedule, and expected responder effect require confirmation. +- The default catalog entry does not pass the upstream `--enable-write-tools` flag. Operators should add it only when write operations are intentionally needed. +- EU accounts require `PAGERDUTY_API_HOST` to be set to the EU API host before server startup. diff --git a/caplets/playwright/CAPLET.md b/caplets/playwright/CAPLET.md index 0411e990..89151b4f 100644 --- a/caplets/playwright/CAPLET.md +++ b/caplets/playwright/CAPLET.md @@ -35,17 +35,11 @@ mcpServer: # Playwright -Use this Caplet when the agent needs an isolated browser automation surface for frontend debugging, accessibility checks, visual inspection, or end-to-end testing workflows. +## Test Workflow -## First Workflow +A local or preview URL is the starting point for an isolated browser session. Visible state, the accessibility tree, console errors, network behavior, and screenshots provide evidence before interaction. The smallest reproducible user flow should prove or disprove the issue, with concise evidence retained for the resulting change or review. -1. Open the target local or preview URL and wait for the page to settle. -2. Inspect visible state, accessibility tree, console errors, network behavior, or screenshots before acting. -3. Reproduce the smallest user flow that proves or disproves the issue. -4. Capture concise evidence for the code change or review. +## Safe Operation -## Operate Carefully - -- This Caplet runs a browser runtime. Keep tests scoped to the target app and avoid unrelated browsing. -- Prefer this over Browser Use for isolated test flows; use Browser Use only when the user's real signed-in browser context matters. -- If browser setup is missing, treat the Caplet as unavailable until setup verification succeeds rather than improvising shell installs. +- This Caplet runs an isolated browser runtime. Tests should remain scoped to the target application and avoid unrelated browsing. +- If browser setup is missing, the Caplet remains unavailable until setup verification succeeds; operators should use the declared setup rather than improvising shell installation. diff --git a/caplets/posthog/CAPLET.md b/caplets/posthog/CAPLET.md index 782bda20..04338082 100644 --- a/caplets/posthog/CAPLET.md +++ b/caplets/posthog/CAPLET.md @@ -17,15 +17,10 @@ mcpServer: # PostHog -Use this Caplet when an agent needs product analytics or feature-flag context from PostHog before planning, debugging, or validating a change. +## Analysis Scope -## First Workflow +A concrete product question, feature flag, experiment, event, or time window keeps analysis bounded. Trends, funnels, retention, and HogQL results provide evidence before conclusions are drawn. Feature flags, experiments, and rollout state should be inspected before dependent code changes. Session replay and event details should be limited to the debugging context needed. -1. Start from a concrete product question, feature flag, experiment, event, or time window. -2. Read trends, funnels, retention, or HogQL results before drawing conclusions. -3. Inspect feature flags, experiments, and rollout state before changing code that depends on them. -4. Use session replay or event details only for the minimal debugging context needed. +## Safe Operation -## Operate Carefully - -PostHog MCP includes mutating tools for flags, insights, dashboards, and other project state. Prefer read-only inspection first, review planned mutations, and keep OAuth access scoped to the PostHog organization and project you intend to expose. +PostHog MCP includes mutating tools for flags, insights, dashboards, and other project state. Read-only inspection should precede mutation, planned changes should be reviewed, and OAuth access should remain scoped to the intended PostHog organization and project. diff --git a/caplets/pypi/CAPLET.md b/caplets/pypi/CAPLET.md index c7382c97..24d1a9a0 100644 --- a/caplets/pypi/CAPLET.md +++ b/caplets/pypi/CAPLET.md @@ -18,17 +18,14 @@ openapiEndpoint: # PyPI Registry -Use this Caplet when the agent needs public PyPI package facts before choosing dependencies, checking versions, inspecting release files, or validating package metadata. +## Package lookup -## First Workflow +- `get_project` returns current project metadata, release history, project URLs, and vulnerability records included by PyPI. +- `get_release` provides details for an exact project version. +- `get_simple_project` provides Simple API file links and hashes for dependency tooling. -1. Use `get_project` for current project metadata, release history, URLs, and vulnerability records included by PyPI. -2. Use `get_release` when an exact version matters. -3. Use `get_simple_project` when dependency tooling needs Simple API file links and hashes. -4. Pair registry facts with the local lockfile, Python environment, and tests before changing dependencies. +Cross-check registry results against the repository lockfile, the active Python environment, and tests before changing dependencies. -## Operate Carefully +## Limits -- PyPI metadata is read-only but not a full supply-chain assessment. -- Use OSV for cross-ecosystem vulnerability checks and local tooling for the actually installed version. -- Deprecated XML-RPC APIs are intentionally excluded; use these JSON endpoints for agent workflows. +PyPI metadata is read-only, can become stale, and is not a complete supply-chain assessment. Verify the version that is actually installed locally. Deprecated XML-RPC APIs are intentionally excluded; the supported operations use PyPI's JSON and Simple API endpoints. diff --git a/caplets/repo-cli/CAPLET.md b/caplets/repo-cli/CAPLET.md index aa3c580e..f25efa56 100644 --- a/caplets/repo-cli/CAPLET.md +++ b/caplets/repo-cli/CAPLET.md @@ -38,6 +38,24 @@ cliTools: # Repository CLI -Use this Caplet to expose a small, typed set of local repository commands without giving an agent arbitrary shell access. +## Prerequisites -Project Binding is required because every command is meant to run against the attached repository. The bound root prevents the agent from accidentally treating an unrelated working directory as the target project. +Repository CLI requires Project Binding. Every command runs from the bound repository root so that an unrelated working directory cannot become the target project. + +The bound environment must provide Git and pnpm. The repository must also define the pnpm `test` script used by `package_test`. + +## Available commands + +- `git_status` reports the concise working-tree status. +- `git_current_branch` prints the current branch name. +- `package_test` runs the repository's pnpm test script with a two-minute timeout. + +## Safety boundary + +This Caplet exposes only the commands and fixed arguments declared in frontmatter; it is not arbitrary shell access. The Git commands are read-only. The test command may execute repository-defined scripts, so operators should review the bound project's test configuration and account for any services, credentials, or files those tests use. + +To add or change a command, update the curated `cliTools.actions` declaration and keep its arguments, timeout, and read-only annotation explicit. + +## Troubleshooting + +If a command targets the wrong directory, correct the Project Binding rather than adding path arguments. If a command is unavailable, verify the required executable and repository script in the bound environment. diff --git a/caplets/sentry/CAPLET.md b/caplets/sentry/CAPLET.md index 1570c9e6..a2078f6d 100644 --- a/caplets/sentry/CAPLET.md +++ b/caplets/sentry/CAPLET.md @@ -17,15 +17,14 @@ mcpServer: # Sentry -Use this Caplet when an agent needs live Sentry context while debugging production errors, investigating traces, or checking release health. +## Investigation scope -## First Workflow +Identify the organization, project, environment, release, issue, trace, and time window that bound the investigation. Narrow targets reduce noise and limit exposure of event data. -1. Narrow by organization, project, environment, release, issue, trace, or time window before querying. -2. Inspect issue frequency, recent events, stack traces, tags, breadcrumbs, and suspect commits before proposing fixes. -3. Correlate deploys or releases with new errors when a regression is suspected. -4. Bring back the smallest evidence set needed to guide code changes or triage. +## Evidence and diagnosis -## Operate Carefully +Inspect issue frequency, recent events, stack traces, tags, breadcrumbs, and suspect commits before deciding on a fix. When a regression is suspected, correlate new errors with deployments or releases. Retain only the smallest evidence set needed for code changes or incident triage. -Sentry data can contain user, request, and environment details. Ask for narrow projects and time windows, summarize only the needed debugging context, and review any mutating tool calls before applying changes to Sentry state. +## Safe operation + +Sentry events can contain user, request, and environment details. Keep project and time-window access narrow, and summarize relevant debugging signals without reproducing unnecessary sensitive data. Review mutating operations and their target state before applying changes in Sentry. diff --git a/caplets/sourcegraph/CAPLET.md b/caplets/sourcegraph/CAPLET.md index d011194a..e33e8268 100644 --- a/caplets/sourcegraph/CAPLET.md +++ b/caplets/sourcegraph/CAPLET.md @@ -16,17 +16,14 @@ mcpServer: # Sourcegraph -Use this Caplet when the agent needs broad code search, repository navigation, or cross-repository context from Sourcegraph. +## Search scope -## First Workflow +Build queries around a precise symbol, file path, package name, migration pattern, or repository filter. Inspect representative matches before drawing conclusions across repositories, and retain enough surrounding source context for review. -1. Start with a precise symbol, file path, package name, migration pattern, or repository filter. -2. Inspect representative matches before generalizing across repositories. -3. Use references and examples to guide local implementation, then verify against the target repo. -4. Bring back code-search evidence with enough source context for review or planning. +## Using search evidence -## Operate Carefully +Sourcegraph references and examples can inform local implementation or planning, but they should be verified against the target repository. Results are only as current as the indexed revision. -- Sourcegraph answers are only as current as the indexed repositories. -- Do not use broad search as a substitute for reading the local repository when it is available. -- For self-managed Sourcegraph, make sure the runtime is pointed at the intended host before using private code search. +## Host and privacy boundary + +For self-managed Sourcegraph, configure the runtime for the intended host before searching private code. Confirm that the connected instance and repository scope are appropriate for the code being investigated. diff --git a/caplets/stripe/CAPLET.md b/caplets/stripe/CAPLET.md index d5117d4f..80a9b7b3 100644 --- a/caplets/stripe/CAPLET.md +++ b/caplets/stripe/CAPLET.md @@ -18,18 +18,16 @@ mcpServer: # Stripe -Use this Caplet when an agent needs live Stripe context for payments, customers, subscriptions, invoices, refunds, reports, account settings, API behavior, or Stripe documentation. +## Prerequisites -## First Workflow +Confirm the intended Stripe mode, account, and workspace before reading or changing resources. Resource checks should include exact IDs, amounts, currency, `livemode` status, and relevant event history. -1. Start in the intended Stripe mode, account, and workspace context before reading or changing resources. -2. Search documentation and API resource details before calling write operations or proposing integration code. -3. Inspect exact resource IDs, amounts, currency, livemode status, and event history before acting. -4. Summarize the customer, payment, invoice, subscription, refund, or report target before mutating anything. +## Safe operation -## Operate Carefully +Inspect Stripe documentation and current API resource state before writes or integration changes. Before mutating a customer, payment, invoice, subscription, refund, report, or account setting, review the exact target and expected result. -- Stripe operations can affect money movement, customer billing, disputes, accounting, and compliance. Prefer read-only inspection before writes. -- Confirm test mode versus live mode explicitly before refunding, canceling, updating subscriptions, or changing account configuration. -- Do not expose payment method details, customer PII, API keys, webhook secrets, or restricted report data in summaries. -- Avoid this Caplet when the task only needs local SDK usage or static API documentation and no live account context. +Stripe operations can affect money movement, customer billing, disputes, accounting, and compliance. Prefer read-only inspection, and explicitly confirm test mode versus live mode before refunds, cancellations, subscription updates, or account-configuration changes. + +## Sensitive data + +Do not reproduce payment method details, customer PII, API keys, webhook secrets, or restricted report data in logs or summaries. diff --git a/caplets/supabase/CAPLET.md b/caplets/supabase/CAPLET.md index 9bf73063..f4de5fe8 100644 --- a/caplets/supabase/CAPLET.md +++ b/caplets/supabase/CAPLET.md @@ -18,19 +18,16 @@ mcpServer: # Supabase -Use this Caplet when an agent needs Supabase project, database, schema, branch, storage, edge function, auth, or documentation context. +## Prerequisites and scope -## First Workflow +Confirm the Supabase organization, project reference, branch, and environment before accessing project state. After installation, the `project_ref` query parameter can scope access to a specific project. For investigation-only access, configure `read_only=true` or feature-group filtering on the MCP URL. -1. Start by confirming the Supabase organization, project reference, branch, and environment before querying project state. -2. Prefer read-only discovery of schemas, tables, policies, migrations, functions, and storage buckets before making changes. -3. Scope high-risk work to a specific project with the `project_ref` query parameter after install when possible. -4. Use `read_only=true` or feature-group filtering on the MCP URL for investigation-only workflows. -5. Summarize intended SQL, policy, migration, storage, or function changes before executing them. +## Safe operation -## Operate Carefully +Inspect schemas, tables, policies, migrations, functions, and storage buckets before making changes. Review intended SQL, RLS policy effects, generated migrations, storage operations, function changes, and branch targets before execution. -- Supabase's own guidance treats MCP access as best suited for development and testing. Do not connect production projects unless the operator has explicitly accepted the risk. -- Database and auth policy changes can expose data or break applications. Review SQL, RLS policy effects, generated migrations, and branch targets carefully. -- Avoid handling PII or secrets through agent-visible prompts and logs. -- Avoid this Caplet when the task only needs local migration files or application code without live Supabase state. +Supabase recommends MCP access primarily for development and testing. Connect a production project only after the operator has explicitly accepted the risk. Database and authentication policy changes can expose data or break applications. + +## Sensitive data + +Keep PII and secrets out of captured prompts and logs. Use narrow project, feature, and read-only scopes to reduce unnecessary data exposure. diff --git a/caplets/terraform/CAPLET.md b/caplets/terraform/CAPLET.md index ad45f967..073d9e95 100644 --- a/caplets/terraform/CAPLET.md +++ b/caplets/terraform/CAPLET.md @@ -32,18 +32,16 @@ mcpServer: # Terraform -Use this Caplet when an agent needs Terraform Registry context for providers, modules, policies, or HCP Terraform and Terraform Enterprise workspace context exposed to the server. +## Prerequisites -## First Workflow +Docker must be available for the catalog runtime. Before relying on results, confirm the Terraform version, provider or module source, workspace, organization, and backend assumptions. -1. Start with read-only Registry lookups for provider, module, resource, data source, and policy documentation. -2. Confirm Terraform version, provider source, module source, workspace, organization, and backend assumptions before proposing changes. -3. Use HCP Terraform or Terraform Enterprise operations only when the server runtime has been configured with the intended token and address. -4. Review generated Terraform recommendations against project policy, security, cost, and compliance requirements before implementation. +The default catalog entry starts the public Registry-capable Docker server without checked-in HCP Terraform credentials. HCP Terraform or Terraform Enterprise access requires the runtime to be configured with the intended address and a least-privilege token scoped to the required organization or workspace. -## Operate Carefully +## Registry and workspace use -- Terraform recommendations can affect infrastructure, cost, data access, and compliance once applied. Treat generated plans as suggestions until reviewed against the project. -- HCP Terraform and Terraform Enterprise tokens should be least-privilege and scoped to the intended organization or workspace. -- The default catalog entry starts the public Registry-capable Docker server without checked-in HCP credentials. -- Avoid this Caplet when the task only needs to edit local Terraform files without external provider, module, or workspace context. +Begin with read-only Registry lookups for provider, module, resource, data source, and policy documentation. Treat generated Terraform recommendations and plans as review material rather than approved changes. + +## Safe operation + +Terraform changes can affect infrastructure, cost, data access, security, and compliance. Review recommendations and plans against project policy and the exact workspace before implementation or apply. diff --git a/caplets/vercel/CAPLET.md b/caplets/vercel/CAPLET.md index 99fdcd9b..d2d80f87 100644 --- a/caplets/vercel/CAPLET.md +++ b/caplets/vercel/CAPLET.md @@ -18,17 +18,16 @@ mcpServer: # Vercel -Use this Caplet when an agent needs live Vercel context for teams, projects, deployments, deployment logs, domains, environment configuration, or Vercel documentation. +## Targeting -## First Workflow +Identify the Vercel team, project, deployment, branch, domain, or request ID before searching. Confirm the target team and project before changing domains, environment variables, deployment settings, or aliases. -1. Start by identifying the Vercel team, project, deployment, branch, domain, or request ID before searching broadly. -2. Inspect project and deployment state before using logs or docs to explain failures. -3. Use deployment logs and build/runtime evidence to distinguish application errors from Vercel platform or configuration issues. -4. Confirm the target team and project before changing domains, environment variables, deployment settings, or aliases. +## Investigation -## Operate Carefully +Inspect project and deployment state before consulting logs or documentation. Deployment logs and build or runtime evidence can distinguish application failures from Vercel platform or configuration problems. -- Vercel changes can affect production traffic, secrets, previews, and custom domains. Prefer read-only inspection before writes. -- Treat environment variables and build logs as sensitive; summarize the relevant signal without exposing secret values. -- Avoid this Caplet when the task only needs local Next.js, frontend, or repo configuration analysis. +## Safe operation + +Vercel changes can affect production traffic, previews, secrets, and custom domains. Prefer read-only inspection before writes, and review the production target and expected traffic impact before mutation. + +Environment variables and build logs may contain sensitive data. Preserve only the relevant diagnostic signal and do not reproduce secret values. diff --git a/docs/plans/2026-07-13-001-refactor-caplet-operator-readme-separation-plan.md b/docs/plans/2026-07-13-001-refactor-caplet-operator-readme-separation-plan.md new file mode 100644 index 00000000..a14eb79d --- /dev/null +++ b/docs/plans/2026-07-13-001-refactor-caplet-operator-readme-separation-plan.md @@ -0,0 +1,692 @@ +--- +title: Caplet Operator README Separation - Plan +type: refactor +date: 2026-07-13 +topic: caplet-operator-readme-separation +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +deepened: 2026-07-13 +--- + +# Caplet Operator README Separation - Plan + +## Goal Capsule + +- **Objective:** Make YAML frontmatter and supported bundle files explicitly referenced from it the sole sources of Caplet runtime behavior, and redefine the Markdown body as independently managed operator documentation. +- **Product authority:** The Code Mode-first direction in `STRATEGY.md`, canonical Caplet vocabulary in `CONTEXT.md` and `CONCEPTS.md`, and this Product Contract govern the cutover. This contract supersedes prior plan language that treats the Markdown body as agent-facing shared operating context. +- **Execution profile:** Land six dependency-ordered units. Characterize the current parser, setup, reload, installer, and agent-surface contracts before replacing body-bearing runtime paths; complete the official content cutover only after the runtime boundary is enforced. +- **Stop conditions:** Stop and re-plan if runtime change detection would need to hash arbitrary sibling files, if public catalog rendering would need to consume runtime configuration, if lock compatibility requires a version migration, or if implementation would introduce the deferred SQL store. +- **Tail ownership:** Each feature-bearing unit owns focused behavioral tests. The final unit owns generated documentation and catalog outputs, the `@caplets/core` Changeset, package/API checks, full verification, and removal of abandoned compatibility paths. +- **Open blockers:** None. + +--- + +## Product Contract + +### Summary + +A Caplet Markdown file remains one portable, catalog-friendly artifact with two independent projections: frontmatter supplies structured configuration, while the body supplies human operator documentation. The contract must translate later to structured storage fields plus a `body` value without changing runtime or content semantics. + +### Problem Frame + +Code Mode does not expose the Markdown body through its normal capability surface, so authors are maintaining agent instructions that agents neither use nor can inspect productively. The body is nevertheless copied into normalized runtime configurations and included in setup content hashing, which makes documentation participate in runtime lifecycle decisions despite providing no runtime value. + +This ambiguity couples a shareable document format to the current file-backed configuration representation. It also makes a future structured store harder to model because runtime configuration and operator content do not yet have distinct ownership or update semantics. + +### Key Decisions + +- **One artifact, two projections:** `CAPLET.md` remains the canonical shareable artifact. Frontmatter and body are interpreted independently rather than creating separate file formats or parallel catalog parsing contracts. +- **Operator README body:** The body documents prerequisites, setup context, troubleshooting, safety considerations, and other Caplet-specific information for human operators. It is not agent operating context. +- **Frontmatter-only runtime authority:** Every runtime-affecting value originates in frontmatter or in a bundle file referenced from frontmatter. Presentation-only frontmatter fields may remain presentation-only; the body never supplies configuration. +- **Independent update lifecycles:** A body-only edit is a content update. It may be detected, published, installed, and rendered without changing runtime configuration, triggering runtime reload, or requiring setup reapproval. +- **No replacement instruction payload:** The cutover does not move the body into a new free-form agent-instruction field. Compact agent-visible capability descriptions remain structured frontmatter metadata; dedicated `useWhen` and `avoidWhen` fields are removed. +- **Clean content cutover:** Official Caplet bodies, examples, templates, and authoring documentation adopt the operator README contract in the same change. + +```mermaid +flowchart TB + M[CAPLET.md shareable artifact] --> F[YAML frontmatter] + M --> B[Markdown body] + F --> C[Structured Caplet configuration] + C --> R[Runtime and agent capability surfaces] + F --> X[References to bundle files] + X --> C + B --> O[Catalog and operator rendering] + F --> U[Runtime configuration lifecycle] + B --> V[Operator content lifecycle] +``` + +The body has no path to runtime or agent capability surfaces. Referenced bundle files reach runtime configuration only through explicit frontmatter references. + +### Actors + +- A1. **Caplet operator:** Installs, configures, troubleshoots, and maintains a Caplet, and reads the body as operational documentation. +- A2. **Caplet author:** Publishes one portable Markdown artifact whose frontmatter and README body have distinct responsibilities. +- A3. **Agent caller:** Discovers and invokes Caplets through Code Mode, progressive exposure, direct exposure, Attach, or native integrations without receiving the README body as instructions or call context. +- A4. **Catalog or operator surface:** Ingests and renders the README body for humans while keeping it outside agent-callable capability data. + +### Key Flows + +- F1. **Share and ingest** + - **Trigger:** A Caplet author or operator supplies a `CAPLET.md` file or Caplet bundle. + - **Actors:** A1, A2, A4 + - **Steps:** The artifact is divided into frontmatter and body projections. Frontmatter is validated as structured Caplet data; the body is retained as operator documentation. + - **Outcome:** One portable artifact supports both runtime configuration and human documentation without mixing their semantics. + +- F2. **Configure and expose** + - **Trigger:** A host loads or reloads a Caplet. + - **Actors:** A1, A3 + - **Steps:** Runtime behavior is derived only from frontmatter and explicitly referenced bundle files. Agent-facing discovery and invocation surfaces receive only their structured, frontmatter-derived metadata and backend capabilities. + - **Outcome:** No README body content enters runtime configuration or an agent capability surface. + +- F3. **Publish a body-only update** + - **Trigger:** An author changes only prerequisites, troubleshooting, or other README content. + - **Actors:** A1, A2, A4 + - **Steps:** The artifact change is recognized as an operator content update and made available to human-facing surfaces. + - **Outcome:** Operators receive the documentation update without runtime configuration churn or setup reapproval. + +- F4. **Publish a configuration update** + - **Trigger:** An author changes frontmatter or a runtime input referenced from frontmatter. + - **Actors:** A1, A2, A3 + - **Steps:** The structured configuration projection changes and follows the existing runtime validation, approval, reload, and exposure lifecycle applicable to that field or referenced input. + - **Outcome:** Runtime-affecting changes remain detectable and governed independently of README content. + +### Requirements + +**Artifact and authority** + +- R1. A Caplet Markdown file must remain a shareable artifact composed of fenced YAML frontmatter and a Markdown body with independently defined semantics. +- R2. Frontmatter and supported bundle files explicitly referenced from it must be the sole sources of runtime-affecting Caplet values, including backend configuration, authentication, exposure, runtime requirements, setup, project binding, and agent-visible capability descriptions. +- R3. Presentation-only frontmatter metadata may remain outside runtime backend configuration without weakening the rule that no runtime value originates in the body. +- R4. The Markdown body must be retained as publishable operator documentation for prerequisites, setup context, troubleshooting, safety considerations, and general Caplet-specific guidance; authors must not place secrets, credentials, private endpoints, customer data, or other sensitive operational material in it. +- R5. Files within a Caplet bundle may remain runtime inputs only when frontmatter references them through a supported path or interpolation mechanism. +- R6. The body must not define, interpolate, override, or implicitly reference runtime configuration. + +**Runtime and agent isolation** + +- R7. The body must not be present in normalized runtime configurations, including runtime configurations expanded from a multi-backend Caplet file. +- R8. Caplets must not expose the body through agent discovery, search, detail, declaration, registration, invocation, result, Attach, or native integration surfaces. +- R9. Backend managers and downstream capability calls must not receive the body as hidden context, instructions, metadata, or configuration. +- R10. Independent host filesystem access to a source `CAPLET.md` file is outside the Caplets exposure guarantee; Caplets itself must provide no agent-facing route to the body. +- R11. Agent-visible Caplet descriptions must continue to come from frontmatter; backend-owned tool metadata may continue to include downstream selection hints. +- R12. No new free-form agent-instruction field may replace the body as part of this cutover. + +**Content and configuration lifecycles** + +- R13. Human-facing catalog and operator surfaces must be able to retain and render the body without making it agent-callable. +- R14. A body-only change must be classified as an operator content update rather than a runtime configuration update. +- R15. A body-only change must not trigger runtime reconfiguration, runtime restart or reload, or setup reapproval. +- R16. Artifact integrity and update detection may cover both projections, but they must preserve the distinction between an operator content change and a runtime-affecting configuration change. +- R17. A frontmatter change or a change to a runtime input referenced from frontmatter must continue through the applicable runtime validation, approval, reload, and exposure lifecycle. + +**Migration and authoring contract** + +- R18. Existing valid third-party Caplet Markdown files must remain parseable without a compatibility mode, while their bodies acquire the new operator-only meaning immediately. +- R19. Official bundled Caplet bodies must be rewritten so agent-directed workflows become operator prerequisites, troubleshooting, safety, or reference documentation where still useful. +- R20. Agent guidance that remains necessary must move to an existing structured frontmatter field or backend-owned tool metadata rather than to another free-form instruction channel. +- R21. Caplet authoring documentation, generated reference material, examples, and templates must describe the body as an operator README and frontmatter as the only runtime authority. +- R22. Tests and historical assumptions that require body preservation in runtime configurations or expanded child configurations must be replaced by coverage of the two-projection contract. + +**Structured-storage readiness** + +- R23. The artifact contract must permit lossless import into and export from a future structured store where frontmatter maps to structured fields and the Markdown body maps to a distinct body value. +- R24. The meaning of runtime configuration and operator content must remain identical whether a Caplet originated from Markdown or a future structured store. + +### Acceptance Examples + +- AE1. **Agent inspection excludes README content. Covers R7-R12.** Given a body containing prerequisites and troubleshooting text, when an agent lists, searches, inspects, registers, or invokes the Caplet through any Caplets capability surface, then none of that body text or a body field is present. +- AE2. **Catalog renders README content. Covers R4, R13.** Given the same Caplet, when a human opens its catalog or operator presentation, then the Markdown body is available as rendered documentation and is clearly separate from structured frontmatter. +- AE3. **Body-only update is content-only. Covers R14-R16.** Given an installed Caplet whose frontmatter and referenced runtime inputs are unchanged, when only troubleshooting prose changes, then the content update is detectable and publishable without runtime reload or setup reapproval. +- AE4. **Frontmatter update remains runtime-affecting. Covers R2, R17.** Given an installed Caplet, when its backend, authentication, exposure, or runtime frontmatter changes, then the existing applicable runtime configuration lifecycle runs. +- AE5. **Referenced bundle input remains functional. Covers R5, R17.** Given frontmatter that references an OpenAPI document or another supported bundle file, when the Caplet loads, then the referenced file remains a runtime input and a change to it remains runtime-affecting. +- AE6. **Body cannot interpolate configuration. Covers R6.** Given Markdown body text that resembles a path, variable, secret reference, or backend directive, when the Caplet loads, then that text remains documentation and cannot alter runtime behavior. +- AE7. **Multi-backend body remains singular documentation. Covers R7, R18.** Given a multi-backend Caplet file with one README body, when child runtime configurations are expanded, then each child receives the appropriate frontmatter-derived configuration and none receives a copy of the body. +- AE8. **Markdown and structured storage preserve semantics. Covers R23-R24.** Given equivalent Caplet data imported from Markdown and from a future structured record, when each is rendered for an operator and projected for runtime use, then both produce equivalent operator content and runtime configuration without using body text as configuration. + +### Scope Boundaries + +- Implementing, selecting, or migrating to a SQL-backed runtime Caplet store is deferred. +- Designing a generic multi-file documentation or asset system is excluded; `CAPLET.md` remains the canonical portable README artifact. +- Adding a replacement free-form agent instruction channel is excluded. +- Automatically grading or rejecting subjective README prose is excluded. +- Preventing an agent with independent filesystem access from opening the shared source file is excluded; the guarantee applies to Caplets-owned capability surfaces. + +### Dependencies and Assumptions + +- Existing catalog and future operator renderers can transport and render the body for humans without registering it as agent-callable capability data. +- Existing frontmatter fields and referenced bundle-file mechanisms remain sufficient for every runtime behavior that must survive the cutover. +- Official body content can be reviewed and reclassified without preserving obsolete agent-directed prose solely for compatibility. + +### Sources and Research + +- `packages/core/src/caplet-files-bundle.ts:803-937,1258-1374,1498-1528,1645-1745` defines the strict frontmatter schema and currently copies the body into singular and expanded runtime configurations. +- `packages/core/src/config-runtime.ts:225-238,304-316` currently retains the optional body in normalized runtime configuration. +- `packages/core/src/setup/hash.ts:4-36` currently includes the body in setup content hashing. +- `packages/core/src/registry.ts:13-58,129-158`, `packages/core/src/exposure/projection.ts:417-450`, `packages/core/src/capability-description.ts:3-17`, and `packages/core/src/code-mode/declarations.ts:9-24,40-48` show that the examined agent-facing surfaces already omit the body. +- `apps/catalog/src/lib/markdown.ts:20-29` and `apps/catalog/src/components/CapletDetail.astro:17-22,75-109` show the existing human-facing body rendering path. +- `packages/core/test/caplet-files.test.ts:9-34,115-265` records current body-preservation and catalog-metadata compatibility expectations. +- `apps/docs/src/content/docs/reference/caplet-files.mdx:14-110` and `docs/plans/2026-06-29-001-feat-multi-backend-caplet-files-plan.md:42-62,199-213` retain the agent-facing body semantics this contract supersedes. +- `docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md:188-201` and `apps/catalog/src/lib/catalog-store.ts:1-12,40-69` demonstrate that existing SQL-backed storage is catalog-scoped rather than runtime Caplet configuration storage. + +--- + +## Planning Contract + +### Product Contract Preservation + +Product Contract changed: R2 clarifies referenced bundle inputs already allowed by R5; R4 makes the existing shareable/catalog-rendered README boundary explicit by excluding sensitive operational material. + +### Key Technical Decisions + +| Decision | Rationale | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Delete `body` from runtime types and values rather than filtering outputs | Backend dispatch receives complete Caplet configurations. Structural removal is the only boundary that prevents hidden body context from reaching managers, expanded children, or future adapters. | +| Produce fingerprints at a pure source-aware boundary | A versioned fingerprint snapshot is derived from validated pre-interpolation template semantics, logical declaring-source identity, and a declared-input reader. The producer does not import engine, installer, setup, Caplet-set manager, or Vault orchestration, which prevents circular authority. | +| Separate stable artifact semantics from volatile resolved execution | Setup and installer use the pre-interpolation snapshot, retaining `$env` and `$vault` reference syntax without resolving secrets. The engine also keeps a non-persisted fingerprint of resolved execution state so credential rotation can reload runtime state without entering lock or approval persistence. | +| Name three stable fingerprint scopes | A per-runtime-ID Caplet fingerprint covers one effective expanded child; an artifact runtime fingerprint covers the children owned by one installable artifact; a host configuration fingerprint covers the host’s sorted runtime-ID map plus enumerated runtime-wide behavior options. Each consumer uses the narrowest scope it owns. | +| Fingerprint validated semantics, not raw YAML or physical paths | Comments, key order, formatting, and different source/install roots are not runtime changes. Canonical frontmatter-derived templates plus logical declared-reference identities and content states are. | +| Keep logical and physical reference identity separate | Fingerprints use a logical identity relative to the declaring source. Resolvers use a private physical identity only to read content, enforce boundaries, and detect cycles; host paths and OS error prose never enter snapshots. | +| Traverse only declared runtime inputs | OpenAPI, Discovery, GraphQL, and Caplet-set references participate in the runtime fingerprint. README links, arbitrary siblings, catalog metadata, unreferenced assets, and `cwd` trees remain outside it. | +| Make Caplet-set runtime discovery authoritative | `capletsRoot` traversal must share the nested runtime loader’s discovery, precedence, duplicate, merge, and cycle semantics. It fingerprints the effective child configuration the runtime can load, not whichever files a broader bundle scan can find. | +| Gate reload before side effects | The engine compares both stable configuration and volatile resolved-execution fingerprints before registry replacement, exposure generation, manager updates, callable-state watcher resets, or reload notifications. Invalid candidates retain the last known-good configuration and fingerprints. | +| Keep artifact integrity distinct from runtime equality | Existing whole-artifact hashing continues to protect restore and local-drift safety. A trusted upstream artifact change with an equal artifact runtime fingerprint becomes a content-only update; a changed fingerprint follows the existing risk and replacement lifecycle. | +| Commit artifact bytes and lock baselines recoverably | Candidate validation and fingerprinting finish before mutation. A successful update is reported only after destination bytes and lock baselines form one recoverable matched pair; synchronous lock failure rolls back, and interrupted transitions are repaired or finalized before the next drift check. | +| Preserve lockfile v1 additively and safely | Keep `installedHash` and `risk.bodyHash` semantics unchanged. Add optional persistence-safe artifact runtime state, live-compute missing legacy baselines, and mark snapshots containing literal secrets or unportable host values live-only instead of persisting a guessing oracle or host correlate. | +| Preserve per-Caplet batch commits | Do not broaden the work into transactional multi-Caplet updates. Complete each Caplet’s artifact-and-lock commit before the next; a later failure may leave prior entries committed, and retry must safely no-op or reclassify them. | +| Preserve raw catalog rendering | Official and community catalog paths continue to retain raw Markdown and render the body for humans. Public browser access is operator presentation under R13, not a Caplets capability-surface leak under R8-R10. | +| Migrate prose without prose-locking tests | Rewrite official bodies and authoring guidance, but test durable parsing, routing, generation, and isolation behavior rather than exact README copy. | + +### High-Level Technical Design + +The cutover creates two data planes from the same portable artifact. Only stable template semantics and volatile resolved execution state reach runtime equality; operator content never does. + +```mermaid +flowchart TB + A[CAPLET.md or bundle source] --> T[Validated pre-interpolation templates] + A --> B[Operator README and artifact bytes] + T --> D[Declared runtime input reader] + D --> S[Versioned fingerprint snapshot] + T --> S + S --> C[Per-Caplet fingerprints] + S --> F[Artifact runtime fingerprint] + S --> G[Host configuration fingerprint] + C --> P[Setup approval] + F --> I[Installer update classifier] + G --> E[Engine reload gate] + T --> V[Environment and Vault resolution] + V --> R[Resolved execution fingerprint] + R --> E + B --> H[Whole-artifact integrity hash] + H --> I + B --> O[Catalog and operator rendering] + E --> X[Registry managers exposure native Attach and Code Mode] +``` + +The pure snapshot producer receives validated pre-interpolation semantics, a logical declaring-source identity, and a declared-input reader. It returns per-Caplet fingerprints, an artifact runtime fingerprint, a host configuration fingerprint, and safe input-state facts. The engine separately computes a non-persisted resolved-execution fingerprint after environment and Vault resolution. + +Declared references carry two identities: a fingerprinted logical identity normalized relative to the declaring source, and a private physical traversal identity used only for reads, boundary checks, and cycle detection. Read results are discriminated as present with digest, missing, or unreadable; snapshots contain neither absolute roots nor raw error text. + +```mermaid +sequenceDiagram + participant W as File event manual reload or secret refresh + participant L as Loader + participant F as Fingerprint producer + participant E as Engine + participant A as Agent adapters + W->>L: Load candidate templates and resolved runtime + alt Candidate invalid + L-->>E: Failure + E-->>E: Retain last known-good state + else Candidate valid + L->>F: Stable template inputs + F-->>E: Stable host configuration fingerprint + L-->>E: Resolved runtime projection + E->>E: Compute volatile resolved-execution fingerprint + alt Stable and resolved fingerprints equal + E-->>E: Runtime no-op + Note over E,A: No registry manager exposure or manifest activity + else Either fingerprint changed + E-->>E: Accept changed runtime state + E->>A: Existing reload and exposure lifecycle + end + end +``` + +Installer classification preserves local-drift safety, validates without mutation, and commits artifact bytes with lock baselines before status or indexing. + +```mermaid +flowchart TB + S[Update requested] --> D{Installed artifact locally modified} + D -->|yes without force| X[Fail closed] + D -->|no or forced| V{Candidate valid and fingerprintable} + V -->|no| K[Keep installed bytes and lock unchanged] + V -->|yes| A{Whole-artifact hash equal} + A -->|yes| N[No-op] + A -->|no| R{Artifact runtime fingerprint equal} + R -->|yes| C[Content-only candidate] + R -->|no| U[Runtime-affecting candidate] + U --> G[Existing risk and approval gate] + C --> M[Recoverable artifact and lock commit] + G --> M + M --> P[Report status and attempt catalog indexing] +``` + +### Implementation Constraints + +- Preserve the existing fenced-frontmatter, file-size, and body-size validity limits. An artifact that exceeds those limits is invalid, not a content-only update. +- Preserve last-known-good runtime behavior on parse, validation, or source-resolution failure; update the stored runtime fingerprint only after a successful load. +- Remove `useWhen` and `avoidWhen` from public JSON configuration and generated frontmatter schemas without adding compatibility aliases. +- Preserve frontmatter-derived `description` plus backend-owned tool, resource, resource-template, and prompt content. +- Do not hash `cwd` trees, arbitrary sibling files, Markdown links, or raw `capletsRoot` directories. Resolve only supported frontmatter-declared runtime inputs. +- Represent missing or unreadable declared inputs with safe deterministic categories so deletion and recovery are runtime changes without embedding filesystem error prose. +- Preserve materialized symlink handling, whole-artifact local-drift checks, and current restore force behavior. +- Keep content-only catalog indexing best-effort: an indexing failure may be reported after a successful local content update but must not roll back the installed artifact. +- Canonical persisted snapshots retain environment and Vault reference syntax. Resolved credentials and environment values participate only in the engine’s volatile in-memory execution fingerprint. +- Runtime snapshots with literal secret-bearing values or unportable absolute host values are live-only for installer comparison and must not be written to lockfiles, output, activity records, diagnostics, or telemetry. +- Validate and fingerprint an update candidate before replacing destination bytes. `--force` may override confirmed local drift and acknowledged risk, but it does not bypass candidate parsing, fingerprint construction, or declared-reference traversal. +- Begin catalog indexing only after the destination and lock commit succeeds. Normalize endpoint unavailability and thrown indexer failures into the ancillary indexing result without rolling back or misreporting the completed update. +- Preserve established per-Caplet batch atomicity: committed earlier entries remain committed if a later selected entry fails, and a retry recognizes those completed entries without treating them as drift. + +### Sequencing + +- U1 removes the forbidden runtime body bridge and establishes characterization coverage. +- U2 defines the shared runtime fingerprint and declared-reference inventory. +- U3 and U4 consume U2 independently for engine lifecycle and installer lifecycle. +- U5 depends on U1 and U4 so the authoring cutover follows the runtime boundary and content-update lifecycle. +- U6 follows U1-U5 and owns generated outputs, release metadata, final verification, and cleanup. + +### System-Wide Impact + +| Surface | Impact | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core runtime and published types | `CapletConfig` and `@caplets/core/config-runtime` lose the optional body field; consumers receive a compile-time API cut and a body-free normalized value. | +| Engine and agent adapters | README-only edits stop before registry, manager, exposure, native, MCP, Attach, and Code Mode notification work. Template or resolved-execution changes preserve existing fan-out. | +| Setup and approval | README, catalog-only, and secret-value rotation preserve artifact approval because stable templates retain reference syntax. Runtime templates and declared-input changes use the per-Caplet fingerprint. | +| Install, restore, Current Host, and dashboard | Update results distinguish content-only from runtime-affecting updates while retaining whole-artifact drift protection, recoverable artifact-and-lock commits, and post-commit catalog indexing. | +| Catalog, authoring, and documentation | Raw README rendering remains available; official Caplets and generated authoring references adopt operator-focused body semantics. | +| Release surface | The public core package changes and requires a Changeset. Generated schemas should remain unchanged; generated docs and the official catalog seed should change. | + +### Risks and Mitigations + +| Risk | Mitigation | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Consumers disagree about fingerprint scope | Define per-Caplet, per-artifact, and host-configuration aggregates in one versioned producer and add adapter-parity fixtures. | +| Resolved secrets enter persistent equality | Fingerprint pre-interpolation templates for setup/install, keep resolved-execution equality engine-local, and never persist raw values or resolution-derived digests. | +| Literal secrets or absolute host paths create a lock oracle | Mark unsafe snapshots live-only; compare installed and source state during the operation without writing optional runtime state or sensitive correlates. | +| Referenced file changes are suppressed as no-ops | Include every supported declared input with logical identity and safe present, missing, and unreadable states. | +| Documentation changes still trigger agent notifications | Require both stable and resolved engine fingerprints to be equal before the early no-op and test each notification exit. | +| Caplet-set traversal disagrees with runtime loading | Share the runtime discovery/precedence contract, handle `configPath` plus `capletsRoot`, separate logical identity from physical cycle keys, and test relocation. | +| Destination and lock baselines diverge on failure | Stage and validate before mutation, retain rollback state through lock commit, repair interrupted transitions before drift classification, and withhold success/indexing until commit. | +| `--force` mutates from an invalid or disappeared source | Keep candidate validity and fingerprint construction as non-bypassable gates before replacement. | +| Legacy or restored locks lack runtime state | Live-compute absent baselines, persist safe optional state after success even when restored bytes match the prior artifact hash, and keep lock version 1. | +| Multi-Caplet updates partially succeed | Preserve per-Caplet commits, finish each matched artifact-and-lock pair before advancing, and make retries idempotent for earlier successes. | +| Catalog indexing changes update success | Run indexing strictly post-commit and report unavailable/throwing indexers as ancillary outcomes in local and Current Host paths. | +| New update status is lost by a relay | Update local, remote, Current Host, activity, and dashboard unions/renderers together and exercise JSON plus human output. | +| Official rewrites remove useful agent selection guidance | Audit each agent-directed body statement; move only necessary compact capability context to `description` and keep the rest operator-facing. | + +### Planning Research + +- `docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md` establishes the pure parse versus runtime-aware loader boundary and warns against divergent validation/resolution paths. +- `docs/solutions/architecture-patterns/native-daemon-service-management.md` establishes configuration mutation and runtime lifecycle as separate ownership domains and favors clean cutover over parallel implementations. +- `docs/solutions/architecture-patterns/code-mode-repl-sessions.md` reinforces capability-scoped agent access and body-free discovery boundaries. +- `packages/core/src/engine.ts`, `packages/core/src/native/service.ts`, `packages/core/src/serve/native-session.ts`, `packages/core/src/serve/index.ts`, and `packages/core/src/serve/http.ts` establish the reload-to-agent-notification fan-out that the semantic no-op must stop. +- `packages/core/src/cli/install.ts`, `packages/core/src/cli/lockfile.ts`, and `packages/core/src/current-host/catalog-operations.ts` establish whole-artifact drift safety, v1 lock persistence, update result relays, and best-effort catalog indexing. +- `packages/core/src/caplet-source/parse.ts`, `packages/core/src/caplet-files.ts`, and `packages/core/src/caplet-sets.ts` show why canonicalization needs source provenance, separate logical and physical reference identities, and runtime-authoritative Caplet-set discovery rather than bundle-wide scanning. +- `packages/core/src/config.ts` and `packages/core/src/config-runtime.ts` establish that stable template semantics must be captured before environment/Vault interpolation, while the engine still needs volatile equality after resolution. + +--- + +## Implementation Units + +### U1. Remove the body from runtime configuration + +**Goal:** Make every normalized Caplet configuration structurally body-free while preserving raw Markdown for content consumers and compact capability descriptions for agents. + +**Requirements:** R1-R12, R18, R20, R22; F1-F2; AE1, AE6-AE7. + +**Dependencies:** None. + +**Files:** + +- `packages/core/src/caplet-files-bundle.ts` +- `packages/core/src/config.ts` +- `packages/core/src/config-runtime.ts` +- `packages/core/src/setup/hash.ts` +- `packages/core/test/caplet-files.test.ts` +- `packages/core/test/config.test.ts` +- `packages/core/test/backend-operation-dispatch.test.ts` +- `packages/core/test/registry.test.ts` +- `packages/core/test/code-mode-declarations.test.ts` +- `packages/core/test/attach-api.test.ts` + +**Approach:** + +- Keep fenced Markdown splitting and body guardrails, but remove the body argument and assignments from singular conversion, plural expansion, child normalization, and returned runtime maps. +- Delete `body` from every runtime union arm, normalized schema, common runtime schema, and interpolation-field allowance. Keep public config parsing strict so a forced legacy body field is rejected rather than silently retained. +- Remove the body from setup hash input immediately; U2 replaces the remaining hand-maintained configuration serialization with the shared per-Caplet runtime fingerprint. +- Preserve the existing registry, exposure, Attach, and Code Mode whitelists. Their role is characterization, not sanitization of a body-bearing source. +- Do not add a replacement free-form instruction property or alter backend manager contracts; dispatch becomes safe because its complete `CapletConfig` input is body-free. + +**Execution note:** Add characterization coverage before deleting the duplicated type/schema paths; a green outward-serialization test alone does not prove backend-manager isolation. + +**Patterns to follow:** + +- Strict frontmatter validation and singular/plural conversion in `packages/core/src/caplet-files-bundle.ts`. +- Explicit registry and projection whitelists in `packages/core/src/registry.ts` and `packages/core/src/exposure/projection.ts`. +- Existing direct-JSON body rejection coverage in `packages/core/test/config.test.ts`. + +**Test scenarios:** + +1. Load singular fixtures across all backend families with a unique README sentinel, fake YAML, a path-like string, and a secret-looking value; each normalized result has no body key or sentinel while frontmatter-derived fields and normalized references remain intact. +2. Load plural fixtures and assert every expanded child lacks body data while parent/shared and child-specific frontmatter inheritance remains unchanged. +3. Pass a parsed configuration through backend-operation dispatch for check, discovery, and invocation; manager adapters receive no body key or sentinel. +4. Serialize registry summary/detail, generated Code Mode declarations, and local/native Attach manifests; none contain body data, while descriptions remain present and authored `useWhen` or `avoidWhen` properties are absent. +5. Force a legacy body property through the internal normalized parser and assert the strict schema rejects it; direct JSON configuration continues rejecting the property. +6. Compare setup hashes for otherwise identical body-free configurations originating from different README bodies; the values match. Changing setup, auth, exposure, or backend configuration still changes the existing hash input. +7. Keep a backend-advertised resource or prompt named `README.md`; it remains downstream-owned content and is never replaced with the Caplet README. + +**Verification:** Every runtime type, parsed value, expanded child, manager input, and agent projection is body-free; catalog/raw Markdown code remains untouched and capability descriptions are unchanged. + +### U2. Establish the canonical runtime fingerprint + +**Goal:** Give source loaders, setup, engine, and installer one pure snapshot producer with explicit consumer-specific fingerprint scopes. + +**Requirements:** R2-R7, R14-R17, R23-R24; F2-F4; AE3-AE8. + +**Dependencies:** U1. + +**Files:** + +- `packages/core/src/caplet-source/runtime-fingerprint.ts` (new) +- `packages/core/src/caplet-source/types.ts` +- `packages/core/src/caplet-files-bundle.ts` +- `packages/core/src/caplet-files.ts` +- `packages/core/src/caplet-source/parse.ts` +- `packages/core/src/caplet-sets.ts` +- `packages/core/src/config.ts` +- `packages/core/src/config-runtime.ts` +- `packages/core/src/setup/hash.ts` +- `packages/core/src/cli/setup-caplet.ts` +- `packages/core/src/cloud/runtime-adapter.ts` +- `packages/core/test/caplet-source.test.ts` +- `packages/core/test/caplet-sets.test.ts` +- `packages/core/test/config.test.ts` +- `packages/core/test/setup-runner.test.ts` + +**Approach:** + +- Make the fingerprint producer a pure lower-layer module. It accepts validated pre-interpolation template semantics, logical source provenance, and a declared-input reader; it does not import engine, installer, setup, Caplet-set manager, or Vault resolution. +- Return a versioned snapshot with per-runtime-ID Caplet fingerprints, an artifact runtime fingerprint, a host configuration fingerprint, and safe declared-input state. Keep physical traversal keys private. +- Define a Caplet fingerprint from one effective expanded child, its parent/child logical identity, and its declared-input graph. Define an artifact aggregate from the children owned by one installable artifact. +- Define the host configuration aggregate from the sorted runtime-ID map plus explicitly enumerated runtime-wide options that change engine behavior, including paging and exposure defaults. +- Keep `$env` and `$vault` reference syntax in stable snapshots. Do not resolve secret values in the producer; U3 owns the separate non-persisted resolved-execution fingerprint. +- Extend the declared-input reader contract to preserve logical identity while distinguishing present with digest, missing, and unreadable without serializing physical paths or error prose. +- Cover OpenAPI specifications, Google Discovery documents, GraphQL schemas and operation documents, Caplet-set config files, and Caplet-set roots. Ignore `cwd` trees, README links, and arbitrary siblings. +- Make `capletsRoot` recursion use the nested runtime loader’s discovery, precedence, duplicate, `configPath` merge, and cycle behavior. Fingerprint effective nested runtime projections, not a raw directory or the broader portable-bundle candidate set. +- Determine persistence eligibility from the stable snapshot. Literal secret-bearing values and unportable absolute host values produce a live-only snapshot; portable template references and logical relative inputs remain eligible. +- Carry successful snapshots with source/load metadata rather than adding them to `CapletConfig` or agent projections. Make local and hosted setup approval consume the per-Caplet stable fingerprint. + +**Patterns to follow:** + +- Runtime-aware source resolution and source metadata flow in `packages/core/src/caplet-source/parse.ts`. +- Stable setup hash serialization in `packages/core/src/setup/hash.ts`. +- Last-known-good configuration behavior in `packages/core/src/engine.ts`. + +**Test scenarios:** + +1. Reach the pure producer through filesystem loading, portable source parsing, and setup identity construction; the same portable fixture yields identical logical inputs and digests. +2. Compute stable fingerprints for semantically identical frontmatter with different YAML comments, key order, formatting, and source roots; values match. +3. Resolve the same `$env` and `$vault` templates to different secret values; stable fingerprints, setup approval, and persistence-safe lock payloads remain equal and contain no resolution-derived material. +4. Use literal bearer/header/client-secret fields and absolute host paths; runtime differences are detectable by live comparison, but optional lock state, outputs, activities, and diagnostics contain no new digest or correlate. +5. Change description, exposure, auth templates, setup, runtime requirements, or backend configuration; the applicable Caplet and containing aggregate fingerprints change. +6. Change only body text or catalog metadata; all stable runtime fingerprints stay equal. +7. Mutate every supported declared input family; the applicable fingerprint changes. Add an arbitrary sibling file, `cwd` file, or README link; it does not. +8. Exercise identical logical references through filesystem and portable readers; present, missing, and unreadable states are distinct and contain no absolute root or OS error text. +9. Fingerprint Caplet sets covering top-level and immediate-directory discovery, deeper ignored candidates, duplicate/precedence behavior, `configPath` plus `capletsRoot` merging, relocation, and a recursive cycle; results match what runtime loading can consume. +10. For a multi-backend parent, change shared metadata and then one child-local field; governed child, artifact, and host aggregates change at the correct scopes. +11. Change a runtime-wide paging or exposure option; only the host configuration fingerprint changes, while an installer compares only its artifact aggregate. +12. Approve setup locally and through the hosted runtime adapter; README, catalog, and resolved-secret changes retain approval, while template or declared-input changes require the applicable recheck. + +**Verification:** One pure module owns versioned canonicalization, logical reference traversal, consumer scopes, and persistence eligibility; adapters agree, no secret-derived or host-specific state is persisted, and undeclared content never affects runtime equality. + +### U3. Make runtime reload a semantic no-op for content changes + +**Goal:** Stop README-only and other runtime-equal file events before registry, manager, exposure, native, MCP, Attach, or Code Mode side effects. + +**Requirements:** R7-R17, R22; F2-F4; AE1, AE3-AE5, AE7. + +**Dependencies:** U2. + +**Files:** + +- `packages/core/src/engine.ts` +- `packages/core/test/engine.test.ts` +- `packages/core/test/native.test.ts` +- `packages/core/test/serve-http.test.ts` +- `packages/core/test/attach-api.test.ts` + +**Approach:** + +- Store the last successful host configuration fingerprint and a separate non-persisted resolved-execution fingerprint. Require both to be equal before the runtime no-op. +- Compute resolved-execution equality after environment and Vault resolution without writing it to lock, setup approval, telemetry, diagnostics, or agent-facing projections. +- Compare a valid candidate before replacing the registry, advancing exposure generation, updating or invalidating managers, resetting callable-state watchers, or emitting reload notifications. +- Treat equality as a successful runtime no-op without changing the meaning of the public reload success result. +- Retain current registry and both fingerprints after parse, validation, Vault, or source-resolution failure. File-watch bookkeeping needed for later additions may continue without becoming an agent-facing event. +- Preserve existing manager invalidation and notification behavior for changed templates, declared inputs, runtime-wide options, or resolved execution values. +- Characterize the downstream fan-out rather than adding body-specific filters in native, MCP, Attach, or Code Mode adapters. + +**Execution note:** Start with failing manual-reload and watched-reload integration tests; the bug is lifecycle churn, not serialized payload content. + +**Patterns to follow:** + +- Last-known-good reload handling in `packages/core/src/engine.ts`. +- Existing `onToolsChanged`, registered-tool reconciliation, and Attach manifest-change tests in the native and serve suites. + +**Test scenarios:** + +1. Start an engine from a temporary directory Caplet, subscribe to reload, record exposure generation, edit only valid README text, and call reload; success is returned with no registry replacement, generation change, manager activity, or reload callback. +2. Repeat through the filesystem watcher/debounce path; body-only edits produce no runtime event. +3. Attach native service, MCP/native sessions, local/native Attach, and HTTP Attach SSE subscribers; a body-only edit produces no tool update, registration update, manifest invalidation, revision change, or manifest-change event. +4. Change a frontmatter description and then a declared runtime input; each causes one normal reload, manager invalidation where applicable, exposure refresh, and downstream notification. +5. Delete and restore a declared input; both transitions are runtime-affecting and never collapse into a content-only no-op. +6. Keep frontmatter templates fixed, rotate resolved environment and Vault values, and trigger the established refresh/reload path; stable configuration equality remains while volatile execution equality changes and runtime consumers refresh without persisting secret-derived state. +7. Make the candidate artifact invalid through malformed frontmatter or an exceeded artifact/body guardrail; reload fails and retains the prior registry, generation, and fingerprint. +8. Compare Code Mode declarations and Attach manifests before and after a body-only edit; bytes/structured values remain stable while the raw catalog body can change independently. + +**Verification:** Runtime-equal file changes cause zero callable-state churn across every subscriber; runtime-different changes and invalid-candidate recovery preserve established behavior. + +### U4. Classify trusted content-only install updates + +**Goal:** Install and publish trusted README/content changes without runtime risk or approval churn while preserving lockfile compatibility and local-drift safety. + +**Requirements:** R13-R18, R23-R24; F1, F3-F4; AE2-AE5, AE8. + +**Dependencies:** U2. + +**Files:** + +- `packages/core/src/cli/install.ts` +- `packages/core/src/cli/lockfile.ts` +- `packages/core/src/cli.ts` +- `packages/core/src/current-host/catalog.ts` +- `packages/core/src/current-host/catalog-operations.ts` +- `apps/dashboard/src/components/DashboardApp.tsx` +- `packages/core/test/cli.test.ts` +- `packages/core/test/current-host-catalog-operations.test.ts` +- `packages/core/test/dashboard-catalog.test.ts` +- `packages/core/test/catalog-indexing.test.ts` + +**Approach:** + +- Run the existing installed-artifact drift check before source-update classification. A locally modified README remains a conflict unless the operator uses the existing force path. +- Treat an upstream candidate as trusted for content-only classification only when it is resolved through the existing lock entry’s unchanged source identity and established provenance checks. An unverifiable or changed source identity cannot bypass the existing risk and confirmation path. +- After the drift decision, acquire, parse, validate, and fingerprint the candidate without mutating destination bytes, lock baselines, timestamps, activity, or catalog state. `--force` cannot bypass this gate. +- Preserve whole-artifact equality as `noop`. When artifact bytes differ, compare source and clean-installed artifact runtime fingerprints. +- Add a machine-readable `content_updated` outcome for equal runtime fingerprints. Keep runtime-different updates on the existing `updated` risk and approval path. +- Stage the candidate and retain recoverable prior destination state through the atomic lock write. Report success only after destination bytes, `installedHash`, optional safe runtime state, and timestamps form one committed pair. +- On synchronous lock failure, restore the prior matched destination and lock state. Detect an interrupted staged or swapped transition before the next drift check and either finalize the new pair or restore the old pair. +- Keep lockfile version 1. Add optional persistence-safe artifact runtime state, compute installed and source values live for legacy or live-only entries, and persist safe baselines only after a successful commit. +- Do not rename or repurpose `installedHash` or `risk.bodyHash`. Never persist optional state derived from literal secrets or unportable host paths. +- Treat restore as a rewrite event: after successful destination replacement, persist missing safe runtime state even when the whole-artifact hash equals the existing baseline; preserve `installedAt` and advance `updatedAt` only when a lock field changes. +- Preserve Git-pinned restore, materialized symlink, missing destination, force-over-drift, and per-Caplet batch commit behavior. Finish one artifact-and-lock pair before processing the next selected Caplet. +- Carry `content_updated` through local JSON/text, remote CLI, Current Host operations, activity rendering, and dashboard state. Human output distinguishes content from runtime without tests pinning subjective copy. +- Start catalog indexing only after commit. Normalize endpoint unavailability and thrown indexer failures into the ancillary indexing result; never index or emit success activity for an uncommitted candidate. + +**Patterns to follow:** + +- Whole-artifact integrity and local-drift guards in `packages/core/src/cli/install.ts`. +- Additive optional lock fields in `packages/core/src/cli/lockfile.ts`. +- Current Host result and catalog-index attachment in `packages/core/src/current-host/catalog-operations.ts`. + +**Test scenarios:** + +1. Install a directory Caplet, change only trusted upstream README text, and update; result is `content_updated`, artifact integrity changes, the artifact runtime fingerprint and approval eligibility stay equal, and catalog indexing runs after commit. +2. Reach the fingerprint producer through install source and destination adapters; equivalent portable source and clean-installed artifacts yield identical logical inputs and digests before update classification. +3. Change frontmatter or a declared runtime input; result remains `updated` and follows existing risk/force behavior. +4. Modify only the installed destination README locally; update fails closed without force even when the source runtime fingerprint is equal. +5. Make the source disappear or make templates/fingerprint traversal invalid after lock read; normal and forced updates preserve destination bytes, lock fields, timestamps, activity, and indexing silence. +6. Inject a lock-writer failure after staged replacement for content-only and runtime updates; the old destination and lock pair remain authoritative and no catalog request occurs. +7. Simulate interruption at staging, destination swap, lock temporary write, and lock replacement boundaries; the next operation recovers or finalizes the pair before local-drift classification. +8. Read a legacy v1 lock without runtime state, live-classify its first body-only update as content-only, and persist a safe optional baseline without changing lock version. +9. Use a legacy entry with a literal secret or absolute host path; live classification succeeds, but lock JSON, output, activity, and diagnostics gain no optional runtime digest or correlate. +10. Restore byte-identical pinned content for a missing destination with a legacy lock; restored status persists missing safe runtime state even though `installedHash` is unchanged. Repeat with force-over-drift and with a failed candidate. +11. Preserve symlink-materialized directory no-op behavior and copying of non-runtime bundle files. +12. Update two selected entries where the first commits and the second disappears, becomes invalid, or hits lock failure; established per-Caplet partial success remains recoverable and retry safely recognizes the first. +13. Return `content_updated` consistently through local JSON, remote response, Current Host operation, activity entry, and dashboard rendering. +14. Force endpoint failure and an indexer throw after a successful content-only commit; installed state and success activity remain, indexing reports unavailable, and a lock failure produces zero indexing calls. + +**Verification:** Trusted upstream content updates are committed and publishable but never runtime-risk updates; candidate and lock failures preserve a recoverable artifact-and-baseline pair; local drift remains fail-closed; every status, legacy-lock, restore, batch, and indexing path agrees. + +### U5. Cut over authoring and official Caplet content + +**Goal:** Make operator README semantics the only current authoring guidance while preserving public catalog rendering and necessary compact agent capability descriptions. + +**Requirements:** R4, R11-R13, R18-R22; F1-F3; AE1-AE3, AE6-AE7. + +**Dependencies:** U1, U4. + +**Files:** + +- `packages/core/src/cli/author.ts` +- `scripts/generate-docs-reference.ts` +- `apps/docs/src/content/docs/reference/caplet-files.mdx` (generated) +- `apps/docs/src/content/docs/vault.mdx` +- `caplets/*/CAPLET.md` +- `apps/catalog/src/lib/markdown.ts` +- `apps/catalog/src/components/CapletDetail.astro` +- `apps/catalog/test/markdown.test.ts` +- `packages/core/test/author-cli.test.ts` +- `packages/core/test/catalog-official-index.test.ts` + +**Approach:** + +- Change authoring templates and generated examples so body content addresses operators: prerequisites, setup context, troubleshooting, safety, and reference material. +- Teach authors to treat README bodies as publishable catalog content and keep secrets, credentials, private endpoints, customer data, and other sensitive operational material out of them. +- Audit every official body. Rewrite agent-directed workflows into operator guidance where useful; delete low-value instruction prose instead of preserving it for compatibility. +- Move only compact context that remains necessary for agent selection into the existing `description` frontmatter field. Remove `useWhen` and `avoidWhen`; do not add a free-form replacement field. +- Keep runtime references explicit in frontmatter. Markdown links and path-like body text remain documentation only. +- Update the generated reference at its generator source, then regenerate the MDX output. Update direct Vault wording that currently describes body text as public metadata. +- Preserve the catalog’s raw `contentMarkdown` and human body rendering path. Do not source operator content from runtime configuration or suppress public browser rendering. +- Keep automated tests focused on authoring shape, frontmatter/body separation, rendered-content routing, and runtime isolation. Human review owns prose quality. + +**Patterns to follow:** + +- Generated documentation source-of-truth rules in `scripts/generate-docs-reference.ts`. +- Raw catalog split and sanitized Markdown rendering in `apps/catalog/src/lib/markdown.ts`. +- Existing official Caplet frontmatter conventions and catalog-grade metadata in `CONCEPTS.md`. + +**Test scenarios:** + +1. Generate a new Caplet authoring template; it contains valid frontmatter, an operator-oriented README body, and publishable-content safety guidance without adding an instructions field. +2. Split official-style Markdown in the catalog helper; frontmatter rows and body Markdown remain independent, and the body remains renderable for humans. +3. Build an official catalog entry from an updated Caplet; raw `contentMarkdown` includes its README while resolved runtime configuration has no body. +4. Put path-like text, a Markdown link, and a vault-looking token in a body; none becomes a runtime reference or interpolation input. +5. Confirm capability descriptions added during the prose audit survive registry and Code Mode projection tests from U1. +6. Do not add assertions for exact headlines, workflows, or subjective body wording; review the official corpus manually against the operator README and sensitive-content contracts. + +**Verification:** Current authoring surfaces and official artifacts consistently teach and demonstrate operator README semantics; catalog rendering remains intact and no content migration recreates an agent instruction channel. + +### U6. Regenerate, release, and verify the clean cutover + +**Goal:** Publish synchronized generated artifacts and package metadata after all behavioral units pass. + +**Requirements:** R18-R24; AE1-AE8. + +**Dependencies:** U1-U5. + +**Files:** + +- `apps/docs/src/content/docs/reference/caplet-files.mdx` +- `apps/catalog/src/data/official-catalog.json` +- `schemas/caplets-config.schema.json` +- `schemas/caplet.schema.json` +- `apps/landing/public/caplets-config.schema.json` +- `apps/landing/public/caplet.schema.json` +- `.changeset/calm-caplet-readme-contract.md` (new) + +**Approach:** + +- Regenerate the documentation reference and official catalog index from their authoritative sources after content migration. +- Run schema generation/checks and retain schema outputs unchanged unless the authoritative frontmatter schema changed unexpectedly; never hand-edit them to describe an internal runtime-type deletion. +- Add a minor `@caplets/core` Changeset describing the exported `config-runtime` type cut, frontmatter-only runtime contract, content-only update classification, and preserved operator README rendering. +- Run focused behavioral suites before the full repository gate. Resolve generated drift at source rather than editing generated outputs. +- Remove temporary serializers, compatibility branches, duplicated reference inventories, obsolete body-preservation tests, and experimental fingerprint code before completion. + +**Test expectation:** No new unit test belongs solely to this release-integration unit; U1-U5 own behavior. This unit proves generated-artifact freshness, public type/build integrity, and whole-repository compatibility. + +**Verification:** Generated docs and catalog data are current, schemas are source-consistent, the Changeset covers the published package change, all focused suites pass, and the full repository gate succeeds with no obsolete body-bearing path. + +--- + +## Verification Contract + +| Gate | Applies to | Command | Required outcome | +| -------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime projection and fingerprint tests | U1-U2 | `pnpm --filter @caplets/core test -- test/caplet-files.test.ts test/config.test.ts test/caplet-source.test.ts test/caplet-sets.test.ts test/setup-runner.test.ts test/backend-operation-dispatch.test.ts test/registry.test.ts test/code-mode-declarations.test.ts test/attach-api.test.ts` | Body is structurally absent, fingerprints classify declared semantics correctly, setup parity holds, and agent-facing structured hints remain. | +| Reload and notification tests | U3 | `pnpm --filter @caplets/core test -- test/engine.test.ts test/native.test.ts test/serve-http.test.ts test/attach-api.test.ts` | Content-only reloads are side-effect-free; runtime changes and invalid candidates preserve established lifecycle behavior. | +| Installer, lock, Current Host, and dashboard tests | U4 | `pnpm --filter @caplets/core test -- test/cli.test.ts test/current-host-catalog-operations.test.ts test/dashboard-catalog.test.ts test/catalog-indexing.test.ts` | Content-only classification, legacy v1 locks, restore/drift safety, status relays, and indexing outcomes agree. | +| Authoring and official catalog tests | U5 | `pnpm --filter @caplets/core test -- test/author-cli.test.ts test/catalog-official-index.test.ts` | Author templates and official index generation preserve the two-projection contract. | +| Catalog rendering tests | U5 | `pnpm --filter @caplets/catalog test -- markdown.test.ts` | Frontmatter and README body remain independently rendered with existing sanitization. | +| Generated documentation and catalog checks | U5-U6 | `pnpm docs:generate`, `pnpm catalog:generate`, then `pnpm docs:check` and `pnpm catalog:check` | Generated MDX and official catalog data match authoritative sources. | +| Public schema and Code Mode API checks | U1-U6 | `pnpm schema:check` and `pnpm code-mode:check-api` | Frontmatter/public config schemas and Code Mode declarations remain source-consistent; no body field is introduced. | +| Static and package verification | U1-U6 | `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, and `pnpm build` | Source, exported types, apps, and package artifacts compile and conform. | +| Changeset validation | U6 | `pnpm changeset status --since=origin/main` | The published `@caplets/core` change has an accepted release entry. | +| Full repository gate | U6 | `pnpm verify` | Formatting, lint, generated APIs/schemas/docs, types, tests, benchmarks, and build all pass in repository order. | + +Behavioral verification must include these cross-cutting invariants: + +- A valid body-only edit changes raw content and whole-artifact integrity but changes no stable template fingerprint, setup approval, resolved-execution fingerprint, exposure generation, manager state, or agent notification. +- Frontmatter templates, runtime-wide options, resolved environment/Vault values, and every supported declared runtime input change the applicable stable or volatile fingerprint and retain their intended reload behavior without persisting secret-derived state. +- Local installed drift remains distinct from a trusted upstream content update and continues to fail closed without force. +- Candidate or lock failure leaves a recoverable matched artifact-and-lock pair; catalog indexing occurs only after commit and cannot roll back or misreport a successful update. +- Legacy and live-only locks classify from installed/source state without migration, secret-derived persistence, or stale restore baselines. +- Public catalog/browser presentation retains the README body; Caplets-owned discovery, invocation, Attach, native, and Code Mode surfaces never do. +- Multi-backend and nested Caplet-set bodies remain operator content and do not multiply into child runtime configurations or fingerprints. + +--- + +## Definition of Done + +### Global + +- The Product Contract remains unchanged and every applicable R/F/AE is traced to an implementation unit or verification invariant. +- Runtime `CapletConfig` types, schemas, values, expanded children, setup inputs, manager inputs, and agent capability projections contain no README body field or content. +- One pure fingerprint producer owns stable per-Caplet, artifact, and host-configuration scopes; the engine separately tracks volatile resolved-execution equality without persistence. +- README-only and other stable-and-resolved-equal changes are observable content updates without runtime reload, reapproval, manager invalidation, exposure churn, or agent notification. +- Runtime-affecting templates, runtime-wide options, declared inputs, and resolved execution values retain their intended validation, reload, risk, approval, and exposure behavior. +- Lockfile v1, safe optional runtime state, recoverable artifact-and-lock commits, whole-artifact drift safety, restore behavior, per-Caplet batches, symlink materialization, and public catalog rendering remain compatible. +- Official Caplet bodies, templates, examples, direct docs, generated docs, and catalog seed reflect the operator README contract. +- A minor `@caplets/core` Changeset exists and the full verification contract passes. +- Abandoned experiments, duplicate hash/reference logic, stale body-preservation assertions, compatibility shims, generated drift, and temporary scaffolding are removed. + +### Per Unit + +| Unit | Done signal | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| U1 | Runtime values and every manager/agent boundary are structurally body-free while compact capability descriptions remain. | +| U2 | Versioned per-Caplet, artifact, and host fingerprints are deterministic, source-root independent, declared-reference complete, body-blind, secret-safe, and adapter-consistent. | +| U3 | Manual and watched content-only reloads cause no engine or adapter side effects; template, declared-input, runtime-wide, and resolved-secret changes still fan out correctly. | +| U4 | Trusted content-only updates, runtime updates, candidate/lock failures, local drift, restore, legacy/live-only locks, indexing, batches, and every status relay preserve coherent outcomes. | +| U5 | Authoring guidance and official Caplets use operator README semantics, with catalog rendering preserved and no subjective copy tests. | +| U6 | Generated artifacts, schemas, release metadata, focused checks, and `pnpm verify` are clean and current. | diff --git a/packages/core/src/attach/api.ts b/packages/core/src/attach/api.ts index dafa8c5e..c8f69e28 100644 --- a/packages/core/src/attach/api.ts +++ b/packages/core/src/attach/api.ts @@ -59,8 +59,6 @@ export type AttachManifestExport = { capletId: string; sourceCapletId?: string | undefined; shadowing: CapletShadowingPolicy; - useWhen?: string | undefined; - avoidWhen?: string | undefined; }; export type AttachProgressiveCapletExport = AttachManifestExport & { @@ -72,6 +70,8 @@ export type AttachToolExport = AttachManifestExport & { kind: "tool"; name: string; downstreamName: string; + useWhen?: string | undefined; + avoidWhen?: string | undefined; }; export type AttachResourceExport = AttachManifestExport & { @@ -251,8 +251,6 @@ function nativeProgressiveCaplets( schemaHash: schemaHash(tool.inputSchema ?? null), capletId: tool.caplet, ...(tool.sourceCaplet ? { sourceCapletId: tool.sourceCaplet } : {}), - ...(tool.useWhen ? { useWhen: tool.useWhen } : {}), - ...(tool.avoidWhen ? { avoidWhen: tool.avoidWhen } : {}), shadowing: tool.shadowing ?? "forbid", })); } @@ -306,8 +304,6 @@ function nativeCodeModeCaplets( schemaHash: null, capletId: caplet.id, ...(caplet.sourceCapletId ? { sourceCapletId: caplet.sourceCapletId } : {}), - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing ?? "forbid", })), ); @@ -431,8 +427,6 @@ function progressiveCapletExport( inputSchema: entry.inputSchema, schemaHash: schemaHash(entry.inputSchema ?? null), capletId: entry.capletId, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -451,8 +445,6 @@ function codeModeCapletExport( description: entry.description, schemaHash: null, capletId: entry.capletId, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -496,8 +488,6 @@ function resourceExport( ...(typeof entry.size === "number" ? { size: entry.size } : {}), schemaHash: null, capletId: entry.capletId, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -523,8 +513,6 @@ function resourceTemplateExport( ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), schemaHash: null, capletId: entry.capletId, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -543,8 +531,6 @@ function promptExport(entry: ExposureProjectionEntry): Array { @@ -523,7 +507,6 @@ const capletHttpActionSchema = z .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."), - ...capletAgentSelectionHintsSchema, inputSchema: z .record(z.string(), z.unknown()) .optional() @@ -619,7 +602,6 @@ const capletCliToolAnnotationsSchema = z const capletCliToolActionSchema = z .object({ description: z.string().min(1).optional().describe("Action capability description."), - ...capletAgentSelectionHintsSchema, inputSchema: z .record(z.string(), z.unknown()) .optional() @@ -721,7 +703,6 @@ const capletFileChildSharedFields = { tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: capletExposureSchema.optional(), shadowing: capletShadowingSchema.optional(), - ...capletAgentSelectionHintsSchema, setup: capletSetupSchema.optional(), projectBinding: capletProjectBindingSchema.optional(), runtime: capletRuntimeRequirementsSchema.optional(), @@ -816,7 +797,6 @@ export const capletFileSchema = z .describe("Optional tags for grouping or searching Caplets."), exposure: capletExposureSchema.optional(), shadowing: capletShadowingSchema.optional(), - ...capletAgentSelectionHintsSchema, setup: capletSetupSchema.optional(), projectBinding: capletProjectBindingSchema.optional(), runtime: capletRuntimeRequirementsSchema.optional(), @@ -1231,8 +1211,8 @@ function discoverCapletFileMapCandidates(paths: string[]): Array<{ id: string; p if (!fileName) { continue; } - if (fileName === "CAPLET.md" && segments.length > 1) { - candidates.push({ id: segments.at(-2) ?? "CAPLET", path, isDirectoryCaplet: true }); + if (fileName === "CAPLET.md" && segments.length === 2) { + candidates.push({ id: segments[0] ?? "CAPLET", path, isDirectoryCaplet: true }); continue; } if (segments.length === 1 && extname(fileName).toLowerCase() === ".md") { @@ -1278,16 +1258,15 @@ export function readCapletFileContent( ); } - return capletToServerConfig(parsed.data, body, baseDir, normalizePath); + return capletToServerConfig(parsed.data, baseDir, normalizePath); } function capletToServerConfig( frontmatter: CapletFileFrontmatter, - body: string, baseDir: string, normalizePath: (value: string | undefined, baseDir: string) => string | undefined, ): unknown { - const expanded = capletToExpandedServerConfigs(frontmatter, body, baseDir, normalizePath); + const expanded = capletToExpandedServerConfigs(frontmatter, baseDir, normalizePath); if (expanded.length > 0) { return { kind: "expanded-caplet-file", @@ -1303,7 +1282,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1315,7 +1293,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1332,7 +1309,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1343,7 +1319,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1357,7 +1332,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1370,7 +1344,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1379,7 +1352,6 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...sharedCapletFields(frontmatter), - body, }; } @@ -1389,8 +1361,6 @@ type SharedCapletFields = { tags?: string[] | undefined; exposure?: z.infer | undefined; shadowing?: z.infer | undefined; - useWhen?: string | undefined; - avoidWhen?: string | undefined; setup?: z.infer | undefined; projectBinding?: z.infer | undefined; runtime?: z.infer | undefined; @@ -1402,8 +1372,6 @@ const CHILD_SHARED_FIELD_KEYS = new Set([ "tags", "exposure", "shadowing", - "useWhen", - "avoidWhen", "setup", "projectBinding", "runtime", @@ -1411,7 +1379,6 @@ const CHILD_SHARED_FIELD_KEYS = new Set([ function capletToExpandedServerConfigs( frontmatter: CapletFileFrontmatter, - body: string, baseDir: string, normalizePath: (value: string | undefined, baseDir: string) => string | undefined, ): ExpandedCapletFileChild[] { @@ -1424,7 +1391,6 @@ function capletToExpandedServerConfigs( frontmatter.mcpServers, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1434,7 +1400,6 @@ function capletToExpandedServerConfigs( frontmatter.openapiEndpoints, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1444,7 +1409,6 @@ function capletToExpandedServerConfigs( frontmatter.googleDiscoveryApis, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1454,7 +1418,6 @@ function capletToExpandedServerConfigs( frontmatter.graphqlEndpoints, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1464,7 +1427,6 @@ function capletToExpandedServerConfigs( frontmatter.httpApis, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1475,7 +1437,6 @@ function capletToExpandedServerConfigs( frontmatter.cliTools as Record>, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1486,7 +1447,6 @@ function capletToExpandedServerConfigs( frontmatter.capletSets, frontmatter, parentShared, - body, baseDir, normalizePath, ); @@ -1500,7 +1460,6 @@ function addExpandedChildren( childMap: Record> | undefined, frontmatter: CapletFileFrontmatter, parentShared: SharedCapletFields, - body: string, baseDir: string, normalizePath: (value: string | undefined, baseDir: string) => string | undefined, ): void { @@ -1525,7 +1484,6 @@ function addExpandedChildren( backend, validatedBackend, mergedShared, - body, baseDir, normalizePath, ), @@ -1564,8 +1522,6 @@ function parentSharedFields(frontmatter: CapletFileFrontmatter): SharedCapletFie tags: frontmatter.tags, exposure: frontmatter.exposure, shadowing: frontmatter.shadowing, - useWhen: frontmatter.useWhen, - avoidWhen: frontmatter.avoidWhen, setup: frontmatter.setup, projectBinding: frontmatter.projectBinding, runtime: frontmatter.runtime, @@ -1582,8 +1538,6 @@ function mergeSharedCapletFields( tags: mergeTags(parent.tags, child.tags), exposure: child.exposure ?? parent.exposure, shadowing: child.shadowing ?? parent.shadowing, - useWhen: child.useWhen ?? parent.useWhen, - avoidWhen: child.avoidWhen ?? parent.avoidWhen, setup: mergeSetup(parent.setup, child.setup), projectBinding: child.projectBinding ?? parent.projectBinding, runtime: mergeRuntime(parent.runtime, child.runtime), @@ -1670,7 +1624,6 @@ function normalizeExpandedBackendConfig( backend: CapletFileBackendFamily, config: Record, shared: SharedCapletFields, - body: string, baseDir: string, normalizePath: (value: string | undefined, baseDir: string) => string | undefined, ): unknown { @@ -1681,7 +1634,6 @@ function normalizeExpandedBackendConfig( specPath: normalizePath(config.specPath as string | undefined, baseDir), backend: "openapi", ...common, - body, }; } if (backend === "googleDiscovery") { @@ -1690,7 +1642,6 @@ function normalizeExpandedBackendConfig( discoveryPath: normalizePath(config.discoveryPath as string | undefined, baseDir), backend: "googleDiscovery", ...common, - body, }; } if (backend === "graphql") { @@ -1704,7 +1655,6 @@ function normalizeExpandedBackendConfig( ), backend: "graphql", ...common, - body, }; } if (backend === "http") { @@ -1712,7 +1662,6 @@ function normalizeExpandedBackendConfig( ...config, backend: "http", ...common, - body, }; } if (backend === "cli") { @@ -1726,7 +1675,6 @@ function normalizeExpandedBackendConfig( ), backend: "cli", ...common, - body, }; } if (backend === "caplets") { @@ -1736,13 +1684,11 @@ function normalizeExpandedBackendConfig( capletsRoot: normalizePath(config.capletsRoot as string | undefined, baseDir), backend: "caplets", ...common, - body, }; } return { ...config, ...common, - body, }; } @@ -1753,8 +1699,6 @@ function normalizedSharedOutput(shared: SharedCapletFields): Record part && part !== ".").join("/"); } export function normalizeMapPath(path: string): string { const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, ""); - if (!normalized || normalized.startsWith("/") || normalized.includes("/../")) { + if ( + !normalized || + normalized.startsWith("/") || + normalized.split("/").some((segment) => segment === "..") + ) { throw new CapletsError("CONFIG_INVALID", `Invalid Caplet file path ${path}`); } return normalized; diff --git a/packages/core/src/caplet-source/bundle.ts b/packages/core/src/caplet-source/bundle.ts index 6336db9f..ee179d1d 100644 --- a/packages/core/src/caplet-source/bundle.ts +++ b/packages/core/src/caplet-source/bundle.ts @@ -1,4 +1,5 @@ import { CapletsError } from "../errors"; +import type { DeclaredInputReader } from "./runtime-fingerprint"; import type { CapletSource, CapletSourceFile } from "./types"; import { normalizeCapletSourcePath } from "./types"; @@ -30,4 +31,26 @@ export class BundleCapletSource implements CapletSource { } return this.files.get(normalized); } + + declaredInputReader(): DeclaredInputReader { + return { + read: (path) => { + const normalized = normalizeCapletSourcePath(path); + if (!normalized) return { state: "unreadable" }; + const file = this.files.get(normalized); + return file + ? { state: "present", content: file.content, privateKey: normalized } + : { state: "missing", privateKey: normalized }; + }, + list: (root) => { + const normalized = normalizeCapletSourcePath(root); + if (!normalized) return { state: "unreadable" }; + const prefix = `${normalized}/`; + const paths = [...this.files.keys()].filter((path) => path.startsWith(prefix)).sort(); + return paths.length > 0 + ? { state: "present", paths, privateKey: normalized } + : { state: "missing", privateKey: normalized }; + }, + }; + } } diff --git a/packages/core/src/caplet-source/filesystem.ts b/packages/core/src/caplet-source/filesystem.ts index a84e2247..1e4ed065 100644 --- a/packages/core/src/caplet-source/filesystem.ts +++ b/packages/core/src/caplet-source/filesystem.ts @@ -1,5 +1,10 @@ -import { Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { Dirent, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { + DeclaredInputListState, + DeclaredInputReader, + DeclaredInputState, +} from "./runtime-fingerprint"; import type { CapletSource, CapletSourceFile } from "./types"; import { normalizeCapletSourcePath } from "./types"; @@ -11,50 +16,120 @@ export class FilesystemCapletSource implements CapletSource { } async listFiles(): Promise { - if (!existsSync(this.root) || !statSync(this.root).isDirectory()) { + try { + if (!existsSync(this.root) || !statSync(this.root).isDirectory()) return []; + return walkSourceFiles(this.root, this.root).sort((left, right) => + left.path.localeCompare(right.path), + ); + } catch { return []; } - return walkFiles(this.root, this.root).sort((left, right) => - left.path.localeCompare(right.path), - ); } async readFile(path: string): Promise { + const state = this.readDeclaredInput(path); + if (state.state !== "present") return undefined; const normalized = normalizeCapletSourcePath(path); - if (!normalized) { - return undefined; + return normalized ? { path: normalized, content: state.content } : undefined; + } + + declaredInputReader(): DeclaredInputReader { + return { + read: (path) => this.readDeclaredInput(path), + list: (root) => this.listDeclaredInputs(root), + }; + } + + private readDeclaredInput(path: string): DeclaredInputState { + const normalized = normalizeCapletSourcePath(path); + if (!normalized) return { state: "unreadable" }; + const absolute = resolve(this.root, normalized); + if (!isWithinRoot(this.root, absolute)) return { state: "unreadable" }; + if (!existsSync(absolute)) return { state: "missing", privateKey: normalized }; + try { + const realRoot = realpathSync(this.root); + const realPath = realpathSync(absolute); + if (!isWithinRoot(realRoot, realPath) || !statSync(realPath).isFile()) { + return { state: "unreadable", privateKey: realPath }; + } + return { + state: "present", + content: readFileSync(realPath, "utf8"), + privateKey: realPath, + }; + } catch { + return { state: "unreadable", privateKey: normalized }; } + } + + private listDeclaredInputs(root: string): DeclaredInputListState { + const normalized = normalizeCapletSourcePath(root); + if (!normalized) return { state: "unreadable" }; const absolute = resolve(this.root, normalized); - if ( - !isWithinRoot(this.root, absolute) || - !existsSync(absolute) || - !statSync(absolute).isFile() - ) { - return undefined; + if (!isWithinRoot(this.root, absolute)) return { state: "unreadable" }; + if (!existsSync(absolute)) return { state: "missing", privateKey: normalized }; + try { + const realRoot = realpathSync(this.root); + const realPath = realpathSync(absolute); + if (!isWithinRoot(realRoot, realPath) || !statSync(realPath).isDirectory()) { + return { state: "unreadable", privateKey: realPath }; + } + return { + state: "present", + paths: walkFilePaths(this.root, absolute, realRoot, new Set()).sort(), + privateKey: realPath, + }; + } catch { + return { state: "unreadable", privateKey: normalized }; } - return { path: normalized, content: readFileSync(absolute, "utf8") }; } } -function walkFiles(root: string, dir: string): CapletSourceFile[] { +function walkSourceFiles(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()) { + files.push(...walkSourceFiles(root, absolute)); continue; } + if (!entry.isFile()) continue; const normalized = normalizeCapletSourcePath(relative(root, absolute)); - if (normalized) { - files.push({ path: normalized, content: readFileSync(absolute, "utf8") }); - } + if (normalized) files.push({ path: normalized, content: readFileSync(absolute, "utf8") }); } return files; } +function walkFilePaths( + root: string, + dir: string, + realRoot: string, + visitedDirectories: Set, +): string[] { + const realDirectory = realpathSync(dir); + if (!isWithinRoot(realRoot, realDirectory) || visitedDirectories.has(realDirectory)) return []; + visitedDirectories.add(realDirectory); + const paths: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort(compareDirents)) { + const absolute = join(dir, entry.name); + try { + const realPath = realpathSync(absolute); + if (!isWithinRoot(realRoot, realPath)) continue; + const stat = statSync(realPath); + if (stat.isDirectory()) { + paths.push(...walkFilePaths(root, absolute, realRoot, visitedDirectories)); + continue; + } + if (!stat.isFile()) continue; + const normalized = normalizeCapletSourcePath(relative(root, absolute)); + if (normalized) paths.push(normalized); + } catch { + continue; + } + } + return paths; +} + function compareDirents(left: Dirent, right: Dirent): number { return left.name.localeCompare(right.name); } diff --git a/packages/core/src/caplet-source/index.ts b/packages/core/src/caplet-source/index.ts index 4c373013..3c2b3cbe 100644 --- a/packages/core/src/caplet-source/index.ts +++ b/packages/core/src/caplet-source/index.ts @@ -1,5 +1,20 @@ export { BundleCapletSource } from "./bundle"; export { parseCapletSource } from "./parse"; +export { + createMemoryDeclaredInputReader, + createRuntimeFingerprintSnapshot, +} from "./runtime-fingerprint"; +export type { + CapletRuntimeFingerprint, + DeclaredInputKind, + DeclaredInputListState, + DeclaredInputReader, + DeclaredInputReadContext, + DeclaredInputSnapshot, + DeclaredInputState, + RuntimeFingerprintProvenance, + RuntimeFingerprintSnapshot, +} from "./runtime-fingerprint"; export type { CapletSourceParseMessage, CapletSourceParseResult, diff --git a/packages/core/src/caplet-source/parse.ts b/packages/core/src/caplet-source/parse.ts index 3a7df7bd..b540fb50 100644 --- a/packages/core/src/caplet-source/parse.ts +++ b/packages/core/src/caplet-source/parse.ts @@ -1,6 +1,11 @@ import { loadCapletFilesFromMap } from "../caplet-files-bundle"; import { parseConfig, type CapletConfig, type CapletsConfig } from "../config-runtime"; import { planCapletRuntimeRoutes, type CapletRuntimePlan } from "../runtime-plan"; +import { + createMemoryDeclaredInputReader, + createRuntimeFingerprintSnapshot, + type RuntimeFingerprintSnapshot, +} from "./runtime-fingerprint"; import type { CapletSource } from "./types"; export type CapletSourceReference = { @@ -35,6 +40,7 @@ export type CapletSourceParseMessage = { export type CapletSourceParseResult = { ok: boolean; config?: CapletsConfig | undefined; + runtimeFingerprint?: RuntimeFingerprintSnapshot | undefined; resolvedCaplets: ParsedCapletSourceCaplet[]; warnings: CapletSourceParseMessage[]; errors: CapletSourceParseMessage[]; @@ -112,24 +118,59 @@ export async function parseCapletSource(source: CapletSource): Promise [ + caplet.id, + { + parentId: caplet.parentId, + ...(caplet.childId ? { childId: caplet.childId } : {}), + sourcePath: caplet.sourcePath, + }, + ]), + ), + reader: + declaredInputReader ?? + createMemoryDeclaredInputReader( + Object.fromEntries(files.map((file) => [file.path, file.content])), + ), + }); + } catch (error) { + return { + ok: false, + config, + resolvedCaplets: [], + warnings: [], + errors: [{ message: errorMessage(error) }], + }; } - const errors = caplets.flatMap((caplet) => - caplet.localReferences - .filter((reference) => !reference.exists) - .map((reference) => ({ + const errors = caplets.flatMap((caplet) => { + const inputs = runtimeFingerprint.caplets[caplet.id]?.declaredInputs ?? []; + for (const reference of caplet.localReferences) { + reference.exists = inputs.some( + (input) => input.logicalPath === reference.path && input.state === "present", + ); + } + return inputs + .filter((input) => input.state !== "present") + .map((input) => ({ path: caplet.sourcePath, - message: `Referenced file ${reference.path} was not found.`, - })), - ); + message: + input.state === "missing" + ? `Referenced file ${input.logicalPath} was not found.` + : `Referenced file ${input.logicalPath} is unreadable or outside the source root.`, + })); + }); return { ok: errors.length === 0, config, + runtimeFingerprint, resolvedCaplets: errors.length === 0 ? caplets : [], warnings: [], errors, diff --git a/packages/core/src/caplet-source/runtime-fingerprint.ts b/packages/core/src/caplet-source/runtime-fingerprint.ts new file mode 100644 index 00000000..71afa0a7 --- /dev/null +++ b/packages/core/src/caplet-source/runtime-fingerprint.ts @@ -0,0 +1,781 @@ +import { createHash } from "node:crypto"; +import { CapletsError } from "../errors"; +import { + loadCapletFilesFromMap, + type CapletFileConfig, + type CapletFileSourceMetadata, +} from "../caplet-files-bundle"; +import { parseConfig, type CapletConfig, type CapletsConfig } from "../config-runtime"; + +const FINGERPRINT_VERSION = 1 as const; +const CAPLET_DOMAIN = "caplets.runtime-fingerprint.caplet.v1"; +const ARTIFACT_DOMAIN = "caplets.runtime-fingerprint.artifact.v1"; +const HOST_DOMAIN = "caplets.runtime-fingerprint.host.v1"; +const INPUT_DOMAIN = "caplets.runtime-fingerprint.input.v1"; +const NESTED_INPUT_DOMAIN = "caplets.runtime-fingerprint.nested-input.v1"; +const RESOLVED_EXECUTION_DOMAIN = "caplets.runtime-fingerprint.resolved-execution.v1"; +const RUNTIME_BACKEND_KEYS = [ + "mcpServers", + "openapiEndpoints", + "googleDiscoveryApis", + "graphqlEndpoints", + "httpApis", + "cliTools", + "capletSets", +] as const; + +export type DeclaredInputState = + | { state: "present"; content: string; privateKey?: string | undefined } + | { state: "missing"; privateKey?: string | undefined } + | { state: "unreadable"; privateKey?: string | undefined }; + +export type DeclaredInputListState = + | { state: "present"; paths: string[]; privateKey?: string | undefined } + | { state: "missing"; privateKey?: string | undefined } + | { state: "unreadable"; privateKey?: string | undefined }; + +export type DeclaredInputReadContext = { + runtimeId: string; + readerScope?: string | undefined; + privateReference?: string | undefined; +}; + +export type DeclaredInputReader = { + read(logicalPath: string, context?: DeclaredInputReadContext): DeclaredInputState; + list(logicalRoot: string, context?: DeclaredInputReadContext): DeclaredInputListState; +}; + +export type RuntimeFingerprintProvenance = { + parentId: string; + childId?: string | undefined; + sourcePath: string; + readerScope?: string | undefined; +}; + +export type DeclaredInputKind = + | "openapi" + | "google-discovery" + | "graphql-schema" + | "graphql-operation" + | "caplet-config" + | "caplets-root"; + +export type DeclaredInputSnapshot = { + kind: DeclaredInputKind; + logicalPath: string; + state: "present" | "missing" | "unreadable"; + digest?: string | undefined; +}; + +type DeclaredInputGraph = { + inputs: DeclaredInputSnapshot[]; + persistenceEligible: boolean; + valid: boolean; +}; + +export type CapletRuntimeFingerprint = { + fingerprint: string; + persistenceEligible: boolean; + valid: boolean; + declaredInputs: DeclaredInputSnapshot[]; +}; + +export type RuntimeFingerprintSnapshot = { + version: typeof FINGERPRINT_VERSION; + caplets: Record; + artifactFingerprint: string; + hostConfigurationFingerprint: string; + persistenceEligible: boolean; + valid: boolean; +}; + +type FingerprintConfig = CapletsConfig & { + telemetry?: boolean | undefined; + serve?: + | { + host?: string | undefined; + port?: number | undefined; + path?: string | undefined; + remoteStatePath?: string | undefined; + upstreamUrl?: string | undefined; + allowUnauthenticatedHttp?: boolean | undefined; + trustProxy?: boolean | undefined; + publicOrigins?: string[] | undefined; + } + | undefined; +}; + +export function createRuntimeFingerprintSnapshot(input: { + config: FingerprintConfig; + provenance: Record; + reader: DeclaredInputReader; +}): RuntimeFingerprintSnapshot { + return createSnapshot(input, new Set()); +} + +export function resolvedExecutionFingerprintForConfig(config: CapletsConfig): string { + return domainHash(RESOLVED_EXECUTION_DOMAIN, { + version: FINGERPRINT_VERSION, + config, + }); +} + +export function createMemoryDeclaredInputReader( + files: Record< + string, + | string + | { state: "present"; content: string; privateKey?: string | undefined } + | { state: "missing" | "unreadable"; privateKey?: string | undefined } + >, +): DeclaredInputReader { + const normalized = new Map(); + for (const [path, value] of Object.entries(files)) { + const logicalPath = normalizeLogicalPath(path); + if (!logicalPath) { + throw new CapletsError("CONFIG_INVALID", "Declared input reader received an invalid path"); + } + normalized.set( + logicalPath, + typeof value === "string" + ? { state: "present", content: value, privateKey: logicalPath } + : value, + ); + } + return { + read(logicalPath) { + const normalizedPath = normalizeLogicalPath(logicalPath); + if (!normalizedPath) return { state: "unreadable" }; + return normalized.get(normalizedPath) ?? { state: "missing", privateKey: normalizedPath }; + }, + list(logicalRoot) { + const root = normalizeLogicalPath(logicalRoot); + if (!root) return { state: "unreadable" }; + const prefix = `${root}/`; + const paths = [...normalized.keys()].filter((path) => path.startsWith(prefix)).sort(); + return paths.length > 0 + ? { state: "present", paths, privateKey: root } + : { state: "missing", privateKey: root }; + }, + }; +} + +function createSnapshot( + input: { + config: FingerprintConfig; + provenance: Record; + reader: DeclaredInputReader; + }, + traversal: Set, +): RuntimeFingerprintSnapshot { + const caplets: Record = {}; + for (const caplet of allCaplets(input.config).sort((left, right) => + left.server.localeCompare(right.server), + )) { + const provenance = input.provenance[caplet.server] ?? { + parentId: caplet.server, + sourcePath: `${caplet.server}/CAPLET.md`, + }; + caplets[caplet.server] = fingerprintCaplet(caplet, provenance, input.reader, traversal); + } + + const sortedCapletFingerprints = Object.fromEntries( + Object.entries(caplets) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, value]) => [id, value.fingerprint]), + ); + const artifactFingerprint = domainHash(ARTIFACT_DOMAIN, sortedCapletFingerprints); + const hostOptions = enumeratedHostOptions(input.config); + const hostConfigurationFingerprint = domainHash(HOST_DOMAIN, { + caplets: sortedCapletFingerprints, + options: transformPaths(hostOptions, []), + }); + return { + version: FINGERPRINT_VERSION, + caplets, + artifactFingerprint, + hostConfigurationFingerprint, + persistenceEligible: + Object.values(caplets).every((caplet) => caplet.persistenceEligible) && + persistenceEligible(hostOptions), + valid: Object.values(caplets).every((caplet) => caplet.valid), + }; +} + +function fingerprintCaplet( + caplet: CapletConfig, + provenance: RuntimeFingerprintProvenance, + reader: DeclaredInputReader, + traversal: Set, +): CapletRuntimeFingerprint { + const declaredInputGraph = readDeclaredInputs(caplet, provenance.readerScope, reader, traversal); + const declaredInputs = declaredInputGraph.inputs; + const semantic = transformPaths(structuredClone(caplet), []); + return { + fingerprint: domainHash(CAPLET_DOMAIN, { + runtimeId: caplet.server, + source: { + parentId: provenance.parentId, + childId: provenance.childId ?? null, + sourcePath: normalizeLogicalPath(provenance.sourcePath) ?? "CAPLET.md", + }, + semantic, + declaredInputs, + }), + persistenceEligible: persistenceEligible(caplet) && declaredInputGraph.persistenceEligible, + valid: declaredInputGraph.valid, + declaredInputs, + }; +} + +function readDeclaredInputs( + caplet: CapletConfig, + readerScope: string | undefined, + reader: DeclaredInputReader, + traversal: Set, +): DeclaredInputGraph { + const references: Array<{ kind: DeclaredInputKind; path: string }> = []; + if (caplet.backend === "openapi" && caplet.specPath) { + references.push({ kind: "openapi", path: caplet.specPath }); + } else if (caplet.backend === "googleDiscovery" && caplet.discoveryPath) { + references.push({ kind: "google-discovery", path: caplet.discoveryPath }); + } else if (caplet.backend === "graphql") { + if (caplet.schemaPath) references.push({ kind: "graphql-schema", path: caplet.schemaPath }); + for (const operation of Object.values(caplet.operations ?? {})) { + if (operation.documentPath) { + references.push({ kind: "graphql-operation", path: operation.documentPath }); + } + } + } else if (caplet.backend === "caplets") { + if (caplet.configPath) references.push({ kind: "caplet-config", path: caplet.configPath }); + if (caplet.capletsRoot) references.push({ kind: "caplets-root", path: caplet.capletsRoot }); + } + + if (caplet.backend === "caplets") { + return fingerprintNestedCapletSet(caplet, readerScope, references, reader, traversal); + } + const inputs = references + .map(({ kind, path }) => + fingerprintOrdinaryInput(caplet.server, readerScope, kind, path, reader), + ) + .sort(compareDeclaredInputs); + return { + inputs, + persistenceEligible: true, + valid: inputs.every((input) => input.state === "present"), + }; +} + +function fingerprintOrdinaryInput( + runtimeId: string, + readerScope: string | undefined, + kind: DeclaredInputKind, + path: string, + reader: DeclaredInputReader, +): DeclaredInputSnapshot { + const logicalPath = declaredLogicalPath(path, kind); + const context = { + runtimeId, + readerScope, + ...(isAbsoluteHostPath(path) ? { privateReference: path } : {}), + }; + const state = reader.read(logicalPath, context); + if (state.state !== "present") return { kind, logicalPath, state: state.state }; + return { + kind, + logicalPath, + state: "present", + digest: domainHash(INPUT_DOMAIN, { kind, content: state.content }), + }; +} + +function fingerprintNestedCapletSet( + caplet: Extract, + readerScope: string | undefined, + references: Array<{ kind: DeclaredInputKind; path: string }>, + reader: DeclaredInputReader, + traversal: Set, +): DeclaredInputGraph { + let configInput: Record | undefined; + let capletInput: CapletFileConfig | undefined; + const nestedProvenance: Record = {}; + const inputs: DeclaredInputSnapshot[] = []; + const traversalKeys: string[] = []; + let valid = true; + + for (const reference of references.sort((left, right) => left.kind.localeCompare(right.kind))) { + const logicalPath = declaredLogicalPath(reference.path, reference.kind); + const context = { + runtimeId: caplet.server, + readerScope, + ...(isAbsoluteHostPath(reference.path) ? { privateReference: reference.path } : {}), + }; + if (reference.kind === "caplet-config") { + const state = reader.read(logicalPath, context); + const cycleKey = state.privateKey ?? `config:${logicalPath}`; + if (state.state !== "present") { + inputs.push({ kind: reference.kind, logicalPath, state: state.state }); + valid = false; + continue; + } + assertNoCycle(cycleKey, traversal); + traversalKeys.push(cycleKey); + try { + const parsed = JSON.parse(state.content) as unknown; + if (!isRecord(parsed)) { + throw new CapletsError( + "CONFIG_INVALID", + `Nested Caplet config ${logicalPath} must be an object`, + ); + } + configInput = normalizeNestedConfigPaths( + parsed, + logicalPath.split("/").slice(0, -1).join("/"), + ); + parseConfig({ version: 1, ...configInput }); + for (const id of capletIds(configInput)) { + nestedProvenance[id] = { parentId: id, sourcePath: logicalPath, readerScope }; + } + inputs.push({ kind: reference.kind, logicalPath, state: "present" }); + } catch (error) { + if (error instanceof CapletsError) throw error; + throw new CapletsError("CONFIG_INVALID", `Nested Caplet config ${logicalPath} is invalid`); + } + continue; + } + + const state = reader.list(logicalPath, context); + const cycleKey = state.privateKey ?? `root:${logicalPath}`; + if (state.state !== "present") { + inputs.push({ kind: reference.kind, logicalPath, state: state.state }); + valid = false; + continue; + } + assertNoCycle(cycleKey, traversal); + traversalKeys.push(cycleKey); + const prefix = `${logicalPath}/`; + const candidatePaths = effectiveCapletRootPaths( + state.paths + .filter((path) => path.startsWith(prefix)) + .map((path) => path.slice(prefix.length)), + ); + const fileStates = candidatePaths.map((path) => { + const sourcePath = `${prefix}${path}`; + return { + path, + state: reader.read(sourcePath, { runtimeId: caplet.server, readerScope }), + }; + }); + const inaccessible = fileStates.filter(({ state }) => state.state !== "present"); + if (inaccessible.length > 0) { + inputs.push({ + kind: reference.kind, + logicalPath, + state: inaccessible.some(({ state }) => state.state === "unreadable") + ? "unreadable" + : "missing", + digest: domainHash(INPUT_DOMAIN, { + kind: reference.kind, + files: inaccessible.map(({ path, state }) => ({ path, state: state.state })), + }), + }); + valid = false; + continue; + } + const files = fileStates.map(({ path, state }) => ({ + path, + content: state.state === "present" ? state.content : "", + })); + const loaded = loadCapletFilesFromMap({ files }); + if (loaded) { + capletInput = normalizeNestedConfigPaths( + loaded.config as Record, + logicalPath, + ) as CapletFileConfig; + for (const [id, metadata] of Object.entries(loaded.metadata ?? {})) { + nestedProvenance[id] = nestedSourceProvenance(metadata, logicalPath, readerScope); + } + for (const [id, path] of Object.entries(loaded.paths)) { + nestedProvenance[id] ??= { + parentId: id, + sourcePath: logicalJoin(logicalPath, path), + readerScope, + }; + } + } + inputs.push({ kind: reference.kind, logicalPath, state: "present" }); + } + + if (!configInput && !capletInput) { + return { + inputs: inputs.sort(compareDeclaredInputs), + persistenceEligible: true, + valid: references.length === 0 || valid, + }; + } + const merged = mergeRuntimeInputs(configInput, capletInput, { + version: 1, + defaultSearchLimit: caplet.defaultSearchLimit, + maxSearchLimit: caplet.maxSearchLimit, + }); + const config = parseConfig(merged); + const nested = createSnapshot( + { config, provenance: nestedProvenance, reader }, + new Set([...traversal, ...traversalKeys]), + ); + const digest = domainHash(NESTED_INPUT_DOMAIN, { + artifactFingerprint: nested.artifactFingerprint, + hostConfigurationFingerprint: nested.hostConfigurationFingerprint, + runtimeIds: Object.keys(nested.caplets).sort(), + }); + return { + inputs: inputs + .map((input) => (input.state === "present" ? { ...input, digest } : input)) + .sort(compareDeclaredInputs), + persistenceEligible: nested.persistenceEligible, + valid: valid && nested.valid, + }; +} + +function assertNoCycle(key: string, traversal: Set): void { + if (traversal.has(key)) { + throw new CapletsError("CONFIG_INVALID", "Nested Caplet set cycle detected"); + } +} + +function nestedSourceProvenance( + metadata: CapletFileSourceMetadata, + root: string, + readerScope: string | undefined, +): RuntimeFingerprintProvenance { + return { + parentId: metadata.parentId, + ...(metadata.childId ? { childId: metadata.childId } : {}), + sourcePath: logicalJoin(root, metadata.path), + readerScope, + }; +} + +function transformPaths(value: unknown, path: string[]): unknown { + if (Array.isArray(value)) { + return value.map((entry, index) => transformPaths(entry, [...path, String(index)])); + } + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, nested]) => nested !== undefined) + .map(([key, nested]) => { + const nextPath = [...path, key]; + if (typeof nested === "string" && DECLARED_PATH_KEYS[key]) { + return [key, canonicalSemanticPath(nested, nextPath.join("."))]; + } + return [key, transformPaths(nested, nextPath)]; + }), + ); +} + +const DECLARED_PATH_KEYS: Record = { + specPath: true, + discoveryPath: true, + schemaPath: true, + documentPath: true, + configPath: true, + capletsRoot: true, +}; + +function canonicalSemanticPath(value: string, label: string): string { + if ( + isAbsoluteHostPath(value) || + hasTemplateReference(value) || + /^[a-z][a-z0-9+.-]*:/iu.test(value) + ) { + return value; + } + const normalized = normalizeLogicalPath(value); + if (!normalized) { + throw new CapletsError("CONFIG_INVALID", `Declared input ${label} contains path traversal`); + } + return normalized; +} + +function declaredLogicalPath(value: string, kind: DeclaredInputKind): string { + if (isAbsoluteHostPath(value)) return `@absolute/${kind}`; + const normalized = normalizeLogicalPath(value); + if (!normalized) { + throw new CapletsError("CONFIG_INVALID", `Declared ${kind} input contains path traversal`); + } + return normalized; +} + +function normalizeLogicalPath(value: string): string | undefined { + const raw = value.trim().replace(/\\/gu, "/").replace(/^\.\//u, ""); + if (!raw || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) return undefined; + const output: string[] = []; + for (const segment of raw.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") return undefined; + output.push(segment); + } + return output.length > 0 ? output.join("/") : undefined; +} + +function logicalJoin(base: string, path: string): string { + const joined = [base, path].filter(Boolean).join("/"); + const normalized = normalizeLogicalPath(joined); + if (!normalized) + throw new CapletsError("CONFIG_INVALID", "Declared input contains path traversal"); + return normalized; +} + +function normalizeNestedConfigPaths( + input: Record, + base: string, +): Record { + const output = structuredClone(input); + for (const backendKey of [ + "openapiEndpoints", + "googleDiscoveryApis", + "graphqlEndpoints", + "capletSets", + ]) { + const entries = output[backendKey]; + if (!isRecord(entries)) continue; + for (const value of Object.values(entries)) { + if (!isRecord(value)) continue; + for (const key of Object.keys(DECLARED_PATH_KEYS)) { + if (typeof value[key] === "string") value[key] = logicalJoin(base, value[key] as string); + } + if (backendKey === "graphqlEndpoints" && isRecord(value.operations)) { + for (const operation of Object.values(value.operations)) { + if (isRecord(operation) && typeof operation.documentPath === "string") { + operation.documentPath = logicalJoin(base, operation.documentPath); + } + } + } + } + } + return output; +} + +function effectiveCapletRootPaths(paths: string[]): string[] { + const directoryIds: Record = {}; + for (const path of paths) { + const parts = path.split("/"); + if (parts.length === 2 && parts[1] === "CAPLET.md") directoryIds[parts[0]!] = true; + } + return paths + .filter((path) => { + const parts = path.split("/"); + if (parts.length === 2 && parts[1] === "CAPLET.md") return true; + if (parts.length !== 1 || !path.toLowerCase().endsWith(".md")) return false; + const id = path.slice(0, path.lastIndexOf(".")); + return !directoryIds[id]; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function mergeRuntimeInputs( + ...inputs: Array | CapletFileConfig | undefined> +): Record { + const backendKeys = RUNTIME_BACKEND_KEYS; + let merged: Record = {}; + for (const input of inputs) { + if (!input) continue; + const inputRecord = input as Record; + const previous = merged; + const incomingIds = new Set( + backendKeys.flatMap((key) => + isRecord(inputRecord[key]) ? Object.keys(inputRecord[key]) : [], + ), + ); + merged = { ...previous, ...inputRecord }; + for (const key of backendKeys) { + if (!(key in inputRecord)) continue; + if (!isRecord(inputRecord[key])) { + merged[key] = inputRecord[key]; + continue; + } + const retained = Object.fromEntries( + Object.entries(isRecord(previous[key]) ? previous[key] : {}).filter( + ([id]) => !incomingIds.has(id), + ), + ); + merged[key] = { + ...retained, + ...inputRecord[key], + }; + } + if ("namespaceAliases" in inputRecord && isRecord(inputRecord.namespaceAliases)) { + merged.namespaceAliases = mergeNamespaceAliases( + previous.namespaceAliases, + inputRecord.namespaceAliases, + ); + } + } + return merged; +} + +function mergeNamespaceAliases(left: unknown, right: unknown): unknown { + if (!isRecord(left) && !isRecord(right)) return undefined; + return { + ...(isRecord(left) ? left : {}), + ...(isRecord(right) ? right : {}), + upstreams: { + ...(isRecord(left) && isRecord(left.upstreams) ? left.upstreams : {}), + ...(isRecord(right) && isRecord(right.upstreams) ? right.upstreams : {}), + }, + }; +} + +function capletIds(input: Record): string[] { + return RUNTIME_BACKEND_KEYS.flatMap((key) => + isRecord(input[key]) ? Object.keys(input[key]) : [], + ); +} + +function allCaplets(config: CapletsConfig): CapletConfig[] { + return RUNTIME_BACKEND_KEYS.flatMap((key) => Object.values(config[key])); +} + +function enumeratedHostOptions(config: FingerprintConfig): unknown { + return { + version: config.version, + paging: { + defaultSearchLimit: config.options.defaultSearchLimit, + maxSearchLimit: config.options.maxSearchLimit, + }, + exposure: { + defaultMode: config.options.exposure, + discoveryTimeoutMs: config.options.exposureDiscoveryTimeoutMs, + discoveryConcurrency: config.options.exposureDiscoveryConcurrency, + }, + completion: { + discoveryTimeoutMs: config.options.completion.discoveryTimeoutMs, + overallTimeoutMs: config.options.completion.overallTimeoutMs, + cacheTtlMs: config.options.completion.cacheTtlMs, + negativeCacheTtlMs: config.options.completion.negativeCacheTtlMs, + }, + namespaceAliases: { + local: config.namespaceAliases.local ?? null, + upstreams: config.namespaceAliases.upstreams, + }, + telemetry: config.telemetry ?? null, + serve: { + host: config.serve?.host ?? null, + port: config.serve?.port ?? null, + path: config.serve?.path ?? null, + remoteStatePath: config.serve?.remoteStatePath ?? null, + upstreamUrl: config.serve?.upstreamUrl ?? null, + allowUnauthenticatedHttp: config.serve?.allowUnauthenticatedHttp ?? null, + trustProxy: config.serve?.trustProxy ?? null, + publicOrigins: config.serve?.publicOrigins ?? [], + }, + }; +} + +function persistenceEligible(value: unknown, path: string[] = []): boolean { + if (typeof value === "string") { + if (isSecretCapablePath(path)) return hasTemplateReference(value); + if (isDynamicSetupExecutionPath(path) && hasTemplateReference(value)) return false; + if (isHostPath(path, value)) return false; + return true; + } + if (Array.isArray(value)) { + return value.every((entry, index) => persistenceEligible(entry, [...path, String(index)])); + } + if (!value || typeof value !== "object") return true; + return Object.entries(value as Record).every(([key, nested]) => + persistenceEligible(nested, [...path, key]), + ); +} + +function isSecretCapablePath(path: string[]): boolean { + const normalized = path.map((segment) => segment.toLowerCase()); + const key = normalized.at(-1); + if (!key) return false; + if (key === "token" || key === "clientsecret") return true; + if (normalized.includes("env") || normalized.includes("headers")) return true; + if (normalized.includes("query") || normalized.includes("jsonbody")) return true; + return false; +} + +function isDynamicSetupExecutionPath(path: string[]): boolean { + const normalized = path.map((segment) => segment.toLowerCase()); + if ( + !normalized.includes("setup") || + (!normalized.includes("actions") && + !normalized.includes("commands") && + !normalized.includes("verify")) + ) { + return false; + } + const key = normalized.at(-1); + return key === "command" || key === "cwd" || normalized.includes("args"); +} + +function isHostPath(path: string[], value: string): boolean { + if (!isAbsoluteHostPath(value)) return false; + const key = path.at(-1)?.toLowerCase() ?? ""; + if (key === "path" && path.includes("serve")) return false; + if (key === "path" && path.includes("actions")) return false; + return ( + key === "cwd" || + key === "command" || + key.endsWith("path") || + path.some((segment) => segment === "args") + ); +} + +function hasTemplateReference(value: string): boolean { + return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{vault:[^}]+\}|\$vault:[A-Za-z0-9_-]+/u.test( + value, + ); +} + +function isAbsoluteHostPath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value); +} + +function compareDeclaredInputs(left: DeclaredInputSnapshot, right: DeclaredInputSnapshot): number { + return left.logicalPath.localeCompare(right.logicalPath) || left.kind.localeCompare(right.kind); +} + +function domainHash(domain: string, value: unknown): string { + const encodedDomain = new TextEncoder().encode(domain); + const encodedValue = new TextEncoder().encode(stableEncode(value)); + const hash = createHash("sha256"); + hash.update(`domain:${encodedDomain.byteLength}:`); + hash.update(encodedDomain); + hash.update(`payload:${encodedValue.byteLength}:`); + hash.update(encodedValue); + return hash.digest("hex"); +} + +function stableEncode(value: unknown): string { + if (value === null) return "n"; + if (value === undefined) return "u"; + if (typeof value === "boolean") return value ? "b1" : "b0"; + if (typeof value === "number") { + const encoded = Object.is(value, -0) ? "-0" : String(value); + return `d${encoded.length}:${encoded}`; + } + if (typeof value === "string") { + const length = new TextEncoder().encode(value).byteLength; + return `s${length}:${value}`; + } + if (Array.isArray(value)) { + return `a${value.length}:[${value.map(stableEncode).join("")}]`; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, nested]) => nested !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return `o${entries.length}:{${entries + .map(([key, nested]) => `${stableEncode(key)}${stableEncode(nested)}`) + .join("")}}`; + } + throw new TypeError(`Unsupported fingerprint value: ${typeof value}`); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/src/caplet-source/types.ts b/packages/core/src/caplet-source/types.ts index b1e539aa..0bc5ec24 100644 --- a/packages/core/src/caplet-source/types.ts +++ b/packages/core/src/caplet-source/types.ts @@ -1,3 +1,5 @@ +import type { DeclaredInputReader } from "./runtime-fingerprint"; + export type CapletSourceFile = { path: string; content: string; @@ -6,6 +8,7 @@ export type CapletSourceFile = { export type CapletSource = { listFiles(): Promise; readFile(path: string): Promise; + declaredInputReader?(): DeclaredInputReader; }; export function normalizeCapletSourcePath(path: string): string | undefined { diff --git a/packages/core/src/catalog/entry.ts b/packages/core/src/catalog/entry.ts index a40c892a..fb7b35cc 100644 --- a/packages/core/src/catalog/entry.ts +++ b/packages/core/src/catalog/entry.ts @@ -26,8 +26,6 @@ export function createCatalogEntry(input: CatalogEntryInput): CatalogEntry { ...(input.contentMarkdown ? { contentMarkdown: input.contentMarkdown } : {}), ...(input.icon ? { icon: input.icon } : {}), tags: stableTags(input.tags ?? []), - intendedTask: input.useWhen?.trim() || "unknown", - ...(input.avoidWhen?.trim() ? { avoidWhen: input.avoidWhen.trim() } : {}), setupReadiness: readiness(input.setupRequired), authReadiness: readiness(input.authRequired), projectBindingReadiness: readiness(input.projectBindingRequired), diff --git a/packages/core/src/catalog/types.ts b/packages/core/src/catalog/types.ts index 7060ba22..69f51d98 100644 --- a/packages/core/src/catalog/types.ts +++ b/packages/core/src/catalog/types.ts @@ -100,8 +100,6 @@ export type CatalogEntryInput = { contentMarkdown?: string | undefined; icon?: CatalogIcon | undefined; tags?: string[] | undefined; - useWhen?: string | undefined; - avoidWhen?: string | undefined; setupRequired?: boolean | undefined; authRequired?: boolean | undefined; projectBindingRequired?: boolean | undefined; @@ -132,8 +130,6 @@ export type CatalogEntry = { contentMarkdown?: string | undefined; icon?: CatalogIcon | undefined; tags: string[]; - intendedTask: string | "unknown"; - avoidWhen?: string | undefined; setupReadiness: CatalogReadiness; authReadiness: CatalogReadiness; projectBindingReadiness: CatalogReadiness; diff --git a/packages/core/src/cli-tools.ts b/packages/core/src/cli-tools.ts index ea0b1963..f2299051 100644 --- a/packages/core/src/cli-tools.ts +++ b/packages/core/src/cli-tools.ts @@ -157,8 +157,6 @@ export class CliToolsManager { return { name: action.name, ...(action.description ? { description: action.description } : {}), - ...(action.useWhen ? { useWhen: action.useWhen } : {}), - ...(action.avoidWhen ? { avoidWhen: action.avoidWhen } : {}), inputSchema: (action.inputSchema ?? DEFAULT_INPUT_SCHEMA) as Tool["inputSchema"], ...(action.outputSchema ? { outputSchema: action.outputSchema as Tool["outputSchema"] } : {}), ...(action.annotations ? { annotations: action.annotations as Tool["annotations"] } : {}), diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 9d1b22c6..6993218b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -3041,7 +3041,13 @@ export function createProgram(io: CliIO = {}): Command { installed: Array<{ id: string; destination: string; - status?: "installed" | "restored" | "updated" | "noop" | undefined; + status?: + | "installed" + | "restored" + | "updated" + | "content_updated" + | "noop" + | undefined; catalogIndexing?: CatalogIndexingResult | undefined; }>; }; @@ -3050,12 +3056,7 @@ export function createProgram(io: CliIO = {}): Command { return; } for (const caplet of result.installed) { - const action = - caplet.status === "noop" - ? "Already installed" - : caplet.status === "restored" - ? "Restored" - : "Installed"; + const action = installStatusLabel(caplet.status, "Installed"); writeOut(`${action} ${caplet.id} to remote ${caplet.destination}\n`); writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); } @@ -3081,7 +3082,7 @@ export function createProgram(io: CliIO = {}): Command { } for (const caplet of result.installed) { writeOut( - `${caplet.status === "noop" ? "Already installed" : "Restored"} ${caplet.id} to ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, + `${installStatusLabel(caplet.status, "Restored")} ${caplet.id} to ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, ); writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); writeVaultSetupNotice(caplet.vaultSetup, writeOut); @@ -3147,7 +3148,7 @@ export function createProgram(io: CliIO = {}): Command { } for (const caplet of result.installed) { writeOut( - `${caplet.status === "noop" ? "Already current" : "Updated"} ${caplet.id} at remote ${caplet.destination}\n`, + `${updateStatusLabel(caplet.status)} ${caplet.id} at remote ${caplet.destination}\n`, ); writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); } @@ -3176,7 +3177,7 @@ export function createProgram(io: CliIO = {}): Command { } for (const caplet of result.installed) { writeOut( - `${caplet.status === "noop" ? "Already current" : "Updated"} ${caplet.id} at ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, + `${updateStatusLabel(caplet.status)} ${caplet.id} at ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, ); writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); writeVaultSetupNotice(caplet.vaultSetup, writeOut); @@ -4298,11 +4299,17 @@ async function attachCatalogIndexingResults( }>, env: NodeJS.ProcessEnv | Record, ): Promise { - const results = await indexInstalledCapletsFromLockfile(installed, { - disableCatalogIndexing: catalogIndexingDisabled(env), - }); - for (const entry of installed) { - entry.catalogIndexing = results.get(entry.id); + try { + const results = await indexInstalledCapletsFromLockfile(installed, { + disableCatalogIndexing: catalogIndexingDisabled(env), + }); + for (const entry of installed) { + entry.catalogIndexing = results.get(entry.id); + } + } catch { + for (const entry of installed) { + entry.catalogIndexing = { status: "unavailable", reason: "indexer_unavailable" }; + } } } @@ -4699,6 +4706,22 @@ function completionRefFromOptions(options: { prompt?: string; resourceTemplate?: throw new CapletsError("REQUEST_INVALID", "complete requires --prompt or --resource-template"); } +function installStatusLabel( + status: string | undefined, + defaultLabel: "Installed" | "Restored", +): string { + if (status === "noop") return "Already installed"; + if (status === "content_updated") return "Content updated"; + if (status === "restored") return "Restored"; + return defaultLabel; +} + +function updateStatusLabel(status: string | undefined): string { + if (status === "noop") return "Already current"; + if (status === "content_updated") return "Content updated"; + return "Updated"; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/packages/core/src/cli/author.ts b/packages/core/src/cli/author.ts index ac7c69c2..4d54ab97 100644 --- a/packages/core/src/cli/author.ts +++ b/packages/core/src/cli/author.ts @@ -255,7 +255,30 @@ function renderCaplet(input: { } } } - lines.push("---", "", `# ${input.name}`, "", input.description, ""); + lines.push( + "---", + "", + `# ${input.name}`, + "", + "This Markdown body is a publishable human operator README. Keep runtime actions, paths, environment and authentication values, structured setup metadata, and agent-selection guidance in YAML frontmatter. Do not include secrets, credentials, private endpoints, customer data, or other sensitive operational material.", + "", + "## Prerequisites and setup context", + "", + "Describe what an operator should have or understand before enabling this Caplet.", + "", + "## Safe operation and limits", + "", + "Record operator-visible risks, confirmation points, permission boundaries, and known capability limits.", + "", + "## Troubleshooting", + "", + "List common setup or operation failures and how an operator can diagnose them.", + "", + "## References", + "", + "Link provider documentation, runbooks, or related operator material. Body links and configuration-looking literals are documentation only, never runtime inputs.", + "", + ); return `${lines.join("\n")}`; } diff --git a/packages/core/src/cli/code-mode.ts b/packages/core/src/cli/code-mode.ts index 296d8d3a..e7d588e0 100644 --- a/packages/core/src/cli/code-mode.ts +++ b/packages/core/src/cli/code-mode.ts @@ -214,8 +214,6 @@ async function listCodeModeCallableCaplets( name: entry.title ?? entry.id, description: entry.description ?? "", shadowing: entry.shadowing, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), }, ]; }) diff --git a/packages/core/src/cli/install.ts b/packages/core/src/cli/install.ts index 1cdc3ff7..c66bcb20 100644 --- a/packages/core/src/cli/install.ts +++ b/packages/core/src/cli/install.ts @@ -1,28 +1,42 @@ import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { accessSync, constants, + closeSync, copyFileSync, - cpSync, existsSync, lstatSync, mkdirSync, + linkSync, mkdtempSync, + openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, + writeFileSync, type Stats, statSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"; import { parse as parseYaml } from "yaml"; -import { discoverCapletFiles, loadCapletFilesWithPaths, validateCapletFile } from "../caplet-files"; +import { + discoverCapletFiles, + loadCapletFilesFromMap, + loadCapletFilesWithPaths, + validateCapletFile, +} from "../caplet-files"; import type { CapletFileConfig } from "../caplet-files"; +import { + createMemoryDeclaredInputReader, + createRuntimeFingerprintSnapshot, + type RuntimeFingerprintSnapshot, +} from "../caplet-source"; +import { parseConfig } from "../config-runtime"; import { resolveProjectCapletsRoot } from "../config"; import { SERVER_ID_PATTERN } from "../config/validation"; import { CapletsError, toSafeError } from "../errors"; @@ -46,11 +60,17 @@ import { type CatalogWorkflowSummary, } from "../catalog"; import { + replaceCapletsLockfileTemporary, + parseCapletsLockfile, readCapletsLockfile, validateLockfileDestination, writeCapletsLockfile, + writeCapletsLockfileAtomically, + writeCapletsLockfileTemporary, type CapletsLockEntry, + type CapletsLockRuntimeFingerprint, type CapletsLockSource, + type CapletsLockfile, } from "./lockfile"; export type InstallableCaplet = { @@ -59,7 +79,7 @@ export type InstallableCaplet = { destination: string; kind: "file" | "directory"; hash?: string | undefined; - status?: "installed" | "restored" | "updated" | "noop" | undefined; + status?: "installed" | "restored" | "updated" | "content_updated" | "noop" | undefined; lockfile?: string | undefined; catalogIndexing?: CatalogIndexingResult | undefined; vaultSetup?: unknown; @@ -77,15 +97,62 @@ type LockedSourceResolution = { cleanup: () => void; }; +export type CapletTransactionPhase = + | "prepared" + | "staged" + | "backup_created" + | "destination_replaced" + | "lock_temporary_written" + | "lock_replaced"; + +export type InstallTransactionDependencies = { + writeLockfileTemporary?: (path: string, lockfile: CapletsLockfile, temporaryPath: string) => void; + replaceLockfileTemporary?: (path: string, temporaryPath: string) => void; + cleanupTransaction?: () => void; + onTransactionPhase?: (phase: CapletTransactionPhase) => "interrupt" | void; +}; + +type CapletArtifactTransactionJournal = { + version: 1; + entryId: string; + destination: string; + hadDestination: boolean; + beforeEntry: CapletsLockEntry; + afterEntry: CapletsLockEntry; + beforeHash?: string | undefined; + afterHash: string; + phase: CapletTransactionPhase; +}; + +type CandidateArtifact = { + plan: InstallPlan; + transactionPaths: CapletTransactionPaths; + stagedPath: string; + artifactHash: string; + runtimeSnapshot: RuntimeFingerprintSnapshot; + risk: CapletsLockEntry["risk"]; +}; + +type InstallCapletsOptions = { + capletIds?: string[]; + destinationRoot?: string; + force?: boolean; + lockfilePath?: string | undefined; + now?: Date | undefined; +}; + export function installCaplets( repo: string, - options: { - capletIds?: string[]; - destinationRoot?: string; - force?: boolean; - lockfilePath?: string | undefined; - now?: Date | undefined; - } = {}, + options: InstallCapletsOptions = {}, +): { installed: InstallableCaplet[] } { + return options.lockfilePath + ? withLockfileTransaction(options.lockfilePath, () => installCapletsUnlocked(repo, options)) + : installCapletsUnlocked(repo, options); +} + +function installCapletsUnlocked( + repo: string, + options: InstallCapletsOptions = {}, ): { installed: InstallableCaplet[] } { const source = resolveInstallSource(repo); try { @@ -149,55 +216,150 @@ export function installCaplets( } } -export function restoreCapletsFromLockfile(options: { +type TransactionLockMetadata = { + pid: number; + acquiredAt: string; + ownerToken: string; +}; + +function withLockfileTransaction(lockfilePath: string, operation: () => T): T { + const path = `${lockfilePath}.transaction.lock`; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const owner: TransactionLockMetadata = { + pid: process.pid, + acquiredAt: new Date().toISOString(), + ownerToken: randomUUID(), + }; + const recoveryPath = recoverDeadTransactionLock(path, owner); + let descriptor: number | undefined; + try { + descriptor = openSync(path, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(owner)}\n`); + } catch (error) { + if (descriptor !== undefined) { + closeSync(descriptor); + rmSync(path, { force: true }); + } + throw new CapletsError( + "CONFIG_EXISTS", + `Another Caplets install, restore, or update holds ${path}; if no operation is running, remove the lock and retry`, + toSafeError(error), + ); + } finally { + if (recoveryPath) rmSync(recoveryPath, { force: true }); + } + try { + return operation(); + } finally { + closeSync(descriptor); + const currentOwner = readTransactionLockMetadata(path); + if (currentOwner?.ownerToken === owner.ownerToken) rmSync(path, { force: true }); + } +} + +function recoverDeadTransactionLock( + path: string, + recoveringOwner: TransactionLockMetadata, +): string | undefined { + if (!existsSync(path)) return undefined; + const existingOwner = readTransactionLockMetadata(path); + if (!existingOwner || !isDeadProcess(existingOwner.pid)) return undefined; + + const recoveryPath = `${path}.recovery`; + if (existsSync(recoveryPath)) { + const recoveryOwner = readTransactionLockMetadata(recoveryPath); + if (!recoveryOwner || !isDeadProcess(recoveryOwner.pid)) return undefined; + rmSync(recoveryPath, { force: true }); + } + + try { + linkSync(path, recoveryPath); + } catch { + return undefined; + } + try { + const claimedOwner = readTransactionLockMetadata(recoveryPath); + if (!claimedOwner || !isDeadProcess(claimedOwner.pid)) return undefined; + writeFileSync(recoveryPath, `${JSON.stringify(recoveringOwner)}\n`, { mode: 0o600 }); + const lockStats = lstatSync(path); + const recoveryStats = lstatSync(recoveryPath); + if (lockStats.dev !== recoveryStats.dev || lockStats.ino !== recoveryStats.ino) { + return undefined; + } + rmSync(path); + return recoveryPath; + } finally { + if (existsSync(path)) rmSync(recoveryPath, { force: true }); + } +} + +function readTransactionLockMetadata(path: string): TransactionLockMetadata | undefined { + try { + const value = JSON.parse(readFileSync(path, "utf8")) as unknown; + if ( + !isRecord(value) || + !Number.isSafeInteger(value.pid) || + (value.pid as number) <= 0 || + typeof value.acquiredAt !== "string" + ) { + return undefined; + } + return { + pid: value.pid as number, + acquiredAt: value.acquiredAt, + ownerToken: typeof value.ownerToken === "string" ? value.ownerToken : "", + }; + } catch { + return undefined; + } +} + +function isDeadProcess(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +type RestoreCapletsOptions = { destinationRoot?: string; lockfilePath: string; force?: boolean; capletIds?: string[] | undefined; now?: Date | undefined; -}): { installed: InstallableCaplet[] } { - const destinationRoot = options.destinationRoot ?? resolveProjectCapletsRoot(); - const lockfile = readCapletsLockfile(options.lockfilePath); - const selectedIds = new Set(options.capletIds ?? []); - const entries = lockfile.entries.filter( - (entry) => selectedIds.size === 0 || selectedIds.has(entry.id), - ); - const missing = [...selectedIds].filter( - (id) => !lockfile.entries.some((entry) => entry.id === id), +}; + +export function restoreCapletsFromLockfile( + options: RestoreCapletsOptions, + dependencies: InstallTransactionDependencies = {}, +): { installed: InstallableCaplet[] } { + return withLockfileTransaction(options.lockfilePath, () => + restoreCapletsFromLockfileUnlocked(options, dependencies), ); - if (missing.length > 0) { - throw new CapletsError( - "CONFIG_NOT_FOUND", - `Caplet ${missing.join(", ")} not found in lockfile`, - ); - } - if (entries.length === 0) { - throw new CapletsError( - "CONFIG_NOT_FOUND", - `No Caplets found in lockfile ${options.lockfilePath}`, - ); - } +} +function restoreCapletsFromLockfileUnlocked( + options: RestoreCapletsOptions, + dependencies: InstallTransactionDependencies, +): { installed: InstallableCaplet[] } { + const destinationRoot = options.destinationRoot ?? resolveProjectCapletsRoot(); + recoverInterruptedCapletTransactions({ + lockfilePath: options.lockfilePath, + destinationRoot, + dependencies, + }); + const lockfile = readCapletsLockfile(options.lockfilePath); + const entries = selectedLockEntries(lockfile, options.capletIds, options.lockfilePath); const nextEntries = new Map(lockfile.entries.map((entry) => [entry.id, entry])); const results: InstallableCaplet[] = []; for (const entry of entries) { const destination = validateLockfileDestination(destinationRoot, entry.destination); const existing = lstatIfExists(destination); - if (existing) { - const currentHash = hashInstalledArtifact(destination); - if (currentHash === entry.installedHash) { - results.push({ - id: entry.id, - source: lockSourceDisplay(entry.source), - destination, - kind: entry.kind, - hash: currentHash, - status: "noop", - lockfile: options.lockfilePath, - }); - continue; - } - if (!options.force) { + const currentHash = existing ? hashInstalledArtifact(destination) : undefined; + if (currentHash !== undefined) { + if (currentHash !== entry.installedHash && !options.force) { throw new CapletsError( "CONFIG_EXISTS", `Caplet ${entry.id} has local modifications at ${destination}; pass --force to replace it`, @@ -206,99 +368,81 @@ export function restoreCapletsFromLockfile(options: { } const lockedSource = resolveLockedSource(entry.source); + let candidate: CandidateArtifact | undefined; try { - const plan: InstallPlan = { - id: entry.id, - source: lockSourceDisplay(entry.source), - sourcePath: lockedSource.sourcePath, - sourceBoundary: dirname(lockedSource.sourcePath), - destination, - kind: entry.kind, - }; - preflightInstallCaplets( - [ - { - id: entry.id, - path: - entry.kind === "directory" - ? join(lockedSource.sourcePath, "CAPLET.md") - : lockedSource.sourcePath, - }, - ], - { - destinationRoot, - force: true, - repoRoot: lockedSource.repoRoot, - sourceId: lockSourceDisplay(entry.source), - }, - ); - installOneCaplet(plan, { force: true }); - const hash = hashInstalledArtifact(destination); - if (hash !== entry.installedHash) { - nextEntries.set(entry.id, { - ...entry, - installedHash: hash, - updatedAt: (options.now ?? new Date()).toISOString(), - }); - writeCapletsLockfile(options.lockfilePath, { - version: 1, - entries: [...nextEntries.values()], - }); + const plan = lockedInstallPlan(entry, destination, lockedSource); + candidate = stageAndValidateCandidate(plan, options.lockfilePath); + if (candidate.artifactHash !== entry.installedHash) { + throw new CapletsError( + "CONFIG_INVALID", + `Locked source for ${entry.id} has changed; run caplets update to accept it`, + ); } - results.push({ - id: entry.id, - source: lockSourceDisplay(entry.source), - destination, - kind: entry.kind, - hash, - status: "restored", - lockfile: options.lockfilePath, - }); + const status = + currentHash === entry.installedHash && candidate.artifactHash === entry.installedHash + ? "noop" + : "restored"; + const finalizingCandidate = candidate; + candidate = undefined; + results.push( + finalizeStagedCandidate({ + lockfilePath: options.lockfilePath, + entry, + candidate: finalizingCandidate, + status, + nextEntries, + now: options.now, + dependencies, + }), + ); } finally { + if (candidate) { + removeInstallPath(candidate.stagedPath, `staged Caplet ${entry.id}`, true); + } lockedSource.cleanup(); } } return { installed: results }; } -export function updateCapletsFromLockfile(options: { +type UpdateCapletsOptions = { destinationRoot?: string; lockfilePath: string; force?: boolean; allowRiskIncrease?: boolean; capletIds?: string[] | undefined; now?: Date | undefined; -}): { installed: InstallableCaplet[] } { - const destinationRoot = options.destinationRoot ?? resolveProjectCapletsRoot(); - const lockfile = readCapletsLockfile(options.lockfilePath); - const selectedIds = new Set(options.capletIds ?? []); - const entries = lockfile.entries.filter( - (entry) => selectedIds.size === 0 || selectedIds.has(entry.id), - ); - const missing = [...selectedIds].filter( - (id) => !lockfile.entries.some((entry) => entry.id === id), +}; + +export function updateCapletsFromLockfile( + options: UpdateCapletsOptions, + dependencies: InstallTransactionDependencies = {}, +): { installed: InstallableCaplet[] } { + return withLockfileTransaction(options.lockfilePath, () => + updateCapletsFromLockfileUnlocked(options, dependencies), ); - if (missing.length > 0) { - throw new CapletsError( - "CONFIG_NOT_FOUND", - `Caplet ${missing.join(", ")} not found in lockfile`, - ); - } - if (entries.length === 0) { - throw new CapletsError( - "CONFIG_NOT_FOUND", - `No Caplets found in lockfile ${options.lockfilePath}`, - ); - } +} +function updateCapletsFromLockfileUnlocked( + options: UpdateCapletsOptions, + dependencies: InstallTransactionDependencies, +): { installed: InstallableCaplet[] } { + const destinationRoot = options.destinationRoot ?? resolveProjectCapletsRoot(); + recoverInterruptedCapletTransactions({ + lockfilePath: options.lockfilePath, + destinationRoot, + dependencies, + }); + const lockfile = readCapletsLockfile(options.lockfilePath); + const entries = selectedLockEntries(lockfile, options.capletIds, options.lockfilePath); const allowRiskIncrease = options.allowRiskIncrease ?? options.force ?? false; const nextEntries = new Map(lockfile.entries.map((entry) => [entry.id, entry])); const results: InstallableCaplet[] = []; for (const entry of entries) { const destination = validateLockfileDestination(destinationRoot, entry.destination); const existing = lstatIfExists(destination); - if (existing) { - const currentHash = hashInstalledArtifact(destination); + const currentHash = existing ? hashInstalledArtifact(destination) : undefined; + if (currentHash !== undefined) { if (currentHash !== entry.installedHash && !options.force) { throw new CapletsError( "CONFIG_EXISTS", @@ -308,105 +452,653 @@ export function updateCapletsFromLockfile(options: { } const lockedSource = resolveLockedSource(entry.source, { useResolvedRevision: false }); + let candidate: CandidateArtifact | undefined; try { if (!existsSync(lockedSource.sourcePath)) { throw new CapletsError("CONFIG_NOT_FOUND", `Locked source for ${entry.id} is unavailable`); } const nextSource = refreshedLockSource(entry.source, lockedSource); - const sourceHash = - entry.kind === "directory" - ? hashDirectoryCapletInstallSource( - lockedSource.sourcePath, - realpathSync(dirname(lockedSource.sourcePath)), - ) - : hashInstalledArtifact(lockedSource.sourcePath); - const nextRisk = riskSummaryForSourcePath(lockedSource.sourcePath); - if (existing && sourceHash === entry.installedHash && !options.force) { - const sourceChanged = !sameLockSource(entry.source, nextSource); - const riskChanged = !sameLockRisk(entry.risk, nextRisk); - if (sourceChanged || riskChanged) { - nextEntries.set(entry.id, { - ...entry, - source: nextSource, - risk: nextRisk, - updatedAt: (options.now ?? new Date()).toISOString(), - }); - writeCapletsLockfile(options.lockfilePath, { - version: 1, - entries: [...nextEntries.values()], - }); + const plan = lockedInstallPlan(entry, destination, lockedSource); + candidate = stageAndValidateCandidate(plan, options.lockfilePath); + + let status: "noop" | "content_updated" | "updated"; + if (currentHash === entry.installedHash && candidate.artifactHash === entry.installedHash) { + status = "noop"; + } else { + const installedFingerprint = existing + ? fingerprintInstalledArtifact(entry, destination)?.artifactFingerprint + : undefined; + status = + installedFingerprint !== undefined && + installedFingerprint === candidate.runtimeSnapshot.artifactFingerprint + ? "content_updated" + : "updated"; + if ( + status === "updated" && + !allowRiskIncrease && + riskIncrease(entry.risk, candidate.risk) + ) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplet ${entry.id} update changes its risk profile; pass --force to update it`, + ); } - results.push({ - id: entry.id, - source: lockSourceDisplay(entry.source), - destination, - kind: entry.kind, - hash: entry.installedHash, - status: "noop", - lockfile: options.lockfilePath, - }); - continue; } - if (!allowRiskIncrease && riskIncrease(entry.risk, nextRisk)) { - throw new CapletsError( - "REQUEST_INVALID", - `Caplet ${entry.id} update changes its risk profile; pass --force to update it`, - ); + + const finalizingCandidate = candidate; + candidate = undefined; + results.push( + finalizeStagedCandidate({ + lockfilePath: options.lockfilePath, + entry, + source: nextSource, + candidate: finalizingCandidate, + status, + nextEntries, + now: options.now, + dependencies, + }), + ); + } finally { + if (candidate) { + removeInstallPath(candidate.stagedPath, `staged Caplet ${entry.id}`, true); } + lockedSource.cleanup(); + } + } + return { installed: results }; +} +function selectedLockEntries( + lockfile: CapletsLockfile, + capletIds: string[] | undefined, + lockfilePath: string, +): CapletsLockEntry[] { + const selectedIds = new Set(capletIds ?? []); + const entries = lockfile.entries.filter( + (entry) => selectedIds.size === 0 || selectedIds.has(entry.id), + ); + const missing = [...selectedIds].filter( + (id) => !lockfile.entries.some((entry) => entry.id === id), + ); + if (missing.length > 0) { + throw new CapletsError( + "CONFIG_NOT_FOUND", + `Caplet ${missing.join(", ")} not found in lockfile`, + ); + } + if (entries.length === 0) { + throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in lockfile ${lockfilePath}`); + } + return entries; +} - const plan: InstallPlan = { - id: entry.id, - source: lockSourceDisplay(entry.source), - sourcePath: lockedSource.sourcePath, - sourceBoundary: dirname(lockedSource.sourcePath), - destination, - kind: entry.kind, - }; - preflightInstallCaplets( - [ - { - id: entry.id, - path: - entry.kind === "directory" - ? join(lockedSource.sourcePath, "CAPLET.md") - : lockedSource.sourcePath, - }, - ], +function lockedInstallPlan( + entry: CapletsLockEntry, + destination: string, + lockedSource: LockedSourceResolution, +): InstallPlan { + return { + id: entry.id, + source: lockSourceDisplay(entry.source), + sourcePath: lockedSource.sourcePath, + sourceBoundary: dirname(lockedSource.sourcePath), + destination, + kind: entry.kind, + }; +} + +function stageAndValidateCandidate(plan: InstallPlan, lockfilePath: string): CandidateArtifact { + rejectUnsafeInstallParents(plan.destination); + makeInstallDirectory(dirname(plan.destination)); + const paths = transactionPaths(lockfilePath, plan.destination, plan.id); + removeInstallPath(paths.stagedPath, `stale staged Caplet ${plan.id}`, true); + try { + copyInstallPath(plan, paths.stagedPath); + const runtimeSnapshot = fingerprintArtifact(plan.id, plan.kind, paths.stagedPath); + const artifactHash = hashInstalledArtifact(paths.stagedPath); + return { + plan, + transactionPaths: paths, + stagedPath: paths.stagedPath, + artifactHash, + runtimeSnapshot, + risk: riskSummaryForSourcePath(paths.stagedPath), + }; + } catch (error) { + removeInstallPath(paths.stagedPath, `staged Caplet ${plan.id}`, true); + if (error instanceof CapletsError) throw error; + throw new CapletsError( + "CONFIG_INVALID", + `Could not validate staged Caplet ${plan.id}`, + toSafeError(error), + ); + } +} + +function fingerprintInstalledArtifact( + entry: CapletsLockEntry, + destination: string, +): RuntimeFingerprintSnapshot | undefined { + try { + return fingerprintArtifact(entry.id, entry.kind, destination); + } catch { + return undefined; + } +} + +function fingerprintArtifact( + id: string, + kind: "file" | "directory", + artifactPath: string, +): RuntimeFingerprintSnapshot { + const files: Array<{ path: string; content: string }> = []; + if (kind === "file") { + files.push({ path: `${id}.md`, content: readFileSync(artifactPath, "utf8") }); + } else { + collectArtifactFiles(artifactPath, artifactPath, files); + } + const configFiles = + kind === "file" + ? files + : files.filter( + (file) => file.path === "CAPLET.md" || /(?:^|\/)[^/]+\/CAPLET\.md$/u.test(file.path), + ); + const loaded = loadCapletFilesFromMap({ files: configFiles }); + if (!loaded) { + throw new CapletsError("CONFIG_INVALID", `Staged Caplet ${id} has no loadable CAPLET.md`); + } + const config = parseConfig({ version: 1, ...loaded.config }); + const contents = Object.fromEntries(files.map((file) => [file.path, file.content])); + return createRuntimeFingerprintSnapshot({ + config, + provenance: Object.fromEntries( + Object.entries(loaded.paths).map(([runtimeId, sourcePath]) => [ + runtimeId, { - destinationRoot, - force: true, - repoRoot: lockedSource.repoRoot, - sourceId: lockSourceDisplay(entry.source), + parentId: loaded.metadata?.[runtimeId]?.parentId ?? id, + ...(loaded.metadata?.[runtimeId]?.childId + ? { childId: loaded.metadata[runtimeId]?.childId } + : {}), + sourcePath, }, - ); - installOneCaplet(plan, { force: true }); - const hash = hashInstalledArtifact(destination); - const now = (options.now ?? new Date()).toISOString(); - nextEntries.set(entry.id, { - ...entry, - source: nextSource, - installedHash: hash, - updatedAt: now, - risk: nextRisk, - }); - writeCapletsLockfile(options.lockfilePath, { - version: 1, - entries: [...nextEntries.values()], + ]), + ), + reader: createMemoryDeclaredInputReader(contents), + }); +} + +function collectArtifactFiles( + root: string, + current: string, + files: Array<{ path: string; content: string }>, +): void { + for (const entry of readdirSync(current, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + collectArtifactFiles(root, path, files); + } else if (entry.isFile()) { + files.push({ + path: relative(root, path).split(sep).join("/"), + content: readFileSync(path, "utf8"), }); - results.push({ - id: entry.id, - source: lockSourceDisplay(entry.source), - destination, - kind: entry.kind, - hash, - status: "updated", - lockfile: options.lockfilePath, + } + } +} + +function runtimeFingerprintForPersistence( + snapshot: RuntimeFingerprintSnapshot, +): CapletsLockRuntimeFingerprint | undefined { + return snapshot.valid && snapshot.persistenceEligible + ? { version: 1, artifactFingerprint: snapshot.artifactFingerprint } + : undefined; +} + +function sameLockEntryExceptUpdatedAt( + left: CapletsLockEntry, + right: Omit & { updatedAt?: string }, +): boolean { + const { updatedAt: _leftUpdatedAt, ...leftComparable } = left; + const { updatedAt: _rightUpdatedAt, ...rightComparable } = right; + return JSON.stringify(leftComparable) === JSON.stringify(rightComparable); +} + +type FinalizeStagedCandidateInput = { + lockfilePath: string; + entry: CapletsLockEntry; + source?: CapletsLockSource | undefined; + candidate: CandidateArtifact; + status: "restored" | "updated" | "content_updated" | "noop"; + nextEntries: Map; + now?: Date | undefined; + dependencies: InstallTransactionDependencies; +}; + +function finalizeStagedCandidate(input: FinalizeStagedCandidateInput): InstallableCaplet { + let cleanupCandidate = true; + try { + const runtimeFingerprint = runtimeFingerprintForPersistence(input.candidate.runtimeSnapshot); + const prospective = { + ...input.entry, + ...(input.source ? { source: input.source } : {}), + installedHash: input.candidate.artifactHash, + risk: input.candidate.risk, + ...(runtimeFingerprint ? { runtimeFingerprint } : { runtimeFingerprint: undefined }), + }; + const lockChanged = !sameLockEntryExceptUpdatedAt(input.entry, prospective); + const afterEntry: CapletsLockEntry = { + ...prospective, + updatedAt: lockChanged ? (input.now ?? new Date()).toISOString() : input.entry.updatedAt, + }; + + if (input.status === "noop" && !lockChanged) { + removeInstallPath(input.candidate.stagedPath, `staged Caplet ${input.entry.id}`, true); + cleanupCandidate = false; + } else { + const updatedEntries = new Map(input.nextEntries); + updatedEntries.set(input.entry.id, afterEntry); + cleanupCandidate = false; + commitStagedCapletAndLock({ + lockfilePath: input.lockfilePath, + currentLockfile: { version: 1, entries: [...updatedEntries.values()] }, + beforeEntry: input.entry, + afterEntry, + candidate: input.candidate, + replaceDestination: input.status !== "noop", + dependencies: input.dependencies, }); - } finally { - lockedSource.cleanup(); + input.nextEntries.set(input.entry.id, afterEntry); + } + + return { + id: input.entry.id, + source: lockSourceDisplay(input.entry.source), + destination: input.candidate.plan.destination, + kind: input.entry.kind, + hash: input.candidate.artifactHash, + status: input.status, + lockfile: input.lockfilePath, + }; + } finally { + if (cleanupCandidate) { + removeInstallPath(input.candidate.stagedPath, `staged Caplet ${input.entry.id}`, true); } } - return { installed: results }; +} + +type CapletTransactionPaths = { + journalPath: string; + journalTemporaryPath: string; + stagedPath: string; + backupPath: string; + lockTemporaryPath: string; +}; + +type CommitStagedCapletInput = { + lockfilePath: string; + currentLockfile: CapletsLockfile; + beforeEntry: CapletsLockEntry; + afterEntry: CapletsLockEntry; + candidate: CandidateArtifact; + replaceDestination: boolean; + dependencies: InstallTransactionDependencies; +}; + +function transactionPaths( + lockfilePath: string, + destination: string, + id: string, +): CapletTransactionPaths { + if (!SERVER_ID_PATTERN.test(id)) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction ID ${id}`); + } + const lockName = basename(lockfilePath || "caplets.lock.json"); + const destinationName = basename(destination); + return { + journalPath: join(dirname(lockfilePath), `.${lockName}.caplet-txn-${id}.json`), + journalTemporaryPath: join(dirname(lockfilePath), `.${lockName}.caplet-txn-${id}.json.tmp`), + stagedPath: join(dirname(destination), `.${destinationName}.caplet-txn-${id}.stage`), + backupPath: join(dirname(destination), `.${destinationName}.caplet-txn-${id}.backup`), + lockTemporaryPath: join(dirname(lockfilePath), `.${lockName}.caplet-txn-${id}.lock.tmp`), + }; +} + +class CapletTransactionInterruption extends Error {} + +function persistTransactionPhase( + journal: CapletArtifactTransactionJournal, + paths: CapletTransactionPaths, + phase: CapletTransactionPhase, + dependencies: InstallTransactionDependencies, +): void { + journal.phase = phase; + writeFileSync(paths.journalTemporaryPath, `${JSON.stringify(journal, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(paths.journalTemporaryPath, paths.journalPath); + if (dependencies.onTransactionPhase?.(phase) === "interrupt") { + throw new CapletTransactionInterruption(`Interrupted after ${phase}`); + } +} + +function commitStagedCapletAndLock(input: CommitStagedCapletInput): void { + const paths = input.candidate.transactionPaths; + const existing = lstatIfExists(input.candidate.plan.destination); + const journal: CapletArtifactTransactionJournal = { + version: 1, + entryId: input.afterEntry.id, + destination: input.afterEntry.destination, + hadDestination: Boolean(existing), + beforeEntry: input.beforeEntry, + afterEntry: input.afterEntry, + ...(existing ? { beforeHash: hashInstalledArtifact(input.candidate.plan.destination) } : {}), + afterHash: input.candidate.artifactHash, + phase: "prepared", + }; + try { + persistTransactionPhase(journal, paths, "prepared", input.dependencies); + persistTransactionPhase(journal, paths, "staged", input.dependencies); + if (input.replaceDestination) { + removeInstallPath(paths.backupPath, `stale Caplet backup ${input.afterEntry.id}`, true); + if (existing) { + renameSync(input.candidate.plan.destination, paths.backupPath); + persistTransactionPhase(journal, paths, "backup_created", input.dependencies); + } + renameSync(paths.stagedPath, input.candidate.plan.destination); + persistTransactionPhase(journal, paths, "destination_replaced", input.dependencies); + } else { + removeInstallPath(paths.stagedPath, `staged Caplet ${input.afterEntry.id}`, true); + } + const writeLockfileTemporary = + input.dependencies.writeLockfileTemporary ?? writeCapletsLockfileTemporary; + writeLockfileTemporary(input.lockfilePath, input.currentLockfile, paths.lockTemporaryPath); + persistTransactionPhase(journal, paths, "lock_temporary_written", input.dependencies); + const replaceLockfileTemporary = + input.dependencies.replaceLockfileTemporary ?? replaceCapletsLockfileTemporary; + replaceLockfileTemporary(input.lockfilePath, paths.lockTemporaryPath); + persistTransactionPhase(journal, paths, "lock_replaced", input.dependencies); + } catch (error) { + if (error instanceof CapletTransactionInterruption) throw error; + try { + rollbackCapletArtifactTransaction(input, journal, paths); + } catch (rollbackError) { + throw new CapletsError( + "CONFIG_INVALID", + `Could not roll back Caplet transaction ${input.afterEntry.id}`, + toSafeError(rollbackError), + ); + } + if (error instanceof CapletsError) throw error; + throw new CapletsError( + "CONFIG_INVALID", + `Could not commit Caplet transaction ${input.afterEntry.id}`, + toSafeError(error), + ); + } + try { + if (input.dependencies.cleanupTransaction) { + input.dependencies.cleanupTransaction(); + } else { + cleanupTransactionPaths(paths); + } + } catch { + // The new artifact and lock are committed. Leave the journal for recovery to clean up. + } +} + +function rollbackCapletArtifactTransaction( + input: CommitStagedCapletInput, + journal: CapletArtifactTransactionJournal, + paths: CapletTransactionPaths, +): void { + const destination = input.candidate.plan.destination; + const destinationHash = existsSync(destination) ? hashInstalledArtifact(destination) : undefined; + const backupHash = existsSync(paths.backupPath) + ? hashInstalledArtifact(paths.backupPath) + : undefined; + const currentLockfile = readCapletsLockfile(input.lockfilePath); + const current = currentLockfile.entries.find((entry) => entry.id === journal.entryId); + const lockIsBefore = sameLockEntry(current, journal.beforeEntry); + const lockIsAfter = sameLockEntry(current, journal.afterEntry); + + if ( + (backupHash !== undefined && + !transactionPhaseMayHaveStarted(journal.phase, "backup_created")) || + (destinationHash === journal.afterHash && + !transactionPhaseMayHaveStarted(journal.phase, "destination_replaced") && + !( + !journal.hadDestination && transactionPhaseMayHaveStarted(journal.phase, "backup_created") + )) || + (lockIsAfter && !transactionPhaseMayHaveStarted(journal.phase, "lock_replaced")) + ) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet transaction ${journal.entryId} reached an impossible rollback state`, + ); + } + + if (lockIsAfter && destinationHash === journal.afterHash) { + cleanupTransactionPaths(paths); + return; + } + + if (backupHash === journal.beforeHash && journal.beforeHash !== undefined) { + removeInstallPath(destination, `uncommitted Caplet ${journal.entryId}`, true); + renameSync(paths.backupPath, destination); + } else if ( + !journal.hadDestination && + (destinationHash === undefined || destinationHash === journal.afterHash) + ) { + removeInstallPath(destination, `uncommitted Caplet ${journal.entryId}`, true); + } else if (destinationHash !== journal.beforeHash) { + throw new CapletsError( + "CONFIG_INVALID", + `Artifact changed during rollback for ${journal.entryId}`, + ); + } + + if (lockIsAfter) { + const entries = currentLockfile.entries.map((entry) => + entry.id === journal.entryId ? journal.beforeEntry : entry, + ); + writeCapletsLockfileAtomically(input.lockfilePath, { version: 1, entries }); + } else if (!lockIsBefore) { + throw new CapletsError( + "CONFIG_INVALID", + `Lock entry changed during rollback for ${journal.entryId}`, + ); + } + cleanupTransactionPaths(paths); +} + +function transactionPhaseMayHaveStarted( + persisted: CapletTransactionPhase, + target: CapletTransactionPhase, +): boolean { + const phases: CapletTransactionPhase[] = [ + "prepared", + "staged", + "backup_created", + "destination_replaced", + "lock_temporary_written", + "lock_replaced", + ]; + return phases.indexOf(persisted) + 1 >= phases.indexOf(target); +} + +function recoverInterruptedCapletTransactions(input: { + lockfilePath: string; + destinationRoot: string; + dependencies: InstallTransactionDependencies; +}): void { + const lockDirectory = dirname(input.lockfilePath); + if (!existsSync(lockDirectory)) return; + const prefix = `.${basename(input.lockfilePath)}.caplet-txn-`; + const journals = readdirSync(lockDirectory) + .filter((name) => name.startsWith(prefix) && name.endsWith(".json")) + .sort(); + for (const name of journals) { + const journalPath = join(lockDirectory, name); + let journalValue: unknown; + try { + journalValue = JSON.parse(readFileSync(journalPath, "utf8")); + } catch (error) { + throw new CapletsError( + "CONFIG_INVALID", + `Invalid Caplet transaction journal ${journalPath}`, + toSafeError(error), + ); + } + const journal = parseTransactionJournal(journalValue, journalPath); + const destination = validateLockfileDestination(input.destinationRoot, journal.destination); + const paths = transactionPaths(input.lockfilePath, destination, journal.entryId); + if (paths.journalPath !== journalPath) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction journal ${journalPath}`); + } + const lockfile = readCapletsLockfile(input.lockfilePath); + const currentEntry = lockfile.entries.find((entry) => entry.id === journal.entryId); + const destinationHash = existsSync(destination) + ? hashInstalledArtifact(destination) + : undefined; + const backupHash = existsSync(paths.backupPath) + ? hashInstalledArtifact(paths.backupPath) + : undefined; + const lockIsBefore = sameLockEntry(currentEntry, journal.beforeEntry); + const lockIsAfter = sameLockEntry(currentEntry, journal.afterEntry); + + if (lockIsAfter && destinationHash === journal.afterHash) { + cleanupTransactionPaths(paths); + continue; + } + if (lockIsBefore && destinationHash === journal.afterHash) { + const entries = lockfile.entries.map((entry) => + entry.id === journal.entryId ? journal.afterEntry : entry, + ); + writeCapletsLockfileAtomically( + input.lockfilePath, + { version: 1, entries }, + paths.lockTemporaryPath, + ); + cleanupTransactionPaths(paths); + continue; + } + if (lockIsAfter && destinationHash === journal.beforeHash) { + const entries = lockfile.entries.map((entry) => + entry.id === journal.entryId ? journal.beforeEntry : entry, + ); + writeCapletsLockfileAtomically( + input.lockfilePath, + { version: 1, entries }, + paths.lockTemporaryPath, + ); + cleanupTransactionPaths(paths); + continue; + } + if ( + lockIsBefore && + (destinationHash === journal.beforeHash || + (!journal.hadDestination && destinationHash === undefined)) + ) { + cleanupTransactionPaths(paths); + continue; + } + if ( + lockIsBefore && + destinationHash === undefined && + journal.beforeHash !== undefined && + backupHash === journal.beforeHash + ) { + renameSync(paths.backupPath, destination); + cleanupTransactionPaths(paths); + continue; + } + throw new CapletsError( + "CONFIG_INVALID", + `Caplet transaction ${journal.entryId} does not match its artifact and lock baselines`, + ); + } +} + +function parseTransactionJournal(value: unknown, path: string): CapletArtifactTransactionJournal { + if (!isRecord(value) || value.version !== 1) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction journal ${path}`); + } + const entryId = typeof value.entryId === "string" ? value.entryId : ""; + const destination = typeof value.destination === "string" ? value.destination : ""; + const phase = value.phase; + const phases: CapletTransactionPhase[] = [ + "prepared", + "staged", + "backup_created", + "destination_replaced", + "lock_temporary_written", + "lock_replaced", + ]; + if ( + !SERVER_ID_PATTERN.test(entryId) || + destination.length === 0 || + typeof value.hadDestination !== "boolean" || + typeof value.afterHash !== "string" || + value.afterHash.length === 0 || + !phases.includes(phase as CapletTransactionPhase) + ) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction journal ${path}`); + } + const beforeEntry = parseCapletsLockfile({ + version: 1, + entries: [value.beforeEntry], + }).entries[0]; + const afterEntry = parseCapletsLockfile({ + version: 1, + entries: [value.afterEntry], + }).entries[0]; + if ( + !beforeEntry || + !afterEntry || + beforeEntry.id !== entryId || + afterEntry.id !== entryId || + beforeEntry.destination !== destination || + afterEntry.destination !== destination + ) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction journal ${path}`); + } + const beforeHash = + value.beforeHash === undefined + ? undefined + : typeof value.beforeHash === "string" + ? value.beforeHash + : null; + if ( + beforeHash === null || + (value.hadDestination && (!beforeHash || beforeHash.length === 0)) || + (!value.hadDestination && beforeHash !== undefined) + ) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet transaction journal ${path}`); + } + return { + version: 1, + entryId, + destination, + hadDestination: value.hadDestination, + beforeEntry, + afterEntry, + ...(beforeHash ? { beforeHash } : {}), + afterHash: value.afterHash, + phase: phase as CapletTransactionPhase, + }; +} + +function sameLockEntry(left: CapletsLockEntry | undefined, right: CapletsLockEntry): boolean { + return left !== undefined && JSON.stringify(left) === JSON.stringify(right); +} + +function cleanupTransactionPaths(paths: CapletTransactionPaths): void { + removeInstallPath(paths.stagedPath, `transaction stage ${paths.stagedPath}`, true); + removeInstallPath(paths.backupPath, `transaction backup ${paths.backupPath}`, true); + rmSync(paths.lockTemporaryPath, { force: true }); + rmSync(paths.journalTemporaryPath, { force: true }); + rmSync(paths.journalPath, { force: true }); } export async function indexInstalledCapletsFromLockfile( @@ -552,8 +1244,6 @@ function catalogEntryForInstalledLockEntry( resolvedRevision: entry.source.resolvedRevision, }), tags: catalogStringArrayFromFrontmatter(frontmatter.tags), - useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), - avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen), setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter), authRequired: catalogAuthRequiredFromFrontmatter(frontmatter), projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter), @@ -647,14 +1337,6 @@ function refreshedLockSource( }; } -function sameLockSource(left: CapletsLockSource, right: CapletsLockSource): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function sameLockRisk(left: CapletsLockEntry["risk"], right: CapletsLockEntry["risk"]): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - function discoverSelectedCapletFiles( sourceRoot: string, selectedIds: Set, @@ -666,12 +1348,26 @@ function discoverSelectedCapletFiles( } const filePath = join(sourceRoot, `${id}.md`); - if (existsSync(filePath) && statSync(filePath).isFile()) { + const fileStats = lstatIfExists(filePath); + if (fileStats?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet source ${filePath} must not be a symbolic link`, + ); + } + if (fileStats?.isFile()) { candidates.push({ id, path: filePath }); } const directoryPath = join(sourceRoot, id, "CAPLET.md"); - if (existsSync(directoryPath) && statSync(directoryPath).isFile()) { + const directoryStats = lstatIfExists(directoryPath); + if (directoryStats?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet source ${directoryPath} must not be a symbolic link`, + ); + } + if (directoryStats?.isFile()) { candidates.push({ id, path: directoryPath }); } } @@ -1267,12 +1963,6 @@ function hashInstalledArtifact(path: string): string { return `sha256:${hash.digest("hex")}`; } -function hashDirectoryCapletInstallSource(path: string, sourceBoundary: string): string { - const hash = createHash("sha256"); - hashDirectoryCapletInstallPath(path, "", hash, sourceBoundary); - return `sha256:${hash.digest("hex")}`; -} - function hashPath(path: string, relativePath: string, hash: ReturnType): void { const stats = lstatSync(path); const mode = stats.mode & 0o111 ? "executable" : "plain"; @@ -1292,46 +1982,6 @@ function hashPath(path: string, relativePath: string, hash: ReturnType, - sourceBoundary: string, - seenDirectories = new Set(), -): void { - const lstat = lstatSync(path); - const resolvedPath = lstat.isSymbolicLink() - ? resolveDirectoryCapletSymlink(path, sourceBoundary) - : path; - const stats = statSync(resolvedPath); - if (stats.isDirectory()) { - const realDirectory = realpathSync(resolvedPath); - if (seenDirectories.has(realDirectory)) { - throw new CapletsError( - "CONFIG_INVALID", - `Directory Caplet symlink ${path} creates a copy cycle`, - ); - } - hash.update(`dir\0${relativePath}\0`); - const childSeenDirectories = new Set(seenDirectories); - childSeenDirectories.add(realDirectory); - for (const entry of readdirSync(resolvedPath).sort()) { - hashDirectoryCapletInstallPath( - join(resolvedPath, entry), - relativePath ? `${relativePath}/${entry}` : entry, - hash, - sourceBoundary, - childSeenDirectories, - ); - } - return; - } - const mode = stats.mode & 0o111 ? "executable" : "plain"; - hash.update(`file\0${relativePath}\0${mode}\0`); - hash.update(readFileSync(resolvedPath)); - hash.update("\0"); -} - function gitRevision(repoRoot: string): string | undefined { try { return execFileSync("git", ["rev-parse", "HEAD"], { @@ -1731,11 +2381,14 @@ function copyInstallPath(plan: InstallPlan, destination: string): void { return; } - cpSync(plan.sourcePath, destination, { - recursive: false, - force: false, - errorOnExist: true, - }); + const sourceStats = lstatSync(plan.sourcePath); + if (!sourceStats.isFile()) { + throw new CapletsError( + "CONFIG_INVALID", + `File Caplet source ${plan.sourcePath} must be a regular file`, + ); + } + copyFileSync(plan.sourcePath, destination, constants.COPYFILE_EXCL); } catch (error) { if (error instanceof CapletsError) { throw error; diff --git a/packages/core/src/cli/lockfile.ts b/packages/core/src/cli/lockfile.ts index 40f6db4a..d700609a 100644 --- a/packages/core/src/cli/lockfile.ts +++ b/packages/core/src/cli/lockfile.ts @@ -43,6 +43,11 @@ export type CapletsLockRiskSummary = { referenceHash?: string | undefined; }; +export type CapletsLockRuntimeFingerprint = { + version: 1; + artifactFingerprint: string; +}; + export type CapletsLockEntry = { id: string; destination: string; @@ -52,6 +57,7 @@ export type CapletsLockEntry = { installedAt: string; updatedAt: string; risk: CapletsLockRiskSummary; + runtimeFingerprint?: CapletsLockRuntimeFingerprint | undefined; }; export type CapletsLockfile = { @@ -95,18 +101,20 @@ function isErrorCode(error: unknown, code: string): boolean { } export function writeCapletsLockfile(path: string, lockfile: CapletsLockfile): void { - const validated = parseCapletsLockfile(lockfile, path); - const stable: CapletsLockfile = { - version: CAPLETS_LOCKFILE_VERSION, - entries: [...validated.entries].sort((left, right) => left.id.localeCompare(right.id)), - }; + writeCapletsLockfileAtomically(path, lockfile); +} + +export function writeCapletsLockfileAtomically( + path: string, + lockfile: CapletsLockfile, + temporaryPath?: string, +): void { const parent = dirname(path); const basename = path.split(/[\\/]/).at(-1) ?? "caplets.lock.json"; - const temporary = join(parent, `.${basename}.tmp-${process.pid}-${Date.now()}`); + const temporary = temporaryPath ?? join(parent, `.${basename}.tmp-${process.pid}-${Date.now()}`); try { - mkdirSync(parent, { recursive: true, mode: 0o700 }); - writeFileSync(temporary, `${JSON.stringify(stable, null, 2)}\n`, { mode: 0o600 }); - renameSync(temporary, path); + writeCapletsLockfileTemporary(path, lockfile, temporary); + replaceCapletsLockfileTemporary(path, temporary); } catch (error) { try { rmSync(temporary, { force: true }); @@ -119,6 +127,24 @@ export function writeCapletsLockfile(path: string, lockfile: CapletsLockfile): v } } +export function writeCapletsLockfileTemporary( + path: string, + lockfile: CapletsLockfile, + temporaryPath: string, +): void { + const validated = parseCapletsLockfile(lockfile, path); + const stable: CapletsLockfile = { + version: CAPLETS_LOCKFILE_VERSION, + entries: [...validated.entries].sort((left, right) => left.id.localeCompare(right.id)), + }; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(temporaryPath, `${JSON.stringify(stable, null, 2)}\n`, { mode: 0o600 }); +} + +export function replaceCapletsLockfileTemporary(path: string, temporaryPath: string): void { + renameSync(temporaryPath, path); +} + export function parseCapletsLockfile(value: unknown, path = "Caplets lockfile"): CapletsLockfile { if (!isRecord(value)) { throw new CapletsError("CONFIG_INVALID", `${path} must be a JSON object`); @@ -181,10 +207,35 @@ function parseLockEntry(value: unknown, label: string): CapletsLockEntry { const installedAt = requireString(value.installedAt, `${label}.installedAt`); const updatedAt = requireString(value.updatedAt, `${label}.updatedAt`); const risk = parseRiskSummary(value.risk, `${label}.risk`); + const runtimeFingerprint = + value.runtimeFingerprint === undefined + ? undefined + : parseRuntimeFingerprint(value.runtimeFingerprint, `${label}.runtimeFingerprint`); if (isAbsolute(destination) || destination.split(/[\\/]/).includes("..")) { throw new CapletsError("CONFIG_INVALID", `${label}.destination must be a safe relative path`); } - return { id, destination, kind, source, installedHash, installedAt, updatedAt, risk }; + return { + id, + destination, + kind, + source, + installedHash, + installedAt, + updatedAt, + risk, + ...(runtimeFingerprint ? { runtimeFingerprint } : {}), + }; +} + +function parseRuntimeFingerprint(value: unknown, label: string): CapletsLockRuntimeFingerprint { + if (!isRecord(value)) throw new CapletsError("CONFIG_INVALID", `${label} must be an object`); + if (value.version !== 1) { + throw new CapletsError("CONFIG_INVALID", `${label}.version must be 1`); + } + return { + version: 1, + artifactFingerprint: requireString(value.artifactFingerprint, `${label}.artifactFingerprint`), + }; } function parseLockSource(value: unknown, label: string): CapletsLockSource { diff --git a/packages/core/src/cli/setup-caplet.ts b/packages/core/src/cli/setup-caplet.ts index 1b2bf0fe..df49d7c2 100644 --- a/packages/core/src/cli/setup-caplet.ts +++ b/packages/core/src/cli/setup-caplet.ts @@ -1,5 +1,4 @@ -import type { CapletConfig } from "../config"; -import { loadConfig, resolveConfigPath, resolveProjectConfigPath } from "../config"; +import { loadConfigWithSources, resolveConfigPath, resolveProjectConfigPath } from "../config"; import { CapletsError } from "../errors"; import { capletSetupContentHash } from "../setup/hash"; import { LocalSetupStore } from "../setup/local-store"; @@ -30,7 +29,8 @@ export async function runCapletSetupCli( const configPath = options.configPath ?? resolveConfigPath(); const projectConfigPath = options.projectConfigPath ?? resolveProjectConfigPath(); - const config = loadConfig(configPath, projectConfigPath); + const loaded = loadConfigWithSources(configPath, projectConfigPath); + const config = loaded.config; const caplet = Object.values({ ...config.mcpServers, ...config.openapiEndpoints, @@ -45,15 +45,14 @@ export async function runCapletSetupCli( return `No setup metadata is defined for ${caplet.name} (${caplet.server}).\n`; } - const contentHash = capletSetupContentHash(caplet as CapletConfig); + const runtimeFingerprint = loaded.runtimeFingerprint?.caplets[caplet.server]; + const contentHash = capletSetupContentHash(runtimeFingerprint); const projectFingerprint = "default"; const store = new LocalSetupStore(options.baseDir ? { baseDir: options.baseDir } : {}); - const existingApproval = await store.getApproval( - projectFingerprint, - caplet.server, - contentHash, - targetKind, - ); + const existingApproval = + runtimeFingerprint?.persistenceEligible === false + ? undefined + : await store.getApproval(projectFingerprint, caplet.server, contentHash, targetKind); const actor: SetupActor = options.yes ? "cli-yes" : "cli-interactive"; if (!existingApproval && !options.yes) { return [ @@ -71,7 +70,7 @@ export async function runCapletSetupCli( ].join("\n"); } - if (options.yes && !existingApproval) { + if (options.yes && !existingApproval && runtimeFingerprint?.persistenceEligible !== false) { await store.approve({ projectFingerprint, capletId: caplet.server, diff --git a/packages/core/src/cloud/runtime-adapter.ts b/packages/core/src/cloud/runtime-adapter.ts index aeacdb17..24f733b2 100644 --- a/packages/core/src/cloud/runtime-adapter.ts +++ b/packages/core/src/cloud/runtime-adapter.ts @@ -1,4 +1,4 @@ -import type { CapletConfig } from "../config"; +import { runtimeFingerprintForConfig, type CapletConfig } from "../config"; import { CapletsEngine } from "../engine"; import { CapletsError } from "../errors"; import { capletSetupContentHash } from "../setup/hash"; @@ -81,12 +81,23 @@ class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { async setupPlan(capletId: string): Promise { const caplet = this.requireCaplet(capletId); - const contentHash = capletSetupContentHash(caplet); + const runtimeFingerprint = runtimeFingerprintForConfig(this.engine.currentConfig())?.caplets[ + capletId + ]; + const contentHash = capletSetupContentHash(runtimeFingerprint); const projectFingerprint = "hosted"; const targetKind = "hosted_sandbox"; - const approved = Boolean( - await this.setupStore.getApproval(projectFingerprint, capletId, contentHash, targetKind), - ); + const approved = + runtimeFingerprint?.persistenceEligible === false + ? false + : Boolean( + await this.setupStore.getApproval( + projectFingerprint, + capletId, + contentHash, + targetKind, + ), + ); return { projectFingerprint, capletId, @@ -95,6 +106,7 @@ class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { targetKind, setup: caplet.setup ?? {}, approved, + persistenceEligible: runtimeFingerprint?.persistenceEligible ?? true, commands: caplet.setup?.commands ?? [], verify: caplet.setup?.verify ?? [], }; @@ -105,7 +117,8 @@ class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { input: { approved: boolean; actor: SetupActor }, ): Promise { const plan = await this.setupPlan(capletId); - if (input.approved && !plan.approved) { + const persistenceEligible = plan.persistenceEligible; + if (input.approved && !plan.approved && persistenceEligible) { await this.setupStore.approve({ projectFingerprint: plan.projectFingerprint, capletId, diff --git a/packages/core/src/code-mode/declarations.ts b/packages/core/src/code-mode/declarations.ts index 68e4b700..09b3ab8f 100644 --- a/packages/core/src/code-mode/declarations.ts +++ b/packages/core/src/code-mode/declarations.ts @@ -2,7 +2,7 @@ import type { CodeModeDeclarationInput } from "./types"; import { CODE_MODE_RUNTIME_API_DECLARATION } from "./runtime-api.generated"; const JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/u; -const MAX_JSDOC_CHARS = 180; +const MAX_JSDOC_CHARS = 240; const CODE_MODE_REPL_GUIDANCE = "REPL reuse: omit `sessionId` to start a fresh reusable Code Mode session; after a successful run, keep `meta.sessionId` and pass it as `sessionId` on later calls when you want to reuse live state. Reused sessions preserve successful top-level `var` bindings, function declarations, and runtime state only while the live session remains available and compatible. A supplied `sessionId` that is unknown or no longer available fails before executing your code instead of starting an empty context. Use `meta.recoveryRef` with `caplets.debug.readRecovery({ recoveryRef })` for audit and manual reconstruction; do not automatically replay recovery history."; @@ -38,13 +38,10 @@ export function generateCodeModeRunToolDescription(declaration: string): string } function capletHintText(caplet: CodeModeDeclarationInput["caplets"][number]): string { - return [ - caplet.description || caplet.name || caplet.id, - caplet.useWhen ? `Use when: ${caplet.useWhen}` : undefined, - caplet.avoidWhen ? `Avoid when: ${caplet.avoidWhen}` : undefined, - ] - .filter((value): value is string => Boolean(value)) - .join(" "); + return boundedSummary( + compactCapletField(caplet.description || caplet.name || caplet.id), + MAX_JSDOC_CHARS, + ); } export function minifyCodeModeDeclarationText(value: string): string { @@ -92,6 +89,10 @@ function sanitizeJsDoc(value: string): string { } function compactJsDoc(value: string): string { + return boundedSummary(compactCapletField(value), MAX_JSDOC_CHARS) || "Caplet."; +} + +function compactCapletField(value: string): string { const cleaned = sanitizeJsDoc(value); const markers = [ " Use inspect for details when needed;", @@ -102,11 +103,14 @@ function compactJsDoc(value: string): string { .map((marker) => cleaned.indexOf(marker)) .filter((index) => index >= 0) .sort((left, right) => left - right)[0]; - const summary = (cutoff === undefined ? cleaned : cleaned.slice(0, cutoff).trim()) || "Caplet."; - if (summary.length <= MAX_JSDOC_CHARS) return summary; - const sentenceEnd = summary.lastIndexOf(".", MAX_JSDOC_CHARS); - if (sentenceEnd >= 40) return summary.slice(0, sentenceEnd + 1); - return `${summary.slice(0, MAX_JSDOC_CHARS - 3).trimEnd()}...`; + return (cutoff === undefined ? cleaned : cleaned.slice(0, cutoff).trim()) || "Caplet."; +} + +function boundedSummary(value: string, limit: number): string { + if (value.length <= limit) return value; + const sentenceEnd = value.lastIndexOf(".", limit); + if (sentenceEnd >= Math.min(40, limit / 2)) return value.slice(0, sentenceEnd + 1); + return `${value.slice(0, limit - 3).trimEnd()}...`; } function fnv1a32(value: string, seed: number): number { diff --git a/packages/core/src/code-mode/runtime-api.d.ts b/packages/core/src/code-mode/runtime-api.d.ts index e129ee82..acd03eaf 100644 --- a/packages/core/src/code-mode/runtime-api.d.ts +++ b/packages/core/src/code-mode/runtime-api.d.ts @@ -44,8 +44,6 @@ type CapletCard = { id: Id; name: string; description: string; - useWhen?: string; - avoidWhen?: string; tags?: string[]; backend?: unknown; }; diff --git a/packages/core/src/code-mode/runtime-api.generated.ts b/packages/core/src/code-mode/runtime-api.generated.ts index 60f2ea6b..b6c5a505 100644 --- a/packages/core/src/code-mode/runtime-api.generated.ts +++ b/packages/core/src/code-mode/runtime-api.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-code-mode-runtime-api.mjs. Do not edit by hand. export const CODE_MODE_RUNTIME_API_DECLARATION = - 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;readRecovery(input:ReadCodeModeRecoveryInput):Promise;}type CapletCard={id:Id;name:string;description:string;useWhen?:string;avoidWhen?:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};context?:{arguments?:Record};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type ReadCodeModeRecoveryInput={recoveryRef:string;cursor?:string;limit?:number};type CodeModeDiagnostic={code:string;message:string;severity:"error"|"warning"|"info";line?:number;column?:number;};type CodeModeRecoveryClassification="setup_like"|"side_effecting"|"unknown";type CodeModeRecoveryEntry={timestamp:string;code:string;declarationHash:string;outcome:{ok:true}|{ok:false;code:string;message:string};diagnostics:Array>;recoveryClassification:CodeModeRecoveryClassification;logsStored?:boolean;summary?:string;};type ReadCodeModeRecoveryResult={entries:CodeModeRecoveryEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};type CodeModeSessionStatus="created"|"reused";type CodeModeRunMeta={runId:string;traceId:string;declarationHash:string;durationMs:number;timeoutMs:number;maxTimeoutMs:number;sessionId?:string|null;sessionStatus?:CodeModeSessionStatus|null;recoveryRef?:string|null;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; + 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;readRecovery(input:ReadCodeModeRecoveryInput):Promise;}type CapletCard={id:Id;name:string;description:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};context?:{arguments?:Record};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type ReadCodeModeRecoveryInput={recoveryRef:string;cursor?:string;limit?:number};type CodeModeDiagnostic={code:string;message:string;severity:"error"|"warning"|"info";line?:number;column?:number;};type CodeModeRecoveryClassification="setup_like"|"side_effecting"|"unknown";type CodeModeRecoveryEntry={timestamp:string;code:string;declarationHash:string;outcome:{ok:true}|{ok:false;code:string;message:string};diagnostics:Array>;recoveryClassification:CodeModeRecoveryClassification;logsStored?:boolean;summary?:string;};type ReadCodeModeRecoveryResult={entries:CodeModeRecoveryEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};type CodeModeSessionStatus="created"|"reused";type CodeModeRunMeta={runId:string;traceId:string;declarationHash:string;durationMs:number;timeoutMs:number;maxTimeoutMs:number;sessionId?:string|null;sessionStatus?:CodeModeSessionStatus|null;recoveryRef?:string|null;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; diff --git a/packages/core/src/code-mode/types.ts b/packages/core/src/code-mode/types.ts index 17fc6010..f1de1552 100644 --- a/packages/core/src/code-mode/types.ts +++ b/packages/core/src/code-mode/types.ts @@ -7,8 +7,6 @@ export type CodeModeCallableCaplet = { name: string; description: string; shadowing?: "forbid" | "allow" | "namespace"; - useWhen?: string; - avoidWhen?: string; }; export type CodeModeDeclarationInput = { diff --git a/packages/core/src/config-runtime.ts b/packages/core/src/config-runtime.ts index 15ed65c3..c2637760 100644 --- a/packages/core/src/config-runtime.ts +++ b/packages/core/src/config-runtime.ts @@ -54,11 +54,6 @@ export type RuntimeRequirementsConfig = { resources?: { class?: RuntimeResourceClass | undefined } | undefined; }; -export type AgentSelectionHintsConfig = { - useWhen?: string | undefined; - avoidWhen?: string | undefined; -}; - export type CapletExposure = | "direct" | "progressive" @@ -105,7 +100,7 @@ export type GoogleDiscoveryApiConfig = CommonCapletConfig & { operationCacheTtlMs: number; }; -export type GraphQlOperationConfig = AgentSelectionHintsConfig & { +export type GraphQlOperationConfig = { document?: string | undefined; documentPath?: string | undefined; operationName?: string | undefined; @@ -125,7 +120,7 @@ export type GraphQlEndpointConfig = CommonCapletConfig & { selectionDepth: number; }; -export type HttpActionConfig = AgentSelectionHintsConfig & { +export type HttpActionConfig = { method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; description?: string | undefined; @@ -145,7 +140,7 @@ export type HttpApiConfig = CommonCapletConfig & { maxResponseBytes: number; }; -export type CliToolActionConfig = AgentSelectionHintsConfig & { +export type CliToolActionConfig = { description?: string | undefined; inputSchema?: Record | undefined; outputSchema?: Record | undefined; @@ -223,14 +218,13 @@ export type CapletsConfig = { capletSets: Record; }; -type CommonCapletConfig = AgentSelectionHintsConfig & { +type CommonCapletConfig = { server: string; name: string; description: string; exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; setup?: CapletSetupConfig | undefined; projectBinding?: ProjectBindingConfig | undefined; runtime?: RuntimeRequirementsConfig | undefined; @@ -284,11 +278,6 @@ const runtimeRequirementsSchema = z .optional(), }) .strict(); -const agentSelectionHintSchema = z.string().trim().min(1).max(500); -const agentSelectionHintsSchema = { - useWhen: agentSelectionHintSchema.optional(), - avoidWhen: agentSelectionHintSchema.optional(), -}; const exposureSchema = z.enum([ "direct", "progressive", @@ -309,8 +298,6 @@ const commonSchema = { tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, - body: z.string().optional(), setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -362,7 +349,6 @@ const graphQlOperationSchema = z documentPath: z.string().min(1).optional(), operationName: z.string().min(1).optional(), description: z.string().min(1).optional(), - ...agentSelectionHintsSchema, }) .strict() .refine((operation) => Boolean(operation.document) !== Boolean(operation.documentPath), { @@ -393,7 +379,6 @@ const httpActionSchema = z .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(), - ...agentSelectionHintsSchema, inputSchema: z.record(z.string(), z.unknown()).optional(), outputSchema: z.record(z.string(), z.unknown()).optional(), query: scalarMapSchema.optional(), @@ -429,7 +414,6 @@ const httpApiSchema = z const cliActionSchema = z .object({ description: z.string().min(1).optional(), - ...agentSelectionHintsSchema, inputSchema: z.record(z.string(), z.unknown()).optional(), outputSchema: z.record(z.string(), z.unknown()).optional(), command: z.string().min(1), @@ -822,5 +806,7 @@ function stripUndefined(value: Record): Record } function hasEnvReference(value: string): boolean { - return /\$\{?[A-Z_][A-Z0-9_]*\}?|\$env:[A-Z_][A-Z0-9_]*/u.test(value); + return /(?:\$\{?[A-Z_][A-Z0-9_]*\}?|\$env:[A-Z_][A-Z0-9_]*|\$\{vault:[^}]+\}|\$vault:[A-Za-z0-9_-]+)/u.test( + value, + ); } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index fee00819..689e2e98 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,7 +1,23 @@ import { existsSync, readFileSync } from "node:fs"; -import { basename, dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { z } from "zod"; -import { loadCapletFilesWithPaths, loadCapletFilesWithPathsBestEffort } from "./caplet-files"; +import { + loadCapletFilesFromMap, + loadCapletFilesWithPaths, + loadCapletFilesWithPathsBestEffort, +} from "./caplet-files"; +import { FilesystemCapletSource } from "./caplet-source/filesystem"; +import { + createRuntimeFingerprintSnapshot, + type DeclaredInputReader, + type RuntimeFingerprintProvenance, + type RuntimeFingerprintSnapshot, +} from "./caplet-source/runtime-fingerprint"; +import { + parseConfig as parseRuntimeTemplateConfig, + type CapletConfig as RuntimeTemplateCapletConfig, + type CapletsConfig as RuntimeTemplateConfig, +} from "./config-runtime"; import { defaultConfigPath, resolveCapletsRoot, @@ -105,11 +121,6 @@ export type RuntimeRequirementsConfig = { resources?: { class?: RuntimeResourceClass | undefined } | undefined; }; -export type AgentSelectionHintsConfig = { - useWhen?: string | undefined; - avoidWhen?: string | undefined; -}; - export type CapletShadowingPolicy = "forbid" | "allow" | "namespace"; export type CapletExposure = @@ -119,7 +130,7 @@ export type CapletExposure = | "direct_and_code_mode" | "progressive_and_code_mode"; -export type CapletServerConfig = AgentSelectionHintsConfig & { +export type CapletServerConfig = { server: string; backend: "mcp"; name: string; @@ -127,7 +138,6 @@ export type CapletServerConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; transport: "stdio" | "http" | "sse"; command?: string | undefined; args?: string[] | undefined; @@ -150,7 +160,7 @@ export type OpenApiAuthConfig = | { type: "headers"; headers: Record } | Extract; -export type OpenApiEndpointConfig = AgentSelectionHintsConfig & { +export type OpenApiEndpointConfig = { server: string; backend: "openapi"; name: string; @@ -158,7 +168,6 @@ export type OpenApiEndpointConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; specPath?: string | undefined; specUrl?: string | undefined; baseUrl?: string | undefined; @@ -171,14 +180,14 @@ export type OpenApiEndpointConfig = AgentSelectionHintsConfig & { runtime?: RuntimeRequirementsConfig | undefined; }; -export type GraphQlOperationConfig = AgentSelectionHintsConfig & { +export type GraphQlOperationConfig = { document?: string | undefined; documentPath?: string | undefined; operationName?: string | undefined; description?: string | undefined; }; -export type GraphQlEndpointConfig = AgentSelectionHintsConfig & { +export type GraphQlEndpointConfig = { server: string; backend: "graphql"; name: string; @@ -186,7 +195,6 @@ export type GraphQlEndpointConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; endpointUrl: string; schemaPath?: string | undefined; schemaUrl?: string | undefined; @@ -202,7 +210,7 @@ export type GraphQlEndpointConfig = AgentSelectionHintsConfig & { runtime?: RuntimeRequirementsConfig | undefined; }; -export type HttpActionConfig = AgentSelectionHintsConfig & { +export type HttpActionConfig = { method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; description?: string | undefined; @@ -213,7 +221,7 @@ export type HttpActionConfig = AgentSelectionHintsConfig & { jsonBody?: unknown; }; -export type HttpApiConfig = AgentSelectionHintsConfig & { +export type HttpApiConfig = { server: string; backend: "http"; name: string; @@ -221,7 +229,6 @@ export type HttpApiConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; baseUrl: string; auth: OpenApiAuthConfig; actions: Record; @@ -233,7 +240,7 @@ export type HttpApiConfig = AgentSelectionHintsConfig & { runtime?: RuntimeRequirementsConfig | undefined; }; -export type GoogleDiscoveryApiConfig = AgentSelectionHintsConfig & { +export type GoogleDiscoveryApiConfig = { server: string; backend: "googleDiscovery"; name: string; @@ -241,7 +248,6 @@ export type GoogleDiscoveryApiConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; discoveryPath?: string | undefined; discoveryUrl?: string | undefined; baseUrl?: string | undefined; @@ -260,7 +266,7 @@ export type CliToolOutputConfig = { type: "text" | "json"; }; -export type CliToolActionConfig = AgentSelectionHintsConfig & { +export type CliToolActionConfig = { description?: string | undefined; inputSchema?: Record | undefined; outputSchema?: Record | undefined; @@ -281,7 +287,7 @@ export type CliToolActionConfig = AgentSelectionHintsConfig & { | undefined; }; -export type CliToolsConfig = AgentSelectionHintsConfig & { +export type CliToolsConfig = { server: string; backend: "cli"; name: string; @@ -289,7 +295,6 @@ export type CliToolsConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; actions: Record; cwd?: string | undefined; env?: Record | undefined; @@ -301,7 +306,7 @@ export type CliToolsConfig = AgentSelectionHintsConfig & { runtime?: RuntimeRequirementsConfig | undefined; }; -export type CapletSetConfig = AgentSelectionHintsConfig & { +export type CapletSetConfig = { server: string; backend: "caplets"; name: string; @@ -309,7 +314,6 @@ export type CapletSetConfig = AgentSelectionHintsConfig & { exposure?: CapletExposure | undefined; shadowing?: CapletShadowingPolicy | undefined; tags?: string[] | undefined; - body?: string | undefined; configPath?: string | undefined; capletsRoot?: string | undefined; defaultSearchLimit: number; @@ -388,8 +392,29 @@ export type ConfigWithSources = { config: CapletsConfig; sources: Record; shadows: Record; + runtimeFingerprint?: RuntimeFingerprintSnapshot | undefined; }; +const runtimeFingerprintByConfig = new WeakMap(); + +export function runtimeFingerprintForConfig( + config: CapletsConfig, + reader?: DeclaredInputReader, +): RuntimeFingerprintSnapshot { + const existing = runtimeFingerprintByConfig.get(config); + if (existing && (existing.valid || reader === undefined)) return existing; + const fingerprint = createRuntimeFingerprintSnapshot({ + config, + provenance: {}, + reader: reader ?? { + read: () => ({ state: "missing" }), + list: () => ({ state: "missing" }), + }, + }); + runtimeFingerprintByConfig.set(config, fingerprint); + return fingerprint; +} + type GenericLocalOverlayConfigWarning = { type?: undefined; kind: ConfigSourceKind; @@ -445,7 +470,11 @@ export type ConfigParseOptions = { vaultRecoveryTarget?: "global" | "remote" | undefined; }; -const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); +const NON_INTERPOLATED_SERVER_FIELDS: Record = { + name: true, + description: true, + tags: true, +}; const VAULT_BARE_REFERENCE = "[A-Za-z0-9_-]+"; const remoteAuthSchema = z @@ -590,21 +619,6 @@ const runtimeRequirementsSchema = z }) .strict() .describe("Runtime feature and resource requirements for hosted execution."); -const agentSelectionHintSchema = z - .string() - .trim() - .min(1) - .max(500) - .describe("Optional author-supplied hint for agent tool/caplet selection."); - -const agentSelectionHintsSchema = { - useWhen: agentSelectionHintSchema - .optional() - .describe("When agents should prefer this Caplet or configured action."), - avoidWhen: agentSelectionHintSchema - .optional() - .describe("When agents should avoid this Caplet or configured action."), -}; const exposureSchema = z .enum(["direct", "progressive", "code_mode", "direct_and_code_mode", "progressive_and_code_mode"]) @@ -683,7 +697,6 @@ const publicServerSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -712,9 +725,7 @@ const publicServerSchema = z }) .strict(); -const normalizedServerSchema = publicServerSchema.extend({ - body: z.string().optional(), -}); +const normalizedServerSchema = publicServerSchema; const publicOpenApiEndpointSchema = z .object({ @@ -736,7 +747,6 @@ const publicOpenApiEndpointSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -761,9 +771,7 @@ const publicOpenApiEndpointSchema = z }) .strict(); -const normalizedOpenApiEndpointSchema = publicOpenApiEndpointSchema.extend({ - body: z.string().optional(), -}); +const normalizedOpenApiEndpointSchema = publicOpenApiEndpointSchema; const operationFilterSchema = z.array(z.string().trim().min(1).max(160)); @@ -796,7 +804,6 @@ const publicGoogleDiscoveryApiSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -821,9 +828,7 @@ const publicGoogleDiscoveryApiSchema = z }) .strict(); -const normalizedGoogleDiscoveryApiSchema = publicGoogleDiscoveryApiSchema.extend({ - body: z.string().optional(), -}); +const normalizedGoogleDiscoveryApiSchema = publicGoogleDiscoveryApiSchema; const graphQlOperationSchema = z .object({ @@ -831,7 +836,6 @@ const graphQlOperationSchema = z 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."), - ...agentSelectionHintsSchema, }) .strict() .superRefine((operation, ctx) => { @@ -872,7 +876,6 @@ const publicGraphQlEndpointSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -914,9 +917,7 @@ const publicGraphQlEndpointSchema = z } }); -const normalizedGraphQlEndpointSchema = publicGraphQlEndpointSchema.extend({ - body: z.string().optional(), -}); +const normalizedGraphQlEndpointSchema = publicGraphQlEndpointSchema; const httpScalarMappingSchema = z.record( z.string(), @@ -936,7 +937,6 @@ const httpActionSchema = z .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."), - ...agentSelectionHintsSchema, inputSchema: z .record(z.string(), z.unknown()) .optional() @@ -992,7 +992,6 @@ const publicHttpApiSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -1012,9 +1011,7 @@ const publicHttpApiSchema = z }) .strict(); -const normalizedHttpApiSchema = publicHttpApiSchema.extend({ - body: z.string().optional(), -}); +const normalizedHttpApiSchema = publicHttpApiSchema; const cliToolOutputSchema = z .object({ @@ -1037,7 +1034,6 @@ const cliToolAnnotationsSchema = z const cliToolActionSchema = z .object({ description: z.string().min(1).optional().describe("Action capability description."), - ...agentSelectionHintsSchema, inputSchema: z .record(z.string(), z.unknown()) .optional() @@ -1091,7 +1087,6 @@ const publicCliToolsSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -1111,9 +1106,7 @@ const publicCliToolsSchema = z }) .strict(); -const normalizedCliToolsSchema = publicCliToolsSchema.extend({ - body: z.string().optional(), -}); +const normalizedCliToolsSchema = publicCliToolsSchema; const publicCapletSetSchema = z .object({ @@ -1150,7 +1143,6 @@ const publicCapletSetSchema = z tags: z.array(z.string().trim().min(1).max(80)).optional(), exposure: exposureSchema.optional(), shadowing: shadowingSchema, - ...agentSelectionHintsSchema, setup: setupSchema.optional(), projectBinding: projectBindingSchema.optional(), runtime: runtimeRequirementsSchema.optional(), @@ -1173,9 +1165,7 @@ const publicCapletSetSchema = z } }); -const normalizedCapletSetSchema = publicCapletSetSchema.extend({ - body: z.string().optional(), -}); +const normalizedCapletSetSchema = publicCapletSetSchema; type ConfigSchemaServerValue = z.infer; type ConfigSchemaOpenApiEndpointValue = z.infer; @@ -1890,7 +1880,9 @@ function buildConfigWithSources( ) { throw new CapletsError("CONFIG_INVALID", emptyMessage); } - return { config, sources, shadows }; + const runtimeFingerprint = createLoadedRuntimeFingerprint(input, sources); + runtimeFingerprintByConfig.set(config, runtimeFingerprint); + return { config, sources, shadows, runtimeFingerprint }; } catch (error) { if (error instanceof CapletsError) { throw error; @@ -1903,6 +1895,301 @@ function buildConfigWithSources( } } +function createLoadedRuntimeFingerprint( + input: ConfigInput, + sources: Record, +): RuntimeFingerprintSnapshot { + const runtimeInput = { + version: input.version, + defaultSearchLimit: input.defaultSearchLimit, + maxSearchLimit: input.maxSearchLimit, + completion: input.completion, + options: input.options, + namespaceAliases: input.namespaceAliases, + mcpServers: input.mcpServers, + openapiEndpoints: input.openapiEndpoints, + googleDiscoveryApis: input.googleDiscoveryApis, + graphqlEndpoints: input.graphqlEndpoints, + httpApis: input.httpApis, + cliTools: input.cliTools, + capletSets: input.capletSets, + }; + const templateConfig = parseRuntimeTemplateConfig(runtimeInput); + const provenance: Record = {}; + const readersByScope = new Map(); + const privateRootsByScope = new Map>(); + for (const caplet of runtimeTemplateCaplets(templateConfig)) { + const source = sources[caplet.server]; + const sourceInfo = fingerprintSourceInfo(source?.path, caplet.server); + provenance[caplet.server] = { + parentId: sourceInfo.parentId, + ...(sourceInfo.childId ? { childId: sourceInfo.childId } : {}), + sourcePath: sourceInfo.sourcePath, + readerScope: sourceInfo.scope, + }; + readersByScope.set( + sourceInfo.scope, + new FilesystemCapletSource(sourceInfo.root).declaredInputReader(), + ); + logicalizeRuntimeTemplatePaths( + caplet, + sourceInfo.root, + literalAbsoluteDeclaredInputValues(source?.path, caplet.server), + ); + } + const privatePath = ( + logicalPath: string, + context: Parameters[1], + ): string | undefined => { + if (!context?.readerScope) return undefined; + if (context.privateReference) { + const logicalBase = logicalPath.split("/").slice(0, -1).join("/") || logicalPath; + const roots = privateRootsByScope.get(context.readerScope) ?? new Map(); + roots.set(logicalBase, dirname(context.privateReference)); + privateRootsByScope.set(context.readerScope, roots); + return context.privateReference; + } + const roots = privateRootsByScope.get(context.readerScope); + if (!roots) return undefined; + const match = [...roots.entries()] + .filter( + ([logicalBase]) => logicalPath === logicalBase || logicalPath.startsWith(`${logicalBase}/`), + ) + .sort(([left], [right]) => right.length - left.length)[0]; + if (!match) return undefined; + const suffix = logicalPath.slice(match[0].length).replace(/^\//u, ""); + return resolve(match[1], suffix); + }; + const privateRoot = ( + logicalRoot: string, + context: Parameters[1], + ): string | undefined => { + if (!context?.readerScope) return undefined; + if (context.privateReference) { + const roots = privateRootsByScope.get(context.readerScope) ?? new Map(); + roots.set(logicalRoot, context.privateReference); + privateRootsByScope.set(context.readerScope, roots); + return context.privateReference; + } + return privatePath(logicalRoot, context); + }; + const reader: DeclaredInputReader = { + read(logicalPath, context) { + const physicalPath = privatePath(logicalPath, context); + if (physicalPath) { + return new FilesystemCapletSource(dirname(physicalPath)) + .declaredInputReader() + .read(basename(physicalPath), context); + } + return context?.readerScope + ? (readersByScope.get(context.readerScope)?.read(logicalPath, context) ?? { + state: "unreadable", + }) + : { state: "unreadable" }; + }, + list(logicalRoot, context) { + const physicalRoot = privateRoot(logicalRoot, context); + if (physicalRoot) { + const listed = new FilesystemCapletSource(dirname(physicalRoot)) + .declaredInputReader() + .list(basename(physicalRoot), context); + if (listed.state !== "present") return listed; + const physicalPrefix = `${basename(physicalRoot)}/`; + return { + ...listed, + paths: listed.paths.map((path) => + path.startsWith(physicalPrefix) + ? `${logicalRoot}/${path.slice(physicalPrefix.length)}` + : logicalRoot, + ), + }; + } + return context?.readerScope + ? (readersByScope.get(context.readerScope)?.list(logicalRoot, context) ?? { + state: "unreadable", + }) + : { state: "unreadable" }; + }, + }; + const hostConfig = Object.assign(templateConfig, { + ...(typeof input.telemetry === "boolean" ? { telemetry: input.telemetry } : {}), + ...(isPlainObject(input.serve) ? { serve: input.serve as ServeConfig } : {}), + }); + return createRuntimeFingerprintSnapshot({ + config: hostConfig, + provenance, + reader, + }); +} + +function runtimeTemplateCaplets(config: RuntimeTemplateConfig): RuntimeTemplateCapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.googleDiscoveryApis), + ...Object.values(config.graphqlEndpoints), + ...Object.values(config.httpApis), + ...Object.values(config.cliTools), + ...Object.values(config.capletSets), + ]; +} + +function fingerprintSourceInfo( + path: string | undefined, + runtimeId: string, +): { + root: string; + sourcePath: string; + scope: string; + parentId: string; + childId?: string | undefined; +} { + const resolvedPath = resolve(path || `${runtimeId}.json`); + const fileName = basename(resolvedPath); + if (fileName === "CAPLET.md") { + const artifactDirectory = dirname(resolvedPath); + const parentId = basename(artifactDirectory); + return { + root: dirname(artifactDirectory), + sourcePath: `${parentId}/CAPLET.md`, + scope: `${runtimeId}:${resolvedPath}`, + parentId, + ...(runtimeId.startsWith(`${parentId}__`) + ? { childId: runtimeId.slice(parentId.length + 2) } + : {}), + }; + } + const parentId = fileName.toLowerCase().endsWith(".md") ? fileName.slice(0, -3) : runtimeId; + return { + root: dirname(resolvedPath), + sourcePath: fileName, + scope: `${runtimeId}:${resolvedPath}`, + parentId, + ...(runtimeId.startsWith(`${parentId}__`) + ? { childId: runtimeId.slice(parentId.length + 2) } + : {}), + }; +} + +function logicalizeRuntimeTemplatePaths( + caplet: RuntimeTemplateCapletConfig, + sourceRoot: string, + literalAbsoluteValues: Set | undefined, +): void { + const visit = (value: unknown): void => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const entry of value) visit(entry); + return; + } + for (const [key, nested] of Object.entries(value as Record)) { + if (typeof nested === "string" && RUNTIME_DECLARED_INPUT_PATH_KEYS[key]) { + if ( + isAbsolute(nested) && + literalAbsoluteValues !== undefined && + !literalAbsoluteValues.has(nested) && + isWithinFingerprintRoot(sourceRoot, nested) + ) { + (value as Record)[key] = relative(sourceRoot, nested) + .split(sep) + .join("/"); + } + continue; + } + visit(nested); + } + }; + visit(caplet); +} + +function literalAbsoluteDeclaredInputValues( + sourcePath: string | undefined, + runtimeId: string, +): Set | undefined { + if (!sourcePath) return undefined; + try { + let sourceConfig: unknown; + if (sourcePath.toLowerCase().endsWith(".md")) { + const fileName = + basename(sourcePath) === "CAPLET.md" + ? `${basename(dirname(sourcePath))}/CAPLET.md` + : basename(sourcePath); + const loaded = loadCapletFilesFromMap({ + files: [{ path: fileName, content: readFileSync(sourcePath, "utf8") }], + }); + sourceConfig = loaded + ? runtimeInputValue(loaded.config as Record, runtimeId) + : undefined; + } else { + sourceConfig = runtimeInputValue( + JSON.parse(readFileSync(sourcePath, "utf8")) as Record, + runtimeId, + ); + } + if (!sourceConfig) return undefined; + const values = new Set(); + const visit = (value: unknown): void => { + if (!value || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value as Record)) { + if ( + typeof nested === "string" && + RUNTIME_DECLARED_INPUT_PATH_KEYS[key] && + isCrossPlatformAbsolutePath(nested) + ) { + values.add(nested); + if (!isAbsolute(nested)) values.add(resolve(dirname(sourcePath), nested)); + } else { + visit(nested); + } + } + }; + visit(sourceConfig); + return values; + } catch { + return undefined; + } +} + +function runtimeInputValue(input: Record, runtimeId: string): unknown { + for (const key of [ + "mcpServers", + "openapiEndpoints", + "googleDiscoveryApis", + "graphqlEndpoints", + "httpApis", + "cliTools", + "capletSets", + ]) { + const values = input[key]; + if (isPlainObject(values)) { + if (values[runtimeId] !== undefined) return values[runtimeId]; + const childId = runtimeId.includes("__") ? runtimeId.split("__").at(-1) : undefined; + if (childId && values[childId] !== undefined) return values[childId]; + } + } + return undefined; +} + +const RUNTIME_DECLARED_INPUT_PATH_KEYS: Record = { + specPath: true, + discoveryPath: true, + schemaPath: true, + documentPath: true, + configPath: true, + capletsRoot: true, +}; + +function isWithinFingerprintRoot(root: string, path: string): boolean { + const nested = relative(resolve(root), resolve(path)); + return ( + nested === "" || (!nested.startsWith(`..${sep}`) && nested !== ".." && !isAbsolute(nested)) + ); +} + +function isCrossPlatformAbsolutePath(path: string): boolean { + return isAbsolute(path) || /^[A-Za-z]:[\\/]/u.test(path); +} + export function loadLocalOverlayConfigWithSources( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), @@ -1955,10 +2242,14 @@ export function loadLocalOverlayConfigWithSources( : undefined, ); + const config = parseConfig(input, { sources, vaultResolver: parseOptions.vaultResolver }); + const runtimeFingerprint = createLoadedRuntimeFingerprint(input, sources); + runtimeFingerprintByConfig.set(config, runtimeFingerprint); return { - config: parseConfig(input, { sources, vaultResolver: parseOptions.vaultResolver }), + config, sources, shadows, + runtimeFingerprint, warnings, sourceFound, }; @@ -2529,7 +2820,7 @@ function normalizeOpenApiPath( ): Record { return { ...endpoint, - specPath: normalizeLocalPath(endpoint.specPath, baseDir), + specPath: normalizeDeclaredLocalPath(endpoint.specPath, baseDir), }; } @@ -2539,7 +2830,7 @@ function normalizeGoogleDiscoveryPath( ): Record { return { ...endpoint, - discoveryPath: normalizeLocalPath(endpoint.discoveryPath, baseDir), + discoveryPath: normalizeDeclaredLocalPath(endpoint.discoveryPath, baseDir), }; } @@ -2554,7 +2845,7 @@ function normalizeGraphQlPath( isPlainObject(operation) ? { ...operation, - documentPath: normalizeLocalPath(operation.documentPath, baseDir), + documentPath: normalizeDeclaredLocalPath(operation.documentPath, baseDir), } : operation, ]), @@ -2562,7 +2853,7 @@ function normalizeGraphQlPath( : endpoint.operations; return { ...endpoint, - schemaPath: normalizeLocalPath(endpoint.schemaPath, baseDir), + schemaPath: normalizeDeclaredLocalPath(endpoint.schemaPath, baseDir), operations, }; } @@ -2597,8 +2888,8 @@ function normalizeCapletSetPaths( ): Record { return { ...endpoint, - configPath: normalizeLocalPath(endpoint.configPath, baseDir), - capletsRoot: normalizeLocalPath(endpoint.capletsRoot, baseDir), + configPath: normalizeDeclaredLocalPath(endpoint.configPath, baseDir), + capletsRoot: normalizeDeclaredLocalPath(endpoint.capletsRoot, baseDir), }; } @@ -2614,6 +2905,18 @@ function normalizeLocalPath(value: unknown, baseDir: string): unknown { return join(baseDir, value); } +function normalizeDeclaredLocalPath(value: unknown, baseDir: string): unknown { + if ( + typeof value === "string" && + !isAbsolute(value) && + !hasInterpolationReference(value) && + value.replace(/\\/gu, "/").split("/").includes("..") + ) { + throw new CapletsError("CONFIG_INVALID", "Declared input path traversal is not allowed"); + } + return normalizeLocalPath(value, baseDir); +} + function rejectProjectConfigExecutableBackendMaps(input: ConfigInput, path: string): ConfigInput { if (input.openapiEndpoints && Object.keys(input.openapiEndpoints).length > 0) { throw new CapletsError( @@ -3006,7 +3309,7 @@ function isPublicMetadataPath(path: string[]): boolean { if (path.length < 3 || !CAPLET_BACKEND_KEY_SET.has(path[0] ?? "")) { return false; } - return NON_INTERPOLATED_SERVER_FIELDS.has(path[2] ?? ""); + return NON_INTERPOLATED_SERVER_FIELDS[path[2] ?? ""] === true; } function isPlainObject(value: unknown): value is Record { diff --git a/packages/core/src/current-host/catalog-operations.ts b/packages/core/src/current-host/catalog-operations.ts index 02b1eaca..dd0de33a 100644 --- a/packages/core/src/current-host/catalog-operations.ts +++ b/packages/core/src/current-host/catalog-operations.ts @@ -209,8 +209,14 @@ async function attachCatalogIndexing( }>, disableCatalogIndexing: boolean, ): Promise { - const indexed = await indexInstalledCapletsFromLockfile(installed, { disableCatalogIndexing }); - for (const entry of installed) entry.catalogIndexing = indexed.get(entry.id); + try { + const indexed = await indexInstalledCapletsFromLockfile(installed, { disableCatalogIndexing }); + for (const entry of installed) entry.catalogIndexing = indexed.get(entry.id); + } catch { + for (const entry of installed) { + entry.catalogIndexing = { status: "unavailable", reason: "indexer_unavailable" }; + } + } } function requireControlContext( diff --git a/packages/core/src/current-host/catalog.ts b/packages/core/src/current-host/catalog.ts index 22cbfbb8..1b9e1e0d 100644 --- a/packages/core/src/current-host/catalog.ts +++ b/packages/core/src/current-host/catalog.ts @@ -60,7 +60,7 @@ export type CurrentHostInstalledCatalogCaplet = { destination: string; kind: "file" | "directory"; hash?: string | undefined; - status?: "installed" | "restored" | "updated" | "noop" | undefined; + status?: "installed" | "restored" | "updated" | "content_updated" | "noop" | undefined; lockfile?: string | undefined; catalogIndexing?: unknown; }; @@ -225,8 +225,6 @@ async function catalogEntriesFromSource(sourceInput: string): Promise boundedString(tag, 256)) && - boundedString(value.intendedTask, 4096) && - optionalBoundedString(value.avoidWhen, 4096) && typeof value.setupReadiness === "string" && value.setupReadiness in readinessValues && typeof value.authReadiness === "string" && diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 20de3c4d..cb7c6fe8 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -15,9 +15,14 @@ import { resolveCapletsRoot, resolveConfigPath, resolveProjectConfigPath, + runtimeFingerprintForConfig, vaultResolverForAuthDir, } from "./config"; import { DEFAULT_OBSERVED_OUTPUT_SHAPE_CACHE_DIR } from "./config/paths"; +import { + resolvedExecutionFingerprintForConfig, + type DeclaredInputReader, +} from "./caplet-source/runtime-fingerprint"; import { DownstreamManager } from "./downstream"; import { CapletsError, errorResult, toSafeError } from "./errors"; import { GraphQLManager } from "./graphql"; @@ -74,6 +79,7 @@ export type CapletsEngineOptions = { projectConfigPath: string, options?: { writeWarning?: ((warning: LocalOverlayConfigWarning) => void) | undefined }, ) => CapletsConfig; + declaredInputReader?: DeclaredInputReader | undefined; observedOutputShapeStore?: ObservedOutputShapeStore | undefined; observedOutputShapeScope?: ObservedOutputShapeKey["scope"] | undefined; observedOutputShapeCacheDir?: string | undefined; @@ -121,6 +127,8 @@ export class CapletsEngine { private readonly watchEnabled: boolean; private readonly writeErr: (value: string) => void; private readonly configLoader: NonNullable; + private readonly declaredInputReader: DeclaredInputReader | undefined; + private readonly requireValidCustomFingerprint: boolean; private readonly observedOutputShapeStore: ObservedOutputShapeStore | undefined; private readonly observedOutputShapeScope: ObservedOutputShapeKey["scope"]; private readonly projectFingerprint: string | undefined; @@ -135,6 +143,8 @@ export class CapletsEngine { private reloading: Promise | undefined; private pendingReload = false; private closed = false; + private stableHostConfigurationFingerprint: string; + private resolvedExecutionFingerprint: string; constructor(options: CapletsEngineOptions = {}) { this.paths = { @@ -144,7 +154,12 @@ export class CapletsEngine { this.writeErr = options.writeErr ?? ((value: string) => process.stderr.write(value)); this.configLoader = options.configLoader ?? runtimeConfigLoader(options.authDir, options.vaultRecoveryTarget); + this.declaredInputReader = options.declaredInputReader; + this.requireValidCustomFingerprint = options.configLoader !== undefined; const config = this.loadConfigWithWarnings(); + this.stableHostConfigurationFingerprint = + runtimeFingerprintForConfig(config).hostConfigurationFingerprint; + this.resolvedExecutionFingerprint = resolvedExecutionFingerprintForConfig(config); this.registry = new ServerRegistry(config); this.telemetry = createRuntimeTelemetryContext({ config: this.registry.config, @@ -545,15 +560,26 @@ export class CapletsEngine { this.writeErr(`${JSON.stringify(toSafeError(error, "CONFIG_INVALID"), null, 2)}\n`); return false; } - if (this.closed) { return false; } + + const nextStableHostConfigurationFingerprint = + runtimeFingerprintForConfig(nextConfig).hostConfigurationFingerprint; + const nextResolvedExecutionFingerprint = resolvedExecutionFingerprintForConfig(nextConfig); + if ( + nextStableHostConfigurationFingerprint === this.stableHostConfigurationFingerprint && + nextResolvedExecutionFingerprint === this.resolvedExecutionFingerprint + ) { + return true; + } const previousConfig = this.registry.config; const nextRegistry = new ServerRegistry(nextConfig); this.registry = nextRegistry; this.exposureGeneration += 1; this.telemetry.config = nextConfig; + this.stableHostConfigurationFingerprint = nextStableHostConfigurationFingerprint; + this.resolvedExecutionFingerprint = nextResolvedExecutionFingerprint; this.downstream.updateRegistry(nextRegistry); this.openapi.updateRegistry(nextRegistry); this.googleDiscovery.updateRegistry(nextRegistry); @@ -581,11 +607,19 @@ export class CapletsEngine { } private loadConfigWithWarnings(): CapletsConfig { - return this.configLoader(this.paths.configPath, this.paths.projectConfigPath, { + const config = this.configLoader(this.paths.configPath, this.paths.projectConfigPath, { writeWarning: (warning) => { this.writeErr(`Warning: ${warning.kind} at ${warning.path}: ${warning.message}\n`); }, }); + const runtimeFingerprint = runtimeFingerprintForConfig(config, this.declaredInputReader); + if (this.requireValidCustomFingerprint && !runtimeFingerprint.valid) { + throw new CapletsError( + "CONFIG_INVALID", + "Caplets runtime references must be present and readable.", + ); + } + return config; } private async reloadUntilSettled(): Promise { diff --git a/packages/core/src/exposure/projection.ts b/packages/core/src/exposure/projection.ts index 6eb96729..a4439e5e 100644 --- a/packages/core/src/exposure/projection.ts +++ b/packages/core/src/exposure/projection.ts @@ -1,4 +1,5 @@ import type { CapletConfig, CapletShadowingPolicy } from "../config"; +import { compactToolSelectionHints } from "../downstream"; import { resolveNamespaceExposure, type NamespaceDiagnostic, @@ -56,8 +57,6 @@ type ExposureProjectionEntryBase< description?: string | undefined; sourceCapletId?: string | undefined; shadowing: CapletShadowingPolicy; - useWhen?: string | undefined; - avoidWhen?: string | undefined; route: Route; }; @@ -84,6 +83,8 @@ export type ExposureProjectionDirectTool = ExposureProjectionEntryBase< inputSchema?: unknown; outputSchema?: unknown; annotations?: unknown; + useWhen?: string | undefined; + avoidWhen?: string | undefined; }; export type ExposureProjectionDirectResource = ExposureProjectionEntryBase< @@ -178,8 +179,6 @@ type ManifestProjectionBase = { outputSchema?: unknown; annotations?: unknown; shadowing: CapletShadowingPolicy; - useWhen?: string | undefined; - avoidWhen?: string | undefined; }; type ManifestProjectionCaplet = ManifestProjectionBase & { @@ -191,6 +190,8 @@ type ManifestProjectionTool = ManifestProjectionBase & { kind: "tool"; name: string; downstreamName: string; + useWhen?: string | undefined; + avoidWhen?: string | undefined; }; type ManifestProjectionResource = ManifestProjectionBase & { @@ -253,8 +254,6 @@ function manifestProgressiveCapletEntry(entry: ManifestProjectionCaplet): Exposu title: entry.title ?? entry.name, description: entry.description, inputSchema: entry.inputSchema, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "progressive-caplet", capletId: entry.capletId }, }; @@ -288,8 +287,6 @@ function manifestDirectResourceEntry(entry: ManifestProjectionResource): Exposur description: entry.description, ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), ...(typeof entry.size === "number" ? { size: entry.size } : {}), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-resource", @@ -310,8 +307,6 @@ function manifestDirectResourceTemplateEntry( title: entry.title, description: entry.description, ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-resource-template", @@ -331,8 +326,6 @@ function manifestDirectPromptEntry(entry: ManifestProjectionPrompt): ExposurePro description: entry.description, inputSchema: entry.inputSchema, arguments: promptArgumentsFromSchema(entry.inputSchema), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-prompt", @@ -368,8 +361,6 @@ function manifestCompletionEntry(entry: ManifestProjectionCompletion): ExposureP ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), title: entry.title, description: entry.description, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "completion", capletId: entry.capletId }, }; @@ -385,8 +376,6 @@ function manifestCodeModeCapletEntry( ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), title: entry.title ?? entry.name, description: entry.description, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "code-mode-caplet", capletId: entry.capletId }, }; @@ -426,8 +415,6 @@ function progressiveCapletEntry(entry: CallableCaplet): ExposureProjectionProgre backend: entry.caplet.backend, inputSchema, operationNames: [...inputSchema.properties.operation.enum], - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "progressive-caplet", capletId }, }; @@ -442,8 +429,6 @@ function codeModeCapletEntry(entry: CallableCaplet): ExposureProjectionCodeModeC title: entry.caplet.name, description: capabilityDescription(entry.caplet), backend: entry.caplet.backend, - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "code-mode-caplet", capletId }, }; @@ -463,8 +448,7 @@ function directToolEntry( inputSchema: tool.inputSchema, outputSchema: tool.outputSchema, annotations: tool.annotations, - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), + ...compactToolSelectionHints(tool), shadowing: shadowingPolicy(entry.caplet), route: { kind: "direct-tool", capletId, downstreamName: tool.name }, }; @@ -483,8 +467,6 @@ function directResourceEntry( description: resource.description, ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), ...(typeof resource.size === "number" ? { size: resource.size } : {}), - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "direct-resource", capletId, downstreamUri: resource.uri }, }; @@ -502,8 +484,6 @@ function directResourceTemplateEntry( title: resourceTemplate.name, description: resourceTemplate.description, ...(resourceTemplate.mimeType ? { mimeType: resourceTemplate.mimeType } : {}), - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "direct-resource-template", @@ -527,8 +507,6 @@ function directPromptEntry( description: prompt.description, inputSchema: { arguments: args }, arguments: args, - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "direct-prompt", capletId, downstreamName: prompt.name }, }; @@ -542,8 +520,6 @@ function completionEntry(entry: CallableCaplet): ExposureProjectionCompletion { capletId, title: "Complete", description: `MCP completion for ${capletId}.`, - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "completion", capletId }, }; diff --git a/packages/core/src/graphql.ts b/packages/core/src/graphql.ts index ace716bc..41e7d772 100644 --- a/packages/core/src/graphql.ts +++ b/packages/core/src/graphql.ts @@ -56,8 +56,6 @@ const SCALAR_JSON_SCHEMA: Record> = { type GraphQlOperation = { name: string; description?: string; - useWhen?: string; - avoidWhen?: string; document: string; operationName?: string; inputSchema: Record; @@ -325,8 +323,6 @@ export class GraphQLManager { return { name: operation.name, ...(operation.description ? { description: operation.description } : {}), - ...(operation.useWhen ? { useWhen: operation.useWhen } : {}), - ...(operation.avoidWhen ? { avoidWhen: operation.avoidWhen } : {}), inputSchema: operation.inputSchema as Tool["inputSchema"], annotations: operation.kind === "query" @@ -405,8 +401,6 @@ function loadConfiguredOperations( return { name, ...(config.description ? { description: config.description } : {}), - ...(config.useWhen ? { useWhen: config.useWhen } : {}), - ...(config.avoidWhen ? { avoidWhen: config.avoidWhen } : {}), document, ...(config.operationName ? { operationName: config.operationName } : {}), inputSchema: variablesSchema(schema, operation), diff --git a/packages/core/src/http-actions.ts b/packages/core/src/http-actions.ts index eb191f18..b9f11d2f 100644 --- a/packages/core/src/http-actions.ts +++ b/packages/core/src/http-actions.ts @@ -169,8 +169,6 @@ export class HttpActionManager { return { name: operation.name, ...(operation.description ? { description: operation.description } : {}), - ...(operation.useWhen ? { useWhen: operation.useWhen } : {}), - ...(operation.avoidWhen ? { avoidWhen: operation.avoidWhen } : {}), inputSchema: (operation.inputSchema ?? DEFAULT_INPUT_SCHEMA) as Tool["inputSchema"], ...(operation.outputSchema ? { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9246910..b7e757be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,7 +92,8 @@ export type { ToolCallResult, } from "./code-mode/types"; export type { CapletSetupCommandConfig, CapletSetupConfig } from "./config"; -export { capletSetupContentHash, stableJson } from "./setup/hash"; +export { capletSetupContentHash } from "./setup/hash"; +export { stableJsonStringify as stableJson } from "./stable-json"; export { LocalSetupStore } from "./setup/local-store"; export { runCapletSetup } from "./setup/runner"; export { CloudAuthClient } from "./cloud-auth/client"; diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index 7090332b..dc399374 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -553,8 +553,6 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { ...(caplet.sourceCapletId ? { sourceCapletId: caplet.sourceCapletId } : {}), name: caplet.name, description: caplet.description ?? "", - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing, })), } @@ -573,8 +571,6 @@ function remoteCodeModeToolDescription(tool: RemoteCapletsTool): string | undefi id: caplet.capletId, name: caplet.name, description: caplet.description ?? "", - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing, })), }); @@ -599,8 +595,6 @@ function remoteCodeModeCallableNativeTools(tools: NativeCapletTool[]): NativeCap title: caplet.name, description: caplet.description, ...(caplet.shadowing ? { shadowing: caplet.shadowing } : {}), - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), promptGuidance: tool?.promptGuidance ?? [], }; }); @@ -939,8 +933,6 @@ function progressiveToolFromProjection( title: entry.title, description: entry.description, inputSchema: entry.inputSchema, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, ...codeModeMarker, }, @@ -991,8 +983,6 @@ function primitiveToolsFromProjection( prompts: boolean; completions: boolean; shadowing: CapletShadowingPolicy; - useWhen?: string; - avoidWhen?: string; } >(); const entryFor = (entry: ExposureProjectionEntry) => { @@ -1004,8 +994,6 @@ function primitiveToolsFromProjection( ) { existing.shadowing = entry.shadowing; } - if (!existing.useWhen && entry.useWhen) existing.useWhen = entry.useWhen; - if (!existing.avoidWhen && entry.avoidWhen) existing.avoidWhen = entry.avoidWhen; return existing; } const next = { @@ -1014,8 +1002,6 @@ function primitiveToolsFromProjection( prompts: false, completions: false, shadowing: entry.shadowing, - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), }; byCaplet.set(entry.capletId, next); return next; @@ -1034,34 +1020,26 @@ function primitiveToolsFromProjection( capletId: string, operation: string, shadowing: CapletShadowingPolicy, - useWhen?: string, - avoidWhen?: string, ) => { const name = `${capletId}__${operation}`; if (directToolNames.has(name)) return; - tools.push(primitiveTool(capletId, operation, shadowing, codeModeMarker, useWhen, avoidWhen)); + tools.push(primitiveTool(capletId, operation, shadowing, codeModeMarker)); }; for (const [capletId, flags] of byCaplet) { if (flags.resources) { - addPrimitiveTool(capletId, "list_resources", flags.shadowing, flags.useWhen, flags.avoidWhen); - addPrimitiveTool(capletId, "read_resource", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "list_resources", flags.shadowing); + addPrimitiveTool(capletId, "read_resource", flags.shadowing); } if (flags.resourceTemplates) { - addPrimitiveTool( - capletId, - "list_resource_templates", - flags.shadowing, - flags.useWhen, - flags.avoidWhen, - ); - addPrimitiveTool(capletId, "read_resource", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "list_resource_templates", flags.shadowing); + addPrimitiveTool(capletId, "read_resource", flags.shadowing); } if (flags.prompts) { - addPrimitiveTool(capletId, "list_prompts", flags.shadowing, flags.useWhen, flags.avoidWhen); - addPrimitiveTool(capletId, "get_prompt", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "list_prompts", flags.shadowing); + addPrimitiveTool(capletId, "get_prompt", flags.shadowing); } if (flags.completions) { - addPrimitiveTool(capletId, "complete", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "complete", flags.shadowing); } } return [...new Map(tools.map((tool) => [tool.name, tool])).values()]; @@ -1072,8 +1050,6 @@ function primitiveTool( operation: string, shadowing: CapletShadowingPolicy, codeModeMarker: Pick | Record, - useWhen?: string, - avoidWhen?: string, ): RemoteCapletsTool { return { name: `${capletId}__${operation}`, @@ -1082,8 +1058,6 @@ function primitiveTool( title: operation, description: `MCP ${operation.replace(/_/g, " ")}.`, inputSchema: primitiveInputSchema(operation), - ...(useWhen ? { useWhen } : {}), - ...(avoidWhen ? { avoidWhen } : {}), shadowing, ...codeModeMarker, }; diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index da36a787..feb43301 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -496,8 +496,6 @@ function progressiveNativeTool(entry: ExposureProjectionProgressiveCaplet): Nati toolName, title: entry.title ?? entry.id, description: projectionNativeDescription(toolName, entry), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), promptGuidance: projectionNativePromptGuidance(toolName, entry), ...(inputSchema ? { inputSchema } : {}), ...(entry.operationNames ? { operationNames: [...entry.operationNames] } : {}), @@ -513,8 +511,6 @@ function codeModeCapletDescriptor(entry: ExposureProjectionCodeModeCaplet): Nati toolName, title: entry.title ?? entry.id, description: projectionNativeDescription(toolName, entry), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), promptGuidance: projectionNativePromptGuidance(toolName, entry), shadowing: entry.shadowing, }; @@ -555,21 +551,15 @@ function nativeProjectionMetadata( capletId: string, projection: ExposureProjection, ): { - useWhen?: string; - avoidWhen?: string; shadowing: CapletShadowingPolicy; } { const entries = projection.entries.filter((entry) => entry.capletId === capletId); - const useWhen = entries.find((entry) => entry.useWhen)?.useWhen; - const avoidWhen = entries.find((entry) => entry.avoidWhen)?.avoidWhen; const shadowing = entries.some((entry) => entry.shadowing === "forbid") ? "forbid" : entries.some((entry) => entry.shadowing === "namespace") ? "namespace" : "allow"; return { - ...(useWhen ? { useWhen } : {}), - ...(avoidWhen ? { avoidWhen } : {}), shadowing, }; } @@ -711,8 +701,6 @@ function codeModeRunNativeTool(capletTools: NativeCapletTool[]): NativeCapletToo name: tool.title, description: tool.description, ...(tool.shadowing ? { shadowing: tool.shadowing } : {}), - ...(tool.useWhen ? { useWhen: tool.useWhen } : {}), - ...(tool.avoidWhen ? { avoidWhen: tool.avoidWhen } : {}), })); const declaration = generateCodeModeDeclarations({ caplets: codeModeCaplets, @@ -756,8 +744,6 @@ function codeModeCallableNativeTools( title: caplet.name, description: caplet.description, ...(caplet.shadowing ? { shadowing: caplet.shadowing } : {}), - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), promptGuidance: tool?.promptGuidance ?? [], }; }); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 47655bea..5bb87d10 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -14,8 +14,6 @@ export type CapletServerSummary = { id: string; name: string; description: string; - useWhen?: string; - avoidWhen?: string; disabled?: boolean; status: ServerStatus; lastError?: SafeErrorSummary; @@ -25,8 +23,6 @@ export type CapletServerDetail = { id: string; name: string; description: string; - useWhen?: string; - avoidWhen?: string; tags?: string[]; backend: | { @@ -132,8 +128,6 @@ export class ServerRegistry { id: server.server, name: server.name, description: server.description, - ...(server.useWhen ? { useWhen: server.useWhen } : {}), - ...(server.avoidWhen ? { avoidWhen: server.avoidWhen } : {}), ...(server.disabled ? { disabled: true } : {}), status: status?.status ?? (server.disabled ? "disabled" : "not_started"), ...(status?.lastError ? { lastError: status.lastError } : {}), @@ -146,8 +140,6 @@ export class ServerRegistry { id: server.server, name: server.name, description: server.description, - ...(server.useWhen ? { useWhen: server.useWhen } : {}), - ...(server.avoidWhen ? { avoidWhen: server.avoidWhen } : {}), ...(server.tags ? { tags: server.tags } : {}), backend, }; diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts index c3ad1e19..356aaa39 100644 --- a/packages/core/src/serve/session.ts +++ b/packages/core/src/serve/session.ts @@ -594,8 +594,6 @@ function codeModeRunToolDescription(caplets: ExposureProjectionCodeModeCaplet[]) id: entry.id, name: entry.title ?? entry.id, description: entry.description ?? "", - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), })), }); return generateCodeModeRunToolDescription(declaration); @@ -623,8 +621,6 @@ class EngineNativeCapletsService implements NativeCapletsService { title: entry.title ?? entry.id, description: entry.description ?? "", ...(entry.shadowing ? { shadowing: entry.shadowing } : {}), - ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), - ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), promptGuidance: [], })); } diff --git a/packages/core/src/setup/hash.ts b/packages/core/src/setup/hash.ts index 7f47d42e..30168a77 100644 --- a/packages/core/src/setup/hash.ts +++ b/packages/core/src/setup/hash.ts @@ -1,50 +1,13 @@ -import { createHash } from "node:crypto"; -import type { CapletConfig } from "../config"; - -export function capletSetupContentHash(caplet: CapletConfig): string { - return createHash("sha256") - .update(stableJson(stableCapletForHash(caplet))) - .digest("hex"); -} - -export function stableJson(value: unknown): string { - return JSON.stringify(sortJson(value)); -} - -function stableCapletForHash(caplet: CapletConfig): Record { - return { - server: caplet.server, - name: caplet.name, - description: caplet.description, - backend: caplet.backend, - tags: caplet.tags, - body: caplet.body, - setup: caplet.setup, - backendConfig: Object.fromEntries( - Object.entries(caplet).filter( - ([key]) => - ![ - "server", - "name", - "description", - "backend", - "tags", - "body", - "setup", - "disabled", - ].includes(key), - ), - ), - }; -} - -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortJson); - if (!value || typeof value !== "object") return value; - return Object.fromEntries( - Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, sortJson(entry)]), - ); +import type { CapletRuntimeFingerprint } from "../caplet-source/runtime-fingerprint"; + +export const LIVE_ONLY_SETUP_CONTENT_HASH = "live-only"; + +export function capletSetupContentHash( + runtimeFingerprint: + | Pick + | undefined, +): string { + return runtimeFingerprint?.valid === true && runtimeFingerprint.persistenceEligible === true + ? runtimeFingerprint.fingerprint + : LIVE_ONLY_SETUP_CONTENT_HASH; } diff --git a/packages/core/src/setup/types.ts b/packages/core/src/setup/types.ts index f59233bf..50aabb42 100644 --- a/packages/core/src/setup/types.ts +++ b/packages/core/src/setup/types.ts @@ -51,6 +51,7 @@ export type SetupPlan = { targetKind: SetupTargetKind; setup: CapletSetupConfig; approved: boolean; + persistenceEligible: boolean; commands: CapletSetupCommandConfig[]; verify: CapletSetupCommandConfig[]; }; diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index 0cec9b01..8b176dae 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -347,8 +347,6 @@ describe("Attach API dispatch", () => { id: "filesystem", name: "Filesystem", description: "Filesystem.", - useWhen: "Use for repository files.", - avoidWhen: "Avoid for network calls.", shadowing: "allow", }, ], @@ -369,8 +367,6 @@ describe("Attach API dispatch", () => { kind: "caplet", name: "Filesystem", capletId: "filesystem", - useWhen: "Use for repository files.", - avoidWhen: "Avoid for network calls.", shadowing: "allow", }), ]); diff --git a/packages/core/test/backend-operation-dispatch.test.ts b/packages/core/test/backend-operation-dispatch.test.ts index aec91bb7..3a0bf01d 100644 --- a/packages/core/test/backend-operation-dispatch.test.ts +++ b/packages/core/test/backend-operation-dispatch.test.ts @@ -10,6 +10,7 @@ import { type BackendOperationManagers, type McpOperationAdapter, } from "../src/index"; +import { loadCapletFilesFromMap } from "../src/caplet-files"; import { DownstreamManager } from "../src/downstream"; import type { CapletConfig } from "../src/config"; import { testBackendOperationRuntime } from "./backend-operation-runtime"; @@ -69,6 +70,39 @@ describe("backend operation dispatch", () => { }, ); + it("dispatches body-free Caplet file configurations to backend managers", async () => { + const loaded = loadCapletFilesFromMap({ + files: [ + { + path: "operator/CAPLET.md", + content: `--- +name: Operator +description: Exercise backend manager isolation. +mcpServer: + command: operator-mcp +--- +# README_SENTINEL +backend: fake +path: ../../secret +token: sk-secret-looking +`, + }, + ], + }); + const config = parseConfig(loaded!.config); + const server = config.mcpServers.operator!; + const managers = managerBundle(); + const runtime = createBackendOperationRuntime(managers as unknown as BackendOperationManagers); + + await runtime.operations.check(server); + await runtime.operations.listTools(server); + await runtime.operations.callTool(server, "echo", {}); + + expect(server).not.toHaveProperty("body"); + expect(managers.mcp.checkServer!.mock.calls[0]?.[0]).not.toHaveProperty("body"); + expect(managers.mcp.listTools.mock.calls[0]?.[0]).not.toHaveProperty("body"); + expect(managers.mcp.callTool.mock.calls[0]?.[0]).not.toHaveProperty("body"); + }); it("retains the exact MCP manager as the separately named MCP capability", () => { const managers = managerBundle(); const runtime = createBackendOperationRuntime(managers as unknown as BackendOperationManagers); @@ -243,10 +277,10 @@ function managerBundle() { function manager(source: string, checkMethod: string) { return { - [checkMethod]: vi.fn(async () => ({ source })), - listTools: vi.fn(async () => [TOOL]), - getTool: vi.fn(async () => TOOL), - callTool: vi.fn(async () => ({ source })), + [checkMethod]: vi.fn(async (..._args: unknown[]) => ({ source })), + listTools: vi.fn(async (..._args: unknown[]) => [TOOL]), + getTool: vi.fn(async (..._args: unknown[]) => TOOL), + callTool: vi.fn(async (..._args: unknown[]) => ({ source })), compact: vi.fn(() => ({ name: TOOL.name })), search: vi.fn(() => [{ name: TOOL.name }]), }; diff --git a/packages/core/test/caplet-files.test.ts b/packages/core/test/caplet-files.test.ts index 95cc5b55..8af6953e 100644 --- a/packages/core/test/caplet-files.test.ts +++ b/packages/core/test/caplet-files.test.ts @@ -24,14 +24,15 @@ openapiEndpoint: }); expect(result?.paths).toEqual({ pypi: "pypi/CAPLET.md" }); - expect(result?.config.openapiEndpoints?.pypi).toEqual( + const config = result?.config.openapiEndpoints?.pypi; + expect(config).toEqual( expect.objectContaining({ name: "PyPI", description: "Query Python package metadata.", specPath: "pypi/openapi.yaml", - body: "\n# PyPI\n", }), ); + expect(config).not.toHaveProperty("body"); }); it("loads top-level exposure from CAPLET.md frontmatter", () => { @@ -243,7 +244,8 @@ googleDiscoveryApis: backend: "googleDiscovery", }, }); - expect(result?.config.googleDiscoveryApis?.["google-workspace__drive"]).toMatchObject({ + const drive = result?.config.googleDiscoveryApis?.["google-workspace__drive"]; + expect(drive).toMatchObject({ name: "Google Drive", description: "Search and inspect Google Drive files.", tags: ["google", "workspace", "drive"], @@ -261,8 +263,8 @@ googleDiscoveryApis: commands: [{ label: "Parent setup", command: "parent-setup" }], verify: [{ label: "Verify Drive", command: "drive-verify" }], }, - body: "\n# Google Workspace\n", }); + expect(drive).not.toHaveProperty("body"); expect(result?.config.googleDiscoveryApis?.["google-workspace__gmail"]).toMatchObject({ name: "Gmail", discoveryPath: "google-workspace/gmail.discovery.json", diff --git a/packages/core/test/caplet-source.test.ts b/packages/core/test/caplet-source.test.ts index 2aeb9c2a..243ffc30 100644 --- a/packages/core/test/caplet-source.test.ts +++ b/packages/core/test/caplet-source.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -118,6 +118,22 @@ describe("CapletSource adapters", () => { await expect(source.readFile("../outside.yaml")).resolves.toBeUndefined(); }); + it("derives declared inputs from listed files for custom sources without a reader", async () => { + const files = await new BundleCapletSource(fixtureFiles).listFiles(); + const source = { + listFiles: async () => files, + readFile: async (path: string) => files.find((file) => file.path === path), + }; + + const parsed = await parseCapletSource(source); + + expect(parsed.ok).toBe(true); + expect(parsed.runtimeFingerprint?.valid).toBe(true); + expect(parsed.runtimeFingerprint?.caplets.weather?.declaredInputs).toEqual([ + expect.objectContaining({ logicalPath: "weather/openapi.yaml", state: "present" }), + ]); + }); + it("list and read normalized relative files for filesystems", async () => { const root = writeFixtureTree(); const source = new FilesystemCapletSource(root); @@ -137,6 +153,35 @@ describe("CapletSource adapters", () => { await expect(source.readFile("/absolute.js")).resolves.toBeUndefined(); }); + it("does not replace source paths with directory-symlink aliases", async () => { + const root = mkdtempSync(join(tmpdir(), "caplets-source-symlink-")); + tempDirs.push(root); + mkdirSync(join(root, "sourcegraph"), { recursive: true }); + mkdirSync(join(root, "toolkit", "caplets"), { recursive: true }); + writeFileSync( + join(root, "sourcegraph", "CAPLET.md"), + "---\nname: Sourcegraph\ndescription: Search source code.\nmcpServer:\n url: https://example.com/mcp\n---\n", + ); + writeFileSync( + join(root, "toolkit", "CAPLET.md"), + "---\nname: Toolkit\ndescription: Group source tools.\ncapletSet:\n capletsRoot: ./caplets\n---\n", + ); + symlinkSync(join(root, "sourcegraph"), join(root, "toolkit", "caplets", "sourcegraph"), "dir"); + + const source = new FilesystemCapletSource(root); + + await expect(source.listFiles()).resolves.toEqual([ + expect.objectContaining({ path: "sourcegraph/CAPLET.md" }), + expect.objectContaining({ path: "toolkit/CAPLET.md" }), + ]); + expect(source.declaredInputReader().list("toolkit/caplets")).toEqual( + expect.objectContaining({ + state: "present", + paths: ["toolkit/caplets/sourcegraph/CAPLET.md"], + }), + ); + }); + it("parses equivalent bundle and filesystem multi-file Caplets identically", async () => { const bundle = await parseCapletSource(new BundleCapletSource(fixtureFiles)); const filesystem = await parseCapletSource(new FilesystemCapletSource(writeFixtureTree())); diff --git a/packages/core/test/caplets-lockfile.test.ts b/packages/core/test/caplets-lockfile.test.ts index 3f57c9e9..cc90f8e9 100644 --- a/packages/core/test/caplets-lockfile.test.ts +++ b/packages/core/test/caplets-lockfile.test.ts @@ -138,6 +138,41 @@ describe("caplets lockfile", () => { } }); + it("round-trips optional v1 runtime fingerprints and rejects malformed present state", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-lockfile-runtime-fingerprint-")); + const lockPath = join(dir, "caplets.lock.json"); + try { + const persisted = lockEntry({ + runtimeFingerprint: { + version: 1, + artifactFingerprint: "sha256:runtime", + }, + }); + writeCapletsLockfile(lockPath, { version: 1, entries: [persisted] }); + expect(readCapletsLockfile(lockPath)).toMatchObject({ version: 1, entries: [persisted] }); + + for (const runtimeFingerprint of [ + null, + {}, + { version: 2, artifactFingerprint: "sha256:runtime" }, + { version: 1, artifactFingerprint: "" }, + ]) { + writeFileSync( + lockPath, + `${JSON.stringify({ + version: 1, + entries: [{ ...lockEntry(), runtimeFingerprint }], + })}\n`, + ); + expect(() => readCapletsLockfile(lockPath)).toThrow( + expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("reports missing lockfiles as not found instead of invalid JSON", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-lockfile-missing-")); const lockPath = join(dir, "caplets.lock.json"); diff --git a/packages/core/test/catalog-model.test.ts b/packages/core/test/catalog-model.test.ts index 570ef59f..54554afc 100644 --- a/packages/core/test/catalog-model.test.ts +++ b/packages/core/test/catalog-model.test.ts @@ -106,7 +106,7 @@ describe("catalog model", () => { expect(first).toBe(second); }); - it("derives readiness, workflow, task, warnings, and low-count display from existing data", () => { + it("derives readiness, workflow, warnings, and low-count display from existing data", () => { const source = normalizeCatalogSourceIdentity("community/tools"); if (!source.eligible) throw new Error("expected source to be eligible"); @@ -119,7 +119,6 @@ describe("catalog model", () => { trustLevel: "community", contentMarkdown: "# Deploy\n\nUse this to deploy.", tags: ["Deploy", "deploy", "ci"], - useWhen: "Deploy the current project.", setupRequired: true, authRequired: true, projectBindingRequired: true, @@ -136,7 +135,6 @@ describe("catalog model", () => { expect(entry.projectBindingReadiness).toBe("required"); expect(entry.workflow).toEqual({ kind: "code_mode", label: "Code Mode" }); expect(entry.children ?? []).toEqual([]); - expect(entry.intendedTask).toBe("Deploy the current project."); expect(entry.installCommand).toEqual({ text: "caplets install community/tools deploy", copyable: false, diff --git a/packages/core/test/cli-install-output.test.ts b/packages/core/test/cli-install-output.test.ts new file mode 100644 index 00000000..06b5caf6 --- /dev/null +++ b/packages/core/test/cli-install-output.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type * as InstallModule from "../src/cli/install"; + +const mocks = vi.hoisted(() => ({ + restore: vi.fn(), +})); + +vi.mock("../src/cli/install", async (importOriginal) => ({ + ...(await importOriginal()), + restoreCapletsFromLockfile: mocks.restore, +})); + +import { runCli } from "../src/cli"; + +const dirs: string[] = []; + +afterEach(() => { + mocks.restore.mockReset(); + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("install human output", () => { + it("labels content-only local restores as content updated", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-install-output-")); + dirs.push(dir); + const destination = join(dir, ".caplets", "github"); + const out: string[] = []; + mocks.restore.mockReturnValue({ + installed: [ + { + id: "github", + destination, + status: "content_updated", + }, + ], + }); + + await runCli(["install", "github"], { + env: { + CAPLETS_CONFIG: join(dir, "missing-config.json"), + CAPLETS_PROJECT_CONFIG: join(dir, ".caplets", "missing-config.json"), + CAPLETS_DISABLE_CATALOG_INDEXING: "1", + }, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toBe(`Content updated github to ${destination}\n`); + }); +}); diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index 06fa23fb..4637272f 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -1287,6 +1287,36 @@ describe("remote CLI routing", () => { ); }); + it("labels content-only remote installs as content updated", async () => { + const context = testContext("caplets-cli-remote-install-content-"); + const out: string[] = []; + const fetchMock = vi.fn(async () => + Response.json({ + ok: true, + result: { + installed: [ + { + id: "github", + destination: "/srv/caplets/.caplets/github", + status: "content_updated", + }, + ], + }, + }), + ); + + await runCli(["install", "spiritledsoftware/caplets", "github", "--remote"], { + env: { + ...remoteEnv(context), + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", + }, + fetch: fetchMock as typeof fetch, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toBe("Content updated github to remote /srv/caplets/.caplets/github\n"); + }); + it("creates the project config by default in remote mode", async () => { const context = testContext("caplets-cli-remote-init-project-"); const out: string[] = []; diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 24e30db4..e39ef334 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -22,6 +22,7 @@ import { readHiddenInputForTest, runCli, } from "../src/cli"; +import { updateCapletsFromLockfile, type CapletTransactionPhase } from "../src/cli/install"; import { loadConfig, parseConfig, type VaultQuarantineOutcome } from "../src/config"; import * as configModule from "../src/config"; import type { CapletsError } from "../src/errors"; @@ -2531,6 +2532,8 @@ describe("cli init", () => { process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: () => {} }); + const beforeRestore = readCapletsLockfile(join(projectRoot, ".caplets.lock.json")).entries[0]; + expect(beforeRestore?.runtimeFingerprint).toBeUndefined(); rmSync(join(projectRoot, ".caplets", "github"), { recursive: true, force: true }); await runCli(["install", "--json"], { writeOut: (value) => out.push(value) }); @@ -2545,6 +2548,13 @@ describe("cli init", () => { }), ], }); + const afterRestore = readCapletsLockfile(join(projectRoot, ".caplets.lock.json")).entries[0]; + expect(afterRestore?.installedAt).toBe(beforeRestore?.installedAt); + expect(afterRestore?.updatedAt).not.toBe(beforeRestore?.updatedAt); + expect(afterRestore?.runtimeFingerprint).toEqual({ + version: 1, + artifactFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -2627,10 +2637,12 @@ describe("cli init", () => { await runCli(["install", repo], { writeOut: () => {} }); const lockfilePath = join(projectRoot, ".caplets.lock.json"); const lockfile = JSON.parse(readFileSync(lockfilePath, "utf8")); - for (const entry of lockfile.entries) { - entry.installedHash = "sha256:stale"; - } - writeFileSync(lockfilePath, `${JSON.stringify(lockfile, null, 2)}\n`); + const initialHashes = Object.fromEntries( + lockfile.entries.map((entry: { id: string; installedHash: string }) => [ + entry.id, + entry.installedHash, + ]), + ); rmSync(join(projectRoot, ".caplets", "github"), { recursive: true, force: true }); rmSync(join(projectRoot, ".caplets", "filesystem.md"), { force: true }); @@ -2641,11 +2653,13 @@ describe("cli init", () => { expect.arrayContaining([ expect.objectContaining({ id: "filesystem", - installedHash: expect.stringMatching(/^sha256:(?!stale$)/), + installedHash: initialHashes.filesystem, + runtimeFingerprint: expect.objectContaining({ version: 1 }), }), expect.objectContaining({ id: "github", - installedHash: expect.stringMatching(/^sha256:(?!stale$)/), + installedHash: initialHashes.github, + runtimeFingerprint: expect.objectContaining({ version: 1 }), }), ]), ); @@ -2655,7 +2669,7 @@ describe("cli init", () => { } }); - it("persists restored lockfile hash changes before a later restore fails", async () => { + it("persists an earlier restore before a later restore fails", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-restore-partial-")); const repo = join(dir, "repo"); const projectRoot = join(dir, "project"); @@ -2675,7 +2689,7 @@ describe("cli init", () => { (entry: { id: string }) => entry.id === "filesystem", ); const github = lockfile.entries.find((entry: { id: string }) => entry.id === "github"); - filesystem.installedHash = "sha256:stale"; + const filesystemHash = filesystem.installedHash; github.source.path = join(repo, "caplets", "missing-github"); writeFileSync(lockfilePath, `${JSON.stringify(lockfile, null, 2)}\n`); rmSync(join(projectRoot, ".caplets", "filesystem.md"), { force: true }); @@ -2689,8 +2703,43 @@ describe("cli init", () => { const restoredFilesystem = restored.entries.find( (entry: { id: string }) => entry.id === "filesystem", ); - expect(restoredFilesystem.installedHash).toMatch(/^sha256:/); - expect(restoredFilesystem.installedHash).not.toBe("sha256:stale"); + expect(restoredFilesystem.installedHash).toBe(filesystemHash); + expect(restoredFilesystem.runtimeFingerprint).toEqual({ + version: 1, + artifactFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects explicitly selected top-level file Caplet symlinks that escape the source", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-source-file-symlink-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const outside = join(dir, "outside.md"); + const cwd = process.cwd(); + try { + writeInstallableRepo(repo); + const sourcePath = join(repo, "caplets", "filesystem.md"); + const outsideContent = readFileSync(sourcePath, "utf8"); + writeFileSync(outside, outsideContent); + rmSync(sourcePath); + symlinkSync(outside, sourcePath); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await expect( + runCli(["install", repo, "filesystem"], { writeOut: () => {} }), + ).rejects.toMatchObject({ + code: "CONFIG_INVALID", + message: expect.stringContaining("must not be a symbolic link"), + } satisfies Partial); + + expect(readFileSync(outside, "utf8")).toBe(outsideContent); + expect(existsSync(join(projectRoot, ".caplets", "filesystem.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".caplets.lock.json"))).toBe(false); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -2820,6 +2869,71 @@ describe("cli init", () => { } }); + it("relays content-updated status through remote update text output", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-remote-content-")); + const authDir = join(dir, "auth"); + const requests: unknown[] = []; + const out: string[] = []; + const fetchMock = vi.fn( + async (_url: Parameters[0], init?: Parameters[1]) => { + requests.push(JSON.parse(String(init?.body))); + return Response.json({ + ok: true, + result: { + installed: [ + { + id: "github", + destination: "/remote/global/github", + status: "content_updated", + }, + ], + }, + }); + }, + ); + try { + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: "https://remote.caplets.test/caplets", + clientId: "rcli_update_test", + clientLabel: "Remote Update Test", + credentials: { + accessToken: "remote-profile-access-token", + refreshToken: "remote-profile-refresh-token", + tokenType: "Bearer", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + }); + + await runCli(["update", "github", "--remote"], { + env: { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://remote.caplets.test/caplets", + CAPLETS_CONFIG: join(dir, "missing-user-config.json"), + CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), + }, + authDir, + fetch: fetchMock, + writeOut: (value) => out.push(value), + }); + + expect(requests).toEqual([ + { + command: "update", + arguments: { + capletIds: ["github"], + force: false, + allowRiskIncrease: false, + }, + }, + ]); + expect(out.join("")).toContain("Content updated github at remote /remote/global/github"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("rejects project-scoped remote catalog lifecycle targets", async () => { await expect( runCli(["install", "--project", "--remote"], { writeOut: () => {} }), @@ -2836,6 +2950,50 @@ describe("cli init", () => { } satisfies Partial); }); + it("rejects locked-source drift during restore even with force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-restore-source-drift-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const cwd = process.cwd(); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["install", repo, "github"], { writeOut: () => {} }); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + const destination = join(projectRoot, ".caplets", "github"); + const destinationReadme = join(destination, "README.md"); + const lockedState = readFileSync(lockfilePath, "utf8"); + const installedReadme = readFileSync(destinationReadme, "utf8"); + writeFileSync(join(repo, "caplets", "github", "README.md"), "changed locked source\n"); + + await expect(runCli(["install"], { writeOut: () => {} })).rejects.toMatchObject({ + code: "CONFIG_INVALID", + message: "Locked source for github has changed; run caplets update to accept it", + } satisfies Partial); + expect(readFileSync(destinationReadme, "utf8")).toBe(installedReadme); + expect(readFileSync(lockfilePath, "utf8")).toBe(lockedState); + expect( + readdirSync(join(projectRoot, ".caplets")).some((name) => name.includes("caplet-txn")), + ).toBe(false); + + writeFileSync(destinationReadme, "local edit\n"); + await expect(runCli(["install", "--force"], { writeOut: () => {} })).rejects.toMatchObject({ + code: "CONFIG_INVALID", + message: "Locked source for github has changed; run caplets update to accept it", + } satisfies Partial); + expect(readFileSync(destinationReadme, "utf8")).toBe("local edit\n"); + expect(readFileSync(lockfilePath, "utf8")).toBe(lockedState); + expect( + readdirSync(join(projectRoot, ".caplets")).some((name) => name.includes("caplet-txn")), + ).toBe(false); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + it("refuses to restore over local modifications without force", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-restore-conflict-")); const repo = join(dir, "repo"); @@ -2875,29 +3033,484 @@ describe("cli init", () => { process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: () => {} }); - writeFileSync(join(repo, "caplets", "github", "README.md"), "updated upstream\n"); - + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + const initialEntry = readCapletsLockfile(lockfilePath).entries[0]!; + const sourceCapletPath = join(repo, "caplets", "github", "CAPLET.md"); + writeFileSync( + sourceCapletPath, + readFileSync(sourceCapletPath, "utf8").replace( + "# GitHub", + "# GitHub\n\nUpdated operator README.", + ), + ); await runCli(["update", "github", "--json"], { writeOut: (value) => out.push(value) }); - expect(readFileSync(join(projectRoot, ".caplets", "github", "README.md"), "utf8")).toBe( - "updated upstream\n", + expect(readFileSync(join(projectRoot, ".caplets", "github", "CAPLET.md"), "utf8")).toContain( + "Updated operator README.", ); - expect(JSON.parse(out.join(""))).toMatchObject({ + const updateResult = JSON.parse(out.join("")); + expect(updateResult).toMatchObject({ entries: [ { id: "github", - status: "updated", + status: "content_updated", destination: join(projectRoot, ".caplets", "github"), lockfile: join(projectRoot, ".caplets.lock.json"), hash: expect.stringMatching(/^sha256:/), }, ], }); + expect(updateResult.entries[0]?.hash).not.toBe(initialEntry.installedHash); + const updatedLock = readCapletsLockfile(lockfilePath); + expect(updatedLock.version).toBe(1); + expect(updatedLock.entries[0]?.installedHash).not.toBe(initialEntry.installedHash); + expect(updatedLock.entries[0]?.runtimeFingerprint).toEqual({ + version: 1, + artifactFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + writeFileSync( + sourceCapletPath, + readFileSync(sourceCapletPath, "utf8").replace( + "Updated operator README.", + "Another operator README update.", + ), + ); + out.length = 0; + await runCli(["update", "github"], { writeOut: (value) => out.push(value) }); + expect(out.join("")).toContain("Content updated github"); + const finalLock = readCapletsLockfile(lockfilePath); + expect(finalLock.entries[0]?.installedHash).not.toBe(updatedLock.entries[0]?.installedHash); + expect(finalLock.entries[0]?.runtimeFingerprint).toEqual( + updatedLock.entries[0]?.runtimeFingerprint, + ); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); } }); + it("live-classifies content updates without persisting literal secret correlates", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-live-only-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + const literalSecret = "literal-secret-that-must-not-persist"; + try { + writeInstallableRepo(repo); + const capletPath = join(repo, "caplets", "github", "CAPLET.md"); + writeFileSync( + capletPath, + readFileSync(capletPath, "utf8").replace( + " command: npx", + ` command: npx\n env:\n TOKEN: ${literalSecret}`, + ), + ); + installCaplets(repo, { capletIds: ["github"], destinationRoot, lockfilePath }); + writeFileSync(join(repo, "caplets", "github", "README.md"), "live-only content update\n"); + + const result = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }); + + expect(result.installed[0]?.status).toBe("content_updated"); + const lockText = readFileSync(lockfilePath, "utf8"); + expect(lockText).not.toContain("runtimeFingerprint"); + expect(lockText).not.toContain(literalSecret); + expect(JSON.stringify(result)).not.toContain(literalSecret); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("classifies content-only updates from the installed artifact instead of stale lock metadata", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-stale-runtime-lock-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + const staleLock = JSON.parse(readFileSync(lockfilePath, "utf8")) as { + entries: Array<{ runtimeFingerprint?: { version: 1; artifactFingerprint: string } }>; + }; + staleLock.entries[0]!.runtimeFingerprint = { + version: 1, + artifactFingerprint: "0".repeat(64), + }; + writeFileSync(lockfilePath, `${JSON.stringify(staleLock, null, 2)}\n`); + writeFileSync(join(repo, "caplets", "github", "README.md"), "new operator README\n"); + + const result = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }); + + expect(result.installed[0]?.status).toBe("content_updated"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("force-updates an installed directory with a missing CAPLET.md as a runtime update", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-missing-installed-caplet-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + const installedCapletPath = join(destinationRoot, "github", "CAPLET.md"); + rmSync(installedCapletPath); + writeFileSync(join(repo, "caplets", "github", "README.md"), "new upstream content\n"); + + const result = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + force: true, + }); + + expect(result.installed[0]?.status).toBe("updated"); + expect(readFileSync(installedCapletPath, "utf8")).toContain("name: GitHub"); + expect(readFileSync(join(destinationRoot, "github", "README.md"), "utf8")).toBe( + "new upstream content\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rolls back artifact bytes when the lock replacement fails synchronously", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-lock-rollback-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + const destination = join(destinationRoot, "github", "README.md"); + const beforeArtifact = readFileSync(destination, "utf8"); + const beforeLock = readFileSync(lockfilePath, "utf8"); + writeFileSync(join(repo, "caplets", "github", "README.md"), "new upstream content\n"); + + expect(() => + updateCapletsFromLockfile( + { destinationRoot, lockfilePath, capletIds: ["github"] }, + { + replaceLockfileTemporary: () => { + throw new Error("injected lock failure"); + }, + }, + ), + ).toThrow(expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError); + expect(readFileSync(destination, "utf8")).toBe(beforeArtifact); + expect(readFileSync(lockfilePath, "utf8")).toBe(beforeLock); + writeFileSync(join(repo, "caplets", "github", "CAPLET.md"), "not valid frontmatter\n"); + expect(() => + updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + force: true, + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError); + expect(readFileSync(destination, "utf8")).toBe(beforeArtifact); + expect(readFileSync(lockfilePath, "utf8")).toBe(beforeLock); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("keeps the committed artifact and lock when post-commit cleanup fails", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-cleanup-failure-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + writeFileSync(join(repo, "caplets", "github", "README.md"), "committed content\n"); + + const updated = updateCapletsFromLockfile( + { destinationRoot, lockfilePath, capletIds: ["github"] }, + { + cleanupTransaction: () => { + throw new Error("injected cleanup failure"); + }, + }, + ); + + expect(updated.installed[0]?.status).toBe("content_updated"); + expect(readFileSync(join(destinationRoot, "github", "README.md"), "utf8")).toBe( + "committed content\n", + ); + expect(readCapletsLockfile(lockfilePath).entries[0]?.installedHash).toBe( + updated.installed[0]?.hash, + ); + expect(readdirSync(projectRoot).some((name) => name.includes(".caplet-txn-github"))).toBe( + true, + ); + + const recovered = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }); + expect(recovered.installed[0]?.status).toBe("noop"); + expect(readdirSync(projectRoot).filter((name) => name.includes(".caplet-txn-"))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("recovers every persisted per-entry transaction phase before drift classification", () => { + const phases: CapletTransactionPhase[] = [ + "prepared", + "staged", + "backup_created", + "destination_replaced", + "lock_temporary_written", + "lock_replaced", + ]; + for (const phase of phases) { + const dir = mkdtempSync(join(tmpdir(), `caplets-update-interrupt-${phase}-`)); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + writeFileSync(join(repo, "caplets", "github", "README.md"), `content after ${phase}\n`); + + expect(() => + updateCapletsFromLockfile( + { destinationRoot, lockfilePath, capletIds: ["github"] }, + { + onTransactionPhase: (persisted) => { + if (persisted === "lock_temporary_written") { + const lockTemporaries = readdirSync(projectRoot).filter((name) => + name.endsWith(".caplet-txn-github.lock.tmp"), + ); + expect(lockTemporaries).toHaveLength(1); + expect( + JSON.parse(readFileSync(join(projectRoot, lockTemporaries[0]!), "utf8")), + ).toMatchObject({ entries: [{ id: "github" }] }); + } + return persisted === phase ? "interrupt" : undefined; + }, + }, + ), + ).toThrow(`Interrupted after ${phase}`); + + const recovered = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }); + expect(["content_updated", "noop"]).toContain(recovered.installed[0]?.status); + expect(readFileSync(join(destinationRoot, "github", "README.md"), "utf8")).toBe( + `content after ${phase}\n`, + ); + const entry = readCapletsLockfile(lockfilePath).entries[0]; + expect(entry?.installedHash).toBe(recovered.installed[0]?.hash); + expect(readdirSync(projectRoot).filter((name) => name.includes(".caplet-txn-"))).toEqual( + [], + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + it("rejects overlapping writers for the same lockfile without disturbing the active update", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-concurrent-lock-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + writeFileSync(join(repo, "caplets", "github", "README.md"), "concurrent content\n"); + + const updated = updateCapletsFromLockfile( + { destinationRoot, lockfilePath, capletIds: ["github"] }, + { + onTransactionPhase: (phase) => { + if (phase !== "prepared") return; + expect(() => + updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + }, + }, + ); + + expect(updated.installed[0]?.status).toBe("content_updated"); + expect(readCapletsLockfile(lockfilePath).entries[0]?.installedHash).toBe( + updated.installed[0]?.hash, + ); + expect(existsSync(`${lockfilePath}.transaction.lock`)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses one transaction path set for staging and commit", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-transaction-paths-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, "custom-caplets.lock.json"); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + writeFileSync(join(repo, "caplets", "github", "README.md"), "path agreement content\n"); + const stagedPath = join(destinationRoot, ".github.caplet-txn-github.stage"); + const journalPath = join(projectRoot, ".custom-caplets.lock.json.caplet-txn-github.json"); + + const updated = updateCapletsFromLockfile( + { destinationRoot, lockfilePath, capletIds: ["github"] }, + { + onTransactionPhase: (phase) => { + if (phase !== "prepared") return; + expect(existsSync(stagedPath)).toBe(true); + expect(existsSync(journalPath)).toBe(true); + }, + }, + ); + + expect(updated.installed[0]?.status).toBe("content_updated"); + expect(readFileSync(join(destinationRoot, "github", "README.md"), "utf8")).toBe( + "path agreement content\n", + ); + expect(existsSync(stagedPath)).toBe(false); + expect(existsSync(journalPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("recovers a transaction lock owned by a dead process", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-dead-lock-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + const transactionLockPath = `${lockfilePath}.transaction.lock`; + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + const deadPid = Number( + execFileSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"], { + encoding: "utf8", + }), + ); + expect(() => process.kill(deadPid, 0)).toThrow(); + writeFileSync( + transactionLockPath, + `${JSON.stringify({ pid: deadPid, acquiredAt: "2026-01-01T00:00:00.000Z" })}\n`, + ); + writeFileSync(join(repo, "caplets", "github", "README.md"), "recovered content\n"); + + const updated = updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }); + + expect(updated.installed[0]?.status).toBe("content_updated"); + expect(existsSync(transactionLockPath)).toBe(false); + expect(existsSync(`${transactionLockPath}.recovery`)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a transaction lock owned by a live process without removing it", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-update-live-lock-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const destinationRoot = join(projectRoot, ".caplets"); + const lockfilePath = join(projectRoot, ".caplets.lock.json"); + const transactionLockPath = `${lockfilePath}.transaction.lock`; + const lockContents = `${JSON.stringify({ + pid: process.pid, + acquiredAt: "2026-01-01T00:00:00.000Z", + ownerToken: "external-live-owner", + })}\n`; + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + installCaplets(repo, { + capletIds: ["github"], + destinationRoot, + lockfilePath, + }); + writeFileSync(transactionLockPath, lockContents); + + expect(() => + updateCapletsFromLockfile({ + destinationRoot, + lockfilePath, + capletIds: ["github"], + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(readFileSync(transactionLockPath, "utf8")).toBe(lockContents); + expect(existsSync(`${transactionLockPath}.recovery`)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); it("reports symlink-materialized directory Caplets as current when unchanged", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-update-symlink-noop-")); @@ -3243,6 +3856,11 @@ describe("cli init", () => { expect(readFileSync(join(projectRoot, ".caplets", "filesystem.md"), "utf8")).toContain( "Updated Files", ); + const retryOut: string[] = []; + await runCli(["update", "filesystem", "--json"], { + writeOut: (value) => retryOut.push(value), + }); + expect(JSON.parse(retryOut.join("")).entries[0].status).toBe("noop"); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -3323,7 +3941,6 @@ describe("cli init", () => { "description: Work with GitHub repositories and pull requests.", "projectBinding:", " required: true", - " description: Requires the current repository to resolve issue references.", "mcpServer:", " command: npx", " args:", @@ -3337,8 +3954,12 @@ describe("cli init", () => { await expect(runCli(["update", "github"], { writeOut: () => {} })).rejects.toMatchObject({ code: "REQUEST_INVALID", } satisfies Partial); + const out: string[] = []; - await runCli(["update", "github", "--force"], { writeOut: () => {} }); + await runCli(["update", "github", "--force", "--json"], { + writeOut: (value) => out.push(value), + }); + expect(JSON.parse(out.join("")).entries[0].status).toBe("updated"); expect(readFileSync(join(projectRoot, ".caplets", "github", "CAPLET.md"), "utf8")).toContain( "projectBinding:", ); diff --git a/packages/core/test/code-mode-declarations.test.ts b/packages/core/test/code-mode-declarations.test.ts index 1608d776..0a42c042 100644 --- a/packages/core/test/code-mode-declarations.test.ts +++ b/packages/core/test/code-mode-declarations.test.ts @@ -26,8 +26,6 @@ describe("generateCodeModeDeclarations", () => { id: "github", name: "GitHub", description: "GitHub repo, issue, PR, workflow ops.", - useWhen: "Use for repository issue, PR, and workflow tasks.", - avoidWhen: "Avoid for package vulnerability lookup.", }, { id: "build-system", @@ -39,9 +37,7 @@ describe("generateCodeModeDeclarations", () => { expect(declaration).toContain('github:CapletHandle<"github">;'); expect(declaration).toContain('"build-system":CapletHandle<"build-system">;'); - expect(declaration).toContain( - "/**GitHub repo, issue, PR, workflow ops. Use when: Use for repository issue, PR, and workflow tasks. Avoid when: Avoid for package vulnerability lookup.*/", - ); + expect(declaration).toContain("/**GitHub repo, issue, PR, workflow ops.*/"); expect(declaration).not.toContain("\n\n"); expect(declaration).not.toContain(" = "); }); @@ -124,8 +120,8 @@ describe("generateCodeModeDeclarations", () => { ], }); - expect(declaration).toContain(`${"A".repeat(177)}...`); - expect(declaration).not.toContain("A".repeat(181)); + expect(declaration).toContain(`${"A".repeat(237)}...`); + expect(declaration).not.toContain("A".repeat(241)); }); it("returns stable hashes for equivalent declaration content", () => { diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index dc9e48ff..69462103 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -374,71 +374,41 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); - it("loads optional agent selection hints from JSON config", () => { - const config = parseConfig({ - mcpServers: { - docs: { - name: "Docs", - description: "Search and read product documentation.", - command: "node", - useWhen: "Use for product documentation questions.", - avoidWhen: "Avoid for source-code search.", - }, - }, - httpApis: { - osv: { - name: "OSV", - description: "Query vulnerability data from OSV.", - baseUrl: "https://api.osv.dev", - auth: { type: "none" }, - useWhen: "Use for package vulnerability lookups.", - actions: { - query_package_version: { - method: "POST", - path: "/v1/query", - useWhen: "Use when the task names one ecosystem, package, and version.", - avoidWhen: "Avoid for multi-package batch requests.", - }, + it.each([ + ["useWhen", "Use for product documentation questions."], + ["avoidWhen", "Avoid for source-code search."], + ])("rejects removed %s metadata from JSON config", (field, value) => { + expect(() => + parseConfig({ + mcpServers: { + docs: { + name: "Docs", + description: "Search and read product documentation.", + command: "node", + [field]: value, }, }, - }, - cliTools: { - repo: { - name: "Repo", - description: "Run repository inspection commands.", - useWhen: "Use for local repository state.", - actions: { - status: { - command: "git", - args: ["status", "--short"], - useWhen: "Use for a concise working-tree status.", + }), + ).toThrow(CapletsError); + expect(() => + parseConfig({ + httpApis: { + osv: { + name: "OSV", + description: "Query vulnerability data from OSV.", + baseUrl: "https://api.osv.dev", + auth: { type: "none" }, + actions: { + query_package_version: { + method: "POST", + path: "/v1/query", + [field]: value, + }, }, }, }, - }, - }); - - expect(config.mcpServers.docs).toMatchObject({ - useWhen: "Use for product documentation questions.", - avoidWhen: "Avoid for source-code search.", - }); - expect(config.httpApis.osv).toMatchObject({ - useWhen: "Use for package vulnerability lookups.", - actions: { - query_package_version: { - useWhen: "Use when the task names one ecosystem, package, and version.", - avoidWhen: "Avoid for multi-package batch requests.", - }, - }, - }); - expect(config.cliTools.repo).toMatchObject({ - useWhen: "Use for local repository state.", - actions: { - status: { - useWhen: "Use for a concise working-tree status.", - }, - }, - }); + }), + ).toThrow(CapletsError); }); it("loads project config when user config does not exist", () => { @@ -522,47 +492,35 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); - it("loads optional agent selection hints from CAPLET.md frontmatter", () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-hints-files-")); - writeFileSync( - join(dir, "osv.md"), - [ - "---", - "name: OSV", - "description: Query vulnerability data from OSV.", - "useWhen: Use for package vulnerability lookups.", - "avoidWhen: Avoid for license or maintainer lookups.", - "httpApi:", - " baseUrl: https://api.osv.dev", - " auth:", - " type: none", - " actions:", - " query_package_version:", - " method: POST", - " path: /v1/query", - " useWhen: Use when the task names one ecosystem, package, and version.", - " avoidWhen: Avoid for multi-package batch requests.", - "---", - "", - "# OSV", - "", - ].join("\n"), - ); - - const config = loadCapletFiles(dir); - - expect(config?.httpApis?.osv).toMatchObject({ - useWhen: "Use for package vulnerability lookups.", - avoidWhen: "Avoid for license or maintainer lookups.", - actions: { - query_package_version: { - useWhen: "Use when the task names one ecosystem, package, and version.", - avoidWhen: "Avoid for multi-package batch requests.", - }, - }, - }); - rmSync(dir, { recursive: true, force: true }); - }); + it.each(["useWhen", "avoidWhen"])( + "rejects removed %s metadata from CAPLET.md frontmatter", + (field) => { + const dir = mkdtempSync(join(tmpdir(), "caplets-hints-files-")); + writeFileSync( + join(dir, "osv.md"), + [ + "---", + "name: OSV", + "description: Query vulnerability data from OSV.", + `${field}: Removed selection metadata.`, + "httpApi:", + " baseUrl: https://api.osv.dev", + " auth:", + " type: none", + " actions:", + " query_package_version:", + " method: POST", + " path: /v1/query", + "---", + "", + "# OSV", + "", + ].join("\n"), + ); + expect(() => loadCapletFiles(dir)).toThrow(CapletsError); + rmSync(dir, { recursive: true, force: true }); + }, + ); it("rejects setup commands that look like agent tools", () => { const root = mkdtempSync(join(tmpdir(), "caplets-setup-invalid-")); @@ -943,17 +901,16 @@ describe("config", () => { command: "user-github", env: { name: "secret-value" }, tags: ["code", "$env:EXAMPLE_TOKEN"], - body: "# GitHub Card\nUse this for GitHub work with ${EXAMPLE_TOKEN} in prose.", }); + expect(config.mcpServers.github).not.toHaveProperty("body"); expect(config.mcpServers.github?.description).toContain("${EXAMPLE_TOKEN}"); expect(config.mcpServers.github?.tags).toContain("$env:EXAMPLE_TOKEN"); - expect(config.mcpServers.github?.body).toContain("${EXAMPLE_TOKEN}"); expect(config.mcpServers.linear).toMatchObject({ server: "linear", name: "Linear Project", command: "project-linear", - body: "# Project Linear", }); + expect(config.mcpServers.linear).not.toHaveProperty("body"); rmSync(dir, { recursive: true, force: true }); }); @@ -2365,8 +2322,8 @@ describe("config", () => { backend: "openapi", specPath: "/tmp/users-openapi.json", auth: { type: "none" }, - body: "# Users API", }); + expect(config.openapiEndpoints.users).not.toHaveProperty("body"); rmSync(root, { recursive: true, force: true }); mkdirSync(root, { recursive: true }); @@ -2427,8 +2384,8 @@ describe("config", () => { scopes: ["https://www.googleapis.com/auth/drive.metadata.readonly"], }, includeOperations: ["files.*"], - body: "# Drive API", }); + expect(config.googleDiscoveryApis.drive).not.toHaveProperty("body"); rmSync(dir, { recursive: true, force: true }); }); @@ -2503,8 +2460,8 @@ describe("config", () => { server: "reviews", backend: "graphql", schemaPath: join(root, "reviews.graphql"), - body: "# Reviews GraphQL", }); + expect(config.graphqlEndpoints.reviews).not.toHaveProperty("body"); rmSync(dir, { recursive: true, force: true }); }); @@ -2598,8 +2555,8 @@ describe("config", () => { backend: "http", auth: { type: "bearer", token: "secret-value" }, description: "Manage support data through HTTP actions and ${EXAMPLE_TOKEN}.", - body: "# Support HTTP", }); + expect(config.httpApis.support).not.toHaveProperty("body"); rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/core/test/current-host-catalog-operations.test.ts b/packages/core/test/current-host-catalog-operations.test.ts index abf9b765..5e9b39a7 100644 --- a/packages/core/test/current-host-catalog-operations.test.ts +++ b/packages/core/test/current-host-catalog-operations.test.ts @@ -10,6 +10,8 @@ import type { const mocks = vi.hoisted(() => ({ detail: vi.fn(), install: vi.fn(), + update: vi.fn(), + index: vi.fn(), })); vi.mock("../src/current-host/catalog", async (importOriginal) => ({ @@ -20,6 +22,8 @@ vi.mock("../src/current-host/catalog", async (importOriginal) => ({ vi.mock("../src/cli/install", async (importOriginal) => ({ ...(await importOriginal()), installCaplets: mocks.install, + updateCapletsFromLockfile: mocks.update, + indexInstalledCapletsFromLockfile: mocks.index, })); const principal: CurrentHostOperatorPrincipal = { @@ -69,6 +73,10 @@ describe("Current Host official catalog installs", () => { mocks.detail.mockReset(); mocks.install.mockReset(); mocks.install.mockReturnValue({ installed: [{ id: "github", kind: "caplet" }] }); + mocks.update.mockReset(); + mocks.index.mockReset(); + mocks.index.mockResolvedValue(new Map()); + vi.mocked(dependencies.activityLog.append).mockReset(); }); it.each([ @@ -114,4 +122,63 @@ describe("Current Host official catalog installs", () => { expect.objectContaining({ capletIds: ["github"] }), ); }); + + it("relays content updates and keeps committed activity when indexing throws", async () => { + mocks.update.mockReturnValue({ + installed: [ + { + id: "github", + source: "repo", + destination: "/tmp/caplets/github", + kind: "directory", + status: "content_updated", + }, + ], + }); + mocks.index.mockRejectedValue(new Error("indexer unavailable")); + const operations = createCurrentHostCatalogOperations(dependencies); + + const result = await operations.update(principal, { + kind: "catalog_update", + capletIds: ["github"], + }); + + expect(result).toMatchObject({ + kind: "catalog_update", + installed: [ + { + status: "content_updated", + catalogIndexing: { status: "unavailable", reason: "indexer_unavailable" }, + }, + ], + }); + expect(dependencies.activityLog.append).toHaveBeenCalledWith( + expect.objectContaining({ + action: "catalog_updated", + metadata: expect.objectContaining({ status: "content_updated" }), + }), + ); + }); + + it("does not index or append success activity when an update is uncommitted", async () => { + mocks.update.mockImplementation(() => { + throw new Error("lock write failed"); + }); + const operations = createCurrentHostCatalogOperations(dependencies); + + await expect( + operations.update(principal, { + kind: "catalog_update", + capletIds: ["github"], + }), + ).rejects.toThrow("lock write failed"); + + expect(mocks.index).not.toHaveBeenCalled(); + expect(dependencies.activityLog.append).not.toHaveBeenCalledWith( + expect.objectContaining({ + action: "catalog_updated", + metadata: expect.objectContaining({ status: expect.anything() }), + }), + ); + }); }); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index e2eb7091..d0a608b6 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -2,6 +2,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMemoryDeclaredInputReader } from "../src/caplet-source"; +import { parseConfig } from "../src/config-runtime"; import { CapletsEngine } from "../src/engine"; import { FileVaultStore } from "../src/vault"; @@ -180,6 +182,211 @@ describe("CapletsEngine", () => { expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["gamma"]); }); + it("treats a manual README-only reload as a successful lifecycle no-op", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const capletDir = join(dir, "user", "alpha"); + const capletPath = join(capletDir, "CAPLET.md"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(capletPath, capletMarkdown("Initial operator notes.")); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + engines.push(engine); + const internal = engine as unknown as { + registry: unknown; + downstream: { updateRegistry: (registry: unknown) => void }; + openapi: { updateRegistry: (registry: unknown) => void }; + googleDiscovery: { updateRegistry: (registry: unknown) => void }; + graphql: { updateRegistry: (registry: unknown) => void }; + http: { updateRegistry: (registry: unknown) => void }; + cli: { updateRegistry: (registry: unknown) => void }; + capletSets: { updateRegistry: (registry: unknown) => void }; + }; + const initialRegistry = internal.registry; + const initialGeneration = engine.currentExposureGeneration(); + const listener = vi.fn(); + engine.onReload(listener); + const registryUpdates = [ + vi.spyOn(internal.downstream, "updateRegistry"), + vi.spyOn(internal.openapi, "updateRegistry"), + vi.spyOn(internal.googleDiscovery, "updateRegistry"), + vi.spyOn(internal.graphql, "updateRegistry"), + vi.spyOn(internal.http, "updateRegistry"), + vi.spyOn(internal.cli, "updateRegistry"), + vi.spyOn(internal.capletSets, "updateRegistry"), + ]; + + writeFileSync(capletPath, capletMarkdown("Updated troubleshooting notes.")); + + await expect(engine.reload()).resolves.toBe(true); + expect(internal.registry).toBe(initialRegistry); + expect(engine.currentExposureGeneration()).toBe(initialGeneration); + expect(registryUpdates.every((spy) => spy.mock.calls.length === 0)).toBe(true); + expect(listener).not.toHaveBeenCalled(); + + writeFileSync( + capletPath, + capletMarkdown("Updated troubleshooting notes.", "Search alpha repositories."), + ); + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 1); + expect(registryUpdates.every((spy) => spy.mock.calls.length === 1)).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + + writeConfig(configPath, { options: { exposure: "direct" } }); + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 2); + expect(registryUpdates.every((spy) => spy.mock.calls.length === 2)).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("settles a watched README-only edit without emitting a runtime reload", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const capletDir = join(dir, "user", "alpha"); + const capletPath = join(capletDir, "CAPLET.md"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(capletPath, capletMarkdown("Initial operator notes.")); + const engine = new CapletsEngine({ configPath, projectConfigPath, watchDebounceMs: 1 }); + engines.push(engine); + const initialGeneration = engine.currentExposureGeneration(); + const listener = vi.fn(); + engine.onReload(listener); + const scheduled = Promise.withResolvers(); + const settled = Promise.withResolvers(); + const originalScheduleReload = engine.scheduleReload.bind(engine); + vi.spyOn(engine, "scheduleReload").mockImplementation(() => { + originalScheduleReload(); + scheduled.resolve(); + }); + const internal = engine as unknown as { + reloadOnce: () => Promise; + resetWatchers: () => void; + }; + const resetWatchers = vi.spyOn(internal, "resetWatchers"); + const originalReloadOnce = internal.reloadOnce.bind(engine); + vi.spyOn(internal, "reloadOnce").mockImplementation(async () => { + const result = await originalReloadOnce(); + settled.resolve(result); + return result; + }); + + writeFileSync(capletPath, capletMarkdown("Watched troubleshooting notes.")); + + await scheduled.promise; + await expect(settled.promise).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration); + expect(listener).not.toHaveBeenCalled(); + expect(resetWatchers).not.toHaveBeenCalled(); + }); + + it("fans out for declared runtime input changes, deletion, and restoration", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const capletDir = join(dir, "user", "weather"); + const capletPath = join(capletDir, "CAPLET.md"); + const specPath = join(capletDir, "openapi.yaml"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(capletPath, openapiCapletMarkdown()); + writeFileSync(specPath, "openapi: 3.0.3\ninfo: { title: Weather, version: one }\npaths: {}\n"); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + engines.push(engine); + const listener = vi.fn(); + engine.onReload(listener); + const initialGeneration = engine.currentExposureGeneration(); + + writeFileSync(specPath, "openapi: 3.0.3\ninfo: { title: Weather, version: two }\npaths: {}\n"); + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 1); + expect(listener).toHaveBeenCalledTimes(1); + + rmSync(specPath); + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 2); + expect(listener).toHaveBeenCalledTimes(2); + + writeFileSync( + specPath, + "openapi: 3.0.3\ninfo: { title: Weather, version: three }\npaths: {}\n", + ); + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 3); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it("fans out when a resolved environment value changes under a stable template", async () => { + const variable = "CAPLETS_U3_RELOAD_TOKEN"; + const original = process.env[variable]; + process.env[variable] = "first-secret"; + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + env: { TOKEN: `$env:${variable}` }, + }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + engines.push(engine); + const listener = vi.fn(); + engine.onReload(listener); + const initialGeneration = engine.currentExposureGeneration(); + + try { + process.env[variable] = "second-secret"; + + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentConfig().mcpServers.alpha?.env).toEqual({ TOKEN: "second-secret" }); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 1); + expect(listener).toHaveBeenCalledOnce(); + } finally { + if (original === undefined) { + delete process.env[variable]; + } else { + process.env[variable] = original; + } + } + }); + + it("fans out when a resolved Vault value changes under a stable template", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access.", + command: process.execPath, + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }); + dirs.push(dir); + process.env.XDG_STATE_HOME = join(dir, "state"); + const store = new FileVaultStore(); + store.set("GH_TOKEN", "first-vault-secret"); + store.grantAccess({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin: { kind: "global-config", path: configPath }, + }); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + engines.push(engine); + const listener = vi.fn(); + engine.onReload(listener); + const initialGeneration = engine.currentExposureGeneration(); + + store.set("GH_TOKEN", "second-vault-secret", { force: true }); + + await expect(engine.reload()).resolves.toBe(true); + expect(engine.currentConfig().mcpServers.github?.env).toEqual({ + GH_TOKEN: "second-vault-secret", + }); + expect(engine.currentExposureGeneration()).toBe(initialGeneration + 1); + expect(listener).toHaveBeenCalledOnce(); + }); + it("includes enabled Google Discovery API Caplets", () => { const { dir, configPath, projectConfigPath } = tempConfig({ googleDiscoveryApis: { @@ -231,6 +438,33 @@ describe("CapletsEngine", () => { }); }); + it("requires custom loaders to provide readable declared inputs", () => { + const config = parseConfig({ + openapiEndpoints: { + weather: { + name: "Weather", + description: "Query weather forecasts.", + specPath: "weather/openapi.yaml", + auth: { type: "none" }, + }, + }, + }); + + expect(() => new CapletsEngine({ watch: false, configLoader: () => config })).toThrow( + expect.objectContaining({ code: "CONFIG_INVALID" }), + ); + + const engine = new CapletsEngine({ + watch: false, + configLoader: () => config, + declaredInputReader: createMemoryDeclaredInputReader({ + "weather/openapi.yaml": "openapi: 3.1.0\ninfo: { title: Weather, version: 1 }\npaths: {}\n", + }), + }); + engines.push(engine); + expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["weather"]); + }); + it("keeps last known-good config when reload validation fails", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -252,6 +486,14 @@ describe("CapletsEngine", () => { engines.push(engine); const listener = vi.fn(); engine.onReload(listener); + const internal = engine as unknown as { + registry: unknown; + stableHostConfigurationFingerprint: string; + resolvedExecutionFingerprint: string; + }; + const initialRegistry = internal.registry; + const initialStableFingerprint = internal.stableHostConfigurationFingerprint; + const initialResolvedFingerprint = internal.resolvedExecutionFingerprint; writeFileSync(configPath, "{ invalid json"); @@ -259,6 +501,9 @@ describe("CapletsEngine", () => { expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["alpha"]); expect(listener).not.toHaveBeenCalled(); expect(errors.join("")).toContain("Caplets config reload failed"); + expect(internal.registry).toBe(initialRegistry); + expect(internal.stableHostConfigurationFingerprint).toBe(initialStableFingerprint); + expect(internal.resolvedExecutionFingerprint).toBe(initialResolvedFingerprint); }); it("keeps last known-good config when config sources disappear", async () => { @@ -462,6 +707,33 @@ describe("CapletsEngine", () => { } }); +function capletMarkdown(readme: string, description = "Search alpha project documents."): string { + return [ + "---", + "name: Alpha", + `description: ${description}`, + "mcpServer:", + ` command: ${JSON.stringify(process.execPath)}`, + "---", + readme, + "", + ].join("\n"); +} + +function openapiCapletMarkdown(): string { + return [ + "---", + "name: Weather", + "description: Inspect weather forecasts.", + "openapiEndpoint:", + " specPath: ./openapi.yaml", + " auth: { type: none }", + "---", + "Operator notes.", + "", + ].join("\n"); +} + function writeConfig(path: string, config: unknown): void { writeFileSync(path, JSON.stringify(config)); } diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 3c36b3c6..f979ca4b 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -698,8 +698,6 @@ describe("RemoteNativeCapletsService", () => { name: "Remote", title: "Remote", description: "Remote Code Mode handle.", - useWhen: "Use for remote repository work.", - avoidWhen: "Avoid for local-only operations.", inputSchema: { type: "object", additionalProperties: true }, schemaHash: "hash-code-mode", capletId: "remote", @@ -724,8 +722,6 @@ describe("RemoteNativeCapletsService", () => { codeModeCaplets: [ expect.objectContaining({ capletId: "remote", - useWhen: "Use for remote repository work.", - avoidWhen: "Avoid for local-only operations.", }), ], }), diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 79209765..344488dd 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -327,8 +327,6 @@ describe("native Caplets service", () => { exposure: "direct", baseUrl: "http://127.0.0.1:1", auth: { type: "none" }, - useWhen: "Use when the service health is needed.", - avoidWhen: "Avoid for mutation requests.", shadowing: "namespace", actions: { ping: { @@ -359,8 +357,6 @@ describe("native Caplets service", () => { type: "object", properties: { verbose: { type: "boolean" } }, }, - useWhen: "Use when the service health is needed.", - avoidWhen: "Avoid for mutation requests.", shadowing: "namespace", }), ]); @@ -796,6 +792,21 @@ describe("native Caplets service", () => { await initialReady; expect(configuredCapletIds(service.listTools())).toEqual(["alpha"]); + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents after reload.", + command: process.execPath, + }, + }, + }), + ), + ); + await expect(service.reload()).resolves.toBe(true); expect(service.listTools()).toEqual([]); await expect(service.execute("alpha", { operation: "inspect" })).rejects.toMatchObject({ @@ -1132,6 +1143,30 @@ describe("native Caplets service", () => { } }); + it("does not notify native tools when only a Caplet README changes", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const capletDir = join(dir, "user", "status"); + const capletPath = join(capletDir, "CAPLET.md"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(capletPath, nativeReadmeCaplet("Initial operator notes.")); + const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); + const before = service.listTools(); + const events: string[][] = []; + service.onToolsChanged((tools) => events.push(configuredCapletIds(tools))); + + try { + writeFileSync(capletPath, nativeReadmeCaplet("Updated troubleshooting notes.")); + + await expect(service.reload()).resolves.toBe(true); + expect(service.listTools()).toEqual(before); + expect(events).toEqual([]); + } finally { + await service.close(); + } + }); + it("notifies native tool listeners only when config parses successfully", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -1368,6 +1403,22 @@ describe("native Caplets service", () => { } }); +function nativeReadmeCaplet(readme: string): string { + return [ + "---", + "name: Status", + "description: Read service status.", + "httpApi:", + " baseUrl: http://127.0.0.1:1", + " auth: { type: none }", + " actions:", + " check: { method: GET, path: /check }", + "---", + readme, + "", + ].join("\n"); +} + async function waitForInitialProjection(service: NativeCapletsService): Promise { await new Promise((resolve) => { const unsubscribe = service.onToolsChanged(() => { diff --git a/packages/core/test/registry.test.ts b/packages/core/test/registry.test.ts index baf44dc7..e943b4e2 100644 --- a/packages/core/test/registry.test.ts +++ b/packages/core/test/registry.test.ts @@ -11,8 +11,6 @@ describe("registry", () => { name: "Enabled Server", description: "A useful enabled server.", command: "node", - useWhen: "Use for enabled test workflows.", - avoidWhen: "Avoid for disabled server checks.", args: ["secret-arg", "$env:SECRET_TOKEN"], env: { SECRET_TOKEN: "$env:SECRET_TOKEN" }, auth: undefined, @@ -90,8 +88,6 @@ describe("registry", () => { expect(registry.get("status")?.backend).toBe("http"); const description = capabilityDescription(config.mcpServers.enabled!); expect(description).toContain("Enabled Server"); - expect(description).toContain("Use when: Use for enabled test workflows."); - expect(description).toContain("Avoid when: Avoid for disabled server checks."); expect(description).toContain("Use tools/search_tools for downstream names"); expect(description).toContain("callTemplate"); expect(description).toContain("Prefer direct call_tool"); @@ -103,11 +99,6 @@ describe("registry", () => { expect(description).not.toContain("secret-env-value"); const remoteDetail = registry.detail(config.mcpServers.remote!); - const enabledDetail = registry.detail(config.mcpServers.enabled!); - expect(enabledDetail).toMatchObject({ - useWhen: "Use for enabled test workflows.", - avoidWhen: "Avoid for disabled server checks.", - }); const serialized = JSON.stringify(remoteDetail); expect(serialized).toContain('"transport":"http"'); expect(serialized).not.toContain("secret-url-value"); diff --git a/packages/core/test/runtime-fingerprint.test.ts b/packages/core/test/runtime-fingerprint.test.ts new file mode 100644 index 00000000..36c77f9c --- /dev/null +++ b/packages/core/test/runtime-fingerprint.test.ts @@ -0,0 +1,1214 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { BundleCapletSource } from "../src/caplet-source/bundle"; +import { FilesystemCapletSource } from "../src/caplet-source/filesystem"; +import { parseCapletSource } from "../src/caplet-source/parse"; +import { + createMemoryDeclaredInputReader, + createRuntimeFingerprintSnapshot, + resolvedExecutionFingerprintForConfig, +} from "../src/caplet-source/runtime-fingerprint"; +import { runCapletSetupCli } from "../src/cli/setup-caplet"; +import { createCloudRuntimeAdapter } from "../src/cloud/runtime-adapter"; +import { loadConfigWithSources } from "../src/config"; +import { LocalSetupStore } from "../src/setup/local-store"; +import { parseConfig } from "../src/config-runtime"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("runtime fingerprints", () => { + it("domain-separates private resolved-execution equality from stable fingerprints", () => { + const config = parseConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + env: { TOKEN: "first-secret" }, + }, + }, + }); + const same = parseConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + env: { TOKEN: "first-secret" }, + }, + }, + }); + const rotated = parseConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + env: { TOKEN: "second-secret" }, + }, + }, + }); + const stable = createRuntimeFingerprintSnapshot({ + config, + provenance: {}, + reader: createMemoryDeclaredInputReader({}), + }); + const resolved = resolvedExecutionFingerprintForConfig(config); + + expect(resolved).toBe(resolvedExecutionFingerprintForConfig(same)); + expect(resolved).not.toBe(resolvedExecutionFingerprintForConfig(rotated)); + expect(resolved).not.toBe(stable.hostConfigurationFingerprint); + expect(resolved).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("is body-blind, semantic, source-root independent, and adapter-consistent", async () => { + const first = [ + { + path: "weather/CAPLET.md", + content: `--- +name: Weather +# formatting and key order are not semantics +description: Inspect weather forecasts. +openapiEndpoint: + auth: { type: none } + specPath: ./openapi.yaml +--- +# Operator notes +First README. +`, + }, + { + path: "weather/openapi.yaml", + content: "openapi: 3.1.0\ninfo: {title: Weather, version: 1.0.0}\npaths: {}\n", + }, + ]; + const second = [ + { + path: "weather/CAPLET.md", + content: `--- +description: Inspect weather forecasts. +openapiEndpoint: + specPath: ././openapi.yaml + auth: + type: none +name: Weather +--- +# Entirely different README +Troubleshooting only. +`, + }, + first[1]!, + ]; + + const firstBundle = await parseCapletSource(new BundleCapletSource(first)); + const secondBundle = await parseCapletSource(new BundleCapletSource(second)); + const root = writeTree(first); + const filesystem = await parseCapletSource(new FilesystemCapletSource(root)); + + expect(firstBundle.runtimeFingerprint).toEqual(secondBundle.runtimeFingerprint); + expect(firstBundle.runtimeFingerprint).toEqual(filesystem.runtimeFingerprint); + expect(firstBundle.runtimeFingerprint?.version).toBe(1); + expect(firstBundle.runtimeFingerprint?.caplets.weather?.declaredInputs).toEqual([ + expect.objectContaining({ + kind: "openapi", + logicalPath: "weather/openapi.yaml", + state: "present", + digest: expect.stringMatching(/^[a-f0-9]{64}$/u), + }), + ]); + }); + + it("changes only the applicable scopes for declared inputs, child semantics, and host options", () => { + const base = parseConfig({ + defaultSearchLimit: 20, + maxSearchLimit: 50, + options: { exposure: "code_mode" }, + openapiEndpoints: { + weather: { + name: "Weather", + description: "Inspect weather forecasts.", + specPath: "weather/openapi.yaml", + auth: { type: "none" }, + }, + }, + httpApis: { + status: { + name: "Status", + description: "Inspect service status safely.", + baseUrl: "https://status.example.com", + auth: { type: "none" }, + actions: { get: { method: "GET", path: "/status" } }, + }, + }, + }); + const provenance = { + weather: { parentId: "weather", sourcePath: "weather/CAPLET.md" }, + status: { parentId: "status", sourcePath: "status/CAPLET.md" }, + }; + const first = createRuntimeFingerprintSnapshot({ + config: base, + provenance, + reader: createMemoryDeclaredInputReader({ "weather/openapi.yaml": "first" }), + }); + const inputChanged = createRuntimeFingerprintSnapshot({ + config: base, + provenance, + reader: createMemoryDeclaredInputReader({ "weather/openapi.yaml": "second" }), + }); + const hostChanged = createRuntimeFingerprintSnapshot({ + config: { ...base, options: { ...base.options, defaultSearchLimit: 21 } }, + provenance, + reader: createMemoryDeclaredInputReader({ "weather/openapi.yaml": "first" }), + }); + + expect(inputChanged.caplets.weather?.fingerprint).not.toBe(first.caplets.weather?.fingerprint); + expect(inputChanged.caplets.status?.fingerprint).toBe(first.caplets.status?.fingerprint); + expect(inputChanged.artifactFingerprint).not.toBe(first.artifactFingerprint); + expect(inputChanged.hostConfigurationFingerprint).not.toBe(first.hostConfigurationFingerprint); + expect(hostChanged.caplets).toEqual(first.caplets); + expect(hostChanged.artifactFingerprint).toBe(first.artifactFingerprint); + expect(hostChanged.hostConfigurationFingerprint).not.toBe(first.hostConfigurationFingerprint); + }); + it("includes every enumerated host runtime option only in the host aggregate", () => { + const base = parseConfig({ + mcpServers: { + tool: { + name: "Tool", + description: "Run one stable host option test tool.", + command: "tool", + }, + }, + }); + const variants = [ + { ...base, options: { ...base.options, defaultSearchLimit: 21 } }, + { ...base, options: { ...base.options, maxSearchLimit: 49 } }, + { ...base, options: { ...base.options, exposure: "direct" as const } }, + { ...base, options: { ...base.options, exposureDiscoveryTimeoutMs: 16_000 } }, + { ...base, options: { ...base.options, exposureDiscoveryConcurrency: 5 } }, + { + ...base, + options: { + ...base.options, + completion: { ...base.options.completion, discoveryTimeoutMs: 751 }, + }, + }, + { + ...base, + options: { + ...base.options, + completion: { ...base.options.completion, overallTimeoutMs: 1_501 }, + }, + }, + { + ...base, + options: { + ...base.options, + completion: { ...base.options.completion, cacheTtlMs: 300_001 }, + }, + }, + { + ...base, + options: { + ...base.options, + completion: { ...base.options.completion, negativeCacheTtlMs: 30_001 }, + }, + }, + { ...base, namespaceAliases: { local: "local", upstreams: {} } }, + { ...base, namespaceAliases: { upstreams: { source: "upstream" } } }, + Object.assign(structuredClone(base), { telemetry: false }), + Object.assign(structuredClone(base), { serve: { host: "127.0.0.1" } }), + Object.assign(structuredClone(base), { serve: { port: 5387 } }), + Object.assign(structuredClone(base), { serve: { path: "/caplets" } }), + Object.assign(structuredClone(base), { serve: { remoteStatePath: "/private/state" } }), + Object.assign(structuredClone(base), { + serve: { upstreamUrl: "https://upstream.example.com" }, + }), + Object.assign(structuredClone(base), { serve: { allowUnauthenticatedHttp: true } }), + Object.assign(structuredClone(base), { serve: { trustProxy: true } }), + Object.assign(structuredClone(base), { + serve: { publicOrigins: ["https://public.example.com"] }, + }), + ]; + const provenance = { tool: { parentId: "tool", sourcePath: "tool/CAPLET.md" } }; + const first = createRuntimeFingerprintSnapshot({ + config: base, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + + for (const variant of variants) { + const changed = createRuntimeFingerprintSnapshot({ + config: variant, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + expect(changed.caplets).toEqual(first.caplets); + expect(changed.artifactFingerprint).toBe(first.artifactFingerprint); + expect(changed.hostConfigurationFingerprint).not.toBe(first.hostConfigurationFingerprint); + } + }); + + it("changes fingerprints for every runtime semantic family", () => { + const base = parseConfig({ + mcpServers: { + tool: { + name: "Tool", + description: "Run a stable remote tool safely.", + tags: ["stable"], + exposure: "code_mode", + shadowing: "forbid", + url: "https://tool.example.com/mcp", + auth: { type: "bearer", token: "$env:TOOL_TOKEN" }, + setup: { commands: [{ label: "Install", command: "install-tool" }] }, + projectBinding: { required: true }, + runtime: { features: ["browser"], resources: { class: "large" } }, + startupTimeoutMs: 10_000, + callTimeoutMs: 60_000, + toolCacheTtlMs: 30_000, + }, + }, + }); + const variants = [ + { description: "Run a changed remote tool safely." }, + { tags: ["changed"] }, + { exposure: "direct" as const }, + { shadowing: "allow" as const }, + { url: "https://changed.example.com/mcp" }, + { auth: { type: "bearer" as const, token: "$vault:TOOL_TOKEN" } }, + { setup: { commands: [{ label: "Install", command: "install-tool-v2" }] } }, + { projectBinding: undefined }, + { runtime: { features: ["docker" as const], resources: { class: "heavy" as const } } }, + { startupTimeoutMs: 10_001 }, + { callTimeoutMs: 60_001 }, + { toolCacheTtlMs: 30_001 }, + ]; + const provenance = { tool: { parentId: "tool", sourcePath: "tool/CAPLET.md" } }; + const first = createRuntimeFingerprintSnapshot({ + config: base, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + + for (const change of variants) { + const changedConfig = structuredClone(base); + Object.assign(changedConfig.mcpServers.tool!, change); + const changed = createRuntimeFingerprintSnapshot({ + config: changedConfig, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + expect(changed.caplets.tool?.fingerprint).not.toBe(first.caplets.tool?.fingerprint); + expect(changed.artifactFingerprint).not.toBe(first.artifactFingerprint); + expect(changed.hostConfigurationFingerprint).not.toBe(first.hostConfigurationFingerprint); + } + }); + + it("retains live-only identity for literal credentials, headers, and environment values", () => { + const config = parseConfig({ + mcpServers: { + token: { + name: "Token", + description: "Use a literal bearer credential.", + url: "https://token.example.com/mcp", + auth: { type: "bearer", token: "token-a" }, + }, + headers: { + name: "Headers", + description: "Use a literal custom header.", + url: "https://headers.example.com/mcp", + auth: { type: "headers", headers: { "X-Feature": "feature-a" } }, + }, + environment: { + name: "Environment", + description: "Use a literal process environment value.", + command: "environment-tool", + env: { MODE: "mode-a" }, + }, + oauth: { + name: "OAuth", + description: "Use a literal OAuth client credential.", + url: "https://oauth.example.com/mcp", + auth: { type: "oauth2", clientSecret: "client-a" }, + }, + }, + }); + const provenance = Object.fromEntries( + ["token", "headers", "environment", "oauth"].map((id) => [ + id, + { parentId: id, sourcePath: `${id}/CAPLET.md` }, + ]), + ); + const first = createRuntimeFingerprintSnapshot({ + config, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + const variants = [ + [ + "token", + (next: typeof config) => { + next.mcpServers.token!.auth = { type: "bearer", token: "token-b" }; + }, + ], + [ + "headers", + (next: typeof config) => { + next.mcpServers.headers!.auth = { + type: "headers", + headers: { "X-Feature": "feature-b" }, + }; + }, + ], + [ + "environment", + (next: typeof config) => { + next.mcpServers.environment!.env = { MODE: "mode-b" }; + }, + ], + [ + "oauth", + (next: typeof config) => { + next.mcpServers.oauth!.auth = { type: "oauth2", clientSecret: "client-b" }; + }, + ], + ] as const; + + for (const [id, mutate] of variants) { + const changedConfig = structuredClone(config); + mutate(changedConfig); + const changed = createRuntimeFingerprintSnapshot({ + config: changedConfig, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + expect(changed.caplets[id]?.fingerprint).not.toBe(first.caplets[id]?.fingerprint); + expect(changed.caplets[id]?.persistenceEligible).toBe(false); + } + const serialized = JSON.stringify(first); + for (const value of ["token-a", "feature-a", "mode-a", "client-a"]) { + expect(serialized).not.toContain(value); + } + }); + + it("scopes shared and child-local changes across a multi-backend artifact", async () => { + const caplet = (description: string, actionPath = "/items") => `--- +name: Workspace +description: ${description} +auth: { type: none } +googleDiscoveryApis: + drive: + name: Drive + discoveryPath: ./drive.json +httpApis: + status: + name: Status + baseUrl: https://status.example.com + actions: + list: + method: GET + path: ${actionPath} +--- +README +`; + const parse = async (content: string) => + await parseCapletSource( + new BundleCapletSource([ + { path: "workspace/CAPLET.md", content }, + { path: "workspace/drive.json", content: '{"name":"drive","version":"v3"}' }, + ]), + ); + const first = await parse(caplet("Work with several workspace APIs.")); + const sharedChanged = await parse(caplet("Work with changed workspace APIs.")); + const childChanged = await parse(caplet("Work with several workspace APIs.", "/changed")); + + expect(sharedChanged.runtimeFingerprint?.caplets["workspace__drive"]?.fingerprint).not.toBe( + first.runtimeFingerprint?.caplets["workspace__drive"]?.fingerprint, + ); + expect(sharedChanged.runtimeFingerprint?.caplets["workspace__status"]?.fingerprint).not.toBe( + first.runtimeFingerprint?.caplets["workspace__status"]?.fingerprint, + ); + expect(childChanged.runtimeFingerprint?.caplets["workspace__drive"]?.fingerprint).toBe( + first.runtimeFingerprint?.caplets["workspace__drive"]?.fingerprint, + ); + expect(childChanged.runtimeFingerprint?.caplets["workspace__status"]?.fingerprint).not.toBe( + first.runtimeFingerprint?.caplets["workspace__status"]?.fingerprint, + ); + expect(childChanged.runtimeFingerprint?.artifactFingerprint).not.toBe( + first.runtimeFingerprint?.artifactFingerprint, + ); + }); + + it("distinguishes safe missing and unreadable inputs without leaking private details", () => { + const config = parseConfig({ + graphqlEndpoints: { + graph: { + name: "Graph", + description: "Query a private graph endpoint.", + endpointUrl: "https://graph.example.com/graphql", + schemaPath: "graph/schema.graphql", + operations: { + present: { documentPath: "graph/present.graphql" }, + missing: { documentPath: "graph/missing.graphql" }, + unreadable: { documentPath: "graph/unreadable.graphql" }, + }, + auth: { type: "none" }, + }, + }, + }); + const snapshot = createRuntimeFingerprintSnapshot({ + config, + provenance: { graph: { parentId: "graph", sourcePath: "graph/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + "graph/schema.graphql": "type Query { ok: Boolean! }", + "graph/present.graphql": "query Present { ok }", + "graph/unreadable.graphql": { state: "unreadable", privateKey: "/private/root/EACCES" }, + }), + }); + const serialized = JSON.stringify(snapshot); + + expect( + snapshot.caplets.graph?.declaredInputs.map(({ logicalPath, state }) => ({ + logicalPath, + state, + })), + ).toEqual([ + { logicalPath: "graph/missing.graphql", state: "missing" }, + { logicalPath: "graph/present.graphql", state: "present" }, + { logicalPath: "graph/schema.graphql", state: "present" }, + { logicalPath: "graph/unreadable.graphql", state: "unreadable" }, + ]); + expect(snapshot.valid).toBe(false); + expect(snapshot.caplets.graph?.valid).toBe(false); + expect(serialized).not.toContain("/private/root"); + expect(serialized).not.toContain("EACCES"); + }); + + it("tracks every Discovery and GraphQL declared input independently", () => { + const config = parseConfig({ + googleDiscoveryApis: { + drive: { + name: "Drive", + description: "Search Drive resources safely.", + discoveryPath: "drive/discovery.json", + auth: { type: "none" }, + }, + }, + graphqlEndpoints: { + graph: { + name: "Graph", + description: "Query a GraphQL service safely.", + endpointUrl: "https://graph.example.com/graphql", + schemaPath: "graph/schema.graphql", + operations: { + viewer: { documentPath: "graph/viewer.graphql" }, + }, + auth: { type: "none" }, + }, + }, + }); + const provenance = { + drive: { parentId: "drive", sourcePath: "drive/CAPLET.md" }, + graph: { parentId: "graph", sourcePath: "graph/CAPLET.md" }, + }; + const files = { + "drive/discovery.json": '{"name":"drive","version":"v3"}', + "graph/schema.graphql": "type Query { viewer: String }", + "graph/viewer.graphql": "query Viewer { viewer }", + }; + const first = createRuntimeFingerprintSnapshot({ + config, + provenance, + reader: createMemoryDeclaredInputReader(files), + }); + + for (const logicalPath of Object.keys(files)) { + const changed = createRuntimeFingerprintSnapshot({ + config, + provenance, + reader: createMemoryDeclaredInputReader({ + ...files, + [logicalPath]: `${files[logicalPath as keyof typeof files]} changed`, + }), + }); + const runtimeId = logicalPath.startsWith("drive/") ? "drive" : "graph"; + expect(changed.caplets[runtimeId]?.fingerprint, logicalPath).not.toBe( + first.caplets[runtimeId]?.fingerprint, + ); + } + }); + + it("fails persistence eligibility closed for literal secrets and absolute host values", () => { + const template = parseConfig({ + mcpServers: { + safe: { + name: "Safe", + description: "Use portable secret references.", + url: "https://safe.example.com/mcp", + auth: { type: "bearer", token: "$vault:SAFE_TOKEN" }, + }, + literal: { + name: "Literal", + description: "Use a literal secret value.", + url: "https://literal.example.com/mcp", + auth: { type: "bearer", token: "secret-value" }, + }, + absolute: { + name: "Absolute", + description: "Use an absolute host executable.", + command: "/opt/private/bin/tool", + }, + windowsAbsolute: { + name: "Windows absolute", + description: "Use an absolute Windows host executable.", + command: "C:\\private\\bin\\tool.exe", + }, + }, + }); + const provenance = Object.fromEntries( + ["safe", "literal", "absolute", "windowsAbsolute"].map((id) => [ + id, + { parentId: id, sourcePath: `${id}/CAPLET.md` }, + ]), + ); + const snapshot = createRuntimeFingerprintSnapshot({ + config: template, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + const retargetedTemplate = structuredClone(template); + retargetedTemplate.mcpServers.absolute!.command = "/opt/private/bin/other-tool"; + const retargeted = createRuntimeFingerprintSnapshot({ + config: retargetedTemplate, + provenance, + reader: createMemoryDeclaredInputReader({}), + }); + + expect(snapshot.caplets.safe?.persistenceEligible).toBe(true); + expect(snapshot.caplets.literal?.persistenceEligible).toBe(false); + expect(snapshot.caplets.absolute?.persistenceEligible).toBe(false); + expect(snapshot.caplets.windowsAbsolute?.persistenceEligible).toBe(false); + expect(retargeted.caplets.absolute?.fingerprint).not.toBe( + snapshot.caplets.absolute?.fingerprint, + ); + expect(snapshot.persistenceEligible).toBe(false); + expect(JSON.stringify(snapshot)).not.toContain("secret-value"); + expect(JSON.stringify(snapshot)).not.toContain("/opt/private"); + expect(JSON.stringify(snapshot)).not.toContain("C:\\private"); + expect(JSON.stringify(retargeted)).not.toContain("/opt/private"); + }); + + it("keeps static HTTP secrets and dynamic setup commands out of persisted identity", () => { + const config = parseConfig({ + httpApis: { + unsafeHttp: { + name: "Unsafe HTTP", + description: "Contains literal request credentials.", + baseUrl: "https://api.example.com", + auth: { type: "none" }, + actions: { + send: { + method: "POST", + path: "/send", + query: { tenant: "private-tenant" }, + jsonBody: { token: "private-body-token" }, + }, + }, + }, + }, + cliTools: { + unsafeSetup: { + name: "Unsafe setup", + description: "Resolves the setup executable dynamically.", + setup: { + commands: [ + { + label: "Install", + command: "$env:SETUP_COMMAND", + args: ["$env:SETUP_ARGUMENT"], + }, + ], + }, + actions: { + run: { + description: "Run the installed tool.", + command: "tool", + }, + }, + }, + }, + }); + const snapshot = createRuntimeFingerprintSnapshot({ + config, + provenance: {}, + reader: createMemoryDeclaredInputReader({}), + }); + + expect(snapshot.caplets.unsafeHttp?.persistenceEligible).toBe(false); + expect(snapshot.caplets.unsafeSetup?.persistenceEligible).toBe(false); + expect(snapshot.persistenceEligible).toBe(false); + expect(JSON.stringify(snapshot)).not.toContain("private-tenant"); + expect(JSON.stringify(snapshot)).not.toContain("private-body-token"); + }); + + it("keeps Vault resolution values outside stable and persistable snapshots", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-vault-fingerprint-")); + tempDirs.push(root); + const configPath = join(root, "config.json"); + const projectConfigPath = join(root, "missing-project.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + vault: { + name: "Vault", + description: "Use a Vault-backed bearer credential.", + url: "https://vault.example.com/mcp", + auth: { type: "bearer", token: "$vault:RUNTIME_TOKEN" }, + }, + }, + }), + "utf8", + ); + const first = loadConfigWithSources(configPath, projectConfigPath, { + vaultResolver: () => ({ storedKey: "first", value: "resolved-vault-secret-one" }), + }).runtimeFingerprint; + const second = loadConfigWithSources(configPath, projectConfigPath, { + vaultResolver: () => ({ storedKey: "second", value: "resolved-vault-secret-two" }), + }).runtimeFingerprint; + + expect(first).toEqual(second); + expect(first?.caplets.vault?.persistenceEligible).toBe(true); + expect(JSON.stringify(first)).not.toContain("resolved-vault-secret-one"); + expect(JSON.stringify(second)).not.toContain("resolved-vault-secret-two"); + }); + + it("hashes explicitly absolute declared inputs live without exposing their host path", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-absolute-fingerprint-")); + tempDirs.push(root); + const configPath = join(root, "config.json"); + const projectConfigPath = join(root, "missing-project.json"); + const specPath = join(root, "openapi.yaml"); + writeFileSync(specPath, "first", "utf8"); + writeFileSync( + configPath, + JSON.stringify({ + openapiEndpoints: { + absolute: { + name: "Absolute", + description: "Read an explicitly absolute OpenAPI input.", + specPath, + auth: { type: "none" }, + }, + }, + }), + "utf8", + ); + const first = loadConfigWithSources(configPath, projectConfigPath).runtimeFingerprint; + writeFileSync(specPath, "second", "utf8"); + const second = loadConfigWithSources(configPath, projectConfigPath).runtimeFingerprint; + const retargetedSpecPath = join(root, "retargeted-openapi.yaml"); + writeFileSync(retargetedSpecPath, "second", "utf8"); + writeFileSync( + configPath, + JSON.stringify({ + openapiEndpoints: { + absolute: { + name: "Absolute", + description: "Read an explicitly absolute OpenAPI input.", + specPath: retargetedSpecPath, + auth: { type: "none" }, + }, + }, + }), + "utf8", + ); + const retargeted = loadConfigWithSources(configPath, projectConfigPath).runtimeFingerprint; + + expect(first?.caplets.absolute?.persistenceEligible).toBe(false); + expect(first?.caplets.absolute?.declaredInputs).toEqual([ + expect.objectContaining({ + logicalPath: "@absolute/openapi", + state: "present", + digest: expect.stringMatching(/^[a-f0-9]{64}$/u), + }), + ]); + expect(second?.caplets.absolute?.fingerprint).not.toBe(first?.caplets.absolute?.fingerprint); + expect(retargeted?.caplets.absolute?.fingerprint).not.toBe( + second?.caplets.absolute?.fingerprint, + ); + expect(JSON.stringify(first)).not.toContain(root); + expect(JSON.stringify(second)).not.toContain(root); + expect(JSON.stringify(retargeted)).not.toContain(root); + }); + + it("recurses through explicitly absolute Caplet-set inputs without persisting host identity", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-absolute-set-fingerprint-")); + tempDirs.push(root); + const configPath = join(root, "config.json"); + const projectConfigPath = join(root, "missing-project.json"); + const nestedConfigPath = join(root, "nested.json"); + const nestedRoot = join(root, "nested-caplets"); + mkdirSync(join(nestedRoot, "file-child"), { recursive: true }); + writeFileSync( + join(nestedRoot, "file-child", "CAPLET.md"), + `--- +name: File child +description: Load a discovered nested child. +mcpServer: + command: file-child +--- +README +`, + "utf8", + ); + const writeNestedConfig = (command: string): void => { + writeFileSync( + nestedConfigPath, + JSON.stringify({ + mcpServers: { + configured: { + name: "Configured child", + description: "Load a configured nested child.", + command, + }, + }, + }), + "utf8", + ); + }; + writeNestedConfig("first"); + writeFileSync( + configPath, + JSON.stringify({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose an absolute nested Caplet collection.", + configPath: nestedConfigPath, + capletsRoot: nestedRoot, + }, + }, + }), + "utf8", + ); + const first = loadConfigWithSources(configPath, projectConfigPath).runtimeFingerprint; + writeNestedConfig("second"); + const second = loadConfigWithSources(configPath, projectConfigPath).runtimeFingerprint; + + expect(first?.caplets.workspace?.persistenceEligible).toBe(false); + expect(first?.caplets.workspace?.declaredInputs).toEqual([ + expect.objectContaining({ logicalPath: "@absolute/caplet-config", state: "present" }), + expect.objectContaining({ logicalPath: "@absolute/caplets-root", state: "present" }), + ]); + expect(second?.caplets.workspace?.fingerprint).not.toBe(first?.caplets.workspace?.fingerprint); + expect(JSON.stringify(first)).not.toContain(root); + expect(JSON.stringify(second)).not.toContain(root); + }); + + it("records unreadable effective Caplet-root files without leaking private identity", () => { + const config = parseConfig({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose a nested Caplet collection.", + capletsRoot: "nested/caplets", + }, + }, + }); + const snapshot = createRuntimeFingerprintSnapshot({ + config, + provenance: { + workspace: { + parentId: "workspace", + sourcePath: "workspace/CAPLET.md", + }, + }, + reader: createMemoryDeclaredInputReader({ + "nested/caplets/tool.md": { + state: "unreadable", + privateKey: "/private/runtime/tool.md", + }, + }), + }); + + expect(snapshot.caplets.workspace?.declaredInputs).toEqual([ + expect.objectContaining({ + kind: "caplets-root", + logicalPath: "nested/caplets", + state: "unreadable", + digest: expect.stringMatching(/^[a-f0-9]{64}$/u), + }), + ]); + expect(JSON.stringify(snapshot)).not.toContain("/private/runtime"); + expect(snapshot.valid).toBe(false); + expect(snapshot.caplets.workspace?.valid).toBe(false); + }); + + it("fingerprints effective nested Caplet-set config and Caplet-root discovery", () => { + const config = parseConfig({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose effective nested Caplets.", + configPath: "nested/config.json", + capletsRoot: "nested/caplets", + }, + }, + }); + const files = { + "nested/config.json": JSON.stringify({ + mcpServers: { + duplicate: { + name: "Configured duplicate", + description: "This configured entry loses to the Caplet file.", + command: "configured", + }, + }, + }), + "nested/caplets/duplicate.md": `---\nname: File duplicate\ndescription: The discovered Caplet file wins by runtime precedence.\nmcpServer:\n command: file-wins\n---\nREADME`, + "nested/caplets/immediate/CAPLET.md": `---\nname: Immediate\ndescription: This immediate directory is discovered.\nmcpServer:\n command: immediate\n---\nREADME`, + "nested/caplets/shadowed.md": `---\nname: Shadowed flat file\ndescription: This flat file loses to the directory Caplet.\nmcpServer:\n command: flat-loses\n---\nREADME`, + "nested/caplets/shadowed/CAPLET.md": `---\nname: Shadowing directory\ndescription: This directory Caplet wins runtime precedence.\nmcpServer:\n command: directory-wins\n---\nREADME`, + "nested/caplets/deeper/ignored/CAPLET.md": `---\nname: Ignored\ndescription: This deeper directory is ignored.\nmcpServer:\n command: ignored\n---\nREADME`, + }; + const first = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader(files), + }); + const ignoredChanged = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + ...files, + "nested/caplets/deeper/ignored/CAPLET.md": + files["nested/caplets/deeper/ignored/CAPLET.md"] + " changed", + }), + }); + const shadowedChanged = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + ...files, + "nested/caplets/shadowed.md": `${files["nested/caplets/shadowed.md"]} changed`, + }), + }); + const effectiveChanged = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + ...files, + "nested/caplets/immediate/CAPLET.md": files["nested/caplets/immediate/CAPLET.md"].replace( + "immediate", + "changed", + ), + }), + }); + + const configuredDuplicateChanged = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + ...files, + "nested/config.json": files["nested/config.json"].replace("configured", "changed"), + }), + }); + + expect(ignoredChanged.caplets.workspace?.fingerprint).toBe( + first.caplets.workspace?.fingerprint, + ); + expect(shadowedChanged.caplets.workspace?.fingerprint).toBe( + first.caplets.workspace?.fingerprint, + ); + expect(configuredDuplicateChanged.caplets.workspace?.fingerprint).toBe( + first.caplets.workspace?.fingerprint, + ); + expect(effectiveChanged.caplets.workspace?.fingerprint).not.toBe( + first.caplets.workspace?.fingerprint, + ); + }); + + it("rejects malformed nested backend maps before overlay merging", () => { + const config = parseConfig({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose nested Caplets.", + configPath: "nested/config.json", + capletsRoot: "nested/caplets", + }, + }, + }); + + expect(() => + createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ + "nested/config.json": JSON.stringify({ mcpServers: "invalid" }), + "nested/caplets/tool.md": + "---\nname: Tool\ndescription: A nested tool.\nmcpServer:\n command: tool\n---\nREADME\n", + }), + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_INVALID" })); + }); + + it("propagates nested live-only taint to the owning Caplet set", () => { + const config = parseConfig({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose a nested secret-bearing collection.", + configPath: "nested/config.json", + }, + }, + }); + const nestedConfig = JSON.stringify({ + mcpServers: { + private: { + name: "Private", + description: "Uses a literal nested bearer credential.", + url: "https://private.example.com/mcp", + auth: { type: "bearer", token: "nested-literal-secret" }, + }, + }, + }); + const snapshot = createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ "nested/config.json": nestedConfig }), + }); + + expect(snapshot.caplets.workspace?.persistenceEligible).toBe(false); + expect(snapshot.persistenceEligible).toBe(false); + expect(JSON.stringify(snapshot)).not.toContain("nested-literal-secret"); + }); + + it("rejects recursive Caplet-set source cycles", () => { + const config = parseConfig({ + capletSets: { + workspace: { + name: "Workspace", + description: "Expose a recursive nested collection.", + configPath: "nested/config.json", + }, + }, + }); + const nestedConfig = JSON.stringify({ + capletSets: { + self: { + name: "Self", + description: "Points back to the same nested source.", + configPath: "config.json", + }, + }, + }); + + expect(() => + createRuntimeFingerprintSnapshot({ + config, + provenance: { workspace: { parentId: "workspace", sourcePath: "workspace/CAPLET.md" } }, + reader: createMemoryDeclaredInputReader({ "nested/config.json": nestedConfig }), + }), + ).toThrow(/cycle/iu); + }); + + it("uses one secret-independent per-Caplet fingerprint for local and hosted setup", async () => { + const root = mkdtempSync(join(tmpdir(), "caplets-setup-fingerprint-")); + tempDirs.push(root); + const configPath = join(root, "config.json"); + const projectConfigPath = join(root, "missing-project.json"); + const capletDir = join(root, "tool"); + const capletPath = join(capletDir, "CAPLET.md"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(configPath, "{}", "utf8"); + const capletFile = (body: string, tokenReference = "$env:SETUP_RUNTIME_TOKEN") => `--- +name: Setup tool +description: Run a setup-enabled remote tool. +setup: + commands: + - label: Install + command: install-tool +mcpServer: + url: https://tool.example.com/mcp + auth: + type: bearer + token: ${tokenReference} +--- +${body} +`; + writeFileSync(capletPath, capletFile("# First README"), "utf8"); + const previousToken = process.env.SETUP_RUNTIME_TOKEN; + const previousOtherToken = process.env.OTHER_TOKEN; + process.env.SETUP_RUNTIME_TOKEN = "resolved-secret-one"; + process.env.OTHER_TOKEN = "resolved-secret-two"; + try { + const first = loadConfigWithSources(configPath, projectConfigPath); + const expected = first.runtimeFingerprint?.caplets.tool?.fingerprint; + expect(expected).toMatch(/^[a-f0-9]{64}$/u); + + const localStoreRoot = join(root, "local-setup"); + await runCapletSetupCli("tool", { + yes: true, + configPath, + projectConfigPath, + baseDir: localStoreRoot, + spawn: async () => ({ exitCode: 0, stdout: "", stderr: "", durationMs: 1 }), + }); + const approvals = JSON.parse( + readFileSync(join(localStoreRoot, "approvals.json"), "utf8"), + ) as Array<{ contentHash: string }>; + + const hosted = createCloudRuntimeAdapter({ + configPath, + projectConfigPath, + runtimeId: "runtime-test", + executionKind: "cloud", + setupStore: new LocalSetupStore({ baseDir: join(root, "hosted-setup") }), + }); + const hostedPlan = await hosted.setupPlan("tool"); + await hosted.close(); + + process.env.SETUP_RUNTIME_TOKEN = "resolved-secret-two"; + writeFileSync(capletPath, capletFile("# Different README"), "utf8"); + const second = loadConfigWithSources(configPath, projectConfigPath); + + expect(approvals[0]?.contentHash).toBe(expected); + expect(hostedPlan.contentHash).toBe(expected); + expect(hostedPlan.persistenceEligible).toBe(true); + expect(second.runtimeFingerprint?.caplets.tool?.fingerprint).toBe(expected); + expect(JSON.stringify(first.runtimeFingerprint)).not.toContain("resolved-secret-one"); + expect(JSON.stringify(second.runtimeFingerprint)).not.toContain("resolved-secret-two"); + + writeFileSync(capletPath, capletFile("# Different README", "$env:OTHER_TOKEN"), "utf8"); + const templateChanged = loadConfigWithSources(configPath, projectConfigPath); + expect(templateChanged.runtimeFingerprint?.caplets.tool?.fingerprint).not.toBe(expected); + + writeFileSync(capletPath, capletFile("# Literal README", "literal-secret"), "utf8"); + const liveOnly = loadConfigWithSources(configPath, projectConfigPath); + expect(liveOnly.runtimeFingerprint?.caplets.tool?.persistenceEligible).toBe(false); + const liveOnlyStoreRoot = join(root, "live-only-setup"); + await runCapletSetupCli("tool", { + yes: true, + configPath, + projectConfigPath, + baseDir: liveOnlyStoreRoot, + spawn: async () => ({ exitCode: 0, stdout: "", stderr: "", durationMs: 1 }), + }); + expect(existsSync(join(liveOnlyStoreRoot, "approvals.json"))).toBe(false); + const liveOnlyAttempt = readFileSync( + join(liveOnlyStoreRoot, "projects", "default", "attempts", "tool.jsonl"), + "utf8", + ); + expect(liveOnlyAttempt).toContain('"contentHash":"live-only"'); + expect(liveOnlyAttempt).not.toContain("literal-secret"); + + const liveHostedRoot = join(root, "live-only-hosted"); + const liveHosted = createCloudRuntimeAdapter({ + configPath, + projectConfigPath, + runtimeId: "runtime-live-only", + executionKind: "cloud", + setupStore: new LocalSetupStore({ baseDir: liveHostedRoot }), + }); + const liveHostedPlan = await liveHosted.setupPlan("tool"); + await liveHosted.close(); + expect(liveHostedPlan.contentHash).toBe("live-only"); + expect(liveHostedPlan.approved).toBe(false); + expect(liveHostedPlan.persistenceEligible).toBe(false); + expect(existsSync(join(liveHostedRoot, "approvals.json"))).toBe(false); + } finally { + if (previousToken === undefined) delete process.env.SETUP_RUNTIME_TOKEN; + else process.env.SETUP_RUNTIME_TOKEN = previousToken; + if (previousOtherToken === undefined) delete process.env.OTHER_TOKEN; + else process.env.OTHER_TOKEN = previousOtherToken; + } + }); + + it("rejects traversal and reports filesystem symlink escape as unreadable", async () => { + const root = mkdtempSync(join(tmpdir(), "caplets-fingerprint-root-")); + const outside = mkdtempSync(join(tmpdir(), "caplets-fingerprint-outside-")); + tempDirs.push(root, outside); + mkdirSync(join(root, "weather"), { recursive: true }); + writeFileSync(join(outside, "openapi.yaml"), "outside", "utf8"); + symlinkSync(join(outside, "openapi.yaml"), join(root, "weather", "openapi.yaml")); + writeFileSync( + join(root, "weather", "CAPLET.md"), + `---\nname: Weather\ndescription: Inspect weather forecasts.\nopenapiEndpoint:\n specPath: ./openapi.yaml\n auth: { type: none }\n---\nREADME`, + "utf8", + ); + + const escaped = await parseCapletSource(new FilesystemCapletSource(root)); + expect(escaped.ok).toBe(false); + expect(escaped.errors).toEqual([ + expect.objectContaining({ + message: expect.stringMatching(/unreadable|outside the source root/iu), + }), + ]); + + const traversal = await parseCapletSource( + new BundleCapletSource([ + { + path: "weather/CAPLET.md", + content: `---\nname: Weather\ndescription: Inspect weather forecasts.\nopenapiEndpoint:\n specPath: ../outside.yaml\n auth: { type: none }\n---\nREADME`, + }, + { path: "outside.yaml", content: "outside" }, + ]), + ); + expect(traversal.ok).toBe(false); + expect(traversal.errors).toEqual([ + expect.objectContaining({ message: expect.stringMatching(/traversal/iu) }), + ]); + }); + + it("changes the digest when an in-root declared-input symlink is retargeted", async () => { + const root = mkdtempSync(join(tmpdir(), "caplets-fingerprint-retarget-")); + tempDirs.push(root); + mkdirSync(join(root, "weather"), { recursive: true }); + writeFileSync(join(root, "first.yaml"), "first", "utf8"); + writeFileSync(join(root, "second.yaml"), "second", "utf8"); + const linked = join(root, "weather", "openapi.yaml"); + symlinkSync(join(root, "first.yaml"), linked); + writeFileSync( + join(root, "weather", "CAPLET.md"), + `---\nname: Weather\ndescription: Inspect weather forecasts.\nopenapiEndpoint:\n specPath: ./openapi.yaml\n auth: { type: none }\n---\nREADME`, + "utf8", + ); + const first = await parseCapletSource(new FilesystemCapletSource(root)); + unlinkSync(linked); + symlinkSync(join(root, "second.yaml"), linked); + const second = await parseCapletSource(new FilesystemCapletSource(root)); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(first.runtimeFingerprint?.caplets.weather?.fingerprint).not.toBe( + second.runtimeFingerprint?.caplets.weather?.fingerprint, + ); + }); +}); + +function writeTree(files: Array<{ path: string; content: string }>): string { + const root = mkdtempSync(join(tmpdir(), "caplets-fingerprint-")); + tempDirs.push(root); + for (const file of files) { + const path = join(root, file.path); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, file.content, "utf8"); + } + return root; +} diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 8c303374..608f00be 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { serve, type WebSocketServerLike } from "@hono/node-server"; import { afterEach, describe, expect, it, vi } from "vitest"; import WebSocket, { WebSocketServer } from "ws"; @@ -2949,6 +2949,31 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("keeps the HTTP Attach revision stable after a README-only reload", async () => { + const context = testContext(); + const capletDir = join(dirname(context.configPath), "status"); + const capletPath = join(capletDir, "CAPLET.md"); + mkdirSync(capletDir, { recursive: true }); + writeFileSync(capletPath, httpReadmeCaplet("Initial operator notes.")); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + const initialResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest"); + const initial = (await initialResponse.json()) as AttachManifest; + + writeFileSync(capletPath, httpReadmeCaplet("Updated troubleshooting notes.")); + + await expect(engine.reload()).resolves.toBe(true); + const nextResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest"); + const next = (await nextResponse.json()) as AttachManifest; + expect(next.revision).toBe(initial.revision); + expect({ ...next, generatedAt: initial.generatedAt }).toEqual(initial); + await engine.close(); + }); + it("rejects an old HTTP Attach export after reload hides its Caplet", async () => { const config = { options: { exposure: "direct" }, @@ -4399,6 +4424,22 @@ function pairedClient( }); } +function httpReadmeCaplet(readme: string): string { + return [ + "---", + "name: Status", + "description: Read service status.", + "httpApi:", + " baseUrl: http://127.0.0.1:1", + " auth: { type: none }", + " actions:", + " check: { method: GET, path: /check }", + "---", + readme, + "", + ].join("\n"); +} + function tempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); dirs.push(dir); diff --git a/packages/core/test/setup-runner.test.ts b/packages/core/test/setup-runner.test.ts index 8d22b9ef..fdbffa8c 100644 --- a/packages/core/test/setup-runner.test.ts +++ b/packages/core/test/setup-runner.test.ts @@ -2,7 +2,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CapletConfig } from "../src/config"; import { runInteractiveSetup, runSetup, type SetupMcpUpsertOptions } from "../src/cli/setup"; import { capletSetupContentHash } from "../src/setup/hash"; import { LocalSetupStore } from "../src/setup/local-store"; @@ -57,10 +56,29 @@ describe("setup runner", () => { }, ); - it("changes content hash when setup metadata changes", () => { - const first = caplet("npm", ["install", "-g", "first"]); - const second = caplet("npm", ["install", "-g", "second"]); - expect(capletSetupContentHash(first)).not.toBe(capletSetupContentHash(second)); + it("uses only persistence-eligible producer fingerprints for setup identity", () => { + expect( + capletSetupContentHash({ + fingerprint: "stable-runtime-fingerprint", + persistenceEligible: true, + valid: true, + }), + ).toBe("stable-runtime-fingerprint"); + expect( + capletSetupContentHash({ + fingerprint: "must-not-persist", + persistenceEligible: false, + valid: true, + }), + ).toBe("live-only"); + expect( + capletSetupContentHash({ + fingerprint: "invalid-runtime-fingerprint", + persistenceEligible: true, + valid: false, + }), + ).toBe("live-only"); + expect(capletSetupContentHash(undefined)).toBe("live-only"); }); it("requires approval before commands run", async () => { @@ -1178,22 +1196,6 @@ function daemonConfig(serve: { }; } -function caplet(command: string, args: string[]): CapletConfig { - return { - server: "ast-grep", - backend: "mcp", - name: "ast-grep", - description: "Structural search", - transport: "stdio", - command: "ast-grep-mcp", - startupTimeoutMs: 10, - callTimeoutMs: 10, - toolCacheTtlMs: 10, - disabled: false, - setup: { commands: [{ label: "Install", command, args }] }, - }; -} - function successfulSpawn(): SetupSpawn { return async () => ({ exitCode: 0, stdout: "ok", stderr: "", durationMs: 1 }); } diff --git a/schemas/caplet.schema.json b/schemas/caplet.schema.json index 06170c4e..5c9db3a6 100644 --- a/schemas/caplet.schema.json +++ b/schemas/caplet.schema.json @@ -44,18 +44,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -957,18 +945,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1605,18 +1581,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2285,18 +2249,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2466,18 +2418,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -2772,18 +2712,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -3047,18 +2975,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -3380,18 +3296,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -3715,18 +3619,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -3869,18 +3761,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -4018,18 +3898,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -4210,18 +4078,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -4407,18 +4263,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -4717,18 +4561,6 @@ "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index cad9d03a..25339fc1 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -434,18 +434,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -856,18 +844,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1287,18 +1263,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -1543,18 +1507,6 @@ "description": "Operation capability description.", "type": "string", "minLength": 1 - }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 } }, "additionalProperties": false @@ -1753,18 +1705,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2166,18 +2106,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -2269,18 +2197,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2490,18 +2406,6 @@ "type": "string", "minLength": 1 }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "inputSchema": { "description": "JSON Schema for call_tool arguments.", "type": "object", @@ -2634,18 +2538,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { @@ -2897,18 +2789,6 @@ "type": "string", "enum": ["forbid", "allow", "namespace"] }, - "useWhen": { - "description": "When agents should prefer this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "avoidWhen": { - "description": "When agents should avoid this Caplet or configured action.", - "type": "string", - "minLength": 1, - "maxLength": 500 - }, "setup": { "type": "object", "properties": { diff --git a/scripts/generate-catalog-index.ts b/scripts/generate-catalog-index.ts index 27ed1ea9..70b48b89 100644 --- a/scripts/generate-catalog-index.ts +++ b/scripts/generate-catalog-index.ts @@ -93,12 +93,6 @@ export async function generateOfficialCatalogEntries(root: string): Promise([ sourcePath: "schemas/caplet.schema.json", canonicalUrl: "https://caplets.dev/caplet.schema.json", intro: - "Caplet files are Markdown files with YAML frontmatter. Store a single-file Caplet as `osv.md`, or use a folder with `CAPLET.md` when the capability needs nearby assets.", + "Caplet files are Markdown files with two independent projections. YAML frontmatter is the sole authority for runtime configuration. The Markdown body is a publishable human operator README rendered separately; it is never a runtime input, interpolation source, selection channel, Code Mode declaration, or agent context. Do not include secrets, credentials, private endpoints, customer data, or other sensitive operational material in the body. Store a single-file Caplet as `osv.md`, or use a folder with `CAPLET.md` when frontmatter references nearby runtime assets.\n\n`useWhen` and `avoidWhen` are no longer valid Caplet configuration fields. Put concise agent-facing capability context in `description`; put operator-only prerequisites, safety notes, troubleshooting, and references in the Markdown body.", }), ), ], @@ -235,8 +235,6 @@ Plural form child fields: | \`tags\` | Optional | array | Optional tags for grouping or searching Caplets. | | \`exposure\` | Optional | "direct" \\| "progressive" \\| "code_mode" \\| "direct_and_code_mode" \\| "progressive_and_code_mode" | How this Caplet is exposed to agents. | | \`shadowing\` | Optional | "forbid" \\| "allow" \\| "namespace" | Whether attached local Caplets may shadow this remote Caplet ID. | -| \`useWhen\` | Optional | string | When agents should prefer this Caplet or configured action. | -| \`avoidWhen\` | Optional | string | When agents should avoid this Caplet or configured action. | | \`setup\` | Optional | object | Optional explicit setup and verification metadata for this Caplet. | Plural \`cliTools\` is recognized only when the value is a child-ID map. \`actions\` is reserved for the singular form and cannot be used as a plural child ID.`; @@ -326,11 +324,29 @@ function commonSchemaRecipes(sourcePath: string): string { ' args: ["-y", "@modelcontextprotocol/server-everything"]', "---", "", - "Use this Caplet when an agent needs the example MCP tools.", + "# Example Tools", + "", + "## Prerequisites and setup context", + "", + "Confirm Node.js and package-registry access before enabling this example.", + "", + "## Safe operation and limits", + "", + "This example server is intended for evaluation. Review its exposed tools and permissions before use.", + "", + "## Troubleshooting", + "", + "If startup fails, verify the local Node.js installation and package-registry connectivity.", + "", + "## References", + "", + "See the package documentation for supported tools and limits.", "```", "", - "Use `useWhen` and `avoidWhen` to guide agent selection, and add `setup` metadata when a", - "Caplet needs local commands before it can run.", + "Runtime actions, paths, environment and authentication values, setup metadata, exposure,", + "and concise agent-facing capability descriptions belong in frontmatter. Treat the body as", + "publishable content: reserve it for human prerequisites, safety, troubleshooting, and", + "references, and do not include secrets or sensitive operational material.", "", "Project-bound Caplet with setup:", "", @@ -354,7 +370,23 @@ function commonSchemaRecipes(sourcePath: string): string { ' args: ["test"]', "---", "", - "Use this Caplet when an agent needs the current repository's local test signal.", + "# Repository Checks", + "", + "## Prerequisites and setup context", + "", + "Install the repository dependencies before running the configured checks.", + "", + "## Safe operation and limits", + "", + "Review the repository scripts before enabling them; they execute with the attached project's permissions.", + "", + "## Troubleshooting", + "", + "If a check cannot start, confirm the package manager and dependencies match the repository lockfile.", + "", + "## References", + "", + "The repository's `package.json` and lockfile document the available scripts and tool versions.", "```", "", "Provider suite with multiple runtime children:", @@ -390,7 +422,23 @@ function commonSchemaRecipes(sourcePath: string): string { " - https://www.googleapis.com/auth/gmail.readonly", "---", "", - "Use this Caplet when an agent needs Workspace context across mail and files.", + "# Google Workspace", + "", + "## Prerequisites and setup context", + "", + "Confirm the operator has approved the OAuth consent and scopes declared in frontmatter.", + "", + "## Safe operation and limits", + "", + "This suite exposes read-only message and file metadata operations. Handle returned workspace data according to organizational policy.", + "", + "## Troubleshooting", + "", + "If a child capability is unavailable, verify its discovery document and granted OAuth scope.", + "", + "## References", + "", + "Consult the Gmail and Drive API documentation for operation and quota details.", "```", "", "Installing the parent `google-workspace` copies the whole suite. Runtime handles are",