diff --git a/.caplets.lock.json b/.caplets.lock.json new file mode 100644 index 00000000..d4b08032 --- /dev/null +++ b/.caplets.lock.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "entries": [ + { + "id": "github", + "destination": "github", + "kind": "directory", + "source": { + "type": "local", + "path": "/tmp/caplets-cli-remote-install-explicit-project-0UFEXG/project/.caplets/repo/caplets/github", + "portability": "non_portable" + }, + "installedHash": "sha256:73c123669a3f22f683156dc2746d8b21442078be5e7463686506ae9057381cd6", + "installedAt": "2026-06-26T19:27:24.767Z", + "updatedAt": "2026-06-26T19:27:24.809Z", + "risk": { + "backendFamilies": ["mcp"], + "safety": "local_control", + "projectBindingRequired": false, + "mutating": false, + "destructive": false, + "bodyHash": "sha256:73c123669a3f22f683156dc2746d8b21442078be5e7463686506ae9057381cd6" + } + } + ] +} diff --git a/.changeset/bright-icons-wave.md b/.changeset/bright-icons-wave.md new file mode 100644 index 00000000..6cc46924 --- /dev/null +++ b/.changeset/bright-icons-wave.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": minor +--- + +Add optional `catalog.icon` metadata to Caplet files and catalog entries so catalog surfaces can show provider icons. diff --git a/.changeset/config.json b/.changeset/config.json index d94dbced..2cca2dcb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@caplets/landing", "@caplets/docs"] + "ignore": ["@caplets/landing", "@caplets/docs", "@caplets/catalog"] } diff --git a/.changeset/fuzzy-catalogs-smile.md b/.changeset/fuzzy-catalogs-smile.md new file mode 100644 index 00000000..f35895c9 --- /dev/null +++ b/.changeset/fuzzy-catalogs-smile.md @@ -0,0 +1,7 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add shared catalog primitives, official catalog generation, public catalog indexing statuses, +and install-time Vault setup recovery metadata for Caplets install/update flows. diff --git a/.changeset/prebuilt-caplets-lockfile-update.md b/.changeset/prebuilt-caplets-lockfile-update.md deleted file mode 100644 index 818e6b46..00000000 --- a/.changeset/prebuilt-caplets-lockfile-update.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@caplets/core": minor -"caplets": minor ---- - -Add lockfile-aware Caplet install, restore, and update workflows, including `caplets update`, JSON lifecycle output, remote-global catalog mutations, derived update risk checks, and new public catalog entries for browser, desktop, observability, and Google Workspace integrations. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 587bd9a6..c9f177c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: - name: Run quality gates run: pnpm verify - - name: Deploy landing page + - name: Deploy sites run: pnpm run alchemy:deploy env: ALCHEMY_STAGE: prod diff --git a/.gitignore b/.gitignore index c09ce3a0..9da68f70 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ benchmark-results/ # brag brag-output/ + +# wrangler +.wrangler/ + +# Test-generated Caplets Lockfiles +.caplets.lock.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 17a8580d..af2a8ae5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["**/.brv/**"], + "ignorePatterns": ["**/.brv/**", "apps/catalog/src/data/official-catalog.json"], "plugins": ["prettier-plugin-astro"], "overrides": [ { diff --git a/CONCEPTS.md b/CONCEPTS.md index 1bf3f2e6..74d1daab 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -16,12 +16,30 @@ The Prebuilt Caplets Catalog is curated as a Code Mode-first capability catalog, Install-ready catalog entries have an explicit verification status, a reproducible validation path, and a named primary Code Mode workflow. Unverified entries may exist as drafts or recipes, but they do not count as install-ready catalog coverage. +### Catalog Search Site + +The public search surface for installable Caplets at `catalog.caplets.dev`. + +The Catalog Search Site is separate from the landing page and docs site. It indexes official Caplets from this repo and community Caplets discovered through public external installs, with search, readable Caplet content, source labels, install-count popularity signals, warnings, and inspection-first copyable install commands as the primary user flow. + +### Public Catalog Indexing Signal + +The public-source install signal that lets `catalog.caplets.dev` discover and rank community Caplets. + +Public Catalog Indexing is not ordinary anonymous telemetry. It may publish public Caplet content, normalized install command, source identity needed to reproduce install, and aggregate install count; it must not publish installer identity, private source URLs, local paths, config, credentials, prompts, tool arguments, tool outputs, or hostnames. Catalog install counts are popularity and ranking signals, not safety signals, and public entries can be suppressed when automatic indexing creates stale, abusive, leaked, or high-risk catalog records. + ### Catalog-Grade Caplet A Caplet that is ready to live in the Prebuilt Caplets Catalog. Catalog-Grade Caplets include enough frontmatter, setup or verification guidance, auth handling, least-privilege scope notes, safety notes, Code Mode workflow guidance, and local/project/runtime metadata for agents to use them without rediscovering private assumptions from the author's environment. +### Catalog Presentation Metadata + +Optional Caplet frontmatter that improves how a Caplet appears in public catalog surfaces without changing runtime behavior, trust, safety status, ranking, or install readiness. + +In v1, Catalog Presentation Metadata is limited to `catalog.icon`, which may identify a safe HTTPS icon URL or a bundled icon path relative to the Caplet directory. + ### Caplets Lockfile A `caplets.lock.json` file that records installed catalog Caplets, their source repository, source path, destination, tracked source channel, resolved revision when available, content hash, and portability status. diff --git a/README.md b/README.md index 2848833d..20bbf861 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ caplets.dev · docs.caplets.dev + · + catalog.caplets.dev

diff --git a/alchemy.run.ts b/alchemy.run.ts index e1d5a405..cebb192a 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,5 +1,5 @@ import alchemy from "alchemy"; -import { Astro } from "alchemy/cloudflare"; +import { Astro, D1Database } from "alchemy/cloudflare"; import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; @@ -10,28 +10,52 @@ const app = await alchemy("caplets", { password: process.env.ALCHEMY_PASSWORD!, }); -const { docsPageDomain, docsPageUrl, landingPageDomain, landingPageUrl } = buildAlchemyDomains( - app.stage, - { local: app.local }, -); +const { + catalogPageDomain, + catalogPageUrl, + docsPageDomain, + docsPageUrl, + landingPageDomain, + landingPageUrl, +} = buildAlchemyDomains(app.stage, { local: app.local }); +const hostSuffix = process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""; export const landingPage = await Astro("landing-page", { cwd: "apps/landing", dev: { - command: "pnpm run dev" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), + command: "pnpm run dev --port 4321" + hostSuffix, }, domains: [landingPageDomain, `www.${landingPageDomain}`], }); export const docsPage = await Astro("docs-page", { cwd: "apps/docs", dev: { - command: "pnpm run dev -- --port 4322" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), + command: "pnpm run dev --port 4322" + hostSuffix, }, domains: [docsPageDomain], }); +export const catalogDatabase = await D1Database("catalog-database", { + name: `caplets-${app.stage}-catalog`, + migrationsDir: "apps/catalog/migrations", + adopt: true, + delete: false, +}); +export const catalogPage = await Astro("catalog-page", { + cwd: "apps/catalog", + entrypoint: "dist/server/entry.mjs", + assets: "dist/client", + dev: { + command: "pnpm run dev --port 4323" + hostSuffix, + }, + bindings: { + CATALOG_DB: catalogDatabase, + }, + domains: [catalogPageDomain], +}); console.log({ "Landing Page URL": landingPageUrl, "Docs Page URL": docsPageUrl, + "Catalog Page URL": catalogPageUrl, }); const [repositoryOwnerFromSlug, repositoryNameFromSlug] = @@ -55,6 +79,7 @@ if (pullRequestNumber) { Landing: ${landingPageUrl} Docs: ${docsPageUrl} +Catalog: ${catalogPageUrl} Built from commit ${shortSha}`, }); diff --git a/apps/catalog/astro.config.mjs b/apps/catalog/astro.config.mjs new file mode 100644 index 00000000..b8a170b5 --- /dev/null +++ b/apps/catalog/astro.config.mjs @@ -0,0 +1,50 @@ +// @ts-check +import cloudflare from "@astrojs/cloudflare"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "astro/config"; +import { fileURLToPath } from "node:url"; + +const cloudflareDevMiddleware = fileURLToPath( + new URL("./src/cloudflare-dev-middleware.ts", import.meta.url), +); +const cloudflareDevWorkers = fileURLToPath( + new URL("./src/cloudflare-workers-dev.ts", import.meta.url), +); +const isProductionBuild = process.env.NODE_ENV === "production"; +const optimizeExclude = [ + "tailwind-variants", + "unified", + "remark-parse", + "remark-rehype", + "rehype-sanitize", + "rehype-stringify", +]; + +export default defineConfig({ + site: "https://catalog.caplets.dev", + output: "server", + adapter: cloudflare(), + devToolbar: { + enabled: false, + }, + vite: { + build: { + assetsInlineLimit: 0, + }, + plugins: [tailwindcss()], + resolve: { + alias: { + "@astrojs/cloudflare/entrypoints/middleware.js": cloudflareDevMiddleware, + ...(isProductionBuild ? {} : { "cloudflare:workers": cloudflareDevWorkers }), + }, + }, + ssr: { + optimizeDeps: { + exclude: optimizeExclude, + }, + }, + optimizeDeps: { + exclude: optimizeExclude, + }, + }, +}); diff --git a/apps/catalog/migrations/0001_catalog.sql b/apps/catalog/migrations/0001_catalog.sql new file mode 100644 index 00000000..9c93b2cf --- /dev/null +++ b/apps/catalog/migrations/0001_catalog.sql @@ -0,0 +1,38 @@ +create table if not exists catalog_counts ( + entry_key text primary key, + install_count integer not null default 0, + updated_at_ms integer not null +); + +create table if not exists catalog_entries ( + entry_key text primary key, + provider text not null, + repository text not null, + source_path text not null, + caplet_id text not null, + resolved_revision text, + content_hash text, + entry_json text not null, + updated_at_ms integer not null +); + +create table if not exists catalog_signal_dedupe ( + entry_key text not null, + signal_fingerprint text not null, + provider text not null, + repository text not null, + window_start_ms integer not null, + accepted_at_ms integer not null, + primary key (entry_key, signal_fingerprint, window_start_ms) +); + +create table if not exists catalog_suppressions ( + entry_key text primary key, + reason text not null, + suppressed_at text not null +); + +create index if not exists catalog_counts_rank_idx on catalog_counts (install_count desc); +create index if not exists catalog_entries_repository_idx on catalog_entries (provider, repository); +create index if not exists catalog_signal_dedupe_entry_time_idx on catalog_signal_dedupe (entry_key, accepted_at_ms); +create index if not exists catalog_signal_dedupe_repository_window_idx on catalog_signal_dedupe (provider, repository, window_start_ms); diff --git a/apps/catalog/package.json b/apps/catalog/package.json new file mode 100644 index 00000000..2dc85314 --- /dev/null +++ b/apps/catalog/package.json @@ -0,0 +1,41 @@ +{ + "name": "@caplets/catalog", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "astro": "astro", + "build": "astro build", + "dev": "astro dev", + "preview": "astro preview", + "test": "vitest run test", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.9", + "@astrojs/cloudflare": "^13.7.0", + "@caplets/core": "workspace:*", + "@hugeicons/core-free-icons": "^4.2.2", + "@tabler/icons": "^3.44.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/vite": "^4.3.1", + "@tanstack/virtual-core": "^3.17.2", + "astro": "^6.4.6", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.1", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3", + "unified": "^11.0.5" + }, + "devDependencies": { + "happy-dom": "^20.10.6", + "vite": "^7.3.5", + "vitest": "^4.1.8", + "wrangler": "^4.105.0" + }, + "packageManager": "pnpm@11.7.0" +} diff --git a/apps/catalog/public/icon-header-dark.png b/apps/catalog/public/icon-header-dark.png new file mode 100644 index 00000000..7f54aa66 Binary files /dev/null and b/apps/catalog/public/icon-header-dark.png differ diff --git a/apps/catalog/public/icon.png b/apps/catalog/public/icon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/catalog/public/icon.png differ diff --git a/apps/catalog/src/cloudflare-dev-middleware.ts b/apps/catalog/src/cloudflare-dev-middleware.ts new file mode 100644 index 00000000..07e111b0 --- /dev/null +++ b/apps/catalog/src/cloudflare-dev-middleware.ts @@ -0,0 +1,3 @@ +import type { MiddlewareHandler } from "astro"; + +export const onRequest: MiddlewareHandler = (_context, next) => next(); diff --git a/apps/catalog/src/cloudflare-workers-dev.ts b/apps/catalog/src/cloudflare-workers-dev.ts new file mode 100644 index 00000000..1c60631f --- /dev/null +++ b/apps/catalog/src/cloudflare-workers-dev.ts @@ -0,0 +1 @@ +export const env = {}; diff --git a/apps/catalog/src/components/CapletDetail.astro b/apps/catalog/src/components/CapletDetail.astro new file mode 100644 index 00000000..bc2435d1 --- /dev/null +++ b/apps/catalog/src/components/CapletDetail.astro @@ -0,0 +1,134 @@ +--- +import { ChevronRightIcon } from "@hugeicons/core-free-icons"; +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import { renderCatalogMarkdown, splitCatalogMarkdown } from "../lib/markdown"; +import HugeIcon from "./HugeIcon.astro"; +import InstallCommand from "./InstallCommand.astro"; +import SafetyNotice from "./SafetyNotice.astro"; +import { Badge } from "./starwind/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "./starwind/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./starwind/table"; + +type Props = { + entry: CatalogEntryRecord; +}; + +const { entry } = Astro.props; +const markdownParts = entry.contentMarkdown ? splitCatalogMarkdown(entry.contentMarkdown) : undefined; +const renderedContent = markdownParts?.bodyMarkdown + ? await renderCatalogMarkdown(markdownParts.bodyMarkdown) + : undefined; +const frontmatterRows = markdownParts?.frontmatterRows ?? []; +const hasReadableContent = Boolean(renderedContent) || frontmatterRows.length > 0; +const detailCommand = hasReadableContent + ? entry.installCommand + : { ...entry.installCommand, copyable: false }; +--- + +
+ +
+
+ {entry.icon ? ( + + ) : ( + + )} +
+
+ {entry.trustLevel} + {entry.source.repository} +
+

{entry.name}

+

{entry.description}

+
+
+
+ Install + +
+
+ +
+
+ + + + CAPLET.md + + + { + hasReadableContent ? ( + <> + {frontmatterRows.length > 0 && ( +
+

Frontmatter

+
+ + + + Field + Value + + + + {frontmatterRows.map((row) => ( + + + {row.value} + + ))} + +
+ {row.key} +
+
+
+ )} + {renderedContent &&
} + + ) : ( +
+

Content unavailable

+

This entry cannot be installed from the catalog until readable Caplet content is available for inspection.

+
+ ) + } + + +
+ +
+
diff --git a/apps/catalog/src/components/CapletResult.astro b/apps/catalog/src/components/CapletResult.astro new file mode 100644 index 00000000..99f2eec2 --- /dev/null +++ b/apps/catalog/src/components/CapletResult.astro @@ -0,0 +1,50 @@ +--- +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import InstallCommand from "./InstallCommand.astro"; +import SafetyNotice from "./SafetyNotice.astro"; +import { Badge } from "./starwind/badge"; +import { Button } from "./starwind/button"; + +type Props = { + entry: CatalogEntryRecord; +}; + +const { entry } = Astro.props; +const detailHref = `/caplets/${encodeURIComponent(entry.entryKey)}/`; +--- + +
+
+
+ {entry.name} + {entry.trustLevel} +
+

{entry.description}

+
+ {entry.source.repository} + {entry.workflow.label} + {entry.installCountDisplay} installs + {entry.authReadiness === "required" ? "auth required" : "auth ready"} +
+ {entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => {tag})} +
+ )} +
+
+ + + +
+
diff --git a/apps/catalog/src/components/CatalogHeader.astro b/apps/catalog/src/components/CatalogHeader.astro new file mode 100644 index 00000000..47522eac --- /dev/null +++ b/apps/catalog/src/components/CatalogHeader.astro @@ -0,0 +1,235 @@ +--- +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; +import ThemeToggle from "./ThemeToggle.astro"; + +type SearchProps = { + count?: number; + value?: string; +}; + +type Props = { + search?: SearchProps; +}; + +const { search } = Astro.props; +--- + +
+ + + Caplets Catalog + + { + search ? ( + + ) : null + } +
+ +
+
+ + + + diff --git a/apps/catalog/src/components/FilterBar.astro b/apps/catalog/src/components/FilterBar.astro new file mode 100644 index 00000000..98100d16 --- /dev/null +++ b/apps/catalog/src/components/FilterBar.astro @@ -0,0 +1,46 @@ +--- +import { Select, SelectContent, SelectItem, SelectSearch, SelectTrigger, SelectValue } from "./starwind/select"; + +const tags = Astro.props.tags as string[]; +--- + +
+ + +
+ Tag + + +
+ +
diff --git a/apps/catalog/src/components/HugeIcon.astro b/apps/catalog/src/components/HugeIcon.astro new file mode 100644 index 00000000..ed76d281 --- /dev/null +++ b/apps/catalog/src/components/HugeIcon.astro @@ -0,0 +1,39 @@ +--- +type IconNode = readonly [string, Readonly>]; + +type Props = { + icon: readonly IconNode[]; + label?: string; + class?: string; +}; + +const { icon, label, class: className } = Astro.props; + +function svgAttrs(attrs: IconNode[1]): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (key === "key") continue; + normalized[key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)] = value; + } + return normalized; +} +--- + + + { + icon.map(([, attrs]) => { + const pathAttrs = svgAttrs(attrs); + return ; + }) + } + diff --git a/apps/catalog/src/components/InstallCommand.astro b/apps/catalog/src/components/InstallCommand.astro new file mode 100644 index 00000000..7562ca40 --- /dev/null +++ b/apps/catalog/src/components/InstallCommand.astro @@ -0,0 +1,34 @@ +--- +import type { CatalogInstallCommand } from "@caplets/core/catalog"; +import { Copy01Icon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; +import { Button } from "./starwind/button"; + +type Props = { + command: CatalogInstallCommand; + detail?: boolean; +}; + +const { command, detail = false } = Astro.props; +--- + +
+ {command.text || "Install command unavailable"} + { + detail && command.copyable ? ( + + ) : detail ? ( + Copy unavailable + ) : ( + Preview + ) + } +
diff --git a/apps/catalog/src/components/ResultList.astro b/apps/catalog/src/components/ResultList.astro new file mode 100644 index 00000000..c987c0c1 --- /dev/null +++ b/apps/catalog/src/components/ResultList.astro @@ -0,0 +1,112 @@ +--- +import HugeIcon from "./HugeIcon.astro"; +import type { CatalogSearchRow } from "../lib/search-row"; +import { AlertCircleIcon, catalogStatusIcons, catalogTrustIcons, Copy01Icon } from "../lib/status-icons"; + +type Props = { + rows: CatalogSearchRow[]; +}; + +const { rows } = Astro.props; +const initialRows = rows.slice(0, 24); +--- + +
+
+
+ Caplet + Description + Installs + Status + Install +
+
+
+ {initialRows.map((row, index) => ( +
+
+ {row.icon ? ( + + ) : ( + + )} + +
+

{row.description}

+
{row.installCountDisplay}
+
+ + + + {row.statuses.map((status) => ( + + + {status.label} + + ))} +
+
+ {row.installCommandCopyable ? ( + + ) : ( + {row.installCommandPreview} + )} +
+
+ ))} +
+
+
+ +
diff --git a/apps/catalog/src/components/SafetyNotice.astro b/apps/catalog/src/components/SafetyNotice.astro new file mode 100644 index 00000000..1e3bfa5b --- /dev/null +++ b/apps/catalog/src/components/SafetyNotice.astro @@ -0,0 +1,33 @@ +--- +import type { CatalogWarning } from "@caplets/core/catalog"; +import { AlertCircleIcon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; + +type Props = { + warnings: CatalogWarning[]; + compact?: boolean; +}; + +const { compact = false, warnings } = Astro.props; +--- + +{ + warnings.length > 0 && ( +
+
+ +

{compact ? "Warnings" : "Inspect before installing"}

+
+
    + {warnings.map((warning) => ( +
  • + {warning.label} + {!compact && {warning.message}} +
  • + ))} +
+
+ ) +} diff --git a/apps/catalog/src/components/SearchShell.astro b/apps/catalog/src/components/SearchShell.astro new file mode 100644 index 00000000..6fd5b806 --- /dev/null +++ b/apps/catalog/src/components/SearchShell.astro @@ -0,0 +1,53 @@ +--- +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import { catalogSearchRowsFromEntries } from "../lib/search-row"; +import { catalogTrustIcons } from "../lib/status-icons"; +import FilterBar from "./FilterBar.astro"; +import ResultList from "./ResultList.astro"; +import HugeIcon from "./HugeIcon.astro"; +import { + AlertCircleIcon, + ComputerUserIcon, + DatabaseSyncIcon, + Key01Icon, + Link01Icon, + Settings02Icon, +} from "@hugeicons/core-free-icons"; + +type Props = { + entries: CatalogEntryRecord[]; +}; + +const { entries } = Astro.props; +const rows = catalogSearchRowsFromEntries(entries); +const tags = [...new Set(rows.flatMap((entry) => entry.tags))].sort((left, right) => + left.localeCompare(right), +); +const indexJson = JSON.stringify(rows).replace(/ +
+ +
+ {legend.map((item) => ( + + + {item.label} + + ))} +
+
+ diff --git a/apps/catalog/src/components/ThemeToggle.astro b/apps/catalog/src/components/ThemeToggle.astro new file mode 100644 index 00000000..7e552903 --- /dev/null +++ b/apps/catalog/src/components/ThemeToggle.astro @@ -0,0 +1,96 @@ +--- +import { + ArrowDown01Icon, + ComputerIcon, + Moon02Icon, + Sun01Icon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; + +const options = [ + { value: "light", label: "Light", icon: Sun01Icon }, + { value: "dark", label: "Dark", icon: Moon02Icon }, + { value: "system", label: "System", icon: ComputerIcon }, +]; +--- + +
+ + + +
+ + + + diff --git a/apps/catalog/src/components/starwind/badge/Badge.astro b/apps/catalog/src/components/starwind/badge/Badge.astro new file mode 100644 index 00000000..dcdf7a2e --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/Badge.astro @@ -0,0 +1,17 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { badge } from "./variants"; + +interface Props + extends HTMLAttributes<"div">, Omit, "type">, VariantProps {} + +const { variant, size, class: className, ...rest } = Astro.props; +const isLink = Astro.props.href ? true : false; +const Tag = Astro.props.href ? "a" : "div"; +--- + + + + diff --git a/apps/catalog/src/components/starwind/badge/index.ts b/apps/catalog/src/components/starwind/badge/index.ts new file mode 100644 index 00000000..53fcc369 --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/index.ts @@ -0,0 +1,7 @@ +import Badge from "./Badge.astro"; +import { badge } from "./variants"; +const BadgeVariants = { badge }; + +export { Badge, BadgeVariants }; + +export default Badge; diff --git a/apps/catalog/src/components/starwind/badge/variants.ts b/apps/catalog/src/components/starwind/badge/variants.ts new file mode 100644 index 00000000..3a9358ce --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/variants.ts @@ -0,0 +1,41 @@ +import { tv } from "tailwind-variants"; + +export const badge = tv({ + base: [ + "starwind-badge inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap", + "[&_svg]:pointer-events-none [&_svg]:shrink-0", + "transition-all outline-none focus-visible:ring-3", + "aria-invalid:border-error aria-invalid:focus-visible:ring-error/40", + ], + variants: { + variant: { + default: "bg-foreground text-background focus-visible:ring-outline/50", + primary: "bg-primary text-primary-foreground focus-visible:ring-primary/50", + secondary: "bg-secondary text-secondary-foreground focus-visible:ring-secondary/50", + outline: "border-border focus-visible:border-outline focus-visible:ring-outline/50 border", + ghost: "bg-foreground/10 text-foreground focus-visible:ring-outline/50", + info: "bg-info text-info-foreground focus-visible:ring-info/50", + success: "bg-success text-success-foreground focus-visible:ring-success/50", + warning: "bg-warning text-warning-foreground focus-visible:ring-warning/50", + error: "bg-error text-error-foreground focus-visible:ring-error/50", + }, + size: { + sm: "px-2.5 py-0.5 text-xs [&_svg:not([class*='size-'])]:size-3", + md: "px-3 py-0.5 text-sm [&_svg:not([class*='size-'])]:size-4", + lg: "px-4 py-1 text-base [&_svg:not([class*='size-'])]:size-4.5", + }, + isLink: { true: "cursor-pointer", false: "" }, + }, + compoundVariants: [ + { isLink: true, variant: "default", className: "hover:bg-foreground/80" }, + { isLink: true, variant: "primary", className: "hover:bg-primary/80" }, + { isLink: true, variant: "secondary", className: "hover:bg-secondary/80" }, + { isLink: true, variant: "outline", className: "hover:border-border/80" }, + { isLink: true, variant: "ghost", className: "hover:bg-foreground/7" }, + { isLink: true, variant: "info", className: "hover:bg-info/80" }, + { isLink: true, variant: "success", className: "hover:bg-success/80" }, + { isLink: true, variant: "warning", className: "hover:bg-warning/80" }, + { isLink: true, variant: "error", className: "hover:bg-error/80" }, + ], + defaultVariants: { variant: "default", size: "md", isLink: false }, +}); diff --git a/apps/catalog/src/components/starwind/button/Button.astro b/apps/catalog/src/components/starwind/button/Button.astro new file mode 100644 index 00000000..b1e00cd0 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/Button.astro @@ -0,0 +1,26 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { button } from "./variants"; + +interface Props + extends + HTMLAttributes<"button">, + Omit, "type">, + VariantProps {} + +const { variant, size, class: className, type, ...rest } = Astro.props; + +const isLink = Boolean(Astro.props.href); +const Tag = isLink ? "a" : "button"; +--- + + + + diff --git a/apps/catalog/src/components/starwind/button/index.ts b/apps/catalog/src/components/starwind/button/index.ts new file mode 100644 index 00000000..16de9b64 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/index.ts @@ -0,0 +1,7 @@ +import Button from "./Button.astro"; +import { button } from "./variants"; +const ButtonVariants = { button }; + +export { Button, ButtonVariants }; + +export default Button; diff --git a/apps/catalog/src/components/starwind/button/variants.ts b/apps/catalog/src/components/starwind/button/variants.ts new file mode 100644 index 00000000..23e8b831 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/variants.ts @@ -0,0 +1,38 @@ +import { tv } from "tailwind-variants"; + +export const button = tv({ + base: [ + "inline-flex items-center justify-center gap-1.5 rounded-md font-medium whitespace-nowrap", + "[&_svg]:pointer-events-none [&_svg]:shrink-0", + "transition-all outline-none focus-visible:ring-3", + "disabled:pointer-events-none disabled:opacity-50", + "aria-invalid:border-error aria-invalid:focus-visible:ring-error/40", + ], + variants: { + variant: { + default: "bg-foreground text-background hover:bg-foreground/90 focus-visible:ring-outline/50", + primary: + "bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/90 focus-visible:ring-secondary/50", + outline: + "dark:border-input focus-visible:ring-outline/50 bg-background dark:bg-input/30 focus-visible:border-outline hover:bg-muted dark:hover:bg-input/50 hover:text-foreground border shadow-xs", + ghost: "hover:bg-muted hover:text-foreground focus-visible:ring-outline/50", + info: "bg-info text-info-foreground hover:bg-info/90 focus-visible:ring-info/50", + success: + "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/50", + warning: + "bg-warning text-warning-foreground hover:bg-warning/90 focus-visible:ring-warning/50", + error: "bg-error text-error-foreground hover:bg-error/90 focus-visible:ring-error/50", + }, + size: { + sm: "h-9 px-4 text-sm has-[>svg]:px-3 [&_svg:not([class*='size-'])]:size-3.5", + md: "h-11 px-5 text-base has-[>svg]:px-4 [&_svg:not([class*='size-'])]:size-4.5", + lg: "h-12 px-8 text-lg has-[>svg]:px-6 [&_svg:not([class*='size-'])]:size-5", + "icon-sm": "size-9 [&_svg:not([class*='size-'])]:size-3.5", + icon: "size-11 [&_svg:not([class*='size-'])]:size-4.5", + "icon-lg": "size-12 [&_svg:not([class*='size-'])]:size-5", + }, + }, + defaultVariants: { variant: "default", size: "md" }, +}); diff --git a/apps/catalog/src/components/starwind/card/Card.astro b/apps/catalog/src/components/starwind/card/Card.astro new file mode 100644 index 00000000..8af25541 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/Card.astro @@ -0,0 +1,19 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { card } from "./variants"; + +type Props = HTMLAttributes<"div"> & VariantProps; + +const { class: className, size, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardAction.astro b/apps/catalog/src/components/starwind/card/CardAction.astro new file mode 100644 index 00000000..17965904 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardAction.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardAction } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardContent.astro b/apps/catalog/src/components/starwind/card/CardContent.astro new file mode 100644 index 00000000..365e809c --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardContent.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardContent } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardDescription.astro b/apps/catalog/src/components/starwind/card/CardDescription.astro new file mode 100644 index 00000000..8b5079b3 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardDescription.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardDescription } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardFooter.astro b/apps/catalog/src/components/starwind/card/CardFooter.astro new file mode 100644 index 00000000..1ba4323e --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardFooter.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardFooter } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardHeader.astro b/apps/catalog/src/components/starwind/card/CardHeader.astro new file mode 100644 index 00000000..011353be --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardHeader.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardHeader } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardTitle.astro b/apps/catalog/src/components/starwind/card/CardTitle.astro new file mode 100644 index 00000000..25f74e97 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardTitle.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardTitle } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/index.ts b/apps/catalog/src/components/starwind/card/index.ts new file mode 100644 index 00000000..747f5313 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/index.ts @@ -0,0 +1,46 @@ +import Card from "./Card.astro"; +import CardAction from "./CardAction.astro"; +import CardContent from "./CardContent.astro"; +import CardDescription from "./CardDescription.astro"; +import CardFooter from "./CardFooter.astro"; +import CardHeader from "./CardHeader.astro"; +import CardTitle from "./CardTitle.astro"; +import { + card, + cardAction, + cardContent, + cardDescription, + cardFooter, + cardHeader, + cardTitle, +} from "./variants"; +const CardVariants = { + card, + cardAction, + cardContent, + cardDescription, + cardFooter, + cardHeader, + cardTitle, +}; + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + CardVariants, +}; + +export default { + Root: Card, + Header: CardHeader, + Footer: CardFooter, + Title: CardTitle, + Description: CardDescription, + Content: CardContent, + Action: CardAction, +}; diff --git a/apps/catalog/src/components/starwind/card/variants.ts b/apps/catalog/src/components/starwind/card/variants.ts new file mode 100644 index 00000000..5c803154 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/variants.ts @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +export const card = tv({ + base: [ + "bg-card text-card-foreground group/card ring-border flex flex-col rounded-xl ring-1", + "has-[[data-slot=card-footer]]:pb-0 has-[>img:first-child]:pt-0", + "*:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + ], + variants: { + size: { + default: "gap-6 py-6", + sm: "gap-4 py-4 text-sm", + }, + }, + defaultVariants: { + size: "default", + }, +}); + +export const cardAction = tv({ + base: "col-start-2 row-span-2 row-start-1 self-start justify-self-end", +}); + +export const cardContent = tv({ + base: "px-6 group-data-[size=sm]/card:px-4", +}); + +export const cardDescription = tv({ + base: "text-muted-foreground text-base group-data-[size=sm]/card:text-sm", +}); + +export const cardFooter = tv({ + base: "bg-muted/50 flex items-center rounded-b-xl border-t p-6 group-data-[size=sm]/card:p-4", +}); + +export const cardHeader = tv({ + base: [ + "@container/card-header grid auto-rows-min items-start gap-1 px-6 group-data-[size=sm]/card:px-4", + "has-[[data-slot=card-action]]:grid-cols-[1fr_auto] has-[[data-slot=card-description]]:grid-rows-[auto_auto]", + ], +}); + +export const cardTitle = tv({ + base: "font-heading text-xl leading-snug font-medium group-data-[size=sm]/card:text-base", +}); diff --git a/apps/catalog/src/components/starwind/select/Select.astro b/apps/catalog/src/components/starwind/select/Select.astro new file mode 100644 index 00000000..ea3090cd --- /dev/null +++ b/apps/catalog/src/components/starwind/select/Select.astro @@ -0,0 +1,757 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"div"> & { + /** + * The name for the hidden + + + diff --git a/apps/catalog/src/components/starwind/select/SelectSeparator.astro b/apps/catalog/src/components/starwind/select/SelectSeparator.astro new file mode 100644 index 00000000..3354b238 --- /dev/null +++ b/apps/catalog/src/components/starwind/select/SelectSeparator.astro @@ -0,0 +1,11 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { selectSeparator } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
diff --git a/apps/catalog/src/components/starwind/select/SelectTrigger.astro b/apps/catalog/src/components/starwind/select/SelectTrigger.astro new file mode 100644 index 00000000..2bb99397 --- /dev/null +++ b/apps/catalog/src/components/starwind/select/SelectTrigger.astro @@ -0,0 +1,36 @@ +--- +import ChevronDown from "@tabler/icons/outline/chevron-down.svg"; +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { selectTrigger } from "./variants"; + +type Props = Omit, "role" | "type"> & + VariantProps & { + /** + * The content to be rendered inside the select trigger + */ + children: any; + }; + +const { class: className, size, ...rest } = Astro.props; +--- + + diff --git a/apps/catalog/src/components/starwind/select/SelectTypes.ts b/apps/catalog/src/components/starwind/select/SelectTypes.ts new file mode 100644 index 00000000..a6bf64b8 --- /dev/null +++ b/apps/catalog/src/components/starwind/select/SelectTypes.ts @@ -0,0 +1,13 @@ +export interface SelectChangeEvent extends CustomEvent { + detail: { + value: string; + selectId: string; + label: string; + }; +} + +export interface SelectEvent extends CustomEvent { + detail: + | { value: string; selectId: string; selectName?: string } + | { value: string; selectId?: string; selectName: string }; +} diff --git a/apps/catalog/src/components/starwind/select/SelectValue.astro b/apps/catalog/src/components/starwind/select/SelectValue.astro new file mode 100644 index 00000000..28061a95 --- /dev/null +++ b/apps/catalog/src/components/starwind/select/SelectValue.astro @@ -0,0 +1,18 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { selectValue } from "./variants"; + +type Props = HTMLAttributes<"span"> & { + /** + * The text to display when no value is selected + */ + placeholder?: string; +}; + +const { placeholder = "select", class: className, ...rest } = Astro.props; +--- + + + {placeholder} + diff --git a/apps/catalog/src/components/starwind/select/index.ts b/apps/catalog/src/components/starwind/select/index.ts new file mode 100644 index 00000000..beb6c75b --- /dev/null +++ b/apps/catalog/src/components/starwind/select/index.ts @@ -0,0 +1,59 @@ +import Select from "./Select.astro"; +import SelectContent from "./SelectContent.astro"; +import SelectGroup from "./SelectGroup.astro"; +import SelectItem from "./SelectItem.astro"; +import SelectLabel from "./SelectLabel.astro"; +import SelectSearch from "./SelectSearch.astro"; +import SelectSeparator from "./SelectSeparator.astro"; +import SelectTrigger from "./SelectTrigger.astro"; +import type { SelectChangeEvent, SelectEvent } from "./SelectTypes"; +import SelectValue from "./SelectValue.astro"; +import { + selectContent, + selectContentInner, + selectItem, + selectItemIcon, + selectLabel, + selectSearch, + selectSeparator, + selectTrigger, + selectValue, +} from "./variants"; +const SelectVariants = { + selectContent, + selectContentInner, + selectItem, + selectItemIcon, + selectLabel, + selectSearch, + selectSeparator, + selectTrigger, + selectValue, +}; + +export { + Select, + type SelectChangeEvent, + SelectContent, + type SelectEvent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSearch, + SelectSeparator, + SelectTrigger, + SelectValue, + SelectVariants, +}; + +export default { + Root: Select, + Trigger: SelectTrigger, + Value: SelectValue, + Content: SelectContent, + Group: SelectGroup, + Label: SelectLabel, + Search: SelectSearch, + Item: SelectItem, + Separator: SelectSeparator, +}; diff --git a/apps/catalog/src/components/starwind/select/variants.ts b/apps/catalog/src/components/starwind/select/variants.ts new file mode 100644 index 00000000..5059bbf2 --- /dev/null +++ b/apps/catalog/src/components/starwind/select/variants.ts @@ -0,0 +1,77 @@ +import { tv } from "tailwind-variants"; + +export const selectContent = tv({ + base: [ + "starwind-select-content", + "bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border shadow-md", + "data-[state=open]:animate-in fade-in zoom-in-95 overflow-hidden will-change-transform", + "data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards fade-out zoom-out-95", + ], + variants: { + side: { + bottom: "slide-in-from-top-2 slide-out-to-top-2 top-full", + top: "slide-in-from-bottom-2 slide-out-to-bottom-2 bottom-full", + }, + align: { + start: "slide-in-from-left-1 slide-out-to-left-1 left-0", + center: "left-1/2 -translate-x-1/2", + end: "slide-in-from-right-1 slide-out-to-right-1 right-0", + }, + size: { + sm: "text-sm [&_[data-slot=select-label]]:text-xs", + md: "text-base [&_[data-slot=select-label]]:text-sm", + lg: "text-lg [&_[data-slot=select-label]]:text-base", + }, + }, + defaultVariants: { side: "bottom", align: "start", size: "md" }, +}); + +export const selectContentInner = tv({ + base: "max-h-96 w-full min-w-(--select-trigger-width) overflow-y-auto p-1", +}); + +export const selectItem = tv({ + base: [ + "relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-8 pl-2 outline-none select-none", + "data-[active]:bg-accent data-[active]:text-accent-foreground", + "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + "not-aria-selected:[&_svg]:hidden aria-selected:[&_svg]:flex", + ], +}); + +export const selectItemIcon = tv({ + base: "absolute right-2 flex size-4 items-center justify-center", +}); + +export const selectLabel = tv({ base: "text-muted-foreground py-1.5 pr-8 pl-2" }); + +export const selectSearch = tv({ + base: [ + "placeholder:text-muted-foreground flex w-full border-0 bg-transparent px-0 py-2.5", + "ring-0 outline-none disabled:cursor-not-allowed disabled:opacity-50", + ], +}); + +export const selectSeparator = tv({ base: "bg-muted -mx-1 my-1 h-px" }); + +export const selectTrigger = tv({ + base: [ + "starwind-select-trigger", + "border-input dark:bg-input/30 text-foreground ring-offset-background flex items-center justify-between gap-2 rounded-md border bg-transparent shadow-xs", + "focus-visible:border-outline focus-visible:ring-outline/50 transition-[color,box-shadow] outline-none focus-visible:ring-3", + "disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + "aria-invalid:border-error aria-invalid:focus:ring-error/40 aria-invalid:focus:ring-3", + ], + variants: { + size: { + sm: "h-9 px-2 text-sm", + md: "h-11 px-3 text-base", + lg: "h-12 px-4 text-lg", + }, + }, + defaultVariants: { + size: "md", + }, +}); + +export const selectValue = tv({ base: "pointer-events-none" }); diff --git a/apps/catalog/src/components/starwind/table/Table.astro b/apps/catalog/src/components/starwind/table/Table.astro new file mode 100644 index 00000000..3afe2506 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/Table.astro @@ -0,0 +1,16 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { table } from "./variants"; + +type Props = HTMLAttributes<"table"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + +
+ + +
+
diff --git a/apps/catalog/src/components/starwind/table/TableBody.astro b/apps/catalog/src/components/starwind/table/TableBody.astro new file mode 100644 index 00000000..8638ba56 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableBody.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableBody } from "./variants"; + +type Props = HTMLAttributes<"tbody"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableCaption.astro b/apps/catalog/src/components/starwind/table/TableCaption.astro new file mode 100644 index 00000000..6a49a3bc --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableCaption.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableCaption } from "./variants"; + +type Props = HTMLAttributes<"caption"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableCell.astro b/apps/catalog/src/components/starwind/table/TableCell.astro new file mode 100644 index 00000000..e244f175 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableCell.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableCell } from "./variants"; + +type Props = HTMLAttributes<"td"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableFoot.astro b/apps/catalog/src/components/starwind/table/TableFoot.astro new file mode 100644 index 00000000..70e7ab10 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableFoot.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableFoot } from "./variants"; + +type Props = HTMLAttributes<"tfoot"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableHead.astro b/apps/catalog/src/components/starwind/table/TableHead.astro new file mode 100644 index 00000000..12a69f29 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableHead.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableHead } from "./variants"; + +type Props = HTMLAttributes<"th"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableHeader.astro b/apps/catalog/src/components/starwind/table/TableHeader.astro new file mode 100644 index 00000000..59bff533 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableHeader.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableHeader } from "./variants"; + +type Props = HTMLAttributes<"thead"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableRow.astro b/apps/catalog/src/components/starwind/table/TableRow.astro new file mode 100644 index 00000000..f98dd1ac --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableRow.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableRow } from "./variants"; + +type Props = HTMLAttributes<"tr"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/index.ts b/apps/catalog/src/components/starwind/table/index.ts new file mode 100644 index 00000000..8c021208 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/index.ts @@ -0,0 +1,51 @@ +import Table from "./Table.astro"; +import TableBody from "./TableBody.astro"; +import TableCaption from "./TableCaption.astro"; +import TableCell from "./TableCell.astro"; +import TableFoot from "./TableFoot.astro"; +import TableHead from "./TableHead.astro"; +import TableHeader from "./TableHeader.astro"; +import TableRow from "./TableRow.astro"; +import { + table, + tableBody, + tableCaption, + tableCell, + tableFoot, + tableHead, + tableHeader, + tableRow, +} from "./variants"; +const TableVariants = { + table, + tableBody, + tableCaption, + tableCell, + tableFoot, + tableHead, + tableHeader, + tableRow, +}; + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFoot, + TableHead, + TableHeader, + TableRow, + TableVariants, +}; + +export default { + Root: Table, + Body: TableBody, + Caption: TableCaption, + Cell: TableCell, + Foot: TableFoot, + Head: TableHead, + Header: TableHeader, + Row: TableRow, +}; diff --git a/apps/catalog/src/components/starwind/table/variants.ts b/apps/catalog/src/components/starwind/table/variants.ts new file mode 100644 index 00000000..9e80ed12 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/variants.ts @@ -0,0 +1,33 @@ +import { tv } from "tailwind-variants"; + +export const table = tv({ + base: "w-full caption-bottom text-sm", +}); + +export const tableBody = tv({ + base: "[&_tr:last-child]:border-0", +}); + +export const tableCaption = tv({ + base: "text-muted-foreground mt-4 text-sm", +}); + +export const tableCell = tv({ + base: "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", +}); + +export const tableFoot = tv({ + base: "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", +}); + +export const tableHead = tv({ + base: "text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", +}); + +export const tableHeader = tv({ + base: "[&_tr]:border-b", +}); + +export const tableRow = tv({ + base: "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", +}); diff --git a/apps/catalog/src/data/official-catalog.json b/apps/catalog/src/data/official-catalog.json new file mode 100644 index 00000000..7e5a43b6 --- /dev/null +++ b/apps/catalog/src/data/official-catalog.json @@ -0,0 +1,1279 @@ +[ + { + "entryKey": "github:spiritledsoftware:caplets:ast-grep%2Fcaplet.md:ast-grep", + "id": "ast-grep", + "name": "ast-grep", + "description": "Search, scan, test, rewrite, and scaffold ast-grep rules through curated MCP tools.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://ast-grep.github.io/logo.svg" + }, + "tags": [ + "code", + "mcp", + "search" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets ast-grep", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:browser-use%2Fcaplet.md:browser-use", + "id": "browser-use", + "name": "Browser Use", + "description": "Drive the user's real browser through Playwright MCP for local control.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://playwright.dev/img/playwright-logo.svg" + }, + "tags": [ + "browser", + "mcp", + "playwright" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets browser-use", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:coding-agent-toolkit%2Fcaplet.md:coding-agent-toolkit", + "id": "coding-agent-toolkit", + "name": "Coding Agent Toolkit", + "description": "self-contained nested toolkit of high-value Caplets for coding agents.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://caplets.dev/icon.png" + }, + "tags": [ + "caplets", + "coding-agent", + "toolkit" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "set", + "label": "Caplet set" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets coding-agent-toolkit", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:computer-use%2Fcaplet.md:computer-use", + "id": "computer-use", + "name": "Computer Use", + "description": "Control local desktop applications and windows through open-computer-use for explicit desktop automation workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "tags": [ + "computer-use", + "desktop", + "local-control" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets computer-use", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:context7%2Fcaplet.md:context7", + "id": "context7", + "name": "Context7", + "description": "Fetch current library and framework documentation through Context7 before using version-sensitive APIs.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://context7.com/favicon.ico" + }, + "tags": [ + "api-reference", + "docs", + "frameworks", + "libraries" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets context7", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:deepwiki%2Fcaplet.md:deepwiki", + "id": "deepwiki", + "name": "DeepWiki", + "description": "Query repository-focused documentation and codebase explanations through DeepWiki's MCP service.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://deepwiki.com/favicon.ico" + }, + "tags": [ + "code", + "docs", + "mcp" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets deepwiki", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:github%2Fcaplet.md:github", + "id": "github", + "name": "GitHub", + "description": "Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://github.githubassets.com/favicons/favicon.svg" + }, + "tags": [ + "code", + "github", + "issues", + "pull-requests", + "reviews" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets github", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:gmail%2Fcaplet.md:gmail", + "id": "gmail", + "name": "Gmail", + "description": "Search, read, label, draft, and send Gmail messages through the Gmail API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png" + }, + "tags": [ + "email", + "gmail", + "google" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets gmail", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-chat%2Fcaplet.md:google-chat", + "id": "google-chat", + "name": "Google Chat", + "description": "Search spaces, read messages, send messages, and add reactions through the Google Chat API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/chat_2020q4_48dp.png" + }, + "tags": [ + "chat", + "collaboration", + "google", + "messaging" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-chat", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-docs%2Fcaplet.md:google-docs", + "id": "google-docs", + "name": "Google Docs", + "description": "Read, create, and edit Google Docs documents through the Google Docs API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/docs_2020q4_48dp.png" + }, + "tags": [ + "docs", + "documents", + "google", + "productivity" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-docs", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-drive%2Fcaplet.md:google-drive", + "id": "google-drive", + "name": "Google Drive", + "description": "Search, read, download, upload, and manage Google Drive files through the Drive API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png" + }, + "tags": [ + "drive", + "files", + "google" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-drive", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-forms%2Fcaplet.md:google-forms", + "id": "google-forms", + "name": "Google Forms", + "description": "Read, create, edit, and inspect responses for Google Forms through the Forms API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/forms_2020q4_48dp.png" + }, + "tags": [ + "forms", + "google", + "responses", + "surveys" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-forms", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-meet%2Fcaplet.md:google-meet", + "id": "google-meet", + "name": "Google Meet", + "description": "Create Meet spaces and inspect Meet conference records, participants, recordings, transcripts, and smart notes through the Meet API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/meet_2020q4_48dp.png" + }, + "tags": [ + "google", + "meet", + "meetings", + "video" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-meet", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-sheets%2Fcaplet.md:google-sheets", + "id": "google-sheets", + "name": "Google Sheets", + "description": "Read, create, and update Google Sheets spreadsheets through the Sheets API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/sheets_2020q4_48dp.png" + }, + "tags": [ + "data", + "google", + "sheets", + "spreadsheets" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-sheets", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-slides%2Fcaplet.md:google-slides", + "id": "google-slides", + "name": "Google Slides", + "description": "Read, create, preview, and edit Google Slides presentations through the Slides API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/slides_2020q4_48dp.png" + }, + "tags": [ + "google", + "presentations", + "productivity", + "slides" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-slides", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-tasks%2Fcaplet.md:google-tasks", + "id": "google-tasks", + "name": "Google Tasks", + "description": "Read, create, update, organize, and complete Google Tasks through the Google Tasks API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://www.gstatic.com/images/branding/product/2x/tasks_48dp.png" + }, + "tags": [ + "google", + "productivity", + "tasks" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-tasks", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:linear%2Fcaplet.md:linear", + "id": "linear", + "name": "Linear", + "description": "Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://linear.app/favicon.ico" + }, + "tags": [ + "issues", + "linear", + "planning", + "projects", + "triage" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets linear", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:lsp%2Fcaplet.md:lsp", + "id": "lsp", + "name": "LSP", + "description": "Language Server Protocol tools for project-aware code intelligence through language-server-mcp.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "tags": [ + "code", + "diagnostics", + "language-server", + "lsp", + "mcp" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets lsp", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:npm%2Fcaplet.md:npm", + "id": "npm", + "name": "npm Registry", + "description": "Query package metadata, versions, dist-tags, and search results from the public npm registry.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://raw.githubusercontent.com/npm/logos/master/npm%20logo/npm-logo-red.svg" + }, + "tags": [ + "code", + "npm", + "openapi", + "packages" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "openapi", + "label": "OpenAPI" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets npm", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:osv%2Fcaplet.md:osv", + "id": "osv", + "name": "OSV Vulnerabilities", + "description": "Query OSV.dev vulnerability data through explicit HTTP actions.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://osv.dev/favicon.ico" + }, + "tags": [ + "code", + "http", + "security", + "vulnerabilities" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "http", + "label": "HTTP API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets osv", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:playwright%2Fcaplet.md:playwright", + "id": "playwright", + "name": "Playwright", + "description": "Drive a browser through Playwright MCP for frontend testing, inspection, and automation workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://playwright.dev/img/playwright-logo.svg" + }, + "tags": [ + "browser", + "frontend", + "mcp", + "testing" + ], + "intendedTask": "unknown", + "setupReadiness": "required", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets playwright", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "setup_required", + "severity": "info", + "label": "Setup required", + "message": "This Caplet includes setup steps that should be completed before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:posthog%2Fcaplet.md:posthog", + "id": "posthog", + "name": "PostHog", + "description": "Inspect PostHog analytics, feature flags, experiments, session replay, and product telemetry through PostHog's hosted MCP server.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://posthog.com/icons/icon-192x192.png" + }, + "tags": [ + "analytics", + "feature-flags", + "posthog", + "product" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets posthog", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:pypi%2Fcaplet.md:pypi", + "id": "pypi", + "name": "PyPI Registry", + "description": "Query Python package metadata, releases, files, vulnerabilities, and Simple API project details from PyPI.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://pypi.org/static/images/logo-small.8998e9d1.svg" + }, + "tags": [ + "code", + "openapi", + "packages", + "pypi", + "python" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "openapi", + "label": "OpenAPI" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets pypi", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:repo-cli%2Fcaplet.md:repo-cli", + "id": "repo-cli", + "name": "Repository CLI", + "description": "Inspect and run common local repository workflows through curated CLI tools.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" + }, + "tags": [ + "cli", + "code" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "cli", + "label": "CLI tools" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets repo-cli", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:sentry%2Fcaplet.md:sentry", + "id": "sentry", + "name": "Sentry", + "description": "Inspect Sentry issues, events, traces, releases, and AI debugging context through Sentry's hosted MCP server.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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", + "icon": { + "type": "url", + "url": "https://sentry.io/favicon.ico" + }, + "tags": [ + "errors", + "observability", + "sentry", + "tracing" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets sentry", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:sourcegraph%2Fcaplet.md:sourcegraph", + "id": "sourcegraph", + "name": "Sourcegraph", + "description": "Search and inspect code across Sourcegraph using its MCP endpoint for repository-aware coding workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "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/favicon.ico\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", + "icon": { + "type": "url", + "url": "https://sourcegraph.com/favicon.ico" + }, + "tags": [ + "code-search", + "mcp", + "sourcegraph" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets sourcegraph", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + } +] diff --git a/apps/catalog/src/env.d.ts b/apps/catalog/src/env.d.ts new file mode 100644 index 00000000..2ddc5b31 --- /dev/null +++ b/apps/catalog/src/env.d.ts @@ -0,0 +1,14 @@ +import "../.astro/types.d.ts"; + +import type { Runtime } from "@astrojs/cloudflare"; +import type { D1Database } from "@cloudflare/workers-types"; + +declare global { + namespace App { + interface Locals extends Runtime {} + } +} + +interface Env { + CATALOG_DB?: D1Database; +} diff --git a/apps/catalog/src/lib/catalog-env.ts b/apps/catalog/src/lib/catalog-env.ts new file mode 100644 index 00000000..82cb22d1 --- /dev/null +++ b/apps/catalog/src/lib/catalog-env.ts @@ -0,0 +1,11 @@ +import type { D1Database } from "@cloudflare/workers-types"; +// @ts-expect-error @astrojs/cloudflare provides this virtual module at runtime. +import { env } from "cloudflare:workers"; + +export type CatalogEnv = { + CATALOG_DB?: D1Database; +}; + +export function getCatalogEnv(): CatalogEnv { + return env as CatalogEnv; +} diff --git a/apps/catalog/src/lib/catalog-response.ts b/apps/catalog/src/lib/catalog-response.ts new file mode 100644 index 00000000..7681ec86 --- /dev/null +++ b/apps/catalog/src/lib/catalog-response.ts @@ -0,0 +1,31 @@ +export function jsonResponse(data: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("cache-control", headers.get("cache-control") ?? "public, max-age=60"); + applySecurityHeaders(headers); + return new Response(JSON.stringify(data, null, 2), { ...init, headers }); +} + +export function notFound(message = "Catalog entry not found."): Response { + return jsonResponse({ ok: false, error: { code: "not_found", message } }, { status: 404 }); +} + +export function applySecurityHeaders(headers: Headers): Headers { + headers.set("x-content-type-options", "nosniff"); + headers.set("referrer-policy", "strict-origin-when-cross-origin"); + headers.set("permissions-policy", "interest-cohort=()"); + headers.set( + "content-security-policy", + [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "img-src 'self' data: https:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self'", + "connect-src 'self'", + ].join("; "), + ); + return headers; +} diff --git a/apps/catalog/src/lib/catalog-store.ts b/apps/catalog/src/lib/catalog-store.ts new file mode 100644 index 00000000..8a045c1c --- /dev/null +++ b/apps/catalog/src/lib/catalog-store.ts @@ -0,0 +1,87 @@ +import { formatCatalogInstallCount, type CatalogEntry } from "@caplets/core/catalog"; +import type { D1Database } from "@cloudflare/workers-types"; +import officialEntries from "../data/official-catalog.json"; +import { rankInstallCount } from "./counts"; + +export type CatalogEntryRecord = CatalogEntry & { + installCount: number; + installCountDisplay: string; + rankScore: number; +}; + +export type CatalogStoreEnv = { + CATALOG_DB?: D1Database | undefined; +}; + +export async function listCatalogEntries(env: CatalogStoreEnv = {}): Promise { + const db = env.CATALOG_DB; + let overlays = new Map(); + let suppressed = new Set(); + let communityEntries: CatalogEntry[] = []; + if (db) { + try { + [overlays, suppressed, communityEntries] = await Promise.all([ + readCountOverlays(db), + readSuppressedEntryKeys(db), + readCommunityEntries(db), + ]); + } catch { + overlays = new Map(); + suppressed = new Set(); + communityEntries = []; + } + } + return [...(officialEntries as CatalogEntry[]), ...communityEntries] + .filter((entry) => !suppressed.has(entry.entryKey)) + .map((entry) => withCount(entry, overlays.get(entry.entryKey) ?? 0)) + .sort((left, right) => { + const rank = right.rankScore - left.rankScore; + return rank === 0 ? left.name.localeCompare(right.name) : rank; + }); +} + +export async function getCatalogEntry( + entryKey: string, + env: CatalogStoreEnv = {}, +): Promise { + return (await listCatalogEntries(env)).find((entry) => entry.entryKey === entryKey); +} + +function withCount(entry: CatalogEntry, installCount: number): CatalogEntryRecord { + return { + ...entry, + installCount, + installCountDisplay: formatCatalogInstallCount(installCount), + rankScore: rankInstallCount(installCount), + }; +} + +async function readCountOverlays(db: D1Database): Promise> { + const result = await db + .prepare("select entry_key as entryKey, install_count as installCount from catalog_counts") + .all<{ entryKey: string; installCount: number }>(); + return new Map( + (result.results ?? []).map((row) => [row.entryKey, Number(row.installCount) || 0]), + ); +} + +async function readSuppressedEntryKeys(db: D1Database): Promise> { + const result = await db.prepare("select entry_key as entryKey from catalog_suppressions").all<{ + entryKey: string; + }>(); + return new Set((result.results ?? []).map((row) => row.entryKey)); +} + +async function readCommunityEntries(db: D1Database): Promise { + const result = await db.prepare("select entry_json as entryJson from catalog_entries").all<{ + entryJson: string; + }>(); + return (result.results ?? []).flatMap((row) => { + try { + const parsed = JSON.parse(row.entryJson) as CatalogEntry; + return parsed.trustLevel === "community" ? [parsed] : []; + } catch { + return []; + } + }); +} diff --git a/apps/catalog/src/lib/counts.ts b/apps/catalog/src/lib/counts.ts new file mode 100644 index 00000000..643cf20b --- /dev/null +++ b/apps/catalog/src/lib/counts.ts @@ -0,0 +1,6 @@ +export function rankInstallCount(count: number): number { + if (!Number.isFinite(count) || count < 10) { + return 0; + } + return Math.floor(count); +} diff --git a/apps/catalog/src/lib/entry-route.ts b/apps/catalog/src/lib/entry-route.ts new file mode 100644 index 00000000..ff508efe --- /dev/null +++ b/apps/catalog/src/lib/entry-route.ts @@ -0,0 +1,3 @@ +export function decodeEntryRouteKey(value: string): string { + return value.replace(/%3A/giu, ":").replace(/%25/giu, "%"); +} diff --git a/apps/catalog/src/lib/ingest.ts b/apps/catalog/src/lib/ingest.ts new file mode 100644 index 00000000..e946ddee --- /dev/null +++ b/apps/catalog/src/lib/ingest.ts @@ -0,0 +1,430 @@ +import { + catalogAuthRequiredFromFrontmatter, + type CatalogEntry, + catalogEntryKey, + catalogIconFromFrontmatter, + catalogMutatesExternalStateFromFrontmatter, + catalogProjectBindingRequiredFromFrontmatter, + catalogSetupRequiredFromFrontmatter, + catalogStringArrayFromFrontmatter, + catalogStringFromFrontmatter, + catalogUsesLocalControlFromFrontmatter, + catalogWorkflowSummaryFromFrontmatter, + createCatalogEntry, + readCatalogCapletFrontmatterFromMarkdown, + type CatalogIndexingStatus, + type CatalogSourceIdentity, +} from "@caplets/core/catalog"; +import type { D1Database } from "@cloudflare/workers-types"; +import { publicCatalogSourceEligibility } from "./public-source"; +import { isSuppressed } from "./suppression"; + +const maxBodyBytes = 16 * 1024; +const maxFetchedCapletBytes = 128 * 1024; +const refractoryWindowMs = 60 * 60 * 1000; +const maxRepositorySignalsPerWindow = 250; +const officialRepository = "spiritledsoftware/caplets"; + +export type CatalogInstallSignal = { + source: string; + capletId: string; + sourcePath: string; + resolvedRevision?: string | undefined; + contentHash?: string | undefined; + entry?: CatalogEntry | undefined; +}; + +export type CatalogInstallSignalResult = { + status: CatalogIndexingStatus; + entryKey?: string | undefined; +}; + +type SignalReservation = { + entryKey: string; + signalFingerprint: string; + windowStartMs: number; +}; + +export async function parseInstallSignalRequest(request: Request): Promise { + const contentLengthHeader = request.headers.get("content-length"); + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined; + if ( + contentLength !== undefined && + (!Number.isSafeInteger(contentLength) || contentLength > maxBodyBytes) + ) { + throw new Error("request_body_too_large"); + } + const body = JSON.parse(await readLimitedRequestText(request)); + if (!isInstallSignal(body)) { + throw new Error("invalid_install_signal"); + } + return body; +} + +export async function acceptInstallSignal(input: { + signal: CatalogInstallSignal; + db?: D1Database | undefined; + fetch?: typeof fetch | undefined; + now?: Date | undefined; +}): Promise { + const eligibility = publicCatalogSourceEligibility(input.signal.source); + if (!eligibility.eligible) { + return { status: "ineligible" }; + } + if (!input.signal.resolvedRevision && !input.signal.contentHash) { + return { status: "revision_unavailable" }; + } + + const entryKey = catalogEntryKey({ + source: eligibility.source, + sourcePath: input.signal.sourcePath, + capletId: input.signal.capletId, + }); + if (eligibility.source.repository === officialRepository) { + return { status: "already_current", entryKey }; + } + if (!input.db) { + return { status: "unavailable", entryKey }; + } + if (await isSuppressed(input.db, entryKey)) { + return { status: "ineligible" }; + } + + const nowMs = input.now?.getTime() ?? Date.now(); + const reservation = await reserveAcceptedSignal(input.db, { + entryKey, + source: eligibility.source, + nowMs, + signal: input.signal, + }); + if (!reservation) { + return { status: "rate_limited", entryKey }; + } + + const entry = await canonicalEntryForAcceptedSignal( + input.signal, + eligibility.source, + entryKey, + input.fetch ?? globalThis.fetch, + ); + if (!entry) { + await releaseAcceptedSignal(input.db, reservation); + return { status: "rejected", entryKey }; + } + + const previousInstallCount = await readExistingInstallCount(input.db, entryKey); + await recordAcceptedSignal(input.db, { + entry, + entryKey, + source: eligibility.source, + nowMs, + signal: input.signal, + }); + return { status: previousInstallCount > 0 ? "counted" : "accepted", entryKey }; +} + +async function readLimitedRequestText(request: Request): Promise { + if (!request.body) { + throw new Error("invalid_install_signal"); + } + return readLimitedStreamText(request.body, maxBodyBytes, "request_body_too_large"); +} + +function concatChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array { + const output = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function readExistingInstallCount(db: D1Database, entryKey: string): Promise { + const record = await db + .prepare("select install_count as installCount from catalog_counts where entry_key = ? limit 1") + .bind(entryKey) + .first<{ installCount: number }>(); + return Number(record?.installCount) || 0; +} + +async function reserveAcceptedSignal( + db: D1Database, + input: { + entryKey: string; + source: CatalogSourceIdentity; + nowMs: number; + signal: CatalogInstallSignal; + }, +): Promise { + const windowStartMs = Math.floor(input.nowMs / refractoryWindowMs) * refractoryWindowMs; + const fingerprint = signalFingerprint(input.signal); + const result = await db + .prepare( + `insert into catalog_signal_dedupe ( + entry_key, signal_fingerprint, provider, repository, window_start_ms, accepted_at_ms + ) + select ?, ?, ?, ?, ?, ? + where not exists ( + select 1 from catalog_signal_dedupe + where entry_key = ? and accepted_at_ms > ? + ) + and ( + select count(*) from catalog_signal_dedupe + where provider = ? and repository = ? and window_start_ms = ? + ) < ?`, + ) + .bind( + input.entryKey, + fingerprint, + input.source.provider, + input.source.repository, + windowStartMs, + input.nowMs, + input.entryKey, + input.nowMs - refractoryWindowMs, + input.source.provider, + input.source.repository, + windowStartMs, + maxRepositorySignalsPerWindow, + ) + .run(); + return d1ChangedRows(result) > 0 + ? { entryKey: input.entryKey, signalFingerprint: fingerprint, windowStartMs } + : undefined; +} + +async function releaseAcceptedSignal( + db: D1Database, + reservation: SignalReservation, +): Promise { + await db + .prepare( + `delete from catalog_signal_dedupe + where entry_key = ? and signal_fingerprint = ? and window_start_ms = ?`, + ) + .bind(reservation.entryKey, reservation.signalFingerprint, reservation.windowStartMs) + .run(); +} + +function isInstallSignal(value: unknown): value is CatalogInstallSignal { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.source === "string" && + typeof candidate.capletId === "string" && + typeof candidate.sourcePath === "string" && + (candidate.resolvedRevision === undefined || typeof candidate.resolvedRevision === "string") && + (candidate.contentHash === undefined || typeof candidate.contentHash === "string") && + (candidate.entry === undefined || isCatalogEntry(candidate.entry)) + ); +} + +function isCatalogEntry(value: unknown): value is CatalogEntry { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.entryKey === "string" && + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.description === "string" && + candidate.trustLevel === "community" && + typeof candidate.sourcePath === "string" && + typeof candidate.installCommand === "object" + ); +} + +async function recordAcceptedSignal( + db: D1Database, + input: { + entry: CatalogEntry; + entryKey: string; + source: CatalogSourceIdentity; + nowMs: number; + signal: CatalogInstallSignal; + }, +): Promise { + const statements = [ + db + .prepare( + `insert into catalog_counts (entry_key, install_count, updated_at_ms) + values (?, 1, ?) + on conflict(entry_key) do update set + install_count = install_count + 1, + updated_at_ms = excluded.updated_at_ms`, + ) + .bind(input.entryKey, input.nowMs), + ]; + statements.push( + db + .prepare( + `insert into catalog_entries ( + entry_key, provider, repository, source_path, caplet_id, + resolved_revision, content_hash, entry_json, updated_at_ms + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(entry_key) do update set + resolved_revision = excluded.resolved_revision, + content_hash = excluded.content_hash, + entry_json = excluded.entry_json, + updated_at_ms = excluded.updated_at_ms`, + ) + .bind( + input.entryKey, + input.source.provider, + input.source.repository, + input.signal.sourcePath, + input.signal.capletId, + input.signal.resolvedRevision, + input.signal.contentHash, + JSON.stringify(input.entry), + input.nowMs, + ), + ); + await db.batch(statements); +} + +async function canonicalEntryForAcceptedSignal( + signal: CatalogInstallSignal, + source: CatalogSourceIdentity, + entryKey: string, + fetchImpl: typeof fetch | undefined, +): Promise { + const entry = signal.entry; + if (!entry || entry.entryKey !== entryKey) return undefined; + const contentMarkdown = await fetchPublicCapletMarkdown(signal, source, fetchImpl); + if (!contentMarkdown) return undefined; + const frontmatter = readCatalogCapletFrontmatterFromMarkdown(contentMarkdown); + return createCatalogEntry({ + id: signal.capletId, + name: catalogStringFromFrontmatter(frontmatter.name) ?? signal.capletId, + description: + catalogStringFromFrontmatter(frontmatter.description) ?? + `Community Caplet ${signal.capletId}.`, + source, + sourcePath: signal.sourcePath, + trustLevel: "community", + resolvedRevision: signal.resolvedRevision, + indexedContentHash: signal.contentHash, + contentMarkdown, + icon: catalogIconFromFrontmatter(frontmatter, { + id: signal.capletId, + source, + sourcePath: signal.sourcePath, + trustLevel: "community", + resolvedRevision: signal.resolvedRevision, + }), + tags: catalogStringArrayFromFrontmatter(frontmatter.tags), + useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), + avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen), + setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter), + authRequired: catalogAuthRequiredFromFrontmatter(frontmatter), + projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter), + workflow: catalogWorkflowSummaryFromFrontmatter(frontmatter, { + kind: "set", + label: "Caplet", + }), + mutatesExternalState: catalogMutatesExternalStateFromFrontmatter(frontmatter), + localControl: catalogUsesLocalControlFromFrontmatter(frontmatter), + }); +} + +async function fetchPublicCapletMarkdown( + signal: CatalogInstallSignal, + source: CatalogSourceIdentity, + fetchImpl: typeof fetch | undefined, +): Promise { + if (!fetchImpl || !signal.resolvedRevision) return undefined; + for (const path of candidateRawCapletPaths(signal.sourcePath)) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + try { + const response = await fetchImpl(rawGithubUrl(source, signal.resolvedRevision, path), { + headers: { accept: "text/plain" }, + signal: controller.signal, + }); + if (!response.ok) continue; + return await readLimitedResponseText(response, maxFetchedCapletBytes); + } catch { + continue; + } finally { + clearTimeout(timeout); + } + } + return undefined; +} + +function candidateRawCapletPaths(sourcePath: string): string[] { + const cleaned = sourcePath.trim().replace(/\\/g, "/").replace(/^\.\//u, ""); + const segments = cleaned.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) return []; + const normalized = segments.join("/"); + if (!normalized) return []; + const candidates = /\.md$/iu.test(normalized) ? [normalized] : [`${normalized}/CAPLET.md`]; + if (!normalized.toLowerCase().startsWith("caplets/")) { + candidates.push(...candidates.map((candidate) => `caplets/${candidate}`)); + } + return [...new Set(candidates)]; +} + +function rawGithubUrl( + source: CatalogSourceIdentity, + resolvedRevision: string, + sourcePath: string, +): string { + const path = sourcePath.split("/").map(encodeURIComponent).join("/"); + return `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${encodeURIComponent( + resolvedRevision, + )}/${path}`; +} + +async function readLimitedResponseText(response: Response, limitBytes: number): Promise { + if (!response.body) { + throw new Error("empty_response_body"); + } + return readLimitedStreamText(response.body, limitBytes, "fetched_caplet_too_large"); +} + +async function readLimitedStreamText( + body: ReadableStream, + limitBytes: number, + tooLargeMessage: string, +): Promise { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > limitBytes) { + throw new Error(tooLargeMessage); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder().decode(concatChunks(chunks, totalBytes)); +} + +function signalFingerprint(signal: CatalogInstallSignal): string { + return JSON.stringify([ + signal.source, + signal.capletId, + signal.sourcePath, + signal.resolvedRevision ?? "", + signal.contentHash ?? "", + ]); +} + +function d1ChangedRows(result: unknown): number { + const meta = isRecord(result) ? result.meta : undefined; + const changes = isRecord(meta) ? meta.changes : undefined; + return typeof changes === "number" ? changes : 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/apps/catalog/src/lib/markdown.ts b/apps/catalog/src/lib/markdown.ts new file mode 100644 index 00000000..9dd13440 --- /dev/null +++ b/apps/catalog/src/lib/markdown.ts @@ -0,0 +1,202 @@ +import { readCatalogCapletFrontmatterFromMarkdown } from "@caplets/core/catalog"; + +const allowedProtocols = ["http", "https"]; +const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u; +const headingPattern = /^(#{1,6})\s+(.+)$/u; +const unorderedListPattern = /^\s*[-*]\s+(.+)$/u; +const orderedListPattern = /^\s*\d+\.\s+(.+)$/u; +const fencedCodePattern = /^\s*```/u; + +export type CatalogFrontmatterRow = { + key: string; + value: string; +}; + +export type CatalogMarkdownParts = { + bodyMarkdown: string; + frontmatterRows: CatalogFrontmatterRow[]; +}; + +export function splitCatalogMarkdown(markdown: string): CatalogMarkdownParts { + const frontmatterMatch = frontmatterPattern.exec(markdown); + if (!frontmatterMatch) { + return { bodyMarkdown: markdown, frontmatterRows: [] }; + } + const frontmatter = readCatalogCapletFrontmatterFromMarkdown(frontmatterMatch[0]); + return { + bodyMarkdown: markdown.slice(frontmatterMatch[0].length).trimStart(), + frontmatterRows: flattenFrontmatterRows(frontmatter), + }; +} + +export async function renderCatalogMarkdown(markdown: string): Promise { + return renderMarkdownBlocks(markdown); +} + +function renderMarkdownBlocks(markdown: string): string { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const html: string[] = []; + let paragraph: string[] = []; + let list: { type: "ol" | "ul"; items: string[] } | undefined; + let codeLines: string[] | undefined; + + function flushParagraph() { + if (paragraph.length === 0) return; + html.push(`

${renderInline(paragraph.join(" "))}

`); + paragraph = []; + } + + function flushList() { + if (!list) return; + html.push( + `<${list.type}>${list.items.map((item) => `
  • ${renderInline(item)}
  • `).join("")}`, + ); + list = undefined; + } + + function flushCode() { + if (!codeLines) return; + html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + codeLines = undefined; + } + + for (const line of lines) { + if (codeLines) { + if (fencedCodePattern.test(line)) { + flushCode(); + } else { + codeLines.push(line); + } + continue; + } + + if (fencedCodePattern.test(line)) { + flushParagraph(); + flushList(); + codeLines = []; + continue; + } + + const trimmed = line.trim(); + if (!trimmed) { + flushParagraph(); + flushList(); + continue; + } + + const heading = headingPattern.exec(trimmed); + if (heading) { + flushParagraph(); + flushList(); + const level = heading[1].length; + html.push(`${renderInline(heading[2])}`); + continue; + } + + const unordered = unorderedListPattern.exec(line); + if (unordered) { + flushParagraph(); + if (list?.type !== "ul") flushList(); + list ??= { type: "ul", items: [] }; + list.items.push(unordered[1]); + continue; + } + + const ordered = orderedListPattern.exec(line); + if (ordered) { + flushParagraph(); + if (list?.type !== "ol") flushList(); + list ??= { type: "ol", items: [] }; + list.items.push(ordered[1]); + continue; + } + + flushList(); + paragraph.push(trimmed); + } + + flushCode(); + flushParagraph(); + flushList(); + return html.join("\n"); +} + +function renderInline(value: string): string { + const escaped = escapeHtml(value); + return escaped + .replace(/`([^`]+)`/gu, (_match, code: string) => `${code}`) + .replace( + /\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/gu, + (_match, label: string, href: string, title?: string) => { + if (!isAllowedHref(href)) return label; + const titleAttribute = title ? ` title="${title}"` : ""; + return `${label}`; + }, + ); +} + +function isAllowedHref(value: string): boolean { + if (!value || hasControlCharacter(value)) return false; + if (value.startsWith("#")) return true; + if (!value.startsWith("//") && !/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value)) return true; + try { + const url = new URL(value); + return allowedProtocols.includes(url.protocol.replace(":", "")); + } catch { + return false; + } +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function flattenFrontmatterRows(value: unknown, prefix: string[] = []): CatalogFrontmatterRow[] { + if (Array.isArray(value)) { + if (value.every(isScalar)) { + return [{ key: formatKey(prefix), value: value.map(formatScalar).join(", ") }]; + } + return value.flatMap((item, index) => flattenFrontmatterRows(item, [...prefix, `[${index}]`])); + } + if (isRecord(value)) { + return Object.entries(value).flatMap(([key, nested]) => + flattenFrontmatterRows(nested, [...prefix, key]), + ); + } + if (prefix.length === 0) return []; + return [{ key: formatKey(prefix), value: formatScalar(value) }]; +} + +function formatKey(parts: string[]): string { + return parts.reduce((key, part) => { + if (!key) return part; + return part.startsWith("[") ? `${key}${part}` : `${key}.${part}`; + }, ""); +} + +function formatScalar(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function isScalar(value: unknown): boolean { + return value === null || ["string", "number", "boolean", "undefined"].includes(typeof value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/apps/catalog/src/lib/public-source.ts b/apps/catalog/src/lib/public-source.ts new file mode 100644 index 00000000..26e25a4d --- /dev/null +++ b/apps/catalog/src/lib/public-source.ts @@ -0,0 +1,11 @@ +import { + normalizeCatalogSourceIdentity, + type CatalogIndexingEligibility, +} from "@caplets/core/catalog"; + +export function publicCatalogSourceEligibility(source: unknown): CatalogIndexingEligibility { + if (typeof source !== "string") { + return { eligible: false, reason: "unsupported_source", redactedSource: "[redacted]" }; + } + return normalizeCatalogSourceIdentity(source); +} diff --git a/apps/catalog/src/lib/rate-limit.ts b/apps/catalog/src/lib/rate-limit.ts new file mode 100644 index 00000000..8297ec39 --- /dev/null +++ b/apps/catalog/src/lib/rate-limit.ts @@ -0,0 +1,22 @@ +export type RateLimitDecision = { + allowed: boolean; + retryAfterSeconds?: number | undefined; +}; + +export function refractoryWindowAllows(input: { + nowMs: number; + previousAcceptedAtMs?: number | undefined; + windowMs: number; +}): RateLimitDecision { + if (input.previousAcceptedAtMs === undefined) { + return { allowed: true }; + } + const nextAllowedAt = input.previousAcceptedAtMs + input.windowMs; + if (input.nowMs >= nextAllowedAt) { + return { allowed: true }; + } + return { + allowed: false, + retryAfterSeconds: Math.ceil((nextAllowedAt - input.nowMs) / 1000), + }; +} diff --git a/apps/catalog/src/lib/search-filter.ts b/apps/catalog/src/lib/search-filter.ts new file mode 100644 index 00000000..91f19cc8 --- /dev/null +++ b/apps/catalog/src/lib/search-filter.ts @@ -0,0 +1,63 @@ +export type CatalogSearchRecord = { + id: string; + name: string; + description: string; + tags: string[]; + trust: string; + setup: string; + count: number; + searchText?: string | undefined; +}; + +export type CatalogSearchFilters = { + query: string; + trust: string; + setup: string; + tag: string; + sort: "rank" | "name"; +}; + +export function filterCatalogSearchRecords( + records: CatalogSearchRecord[], + filters: CatalogSearchFilters, +): CatalogSearchRecord[] { + const query = filters.query.trim().toLowerCase(); + const tag = filters.tag.trim().toLowerCase(); + const normalizedQuery = normalizeSearchText(query); + const normalizedTag = normalizeSearchText(tag); + return records + .filter((record) => { + const searchable = + record.searchText ?? [record.name, record.description, record.tags.join(" ")].join(" "); + const lowerSearchable = searchable.toLowerCase(); + const normalizedSearchable = normalizeSearchText(lowerSearchable); + return ( + (!query || + lowerSearchable.includes(query) || + normalizedSearchable.includes(normalizedQuery)) && + (filters.trust === "all" || record.trust === filters.trust) && + (filters.setup === "all" || record.setup === filters.setup) && + (!tag || + tag === "all" || + record.tags.some((recordTag) => tagMatches(recordTag, tag, normalizedTag))) + ); + }) + .sort((left, right) => { + if (filters.sort === "name") return left.name.localeCompare(right.name); + const rank = right.count - left.count; + return rank === 0 ? left.name.localeCompare(right.name) : rank; + }); +} + +function tagMatches(recordTag: string, tag: string, normalizedTag: string): boolean { + const lowerTag = recordTag.toLowerCase(); + return ( + lowerTag === tag || + lowerTag.includes(tag) || + normalizeSearchText(lowerTag).includes(normalizedTag) + ); +} + +function normalizeSearchText(value: string): string { + return value.replace(/[^a-z0-9]+/g, " ").trim(); +} diff --git a/apps/catalog/src/lib/search-row.ts b/apps/catalog/src/lib/search-row.ts new file mode 100644 index 00000000..036aa431 --- /dev/null +++ b/apps/catalog/src/lib/search-row.ts @@ -0,0 +1,84 @@ +import type { CatalogIcon, CatalogWarning, CatalogWarningSeverity } from "@caplets/core/catalog"; +import type { CatalogEntryRecord } from "./catalog-store"; + +export type CatalogSearchStatusCode = CatalogWarning["code"] | "vault_required"; + +export type CatalogSearchStatus = { + code: CatalogSearchStatusCode; + label: string; + severity: CatalogWarningSeverity; +}; + +export type CatalogSearchRow = { + id: string; + name: string; + description: string; + tags: string[]; + icon?: CatalogIcon | undefined; + trust: string; + setup: string; + count: number; + installCountDisplay: string; + sourceRepository: string; + workflowLabel: string; + authReadiness: string; + projectBindingReadiness: string; + detailHref: string; + installCommandText: string; + installCommandPreview: string; + installCommandCopyable: boolean; + statuses: CatalogSearchStatus[]; + searchText: string; +}; + +export function catalogSearchRowFromEntry(entry: CatalogEntryRecord): CatalogSearchRow { + const statuses = entry.warnings.map((warning) => ({ + code: warning.code, + label: warning.label, + severity: warning.severity, + })); + const installCommandText = entry.installCommand.text || "Install command unavailable"; + const installCommandPreview = previewInstallCommand(installCommandText); + const row: Omit = { + id: entry.entryKey, + name: entry.name, + description: entry.description, + tags: entry.tags, + ...(entry.icon ? { icon: entry.icon } : {}), + trust: entry.trustLevel, + setup: entry.setupReadiness, + count: entry.rankScore, + installCountDisplay: entry.installCountDisplay, + sourceRepository: entry.source.repository, + workflowLabel: entry.workflow.label, + authReadiness: entry.authReadiness, + projectBindingReadiness: entry.projectBindingReadiness, + detailHref: `/caplets/${encodeURIComponent(entry.entryKey)}/`, + installCommandText, + installCommandPreview, + installCommandCopyable: entry.installCommand.copyable, + statuses, + }; + return { + ...row, + searchText: [ + row.name, + row.description, + row.tags.join(" "), + row.sourceRepository, + row.workflowLabel, + row.installCommandText, + ] + .join(" ") + .toLowerCase(), + }; +} + +export function catalogSearchRowsFromEntries(entries: CatalogEntryRecord[]): CatalogSearchRow[] { + return entries.map(catalogSearchRowFromEntry); +} + +function previewInstallCommand(command: string): string { + const normalized = command.trim(); + return normalized || "Install command unavailable"; +} diff --git a/apps/catalog/src/lib/status-icons.ts b/apps/catalog/src/lib/status-icons.ts new file mode 100644 index 00000000..99790ab6 --- /dev/null +++ b/apps/catalog/src/lib/status-icons.ts @@ -0,0 +1,32 @@ +import { + AlertCircleIcon, + BadgeCheckIcon, + ComputerUserIcon, + DatabaseSyncIcon, + Key01Icon, + Link01Icon, + Settings02Icon, + Shield01Icon, +} from "@hugeicons/core-free-icons"; +import type { CatalogSearchStatusCode } from "./search-row"; + +type IconNode = readonly [string, Readonly>]; +export type IconSvgObject = readonly IconNode[]; + +export const catalogStatusIcons: Record = { + unverified_community: Shield01Icon, + local_control: ComputerUserIcon, + mutating_saas: DatabaseSyncIcon, + auth_required: Key01Icon, + setup_required: Settings02Icon, + project_binding_required: Link01Icon, + readiness_unknown: AlertCircleIcon, + vault_required: Key01Icon, +}; + +export const catalogTrustIcons: Record = { + official: BadgeCheckIcon, + community: Shield01Icon, +}; + +export { AlertCircleIcon, Copy01Icon } from "@hugeicons/core-free-icons"; diff --git a/apps/catalog/src/lib/suppression.ts b/apps/catalog/src/lib/suppression.ts new file mode 100644 index 00000000..6b2af72e --- /dev/null +++ b/apps/catalog/src/lib/suppression.ts @@ -0,0 +1,18 @@ +import type { D1Database } from "@cloudflare/workers-types"; + +export type SuppressionRecord = { + entryKey: string; + reason: string; + suppressedAt: string; +}; + +export async function isSuppressed(db: D1Database | undefined, entryKey: string): Promise { + if (!db) { + throw new Error("CATALOG_DB is required for suppression checks"); + } + const record = await db + .prepare("select entry_key from catalog_suppressions where entry_key = ? limit 1") + .bind(entryKey) + .first<{ entry_key: string }>(); + return Boolean(record); +} diff --git a/apps/catalog/src/lib/theme.ts b/apps/catalog/src/lib/theme.ts new file mode 100644 index 00000000..57959048 --- /dev/null +++ b/apps/catalog/src/lib/theme.ts @@ -0,0 +1,31 @@ +export const catalogThemeStorageKey = "caplets.catalog.theme"; +export const catalogThemeSystemQuery = "(prefers-color-scheme: dark)"; + +export const catalogThemePreferences = ["light", "dark", "system"] as const; + +export type CatalogThemePreference = (typeof catalogThemePreferences)[number]; +export type CatalogResolvedTheme = Exclude; + +export function isCatalogThemePreference( + value: string | null | undefined, +): value is CatalogThemePreference { + return value === "light" || value === "dark" || value === "system"; +} + +export function normalizeCatalogThemePreference( + value: string | null | undefined, +): CatalogThemePreference { + return isCatalogThemePreference(value) ? value : "system"; +} + +export function resolveCatalogTheme( + preference: CatalogThemePreference, + systemPrefersDark: boolean, +): CatalogResolvedTheme { + if (preference !== "system") return preference; + return systemPrefersDark ? "dark" : "light"; +} + +export function catalogThemeLabel(preference: CatalogThemePreference): string { + return preference === "light" ? "Light" : preference === "dark" ? "Dark" : "System"; +} diff --git a/apps/catalog/src/middleware.ts b/apps/catalog/src/middleware.ts new file mode 100644 index 00000000..80a9bdec --- /dev/null +++ b/apps/catalog/src/middleware.ts @@ -0,0 +1,8 @@ +import { defineMiddleware } from "astro/middleware"; +import { applySecurityHeaders } from "./lib/catalog-response"; + +export const onRequest = defineMiddleware(async (_context, next) => { + const response = await next(); + applySecurityHeaders(response.headers); + return response; +}); diff --git a/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts b/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts new file mode 100644 index 00000000..936e2044 --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts @@ -0,0 +1,17 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../../lib/catalog-env"; +import { getCatalogEntry } from "../../../../../lib/catalog-store"; +import { jsonResponse, notFound } from "../../../../../lib/catalog-response"; +import { decodeEntryRouteKey } from "../../../../../lib/entry-route"; + +export async function GET(context: APIContext): Promise { + const entryKey = context.params.entryKey + ? decodeEntryRouteKey(context.params.entryKey) + : undefined; + if (!entryKey) { + return notFound(); + } + + const entry = await getCatalogEntry(entryKey, getCatalogEnv()); + return entry ? jsonResponse({ version: 1, entry }) : notFound(); +} diff --git a/apps/catalog/src/pages/api/v1/catalog/index.ts b/apps/catalog/src/pages/api/v1/catalog/index.ts new file mode 100644 index 00000000..ab16685c --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/index.ts @@ -0,0 +1,11 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../lib/catalog-env"; +import { listCatalogEntries } from "../../../../lib/catalog-store"; +import { jsonResponse } from "../../../../lib/catalog-response"; + +export async function GET(_context: APIContext): Promise { + return jsonResponse({ + version: 1, + entries: await listCatalogEntries(getCatalogEnv()), + }); +} diff --git a/apps/catalog/src/pages/api/v1/catalog/install-signals.ts b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts new file mode 100644 index 00000000..18d0fd09 --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts @@ -0,0 +1,51 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../lib/catalog-env"; +import { acceptInstallSignal, parseInstallSignalRequest } from "../../../../lib/ingest"; +import { jsonResponse } from "../../../../lib/catalog-response"; + +export async function POST(context: APIContext): Promise { + let signal: Awaited>; + try { + signal = await parseInstallSignalRequest(context.request); + } catch { + return jsonResponse( + { ok: false, error: { code: "invalid_request", message: "Invalid catalog signal." } }, + { status: 400, headers: { "cache-control": "no-store" } }, + ); + } + + try { + const result = await acceptInstallSignal({ + signal, + db: getCatalogEnv().CATALOG_DB, + }); + if (result.status === "unavailable") { + return jsonResponse( + { + ok: false, + result, + error: { + code: "indexer_unavailable", + message: "Catalog indexer is unavailable.", + }, + }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + return jsonResponse( + { ok: true, result }, + { + status: result.status === "ineligible" ? 202 : 200, + headers: { "cache-control": "no-store" }, + }, + ); + } catch { + return jsonResponse( + { + ok: false, + error: { code: "internal_error", message: "Catalog signal ingestion failed." }, + }, + { status: 500, headers: { "cache-control": "no-store" } }, + ); + } +} diff --git a/apps/catalog/src/pages/caplets/[entryKey].astro b/apps/catalog/src/pages/caplets/[entryKey].astro new file mode 100644 index 00000000..1448930a --- /dev/null +++ b/apps/catalog/src/pages/caplets/[entryKey].astro @@ -0,0 +1,56 @@ +--- +import CatalogHeader from "../../components/CatalogHeader.astro"; +import CapletDetail from "../../components/CapletDetail.astro"; +import ThemeInit from "../../components/ThemeInit.astro"; +import { getCatalogEnv } from "../../lib/catalog-env"; +import { decodeEntryRouteKey } from "../../lib/entry-route"; +import { getCatalogEntry } from "../../lib/catalog-store"; +import "../../styles/starwind.css"; +import "../../styles/catalog.css"; + +export const prerender = false; + +const entryKey = Astro.params.entryKey ? decodeEntryRouteKey(Astro.params.entryKey) : undefined; +const entry = entryKey ? await getCatalogEntry(entryKey, getCatalogEnv()) : undefined; +if (!entry) { + Astro.response.status = 404; +} +--- + + + + + + + {entry ? `${entry.name} - Caplets Catalog` : "Caplet unavailable"} + + + + + + + +
    + { + entry ? ( + + ) : ( +
    +

    Caplet unavailable

    +

    This catalog entry is unavailable, suppressed, or no longer indexed.

    + Back to search +
    + ) + } +
    +
    + + + diff --git a/apps/catalog/src/pages/index.astro b/apps/catalog/src/pages/index.astro new file mode 100644 index 00000000..7b28bf3c --- /dev/null +++ b/apps/catalog/src/pages/index.astro @@ -0,0 +1,46 @@ +--- +import CatalogHeader from "../components/CatalogHeader.astro"; +import SearchShell from "../components/SearchShell.astro"; +import ThemeInit from "../components/ThemeInit.astro"; +import { getCatalogEnv } from "../lib/catalog-env"; +import { listCatalogEntries } from "../lib/catalog-store"; +import "../styles/starwind.css"; +import "../styles/catalog.css"; + +export const prerender = false; + +const entries = await listCatalogEntries(getCatalogEnv()); +--- + + + + + + + Caplets Catalog + + + + + + + +
    +
    + Not security-reviewed. + Inspect Caplets before installing. +
    +
    + +
    +
    +
    + + + diff --git a/apps/catalog/src/scripts/copy.ts b/apps/catalog/src/scripts/copy.ts new file mode 100644 index 00000000..0d9acf9b --- /dev/null +++ b/apps/catalog/src/scripts/copy.ts @@ -0,0 +1,34 @@ +const copyStatus = document.querySelector("[data-copy-status]") as HTMLElement | null; +let clearCopyStatusTimer = 0; + +function announceCopyStatus(message: string): void { + if (!copyStatus) return; + window.clearTimeout(clearCopyStatusTimer); + copyStatus.textContent = ""; + window.setTimeout(() => { + copyStatus.textContent = message; + clearCopyStatusTimer = window.setTimeout(() => { + copyStatus.textContent = ""; + }, 3200); + }, 0); +} + +async function copyCommand(button: HTMLButtonElement): Promise { + const command = button.dataset.copyCommand ?? ""; + try { + await navigator.clipboard.writeText(command); + announceCopyStatus("Install command copied."); + } catch { + announceCopyStatus("Copy failed. Select the command text manually."); + } +} + +document.addEventListener("click", (event) => { + const button = (event.target as Element | null)?.closest( + "[data-copy-command]", + ); + if (!button) return; + void copyCommand(button); +}); + +export {}; diff --git a/apps/catalog/src/scripts/search.ts b/apps/catalog/src/scripts/search.ts new file mode 100644 index 00000000..a30b73db --- /dev/null +++ b/apps/catalog/src/scripts/search.ts @@ -0,0 +1,5 @@ +import { initVirtualCatalogSearch } from "./virtual-results"; + +initVirtualCatalogSearch(); + +export {}; diff --git a/apps/catalog/src/scripts/theme-init.ts b/apps/catalog/src/scripts/theme-init.ts new file mode 100644 index 00000000..484fbab6 --- /dev/null +++ b/apps/catalog/src/scripts/theme-init.ts @@ -0,0 +1,21 @@ +import { + catalogThemeStorageKey, + catalogThemeSystemQuery, + normalizeCatalogThemePreference, + resolveCatalogTheme, +} from "../lib/theme"; + +try { + const preference = normalizeCatalogThemePreference(localStorage.getItem(catalogThemeStorageKey)); + const theme = resolveCatalogTheme(preference, window.matchMedia(catalogThemeSystemQuery).matches); + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = preference; + document.documentElement.style.colorScheme = theme; +} catch { + const theme = window.matchMedia(catalogThemeSystemQuery).matches ? "dark" : "light"; + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = "system"; + document.documentElement.style.colorScheme = theme; +} + +export {}; diff --git a/apps/catalog/src/scripts/theme.ts b/apps/catalog/src/scripts/theme.ts new file mode 100644 index 00000000..6e331035 --- /dev/null +++ b/apps/catalog/src/scripts/theme.ts @@ -0,0 +1,135 @@ +import { + catalogThemeLabel, + catalogThemeStorageKey, + catalogThemeSystemQuery, + isCatalogThemePreference, + normalizeCatalogThemePreference, + resolveCatalogTheme, + type CatalogThemePreference, +} from "../lib/theme"; + +const query = window.matchMedia(catalogThemeSystemQuery); +const root = document.querySelector("[data-theme-menu-root]") as HTMLElement | null; +const trigger = document.querySelector("[data-theme-trigger]") as HTMLButtonElement | null; +const menu = document.querySelector("[data-theme-menu]") as HTMLElement | null; +const label = document.querySelector("[data-theme-label]") as HTMLElement | null; +const controls = Array.from( + document.querySelectorAll("[data-theme-option]"), +) as HTMLButtonElement[]; +const currentIcons = Array.from( + document.querySelectorAll("[data-theme-current-icon]"), +) as HTMLElement[]; + +function getStoredPreference(): CatalogThemePreference { + try { + return normalizeCatalogThemePreference(localStorage.getItem(catalogThemeStorageKey)); + } catch { + return "system"; + } +} + +function storePreference(preference: CatalogThemePreference): void { + try { + localStorage.setItem(catalogThemeStorageKey, preference); + } catch { + // Storage can be unavailable in hardened browser contexts; keep the in-page selection usable. + } +} + +function applyTheme(preference: CatalogThemePreference): void { + const theme = resolveCatalogTheme(preference, query.matches); + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = preference; + document.documentElement.style.colorScheme = theme; + + for (const control of controls) { + const selected = control.dataset.themeOption === preference; + control.setAttribute("aria-checked", String(selected)); + if (selected && label) + label.textContent = control.dataset.themeLabelValue ?? catalogThemeLabel(preference); + } + + for (const icon of currentIcons) { + icon.hidden = icon.dataset.themeCurrentIcon !== preference; + } +} + +function setMenuOpen(open: boolean): void { + if (!trigger || !menu) return; + trigger.setAttribute("aria-expanded", String(open)); + menu.hidden = !open; +} + +function isMenuOpen(): boolean { + return trigger?.getAttribute("aria-expanded") === "true"; +} + +function focusOption(index: number): void { + controls.at(index)?.focus(); +} + +function focusedOptionIndex(): number { + return controls.findIndex((control) => control === document.activeElement); +} + +trigger?.addEventListener("click", () => { + setMenuOpen(!isMenuOpen()); +}); + +trigger?.addEventListener("keydown", (event) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + setMenuOpen(true); + focusOption(event.key === "ArrowDown" ? 0 : controls.length - 1); +}); + +for (const control of controls) { + control.addEventListener("click", () => { + const preference = control.dataset.themeOption ?? null; + if (!isCatalogThemePreference(preference)) return; + storePreference(preference); + applyTheme(preference); + setMenuOpen(false); + trigger?.focus(); + }); + + control.addEventListener("keydown", (event) => { + const currentIndex = focusedOptionIndex(); + if (event.key === "ArrowDown") { + event.preventDefault(); + focusOption((currentIndex + 1) % controls.length); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + focusOption((currentIndex - 1 + controls.length) % controls.length); + } + if (event.key === "Home") { + event.preventDefault(); + focusOption(0); + } + if (event.key === "End") { + event.preventDefault(); + focusOption(controls.length - 1); + } + }); +} + +document.addEventListener("click", (event) => { + if (!isMenuOpen() || root?.contains(event.target as Node)) return; + setMenuOpen(false); +}); + +document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !isMenuOpen()) return; + setMenuOpen(false); + trigger?.focus(); +}); + +query.addEventListener("change", () => { + const preference = getStoredPreference(); + if (preference === "system") applyTheme(preference); +}); + +applyTheme(getStoredPreference()); + +export {}; diff --git a/apps/catalog/src/scripts/virtual-results.ts b/apps/catalog/src/scripts/virtual-results.ts new file mode 100644 index 00000000..12acdf82 --- /dev/null +++ b/apps/catalog/src/scripts/virtual-results.ts @@ -0,0 +1,318 @@ +import { + observeWindowOffset, + observeWindowRect, + Virtualizer, + windowScroll, + type VirtualItem, +} from "@tanstack/virtual-core"; +import { filterCatalogSearchRecords, type CatalogSearchFilters } from "../lib/search-filter"; +import type { CatalogSearchRow } from "../lib/search-row"; +import { + AlertCircleIcon, + catalogStatusIcons, + catalogTrustIcons, + Copy01Icon, + type IconSvgObject, +} from "../lib/status-icons"; + +const rowHeight = 72; +const compactRowHeight = 168; +const mobileRowHeight = 188; +const narrowRowHeight = 320; +const overscan = 8; + +export type VirtualCatalogSearch = { + applySearch(): void; + destroy(): void; + renderedRowCount(): number; +}; + +export function initVirtualCatalogSearch( + root: Document | HTMLElement = document, +): VirtualCatalogSearch | undefined { + const shell = root.querySelector("[data-search-shell]") as HTMLElement | null; + const input = root.querySelector("[data-search-input]") as HTMLInputElement | null; + const resultList = root.querySelector("[data-result-list]") as HTMLElement | null; + const resultSpacer = root.querySelector("[data-result-spacer]") as HTMLElement | null; + const resultTable = root.querySelector("[data-result-table]") as HTMLElement | null; + const resultStatus = root.querySelector("[data-result-status]") as HTMLElement | null; + const emptyState = root.querySelector("[data-empty-state]") as HTMLElement | null; + const reset = root.querySelector("[data-reset-search]") as HTMLButtonElement | null; + const trust = root.querySelector('[data-filter="trust"]') as HTMLSelectElement | null; + const setup = root.querySelector('[data-filter="setup"]') as HTMLSelectElement | null; + const tag = root.querySelector('[data-filter="tag"]') as HTMLInputElement | null; + const tagSelect = root.querySelector("#catalog-tag-select") as HTMLElement | null; + const sort = root.querySelector("[data-sort]") as HTMLSelectElement | null; + const index = root.querySelector("[data-search-index]") as HTMLScriptElement | null; + if (!shell || !input || !resultList || !resultSpacer || !resultStatus || !emptyState || !index) { + return undefined; + } + + const inputEl = input; + const resultListEl = resultList; + const resultSpacerEl = resultSpacer; + const resultStatusEl = resultStatus; + const emptyStateEl = emptyState; + const rows = parseRows(index.textContent ?? "[]"); + let visibleRows = [...rows]; + let lastFocusedControl: HTMLElement | null = null; + const virtualizer = new Virtualizer({ + count: visibleRows.length, + getScrollElement: () => window, + estimateSize: estimateRowHeight, + overscan, + scrollToFn: windowScroll, + observeElementRect: observeWindowRect, + observeElementOffset: observeWindowOffset, + getItemKey: (index) => visibleRows[index]?.id ?? index, + onChange: () => renderVirtualRows(), + }); + const cleanupVirtualizer = virtualizer._didMount(); + virtualizer._willUpdate(); + + function controls(): Array { + return [inputEl, trust, setup, tag, sort]; + } + + function filters(): CatalogSearchFilters { + return { + query: inputEl.value, + trust: trust?.value ?? "all", + setup: setup?.value ?? "all", + tag: tag?.value.trim() || "all", + sort: sort?.value === "name" ? "name" : "rank", + }; + } + + function applyUrlState(): void { + const params = new URLSearchParams(window.location.search); + inputEl.value = params.get("q") ?? ""; + if (trust) trust.value = params.get("scope") ?? "all"; + if (setup) setup.value = params.get("setup") ?? "all"; + setTagValue(params.get("tag") ?? "all"); + if (sort) sort.value = params.get("sort") === "name" ? "name" : "rank"; + } + + function writeUrlState(): void { + const params = new URLSearchParams(); + if (inputEl.value.trim()) params.set("q", inputEl.value.trim()); + if (trust && trust.value !== "all") params.set("scope", trust.value); + if (setup && setup.value !== "all") params.set("setup", setup.value); + if (tag?.value.trim()) params.set("tag", tag.value.trim()); + if (sort && sort.value !== "rank") params.set("sort", sort.value); + const query = params.toString(); + const next = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; + window.history.replaceState(null, "", next); + } + + function applySearch(options: { writeUrl?: boolean; resetScroll?: boolean } = {}): void { + lastFocusedControl = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + visibleRows = filterCatalogSearchRecords(rows, filters()) as CatalogSearchRow[]; + virtualizer.setOptions({ ...virtualizer.options, count: visibleRows.length }); + resultStatusEl.textContent = `${visibleRows.length} ${visibleRows.length === 1 ? "Caplet" : "Caplets"}`; + resultTable?.setAttribute("aria-rowcount", String(visibleRows.length + 1)); + emptyStateEl.hidden = visibleRows.length > 0; + resultSpacerEl.style.height = `${Math.max(virtualizer.getTotalSize(), visibleRows.length ? estimateRowHeight() : 1)}px`; + if (options.writeUrl !== false) writeUrlState(); + if (options.resetScroll !== false) virtualizer.scrollToIndex(0, { align: "start" }); + renderVirtualRows(); + if ( + lastFocusedControl && + controls().includes(lastFocusedControl as HTMLInputElement | HTMLSelectElement) + ) { + lastFocusedControl.focus(); + } + } + + function renderVirtualRows(): void { + const items = virtualizer.getVirtualItems(); + resultSpacerEl.style.height = `${Math.max(virtualizer.getTotalSize(), visibleRows.length ? estimateRowHeight() : 1)}px`; + resultListEl.replaceChildren(...items.map((item) => renderRow(item, visibleRows[item.index]))); + } + + function navigateFromRowClick(event: MouseEvent): void { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + const target = event.target as Element | null; + if (!target || target.closest("[data-row-action]") || target.closest("a")) return; + const row = target.closest("[data-result-row]"); + const href = row?.dataset.detailHref; + if (href) window.location.href = href; + } + + const events = new AbortController(); + for (const control of controls()) { + control?.addEventListener("input", () => applySearch(), { signal: events.signal }); + control?.addEventListener("change", () => applySearch(), { signal: events.signal }); + } + tagSelect?.addEventListener( + "starwind-select:change", + (event) => { + const customEvent = event as CustomEvent<{ value?: string }>; + setTagValue(customEvent.detail.value ?? "all", { syncSelect: false }); + applySearch(); + }, + { signal: events.signal }, + ); + + reset?.addEventListener( + "click", + () => { + inputEl.value = ""; + for (const select of [trust, setup]) { + if (select) select.value = "all"; + } + setTagValue("all"); + if (sort) sort.value = "rank"; + applySearch(); + inputEl.focus(); + }, + { signal: events.signal }, + ); + + window.addEventListener( + "popstate", + () => { + applyUrlState(); + applySearch({ writeUrl: false }); + }, + { signal: events.signal }, + ); + + window.addEventListener( + "resize", + () => { + virtualizer.measure(); + renderVirtualRows(); + }, + { signal: events.signal }, + ); + resultListEl.addEventListener("click", navigateFromRowClick, { signal: events.signal }); + + applyUrlState(); + applySearch({ writeUrl: false, resetScroll: false }); + + return { + applySearch, + destroy() { + events.abort(); + cleanupVirtualizer(); + }, + renderedRowCount() { + return resultListEl.querySelectorAll("[data-result-row]").length; + }, + }; +} + +function setTagValue(value: string, options: { syncSelect?: boolean } = {}): void { + const syncSelect = options.syncSelect ?? true; + const normalizedValue = value.trim() || "all"; + const tagInput = document.querySelector('[data-filter="tag"]') as HTMLInputElement | null; + const tagSelect = document.querySelector("#catalog-tag-select") as HTMLElement | null; + if (tagInput) tagInput.value = normalizedValue === "all" ? "" : normalizedValue; + if (!tagSelect || !syncSelect) return; + + document.dispatchEvent( + new CustomEvent("starwind-select:select", { + detail: { selectId: tagSelect.id, value: normalizedValue }, + }), + ); + + const triggerValue = tagSelect.querySelector('[data-slot="select-value"]'); + if (triggerValue) + triggerValue.textContent = normalizedValue === "all" ? "Any tag" : normalizedValue; +} + +function parseRows(value: string): CatalogSearchRow[] { + const parsed = JSON.parse(value) as CatalogSearchRow[]; + return Array.isArray(parsed) ? parsed : []; +} + +function estimateRowHeight(): number { + if (window.matchMedia("(max-width: 420px)").matches) return narrowRowHeight; + if (window.matchMedia("(max-width: 640px)").matches) return mobileRowHeight; + if (window.matchMedia("(max-width: 900px)").matches) return compactRowHeight; + return rowHeight; +} + +function renderRow(item: VirtualItem, row: CatalogSearchRow | undefined): HTMLElement { + const element = document.createElement("article"); + element.className = "catalog-result-row"; + element.role = "row"; + element.dataset.resultRow = ""; + element.dataset.detailHref = row?.detailHref ?? ""; + element.dataset.index = String(item.index); + element.setAttribute("aria-rowindex", String(item.index + 2)); + element.style.transform = `translateY(${item.start}px)`; + if (!row) return element; + element.innerHTML = ` +
    + ${renderCapletIcon(row)} + +
    +

    ${escapeHtml(row.description)}

    +
    ${escapeHtml(row.installCountDisplay)}
    +
    + ${renderIcon(catalogTrustIcons[row.trust] ?? AlertCircleIcon, row.trust, "catalog-result-row__trust-icon")} + ${row.statuses.map((status) => `${renderIcon(catalogStatusIcons[status.code] ?? AlertCircleIcon, status.label, "catalog-result-row__status-icon")}${escapeHtml(status.label)}`).join("")} +
    +
    + ${ + row.installCommandCopyable + ? `` + : `${escapeHtml(row.installCommandPreview)}` + } +
    + `; + return element; +} + +function renderCapletIcon(row: CatalogSearchRow): string { + if (!row.icon) { + return ``; + } + return ``; +} + +function renderIcon(icon: IconSvgObject, label: string, className = ""): string { + const classAttribute = className ? ` class="${escapeAttribute(className)}"` : ""; + const accessibilityAttributes = label + ? ` aria-label="${escapeAttribute(label)}" role="img"` + : ` aria-hidden="true"`; + return `${icon + .map( + ([, attrs]) => + ` key !== "key") + .map(([key, value]) => `${dashAttr(key)}="${escapeAttribute(String(value))}"`) + .join(" ")} />`, + ) + .join("")}`; +} + +function dashAttr(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replace(/'/g, "'"); +} diff --git a/apps/catalog/src/styles/catalog.css b/apps/catalog/src/styles/catalog.css new file mode 100644 index 00000000..9eb204b7 --- /dev/null +++ b/apps/catalog/src/styles/catalog.css @@ -0,0 +1,647 @@ +.skip-link { + position: fixed; + z-index: 60; + top: 1rem; + left: 1rem; + transform: translateY(-150%); + border-radius: 0.5rem; + background: var(--foreground); + color: var(--background); + padding: 0.65rem 0.85rem; + font-weight: 700; + transition: transform 160ms ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.copy-status { + position: fixed; + z-index: 50; + right: 1rem; + bottom: 1rem; + pointer-events: none; + max-width: min(24rem, calc(100vw - 2rem)); + border: 1px solid var(--border); + border-radius: 0.625rem; + background: var(--popover); + color: var(--popover-foreground); + padding: 0.75rem 1rem; + font-size: 0.875rem; + opacity: 0; + transform: translateY(0.5rem); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.copy-status:not(:empty) { + opacity: 1; + transform: translateY(0); +} + +.markdown-body { + max-width: 74ch; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3 { + margin: 1.2em 0 0.4em; + line-height: 1.18; + text-wrap: balance; +} + +.markdown-body h1:first-child { + margin-top: 0; +} + +.markdown-body p { + text-wrap: pretty; +} + +.markdown-body pre, +.markdown-body code, +.record-panel code { + font-family: var(--font-mono); +} + +.markdown-body pre { + overflow-x: auto; + border-radius: 0.625rem; + background: var(--code-surface); + color: var(--code-ink); + padding: 0.875rem; +} + +.catalog-intro { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.65rem; + align-items: baseline; + margin-bottom: 0.65rem; + color: var(--muted-foreground); + font-size: 0.9rem; +} + +.catalog-intro strong { + color: var(--foreground); +} + +.catalog-search { + overflow: clip; + border: 1px solid var(--border); + border-radius: 0.625rem; + background: var(--card); + color: var(--card-foreground); +} + +.catalog-search__controls { + display: grid; + gap: 0.65rem; + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--card) 94%, var(--background)); + padding: 0.75rem; +} + +.catalog-filterbar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.55rem; +} + +.catalog-filterbar label, +.catalog-filterbar > div { + display: grid; + gap: 0.3rem; +} + +.catalog-filterbar label > select, +.catalog-filterbar label > input { + min-height: 2.75rem; + width: 100%; + border-color: var(--border); + border-radius: 0.5rem; + background: var(--card); + color: var(--card-foreground); + font-size: 0.9rem; +} + +.catalog-tag-select { + min-width: 0; +} + +.catalog-tag-select__content { + max-width: min(22rem, calc(100vw - 2rem)); +} + +.catalog-tag-select__content [data-slot="select-search-wrapper"] { + margin-bottom: 0.2rem; + border-color: var(--border); + background: color-mix(in oklch, var(--popover) 88%, var(--muted)); +} + +.catalog-tag-select__content [data-slot="select-search"] { + min-height: 2.25rem; + color: var(--popover-foreground); + font-size: 0.9rem; +} + +.catalog-filterbar label > input::placeholder { + color: var(--muted-foreground); + opacity: 1; +} + +.catalog-filterbar label > select:focus, +.catalog-filterbar label > input:focus { + border-color: var(--outline); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--outline), transparent 70%); +} + +.catalog-search__legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.catalog-search__legend-item { + display: inline-flex; + min-height: 2rem; + align-items: center; + gap: 0.3rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + color: var(--muted-foreground); + padding: 0.25rem 0.45rem; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 700; +} + +.catalog-result-grid { + overflow: visible; +} + +.catalog-result-grid__header, +.catalog-result-row { + display: grid; + grid-template-columns: minmax(10rem, 1.1fr) minmax(14rem, 1.7fr) 5rem 9.25rem minmax(12rem, 1.2fr); + gap: 0.75rem; + align-items: center; +} + +.catalog-result-grid__header { + border-bottom: 1px solid var(--border); + background: var(--muted); + color: var(--muted-foreground); + padding: 0.45rem 0.9rem; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; +} + +.catalog-result-grid__spacer { + position: relative; + height: calc(var(--result-row-height, 72px) * var(--result-count, 1)); + min-height: 1px; +} + +.catalog-result-grid__rows { + position: absolute; + inset: 0 0 auto; +} + +.catalog-result-row { + position: absolute; + left: 0; + right: 0; + min-height: 72px; + border-bottom: 1px solid var(--border); + padding: 0.55rem 0.9rem; + cursor: pointer; + transform: translateY(calc(var(--result-row-height, 72px) * var(--result-index, 0))); + transition: background-color 160ms ease; +} + +.catalog-result-row:hover { + background: var(--muted); +} + +.catalog-result-row__name, +.catalog-result-row__actions, +.catalog-result-row__command { + min-width: 0; +} + +.catalog-result-row__name { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.catalog-result-row__icon { + box-sizing: border-box; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.4rem; + background: #fff; + object-fit: contain; + padding: 0.25rem; +} + +.catalog-result-row__icon--fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--muted); + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 800; + padding: 0; +} + +.catalog-result-row__heading { + display: flex; + min-width: 0; + align-items: center; + gap: 0.35rem; +} + +.catalog-result-row__title { + display: block; + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} + +.catalog-result-row__title:hover { + color: var(--primary-accent); +} + +.catalog-result-row__title:focus-visible, +.catalog-result-row__command:focus-visible { + outline: 2px solid var(--outline); + outline-offset: 2px; +} + +.catalog-result-row__installs { + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; +} + +.catalog-result-row__trust { + display: inline-flex; + flex: 0 0 auto; + min-width: 2rem; + min-height: 2rem; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid transparent; + background: var(--success); + color: var(--success-foreground); + padding-inline: 0.4rem; +} + +.catalog-result-row__trust--community { + border-color: var(--border); + background: var(--muted); + color: var(--muted-foreground); +} + +.catalog-result-row__description { + display: -webkit-box; + overflow: hidden; + margin: 0; + color: var(--muted-foreground); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.catalog-result-row__actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.35rem; + align-items: center; +} + +.catalog-result-row__command { + display: grid; + min-width: 0; + min-height: 2.75rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--muted); + color: var(--muted-foreground); + padding: 0.45rem 0.55rem; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + text-align: left; + white-space: nowrap; +} + +.catalog-result-row__command--copy { + cursor: copy; +} + +.catalog-result-row__command--copy:hover { + border-color: color-mix(in oklch, var(--border), var(--primary-accent) 45%); + color: var(--foreground); +} + +.catalog-result-row__command-text { + min-width: 0; + overflow: hidden; + font: inherit; + text-overflow: ellipsis; +} + +.catalog-result-row__command-copy-icon { + flex: 0 0 auto; + opacity: 0; + transform: translateX(2px); + transition: + opacity 140ms ease, + transform 140ms ease; +} + +.catalog-result-row__command--copy:hover .catalog-result-row__command-copy-icon, +.catalog-result-row__command--copy:focus-visible .catalog-result-row__command-copy-icon { + opacity: 1; + transform: translateX(0); +} + +.catalog-result-row__command--unavailable { + display: block; + text-overflow: ellipsis; +} + +.catalog-result-row__statuses { + display: flex; + flex-wrap: nowrap; + gap: 0.25rem; + justify-content: flex-start; + min-width: 0; + overflow: hidden; +} + +.catalog-result-row__status { + display: inline-flex; + min-width: 2rem; + min-height: 2rem; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + color: var(--muted-foreground); + padding-inline: 0.4rem; +} + +.catalog-result-row__status-label { + display: none; + margin-left: 0.25rem; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; +} + +.catalog-result-row__status--danger { + background: var(--error); + color: var(--error-foreground); +} + +.catalog-result-row__status--caution { + background: var(--warning); + color: var(--warning-foreground); +} + +.catalog-result-row__status--info { + background: var(--info); + color: var(--info-foreground); +} + +@media (max-width: 900px) { + .catalog-result-grid__spacer { + --result-row-height: 168px; + } + + .catalog-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .catalog-result-grid__header { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .catalog-result-row { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "name installs" + "description statuses" + "actions actions"; + min-height: 168px; + align-items: center; + } + + .catalog-result-row__name { + grid-area: name; + } + + .catalog-result-row__description { + grid-area: description; + } + + .catalog-result-row__actions { + grid-area: actions; + } + + .catalog-result-row__installs { + grid-area: installs; + } + + .catalog-result-row__statuses { + grid-area: statuses; + flex-wrap: wrap; + overflow: visible; + justify-self: end; + } + + .catalog-result-row__inspect { + min-height: 2.75rem; + } + + .catalog-result-row__status-label { + display: inline; + } +} + +@media (max-width: 640px) { + .catalog-result-grid__spacer { + --result-row-height: 188px; + } + + .catalog-search__controls { + gap: 0.5rem; + padding: 0.5rem; + } + + .catalog-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + } + + .catalog-filterbar label, + .catalog-filterbar > div { + gap: 0.2rem; + } + + .catalog-filterbar label > span, + .catalog-filterbar > div > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .catalog-filterbar label > select, + .catalog-filterbar label > input, + .catalog-tag-select [data-slot="select-trigger"] { + font-size: 0.84rem; + } + + .catalog-search__legend { + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 0.15rem; + } + + .catalog-search__legend-item { + flex: 0 0 auto; + min-height: 2.75rem; + } + + .catalog-result-row { + gap: 0.45rem; + min-height: 188px; + padding-inline: 0.7rem; + } + + .catalog-result-row__actions { + grid-template-columns: minmax(5.25rem, auto) minmax(0, 1fr); + } + + .catalog-result-row__heading { + align-items: flex-start; + } + + .catalog-result-row__title { + width: 100%; + min-height: 2.75rem; + display: flex; + align-items: center; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + } + + .catalog-result-row__status { + max-width: none; + } + + .catalog-result-row__status-label { + overflow: visible; + text-overflow: clip; + white-space: normal; + } +} + +@media (max-width: 420px) { + .catalog-result-grid__spacer { + --result-row-height: 320px; + } + + .catalog-result-row { + grid-template-areas: + "name name" + "installs installs" + "description description" + "statuses statuses" + "actions actions"; + min-height: 320px; + } + + .catalog-result-row__statuses { + justify-self: start; + } +} + +.frontmatter-table { + width: 100%; +} + +@media (max-width: 640px) { + .frontmatter-table, + .frontmatter-table thead, + .frontmatter-table tbody, + .frontmatter-table tr, + .frontmatter-table th, + .frontmatter-table td { + display: block; + width: 100%; + } + + .frontmatter-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .frontmatter-table tr { + border-bottom: 1px solid var(--border); + padding: 0.7rem; + } + + .frontmatter-table tr:last-child { + border-bottom: 0; + } + + .frontmatter-table th, + .frontmatter-table td { + border: 0; + padding: 0; + } + + .frontmatter-table td { + margin-top: 0.3rem; + } +} diff --git a/apps/catalog/src/styles/starwind.css b/apps/catalog/src/styles/starwind.css new file mode 100644 index 00000000..8245da0f --- /dev/null +++ b/apps/catalog/src/styles/starwind.css @@ -0,0 +1,178 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@plugin "@tailwindcss/forms"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-accent: var(--primary-accent); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary-accent: var(--secondary-accent); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-error: var(--error); + --color-error-foreground: var(--error-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-outline: var(--outline); + --radius-xs: calc(var(--radius) - 0.375rem); + --radius-sm: calc(var(--radius) - 0.25rem); + --radius-md: calc(var(--radius) - 0.125rem); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 0.25rem); + --radius-2xl: calc(var(--radius) + 0.5rem); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +:root { + color-scheme: light; + --ember: oklch(62% 0.17 37); + --ember-quiet: oklch(86% 0.07 45); + --linen: oklch(97% 0.018 86); + --paper: oklch(99% 0.014 88); + --parchment: oklch(93% 0.045 82); + --ash: oklch(84% 0.032 80); + --ink: oklch(23% 0.025 98); + --muted-olive: oklch(46% 0.035 110); + --danger: oklch(48% 0.16 31); + --success-tone: oklch(45% 0.11 145); + --radius: 0.625rem; + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + + --background: var(--linen); + --foreground: var(--ink); + --card: var(--paper); + --card-foreground: var(--ink); + --popover: var(--paper); + --popover-foreground: var(--ink); + --primary: var(--ink); + --primary-foreground: var(--paper); + --primary-accent: var(--ember); + --secondary: var(--parchment); + --secondary-foreground: var(--ink); + --secondary-accent: var(--ember-quiet); + --muted: color-mix(in oklch, var(--paper) 70%, var(--parchment)); + --muted-foreground: var(--muted-olive); + --accent: var(--parchment); + --accent-foreground: var(--ink); + --info: color-mix(in oklch, var(--paper) 76%, var(--ember-quiet)); + --info-foreground: var(--ink); + --success: color-mix(in oklch, var(--success-tone), var(--paper) 82%); + --success-foreground: oklch(29% 0.08 145); + --warning: color-mix(in oklch, var(--parchment), var(--ember-quiet) 35%); + --warning-foreground: var(--ink); + --error: color-mix(in oklch, var(--danger), var(--paper) 82%); + --error-foreground: oklch(36% 0.14 31); + --border: var(--ash); + --input: var(--ash); + --outline: oklch(62% 0.17 37 / 0.38); + --code-surface: oklch(21% 0.025 98); + --code-ink: var(--paper); +} + +.dark { + color-scheme: dark; + --ember: oklch(73% 0.16 42); + --ember-quiet: oklch(33% 0.08 45); + --linen: oklch(17% 0.022 92); + --paper: oklch(21% 0.02 92); + --parchment: oklch(28% 0.035 82); + --ash: oklch(39% 0.035 82); + --ink: oklch(92% 0.018 86); + --muted-olive: oklch(75% 0.035 88); + --danger: oklch(76% 0.16 31); + --success-tone: oklch(75% 0.12 145); + + --background: var(--linen); + --foreground: var(--ink); + --card: var(--paper); + --card-foreground: var(--ink); + --popover: color-mix(in oklch, var(--paper) 90%, black); + --popover-foreground: var(--ink); + --primary: var(--ink); + --primary-foreground: var(--linen); + --secondary: var(--parchment); + --secondary-foreground: var(--ink); + --muted: color-mix(in oklch, var(--paper) 76%, var(--ash)); + --muted-foreground: var(--muted-olive); + --accent: var(--parchment); + --accent-foreground: var(--ink); + --info: color-mix(in oklch, var(--paper) 80%, var(--ember)); + --info-foreground: var(--ink); + --success: oklch(31% 0.065 145); + --success-foreground: oklch(88% 0.085 145); + --warning: color-mix(in oklch, var(--parchment), var(--ember) 18%); + --warning-foreground: var(--ink); + --error: oklch(31% 0.08 31); + --error-foreground: oklch(90% 0.08 31); + --border: var(--ash); + --input: var(--ash); + --outline: oklch(73% 0.16 42 / 0.42); + --code-surface: oklch(12% 0.02 92); + --code-ink: oklch(93% 0.014 88); +} + +@layer base { + * { + @apply border-border outline-outline/50; + } + + html { + @apply scroll-smooth bg-background text-foreground; + } + + body { + @apply min-h-screen min-w-80 bg-background font-sans text-[15px] leading-relaxed text-foreground antialiased; + margin: 0; + } + + a { + @apply text-inherit; + } + + button, + select { + @apply cursor-pointer; + } + + button, + input, + select { + font: inherit; + } + + [hidden] { + display: none !important; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 1ms !important; + animation-duration: 1ms !important; + } + } +} diff --git a/apps/catalog/starwind.config.json b/apps/catalog/starwind.config.json new file mode 100644 index 00000000..672aeb1a --- /dev/null +++ b/apps/catalog/starwind.config.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://starwind.dev/config-schema.json", + "tailwind": { + "css": "src/styles/starwind.css", + "baseColor": "neutral", + "cssVariables": true + }, + "componentDir": "src/components", + "utilsDir": "src/lib/utils", + "components": [ + { + "name": "button", + "version": "2.3.3" + }, + { + "name": "badge", + "version": "1.4.3" + }, + { + "name": "card", + "version": "2.0.2" + }, + { + "name": "table", + "version": "1.1.2" + }, + { + "name": "select", + "version": "1.9.0" + } + ] +} diff --git a/apps/catalog/test/catalog-api.test.ts b/apps/catalog/test/catalog-api.test.ts new file mode 100644 index 00000000..5065a578 --- /dev/null +++ b/apps/catalog/test/catalog-api.test.ts @@ -0,0 +1,61 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { describe, expect, it } from "vitest"; +import { getCatalogEntry, listCatalogEntries } from "../src/lib/catalog-store"; + +describe("catalog read model", () => { + it("serves official entries with low-count display and generated install commands", async () => { + const entries = await listCatalogEntries(); + const github = entries.find((entry) => entry.id === "github"); + + expect(github).toMatchObject({ + installCount: 0, + installCountDisplay: "<10", + installCommand: { + text: "caplets install spiritledsoftware/caplets github", + copyable: true, + }, + trustLevel: "official", + }); + expect(await getCatalogEntry(github?.entryKey ?? "")).toMatchObject({ id: "github" }); + }); + + it("hides suppressed entries from list and detail reads", async () => { + const githubEntryKey = "github:spiritledsoftware:caplets:github%2Fcaplet.md:github"; + const entries = await listCatalogEntries({ + CATALOG_DB: fakeD1([githubEntryKey]), + }); + expect(entries.some((entry) => entry.id === "github")).toBe(false); + await expect( + getCatalogEntry(githubEntryKey, { + CATALOG_DB: fakeD1([githubEntryKey]), + }), + ).resolves.toBeUndefined(); + }); +}); + +function fakeD1(suppressedEntryKeys: string[]) { + return { + prepare(sql: string) { + return { + bind(...values: unknown[]) { + return { + first: async () => { + if (sql.includes("catalog_suppressions")) { + return suppressedEntryKeys.includes(String(values[0])) + ? { entry_key: values[0] } + : null; + } + return null; + }, + }; + }, + all: async () => { + if (sql.includes("catalog_suppressions")) { + return { results: suppressedEntryKeys.map((entryKey) => ({ entryKey })) }; + } + return { results: [] }; + }, + }; + }, + } as unknown as D1Database; +} diff --git a/apps/catalog/test/fixtures/catalog-search-rows.ts b/apps/catalog/test/fixtures/catalog-search-rows.ts new file mode 100644 index 00000000..53b3ca1e --- /dev/null +++ b/apps/catalog/test/fixtures/catalog-search-rows.ts @@ -0,0 +1,44 @@ +import type { CatalogSearchRow } from "../../src/lib/search-row"; + +type CatalogSearchRowFixture = Partial & Pick; + +export function catalogSearchRowFixture(input: CatalogSearchRowFixture): CatalogSearchRow { + const description = input.description ?? `${input.name} test description`; + const tags = input.tags ?? []; + const installCommandText = + input.installCommandText ?? `caplets install spiritledsoftware/caplets ${input.id}`; + const installCommandPreview = input.installCommandPreview ?? installCommandText; + return { + description, + tags, + trust: "official", + setup: "ready", + count: 0, + installCountDisplay: "<10", + sourceRepository: "spiritledsoftware/caplets", + workflowLabel: "MCP server", + authReadiness: "ready", + projectBindingReadiness: "ready", + detailHref: `/caplets/${encodeURIComponent(input.id)}/`, + installCommandText, + installCommandCopyable: true, + statuses: [], + searchText: [input.name, description, tags.join(" "), installCommandText] + .join(" ") + .toLowerCase(), + ...input, + installCommandPreview, + }; +} + +export function manyCatalogSearchRows(count: number): CatalogSearchRow[] { + return Array.from({ length: count }, (_, index) => + catalogSearchRowFixture({ + id: `caplet-${index}`, + name: `Caplet ${index}`, + description: `Compact row ${index} for virtual rendering checks.`, + tags: index % 2 === 0 ? ["even", "mcp"] : ["odd", "cli"], + count: count - index, + }), + ); +} diff --git a/apps/catalog/test/fixtures/cloudflare-workers.ts b/apps/catalog/test/fixtures/cloudflare-workers.ts new file mode 100644 index 00000000..1c60631f --- /dev/null +++ b/apps/catalog/test/fixtures/cloudflare-workers.ts @@ -0,0 +1 @@ +export const env = {}; diff --git a/apps/catalog/test/ingest.test.ts b/apps/catalog/test/ingest.test.ts new file mode 100644 index 00000000..a064a9f1 --- /dev/null +++ b/apps/catalog/test/ingest.test.ts @@ -0,0 +1,421 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { CatalogEntry } from "@caplets/core/catalog"; +import type { APIContext } from "astro"; +import { describe, expect, it } from "vitest"; +import { acceptInstallSignal, parseInstallSignalRequest } from "../src/lib/ingest"; +import { POST as postInstallSignal } from "../src/pages/api/v1/catalog/install-signals"; + +describe("catalog install signal ingestion", () => { + it("accepts revision-bound public GitHub signals without echoing raw private values", async () => { + const db = fakeD1(); + await expect( + acceptInstallSignal({ + db, + fetch: rawCapletFetch(), + signal: { + source: "https://github.com/Community/Tools.git", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + entry: submittedEntry(), + }, + }), + ).resolves.toEqual({ + status: "accepted", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.length).toBeGreaterThan(0); + }); + + it("rejects install signals that cannot be canonicalized before counting", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ + status: "rejected", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + + it("skips private or unpinned signals with categorical statuses only", async () => { + await expect( + acceptInstallSignal({ + signal: { + source: "../private", + capletId: "secret", + sourcePath: "secret/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ status: "ineligible" }); + + await expect( + acceptInstallSignal({ + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + }, + }), + ).resolves.toEqual({ status: "revision_unavailable" }); + }); + + it("enforces body size without trusting content-length", async () => { + const body = JSON.stringify({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + padding: "x".repeat(16 * 1024), + }); + + await expect( + parseInstallSignalRequest( + new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body, + }), + ), + ).rejects.toThrow("request_body_too_large"); + }); + + it("does not count suppressed entries", async () => { + const db = fakeD1({ + suppressedEntryKeys: new Set(["github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy"]), + }); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ status: "ineligible" }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + + it("does not let direct signals create community records for official Caplets", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "spiritledsoftware/caplets", + capletId: "github", + sourcePath: "caplets/github/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ + status: "already_current", + entryKey: "github:spiritledsoftware:caplets:caplets%2Fgithub%2Fcaplet.md:github", + }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + + it("persists submitted community entries when D1 is available", async () => { + const db = fakeD1(); + await acceptInstallSignal({ + db, + fetch: rawCapletFetch(), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "deploy", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "community", + repo: "tools", + repository: "community/tools", + canonicalUrl: "https://github.com/community/tools", + }, + sourcePath: "caplets/deploy/CAPLET.md", + trustLevel: "community", + resolvedRevision: "abc123", + indexedContentHash: "sha256:abc", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "caplets install community/tools#abc123 deploy", + copyable: true, + revisionBound: true, + }, + warnings: [], + }, + }, + }); + + expect(db.executedWrites.some((statement) => statement.includes("catalog_entries"))).toBe(true); + }); + + it("does not buffer oversized fetched CAPLET markdown before rejecting", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + fetch: rawCapletFetch("x".repeat(128 * 1024 + 1)), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: submittedEntry(), + }, + }), + ).resolves.toEqual({ + status: "rejected", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + + it("canonicalizes submitted community entries before storing them", async () => { + const db = fakeD1(); + await acceptInstallSignal({ + db, + fetch: rawCapletFetch( + [ + "---", + "name: Fetched Deploy", + "description: Fetched from GitHub.", + "tags:", + " - fetched", + "catalog:", + " icon: ./icon.svg", + "httpApi:", + " baseUrl: https://api.example.com", + " auth:", + " type: bearer", + " actions:", + " list:", + " method: GET", + " path: /projects", + "---", + "", + "# Fetched Deploy", + "", + ].join("\n"), + ), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "different", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "spiritledsoftware", + repo: "caplets", + repository: "spiritledsoftware/caplets", + canonicalUrl: "https://github.com/spiritledsoftware/caplets", + }, + sourcePath: "other/CAPLET.md", + trustLevel: "community", + resolvedRevision: "wrong", + indexedContentHash: "wrong", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "curl https://example.invalid/install.sh | sh", + copyable: true, + revisionBound: false, + }, + warnings: [], + }, + }, + }); + + const writes = db.executedWrites.join("\n"); + expect(writes).toContain('"id":"deploy"'); + expect(writes).toContain('"name":"Fetched Deploy"'); + expect(writes).toContain('"contentMarkdown":"---\\nname: Fetched Deploy'); + expect(writes).toContain('"repository":"community/tools"'); + expect(writes).toContain('"sourcePath":"caplets/deploy/CAPLET.md"'); + expect(writes).toContain('"resolvedRevision":"abc123"'); + expect(writes).toContain('"indexedContentHash":"sha256:abc"'); + expect(writes).toContain( + '"icon":{"type":"bundled","path":"icon.svg","url":"https://raw.githubusercontent.com/community/tools/abc123/caplets/deploy/icon.svg"}', + ); + expect(writes).toContain('"text":"caplets install community/tools#abc123 deploy"'); + expect(writes).not.toContain("curl https://example.invalid/install.sh"); + expect(writes).not.toContain('"name":"Deploy"'); + expect(writes).not.toContain("spiritledsoftware/caplets"); + }); + + it("fails loudly when the ingest route has no D1 binding", async () => { + const response = await postInstallSignal({ + request: new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body: JSON.stringify({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }), + }), + locals: {}, + } as APIContext); + + await expect(response.json()).resolves.toMatchObject({ + ok: false, + result: { status: "unavailable" }, + error: { code: "indexer_unavailable" }, + }); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("marks install signal route parse failures as no-store", async () => { + const response = await postInstallSignal({ + request: new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body: "{", + }), + locals: {}, + } as APIContext); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("marks install signal route accepted responses as no-store", async () => { + const response = await postInstallSignal({ + request: new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body: JSON.stringify({ + source: "../private", + capletId: "secret", + sourcePath: "secret/CAPLET.md", + resolvedRevision: "abc123", + }), + }), + locals: {}, + } as APIContext); + + expect(response.status).toBe(202); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); +}); + +function fakeD1( + input: { + suppressedEntryKeys?: Set; + reservationChanges?: number; + existingInstallCount?: number; + previousAcceptedAtMs?: number; + } = {}, +) { + const suppressedEntryKeys = input.suppressedEntryKeys ?? new Set(); + const db = { + executedWrites: [] as string[], + prepare(sql: string) { + return { + bind(...values: unknown[]) { + const statement = `${sql} ${values.join(" ")}`; + return { + first: async () => { + if (sql.includes("catalog_suppressions")) { + return suppressedEntryKeys.has(String(values[0])) ? { entry_key: values[0] } : null; + } + if (sql.includes("catalog_counts")) { + return input.existingInstallCount === undefined + ? null + : { installCount: input.existingInstallCount }; + } + if (sql.includes("catalog_signal_dedupe")) { + return input.previousAcceptedAtMs === undefined + ? null + : { acceptedAtMs: input.previousAcceptedAtMs }; + } + return null; + }, + run: async () => { + db.executedWrites.push(statement); + return { meta: { changes: input.reservationChanges ?? 1 } }; + }, + toString: () => statement, + }; + }, + }; + }, + batch: async (statements: unknown[]) => { + db.executedWrites.push(...statements.map(String)); + return []; + }, + }; + return db as unknown as D1Database & { executedWrites: string[] }; +} + +function rawCapletFetch(markdown = "# Deploy\n"): typeof fetch { + return (async () => new Response(markdown)) as typeof fetch; +} + +function submittedEntry(): CatalogEntry { + return { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "deploy", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "community", + repo: "tools", + repository: "community/tools", + canonicalUrl: "https://github.com/community/tools", + }, + sourcePath: "caplets/deploy/CAPLET.md", + trustLevel: "community", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "caplets install community/tools#abc123 deploy", + copyable: true, + revisionBound: true, + }, + warnings: [], + }; +} diff --git a/apps/catalog/test/markdown.test.ts b/apps/catalog/test/markdown.test.ts new file mode 100644 index 00000000..8ab6a148 --- /dev/null +++ b/apps/catalog/test/markdown.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { renderCatalogMarkdown, splitCatalogMarkdown } from "../src/lib/markdown"; + +describe("catalog Markdown rendering", () => { + it("strips raw HTML and dangerous URLs from indexed content", async () => { + const html = await renderCatalogMarkdown( + "# Caplet\n\n\n\n[bad](javascript:alert(1)) [ok](https://example.com)", + ); + + expect(html).toContain("

    Caplet

    "); + expect(html).not.toContain(" +
    +
    +
    + + +
    + `; +} + +function input(): HTMLInputElement { + return document.querySelector("[data-search-input]") as HTMLInputElement; +} + +function select(name: "trust" | "setup"): HTMLSelectElement { + return document.querySelector(`[data-filter="${name}"]`) as HTMLSelectElement; +} + +function tagInput(): HTMLInputElement { + return document.querySelector('[data-filter="tag"]') as HTMLInputElement; +} + +function tagSelect(): HTMLElement { + return document.querySelector("#catalog-tag-select") as HTMLElement; +} + +function sort(): HTMLSelectElement { + return document.querySelector("[data-sort]") as HTMLSelectElement; +} + +function resultSpacer(): HTMLElement { + return document.querySelector("[data-result-spacer]") as HTMLElement; +} + +function resultRows(): HTMLElement[] { + return Array.from(document.querySelectorAll("[data-result-row]")); +} diff --git a/apps/catalog/tsconfig.json b/apps/catalog/tsconfig.json new file mode 100644 index 00000000..990abae7 --- /dev/null +++ b/apps/catalog/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/apps/catalog/vitest.config.ts b/apps/catalog/vitest.config.ts new file mode 100644 index 00000000..9b94b85a --- /dev/null +++ b/apps/catalog/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "cloudflare:workers": fileURLToPath( + new URL("./test/fixtures/cloudflare-workers.ts", import.meta.url), + ), + }, + }, + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index eda7b857..18f8b1ba 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -42,6 +42,7 @@ export default defineConfig({ items: [ { label: "Code Mode", link: "/code-mode/" }, { label: "Add capabilities", link: "/capabilities/" }, + { label: "Catalog", link: "/catalog/" }, { label: "Caplets Vault", link: "/vault/" }, { label: "Agent integrations", link: "/agent-integrations/" }, { label: "Remote attach", link: "/remote-attach/" }, @@ -54,6 +55,7 @@ export default defineConfig({ { label: "Configuration schema", link: "/reference/config/" }, { label: "Code Mode API", link: "/reference/code-mode-api/" }, { label: "Caplet files", link: "/reference/caplet-files/" }, + { label: "Catalog indexing privacy", link: "/privacy/indexing/" }, { label: "GitHub releases", link: "https://github.com/spiritledsoftware/caplets/releases", diff --git a/apps/docs/src/components/CapletsThemeProvider.astro b/apps/docs/src/components/CapletsThemeProvider.astro index f46fe61a..73912682 100644 --- a/apps/docs/src/components/CapletsThemeProvider.astro +++ b/apps/docs/src/components/CapletsThemeProvider.astro @@ -4,7 +4,7 @@ const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("starlight-theme"); const parseTheme = (theme) => - theme === "dark" || theme === "auto" || theme === "light" ? theme : "light"; + theme === "dark" || theme === "auto" || theme === "light" ? theme : "dark"; const theme = parseTheme(storedTheme); const resolvedTheme = theme === "auto" @@ -14,7 +14,7 @@ : theme; document.documentElement.dataset.theme = resolvedTheme; return { - updatePickers(theme = storedTheme || "light") { + updatePickers(theme = storedTheme || "dark") { theme = parseTheme(theme); document.querySelectorAll("starlight-theme-select").forEach((picker) => { const select = picker.querySelector("select"); diff --git a/apps/docs/src/components/CapletsThemeSelect.astro b/apps/docs/src/components/CapletsThemeSelect.astro index 2a6d2b2d..c9146cf3 100644 --- a/apps/docs/src/components/CapletsThemeSelect.astro +++ b/apps/docs/src/components/CapletsThemeSelect.astro @@ -7,8 +7,8 @@ import Select from "@astrojs/starlight/components/Select.astro"; icon="sun" label={Astro.locals.t("themeSelect.accessibleLabel")} options={[ - { label: Astro.locals.t("themeSelect.dark"), selected: false, value: "dark" }, - { label: Astro.locals.t("themeSelect.light"), selected: true, value: "light" }, + { label: Astro.locals.t("themeSelect.dark"), selected: true, value: "dark" }, + { label: Astro.locals.t("themeSelect.light"), selected: false, value: "light" }, { label: Astro.locals.t("themeSelect.auto"), selected: false, value: "auto" }, ]} width="6.25em" @@ -33,7 +33,7 @@ import Select from "@astrojs/starlight/components/Select.astro"; const storageKey = "starlight-theme"; const parseTheme = (theme: unknown): Theme => - theme === "auto" || theme === "dark" || theme === "light" ? theme : "light"; + theme === "auto" || theme === "dark" || theme === "light" ? theme : "dark"; const loadTheme = (): Theme => parseTheme(typeof localStorage !== "undefined" && localStorage.getItem(storageKey)); diff --git a/apps/docs/src/content/docs/catalog.mdx b/apps/docs/src/content/docs/catalog.mdx new file mode 100644 index 00000000..35578959 --- /dev/null +++ b/apps/docs/src/content/docs/catalog.mdx @@ -0,0 +1,55 @@ +--- +title: Catalog +description: Search public Caplets and inspect installable catalog entries. +--- + + + catalog.caplets.dev + +is the public search surface for Caplets. It lists official Caplets from this repository and public +community Caplets discovered from successful public external installs. + + + Browse public catalog + + +Catalog entries show the Caplet name, description, tags, source repository, setup and auth +readiness, warning labels, aggregate install counts, readable `CAPLET.md` content, and a +generated install command when the catalog can tie that command to inspected content. + +## Safety model + +The catalog is not a scanner, certification program, endorsement list, or moderation +queue. V1 performs basic public-source eligibility checks, bounded ingestion, sanitized +Markdown rendering, count bucketing, and operator suppression. It does not claim semantic +malware review, dependency scanning, vulnerability scanning, secret scanning, or human +approval. + +Install counts are popularity and ranking signals only. They are not safety, quality, or +trust signals. + +## Install commands + +Install commands are generated by Caplets from normalized source metadata and Caplet IDs. +Community Markdown cannot author the copyable command shown by the catalog. + +Result rows may show a command preview. Copy actions live on detail pages after the source +content and warnings are visible. If content cannot be inspected or tied to a revision or +content hash, the catalog hides copy actions and shows recovery guidance instead. + +## Public API + +Agents and tools should use the structured read API rather than scraping HTML: + +- `https://catalog.caplets.dev/api/v1/catalog` +- `https://catalog.caplets.dev/api/v1/catalog/entries/` + +The API returns the same shared catalog entry model used by the site and CLI indexing +statuses. The npm subpath `@caplets/core/catalog` exposes worker-safe implementation +helpers for this v1 model, but the stable external discovery contract is the versioned +HTTP API. diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index c15d39b6..76315f49 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -33,12 +33,9 @@ caplets doctor `doctor` should identify the active Caplets config path and report the checks it can run for your configured integrations. Fix any failed check before testing through an agent. -Then ask your agent: - -```text -Use Caplets Code Mode to query OSV for npm react 18.2.0. Return the package, -version, vulnerability count, and advisory IDs as compact JSON. -``` +Then ask your agent to use Caplets Code Mode to query OSV for `npm` package +`react@18.2.0`, returning the package, version, vulnerability count, and advisory IDs +as compact JSON. A successful agent run should use the visible {"caplets\_\_code_mode"} tool and inspect an OSV handle such as `caplets.osv`. If the agent cannot see that tool, go to diff --git a/apps/docs/src/content/docs/install.mdx b/apps/docs/src/content/docs/install.mdx index 8f2ab715..aec52a37 100644 --- a/apps/docs/src/content/docs/install.mdx +++ b/apps/docs/src/content/docs/install.mdx @@ -55,6 +55,19 @@ caplets update sentry github Install, restore, and update refuse to overwrite local edits unless you pass `--force`. Use `--json` when a script needs per-entry statuses and machine-readable errors. +For public external sources, install, restore, and update also attempt best-effort catalog +indexing after the local mutation succeeds. Human output prints a short notice when a +public Caplet source may be indexed. JSON output includes per-entry `catalogIndexing` +status values. Indexing failures never block install/update/restore, and private or local +sources are skipped with redacted categorical statuses. + +Set `CAPLETS_DISABLE_CATALOG_INDEXING=1` to skip catalog indexing network requests +entirely. + +If an installed Caplet references unresolved Vault values, output includes immediate +recovery guidance. JSON rows include `vaultSetup` with recovery commands; human output +prints the relevant `caplets vault set` or `caplets vault access grant` command. + For explicit remote catalog lifecycle operations, `--remote` targets the remote machine's global Caplets root and global lockfile: diff --git a/apps/docs/src/content/docs/privacy/indexing.mdx b/apps/docs/src/content/docs/privacy/indexing.mdx new file mode 100644 index 00000000..09532aa0 --- /dev/null +++ b/apps/docs/src/content/docs/privacy/indexing.mdx @@ -0,0 +1,51 @@ +--- +title: Catalog indexing privacy +description: What public catalog indexing can and cannot publish. +--- + +Catalog indexing is separate from anonymous telemetry. + +Set `CAPLETS_DISABLE_CATALOG_INDEXING=1` to disable catalog indexing submissions. + +Anonymous telemetry intentionally excludes source URLs, Caplet IDs, hostnames, local paths, +tool arguments, tool outputs, and raw config. Public catalog indexing needs different data: +it publishes public-source metadata so other users can search for public Caplets. + +## What can become public + +After a successful install, restore, or update of an eligible public external Caplet, +Caplets may submit: + +- Normalized public provider and repository identity, such as `github.com/owner/repo`. +- Caplet ID and source path inside the public repository. +- Resolved revision or content hash when available. +- Installed content hash. +- Generated install command metadata. +- Safe derived catalog metadata, warnings, and aggregate install counts. + +## What must not be sent + +Catalog indexing must not send installer identity, local paths, private config, credentials, +raw agent prompts, tool arguments, tool outputs, hostnames for private sources, private +source URLs, Vault secret values, raw environment values, or individual install-event +identity. + +Ineligible sources return categorical statuses such as `ineligible`, +`revision_unavailable`, or `unavailable` without echoing raw private source values. + +## Nonblocking behavior + +Indexing is best effort. If the catalog indexer is unavailable, rate-limited, or rejects a +source, install/update/restore still succeeds or fails based on the local lifecycle result, +not on catalog reporting. + +## Vault values + +Vault references are local or remote runtime setup. Vault values are never written to the +catalog, lockfiles, install-count events, logs, or JSON output. When install/update finds +unresolved Vault setup, CLI output provides recovery commands such as: + +```sh +caplets vault set GH_TOKEN +caplets vault access grant GH_TOKEN github +``` diff --git a/apps/docs/src/content/docs/reference/caplet-files.mdx b/apps/docs/src/content/docs/reference/caplet-files.mdx index 81fd2267..7700e157 100644 --- a/apps/docs/src/content/docs/reference/caplet-files.mdx +++ b/apps/docs/src/content/docs/reference/caplet-files.mdx @@ -71,6 +71,7 @@ Use this Caplet when an agent needs the current repository's local test signal. | `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. | +| `catalog` | Optional | object | Optional presentation metadata for public catalog surfaces. | | `mcpServer` | Optional | object | MCP server backend configuration for this Caplet. | | `openapiEndpoint` | Optional | object | OpenAPI endpoint backend configuration for this Caplet. | | `googleDiscoveryApi` | Optional | object | Google Discovery API backend configuration for this Caplet. | diff --git a/apps/docs/src/content/docs/vault.mdx b/apps/docs/src/content/docs/vault.mdx index 710ecaf4..2215b9fd 100644 --- a/apps/docs/src/content/docs/vault.mdx +++ b/apps/docs/src/content/docs/vault.mdx @@ -214,3 +214,19 @@ caplets vault access grant GH_TOKEN github If the Caplet starts through an agent harness, restart the agent after setting or granting Vault values so the Caplets runtime reloads clean config. + +## Install-time setup guidance + +After `caplets install`, lockfile restore, or `caplets update`, Caplets checks the runtime +config for unresolved Vault references on affected Caplets. The install lifecycle remains +successful, but output includes recovery guidance when a key is missing or ungranted. + +Human output names only the command to run: + +```sh +caplets vault set GH_TOKEN +caplets vault access grant GH_TOKEN github +``` + +`--json` includes a per-entry `vaultSetup` object with `status`, `messages`, and +`recoveryCommands`. It does not include raw secret values. diff --git a/apps/docs/src/styles/global.css b/apps/docs/src/styles/global.css index c09f394c..1c090871 100644 --- a/apps/docs/src/styles/global.css +++ b/apps/docs/src/styles/global.css @@ -30,8 +30,9 @@ :root[data-theme="dark"] { --sl-color-accent-low: oklch(31% 0.04 35); - --sl-color-accent: oklch(72% 0.12 35); + --sl-color-accent: oklch(78% 0.115 35); --sl-color-accent-high: oklch(84% 0.08 35); + --sl-color-text-accent: oklch(84% 0.08 35); --sl-color-white: oklch(94% 0.018 82); --sl-color-gray-1: oklch(93% 0.018 82); --sl-color-gray-2: oklch(84% 0.018 82); @@ -55,6 +56,12 @@ li { text-wrap: pretty; } +.sl-markdown-content > p, +.sl-markdown-content > ul, +.sl-markdown-content > ol { + max-width: 74ch; +} + .hero { border-bottom: 1px solid var(--sl-color-gray-5); } @@ -73,6 +80,36 @@ li { white-space: nowrap; } +.caplets-docs-cta { + display: inline-flex; + align-items: center; + min-height: 44px; + border: 1px solid var(--sl-color-accent); + border-radius: 0.625rem; + background: var(--sl-color-accent); + color: var(--caplets-paper); + padding: 0.65rem 0.9rem; + font-weight: 700; + text-decoration: none; +} + +.caplets-docs-cta:hover { + background: var(--sl-color-accent-high); + color: var(--caplets-paper); +} + +:root[data-theme="dark"] .caplets-docs-cta, +:root[data-theme="dark"] .caplets-docs-cta:hover { + color: var(--caplets-ink); +} + +:root[data-theme="dark"] .sidebar-content a[aria-current="page"], +:root[data-theme="dark"] .sidebar-content a[aria-current="true"], +:root[data-theme="dark"] mobile-starlight-toc a[aria-current="page"], +:root[data-theme="dark"] mobile-starlight-toc a[aria-current="true"] { + color: var(--sl-color-black); +} + .sl-markdown-content table { font-size: 0.9rem; } @@ -85,6 +122,49 @@ li { color: var(--sl-color-gray-2); } +:root:not([data-theme="dark"]) .sidebar-content a:hover, +:root:not([data-theme="dark"]) .sidebar-content a:focus-visible, +:root:not([data-theme="dark"]) .sidebar-content summary:hover, +:root:not([data-theme="dark"]) .sidebar-content summary:focus-visible, +:root:not([data-theme="dark"]) .right-sidebar a:hover, +:root:not([data-theme="dark"]) .right-sidebar a:focus-visible, +:root:not([data-theme="dark"]) mobile-starlight-toc a:hover, +:root:not([data-theme="dark"]) mobile-starlight-toc a:focus-visible { + background-color: color-mix(in oklch, var(--sl-color-accent-low), transparent 35%); + color: var(--sl-color-gray-1); +} + +:root:not([data-theme="dark"]) .sidebar-content a[aria-current="page"]:hover, +:root:not([data-theme="dark"]) .sidebar-content a[aria-current="true"]:hover, +:root:not([data-theme="dark"]) .sidebar-content a[aria-current="page"]:focus-visible, +:root:not([data-theme="dark"]) .sidebar-content a[aria-current="true"]:focus-visible, +:root:not([data-theme="dark"]) .right-sidebar a[aria-current="page"]:hover, +:root:not([data-theme="dark"]) .right-sidebar a[aria-current="true"]:hover, +:root:not([data-theme="dark"]) .right-sidebar a[aria-current="page"]:focus-visible, +:root:not([data-theme="dark"]) .right-sidebar a[aria-current="true"]:focus-visible, +:root:not([data-theme="dark"]) mobile-starlight-toc a[aria-current="page"]:hover, +:root:not([data-theme="dark"]) mobile-starlight-toc a[aria-current="true"]:hover, +:root:not([data-theme="dark"]) mobile-starlight-toc a[aria-current="page"]:focus-visible, +:root:not([data-theme="dark"]) mobile-starlight-toc a[aria-current="true"]:focus-visible { + background-color: var(--sl-color-accent); + color: var(--caplets-paper); +} + +:root:not([data-theme="dark"]) starlight-theme-select select:hover, +:root:not([data-theme="dark"]) starlight-theme-select select:focus, +:root:not([data-theme="dark"]) starlight-theme-select select:focus-visible { + border-color: var(--sl-color-accent); + background-color: color-mix(in oklch, var(--sl-color-accent-low), transparent 20%); + color: var(--sl-color-gray-1); +} + +:root:not([data-theme="dark"]) starlight-theme-select select:hover option, +:root:not([data-theme="dark"]) starlight-theme-select select:focus option, +:root:not([data-theme="dark"]) starlight-theme-select select:focus-visible option { + background-color: var(--sl-color-white); + color: var(--sl-color-gray-1); +} + @media (max-width: 44rem) { html, body { @@ -102,11 +182,20 @@ li { box-shadow: none; } + :root[data-theme="dark"] starlight-menu-button button { + background-color: var(--sl-color-gray-6); + color: var(--sl-color-gray-1); + } + mobile-starlight-toc .display-current, mobile-starlight-toc [aria-current="true"] { color: var(--sl-color-gray-2); } + :root[data-theme="dark"] mobile-starlight-toc [aria-current="true"] { + color: var(--sl-color-black); + } + mobile-starlight-toc details[open] .toggle, mobile-starlight-toc details .toggle:hover { color: var(--sl-color-gray-1); @@ -134,6 +223,14 @@ li { min-width: 44px; } + .sl-heading-wrapper .sl-anchor-link { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; + } + .sidebar-content a, .sidebar-content summary { align-items: center; @@ -146,6 +243,7 @@ li { .expressive-code .ec-line .code { min-width: 0; + padding-right: 2.75rem; overflow-wrap: anywhere; white-space: pre-wrap; } diff --git a/apps/landing/public/caplet.schema.json b/apps/landing/public/caplet.schema.json index ea20ee26..accae7a2 100644 --- a/apps/landing/public/caplet.schema.json +++ b/apps/landing/public/caplet.schema.json @@ -203,6 +203,18 @@ "additionalProperties": false, "description": "Runtime feature and resource requirements for hosted execution." }, + "catalog": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "minLength": 1, + "description": "Optional catalog presentation icon. Use an HTTPS image URL or a bundled image path relative to this Caplet directory. Values must not use credentials, local or absolute paths, path traversal, query strings, fragments, or private hosts." + } + }, + "additionalProperties": false, + "description": "Optional presentation metadata for public catalog surfaces." + }, "mcpServer": { "type": "object", "properties": { diff --git a/apps/landing/src/components/landing/Footer.astro b/apps/landing/src/components/landing/Footer.astro index 161a959b..b5971d0a 100644 --- a/apps/landing/src/components/landing/Footer.astro +++ b/apps/landing/src/components/landing/Footer.astro @@ -6,6 +6,10 @@ import ArrowUpRight from "@tabler/icons/outline/arrow-up-right.svg";

    Caplets gives agents capabilities, not giant tool walls.