From f44502aa39a771b12b17e6d72a9f532a7edb79e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 09:56:17 +1000 Subject: [PATCH 01/11] docs(plans): add recommended skill catalog plan --- .claude/plans/recommended-skill-catalog.md | 779 +++++++++++++++++++++ 1 file changed, 779 insertions(+) create mode 100644 .claude/plans/recommended-skill-catalog.md diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md new file mode 100644 index 0000000..87a9702 --- /dev/null +++ b/.claude/plans/recommended-skill-catalog.md @@ -0,0 +1,779 @@ +# Recommended Skill Catalog Implementation Plan + +**Status:** Draft PR plan only +**Date:** 2026-08-24 +**Implementation target:** A later feature branch based on then-current `origin/main` + +## Decision + +Add one built-in, typed catalog named `recommended` without changing the default search scope: + +```text +allagents skill search --catalog recommended +searchSkills(query, { catalog: 'recommended' }) +``` + +Without `--catalog`, `skill search` remains global GitHub Code Search. `--catalog` and `--owner` are mutually exclusive and fail validation with exit code 2. When `--catalog recommended` is present, the named catalog is a hard boundary: an empty result, source-health failure, query-batch failure, or manifest failure never falls back to global GitHub results. The catalog is not a marketplace registry; it describes repositories, subtrees, valid marketplace-backed sources, search-only sources, and external-lifecycle distributions without registering any of them in `MarketplaceRegistry`. + +The one user-facing catalog label is exactly **Recommended**. This is a discovery label, not a security, trust, or license assertion. UI, JSON documentation, and release notes must never call catalog entries “verified” or “safe.” Every catalog result instead carries explicit source classification, install policy, metadata, and warnings. Catalog membership must never claim that a source is security-reviewed, license-approved, dependency-complete, compatible with every client, or safe to bulk-install. + +## Problem and observed repository state + +`src/core/skill-search.ts` currently builds up to four global `filename:SKILL.md` GitHub Code Search queries, merges them by priority, filters hidden paths, ranks, enriches, deduplicates, and paginates. `SkillSearchOptions` has only `owner`, `page`, and `limit`. The CLI in `src/cli/commands/plugin-skills.ts` exposes the same three flags. + +The interactive CLI currently loses source boundaries in two places: + +1. `collectSelectedSkillSearchRepos()` reduces selected hits to `item.repo`. +2. `installFromSearch()` passes that repository root to `addPlugin()` or `addUserPlugin()`. + +That is incorrect for two logical sources in one repository, such as `NousResearch/hermes-agent/skills` and `NousResearch/hermes-agent/optional-skills`. It also makes a selected skill-directory URL unsafe as an installation root: `src/core/transform.ts::copySkills()` copies only `SKILL.md` for a root-level standalone skill, while a repository or common subtree plus a qualified allowlist copies the complete selected skill directory recursively, including `references/`, scripts, and other sibling assets inside that directory. + +Subpath identity is also currently collapsed: + +- `src/core/workspace-modify.ts::resolveGitHubIdentity()` returns only lower-cased `owner/repo`. +- `findPluginEntryByGitHubIdentity()` and `canonicalizeGitHubPluginSource()` can promote two subtrees to their repository root. +- `src/cli/commands/plugin-skills.ts::recordSourceProvenance()` keys sync state by repository only. +- `src/core/sync.ts::buildSourcesProvenance()` also writes `sources[owner/repo]`. + +Finally, clean clones currently fail before discovery. `src/core/git.ts::createGit()` supplies `filter.lfs.*` config values to `simple-git@3.30.0` but does not set `allowUnsafeFilter: true`. Clean-cache installs of `mattpocock/skills` and both Hermes subtrees hit simple-git's unsafe-filter rejection. Cache-seeded runs subsequently copied 36, 82, and 117 skills respectively with zero copy failures; those runs do not prove clean installability. + +## Goals + +1. Restrict search to one curated, static, versioned, typed source set when `catalog: 'recommended'` is requested. +2. Enforce canonical repository, effective ref, and exact segment-boundary subtree constraints in application code before ranking or pagination, with no global fallback. +3. Keep Hermes core and optional as different catalog identities even though they share one repository clone/cache. +4. Carry an exact install descriptor from catalog entry to selection, project workspace configuration, installation, and sync provenance. +5. Preserve complete skill directories by installing repository/common-subtree roots with qualified allowlists, never selected skill directories. +6. Respect marketplace manifests only for actual marketplace sources; never coerce plain repositories or subtrees into `MarketplaceRegistry`. +7. Surface category, homepage, author, SPDX metadata, suitability, license, lifecycle, dependency, and bulk-install warnings without a boolean trust field. +8. Separate read-only search/discovery provenance from actual install provenance. +9. Prove project-scoped installation from clean clones in disposable workspaces. +10. Require PR review, mandatory manifest validation, and read-only catalog health checks for catalog changes. + +## Non-goals + +- Replacing global GitHub search or changing its no-option default. +- Falling back to global GitHub when a named-catalog search cannot return a valid bounded result. +- Accepting arbitrary catalog refs in MVP. GitHub Code Search searches the repository default branch; non-default/tag/SHA refs remain out of scope until catalog search becomes ref-aware. +- Adding a user-selected catalog default to `workspace.yaml`; only provenance for an actual catalog install is persisted. +- Turning the catalog into a remote service, user-editable registry, marketplace registry, security scanner, vendored mirror, or repository mirror. +- Creating split generated/manual registries. There is one typed catalog object and one schema version. +- Adding a direct-to-main updater, shell-based catalog sync, or any mutation-capable catalog health command. Catalog changes arrive only through reviewed PRs. +- Adding a boolean `trusted`, `verified`, `safe`, or equivalent field. Suitability is represented by classification, install policy, warning codes, and health results. +- Provisioning MCP servers, Rube, API credentials, binaries, platform packages, browsers, or other skill dependencies. +- Declaring license compatibility or conducting legal/security approval. +- Automatically running upstream lifecycle scripts, especially gstack's setup/update flow. +- Bulk-installing optional or experimental sources by default. +- Making the broken nested Composio manifest installable. +- Adding `numman-ali/n-skills` itself as a source without a separate source-suitability decision; this revision incorporates its audited architectural decisions only. + +## Public CLI and API contract + +### CLI + +Add to `src/cli/commands/plugin-skills.ts::searchCmd`: + +```text +--catalog Restrict results to a built-in catalog. Initially: recommended. +``` + +Examples: + +```text +allagents skill search testing --catalog recommended +allagents skill search testing --catalog recommended --page 2 --limit 10 +allagents --json skill search testing --catalog recommended +``` + +Validation is centralized in `validateSkillSearchArgs()`: + +- Unknown catalog: `Unknown skill catalog "". Available catalogs: recommended.` +- Catalog plus owner: `--catalog and --owner cannot be used together.` +- Both are `SkillSearchError` with `kind: 'validation'`; CLI exit code remains 2 and JSON uses the existing failed command envelope. +- Existing query length, page, limit, owner, API, and rate-limit behavior remains unchanged. + +TTY catalog results use the existing multi-select, but: + +- installable results are grouped by exact `installSource`; +- search-only and external-lifecycle results remain visible but are disabled in the picker; +- warnings are included in the hint and repeated once before an optional/experimental install; +- no source is preselected; +- only explicitly selected skills are enabled; +- one source is fetched/configured once and all selected qualified selectors for it are written in one allowlist update; +- all source mutations complete before one project/user sync, rather than syncing once per selected skill. + +Non-TTY output keeps the table format and appends a concise policy marker only when needed, such as `optional`, `search only`, or `external installer`. JSON is authoritative for full warnings. + +The TUI action `src/cli/tui/actions/skills.ts::runSearchOnlineSkills()` continues using global search because it has no catalog selector in this scope. It must use `item.installSource` rather than reconstructing a repository source, preserving compatibility for global results where `installSource === repo`. + +### Core API + +In `src/core/skill-search.ts`: + +```ts +export type SkillCatalogName = 'recommended'; + +export interface SkillSearchOptions { + owner?: string; + catalog?: SkillCatalogName; + page?: number; + limit?: number; +} +``` + +Extend `SkillSearchItem` additively. Replace boolean installability with an explicit policy enum; it describes supported behavior and is not a trust signal: + +```ts +installSource: string; +installSelector: string; +installation: { + policy: 'repository-install' | SkillCatalogInstallPolicy; + reasonCodes: readonly string[]; +}; +catalog?: { + name: SkillCatalogName; + label: 'Recommended'; + version: 1; + identity: string; + sourceId: string; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + warnings: readonly SkillCatalogWarning[]; + discovery: CatalogDiscoveryProvenance; + installDescriptor: CatalogInstallDescriptor; +}; +``` + +For global search, `installSource` is canonical `owner/repo`, `installSelector` is the existing qualified name, `installation.policy` is `repository-install`, and `catalog` is absent. This preserves existing default search/install behavior while eliminating downstream source reconstruction. + +For catalog search, all catalog fields come from the matched source plus the bounded GitHub response. `SkillSearchResult` retains `query`, `items`, `total`, and `truncated`; no persistent catalog preference is added. + +## One versioned typed catalog and source identity + +Create `src/models/skill-catalog.ts` for data-independent catalog types plus `CatalogInstallDescriptorSchema`, and `src/core/skill-catalog.ts` for the single immutable catalog object, stable source IDs, metadata, lookup helpers, identity construction, path-boundary helpers, and warning text. This keeps `src/models/workspace-config.ts` from importing a core module. Neither file imports `src/core/marketplace.ts`, and there is no generated registry beside the manual catalog object. + +```ts +export type SkillCatalogName = 'recommended'; +export type SkillCatalogClassification = + | 'recommended' + | 'optional' + | 'external-lifecycle'; +export type SkillCatalogSourceKind = + | 'repository' + | 'subtree' + | 'marketplace' + | 'external-lifecycle'; +export type SkillCatalogInstallPolicy = + | 'direct-selective' + | 'marketplace-selective' + | 'search-only' + | 'external-installer'; +export type SkillCatalogBulkPolicy = 'allowed' | 'explicit-only' | 'forbidden'; +export type SkillCatalogCategory = + | 'software-engineering' + | 'business-operations' + | 'integrations' + | 'general-purpose' + | 'agent-engineering' + | 'documentation' + | 'knowledge-work'; + +export interface SkillCatalogAuthor { + name: string; + url: string; +} + +export interface SkillCatalogWarning { + code: + | 'not-security-reviewed' + | 'license-metadata' + | 'license-ambiguous' + | 'external-dependencies' + | 'external-lifecycle' + | 'broken-marketplace' + | 'large-source' + | 'optional-source'; + message: string; +} + +export interface SkillCatalogSource { + sourceId: string; + repo: `${string}/${string}`; + effectiveRef: string; + approvedRoot: '.' | string; + installRoot: '.' | string; + installSource: string; + installableSubpath?: string; + displayName: string; + description: string; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + installPolicy: SkillCatalogInstallPolicy; + bulkPolicy: SkillCatalogBulkPolicy; + manifestBoundary: 'none' | 'authoritative'; + warnings: readonly SkillCatalogWarning[]; +} + +export interface SkillCatalog { + schemaVersion: 1; + name: 'recommended'; + label: 'Recommended'; + sources: readonly SkillCatalogSource[]; +} +export const RECOMMENDED_SKILL_CATALOG: SkillCatalog = Object.freeze({ + schemaVersion: 1, + name: 'recommended', + label: 'Recommended', + sources: Object.freeze(RECOMMENDED_SOURCE_ENTRIES), +}); +``` + +`RECOMMENDED_SOURCE_ENTRIES` is a private, same-file typed constant populated exactly from the source and metadata tables below. It is not a second registry and is not emitted by a generator. + +Stable `sourceId` values are borrowed as a design rule from the `numman-ali/n-skills` audit: kebab-case, human-assigned, never derived from display text, never reused, and unchanged when metadata changes. There is one catalog schema version. Increment it only for a catalog schema/semantic change, not ordinary source metadata edits. + +Catalog identity is never repository identity. Define one constructor used by search, selection, config, and sync state: + +```ts +catalogSourceIdentity({ + catalog: 'recommended', + sourceId, + effectiveRef, + approvedRoot, +}): string +``` + +Its canonical serialized form is `recommended:@#`, using `.` for repository root. These four fields are mandatory and are the identity components. `repo` remains an explicit validated descriptor field but is never the sole deduplication, install, or provenance key. + +`effectiveRef` is mandatory and, in MVP, must equal the repository's current default branch because GitHub Code Search is not arbitrary-ref-aware. `approvedRoot` is the hard search boundary; `.` means repository root. `installableSubpath` may narrow installation inside that boundary. `installSource` must encode the same repository, effective ref, and installation root, for example `NousResearch/hermes-agent@main/optional-skills`. + +Invariants checked by unit and manifest validation: + +- Stable source IDs and full catalog identities are unique; Hermes may share `repo` but never identity. +- Paths are normalized POSIX-relative segment paths with no leading/trailing slash, empty segment, `.` segment (except root sentinel), or `..` segment. +- Each `effectiveRef` equals the upstream default branch observed by the read-only health check; arbitrary refs fail validation in MVP. +- `installSource` parses back to the same repo/ref and `installRoot`. +- `external-installer` and `search-only` require `bulkPolicy: 'forbidden'`; optional entries require `explicit-only` or `forbidden`. +- Marketplace entries require `manifestBoundary: 'authoritative'`; plain repository/subtree entries require `none`. +- `spdxLicense: null` means no single catalog-asserted SPDX identifier, not “unlicensed.” +- Every entry includes the common `not-security-reviewed` warning through a shared helper, not duplicated strings. + +`approvedRoot` is the search boundary. `installRoot` is the exact root cloned/resolved for installation and may be broader only when asset preservation or marketplace semantics require it. `installableSubpath` may narrow install eligibility inside the approved search boundary. All matching uses path segments, never string-prefix approximation. + +## Initial source list and classifications + +The `recommended` catalog is the name of the built-in catalog, not a claim that every member is generally recommended. It contains all entries below so users can search a known set while seeing each source's actual classification. + +In this plan, `optional` is the optional/experimental classification requested by the product contract. The final six rows are the useful candidates sourced from EntityProcess's `ai-research-wiki` and rechecked against their upstream repositories: Anthropic Skills, Addy Osmani Agent Skills, Superpowers, Context Engineering Skills, Elastic Docs Skills, and Anthropic Knowledge Work Plugins. + +| Source ID | Repository | Effective ref | Approved root | Classification | Kind / install / bulk policy | Install source and audited facts | +|---|---|---|---|---|---|---| +| `gstack` | `garrytan/gstack` | `main` | `.` | `external-lifecycle` | `external-lifecycle` / `external-installer` / `forbidden` | `garrytan/gstack@main`; no AllAgents install action. 64 `SKILL.md`; MIT. Full distribution requires checkout, build/setup, host-specific generation, and updates. Generic copying is not a substitute. | +| `paperclip-companies` | `paperclipai/companies` | `main` | `.` | `optional` | `repository` / `direct-selective` / `explicit-only` | `paperclipai/companies@main`, installable only under `skills/`. Root-plus-qualified-allowlist preserves assets. 523 `SKILL.md` in the tree; supported root discovery exposes `company-creator` and `readme-updater`; all other hits are search-only. Licensing is ambiguous. | +| `mattpocock-skills` | `mattpocock/skills` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `mattpocock/skills@main`; valid root marketplace manifest is authoritative. 36 recursive discoveries; MIT. | +| `composio-awesome-claude-skills` | `ComposioHQ/awesome-claude-skills` | `master` | `.` | `optional` | `repository` / `search-only` / `forbidden` | `ComposioHQ/awesome-claude-skills@master`; no generic/marketplace install. 864 discoverable directories; nested manifest has 107 missing/misresolved source paths; most skills require Rube/MCP; licensing is incomplete. | +| `hermes-core` | `NousResearch/hermes-agent` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `NousResearch/hermes-agent@main/skills`; 82 skills; MIT. Exact boundary rejects optional and unrelated paths. | +| `hermes-optional` | `NousResearch/hermes-agent` | `main` | `optional-skills` | `optional` | `subtree` / `direct-selective` / `explicit-only` | `NousResearch/hermes-agent@main/optional-skills`; 117 skills; MIT; many platform/API/binary/service requirements. Never default bulk-install. | +| `anthropic-skills` | `anthropics/skills` | `main` | `skills` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/skills@main`, installed from repository root through its authoritative manifest. 20 audited skills; per-skill/repository license metadata caveat. | +| `addyosmani-agent-skills` | `addyosmani/agent-skills` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `addyosmani/agent-skills@main/skills`; 24 audited skills; MIT. | +| `obra-superpowers` | `obra/superpowers` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `obra/superpowers@main/skills`; 14 audited skills; MIT. | +| `context-engineering-skills` | `muratcankoylan/Agent-Skills-for-Context-Engineering` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `muratcankoylan/Agent-Skills-for-Context-Engineering@main`, repository root plus qualified allowlist; 23 repo-wide audited skills; MIT. | +| `elastic-docs-skills` | `elastic/elastic-docs-skills` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `elastic/elastic-docs-skills@main`, repository root plus qualified allowlist; 20 repo-wide audited skills; Apache-2.0. | +| `anthropic-knowledge-work` | `anthropics/knowledge-work-plugins` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/knowledge-work-plugins@main`; local root marketplace plugins only. 212 audited skills; Apache-2.0. Unsupported remote URL/git-subdir entries fail closed. | + +Metadata is mandatory catalog data, not runtime inference: + +| Source ID | Category | Author | Author URL | Homepage | SPDX | +|---|---|---|---|---|---| +| `gstack` | `software-engineering` | Garry Tan | `https://github.com/garrytan` | `https://github.com/garrytan/gstack` | `MIT` | +| `paperclip-companies` | `business-operations` | Paperclip AI | `https://github.com/paperclipai` | `https://github.com/paperclipai/companies` | `null` | +| `mattpocock-skills` | `software-engineering` | Matt Pocock | `https://github.com/mattpocock` | `https://aihero.dev/skills` | `MIT` | +| `composio-awesome-claude-skills` | `integrations` | Composio | `https://github.com/ComposioHQ` | `https://github.com/ComposioHQ/awesome-claude-skills` | `null` | +| `hermes-core` | `general-purpose` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | +| `hermes-optional` | `integrations` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | +| `anthropic-skills` | `general-purpose` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/skills` | `null` | +| `addyosmani-agent-skills` | `software-engineering` | Addy Osmani | `https://github.com/addyosmani` | `https://skills.addy.ie` | `MIT` | +| `obra-superpowers` | `software-engineering` | obra | `https://github.com/obra` | `https://github.com/obra/superpowers` | `MIT` | +| `context-engineering-skills` | `agent-engineering` | Murat Can Koylan | `https://github.com/muratcankoylan` | `https://github.com/muratcankoylan/Agent-Skills-for-Context-Engineering` | `MIT` | +| `elastic-docs-skills` | `documentation` | Elastic | `https://github.com/elastic` | `https://elastic.github.io/elastic-docs-skills/` | `Apache-2.0` | +| `anthropic-knowledge-work` | `knowledge-work` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/knowledge-work-plugins` | `Apache-2.0` | + +`null` SPDX values deliberately preserve licensing ambiguity or mixed/per-skill metadata. The order above is stable catalog order and the final relevance tie-breaker. Counts are audit evidence and drift sentinels, not runtime acceptance limits; upstream additions/removals do not automatically change classification or policy. + +## Catalog search algorithm + +### Query construction + +Keep `buildSearchQueries(query, owner)` for global search. Add catalog-specific construction rather than overloading owner semantics: + +1. Resolve `getSkillCatalog('recommended')` and deduplicate canonical repositories case-insensitively for network efficiency only. This does not deduplicate catalog identities; Hermes remains two sources. +2. Read-only preflight each unique repository through GitHub's repository/ref API. Require its default branch to equal every entry's `effectiveRef` and resolve the current head SHA. A mismatch or unavailable ref fails the named-catalog search with no global fallback. This constraint is why arbitrary refs are out of MVP. +3. Build the same semantic variants used today: path term, hyphenated content when applicable, and primary content. Catalog search omits query-as-owner because repository qualifiers define scope. +4. Batch repository qualifiers deterministically so each unencoded GitHub query stays below a documented 240-character internal ceiling. Each batch is emitted as a parenthesized OR group of exact `repo:owner/name` qualifiers. Never use `user:` or a broad owner qualifier for catalog mode. +5. Fetch page 1 at 100 results for advisory variants. For each primary batch, fetch enough 100-result pages to cover `page * limit * 3`, capped by GitHub's existing 1,000-result ceiling. +6. Dispatch independent query batches with `Promise.allSettled`. Any required repository/ref preflight or primary batch failure fails catalog search. Advisory failures may warn and merge survivors only when all catalog boundaries remain intact; none may trigger global search. + +Unit tests must assert the literal qualifier set, deterministic batching, query-length ceiling, omission of non-catalog repositories, one network repository qualifier for Hermes, two Hermes catalog identities, default-ref enforcement, and no fallback calls. + +### Application-side source enforcement + +GitHub query qualifiers are only a first filter. Before `rankByRelevance()`, `truncateForProcessing()`, enrichment, name deduplication, or pagination: + +1. Canonicalize response `repository.full_name` to lower case and require an exact catalog repository match. +2. Bind the response to the preflighted default branch/head and the entry's `effectiveRef`; never relabel a default-branch result as an arbitrary ref. +3. Normalize API paths as POSIX relative paths; reject empty paths, absolute paths, `.`/`..` segments, backslashes, and any basename other than `SKILL.md`. +4. Match `approvedRoot` by path segments: + - `.` matches any valid path in that repository; + - `skills` matches `skills/<...>/SKILL.md` and never `skills-old/...`; + - `optional-skills` matches `optional-skills/<...>/SKILL.md` and never `optional-skills-old/...`; + - exact boundary checks are case-sensitive because Git paths are case-sensitive. +5. If multiple entries in one repository match, select the entry with the longest matching `approvedRoot`; use stable catalog order only as a final tie-breaker. +6. Derive `installSelector` relative to `installRoot`. For repository roots with a `skills/` directory, strip the leading `skills/`; for subtree roots, strip the root. Preserve intermediate namespace segments so duplicate leaf names remain distinguishable. +7. Derive `installation.policy`, never a boolean: + - `external-installer` and `search-only` remain those exact policies; + - Paperclip hits outside exact `skills/`, or not resolvable by root discovery, become `search-only` with reason codes; + - marketplace hits remain `marketplace-selective`, but the picker enables them only after manifest preflight resolves the selected path to a valid local plugin; + - direct subtree/repository hits inside their installation boundary remain `direct-selective`. +8. Attach catalog identity, metadata, warning codes, discovery provenance, and exact install descriptor, then continue the existing hidden-directory filter, relevance scoring, enrichment, per-name cap, and pagination. + +Catalog deduplication keys are `catalog identity + path`; repository alone is never a catalog key. Global mode retains `repo + qualifiedName`. Stable ordering for equal relevance scores is catalog order, repository, then path; do not depend on network completion order. + +`total` is the application-filtered, de-duplicated working total. `truncated` is true if any GitHub batch is incomplete/capped or if additional filtered pages exist. Zero bounded hits returns zero catalog hits, not global results. + +## Search-to-install design + +Replace `collectSelectedSkillSearchRepos()` with `collectSelectedSkillSearchSources()` in `src/cli/commands/plugin-skills.ts`. A selection key is `catalog identity + repository path`; path or repository alone is insufficient. + +```ts +interface SelectedSkillSearchSource { + catalogIdentity?: string; + installDescriptor?: CatalogInstallDescriptor; + installSource: string; + installPolicy: 'repository-install' | SkillCatalogInstallPolicy; + classification?: SkillCatalogClassification; + warnings: readonly SkillCatalogWarning[]; + selectors: string[]; +} +``` + +Collection rules: + +- Preserve displayed result order. +- Group catalog hits by full catalog identity and exact install descriptor, never by repo. Global hits continue grouping by normalized `installSource`. +- Deduplicate selectors within an identity while preserving order. +- Never merge `hermes-core` and `hermes-optional`. +- Reject selected keys absent from the current bounded result set. +- Global results continue to group at repository root and use the existing whole-repository install behavior. + +For catalog direct-selective groups: + +1. Fetch the repository once, resolving the declared common root. +2. Discover from that root and require every selected qualified selector to resolve exactly. +3. Upsert one plugin entry whose `source` is the catalog `installSource` and whose `skills` array contains selected qualified selectors plus any existing selectors for that exact source. +4. Do not promote the source to a broader common repository root. +5. Sync once after all selected groups are configured. + +For catalog marketplace-selective groups: + +1. Fetch/register the root as a marketplace using existing manifest semantics. +2. Resolve each selected hit to a valid manifest plugin and local skill path. A manifest is authoritative; recursive paths outside it are not installed. +3. Add only the required `plugin@marketplace` entries and set per-plugin qualified allowlists. +4. Reject remote manifest source types unsupported by the current installer rather than treating their paths as local. +5. Sync once after all groups are configured. + +For optional groups, display warnings and require an explicit confirmation after selection. For `search-only` and `external-installer`, disable selection; if a stale/programmatic selection reaches the installer, return a validation-style error before config mutation. The external error includes the upstream lifecycle URL. No lifecycle script is executed by AllAgents. + +## Discovery provenance, install descriptor, and persisted provenance + +Discovery and installation are different observations and must not overwrite each other. + +```ts +export interface CatalogInstallDescriptor { + catalog: 'recommended'; + catalogVersion: 1; + sourceId: string; + repo: `${string}/${string}`; + effectiveRef: string; + approvedRoot: '.' | string; + installSource: string; + installRoot: '.' | string; + sourceKind: SkillCatalogSourceKind; + installPolicy: SkillCatalogInstallPolicy; +} + +export interface CatalogDiscoveryProvenance { + catalogIdentity: string; + provider: 'github-code-search'; + repo: `${string}/${string}`; + effectiveRef: string; + catalogVersion: 1; + approvedRoot: '.' | string; + repositoryHeadSha: string; + skillPath: string; + blobSha: string; +} +``` + +`CatalogDiscoveryProvenance` describes why a search hit was admitted: catalog identity, preflighted default ref/head, approved search root, response path, and Code Search blob SHA. It is returned in the search result and may be logged for diagnostics, but it is not installation proof. + +`CatalogInstallDescriptor` is immutable input to installation. Before mutation, the installer must re-resolve the exact repo/ref/root, reject descriptor drift, and clone/fetch `effectiveRef`. Arbitrary caller-provided ref overrides are rejected in MVP. + +Extend `src/models/workspace-config.ts::PluginEntrySchema` with an optional `catalogSource` object matching `CatalogInstallDescriptor`. Every catalog-originated project install writes it; user-scope installs use the same schema for consistency. The ordinary `source` remains the exact fetch/install spelling, including `@` and subpath. Marketplace plugin entries retain `plugin@marketplace` in `source` and use `catalogSource` to preserve the originating repo/ref/approved root/install root. + +```yaml +plugins: + - source: NousResearch/hermes-agent@main/skills + skills: [research/llm-wiki] + catalogSource: + catalog: recommended + catalogVersion: 1 + sourceId: hermes-core + repo: NousResearch/hermes-agent + effectiveRef: main + approvedRoot: skills + installSource: NousResearch/hermes-agent@main/skills + installRoot: skills + sourceKind: subtree + installPolicy: direct-selective +``` + +This is an additive workspace-schema-v2 field, not a schema-version migration. It prevents later sync/update code from reconstructing a catalog source from repository alone. + +Preserve existing repository-promotion behavior for non-catalog `skill add` by making matching mode explicit in `upsertGitHubPluginSourceAllowlistInConfig()` and project/user wrappers: + +```ts +{ identity: 'repository-promoting' | 'catalog-exact', catalogSource?: CatalogInstallDescriptor } +``` + +The default remains `repository-promoting`. Catalog installs require `catalog-exact`; they match/merge only identical full catalog identities and reject a conflicting descriptor. They never promote sibling catalog roots. + +Extend `src/models/sync-state.ts::SyncStateSourceSchema` additively with optional `catalogSource` and `resolvedRoot`. Catalog sync-state keys are the canonical catalog identities: + +```text +recommended:hermes-core@main#skills +recommended:hermes-optional@main#optional-skills +``` + +Actual install provenance is `{ catalogSource, resolvedRef, resolvedSha, resolvedRoot, pluginSpec }` captured after fetch/manifest resolution. It records the installed commit and root and is deliberately separate from the earlier discovery head/blob. The two Hermes records may share `resolvedSha` and physical repository cache, but never a state key. `src/core/plugin.ts::fetchCache` and `getPluginCachePath()` remain repository+ref keyed only for physical clone reuse. + +`src/core/sync.ts::buildSourcesProvenance()` rebuilds the complete derived `sources` map. For catalog entries it requires persisted `catalogSource`, verifies source/ref/root consistency, and emits catalog-identity keys. For non-catalog entries it preserves existing keys. A legacy repo-only state record still parses and is replaced on the next full sync; workspace config is never inferred from old sync state. + +Tests install core and optional in both orders and assert two workspace descriptors, two catalog-identity provenance keys, one physical cache identity, exact resolved roots/refs, and no promotion to repository root. + +## Clean-clone fix + +In `src/core/git.ts::createGit()`, set simple-git's `allowUnsafeFilter: true` alongside the existing `filter.lfs.*` config. Do not remove `GIT_LFS_SKIP_SMUDGE=1` or the LFS filter overrides; they prevent large LFS downloads during discovery/install. Do not apply a global user/repository git config. + +Add a regression to `tests/unit/core/git.test.ts` that creates a disposable local origin and calls the real `cloneTo()` into an empty destination. The test must exercise the configured `filter.lfs.*` path so it fails with simple-git's unsafe-filter rejection if `allowUnsafeFilter` is removed. Assert clone success and expected tracked content; do not merely snapshot an options object. + +This fix is stage zero for catalog install claims. A cache-seeded run is not acceptable evidence. + +## Manifest validation, read-only health, and review gate + +Create `src/core/skill-catalog-health.ts` with two reusable entry points: + +```ts +validateSkillCatalog(catalog: SkillCatalog): CatalogValidationIssue[] +checkSkillCatalogHealth(catalog: SkillCatalog, deps): Promise +``` + +`validateSkillCatalog()` is deterministic and offline: type/invariant checks, identity uniqueness, source/ref/root/install-source consistency, metadata presence, SPDX syntax when non-null, policy combinations, warning requirements, and absence of arbitrary refs. + +`checkSkillCatalogHealth()` is networked but strictly read-only. It performs only GitHub GET requests and returns per-source statuses `healthy`, `drifted`, or `unreachable` with reason codes; it never clones, installs, writes config, updates refs, opens PRs, or executes source code. It checks: + +1. repository exists and canonical name still matches; +2. default branch equals `effectiveRef` and the ref resolves; +3. `approvedRoot`, `installRoot`, and installable subpath exist at that ref; +4. representative `SKILL.md` paths remain within segment boundaries; +5. valid marketplace-selective entries have a parseable authoritative manifest at the effective ref; +6. every local manifest plugin source resolves inside `installRoot`, every selected skill path resolves inside `approvedRoot`, and no path escapes through `..`, absolute paths, symlinks, or misresolved relative sources; +7. search-only/external entries remain non-installable and retain required warning codes; Composio's broken nested manifest is reported as an expected health warning, never accepted as a marketplace manifest. + +Create `scripts/validate-skill-catalog.ts`, importing the same catalog object and health functions. Add package scripts: + +```json +"catalog:validate": "bun run scripts/validate-skill-catalog.ts --ci", +"catalog:health": "bun run scripts/validate-skill-catalog.ts --report" +``` + +Both modes are read-only. `--ci` fails on any static error, repository/ref/root drift, or invalid authoritative marketplace manifest. `--report` emits the same structured report for maintainers and exits nonzero for drift/unreachable sources; it never repairs anything. + +Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project dependencies and runs `bun run catalog:validate` with the workflow's read-only `GITHUB_TOKEN` (`contents: read`; no write permissions). Configure the repository ruleset/branch protection before implementation merge so this job is required and catalog changes require at least one human approval; record that setting and the health report/upstream SHAs in the PR. Any modification to `src/core/skill-catalog.ts`, catalog validation, or catalog policy must arrive through that path. If the required-check/approval rule cannot be enabled, catalog implementation remains blocked. No direct-to-main updater or automated catalog-writing workflow is added. + +## Exact repository changes + +### New files + +- `src/models/skill-catalog.ts` + - Catalog/source/metadata/provenance types and Zod `CatalogInstallDescriptorSchema`; no catalog entries or registry data. +- `src/core/skill-catalog.ts` + - Single schema-version-1 `Recommended` catalog, stable source IDs, explicit repo/ref/roots, metadata, warnings, identity constructor, and segment-boundary helpers. +- `src/core/skill-catalog-health.ts` + - Offline validation and dependency-injected read-only GitHub health/manifest checks. +- `scripts/validate-skill-catalog.ts` + - Read-only `--ci` and `--report` entry points over the same catalog/validator. +- `tests/unit/core/skill-catalog.test.ts` + - Typed-data invariants, identity, metadata, policies, exact boundaries, and Hermes separation. +- `tests/unit/core/skill-catalog-health.test.ts` + - Ref/root drift, manifest traversal/misresolution, status reporting, and zero-mutation dependency assertions. + +- `tests/unit/models/skill-catalog.test.ts` + - Descriptor schema acceptance/rejection and versioned serialization. + +### Modified production and configuration files + +- `src/core/skill-search.ts` + - Add `catalog` option, policy/metadata/provenance result fields, default-ref preflight, deterministic repository batching, and hard-boundary filtering before rank/pagination. + - Keep no-option global behavior, but never invoke it as catalog fallback. +- `src/cli/commands/plugin-skills.ts` + - Add `--catalog`, catalog-identity selection grouping, exact-descriptor direct/marketplace installers, warnings, and one final sync. + - Persist `catalogSource`; record actual install provenance separately from discovery provenance. +- `src/cli/metadata/plugin-skills.ts::skillsSearchMeta` + - Document `--catalog`, mutual exclusion, Recommended label, policy/metadata/provenance JSON fields, no fallback, and arbitrary-ref exclusion. +- `src/cli/tui/actions/skills.ts::runSearchOnlineSkills` + - Consume `installSource`; keep this TUI surface on global search in this change. +- `src/utils/plugin-path.ts` + - Parse/render exact repo/ref/root descriptors and retain segment-safe path normalization; repository identity is not catalog identity. +- `src/models/workspace-config.ts::PluginEntrySchema` + - Add optional typed `catalogSource: CatalogInstallDescriptor` while retaining workspace schema version 2. +- `src/core/workspace-modify.ts` + - Add repository-promoting versus catalog-exact upsert mode and preserve/validate catalog descriptors. +- `src/core/user-workspace.ts` + - Thread catalog-exact mode and descriptor through user-scoped allowlist upsert. +- `src/models/sync-state.ts::SyncStateSourceSchema` + - Add optional catalog descriptor and resolved root while retaining sync-state schema version 1. +- `src/core/sync.ts::buildSourcesProvenance` + - Emit full catalog-identity keys and exact root/ref install provenance. +- `src/core/git.ts::createGit` + - Enable `allowUnsafeFilter` for the intentional fixed LFS filters. +- `package.json` + - Add `catalog:validate` and `catalog:health` scripts. +- `.github/workflows/ci.yml` + - Add the `Catalog Manifest` validation job with read-only token permissions; mark it required in the repository ruleset before merge. + +`src/core/marketplace.ts` and `src/utils/marketplace-manifest-parser.ts` remain marketplace dependencies rather than catalog registries. Reuse their manifest schemas/resolution rules; do not store catalog entries in `MarketplaceRegistry`. + +### Modified tests + +- `tests/unit/core/skill-search.test.ts` +- `tests/unit/cli/skill-search-summary.test.ts` +- `tests/unit/cli/skills-add-standalone-install.test.ts` +- `tests/unit/core/github-skill-source-promotion.test.ts` +- `tests/unit/core/git.test.ts` +- `tests/unit/models/workspace-config.test.ts` +- `tests/unit/models/sync-state-schema.test.ts` +- `tests/e2e/plugin-skills.test.ts` + +Add a focused TUI unit only if the existing action is first made dependency-injectable without production-only indirection; otherwise cover `installSource` through the exported selection helper and perform the TUI smoke check manually. + +### Documentation and changelog + +- `README.md` command table: add `allagents skill search [--catalog recommended]`, label the catalog Recommended, and state global is the no-option default but never a catalog fallback. +- `docs/src/content/docs/docs/reference/cli.mdx`: add complete search syntax, flags, metadata/policy/provenance JSON fields, mutual exclusion, no-fallback behavior, default-ref-only MVP, and project config descriptor semantics. +- `docs/src/content/docs/docs/guides/marketplaces.mdx`: distinguish catalog sources from marketplaces and document authoritative-manifest validation without registering plain repositories. +- `CHANGELOG.md` under `Unreleased` / `Added`: Recommended catalog search, versioned source identity, exact ref/root descriptors, read-only health/CI validation, and warnings. Under `Fixed`: clean clone failure caused by simple-git unsafe LFS filter validation. + +## Automated test matrix + +### Catalog data and boundaries + +- Every source row above is present with exact source ID, repository, effective ref, approved root, install root/source, classification, kind, install policy, bulk policy, metadata, SPDX value, and warnings. +- Catalog schema version and label are exactly `1` and `Recommended`. +- Full identity serialization includes catalog, source ID, effective ref, and approved root; repository-only keys reject. +- Unknown catalog rejects. +- `catalog + owner` rejects before token resolution or network access. +- `skills/x/SKILL.md` matches Hermes core. +- `optional-skills/x/SKILL.md` matches Hermes optional. +- `optional-skills-old/x/SKILL.md`, `skills-old/x/SKILL.md`, and `docs/x/SKILL.md` match neither Hermes entry. +- Repository case is normalized; Git path case is not. +- Absolute, traversal, backslash, and non-`SKILL.md` paths reject. +- Paperclip `skills/company-creator/SKILL.md` has `direct-selective` policy and ambiguous-license warning; a hit elsewhere in the repository has `search-only` policy. +- Composio and gstack results remain visible with different non-install policies. + +### Query, ref, and merge behavior + +- Catalog queries contain only exact catalog `repo:` qualifiers. +- Unique repository network preflight treats Hermes once while retaining two catalog identities. +- Default branch/ref mismatch fails catalog search; no global search function is called. +- Arbitrary requested ref, tag, or SHA rejects in MVP. +- Long qualifier sets split under the query ceiling without omission or duplication. +- Query-as-owner is absent in catalog mode. +- A forged response from a non-catalog repository is discarded. +- Segment-boundary filtering occurs before truncation, ranking, enrichment, and pagination; fill a first response page with out-of-boundary hits and prove valid later hits survive. +- A required preflight/primary batch failure fails the search. Zero bounded hits stays zero. Neither path falls back globally. +- Equal-score results have deterministic catalog/repository/path ordering. +- Global query literals and existing relevance tests remain unchanged. + +### Selection and install behavior + +- Selection keys distinguish identical paths and identical repositories under different catalog identities. +- Multiple skills from one full catalog identity/descriptor produce one group and ordered unique selectors. +- Hermes core and optional produce two groups. +- Catalog direct installs write an exact ref-qualified common root/subtree source, never a selected skill directory. +- Selected nested skill fixtures copy `SKILL.md`, `references/`, scripts, and nested assets. +- Project workspace entries persist full `catalogSource`; marketplace plugin specs retain originating descriptor. +- Catalog-exact upsert retains two Hermes config entries in both install orders. +- Existing non-catalog sibling skill URLs still pass repository-promoting tests. +- Optional install requires confirmation; cancellation mutates nothing. +- Search-only/external stale selections fail before config/cache mutation. +- Marketplace selections outside an authoritative validated manifest fail closed. +- One selection transaction invokes one final sync. + +### Provenance, validation, and clone behavior + +- Discovery provenance contains bounded path/blob plus preflighted ref/head and is not reused as actual install provenance. +- Actual install provenance contains full descriptor, resolved ref/SHA/root, and catalog-identity state key. +- Real empty-destination clone succeeds with LFS filters enabled. +- Core and optional share physical repository cache/fetch but write two catalog-identity provenance records. +- Legacy repo-only sync-state input parses; next complete sync emits current catalog-identity keys and drops obsolete derived keys. +- Static catalog validation rejects missing metadata, invalid SPDX syntax, duplicate identity, illegal policy combinations, and non-default/arbitrary refs. +- Read-only health tests return `healthy`, `drifted`, or `unreachable`; injected mutating dependencies are never called. +- Authoritative marketplace manifest validation rejects missing/misresolved/escaping paths; Composio remains a warned search-only source. + +### CLI and JSON + +- `--catalog recommended` reaches `searchSkills()` and prints the Recommended label plus policy/warnings. +- Unknown catalog and catalog-owner conflict return exit 2 in text and JSON modes. +- JSON items contain exact install source/selector, policy reason codes, metadata, catalog identity, discovery provenance, and install descriptor; no `trusted`, `verified`, `safe`, or `installable` boolean exists. +- No-flag CLI and TUI searches remain global. +- Non-TTY mode never prompts or installs. + +## Project-scoped disposable install matrix + +Run this only after the clone regression and focused automated tests pass. From the implementation worktree, build once, capture the exact CLI path, then use that built CLI for every row: + +```sh +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" +bun run build +CLI="$REPO_ROOT/dist/index.js" +``` + +Each row gets a unique temporary `HOME` and project directory so no user cache, registry, credentials file, or workspace can make a clean clone appear successful. Initialize every row exactly as follows, execute the row's named catalog search and project install exercise from the matrix, inspect the three named outputs, then delete the case root: + +```sh +CASE_ROOT="$(mktemp -d)" +export HOME="$CASE_ROOT/home" +mkdir -p "$HOME" "$CASE_ROOT/project" +cd "$CASE_ROOT/project" +"$CLI" init . +# Execute the matrix row's catalog search and project-scoped install attempt. +# Inspect .allagents/workspace.yaml, .allagents/sync-state.json, +# and .agents/skills/ (the initialized universal-client target). +rm -rf "$CASE_ROOT" +``` + +Do not run `git config` outside a disposable fixture. Record command, upstream HEAD SHA, selected selectors or manifest plugins, copied/failed counts, resulting source strings, warnings, and asset checks in the implementation PR description. + +| Source | Clean project-scoped exercise | Required observation | +|---|---|---| +| gstack | Search catalog and attempt selection | `recommended:gstack@main#.` discovery stays bounded; picker blocks generic install and points to upstream lifecycle. No workspace mutation. | +| paperclip companies | Search, select `company-creator`, project install | Config source is `paperclipai/companies@main` with full descriptor and qualified allowlist; installed directory includes `references/`; ambiguous-license warning shown. | +| mattpocock skills | Search and install through manifest | Descriptor preserves `mattpocock/skills@main`; mandatory authoritative-manifest validation passes; recursive discoveries outside valid manifest resolution do not install. | +| Composio awesome skills | Search and attempt selection | Identity uses `@master#.`; results are optional/search-only with broken-manifest, dependency, and licensing warnings. No registration/install/config mutation. | +| Hermes core | Search and install selected core skills; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/skills`; config descriptor identity is `recommended:hermes-core@main#skills`; clean clone succeeds; discovery count is 82 at audited revision; selected copies have zero failures. | +| Hermes optional | Search and install one selected optional skill after warning; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/optional-skills`; config descriptor identity is `recommended:hermes-optional@main#optional-skills`; no default bulk selection; discovery count is 117; requirements warning shown. | +| Hermes core + optional | Install both in the same disposable project, then reverse order in a second project | Two ref/root-preserving config descriptors and two catalog-identity provenance keys, one physical cache, exact boundaries, zero source promotion. | +| anthropics skills | Search and marketplace-selective install | Descriptor preserves `anthropics/skills@main`, approved root `skills`, and repository install root; only manifest-declared local skill paths install; license-metadata warning remains visible. | +| addyosmani agent skills | Search and project-selective install | Exact `addyosmani/agent-skills@main/skills` source/descriptor, audited discovery count 24, complete selected directories, zero failures. | +| obra superpowers | Search and project-selective install | Exact `obra/superpowers@main/skills` source/descriptor, audited discovery count 14, complete selected directories, zero failures. | +| context engineering | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 23, zero failures. | +| Elastic docs skills | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 20, zero failures. | +| Anthropic knowledge work | Search and marketplace-selective install of local manifest entries | Descriptor preserves `anthropics/knowledge-work-plugins@main` root; audited search count 212; mandatory manifest validation passes for local entries; unsupported remote entries fail closed. | + +Count drift is not automatically a failure. If upstream HEAD differs from the audited revision, record the new count and classify every delta before updating catalog expectations. Missing assets, boundary leakage, manifest misresolution, config collapse, clone failure, or any copy failure is a release blocker. + +## Incorporated `numman-ali/n-skills` audit decisions + +Architectural audit reference: `numman-ali/n-skills` commit `b1c6173aa7f83c569248996e1db1b9ae7afdb76f`. + +Borrowed decisions, now reflected throughout this plan: + +- stable human-assigned source IDs; +- one schema-versioned typed catalog; +- explicit repository, effective ref, approved search root, and install root; +- category, homepage, author, and nullable SPDX metadata; +- separate discovery provenance and actual install provenance; +- catalog identity composed from catalog name, source ID, effective ref, and approved root; +- PR review, mandatory authoritative-manifest validation, and read-only health reporting; +- segment-boundary application filtering and no global fallback; +- exact descriptor/root/ref persistence through project workspace config. + +Explicitly not borrowed: + +- vendoring or mirroring upstream content; +- a direct-to-main updater or automated registry writer; +- shell-based catalog synchronization; +- boolean trust/safety/verification fields; +- dependency installation or lifecycle execution; +- split generated and hand-maintained registries. + +The audit commit informs architecture but does not, by itself, add `numman-ali/n-skills` as a catalog source. Adding it later requires an evidence-backed source row, classification, ref/root, metadata, manifest policy, warnings, disposable project install, health result, and reviewed PR. No finding is invented here. + +## Migration and compatibility + +- Workspace schema remains version 2 with an additive optional `catalogSource` descriptor. Existing entries parse unchanged; catalog-originated entries never drop this descriptor during modify/sync/update. +- Sync-state schema remains version 1 with additive optional catalog descriptor/resolved-root fields. `sources` is derived state; a full sync replaces obsolete repository-only catalog provenance with full catalog-identity keys. +- Existing global search calls receive additive fields and preserve no-option behavior. +- Existing `--owner`, pagination, rate-limit, token lookup, relevance, and global install behavior remain intact. +- Existing non-catalog source promotion remains the default matching mode; catalog installs use catalog-exact mode. +- Marketplace registries are neither seeded nor modified merely by searching or health checking. Registration occurs only after explicit install selection for a valid marketplace entry. +- No migration guesses catalog provenance for pre-existing repository entries. Only a future explicit catalog install/update can attach a descriptor. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| GitHub Code Search ignores/misparses a large repository qualifier expression. | Deterministic short query batches, literal query tests, application-side exact repository/root filtering, fatal required-batch failures, no fallback. | +| Search result comes from a different ref than installation. | MVP requires catalog ref to equal upstream default; read-only preflight binds discovery head; exact descriptor forces install ref; provenance remains separate. | +| Boundary prefix error leaks `optional-skills-old`. | Segment-based approved-root matcher with adversarial cases before ranking/pagination. | +| Catalog grows request count and hits Code Search rate limits. | Deduplicate only network repository preflights, batch qualifiers, retain token resolution, document truncation; never collapse source identities. | +| Search results advertise paths a manifest cannot install. | Mandatory authoritative-manifest CI validation plus install-time preflight; fail closed. | +| Two Hermes entries collapse in config or state. | Full catalog identity, persisted exact descriptors, catalog-exact upsert, two-order E2E matrix. | +| Selected skill loses assets. | Explicit install root plus qualified allowlist; asset-bearing fixture and Paperclip `references/` check. | +| Optional source installs hundreds of skills accidentally. | `bulkPolicy: 'explicit-only'`, no preselection, warning confirmation, stale-selection guard. | +| Recommended label is mistaken for endorsement. | Required warnings and metadata; never expose verified/safe/trusted booleans or wording. | +| Upstream ref/layout/count/license changes. | Read-only health report and required CI validation; record upstream SHA; catalog changes only through reviewed PRs. | +| Health tooling mutates upstream/local state. | GET-only dependency surface, mutation-negative tests, no repair/update mode. | +| LFS workaround weakens git safety globally. | Set `allowUnsafeFilter` only on the controlled `simple-git` instance with fixed filter keys; never accept user-supplied filters or alter global git config. | + +## Staged execution order + +1. **Fix clean clones first.** Add `allowUnsafeFilter`, the real local-clone regression, and confirm clean remote Matt/Hermes clones reach discovery. +2. **Add the one versioned catalog and validator.** Land stable IDs, metadata, explicit refs/roots, full identity, policy enums, static invariants, and audited source rows. +3. **Add read-only health and CI manifest gate.** Implement GET-only checks, package scripts, required `Catalog Manifest` job, and review evidence format. +4. **Extend the core search API.** Add catalog option, default-ref preflight, qualifier batching, segment-boundary enforcement, metadata/discovery provenance, stable ordering, and no-fallback tests. +5. **Persist exact install descriptors.** Extend workspace/sync-state schemas additively; implement catalog-exact upsert and full catalog-identity provenance keys. +6. **Implement catalog-aware CLI selection/install.** Add flag/validation, identity grouping, exact direct/marketplace descriptors, warnings, one-sync transaction, and stale-selection guards. +7. **Update the global TUI consumer.** Use `installSource` without adding catalog selection to the TUI. +8. **Run focused automated suites.** Catalog, health/manifest, search, selection, workspace identity, provenance, clone, and plugin-skills E2E. +9. **Run the disposable project matrix.** Fresh `HOME` per row, built CLI, actual remote sources, exact config/state/filesystem evidence. +10. **Update durable docs and changelog.** README, CLI reference, marketplace distinction, maintenance/review gate, and `Unreleased` entries. +11. **Final reviewed PR gate.** Attach health report/upstream SHAs, pass mandatory manifest CI, obtain human review, and verify the catalog is labeled Recommended without endorsement language. + +## Implementation completion criteria + +Implementation is complete only when: + +- `allagents skill search --catalog recommended` and `searchSkills(query, { catalog: 'recommended' })` enforce catalog/ref/root hard boundaries without global fallback; +- `--catalog` plus `--owner` and any arbitrary ref override fail as specified; +- all source rows expose exact IDs, refs/roots, metadata, classification, policy, warnings, and full identities; +- gstack and Composio cannot enter generic install flows; +- Paperclip preserves `company-creator/references`; +- Hermes core and optional remain distinct in result identity, selection, workspace descriptors, and actual install provenance; +- discovery and install provenance remain separate; +- clean clones succeed without cache seeding; +- authoritative marketplace manifests pass the required CI validator and install-time preflight; +- read-only health checks report drift without mutation; +- project workspace config preserves exact catalog install descriptor/root/ref through subsequent sync/update; +- global search remains the no-option default but is never a named-catalog fallback; +- focused tests and every applicable disposable matrix row pass with evidence recorded in the implementation PR; +- the reviewed PR passes `Catalog Manifest` CI and README, CLI reference, marketplace guide, and changelog match the shipped contract. From 5d61325b403108e584ff396883488763fbc71cc7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 11:04:52 +1000 Subject: [PATCH 02/11] feat(skills): add recommended catalog --- .github/workflows/ci.yml | 14 + package.json | 2 + scripts/validate-skill-catalog.ts | 111 +++ src/cli/commands/plugin-skills.ts | 755 +++++++++++++++--- src/cli/metadata/plugin-skills.ts | 55 +- src/cli/tui/actions/skills.ts | 87 +- src/core/git.ts | 28 +- src/core/plugin.ts | 62 +- src/core/skill-catalog-health.ts | 483 +++++++++++ src/core/skill-catalog.ts | 425 ++++++++++ src/core/skill-search.ts | 636 +++++++++++++-- src/core/sync.ts | 149 +++- src/core/user-workspace.ts | 20 +- src/core/workspace-modify.ts | 147 +++- src/models/skill-catalog.ts | 122 +++ src/models/sync-state.ts | 3 + src/models/workspace-config.ts | 3 + src/utils/marketplace-manifest-parser.ts | 96 ++- src/utils/plugin-path.ts | 45 ++ tests/e2e/plugin-skills.test.ts | 44 + tests/unit/cli/skill-search-install.test.ts | 225 ++++++ tests/unit/cli/skill-search-summary.test.ts | 163 +++- tests/unit/core/git.test.ts | 38 +- .../github-skill-source-promotion.test.ts | 90 +++ tests/unit/core/skill-catalog-health.test.ts | 172 ++++ .../core/skill-catalog-provenance.test.ts | 83 ++ tests/unit/core/skill-catalog.test.ts | 125 +++ tests/unit/core/skill-search.test.ts | 294 +++++++ tests/unit/models/skill-catalog.test.ts | 43 + tests/unit/models/sync-state-schema.test.ts | 38 + tests/unit/models/workspace-config.test.ts | 32 + 31 files changed, 4240 insertions(+), 350 deletions(-) create mode 100644 scripts/validate-skill-catalog.ts create mode 100644 src/core/skill-catalog-health.ts create mode 100644 src/core/skill-catalog.ts create mode 100644 src/models/skill-catalog.ts create mode 100644 tests/unit/cli/skill-search-install.test.ts create mode 100644 tests/unit/core/skill-catalog-health.test.ts create mode 100644 tests/unit/core/skill-catalog-provenance.test.ts create mode 100644 tests/unit/core/skill-catalog.test.ts create mode 100644 tests/unit/models/skill-catalog.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a432453..cf91ba7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,20 @@ jobs: - run: bun install --frozen-lockfile --ignore-scripts - run: bun run lint + catalog-manifest: + name: Catalog Manifest + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run catalog:validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test: name: Test runs-on: ubuntu-latest diff --git a/package.json b/package.json index 2a560db..0de1e38 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test:e2e": "bun test tests/e2e", "smoke:mcp-oauth": "bun run scripts/smoke-mcp-oauth.ts", "dev:mcp-server": "bun run scripts/dev-mcp-server.ts", + "catalog:validate": "bun run scripts/validate-skill-catalog.ts --ci", + "catalog:health": "bun run scripts/validate-skill-catalog.ts --report", "typecheck": "tsc --noEmit", "lint": "biome lint src", "lint:fix": "biome lint --write src", diff --git a/scripts/validate-skill-catalog.ts b/scripts/validate-skill-catalog.ts new file mode 100644 index 0000000..e84ada7 --- /dev/null +++ b/scripts/validate-skill-catalog.ts @@ -0,0 +1,111 @@ +import { execFile } from 'node:child_process'; +import { + type CatalogHealthDependencies, + type CatalogTreeEntry, + checkSkillCatalogHealth, + validateSkillCatalog, +} from '../src/core/skill-catalog-health.js'; +import { RECOMMENDED_SKILL_CATALOG } from '../src/core/skill-catalog.js'; + +const mode = process.argv[2]; +if (mode !== '--ci' && mode !== '--report') { + console.error('Usage: bun run scripts/validate-skill-catalog.ts --ci|--report'); + process.exit(2); +} + +const token = + process.env.GITHUB_TOKEN || + (await new Promise((resolve) => { + execFile('gh', ['auth', 'token'], { timeout: 3000 }, (error, stdout) => { + resolve(error ? undefined : stdout.trim() || undefined); + }); + })); +const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'allagents-catalog-health', +}; +if (token) headers.Authorization = `Bearer ${token}`; + +async function githubGet(path: string): Promise { + const response = await fetch(`https://api.github.com${path}`, { + method: 'GET', + headers, + }); + if (!response.ok) { + throw new Error(`GitHub GET ${path} failed: ${response.status}`); + } + return (await response.json()) as T; +} + +const dependencies: CatalogHealthDependencies = { + async getRepository(repo) { + const repository = await githubGet<{ + full_name: string; + default_branch: string; + }>(`/repos/${repo}`); + const branch = await githubGet<{ commit: { sha: string } }>( + `/repos/${repo}/branches/${encodeURIComponent(repository.default_branch)}`, + ); + return { + fullName: repository.full_name, + defaultBranch: repository.default_branch, + headSha: branch.commit.sha, + }; + }, + async getTree(repo, ref) { + const result = await githubGet<{ + truncated: boolean; + tree: Array<{ path: string; type: string; mode: string }>; + }>(`/repos/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`); + if (result.truncated) { + throw new Error(`GitHub tree response was truncated for ${repo}@${ref}`); + } + return result.tree + .filter( + (entry): entry is { path: string; type: 'blob' | 'tree'; mode: string } => + entry.type === 'blob' || entry.type === 'tree', + ) + .map( + (entry): CatalogTreeEntry => ({ + path: entry.path, + type: entry.type, + mode: entry.mode, + }), + ); + }, + async getTextFile(repo, ref, path) { + const result = await githubGet<{ + type: string; + encoding?: string; + content?: string; + }>( + `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`, + ); + if ( + result.type !== 'file' || + result.encoding !== 'base64' || + typeof result.content !== 'string' + ) { + return null; + } + return Buffer.from(result.content.replace(/\n/g, ''), 'base64').toString( + 'utf8', + ); + }, +}; + +const validationIssues = validateSkillCatalog(RECOMMENDED_SKILL_CATALOG); +if (validationIssues.length > 0) { + console.error(JSON.stringify({ validationIssues }, null, 2)); + process.exit(1); +} + +const report = await checkSkillCatalogHealth( + RECOMMENDED_SKILL_CATALOG, + dependencies, +); +console.log(JSON.stringify(report, null, 2)); +if (report.sources.some((source) => source.status !== 'healthy')) { + process.exit(1); +} diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 8d58077..a4962f8 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -1,6 +1,7 @@ -import { existsSync } from 'node:fs'; +import * as prompts from '@clack/prompts'; +import { existsSync, realpathSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import chalk from 'chalk'; import { command, @@ -12,6 +13,12 @@ import { string, } from 'cmd-ts'; import { getHomeDir } from '../../constants.js'; +import type { + CatalogInstallDescriptor, + SkillCatalogClassification, + SkillCatalogInstallPolicy, + SkillCatalogWarning, +} from '../../models/skill-catalog.js'; import { addMarketplace, findMarketplaceRegistration, @@ -30,6 +37,10 @@ import { qualifiedName, searchSkills, } from '../../core/skill-search.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, +} from '../../core/skill-catalog.js'; import { type DiscoveredSkillEntry, discoverSkillEntries, @@ -43,7 +54,6 @@ import type { SyncResult } from '../../core/sync.js'; import { addUserEnabledSkill, addUserPlugin, - hasUserPlugin, isUserConfigPath, removeUserDisabledSkill, setUserPluginSkillsMode, @@ -52,13 +62,13 @@ import { import { addEnabledSkill, addPlugin, - hasPlugin, removeDisabledSkill, resolveGitHubIdentity, setPluginSkillsMode, upsertGitHubPluginSourceAllowlist, } from '../../core/workspace-modify.js'; import { + parseCatalogLocalMarketplaceManifest, parseMarketplaceManifest, resolvePluginSourcePath, } from '../../utils/marketplace-manifest-parser.js'; @@ -67,6 +77,7 @@ import { isGitHubUrl, parseGitHubUrl, stripGitRef, + parseExactGitHubInstallSource, } from '../../utils/plugin-path.js'; import { parseSkillMetadata } from '../../validators/skill.js'; import { @@ -170,9 +181,7 @@ function extractInlineRef(spec: string): string | undefined { * normalized to its containing skill directory. * Without subpath: skill name = repo name (caller should use resolveSkillNameFromRepo to check frontmatter) */ -export function resolveSkillFromUrl( - skill: string, -): { +export function resolveSkillFromUrl(skill: string): { skill: string; from: string; parsed: ReturnType; @@ -1120,7 +1129,11 @@ async function selectAndInstallSkillsFromSource(opts: { isUser: boolean; workspacePath: string; }): Promise< - | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { + success: true; + installed: Array<{ pluginName: string; skills: string[] }>; + syncResult: SyncResult; + } | { success: false; error: string } | { success: 'cancelled' } > { @@ -1144,14 +1157,17 @@ async function selectAndInstallSkillsFromSource(opts: { return installAllSkillsFromSource(opts); } - const p = await import('@clack/prompts'); + const p = prompts; // Marketplace repos: show a grouped multiselect picker by plugin if (discovered.isMarketplace) { const allSkillNames = discovered.skills.map((s) => s.name); // Group skills by pluginName - const groups: Record> = {}; + const groups: Record< + string, + Array<{ label: string; value: string; hint?: string }> + > = {}; for (const skill of discovered.skills) { const group = skill.pluginName ?? 'Other'; if (!groups[group]) groups[group] = []; @@ -1182,7 +1198,9 @@ async function selectAndInstallSkillsFromSource(opts: { // Subset selected → install per-plugin, filtered to selected skills const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null; - const sourceLocation = parsed ? `${parsed.owner}/${parsed.repo}` : undefined; + const sourceLocation = parsed + ? `${parsed.owner}/${parsed.repo}` + : undefined; let marketplaceName: string | undefined; const existingAnyScope = await findMarketplaceRegistration( @@ -1193,7 +1211,10 @@ async function selectAndInstallSkillsFromSource(opts: { if (existingAnyScope) { marketplaceName = existingAnyScope.key; - await updateMarketplace(marketplaceName, isUser ? undefined : workspacePath); + await updateMarketplace( + marketplaceName, + isUser ? undefined : workspacePath, + ); } else { const scopeOptions = isUser ? undefined @@ -1213,7 +1234,10 @@ async function selectAndInstallSkillsFromSource(opts: { } if (!marketplaceName) { - return { success: false, error: `Failed to register marketplace from '${from}'` }; + return { + success: false, + error: `Failed to register marketplace from '${from}'`, + }; } const mktPlugins = await listMarketplacePlugins( @@ -1221,7 +1245,10 @@ async function selectAndInstallSkillsFromSource(opts: { isUser ? undefined : workspacePath, ); if (mktPlugins.plugins.length === 0) { - return { success: false, error: `No plugins found in marketplace '${marketplaceName}'.` }; + return { + success: false, + error: `No plugins found in marketplace '${marketplaceName}'.`, + }; } const installed: Array<{ pluginName: string; skills: string[] }> = []; @@ -1231,7 +1258,9 @@ async function selectAndInstallSkillsFromSource(opts: { ? mktPlugin.skills.map((s) => s.split('/').pop() ?? '').filter(Boolean) : await discoverSkillNames(mktPlugin.path); - const pluginSelectedSkills = allPluginSkillNames.filter((n) => selectedNames.includes(n)); + const pluginSelectedSkills = allPluginSkillNames.filter((n) => + selectedNames.includes(n), + ); if (pluginSelectedSkills.length === 0) continue; const pluginSpec = `${mktPlugin.name}@${marketplaceName}`; @@ -1252,7 +1281,11 @@ async function selectAndInstallSkillsFromSource(opts: { } const setModeResult = isUser - ? await setUserPluginSkillsMode(mktPlugin.name, 'allowlist', pluginSelectedSkills) + ? await setUserPluginSkillsMode( + mktPlugin.name, + 'allowlist', + pluginSelectedSkills, + ) : await setPluginSkillsMode( mktPlugin.name, 'allowlist', @@ -1267,16 +1300,24 @@ async function selectAndInstallSkillsFromSource(opts: { }; } - installed.push({ pluginName: mktPlugin.name, skills: pluginSelectedSkills }); + installed.push({ + pluginName: mktPlugin.name, + skills: pluginSelectedSkills, + }); } if (installed.length === 0) { - return { success: false, error: 'No matching skills found in marketplace plugins.' }; + return { + success: false, + error: 'No matching skills found in marketplace plugins.', + }; } if (!isJsonMode()) { const total = installed.reduce((sum, i) => sum + i.skills.length, 0); - console.log(`✓ Enabled ${total} skill(s) across ${installed.length} plugin(s)`); + console.log( + `✓ Enabled ${total} skill(s) across ${installed.length} plugin(s)`, + ); } const syncResult = isUser @@ -1326,7 +1367,10 @@ async function selectAndInstallSkillsFromSource(opts: { }; } - const existingEnabled = await getEnabledSkillsForGitHubSource(from, workspacePath); + const existingEnabled = await getEnabledSkillsForGitHubSource( + from, + workspacePath, + ); const desiredSkills = [...existingEnabled]; for (const name of selectedNames) { if (!desiredSkills.includes(name)) desiredSkills.push(name); @@ -1334,7 +1378,11 @@ async function selectAndInstallSkillsFromSource(opts: { const updateResult = isUser ? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills) - : await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath); + : await upsertGitHubPluginSourceAllowlist( + from, + desiredSkills, + workspacePath, + ); if (!updateResult.success) { return { @@ -1343,7 +1391,9 @@ async function selectAndInstallSkillsFromSource(opts: { }; } - const pluginName = extractPrimaryPluginName(updateResult.normalizedPlugin ?? from); + const pluginName = extractPrimaryPluginName( + updateResult.normalizedPlugin ?? from, + ); console.log( `✓ Enabled ${selectedNames.length} skill(s) from ${pluginName}: ${selectedNames.join(', ')}`, ); @@ -2298,28 +2348,87 @@ export function formatSkillSearchSummary( } export function formatSkillSearchHint( - item: Pick, + item: Pick & + Partial>, ): string { - return [item.stars > 0 ? `★ ${item.stars}` : '', item.description ?? ''] + const policy = + item.installation?.policy === 'search-only' + ? 'search only' + : item.installation?.policy === 'external-installer' + ? 'external installer' + : item.catalog?.classification === 'optional' + ? 'optional' + : ''; + const warnings = item.catalog?.warnings + .map((warning) => warning.message) + .join(' '); + return [ + item.stars > 0 ? `★ ${item.stars}` : '', + policy, + item.description ?? '', + warnings ?? '', + ] .filter(Boolean) .join(' '); } -export function collectSelectedSkillSearchRepos( - items: Pick[], - selectedPaths: string[], -): string[] { - const selectedSet = new Set(selectedPaths); - const repos: string[] = []; - const seenRepos = new Set(); +export interface SelectedSkillSearchSource { + catalogIdentity?: string; + installDescriptor?: CatalogInstallDescriptor; + installSource: string; + installPolicy: 'repository-install' | SkillCatalogInstallPolicy; + classification?: SkillCatalogClassification; + warnings: readonly SkillCatalogWarning[]; + selectors: string[]; +} - for (const item of items) { - if (!selectedSet.has(item.path) || seenRepos.has(item.repo)) continue; - seenRepos.add(item.repo); - repos.push(item.repo); +export function skillSearchSelectionKey(item: SkillSearchItem): string { + const identity = item.catalog?.identity ?? item.installSource.toLowerCase(); + return `${identity}#${item.repo.toLowerCase()}#${item.path}`; +} + +export function collectSelectedSkillSearchSources( + items: SkillSearchItem[], + selectedKeys: string[], +): SelectedSkillSearchSource[] { + const selected = new Set(selectedKeys); + const known = new Set(items.map(skillSearchSelectionKey)); + for (const key of selected) { + if (!known.has(key)) { + throw new SkillSearchError( + 'Selected skill is not present in the current search result.', + 'validation', + ); + } } - return repos; + const groups = new Map(); + for (const item of items) { + const selectionKey = skillSearchSelectionKey(item); + if (!selected.has(selectionKey)) continue; + const groupKey = item.catalog + ? `${item.catalog.identity}#${JSON.stringify(item.catalog.installDescriptor)}` + : item.installSource.toLowerCase(); + let group = groups.get(groupKey); + if (!group) { + group = { + ...(item.catalog && { + catalogIdentity: item.catalog.identity, + installDescriptor: item.catalog.installDescriptor, + classification: item.catalog.classification, + }), + installSource: item.installSource, + installPolicy: item.installation.policy, + warnings: item.catalog?.warnings ?? [], + selectors: [], + }; + groups.set(groupKey, group); + } + if (!group.selectors.includes(item.installSelector)) { + group.selectors.push(item.installSelector); + } + } + return [...groups.values()]; } /** Print results in gh-compatible tabular format: repo, skillName, description, stars. */ @@ -2342,7 +2451,15 @@ function printSearchResults( : item.description, ) : ''; - const starsAndDesc = [stars, desc].filter(Boolean).join(' '); + const policy = + item.installation.policy === 'search-only' + ? chalk.yellow('search only') + : item.installation.policy === 'external-installer' + ? chalk.yellow('external installer') + : item.catalog?.classification === 'optional' + ? chalk.yellow('optional') + : ''; + const starsAndDesc = [policy, stars, desc].filter(Boolean).join(' '); console.log( ` ${chalk.cyan(repoCol)} ${chalk.bold(nameCol)} ${starsAndDesc}`, ); @@ -2350,35 +2467,429 @@ function printSearchResults( console.log(''); } -/** - * Interactive install flow for a selected plugin from search results. - * Returns true if plugin was installed. - */ -async function installFromSearch(repos: string[]): Promise { - const p = await import('@clack/prompts'); - - const workspacePath = process.cwd(); - const installableRepos: string[] = []; - - for (const repo of repos) { - const isInstalledProject = hasProjectSkillConfig(workspacePath) - ? await hasPlugin(repo, workspacePath) - : false; - const isInstalledUser = await hasUserPlugin(repo); - - if (isInstalledProject || isInstalledUser) { - const scopeLabel = isInstalledUser ? 'user' : 'project'; - p.log.info( - `Plugin ${chalk.bold(repo)} is already installed (${scopeLabel} scope).`, - ); +type InstallSearchScope = 'project' | 'user'; + +type InstallSearchDeps = { + fetchPlugin?: typeof fetchPlugin; + parseMarketplaceManifest?: typeof parseMarketplaceManifest; + addMarketplace?: typeof addMarketplace; + findMarketplaceRegistration?: typeof findMarketplaceRegistration; + updateMarketplace?: typeof updateMarketplace; + addPlugin?: typeof addPlugin; + addUserPlugin?: typeof addUserPlugin; + upsertProjectAllowlist?: typeof upsertGitHubPluginSourceAllowlist; + upsertUserAllowlist?: typeof upsertUserGitHubPluginSourceAllowlist; + syncWorkspace?: typeof syncWorkspace; + syncUserWorkspace?: typeof syncUserWorkspace; +}; + +export interface InstallSearchTransactionResult { + success: boolean; + installed: string[]; + errors: Array<{ source: string; error: string }>; + syncResult?: SyncResult; +} + +function validateSelectedCatalogDescriptor( + group: SelectedSkillSearchSource, +): CatalogInstallDescriptor { + const descriptor = group.installDescriptor; + if (!descriptor || !group.catalogIdentity) { + throw new SkillSearchError( + 'Catalog selection is missing its exact install descriptor.', + 'validation', + ); + } + const source = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === descriptor.sourceId, + ); + const parsed = parseExactGitHubInstallSource(descriptor.installSource); + if ( + !source || + JSON.stringify(catalogInstallDescriptor(source)) !== + JSON.stringify(descriptor) || + !parsed || + parsed.repo.toLowerCase() !== descriptor.repo.toLowerCase() || + parsed.ref !== descriptor.effectiveRef || + parsed.root !== descriptor.installRoot || + group.installSource !== descriptor.installSource + ) { + throw new SkillSearchError( + `Catalog install descriptor drift for ${descriptor.sourceId}.`, + 'validation', + ); + } + return descriptor; +} + +async function configureCatalogDirectSource( + group: SelectedSkillSearchSource, + descriptor: CatalogInstallDescriptor, + scope: InstallSearchScope, + workspacePath: string, + deps: InstallSearchDeps, +): Promise< + { success: true; label: string } | { success: false; error: string } +> { + const fetchResult = await (deps.fetchPlugin ?? fetchPlugin)( + descriptor.installSource, + { branch: descriptor.effectiveRef }, + ); + if (!fetchResult.success) { + return { + success: false, + error: fetchResult.error ?? 'Failed to fetch catalog source.', + }; + } + if ( + fetchResult.resolvedRef && + fetchResult.resolvedRef !== descriptor.effectiveRef + ) { + return { + success: false, + error: 'Catalog source resolved to an unexpected ref.', + }; + } + const sourcePath = resolveFetchedSourcePath( + descriptor.installSource, + fetchResult.cachePath, + ); + const available = await discoverSkillEntries(sourcePath); + for (const selector of group.selectors) { + if (!available.some((entry) => entry.subpath === selector)) { + return { + success: false, + error: `Selected skill '${selector}' no longer resolves inside ${descriptor.installSource}.`, + }; + } + } + const options = { + identity: 'catalog-exact' as const, + catalogSource: descriptor, + }; + const result = + scope === 'project' + ? await ( + deps.upsertProjectAllowlist ?? upsertGitHubPluginSourceAllowlist + )(descriptor.installSource, group.selectors, workspacePath, options) + : await ( + deps.upsertUserAllowlist ?? upsertUserGitHubPluginSourceAllowlist + )(descriptor.installSource, group.selectors, options); + return result.success + ? { success: true, label: descriptor.installSource } + : { success: false, error: result.error ?? 'Failed to update workspace.' }; +} + +function isCanonicalPathWithin(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + return ( + relativePath !== '..' && + !relativePath.startsWith( + `..${process.platform === 'win32' ? '\\' : '/'}`, + ) && + resolve(root, relativePath) === candidate + ); +} + +async function configureCatalogMarketplaceSource( + group: SelectedSkillSearchSource, + descriptor: CatalogInstallDescriptor, + scope: InstallSearchScope, + workspacePath: string, + deps: InstallSearchDeps, +): Promise< + { success: true; labels: string[] } | { success: false; error: string } +> { + const fetchResult = await (deps.fetchPlugin ?? fetchPlugin)( + descriptor.installSource, + { branch: descriptor.effectiveRef }, + ); + if (!fetchResult.success) { + return { + success: false, + error: fetchResult.error ?? 'Failed to fetch catalog marketplace.', + }; + } + if ( + fetchResult.resolvedRef && + fetchResult.resolvedRef !== descriptor.effectiveRef + ) { + return { + success: false, + error: 'Catalog marketplace resolved to an unexpected ref.', + }; + } + const sourcePath = resolveFetchedSourcePath( + descriptor.installSource, + fetchResult.cachePath, + ); + const manifest = await ( + deps.parseMarketplaceManifest ?? parseCatalogLocalMarketplaceManifest + )(sourcePath); + if (!manifest.success || manifest.warnings.length > 0) { + return { + success: false, + error: manifest.success + ? `Authoritative marketplace manifest is not strictly valid: ${manifest.warnings.join('; ')}` + : manifest.error, + }; + } + + const canonicalRoot = realpathSync(sourcePath); + const matches = new Map< + string, + Array<{ pluginName: string; selector: string }> + >(); + for (const plugin of manifest.data.plugins) { + if (typeof plugin.source !== 'string') continue; + const pluginPath = resolvePluginSourcePath(plugin.source, sourcePath); + let canonicalPluginPath: string; + try { + canonicalPluginPath = realpathSync(pluginPath); + } catch { continue; } + if (!isCanonicalPathWithin(canonicalRoot, canonicalPluginPath)) continue; - installableRepos.push(repo); + const declaredSkills = Array.isArray(plugin.skills) + ? plugin.skills + : plugin.skills + ? [plugin.skills] + : []; + const entries: DiscoveredSkillEntry[] = []; + if (declaredSkills.length === 0) { + entries.push(...(await discoverSkillEntries(canonicalPluginPath))); + } else { + for (const declaredSkill of declaredSkills) { + let canonicalSkillPath: string; + try { + canonicalSkillPath = realpathSync( + resolvePluginSourcePath(declaredSkill, canonicalPluginPath), + ); + } catch { + continue; + } + if ( + !isCanonicalPathWithin(canonicalRoot, canonicalSkillPath) || + !isCanonicalPathWithin(canonicalPluginPath, canonicalSkillPath) + ) { + continue; + } + entries.push(...(await discoverSkillEntries(canonicalSkillPath))); + } + } + for (const entry of entries) { + const fullSelector = relative(canonicalRoot, entry.skillPath) + .split(/[\\/]/) + .join('/'); + const candidates = new Set([ + entry.subpath, + fullSelector, + fullSelector.startsWith('skills/') + ? fullSelector.slice('skills/'.length) + : fullSelector, + ]); + for (const requested of group.selectors) { + if (!candidates.has(requested)) continue; + const existing = matches.get(requested) ?? []; + existing.push({ pluginName: plugin.name, selector: entry.subpath }); + matches.set(requested, existing); + } + } + } + for (const selector of group.selectors) { + const candidates = matches.get(selector) ?? []; + if (candidates.length !== 1) { + return { + success: false, + error: + candidates.length === 0 + ? `Selected skill '${selector}' is outside the authoritative marketplace manifest.` + : `Selected skill '${selector}' is ambiguous in the authoritative marketplace manifest.`, + }; + } } - if (installableRepos.length === 0) { - return false; + const scopeWorkspace = scope === 'project' ? workspacePath : undefined; + const existing = await ( + deps.findMarketplaceRegistration ?? findMarketplaceRegistration + )(descriptor.sourceId, descriptor.repo, scopeWorkspace); + let marketplaceName: string | undefined; + if (existing) { + marketplaceName = existing.key; + await (deps.updateMarketplace ?? updateMarketplace)( + marketplaceName, + scopeWorkspace, + ); + } else { + const registration = await (deps.addMarketplace ?? addMarketplace)( + descriptor.repo, + descriptor.sourceId, + descriptor.effectiveRef, + undefined, + scope === 'project' ? { scope: 'project', workspacePath } : undefined, + ); + if (registration.success) marketplaceName = registration.marketplace?.name; + } + if (!marketplaceName) { + return { success: false, error: 'Failed to register catalog marketplace.' }; + } + + const selectorsByPlugin = new Map(); + for (const requested of group.selectors) { + const match = matches.get(requested)?.[0]; + if (!match) continue; + const selectors = selectorsByPlugin.get(match.pluginName) ?? []; + if (!selectors.includes(match.selector)) selectors.push(match.selector); + selectorsByPlugin.set(match.pluginName, selectors); + } + const labels: string[] = []; + for (const [pluginName, selectors] of selectorsByPlugin) { + const pluginSpec = `${pluginName}@${marketplaceName}`; + const options = { + identity: 'catalog-exact' as const, + catalogSource: descriptor, + }; + const result = + scope === 'project' + ? await ( + deps.upsertProjectAllowlist ?? upsertGitHubPluginSourceAllowlist + )(pluginSpec, selectors, workspacePath, options) + : await ( + deps.upsertUserAllowlist ?? upsertUserGitHubPluginSourceAllowlist + )(pluginSpec, selectors, options); + if (!result.success) { + return { + success: false, + error: result.error ?? `Failed to configure ${pluginSpec}.`, + }; + } + labels.push(pluginSpec); + } + return { success: true, labels }; +} + +export async function installSelectedSkillSearchSources( + sources: SelectedSkillSearchSource[], + scope: InstallSearchScope, + workspacePath: string, + deps: InstallSearchDeps = {}, +): Promise { + for (const source of sources) { + if ( + source.installPolicy === 'search-only' || + source.installPolicy === 'external-installer' + ) { + return { + success: false, + installed: [], + errors: [ + { + source: source.installSource, + error: + source.installPolicy === 'external-installer' + ? `Use the upstream lifecycle at ${source.installDescriptor ? RECOMMENDED_SKILL_CATALOG.sources.find((entry) => entry.sourceId === source.installDescriptor?.sourceId)?.homepage : source.installSource}.` + : 'This catalog source is search-only and cannot be installed.', + }, + ], + }; + } + if (source.installDescriptor) validateSelectedCatalogDescriptor(source); + } + + const installed: string[] = []; + const errors: Array<{ source: string; error: string }> = []; + for (const source of sources) { + if (!source.installDescriptor) { + const result = + scope === 'project' + ? await (deps.addPlugin ?? addPlugin)( + source.installSource, + workspacePath, + ) + : await (deps.addUserPlugin ?? addUserPlugin)(source.installSource); + if (result.success) installed.push(source.installSource); + else { + errors.push({ + source: source.installSource, + error: result.error ?? 'Failed to install plugin.', + }); + } + continue; + } + const descriptor = source.installDescriptor; + if (source.installPolicy === 'marketplace-selective') { + const result = await configureCatalogMarketplaceSource( + source, + descriptor, + scope, + workspacePath, + deps, + ); + if (result.success) installed.push(...result.labels); + else errors.push({ source: source.installSource, error: result.error }); + } else { + const result = await configureCatalogDirectSource( + source, + descriptor, + scope, + workspacePath, + deps, + ); + if (result.success) installed.push(result.label); + else errors.push({ source: source.installSource, error: result.error }); + } + } + + let syncResult: SyncResult | undefined; + if (installed.length > 0) { + syncResult = + scope === 'project' + ? await (deps.syncWorkspace ?? syncWorkspace)(workspacePath) + : await (deps.syncUserWorkspace ?? syncUserWorkspace)(); + } + return { + success: + errors.length === 0 && (syncResult?.success ?? installed.length > 0), + installed, + errors, + ...(syncResult && { syncResult }), + }; +} + +/** Interactive wrapper around the single catalog/global install transaction. */ +async function installFromSearch( + sources: SelectedSkillSearchSource[], +): Promise { + const p = prompts; + for (const source of sources) { + if ( + source.installPolicy === 'search-only' || + source.installPolicy === 'external-installer' + ) { + p.log.error( + source.installPolicy === 'external-installer' + ? `Use the upstream lifecycle for ${source.installSource}; AllAgents will not execute it.` + : `${source.installSource} is search-only.`, + ); + return false; + } + } + + const optionalSources = sources.filter( + (source) => source.classification === 'optional', + ); + if (optionalSources.length > 0) { + const warningLines = optionalSources.flatMap((source) => [ + source.installSource, + ...source.warnings.map((warning) => ` ${warning.message}`), + ]); + p.note(warningLines.join('\n'), 'Optional source warnings'); + const confirmed = await p.confirm({ + message: 'Install the selected optional skills?', + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) return false; } const scopeChoice = await p.select({ @@ -2388,72 +2899,42 @@ async function installFromSearch(repos: string[]): Promise { { label: 'User (global)', value: 'user' as const }, ], }); - if (p.isCancel(scopeChoice)) return false; - const s = p.spinner(); - s.start( - `Installing ${installableRepos.length === 1 ? 'plugin' : 'plugins'}...`, - ); - + const spinner = p.spinner(); + spinner.start(`Installing ${sources.length === 1 ? 'source' : 'sources'}...`); try { - const installedRepos: string[] = []; - const failedRepos: Array<{ repo: string; error: string }> = []; - - for (const repo of installableRepos) { - const result = - scopeChoice === 'project' - ? await addPlugin(repo, workspacePath) - : await addUserPlugin(repo); - - if (!result.success) { - failedRepos.push({ repo, error: result.error ?? 'Unknown error' }); - continue; - } - - installedRepos.push(repo); - } - - if (installedRepos.length === 0) { - s.stop('Installation failed'); - for (const { repo, error } of failedRepos) { - p.log.error(`${chalk.bold(repo)}: ${error}`); + const result = await installSelectedSkillSearchSources( + sources, + scopeChoice, + process.cwd(), + ); + if (result.installed.length === 0) { + spinner.stop('Installation failed'); + for (const failure of result.errors) { + p.log.error(`${chalk.bold(failure.source)}: ${failure.error}`); } return false; } - - s.message('Syncing...'); - const syncResult = - scopeChoice === 'project' - ? await syncWorkspace(workspacePath) - : await syncUserWorkspace(); - - s.stop( - installedRepos.length === 1 - ? 'Installed and synced' - : 'Installed plugins and synced', + spinner.stop( + result.success ? 'Installed and synced' : 'Installed with errors', ); - - for (const { repo, error } of failedRepos) { - p.log.error(`${chalk.bold(repo)}: ${error}`); - } - - const lines = formatVerboseSyncLines(syncResult); - const noteLines = - installedRepos.length > 1 ? [...installedRepos, '', ...lines] : lines; - if (noteLines.length > 0) { - p.note( - noteLines.join('\n'), - installedRepos.length === 1 - ? `Installed: ${installedRepos[0]}` - : `Installed: ${installedRepos.length} plugins`, - ); + for (const failure of result.errors) { + p.log.error(`${chalk.bold(failure.source)}: ${failure.error}`); } - - return true; - } catch (err) { - s.stop('Installation failed'); - p.log.error(err instanceof Error ? err.message : String(err)); + const lines = result.syncResult + ? formatVerboseSyncLines(result.syncResult) + : []; + p.note( + [...result.installed, ...(lines.length > 0 ? ['', ...lines] : [])].join( + '\n', + ), + `Installed: ${result.installed.length} source${result.installed.length === 1 ? '' : 's'}`, + ); + return result.success; + } catch (error) { + spinner.stop('Installation failed'); + p.log.error(error instanceof Error ? error.message : String(error)); return false; } } @@ -2468,6 +2949,12 @@ const searchCmd = command({ long: 'owner', description: 'Scope to a single GitHub owner (org or user).', }), + catalog: option({ + type: optional(string), + long: 'catalog', + description: + 'Restrict results to a built-in catalog. Initially: recommended.', + }), page: option({ type: optional(string), long: 'page', @@ -2479,11 +2966,14 @@ const searchCmd = command({ description: 'Results per page (1–100, default 15).', }), }, - handler: async ({ query, owner, page, limit }) => { + handler: async ({ query, owner, catalog, page, limit }) => { try { const searchQuery = query.join(' ').trim(); const opts: SkillSearchOptions = {}; if (owner) opts.owner = owner; + if (catalog) { + opts.catalog = catalog as 'recommended'; + } if (page !== undefined) { const n = Number.parseInt(page, 10); if (Number.isNaN(n)) { @@ -2531,27 +3021,31 @@ const searchCmd = command({ if (!isTTY) { // Non-interactive: print table with stars and exit + if (result.items.some((item) => item.catalog)) { + console.log('Catalog: Recommended'); + } printSearchResults(result.items, searchQuery, result.truncated); return; } // Interactive mode: filter-as-you-type multiselect with install support - const { autocompleteMultiselect, isCancel, log } = await import( - '@clack/prompts' - ); + const { autocompleteMultiselect, isCancel, log } = prompts; log.success( - formatSkillSearchSummary( + `${result.items.some((item) => item.catalog) ? 'Recommended · ' : ''}${formatSkillSearchSummary( result.items.length, searchQuery, result.truncated, - ), + )}`, ); const options = result.items.map((item) => ({ label: `${qualifiedName(item)} ${chalk.dim(item.repo)}`, - value: item.path, + value: skillSearchSelectionKey(item), hint: formatSkillSearchHint(item), + disabled: + item.installation.policy === 'search-only' || + item.installation.policy === 'external-installer', })); const selected = await autocompleteMultiselect({ @@ -2565,15 +3059,12 @@ const searchCmd = command({ return; } - const reposToInstall = collectSelectedSkillSearchRepos( + const sourcesToInstall = collectSelectedSkillSearchSources( result.items, selected as string[], ); - if (reposToInstall.length === 0) { - return; - } - - await installFromSearch(reposToInstall); + if (sourcesToInstall.length === 0) return; + await installFromSearch(sourcesToInstall); } catch (error) { if (error instanceof SkillSearchError) { const exitCode = error.kind === 'validation' ? 2 : 1; diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index c4d5e65..6796a83 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -124,30 +124,27 @@ export const skillsUpdateMeta: AgentCommandMeta = { cancelled: 'number', }, }, - jsonFields: [ - 'id', - 'source', - 'status', - 'error', - ] as const, + jsonFields: ['id', 'source', 'status', 'error'] as const, }; export const skillsSearchMeta: AgentCommandMeta = { command: 'skill search', description: - 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are ranked by relevance, with skill-name matches first. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.', + 'Search SKILL.md files through global GitHub Code Search, or constrain discovery to the built-in Recommended catalog with exact repository/ref/root boundaries. Catalog searches never fall back to global results. In TTY mode, installable results can be selectively installed.', whenToUse: - 'To discover available skills from public GitHub repositories without leaving the CLI. Bridges "I want a skill that does X" → install.', + 'To discover public GitHub skills globally, or to search a versioned catalog whose classification, policy, warnings, source metadata, and provenance are explicit. Recommended is a discovery label, not a security or license assertion.', examples: [ 'allagents skill search terraform', 'allagents skill pr-search', 'allagents skill "pr search"', 'allagents skill search terraform --owner hashicorp', + 'allagents skill search testing --catalog recommended', + 'allagents --json skill search testing --catalog recommended', 'allagents skill search docs --page 2 --limit 10', 'allagents --json skill search docs --limit 5', ], expectedOutput: - 'Skills ranked by relevance: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.', + 'Ranked global or Recommended catalog results with exact install source, selector, policy, warnings, metadata, and discovery provenance. Search-only and external-lifecycle entries remain visible but cannot be selected.', positionals: [ { name: 'query', @@ -162,6 +159,12 @@ export const skillsSearchMeta: AgentCommandMeta = { type: 'string', description: 'Scope to a single GitHub owner (org or user).', }, + { + flag: '--catalog', + type: 'string', + description: + 'Restrict to a built-in catalog (currently recommended). Mutually exclusive with --owner; default-branch refs only; never falls back to global search.', + }, { flag: '--page', type: 'string', @@ -184,6 +187,40 @@ export const skillsSearchMeta: AgentCommandMeta = { description: 'string', sha: 'string', stars: 'number', + installSource: 'string', + installSelector: 'string', + installation: { + policy: + 'repository-install | direct-selective | marketplace-selective | search-only | external-installer', + reasonCodes: ['string'], + }, + catalog: { + name: 'recommended?', + label: 'Recommended?', + version: '1?', + identity: 'string?', + sourceId: 'string?', + classification: 'recommended | optional | external-lifecycle?', + sourceKind: + 'repository | subtree | marketplace | external-lifecycle?', + category: 'string?', + homepage: 'string?', + author: { name: 'string', url: 'string' }, + spdxLicense: 'string | null', + warnings: [{ code: 'string', message: 'string' }], + discovery: { + catalogIdentity: 'string', + provider: 'github-code-search', + repo: 'string', + effectiveRef: 'string', + catalogVersion: 1, + approvedRoot: 'string', + repositoryHeadSha: 'string', + skillPath: 'string', + blobSha: 'string', + }, + installDescriptor: 'CatalogInstallDescriptor', + }, }, ], total: 'number', diff --git a/src/cli/tui/actions/skills.ts b/src/cli/tui/actions/skills.ts index faec2b7..e945e83 100644 --- a/src/cli/tui/actions/skills.ts +++ b/src/cli/tui/actions/skills.ts @@ -1,5 +1,9 @@ import * as p from '@clack/prompts'; -import { getAllSkillsFromPlugins, discoverSkillNames, type SkillInfo } from '../../../core/skills.js'; +import { + getAllSkillsFromPlugins, + discoverSkillNames, + type SkillInfo, +} from '../../../core/skills.js'; import { removeDisabledSkill, addEnabledSkill, @@ -16,7 +20,11 @@ import { listMarketplaces, listMarketplacePlugins, } from '../../../core/marketplace.js'; -import { searchSkills, qualifiedName, type SkillSearchItem } from '../../../core/skill-search.js'; +import { + searchSkills, + qualifiedName, + type SkillSearchItem, +} from '../../../core/skill-search.js'; import { getHomeDir } from '../../../constants.js'; import type { TuiContext } from '../context.js'; import type { TuiCache } from '../cache.js'; @@ -88,18 +96,22 @@ interface MarketplaceSkillPreview { * Load skills available from all configured marketplaces. * Scans each marketplace plugin's local directory for skills. */ -async function loadMarketplaceSkills(cache?: TuiCache): Promise { +async function loadMarketplaceSkills( + cache?: TuiCache, +): Promise { const previews: MarketplaceSkillPreview[] = []; try { const cachedMarketplaces = cache?.getMarketplaces(); - const marketplaces = cachedMarketplaces ?? await listMarketplaces(); + const marketplaces = cachedMarketplaces ?? (await listMarketplaces()); if (!cachedMarketplaces) cache?.setMarketplaces(marketplaces); for (const marketplace of marketplaces) { const cachedPlugins = cache?.getMarketplacePlugins(marketplace.name); - const result = cachedPlugins ?? await listMarketplacePlugins(marketplace.name); - if (!cachedPlugins) cache?.setMarketplacePlugins(marketplace.name, result); + const result = + cachedPlugins ?? (await listMarketplacePlugins(marketplace.name)); + if (!cachedPlugins) + cache?.setMarketplacePlugins(marketplace.name, result); for (const plugin of result.plugins) { const skillNames = await discoverSkillNames(plugin.path); @@ -110,7 +122,8 @@ async function loadMarketplaceSkills(cache?: TuiCache): Promise { +export async function runSkills( + context: TuiContext, + cache?: TuiCache, +): Promise { try { const skills = await loadAllSkills(context); @@ -204,7 +220,9 @@ async function runToggleSkills( const selectedSet = new Set(selected); // Compute diff - const toDisable = skills.filter((s) => !s.disabled && !selectedSet.has(s.key)); + const toDisable = skills.filter( + (s) => !s.disabled && !selectedSet.has(s.key), + ); const toEnable = skills.filter((s) => s.disabled && selectedSet.has(s.key)); if (toDisable.length === 0 && toEnable.length === 0) { @@ -220,7 +238,8 @@ async function runToggleSkills( // Disable newly unchecked skills for (const skill of toDisable) { - const effectivePath = skill.scope === 'user' ? getHomeDir() : context.workspacePath; + const effectivePath = + skill.scope === 'user' ? getHomeDir() : context.workspacePath; if (effectivePath) { await removeInstalledSkill({ targetSkill: skill, @@ -264,10 +283,14 @@ async function runToggleSkills( const changes: string[] = []; for (const skill of toEnable) { - changes.push(`✓ Enabled: ${skill.name} (${skill.pluginName}) [${skill.scope}]`); + changes.push( + `✓ Enabled: ${skill.name} (${skill.pluginName}) [${skill.scope}]`, + ); } for (const skill of toDisable) { - changes.push(`✗ Disabled: ${skill.name} (${skill.pluginName}) [${skill.scope}]`); + changes.push( + `✗ Disabled: ${skill.name} (${skill.pluginName}) [${skill.scope}]`, + ); } p.note(changes.join('\n'), 'Updated'); } @@ -304,13 +327,20 @@ async function runBrowseMarketplaceSkills( } // Group by plugin for display - const byPlugin = new Map(); + const byPlugin = new Map< + string, + { ref: string; description?: string | undefined; skills: string[] } + >(); for (const skill of marketplaceSkills) { const existing = byPlugin.get(skill.pluginRef); if (existing) { existing.skills.push(skill.skillName); } else { - const entry: { ref: string; description?: string | undefined; skills: string[] } = { + const entry: { + ref: string; + description?: string | undefined; + skills: string[]; + } = { ref: skill.pluginRef, skills: [skill.skillName], }; @@ -343,7 +373,9 @@ async function runBrowseMarketplaceSkills( // Check if plugin is already installed in either scope const workspacePath = context.workspacePath ?? process.cwd(); - const isInstalledProject = context.workspacePath ? await hasPlugin(selected, workspacePath) : false; + const isInstalledProject = context.workspacePath + ? await hasPlugin(selected, workspacePath) + : false; const isInstalledUser = await hasUserPlugin(selected); if (isInstalledProject || isInstalledUser) { @@ -366,7 +398,10 @@ async function runBrowseMarketplaceSkills( /** * Search GitHub for skills by keyword, display results, and install a selected plugin. */ -async function runSearchOnlineSkills(context: TuiContext, cache?: TuiCache): Promise { +async function runSearchOnlineSkills( + context: TuiContext, + cache?: TuiCache, +): Promise { const query = await text({ message: 'Search for skills on GitHub', placeholder: 'e.g. commit, deploy, aws', @@ -385,7 +420,10 @@ async function runSearchOnlineSkills(context: TuiContext, cache?: TuiCache): Pro items = result.items; } catch (error) { s.stop('Search failed'); - p.note(error instanceof Error ? error.message : String(error), 'Search Error'); + p.note( + error instanceof Error ? error.message : String(error), + 'Search Error', + ); return; } @@ -397,11 +435,12 @@ async function runSearchOnlineSkills(context: TuiContext, cache?: TuiCache): Pro } // One option per skill, showing name and repo - const options: Array<{ label: string; value: string; hint?: string }> = items.map((item) => ({ - label: qualifiedName(item), - value: item.repo, - hint: item.repo + (item.description ? ` · ${item.description}` : ''), - })); + const options: Array<{ label: string; value: string; hint?: string }> = + items.map((item) => ({ + label: qualifiedName(item), + value: item.installSource, + hint: item.repo + (item.description ? ` · ${item.description}` : ''), + })); options.push({ label: 'Back', value: '__back__' }); const selected = await autocomplete({ @@ -416,7 +455,9 @@ async function runSearchOnlineSkills(context: TuiContext, cache?: TuiCache): Pro // Check if plugin is already installed in either scope const workspacePath = context.workspacePath ?? process.cwd(); - const isInstalledProject = context.workspacePath ? await hasPlugin(selected, workspacePath) : false; + const isInstalledProject = context.workspacePath + ? await hasPlugin(selected, workspacePath) + : false; const isInstalledUser = await hasUserPlugin(selected); if (isInstalledProject || isInstalledUser) { diff --git a/src/core/git.ts b/src/core/git.ts index 31549b0..9f9be53 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -1,4 +1,4 @@ -import simpleGit from 'simple-git'; +import simpleGit, { type SimpleGitOptions } from 'simple-git'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, normalize, resolve, sep } from 'node:path'; @@ -11,7 +11,9 @@ const CLONE_TIMEOUT_MS = (() => { const raw = process.env.ALLAGENTS_CLONE_TIMEOUT_MS; if (!raw) return DEFAULT_CLONE_TIMEOUT_MS; const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLONE_TIMEOUT_MS; + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_CLONE_TIMEOUT_MS; })(); export function createGitEnv(): NodeJS.ProcessEnv { @@ -23,15 +25,17 @@ export function createGitEnv(): NodeJS.ProcessEnv { } function createGit(baseDir?: string) { - return simpleGit(baseDir, { + const options = { timeout: { block: CLONE_TIMEOUT_MS }, + allowUnsafeFilter: true, config: [ 'filter.lfs.required=false', 'filter.lfs.smudge=', 'filter.lfs.clean=', 'filter.lfs.process=', ], - }).env(createGitEnv()); + } as Partial & { allowUnsafeFilter: true }; + return simpleGit(baseDir, options).env(createGitEnv()); } /** @@ -45,10 +49,7 @@ export function gitHubUrl(owner: string, repo: string): string { * Shallow-clone a repository to an auto-created temp directory. * Caller must call `cleanupTempDir()` when done. */ -export async function cloneToTemp( - url: string, - ref?: string, -): Promise { +export async function cloneToTemp(url: string, ref?: string): Promise { const tempDir = await mkdtemp(join(tmpdir(), 'allagents-')); const git = createGit(); const cloneOptions = ref @@ -109,17 +110,10 @@ export async function repoExists(url: string): Promise { /** * Check if a specific ref (branch/tag) exists on the remote. */ -export async function refExists( - url: string, - ref: string, -): Promise { +export async function refExists(url: string, ref: string): Promise { const git = createGit(); try { - const result = await git.listRemote([ - '--refs', - url, - ref, - ]); + const result = await git.listRemote(['--refs', url, ref]); return result.trim().length > 0; } catch { return false; diff --git a/src/core/plugin.ts b/src/core/plugin.ts index 3ca6eb8..cfac89a 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -94,7 +94,11 @@ export function resetFetchCache(): void { * @param path - Local path where the repo already exists * @param branch - Optional branch override (seeds branch-qualified cache key) */ -export function seedFetchCache(url: string, path: string, branch?: string): void { +export function seedFetchCache( + url: string, + path: string, + branch?: string, +): void { const parsed = parseGitHubUrl(url); if (!parsed) return; @@ -409,14 +413,41 @@ export interface InstalledPluginUpdateResult { * Dependencies for updatePlugin (for testing) */ export interface UpdatePluginDeps { - parsePluginSpec: (spec: string) => { plugin: string; marketplaceName: string; owner?: string; repo?: string } | null; - getMarketplaceRegistration: (name: string, sourceLocation?: string) => Promise<{ + parsePluginSpec: ( + spec: string, + ) => { + plugin: string; + marketplaceName: string; + owner?: string; + repo?: string; + } | null; + getMarketplaceRegistration: ( + name: string, + sourceLocation?: string, + ) => Promise<{ key: string; - entry: { name: string; path: string; source: { type: 'github' | 'git' | 'local'; location: string } }; + entry: { + name: string; + path: string; + source: { type: 'github' | 'git' | 'local'; location: string }; + }; } | null>; - validateMarketplaceAccess: (marketplace: { name: string; path: string; source: { type: 'github' | 'git' | 'local'; location: string } }) => string | undefined; - parseMarketplaceManifest: (path: string) => Promise<{ success: boolean; data?: { plugins: Array<{ name: string; source: string | { url: string } }> } }>; - updateMarketplace: (name: string) => Promise>; + validateMarketplaceAccess: (marketplace: { + name: string; + path: string; + source: { type: 'github' | 'git' | 'local'; location: string }; + }) => string | undefined; + parseMarketplaceManifest: ( + path: string, + ) => Promise<{ + success: boolean; + data?: { + plugins: Array<{ name: string; source: string | { url: string } }>; + }; + }>; + updateMarketplace: ( + name: string, + ) => Promise>; /** Optional fetch function for testing - defaults to fetchPlugin */ fetchFn?: (url: string) => Promise; } @@ -444,7 +475,12 @@ export async function updatePlugin( return { plugin: pluginSpec, success: result.success, - action: result.action === 'updated' ? 'updated' : result.success ? 'skipped' : 'failed', + action: + result.action === 'updated' + ? 'updated' + : result.success + ? 'skipped' + : 'failed', ...(result.error && { error: result.error }), }; } @@ -458,7 +494,8 @@ export async function updatePlugin( } // Get marketplace info (with source location fallback for owner/repo format) - const sourceLocation = parsed.owner && parsed.repo ? `${parsed.owner}/${parsed.repo}` : undefined; + const sourceLocation = + parsed.owner && parsed.repo ? `${parsed.owner}/${parsed.repo}` : undefined; const registration = await deps.getMarketplaceRegistration( parsed.marketplaceName, sourceLocation, @@ -544,7 +581,12 @@ export async function updatePlugin( return { plugin: pluginSpec, success: fetchResult.success, - action: fetchResult.action === 'updated' ? 'updated' : fetchResult.success ? 'skipped' : 'failed', + action: + fetchResult.action === 'updated' + ? 'updated' + : fetchResult.success + ? 'skipped' + : 'failed', ...(fetchResult.error && { error: fetchResult.error }), }; } diff --git a/src/core/skill-catalog-health.ts b/src/core/skill-catalog-health.ts new file mode 100644 index 0000000..945f3b0 --- /dev/null +++ b/src/core/skill-catalog-health.ts @@ -0,0 +1,483 @@ +import { posix } from 'node:path'; +import { + MarketplaceManifestLenientSchema, + MarketplacePluginEntrySchema, +} from '../models/marketplace-manifest.js'; +import type { + SkillCatalog, + SkillCatalogSource, + SkillCatalogWarningCode, +} from '../models/skill-catalog.js'; +import { parseExactGitHubInstallSource } from '../utils/plugin-path.js'; +import { + catalogSourceIdentity, + isCatalogSkillPath, + normalizeCatalogPath, + pathWithinCatalogRoot, +} from './skill-catalog.js'; + +export interface CatalogValidationIssue { + sourceId?: string; + code: string; + message: string; +} + +const SPDX_EXPRESSION = + /^[A-Za-z0-9][A-Za-z0-9.+-]*(?:\s+(?:AND|OR)\s+[A-Za-z0-9][A-Za-z0-9.+-]*)*$/; +const REQUIRED_NON_INSTALL_WARNING: Record< + 'search-only' | 'external-installer', + SkillCatalogWarningCode +> = { + 'search-only': 'broken-marketplace', + 'external-installer': 'external-lifecycle', +}; + +export function validateSkillCatalog( + catalog: SkillCatalog, +): CatalogValidationIssue[] { + const issues: CatalogValidationIssue[] = []; + const sourceIds = new Set(); + const identities = new Set(); + + if (catalog.schemaVersion !== 1 || catalog.name !== 'recommended') { + issues.push({ + code: 'invalid-catalog-header', + message: 'Catalog must use schema version 1 and name recommended.', + }); + } + if (catalog.label !== 'Recommended') { + issues.push({ + code: 'invalid-catalog-label', + message: 'Catalog label must be Recommended.', + }); + } + + for (const source of catalog.sources) { + validateSource(source, issues); + const identity = catalogSourceIdentity({ + catalog: catalog.name, + sourceId: source.sourceId, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + }); + if (sourceIds.has(source.sourceId)) { + issues.push({ + sourceId: source.sourceId, + code: 'duplicate-source-id', + message: `Duplicate source ID: ${source.sourceId}`, + }); + } + if (identities.has(identity)) { + issues.push({ + sourceId: source.sourceId, + code: 'duplicate-identity', + message: `Duplicate catalog identity: ${identity}`, + }); + } + sourceIds.add(source.sourceId); + identities.add(identity); + } + return issues; +} + +function validateSource( + source: SkillCatalogSource, + issues: CatalogValidationIssue[], +): void { + const issue = (code: string, message: string): void => { + issues.push({ sourceId: source.sourceId, code, message }); + }; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.sourceId)) { + issue('invalid-source-id', 'Source ID must be stable kebab-case.'); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) { + issue('invalid-repository', `Invalid repository: ${source.repo}`); + } + if (!/^[A-Za-z0-9_.-]+$/.test(source.effectiveRef)) { + issue( + 'invalid-effective-ref', + 'Effective ref must be a default branch name.', + ); + } + for (const [field, value] of [ + ['approvedRoot', source.approvedRoot], + ['installRoot', source.installRoot], + ['installableSubpath', source.installableSubpath], + ] as const) { + if ( + value !== undefined && + value !== '.' && + normalizeCatalogPath(value) === null + ) { + issue( + 'invalid-path', + `${field} is not a normalized POSIX-relative path.`, + ); + } + } + if ( + source.installableSubpath && + !pathWithinCatalogRoot(source.installableSubpath, source.approvedRoot) + ) { + issue( + 'installable-subpath-outside-boundary', + 'Installable subpath must remain inside the approved root.', + ); + } + + const parsed = parseExactGitHubInstallSource(source.installSource); + if ( + !parsed || + parsed.repo.toLowerCase() !== source.repo.toLowerCase() || + parsed.ref !== source.effectiveRef || + parsed.root !== source.installRoot + ) { + issue( + 'install-source-mismatch', + 'Install source must encode the same repository, effective ref, and install root.', + ); + } + if (!source.displayName || !source.description || !source.homepage) { + issue( + 'missing-metadata', + 'Display name, description, and homepage are required.', + ); + } + if (!source.author.name || !source.author.url) { + issue('missing-author', 'Author name and URL are required.'); + } + for (const url of [source.homepage, source.author.url]) { + try { + new URL(url); + } catch { + issue('invalid-url', `Invalid metadata URL: ${url}`); + } + } + if ( + source.spdxLicense !== null && + !SPDX_EXPRESSION.test(source.spdxLicense) + ) { + issue('invalid-spdx', `Invalid SPDX expression: ${source.spdxLicense}`); + } + + const warningCodes = new Set(source.warnings.map((warning) => warning.code)); + if (!warningCodes.has('not-security-reviewed')) { + issue( + 'missing-common-warning', + 'Every source must include the not-security-reviewed warning.', + ); + } + if ( + source.classification === 'optional' && + !warningCodes.has('optional-source') + ) { + issue( + 'missing-optional-warning', + 'Optional sources require an explicit warning.', + ); + } + if ( + source.classification === 'optional' && + !['explicit-only', 'forbidden'].includes(source.bulkPolicy) + ) { + issue( + 'invalid-optional-bulk-policy', + 'Optional sources cannot allow implicit bulk installation.', + ); + } + if ( + ['search-only', 'external-installer'].includes(source.installPolicy) && + source.bulkPolicy !== 'forbidden' + ) { + issue( + 'invalid-non-installable-bulk-policy', + 'Search-only and external-installer sources must forbid bulk installation.', + ); + } + if ( + source.installPolicy === 'search-only' || + source.installPolicy === 'external-installer' + ) { + const requiredWarning = REQUIRED_NON_INSTALL_WARNING[source.installPolicy]; + if (!warningCodes.has(requiredWarning)) { + issue( + 'missing-policy-warning', + `${source.installPolicy} requires warning ${requiredWarning}.`, + ); + } + } + if ( + (source.sourceKind === 'marketplace') !== + (source.manifestBoundary === 'authoritative') + ) { + issue( + 'invalid-manifest-boundary', + 'Only marketplace sources use an authoritative manifest boundary.', + ); + } + if ( + source.sourceKind === 'marketplace' && + source.installPolicy !== 'marketplace-selective' + ) { + issue( + 'invalid-marketplace-policy', + 'Marketplace sources must use marketplace-selective installation.', + ); + } +} + +export interface CatalogRepositoryHealth { + fullName: string; + defaultBranch: string; + headSha: string; +} + +export interface CatalogTreeEntry { + path: string; + type: 'blob' | 'tree'; + mode?: string; +} + +export interface CatalogHealthDependencies { + getRepository(repo: string): Promise; + getTree(repo: string, ref: string): Promise; + getTextFile(repo: string, ref: string, path: string): Promise; +} + +export interface CatalogSourceHealth { + sourceId: string; + identity: string; + status: 'healthy' | 'drifted' | 'unreachable'; + reasonCodes: readonly string[]; + repositoryHeadSha?: string; +} + +export interface CatalogHealthReport { + catalog: 'recommended'; + catalogVersion: 1; + checkedAt: string; + sources: readonly CatalogSourceHealth[]; +} + +export async function checkSkillCatalogHealth( + catalog: SkillCatalog, + deps: CatalogHealthDependencies, +): Promise { + const repositoryRequests = new Map< + string, + Promise<{ + repository: CatalogRepositoryHealth; + tree: readonly CatalogTreeEntry[]; + }> + >(); + const inspectRepository = ( + source: SkillCatalogSource, + ): Promise<{ + repository: CatalogRepositoryHealth; + tree: readonly CatalogTreeEntry[]; + }> => { + const key = `${source.repo.toLowerCase()}@${source.effectiveRef}`; + const existing = repositoryRequests.get(key); + if (existing) return existing; + const request = Promise.all([ + deps.getRepository(source.repo), + deps.getTree(source.repo, source.effectiveRef), + ]).then(([repository, tree]) => ({ repository, tree })); + repositoryRequests.set(key, request); + return request; + }; + + const sources = await Promise.all( + catalog.sources.map(async (source): Promise => { + const identity = catalogSourceIdentity({ + catalog: catalog.name, + sourceId: source.sourceId, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + }); + try { + const { repository, tree } = await inspectRepository(source); + const reasons = await inspectSourceHealth( + source, + repository, + tree, + deps, + ); + return { + sourceId: source.sourceId, + identity, + status: reasons.length === 0 ? 'healthy' : 'drifted', + reasonCodes: reasons, + repositoryHeadSha: repository.headSha, + }; + } catch { + return { + sourceId: source.sourceId, + identity, + status: 'unreachable', + reasonCodes: ['repository-unreachable'], + }; + } + }), + ); + + return { + catalog: catalog.name, + catalogVersion: catalog.schemaVersion, + checkedAt: new Date().toISOString(), + sources, + }; +} + +async function inspectSourceHealth( + source: SkillCatalogSource, + repository: CatalogRepositoryHealth, + tree: readonly CatalogTreeEntry[], + deps: CatalogHealthDependencies, +): Promise { + const reasons: string[] = []; + if (repository.fullName.toLowerCase() !== source.repo.toLowerCase()) { + reasons.push('repository-renamed'); + } + if (repository.defaultBranch !== source.effectiveRef) { + reasons.push('default-ref-drift'); + } + const paths = new Map(tree.map((entry) => [entry.path, entry])); + for (const root of [ + source.approvedRoot, + source.installRoot, + source.installableSubpath, + ]) { + if (root && root !== '.' && !paths.has(root)) reasons.push('root-missing'); + } + const skillPaths = tree + .filter((entry) => entry.type === 'blob' && isCatalogSkillPath(entry.path)) + .map((entry) => entry.path) + .filter((path) => pathWithinCatalogRoot(path, source.approvedRoot)); + if (skillPaths.length === 0) reasons.push('skills-missing'); + + if (source.manifestBoundary === 'authoritative') { + const manifestReasons = await inspectAuthoritativeManifest( + source, + paths, + deps, + ); + reasons.push(...manifestReasons); + } + return [...new Set(reasons)]; +} + +async function inspectAuthoritativeManifest( + source: SkillCatalogSource, + paths: ReadonlyMap, + deps: CatalogHealthDependencies, +): Promise { + const rootPrefix = source.installRoot === '.' ? '' : `${source.installRoot}/`; + const candidates = [ + `${rootPrefix}.github/plugin/marketplace.json`, + `${rootPrefix}.claude-plugin/marketplace.json`, + ]; + const manifestPath = candidates.find((candidate) => paths.has(candidate)); + if (!manifestPath) return ['manifest-missing']; + const content = await deps.getTextFile( + source.repo, + source.effectiveRef, + manifestPath, + ); + if (content === null) return ['manifest-unreadable']; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(content); + } catch { + return ['manifest-invalid-json']; + } + const parsed = MarketplaceManifestLenientSchema.safeParse(parsedJson); + if (!parsed.success) return ['manifest-invalid']; + + const reasons: string[] = []; + for (const rawPlugin of parsed.data.plugins) { + if ( + typeof rawPlugin !== 'object' || + rawPlugin === null || + !('source' in rawPlugin) + ) { + reasons.push('manifest-invalid'); + continue; + } + // Remote plugin distributions have their own lifecycle. Only local entries + // are installable through this catalog source and constrained to its root. + if (typeof rawPlugin.source !== 'string') continue; + const parsedPlugin = MarketplacePluginEntrySchema.safeParse(rawPlugin); + if (!parsedPlugin.success) { + reasons.push('manifest-invalid'); + continue; + } + const plugin = parsedPlugin.data; + if (typeof plugin.source !== 'string') continue; + const pluginRoot = resolveTreePath(source.installRoot, plugin.source); + if ( + !pluginRoot || + (pluginRoot === '.' + ? source.installRoot !== '.' + : !pathWithinCatalogRoot(pluginRoot, source.installRoot)) + ) { + reasons.push('manifest-source-escape'); + continue; + } + if ( + pluginRoot !== '.' && + (!paths.has(pluginRoot) || treePathContainsSymlink(pluginRoot, paths)) + ) { + reasons.push('manifest-source-missing'); + continue; + } + const declaredSkills = Array.isArray(plugin.skills) + ? plugin.skills + : plugin.skills + ? [plugin.skills] + : []; + for (const skillPath of declaredSkills) { + const resolved = resolveTreePath(pluginRoot, skillPath); + if ( + !resolved || + !pathWithinCatalogRoot(resolved, source.approvedRoot) || + (!paths.has(resolved) && !paths.has(`${resolved}/SKILL.md`)) || + treePathContainsSymlink(resolved, paths) + ) { + reasons.push('manifest-skill-invalid'); + } + } + } + return reasons; +} + +function treePathContainsSymlink( + path: string, + entries: ReadonlyMap, +): boolean { + let current = ''; + for (const segment of path.split('/')) { + current = current ? `${current}/${segment}` : segment; + if (entries.get(current)?.mode === '120000') return true; + } + return false; +} + +function resolveTreePath(root: '.' | string, relative: string): string | null { + if (!relative || relative.startsWith('/') || relative.includes('\\')) + return null; + const base = root === '.' ? '' : root; + const normalized = posix + .normalize(posix.join(base, relative)) + .replace(/\/$/, ''); + if ((normalized === '.' || normalized === '') && root === '.') return '.'; + if ( + normalized === '..' || + normalized.startsWith('../') || + normalizeCatalogPath(normalized) === null + ) { + return null; + } + return normalized; +} diff --git a/src/core/skill-catalog.ts b/src/core/skill-catalog.ts new file mode 100644 index 0000000..32d919b --- /dev/null +++ b/src/core/skill-catalog.ts @@ -0,0 +1,425 @@ +import type { + CatalogInstallDescriptor, + SkillCatalog, + SkillCatalogName, + SkillCatalogSource, + SkillCatalogWarning, + SkillCatalogWarningCode, +} from '../models/skill-catalog.js'; + +const WARNING_TEXT: Record = { + 'not-security-reviewed': + 'Catalog inclusion is not a security review or safety guarantee.', + 'license-metadata': + 'Repository or per-skill license metadata must be reviewed before use.', + 'license-ambiguous': + 'No single catalog-asserted SPDX license applies to this source.', + 'external-dependencies': + 'Some skills require external services, credentials, binaries, or platform packages.', + 'external-lifecycle': + 'Use the upstream setup and update lifecycle; AllAgents does not run it.', + 'broken-marketplace': + 'The upstream marketplace manifest contains missing or misresolved sources.', + 'large-source': + 'This is a large source; select only the skills you intend to install.', + 'optional-source': + 'This optional source requires explicit selection and confirmation.', +}; + +function warnings( + ...codes: Exclude[] +): readonly SkillCatalogWarning[] { + return Object.freeze( + ['not-security-reviewed' as const, ...codes].map((code) => + Object.freeze({ code, message: WARNING_TEXT[code] }), + ), + ); +} + +function source(entry: SkillCatalogSource): SkillCatalogSource { + return Object.freeze({ + ...entry, + author: Object.freeze(entry.author), + warnings: Object.freeze(entry.warnings), + }); +} + +const RECOMMENDED_SOURCE_ENTRIES: readonly SkillCatalogSource[] = Object.freeze( + [ + source({ + sourceId: 'gstack', + repo: 'garrytan/gstack', + effectiveRef: 'main', + approvedRoot: '.', + installRoot: '.', + installSource: 'garrytan/gstack@main', + displayName: 'gstack', + description: + 'Garry Tan’s full software-engineering workflow distribution.', + category: 'software-engineering', + homepage: 'https://github.com/garrytan/gstack', + author: { name: 'Garry Tan', url: 'https://github.com/garrytan' }, + spdxLicense: 'MIT', + classification: 'external-lifecycle', + sourceKind: 'external-lifecycle', + installPolicy: 'external-installer', + bulkPolicy: 'forbidden', + manifestBoundary: 'none', + warnings: warnings('external-lifecycle', 'external-dependencies'), + }), + source({ + sourceId: 'paperclip-companies', + repo: 'paperclipai/companies', + effectiveRef: 'main', + approvedRoot: '.', + installRoot: '.', + installSource: 'paperclipai/companies@main', + installableSubpath: 'skills', + displayName: 'Paperclip Companies', + description: + 'Company-building skills and company templates from Paperclip AI.', + category: 'business-operations', + homepage: 'https://github.com/paperclipai/companies', + author: { name: 'Paperclip AI', url: 'https://github.com/paperclipai' }, + spdxLicense: null, + classification: 'optional', + sourceKind: 'repository', + installPolicy: 'direct-selective', + bulkPolicy: 'explicit-only', + manifestBoundary: 'none', + warnings: warnings( + 'optional-source', + 'license-ambiguous', + 'large-source', + ), + }), + source({ + sourceId: 'mattpocock-skills', + repo: 'mattpocock/skills', + effectiveRef: 'main', + approvedRoot: '.', + installRoot: '.', + installSource: 'mattpocock/skills@main', + displayName: 'Matt Pocock Skills', + description: 'Software-engineering skills curated by Matt Pocock.', + category: 'software-engineering', + homepage: 'https://aihero.dev/skills', + author: { name: 'Matt Pocock', url: 'https://github.com/mattpocock' }, + spdxLicense: 'MIT', + classification: 'recommended', + sourceKind: 'marketplace', + installPolicy: 'marketplace-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'authoritative', + warnings: warnings(), + }), + source({ + sourceId: 'composio-awesome-claude-skills', + repo: 'ComposioHQ/awesome-claude-skills', + effectiveRef: 'master', + approvedRoot: '.', + installRoot: '.', + installSource: 'ComposioHQ/awesome-claude-skills@master', + displayName: 'Composio Awesome Claude Skills', + description: 'A large collection of integration-oriented Claude skills.', + category: 'integrations', + homepage: 'https://github.com/ComposioHQ/awesome-claude-skills', + author: { name: 'Composio', url: 'https://github.com/ComposioHQ' }, + spdxLicense: null, + classification: 'optional', + sourceKind: 'repository', + installPolicy: 'search-only', + bulkPolicy: 'forbidden', + manifestBoundary: 'none', + warnings: warnings( + 'optional-source', + 'broken-marketplace', + 'external-dependencies', + 'license-ambiguous', + 'large-source', + ), + }), + source({ + sourceId: 'hermes-core', + repo: 'NousResearch/hermes-agent', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: 'skills', + installSource: 'NousResearch/hermes-agent@main/skills', + displayName: 'Hermes Core Skills', + description: 'Core general-purpose skills from Hermes Agent.', + category: 'general-purpose', + homepage: 'https://github.com/NousResearch/hermes-agent', + author: { name: 'Nous Research', url: 'https://github.com/NousResearch' }, + spdxLicense: 'MIT', + classification: 'recommended', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'none', + warnings: warnings(), + }), + source({ + sourceId: 'hermes-optional', + repo: 'NousResearch/hermes-agent', + effectiveRef: 'main', + approvedRoot: 'optional-skills', + installRoot: 'optional-skills', + installSource: 'NousResearch/hermes-agent@main/optional-skills', + displayName: 'Hermes Optional Skills', + description: + 'Optional Hermes skills with additional runtime requirements.', + category: 'integrations', + homepage: 'https://github.com/NousResearch/hermes-agent', + author: { name: 'Nous Research', url: 'https://github.com/NousResearch' }, + spdxLicense: 'MIT', + classification: 'optional', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + bulkPolicy: 'explicit-only', + manifestBoundary: 'none', + warnings: warnings('optional-source', 'external-dependencies'), + }), + source({ + sourceId: 'anthropic-skills', + repo: 'anthropics/skills', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: '.', + installSource: 'anthropics/skills@main', + displayName: 'Anthropic Skills', + description: 'General-purpose skills maintained by Anthropic.', + category: 'general-purpose', + homepage: 'https://github.com/anthropics/skills', + author: { name: 'Anthropic', url: 'https://github.com/anthropics' }, + spdxLicense: null, + classification: 'recommended', + sourceKind: 'marketplace', + installPolicy: 'marketplace-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'authoritative', + warnings: warnings('license-metadata'), + }), + source({ + sourceId: 'addyosmani-agent-skills', + repo: 'addyosmani/agent-skills', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: 'skills', + installSource: 'addyosmani/agent-skills@main/skills', + displayName: 'Addy Osmani Agent Skills', + description: 'Software-engineering agent skills by Addy Osmani.', + category: 'software-engineering', + homepage: 'https://skills.addy.ie', + author: { name: 'Addy Osmani', url: 'https://github.com/addyosmani' }, + spdxLicense: 'MIT', + classification: 'recommended', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'none', + warnings: warnings(), + }), + source({ + sourceId: 'obra-superpowers', + repo: 'obra/superpowers', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: 'skills', + installSource: 'obra/superpowers@main/skills', + displayName: 'Superpowers', + description: 'Software-development skills from the Superpowers project.', + category: 'software-engineering', + homepage: 'https://github.com/obra/superpowers', + author: { name: 'obra', url: 'https://github.com/obra' }, + spdxLicense: 'MIT', + classification: 'recommended', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'none', + warnings: warnings(), + }), + source({ + sourceId: 'context-engineering-skills', + repo: 'muratcankoylan/Agent-Skills-for-Context-Engineering', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: '.', + installSource: 'muratcankoylan/Agent-Skills-for-Context-Engineering@main', + displayName: 'Context Engineering Skills', + description: 'Skills for context engineering and agent systems.', + category: 'agent-engineering', + homepage: + 'https://github.com/muratcankoylan/Agent-Skills-for-Context-Engineering', + author: { + name: 'Murat Can Koylan', + url: 'https://github.com/muratcankoylan', + }, + spdxLicense: 'MIT', + classification: 'recommended', + sourceKind: 'repository', + installPolicy: 'direct-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'none', + warnings: warnings(), + }), + source({ + sourceId: 'elastic-docs-skills', + repo: 'elastic/elastic-docs-skills', + effectiveRef: 'main', + approvedRoot: 'skills', + installRoot: '.', + installSource: 'elastic/elastic-docs-skills@main', + displayName: 'Elastic Docs Skills', + description: 'Documentation skills maintained by Elastic.', + category: 'documentation', + homepage: 'https://elastic.github.io/elastic-docs-skills/', + author: { name: 'Elastic', url: 'https://github.com/elastic' }, + spdxLicense: 'Apache-2.0', + classification: 'recommended', + sourceKind: 'repository', + installPolicy: 'direct-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'none', + warnings: warnings(), + }), + source({ + sourceId: 'anthropic-knowledge-work', + repo: 'anthropics/knowledge-work-plugins', + effectiveRef: 'main', + approvedRoot: '.', + installRoot: '.', + installSource: 'anthropics/knowledge-work-plugins@main', + displayName: 'Anthropic Knowledge Work Plugins', + description: 'Knowledge-work plugins and skills maintained by Anthropic.', + category: 'knowledge-work', + homepage: 'https://github.com/anthropics/knowledge-work-plugins', + author: { name: 'Anthropic', url: 'https://github.com/anthropics' }, + spdxLicense: 'Apache-2.0', + classification: 'recommended', + sourceKind: 'marketplace', + installPolicy: 'marketplace-selective', + bulkPolicy: 'allowed', + manifestBoundary: 'authoritative', + warnings: warnings(), + }), + ], +); + +export const RECOMMENDED_SKILL_CATALOG: SkillCatalog = Object.freeze({ + schemaVersion: 1, + name: 'recommended', + label: 'Recommended', + sources: RECOMMENDED_SOURCE_ENTRIES, +}); + +export function getSkillCatalog(name: SkillCatalogName): SkillCatalog { + if (name !== 'recommended') { + throw new Error(`Unknown skill catalog "${name}".`); + } + return RECOMMENDED_SKILL_CATALOG; +} + +export function catalogSourceIdentity(input: { + catalog: 'recommended'; + sourceId: string; + effectiveRef: string; + approvedRoot: '.' | string; +}): string { + return `${input.catalog}:${input.sourceId}@${input.effectiveRef}#${input.approvedRoot}`; +} + +export function catalogInstallDescriptor( + source: SkillCatalogSource, +): CatalogInstallDescriptor { + return { + catalog: 'recommended', + catalogVersion: 1, + sourceId: source.sourceId, + repo: source.repo, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + installSource: source.installSource, + installRoot: source.installRoot, + sourceKind: source.sourceKind, + installPolicy: source.installPolicy, + }; +} + +export function normalizeCatalogPath(path: string): string | null { + if ( + !path || + path.startsWith('/') || + path.endsWith('/') || + path.includes('\\') + ) { + return null; + } + const segments = path.split('/'); + if ( + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + return null; + } + return segments.join('/'); +} + +export function isCatalogSkillPath(path: string): boolean { + const normalized = normalizeCatalogPath(path); + return normalized !== null && normalized.split('/').at(-1) === 'SKILL.md'; +} + +export function pathWithinCatalogRoot( + path: string, + root: '.' | string, +): boolean { + const normalized = normalizeCatalogPath(path); + if (!normalized) return false; + if (root === '.') return true; + const normalizedRoot = normalizeCatalogPath(root); + if (!normalizedRoot) return false; + const pathSegments = normalized.split('/'); + const rootSegments = normalizedRoot.split('/'); + return rootSegments.every( + (segment, index) => pathSegments[index] === segment, + ); +} + +export function relativeToCatalogRoot( + path: string, + root: '.' | string, +): string | null { + if (!pathWithinCatalogRoot(path, root)) return null; + if (root === '.') return path; + return path.split('/').slice(root.split('/').length).join('/'); +} + +export function matchCatalogSource( + repo: string, + path: string, + catalog: SkillCatalog = RECOMMENDED_SKILL_CATALOG, +): SkillCatalogSource | undefined { + if (!isCatalogSkillPath(path)) return undefined; + return catalog.sources + .map((entry, index) => ({ entry, index })) + .filter( + ({ entry }) => + entry.repo.toLowerCase() === repo.toLowerCase() && + pathWithinCatalogRoot(path, entry.approvedRoot), + ) + .sort((left, right) => { + const depth = + (right.entry.approvedRoot === '.' + ? 0 + : right.entry.approvedRoot.split('/').length) - + (left.entry.approvedRoot === '.' + ? 0 + : left.entry.approvedRoot.split('/').length); + return depth || left.index - right.index; + })[0]?.entry; +} + +export function warningText(code: SkillCatalogWarningCode): string { + return WARNING_TEXT[code]; +} diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts index 9f013af..0384dbf 100644 --- a/src/core/skill-search.ts +++ b/src/core/skill-search.ts @@ -1,3 +1,22 @@ +import type { + CatalogDiscoveryProvenance, + CatalogInstallDescriptor, + SkillCatalogAuthor, + SkillCatalogCategory, + SkillCatalogClassification, + SkillCatalogInstallPolicy, + SkillCatalogName, + SkillCatalogSourceKind, + SkillCatalogWarning, +} from '../models/skill-catalog.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, + catalogSourceIdentity, + matchCatalogSource, + pathWithinCatalogRoot, + relativeToCatalogRoot, +} from './skill-catalog.js'; import { parseSkillMetadata } from '../validators/skill.js'; /** @@ -18,6 +37,7 @@ import { parseSkillMetadata } from '../validators/skill.js'; const OWNER_REGEX = /^[A-Za-z0-9-]{1,39}$/; const SEARCH_PAGE_SIZE = 100; const MAX_RESULTS = 1000; +const CATALOG_QUERY_MAX_LENGTH = 240; /** * GitHub username pattern: starts and ends with alphanumeric, may contain @@ -46,6 +66,30 @@ export interface SkillSearchItem { sha: string; /** Repository star count (0 when unavailable). */ stars: number; + /** Exact source passed to installation. */ + installSource: string; + /** Qualified selector relative to the exact installation root. */ + installSelector: string; + installation: { + policy: 'repository-install' | SkillCatalogInstallPolicy; + reasonCodes: readonly string[]; + }; + catalog?: { + name: SkillCatalogName; + label: 'Recommended'; + version: 1; + identity: string; + sourceId: string; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + warnings: readonly SkillCatalogWarning[]; + discovery: CatalogDiscoveryProvenance; + installDescriptor: CatalogInstallDescriptor; + }; } export interface SkillSearchResult { @@ -57,6 +101,7 @@ export interface SkillSearchResult { export interface SkillSearchOptions { owner?: string; + catalog?: SkillCatalogName; page?: number; limit?: number; } @@ -64,7 +109,10 @@ export interface SkillSearchOptions { const ENRICHMENT_CONCURRENCY = 10; export class SkillSearchError extends Error { - constructor(message: string, public readonly kind: 'validation' | 'rate-limit' | 'api') { + constructor( + message: string, + public readonly kind: 'validation' | 'rate-limit' | 'api', + ) { super(message); this.name = 'SkillSearchError'; } @@ -78,14 +126,36 @@ export function validateSkillSearchArgs( query: string, options: SkillSearchOptions, ): void { + const requestedCatalog = options.catalog as string | undefined; + if (requestedCatalog !== undefined && requestedCatalog !== 'recommended') { + throw new SkillSearchError( + `Unknown skill catalog "${requestedCatalog}". Available catalogs: recommended.`, + 'validation', + ); + } + if (options.catalog !== undefined && options.owner !== undefined) { + throw new SkillSearchError( + '--catalog and --owner cannot be used together.', + 'validation', + ); + } if (query.trim().length < 2) { - throw new SkillSearchError('Search query must be at least 2 characters.', 'validation'); + throw new SkillSearchError( + 'Search query must be at least 2 characters.', + 'validation', + ); } if (options.page !== undefined && options.page < 1) { throw new SkillSearchError('--page must be >= 1.', 'validation'); } - if (options.limit !== undefined && (options.limit < 1 || options.limit > 100)) { - throw new SkillSearchError('--limit must be between 1 and 100.', 'validation'); + if ( + options.limit !== undefined && + (options.limit < 1 || options.limit > 100) + ) { + throw new SkillSearchError( + '--limit must be between 1 and 100.', + 'validation', + ); } if (options.owner !== undefined && !OWNER_REGEX.test(options.owner)) { throw new SkillSearchError( @@ -147,32 +217,134 @@ export function buildSearchQueries( // doesn't mention the query term (e.g. `plugins/cargowise/skills/*/SKILL.md` // when searching "cargowise"). const queries: SkillSearchQuery[] = [ - { priority: 1, label: 'path', q: join('filename:SKILL.md', `path:${pathTerm}`, userClause) }, + { + priority: 1, + label: 'path', + q: join('filename:SKILL.md', `path:${pathTerm}`, userClause), + }, ]; if (pathTerm !== trimmed) { - queries.push({ priority: 2, label: 'hyphen', q: join('filename:SKILL.md', pathTerm, userClause) }); + queries.push({ + priority: 2, + label: 'hyphen', + q: join('filename:SKILL.md', pathTerm, userClause), + }); } if (!owner && couldBeOwner(trimmed)) { - queries.push({ priority: 3, label: 'owner', q: `filename:SKILL.md user:${trimmed}` }); + queries.push({ + priority: 3, + label: 'owner', + q: `filename:SKILL.md user:${trimmed}`, + }); } // P4 always: primary content search. - queries.push({ priority: 4, label: 'primary', q: join('filename:SKILL.md', trimmed, userClause) }); + queries.push({ + priority: 4, + label: 'primary', + q: join('filename:SKILL.md', trimmed, userClause), + }); return queries; } +export interface CatalogSkillSearchQuery extends SkillSearchQuery { + batch: number; + required: boolean; + repositories: readonly string[]; +} + +/** Build deterministic repository-qualified Code Search batches. */ +export function buildCatalogSearchQueries( + query: string, +): CatalogSkillSearchQuery[] { + const repositories = [ + ...new Map( + RECOMMENDED_SKILL_CATALOG.sources.map((source) => [ + source.repo.toLowerCase(), + source.repo, + ]), + ).values(), + ]; + const trimmed = query.trim(); + const pathTerm = trimmed.replace(/ /g, '-'); + const variants: Array< + Pick & { + prefix: string; + required: boolean; + } + > = [ + { + priority: 1, + label: 'path', + prefix: `filename:SKILL.md path:${pathTerm}`, + required: false, + }, + ]; + if (pathTerm !== trimmed) { + variants.push({ + priority: 2, + label: 'hyphen', + prefix: `filename:SKILL.md ${pathTerm}`, + required: false, + }); + } + variants.push({ + priority: 4, + label: 'primary', + prefix: `filename:SKILL.md ${trimmed}`, + required: true, + }); + + // GitHub Code Search applies repeated repo qualifiers as a repository union; + // unlike a parenthesized OR expression, this syntax is accepted by the API. + // Returned items are still checked against the same batch and catalog roots. + return variants.flatMap((variant) => + batchCatalogRepositories(variant.prefix, repositories).map( + (batch, index): CatalogSkillSearchQuery => ({ + priority: variant.priority, + label: variant.label, + q: `${variant.prefix} ${batch.map((repo) => `repo:${repo}`).join(' ')}`, + batch: index, + required: variant.required, + repositories: batch, + }), + ), + ); +} + +function batchCatalogRepositories( + prefix: string, + repositories: readonly string[], +): string[][] { + const batches: string[][] = []; + let current: string[] = []; + for (const repository of repositories) { + const candidate = [...current, repository]; + const query = `${prefix} ${candidate.map((repo) => `repo:${repo}`).join(' ')}`; + if (query.length > CATALOG_QUERY_MAX_LENGTH && current.length > 0) { + batches.push(current); + current = [repository]; + } else { + current = candidate; + } + } + if (current.length > 0) batches.push(current); + return batches; +} + /** * Map a GitHub API response to a SkillSearchError. The 403 / rate-limit body * has a distinctive `documentation_url` and `message` shape; everything else * falls back to a generic API error. */ function classifyApiError(status: number, body: unknown): SkillSearchError { - const msg = typeof body === 'object' && body !== null && 'message' in body - ? String((body as { message: unknown }).message ?? '') - : ''; + const msg = + typeof body === 'object' && body !== null && 'message' in body + ? String((body as { message: unknown }).message ?? '') + : ''; if (status === 401) { return new SkillSearchError( 'GitHub Code Search requires authentication. Run `gh auth login` or set GITHUB_TOKEN.', @@ -226,7 +398,9 @@ export async function resolveGhToken(): Promise { * namespace is set, or just `` otherwise. Used as the dedup key * together with the repo full name. */ -export function qualifiedName(item: Pick): string { +export function qualifiedName( + item: Pick, +): string { return item.namespace ? `${item.namespace}/${item.name}` : item.name; } @@ -281,7 +455,9 @@ function parseSkillPath( // No `skills` segment — use the parent directory of the skill file as the name. // Skip the file itself (last segment) and use the directory before it. const lastPart = parts[parts.length - 1]?.toLowerCase() ?? ''; - const fileIdx = lastPart.endsWith('.md') ? parts.length - 2 : parts.length - 1; + const fileIdx = lastPart.endsWith('.md') + ? parts.length - 2 + : parts.length - 1; if (fileIdx >= 0) { const parent = parts[fileIdx]; if (parent) return { namespace: '', name: parent }; @@ -338,7 +514,11 @@ async function runOneQuery( items?: Array<{ path?: string; sha?: string; - repository?: { full_name?: string; description?: string; stargazers_count?: number }; + repository?: { + full_name?: string; + description?: string; + stargazers_count?: number; + }; }>; }; @@ -355,6 +535,12 @@ async function runOneQuery( description: item.repository?.description ?? '', sha: item.sha ?? '', stars: item.repository?.stargazers_count ?? 0, + installSource: repo, + installSelector: namespace ? `${namespace}/${name}` : name, + installation: { + policy: 'repository-install', + reasonCodes: [], + }, }; }); @@ -383,7 +569,13 @@ async function fetchPrimaryPages( let truncated = false; for (let currentPage = 1; currentPage <= numPages; currentPage += 1) { - const result = await runOneQuery(q, currentPage, SEARCH_PAGE_SIZE, token, fetchFn); + const result = await runOneQuery( + q, + currentPage, + SEARCH_PAGE_SIZE, + token, + fetchFn, + ); items.push(...result.items); total = result.total; truncated = truncated || result.truncated; @@ -392,6 +584,7 @@ async function fetchPrimaryPages( break; } } + truncated = truncated || items.length < Math.min(total, MAX_RESULTS); return { items, total, truncated }; } @@ -423,18 +616,22 @@ export async function searchSkills( ): Promise { validateSkillSearchArgs(query, options); const fetchFn = deps.fetch ?? fetch; - const logger = deps.logger ?? ((msg: string) => process.stderr.write(`${msg}\n`)); - + const logger = + deps.logger ?? ((msg: string) => process.stderr.write(`${msg}\n`)); const page = options.page ?? 1; const limit = options.limit ?? 15; const token = await (deps.tokenResolver ?? resolveGhToken)(); + if (options.catalog === 'recommended') { + return searchCatalogSkills(query, page, limit, token, fetchFn, logger); + } + const queries = buildSearchQueries(query, options.owner); const settled = await Promise.allSettled( queries.map((entry) => entry.priority === 4 ? fetchPrimaryPages(entry.q, page, limit, token, fetchFn) - : runOneQuery(entry.q, 1, SEARCH_PAGE_SIZE, token, fetchFn) + : runOneQuery(entry.q, 1, SEARCH_PAGE_SIZE, token, fetchFn), ), ); @@ -457,7 +654,9 @@ export async function searchSkills( } else { // Non-primary failure — log and continue. Primary failures are handled above. const reason = - outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); + outcome.reason instanceof Error + ? outcome.reason.message + : String(outcome.reason); logger(`Warning: skill search "${entry.label}" query failed: ${reason}`); } } @@ -486,7 +685,11 @@ export async function searchSkills( const filtered = filterByRelevance(workingSet, query); rankByRelevance(filtered, query); const dedupedByName = deduplicateByName(filtered); - const { items: finalItems, totalPages } = paginate(dedupedByName, page, limit); + const { items: finalItems, totalPages } = paginate( + dedupedByName, + page, + limit, + ); return { query, @@ -496,17 +699,297 @@ export async function searchSkills( }; } +interface CatalogRepositoryPreflight { + repo: string; + effectiveRef: string; + headSha: string; +} + +async function searchCatalogSkills( + query: string, + page: number, + limit: number, + token: string | undefined, + fetchFn: typeof fetch, + logger: (message: string) => void, +): Promise { + const preflight = await preflightCatalogRepositories(token, fetchFn); + const queries = buildCatalogSearchQueries(query); + const settled = await Promise.allSettled( + queries.map((entry) => + entry.required + ? fetchPrimaryPages(entry.q, page, limit, token, fetchFn) + : runOneQuery(entry.q, 1, SEARCH_PAGE_SIZE, token, fetchFn), + ), + ); + + type CatalogBucket = { + priority: number; + batch: number; + result: QueryRunResult; + }; + const buckets: CatalogBucket[] = []; + for (let index = 0; index < queries.length; index += 1) { + const entry = queries[index]; + const outcome = settled[index]; + if (!entry || !outcome) continue; + if (outcome.status === 'rejected') { + if (entry.required) throw outcome.reason; + const reason = + outcome.reason instanceof Error + ? outcome.reason.message + : String(outcome.reason); + logger( + `Warning: Recommended catalog "${entry.label}" batch ${entry.batch + 1} failed: ${reason}`, + ); + continue; + } + buckets.push({ + priority: entry.priority, + batch: entry.batch, + result: outcome.value, + }); + } + buckets.sort( + (left, right) => left.priority - right.priority || left.batch - right.batch, + ); + + const bounded = buckets + .flatMap((bucket) => bucket.result.items) + .map((item) => attachCatalogSource(item, preflight)) + .filter((item): item is SkillSearchItem => item !== null); + const deduped = dedupeItems(bounded); + const visible = deduped.filter((item) => { + const firstSegment = item.path.split('/')[0] ?? ''; + return !firstSegment.startsWith('.'); + }); + + rankCatalogByRelevance(visible, query); + const workingSet = truncateForProcessing(visible, page, limit); + await Promise.all([ + fetchStarsForItems(workingSet, token, fetchFn), + enrichDescriptionsForItems(workingSet, token, fetchFn), + ]); + const filtered = filterByRelevance(workingSet, query); + rankCatalogByRelevance(filtered, query); + const dedupedByName = deduplicateByName(filtered); + const { items, totalPages } = paginate(dedupedByName, page, limit); + return { + query, + items, + total: dedupedByName.length, + truncated: + buckets.some((bucket) => bucket.result.truncated) || totalPages > page, + }; +} + +async function preflightCatalogRepositories( + token: string | undefined, + fetchFn: typeof fetch, +): Promise> { + const sourcesByRepository = new Map< + string, + (typeof RECOMMENDED_SKILL_CATALOG.sources)[number] + >(); + for (const source of RECOMMENDED_SKILL_CATALOG.sources) { + const key = source.repo.toLowerCase(); + const existing = sourcesByRepository.get(key); + if (existing && existing.effectiveRef !== source.effectiveRef) { + throw new SkillSearchError( + `Catalog repository ${source.repo} has conflicting effective refs.`, + 'validation', + ); + } + if (!existing) sourcesByRepository.set(key, source); + } + + const headers = buildGitHubApiHeaders(token); + const results = await Promise.all( + [...sourcesByRepository.values()].map(async (source) => { + const repositoryResponse = await fetchFn( + `https://api.github.com/repos/${source.repo}`, + { headers }, + ); + let repositoryBody: unknown = null; + try { + repositoryBody = await repositoryResponse.json(); + } catch { + // Error classification below handles an empty body. + } + if (!repositoryResponse.ok) { + throw classifyApiError(repositoryResponse.status, repositoryBody); + } + const repositoryFullName = + repositoryBody && + typeof repositoryBody === 'object' && + 'full_name' in repositoryBody && + typeof repositoryBody.full_name === 'string' + ? repositoryBody.full_name + : undefined; + const defaultBranch = + repositoryBody && + typeof repositoryBody === 'object' && + 'default_branch' in repositoryBody && + typeof repositoryBody.default_branch === 'string' + ? repositoryBody.default_branch + : undefined; + if ( + repositoryFullName?.toLowerCase() !== source.repo.toLowerCase() || + defaultBranch !== source.effectiveRef + ) { + throw new SkillSearchError( + `Recommended catalog source ${source.sourceId} no longer resolves to ${source.repo}@${source.effectiveRef}.`, + 'api', + ); + } + + const refResponse = await fetchFn( + `https://api.github.com/repos/${source.repo}/git/ref/heads/${encodeURIComponent(source.effectiveRef)}`, + { headers }, + ); + let refBody: unknown = null; + try { + refBody = await refResponse.json(); + } catch { + // Error classification below handles an empty body. + } + if (!refResponse.ok) throw classifyApiError(refResponse.status, refBody); + const refObject = + refBody && typeof refBody === 'object' && 'object' in refBody + ? refBody.object + : undefined; + const headSha = + refObject && + typeof refObject === 'object' && + 'sha' in refObject && + typeof refObject.sha === 'string' + ? refObject.sha + : undefined; + if (!headSha) { + throw new SkillSearchError( + `Recommended catalog source ${source.sourceId} did not resolve a head SHA.`, + 'api', + ); + } + return { + key: source.repo.toLowerCase(), + value: { + repo: source.repo, + effectiveRef: source.effectiveRef, + headSha, + }, + }; + }), + ); + return new Map(results.map((result) => [result.key, result.value])); +} + +function attachCatalogSource( + item: SkillSearchItem, + preflight: ReadonlyMap, +): SkillSearchItem | null { + const repository = preflight.get(item.repo.toLowerCase()); + if (!repository) return null; + const source = matchCatalogSource(item.repo, item.path); + if (!source || source.effectiveRef !== repository.effectiveRef) return null; + const relativePath = relativeToCatalogRoot(item.path, source.installRoot); + if (relativePath === null || !relativePath.endsWith('/SKILL.md')) return null; + + let selector = relativePath.slice(0, -'/SKILL.md'.length); + if (source.installRoot === '.' && selector.startsWith('skills/')) { + selector = selector.slice('skills/'.length); + } + if (!selector) return null; + + let policy = source.installPolicy; + const reasonCodes: string[] = []; + if ( + source.installableSubpath && + !pathWithinCatalogRoot(item.path, source.installableSubpath) + ) { + policy = 'search-only'; + reasonCodes.push('outside-installable-subpath'); + } + if (policy === 'search-only') reasonCodes.push('search-only-source'); + if (policy === 'external-installer') reasonCodes.push('external-lifecycle'); + + const identity = catalogSourceIdentity({ + catalog: 'recommended', + sourceId: source.sourceId, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + }); + return { + ...item, + installSource: source.installSource, + installSelector: selector, + installation: { policy, reasonCodes }, + catalog: { + name: 'recommended', + label: RECOMMENDED_SKILL_CATALOG.label, + version: RECOMMENDED_SKILL_CATALOG.schemaVersion, + identity, + sourceId: source.sourceId, + classification: source.classification, + sourceKind: source.sourceKind, + category: source.category, + homepage: source.homepage, + author: source.author, + spdxLicense: source.spdxLicense, + warnings: source.warnings, + discovery: { + catalogIdentity: identity, + provider: 'github-code-search', + repo: source.repo, + effectiveRef: source.effectiveRef, + catalogVersion: 1, + approvedRoot: source.approvedRoot, + repositoryHeadSha: repository.headSha, + skillPath: item.path, + blobSha: item.sha, + }, + installDescriptor: catalogInstallDescriptor(source), + }, + }; +} + +function rankCatalogByRelevance(items: SkillSearchItem[], query: string): void { + const sourceOrder = new Map( + RECOMMENDED_SKILL_CATALOG.sources.map((source, index) => [ + source.sourceId, + index, + ]), + ); + items.sort((left, right) => { + const score = relevanceScore(right, query) - relevanceScore(left, query); + if (score !== 0) return score; + const source = + (sourceOrder.get(left.catalog?.sourceId ?? '') ?? + Number.MAX_SAFE_INTEGER) - + (sourceOrder.get(right.catalog?.sourceId ?? '') ?? + Number.MAX_SAFE_INTEGER); + return ( + source || + left.repo.localeCompare(right.repo) || + left.path.localeCompare(right.path) + ); + }); +} + /** * Drop duplicate hits by `repo + qualifiedName`. Same folder surfaced by * multiple query buckets (e.g. both `in:path` and content match) collapses to * one entry, with the higher-priority bucket's occurrence winning because - * items are merged in priority order before this runs. + * catalog entries use full identity + path; global entries use repository + + * qualified name. Higher-priority query buckets win. */ function dedupeItems(items: SkillSearchItem[]): SkillSearchItem[] { const seen = new Set(); const out: SkillSearchItem[] = []; for (const item of items) { - const key = `${item.repo}#${qualifiedName(item)}`; + const key = item.catalog + ? `${item.catalog.identity}#${item.path}` + : `${item.repo}#${qualifiedName(item)}`; if (seen.has(key)) continue; seen.add(key); out.push(item); @@ -514,7 +997,10 @@ function dedupeItems(items: SkillSearchItem[]): SkillSearchItem[] { return out; } -function splitRepo(item: Pick): { owner: string; repoName: string } { +function splitRepo(item: Pick): { + owner: string; + repoName: string; +} { const [owner = item.repo, repoName = ''] = item.repo.split('/', 2); return { owner, repoName }; } @@ -552,7 +1038,10 @@ function rankByRelevance(items: SkillSearchItem[], query: string): void { items.sort((a, b) => relevanceScore(b, query) - relevanceScore(a, query)); } -function filterByRelevance(items: SkillSearchItem[], query: string): SkillSearchItem[] { +function filterByRelevance( + items: SkillSearchItem[], + query: string, +): SkillSearchItem[] { const term = query.trim().toLowerCase(); const termHyphen = term.replace(/ /g, '-'); @@ -569,11 +1058,13 @@ function filterByRelevance(items: SkillSearchItem[], query: string): SkillSearch }); } -function truncateForProcessing(items: SkillSearchItem[], page: number, limit: number): SkillSearchItem[] { +function truncateForProcessing( + items: SkillSearchItem[], + page: number, + limit: number, +): SkillSearchItem[] { const maxToProcess = Math.max(page * limit * 3, limit * 3); - return items.length > maxToProcess - ? items.slice(0, maxToProcess) - : items; + return items.length > maxToProcess ? items.slice(0, maxToProcess) : items; } function deduplicateByName(items: SkillSearchItem[]): SkillSearchItem[] { @@ -624,16 +1115,22 @@ async function fetchStarsForItems( const headers = buildGitHubApiHeaders(token); const starsMap = new Map(); - await forEachWithConcurrency(uniqueRepos, ENRICHMENT_CONCURRENCY, async (repo) => { - try { - const res = await fetchFn(`https://api.github.com/repos/${repo}`, { headers }); - if (!res.ok) return; - const body = await res.json() as { stargazers_count?: number }; - starsMap.set(repo, body.stargazers_count ?? 0); - } catch { - // ignore — stars stay 0 - } - }); + await forEachWithConcurrency( + uniqueRepos, + ENRICHMENT_CONCURRENCY, + async (repo) => { + try { + const res = await fetchFn(`https://api.github.com/repos/${repo}`, { + headers, + }); + if (!res.ok) return; + const body = (await res.json()) as { stargazers_count?: number }; + starsMap.set(repo, body.stargazers_count ?? 0); + } catch { + // ignore — stars stay 0 + } + }, + ); for (const item of items) { const s = starsMap.get(item.repo); @@ -649,28 +1146,40 @@ async function enrichDescriptionsForItems( const headers = buildGitHubApiHeaders(token); const descriptionMap = new Map(); - const uniqueSkills = [...new Set(items.map((item) => `${item.repo}#${item.sha}`))]; - - await forEachWithConcurrency(uniqueSkills, ENRICHMENT_CONCURRENCY, async (key) => { - const [repo, sha] = key.split('#'); - if (!repo || !sha) return; - - try { - const res = await fetchFn(`https://api.github.com/repos/${repo}/git/blobs/${sha}`, { headers }); - if (!res.ok) return; - - const body = await res.json() as { content?: string; encoding?: string }; - const content = decodeGitBlob(body.content, body.encoding); - if (!content) return; - - const metadata = parseSkillMetadata(content); - if (!metadata?.description) return; + const uniqueSkills = [ + ...new Set(items.map((item) => `${item.repo}#${item.sha}`)), + ]; - descriptionMap.set(key, metadata.description); - } catch { - // Ignore metadata fetch failures and keep the repo description fallback. - } - }); + await forEachWithConcurrency( + uniqueSkills, + ENRICHMENT_CONCURRENCY, + async (key) => { + const [repo, sha] = key.split('#'); + if (!repo || !sha) return; + + try { + const res = await fetchFn( + `https://api.github.com/repos/${repo}/git/blobs/${sha}`, + { headers }, + ); + if (!res.ok) return; + + const body = (await res.json()) as { + content?: string; + encoding?: string; + }; + const content = decodeGitBlob(body.content, body.encoding); + if (!content) return; + + const metadata = parseSkillMetadata(content); + if (!metadata?.description) return; + + descriptionMap.set(key, metadata.description); + } catch { + // Ignore metadata fetch failures and keep the repo description fallback. + } + }, + ); for (const item of items) { const description = descriptionMap.get(`${item.repo}#${item.sha}`); @@ -698,7 +1207,9 @@ async function forEachWithConcurrency( await Promise.all(Array.from({ length: workerCount }, () => runWorker())); } -function buildGitHubApiHeaders(token: string | undefined): Record { +function buildGitHubApiHeaders( + token: string | undefined, +): Record { const headers: Record = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', @@ -708,7 +1219,10 @@ function buildGitHubApiHeaders(token: string | undefined): Record` suffix). Local plugins and marketplace specs are skipped since * they have no remote ref to record. */ -async function buildSourcesProvenance( +export async function buildSourcesProvenance( validatedPlugins: ValidatedPlugin[], pluginEntries: PluginEntry[], + deps: { fetchPlugin?: typeof fetchPlugin } = {}, ): Promise> { + const fetchPluginFn = deps.fetchPlugin ?? fetchPlugin; const sources: Record = {}; + const catalogPluginSpecs = new Set(); + + for (const entry of pluginEntries) { + if (typeof entry === 'string' || !entry.catalogSource) continue; + const descriptorResult = CatalogInstallDescriptorSchema.safeParse( + entry.catalogSource, + ); + if (!descriptorResult.success) continue; + const descriptor = descriptorResult.data; + const catalogEntry = RECOMMENDED_SKILL_CATALOG.sources.find( + (candidate) => candidate.sourceId === descriptor.sourceId, + ); + if ( + !catalogEntry || + JSON.stringify(catalogInstallDescriptor(catalogEntry)) !== + JSON.stringify(descriptor) + ) { + continue; + } + const parsedSource = parseExactGitHubInstallSource( + descriptor.installSource, + ); + if ( + !parsedSource || + parsedSource.repo.toLowerCase() !== descriptor.repo.toLowerCase() || + parsedSource.ref !== descriptor.effectiveRef || + parsedSource.root !== descriptor.installRoot + ) { + continue; + } + if ( + !validatedPlugins.some( + (validated) => validated.success && validated.plugin === entry.source, + ) + ) { + continue; + } + + catalogPluginSpecs.add(entry.source); + const fetchResult = await fetchPluginFn(descriptor.installSource, { + branch: descriptor.effectiveRef, + }); + if (!fetchResult.success || !fetchResult.resolvedSha) continue; + const identity = catalogSourceIdentity({ + catalog: descriptor.catalog, + sourceId: descriptor.sourceId, + effectiveRef: descriptor.effectiveRef, + approvedRoot: descriptor.approvedRoot, + }); + if (!sources[identity]) { + sources[identity] = { + pluginSpec: entry.source, + resolvedRef: fetchResult.resolvedRef ?? descriptor.effectiveRef, + resolvedSha: fetchResult.resolvedSha, + resolvedRoot: descriptor.installRoot, + catalogSource: descriptor, + }; + } + } - // Index user-declared pins by their raw source string so we can attach them - // to the matching validated plugin (whose `.plugin` may have `@pin` spliced in). const pinByRawSource = new Map(); for (const entry of pluginEntries) { if (typeof entry === 'string') continue; @@ -1907,21 +1973,20 @@ async function buildSourcesProvenance( } for (const validated of validatedPlugins) { - if (!validated.success) continue; + if (!validated.success || catalogPluginSpecs.has(validated.plugin)) + continue; const spec = validated.plugin; if (!isGitHubUrl(spec)) continue; const parsed = parseGitHubUrl(spec); if (!parsed) continue; - // Re-call fetchPlugin to pick up the cached resolvedSha / resolvedRef. - const fetchResult = await fetchPlugin(spec, { + const fetchResult = await fetchPluginFn(spec, { ...(parsed.branch && { branch: parsed.branch }), }); if (!fetchResult.success || !fetchResult.resolvedSha) continue; const rawBase = stripGitRef(`${parsed.owner}/${parsed.repo}`); const pinned = pinByRawSource.get(rawBase) ?? parsed.branch; - sources[rawBase] = { pluginSpec: rawBase, resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', @@ -2191,7 +2256,12 @@ export async function syncWorkspace( ? new Set(config.enabledSkills) : undefined; const allSkills = await sw.measure('skill-collection', () => - collectAllSkills(validPlugins, disabledSkillsSet, enabledSkillsSet, warnings), + collectAllSkills( + validPlugins, + disabledSkillsSet, + enabledSkillsSet, + warnings, + ), ); // Build per-plugin skill name maps (handles conflicts automatically) @@ -2239,9 +2309,14 @@ export async function syncWorkspace( // This preserves user-owned hooks and replaces only the allagents-managed // subset recorded in sync state. const codexHookSync = await sw.measure('codex-hooks-sync', async () => - syncCodexProjectHooks(validPlugins, workspacePath, previousState?.codexHooks, { - dryRun, - }), + syncCodexProjectHooks( + validPlugins, + workspacePath, + previousState?.codexHooks, + { + dryRun, + }, + ), ); warnings.push(...codexHookSync.warnings); @@ -2339,17 +2414,14 @@ export async function syncWorkspace( // Step 5d: Copy workspace files with GitHub cache // Pass repositories and skillsIndexRefs so conditional links are embedded in WORKSPACE-RULES - workspaceFileResults.push(...(await copyWorkspaceFiles( - sourcePath, - workspacePath, - filesToCopy, - { + workspaceFileResults.push( + ...(await copyWorkspaceFiles(sourcePath, workspacePath, filesToCopy, { dryRun, githubCache, repositories: config.repositories, skillsIndexRefs, - }, - ))); + })), + ); // If claude is a client and CLAUDE.md doesn't exist, copy AGENTS.md to CLAUDE.md // Skip when repositories is empty (no agent files should be created) @@ -2624,24 +2696,22 @@ export async function syncUserWorkspace( selectivePurgeWorkspace(homeDir, previousState, syncClients), ); - const relocatedHooks = await sw.measure( - 'legacy-copilot-hook-scan', - () => - findRelocatedGitHubHooks( - validPlugins - .filter( - (plugin) => - plugin.clients.includes('copilot') && - plugin.fileArtifacts?.github !== false, - ) - .map((plugin) => ({ - pluginPath: plugin.resolved, - ...(plugin.exclude && { exclude: plugin.exclude }), - })), - homeDir, - 'copilot', - { clientMappings: USER_CLIENT_MAPPINGS }, - ), + const relocatedHooks = await sw.measure('legacy-copilot-hook-scan', () => + findRelocatedGitHubHooks( + validPlugins + .filter( + (plugin) => + plugin.clients.includes('copilot') && + plugin.fileArtifacts?.github !== false, + ) + .map((plugin) => ({ + pluginPath: plugin.resolved, + ...(plugin.exclude && { exclude: plugin.exclude }), + })), + homeDir, + 'copilot', + { clientMappings: USER_CLIENT_MAPPINGS }, + ), ); for (const filePath of relocatedHooks.found) { @@ -2663,7 +2733,12 @@ export async function syncUserWorkspace( ? new Set(config.enabledSkills) : undefined; const allSkills = await sw.measure('skill-collection', () => - collectAllSkills(validPlugins, disabledSkillsSet, enabledSkillsSet, warnings), + collectAllSkills( + validPlugins, + disabledSkillsSet, + enabledSkillsSet, + warnings, + ), ); const pluginSkillMaps = buildPluginSkillNameMaps(allSkills); diff --git a/src/core/user-workspace.ts b/src/core/user-workspace.ts index 6772c24..0929330 100644 --- a/src/core/user-workspace.ts +++ b/src/core/user-workspace.ts @@ -24,6 +24,7 @@ import { } from './marketplace.js'; import { type ModifyResult, + type SourceAllowlistUpsertOptions, ensureObjectPluginEntry, extractPluginNames, findPluginEntryByName, @@ -75,7 +76,9 @@ export function isUserConfigPath(workspacePath: string): boolean { * Ensure user-level workspace.yaml exists with default config. * Creates it if missing, does not overwrite existing. */ -export async function ensureUserWorkspace(clients?: ClientEntry[]): Promise { +export async function ensureUserWorkspace( + clients?: ClientEntry[], +): Promise { const configPath = getUserWorkspaceConfigPath(); if (existsSync(configPath)) return; @@ -528,7 +531,10 @@ export async function removeUserDisabledSkill( const entry = config.plugins[index]; if (!entry) { - return { success: false, error: `Plugin '${pluginName}' not found in user workspace config` }; + return { + success: false, + error: `Plugin '${pluginName}' not found in user workspace config`, + }; } if ( typeof entry === 'string' || @@ -682,7 +688,10 @@ export async function removeUserEnabledSkill( const entry = config.plugins[index]; if (!entry) { - return { success: false, error: `Plugin '${pluginName}' not found in user workspace config` }; + return { + success: false, + error: `Plugin '${pluginName}' not found in user workspace config`, + }; } if ( typeof entry === 'string' || @@ -750,7 +759,8 @@ export async function setUserPluginSkillsMode( entry.skills = [...skillNames]; } else { // For blocklist, clear the field if no exclusions (= all enabled) - entry.skills = skillNames.length > 0 ? { exclude: [...skillNames] } : undefined; + entry.skills = + skillNames.length > 0 ? { exclude: [...skillNames] } : undefined; } await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); @@ -766,6 +776,7 @@ export async function setUserPluginSkillsMode( export async function upsertUserGitHubPluginSourceAllowlist( source: string, skillNames: string[], + options: SourceAllowlistUpsertOptions = {}, ): Promise { await ensureUserWorkspace(); const configPath = getUserWorkspaceConfigPath(); @@ -777,6 +788,7 @@ export async function upsertUserGitHubPluginSourceAllowlist( config, source, skillNames, + options, ); if (!result.success) return result; diff --git a/src/core/workspace-modify.ts b/src/core/workspace-modify.ts index 29fc682..314f551 100644 --- a/src/core/workspace-modify.ts +++ b/src/core/workspace-modify.ts @@ -3,6 +3,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { dump, load } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import { + CatalogInstallDescriptorSchema, + type CatalogInstallDescriptor, +} from '../models/skill-catalog.js'; import type { ClientEntry, PluginEntry, @@ -25,6 +29,11 @@ import { parsePluginSpec, resolvePluginSpecWithAutoRegister, } from './marketplace.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, + catalogSourceIdentity, +} from './skill-catalog.js'; /** * Default clients for auto-created project workspace.yaml. @@ -43,6 +52,11 @@ export interface ModifyResult { replaced?: boolean; // true if an existing plugin was replaced with --force } +export interface SourceAllowlistUpsertOptions { + identity?: 'repository-promoting' | 'catalog-exact'; + catalogSource?: CatalogInstallDescriptor; +} + /** * Update the clients list in .allagents/workspace.yaml * @param clients - New list of client types @@ -118,7 +132,9 @@ export async function addPlugin( // Handle plugin@marketplace format if (isPluginSpec(plugin)) { - const resolved = await resolvePluginSpecWithAutoRegister(plugin, { workspacePath }); + const resolved = await resolvePluginSpecWithAutoRegister(plugin, { + workspacePath, + }); if (!resolved.success) { return { success: false, @@ -420,7 +436,8 @@ export function extractPluginNames(pluginSource: string): string[] { if (ownerRepo !== parsed.repo) names.push(ownerRepo); if (parsed.subpath) { const subpathName = parsed.subpath.split('/').filter(Boolean).pop(); - if (subpathName && !names.includes(subpathName)) names.push(subpathName); + if (subpathName && !names.includes(subpathName)) + names.push(subpathName); } if (!names.includes(parsed.repo)) names.push(parsed.repo); if (!names.includes(ownerRepo)) names.push(ownerRepo); @@ -457,7 +474,8 @@ export function ensureObjectPluginEntry( index: number, ): Exclude { const entry = config.plugins[index]; - if (entry === undefined) throw new Error(`Plugin entry at index ${index} not found`); + if (entry === undefined) + throw new Error(`Plugin entry at index ${index} not found`); if (typeof entry === 'string') { const objectEntry: Exclude = { source: entry }; config.plugins[index] = objectEntry; @@ -539,7 +557,9 @@ export function canonicalizeGitHubPluginSource( { owner: current.owner, repo: current.repo, - ...(current.branch || next.branch ? { branch: current.branch ?? next.branch } : {}), + ...(current.branch || next.branch + ? { branch: current.branch ?? next.branch } + : {}), ...(sharedParts.length > 0 ? { subpath: sharedParts.join('/') } : {}), }, currentSource, @@ -556,7 +576,9 @@ async function findPluginEntryByGitHubIdentity( for (let i = 0; i < config.plugins.length; i++) { const entry = config.plugins[i]; if (!entry) continue; - const existingIdentity = await resolveGitHubIdentity(getPluginSource(entry)); + const existingIdentity = await resolveGitHubIdentity( + getPluginSource(entry), + ); if (existingIdentity === identity) return i; } @@ -567,10 +589,108 @@ export async function upsertGitHubPluginSourceAllowlistInConfig( config: WorkspaceConfig, source: string, skillNames: string[], + options: SourceAllowlistUpsertOptions = {}, ): Promise { const normalizedSkills = uniqueSkillNames(skillNames); - const exactIndex = config.plugins.findIndex((entry) => getPluginSource(entry) === source); + if (options.identity === 'catalog-exact') { + const parsedDescriptor = CatalogInstallDescriptorSchema.safeParse( + options.catalogSource, + ); + if (!parsedDescriptor.success) { + return { + success: false, + error: + 'Catalog-exact source updates require a valid catalog descriptor.', + }; + } + const descriptor = parsedDescriptor.data; + const catalogEntry = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === descriptor.sourceId, + ); + if ( + !catalogEntry || + JSON.stringify(catalogInstallDescriptor(catalogEntry)) !== + JSON.stringify(descriptor) + ) { + return { + success: false, + error: `Catalog descriptor drift for ${descriptor.sourceId}.`, + }; + } + if ( + descriptor.sourceKind !== 'marketplace' && + source !== descriptor.installSource + ) { + return { + success: false, + error: 'Catalog direct-install source does not match its descriptor.', + }; + } + if (descriptor.sourceKind === 'marketplace' && !isPluginSpec(source)) { + return { + success: false, + error: + 'Catalog marketplace entries require a plugin@marketplace source.', + }; + } + + const targetIdentity = catalogSourceIdentity({ + catalog: descriptor.catalog, + sourceId: descriptor.sourceId, + effectiveRef: descriptor.effectiveRef, + approvedRoot: descriptor.approvedRoot, + }); + for (let index = 0; index < config.plugins.length; index += 1) { + const existing = config.plugins[index]; + if (!existing) continue; + if (typeof existing === 'string') { + if (existing !== source) continue; + config.plugins[index] = { + source, + skills: normalizedSkills, + catalogSource: descriptor, + }; + return { success: true, normalizedPlugin: source }; + } + if (existing.source !== source) continue; + if (existing.catalogSource) { + const existingIdentity = catalogSourceIdentity({ + catalog: existing.catalogSource.catalog, + sourceId: existing.catalogSource.sourceId, + effectiveRef: existing.catalogSource.effectiveRef, + approvedRoot: existing.catalogSource.approvedRoot, + }); + if ( + existingIdentity !== targetIdentity || + JSON.stringify(existing.catalogSource) !== JSON.stringify(descriptor) + ) { + return { + success: false, + error: `Conflicting catalog descriptor for source ${source}.`, + }; + } + } + const existingSkills = Array.isArray(existing.skills) + ? existing.skills + : []; + existing.skills = uniqueSkillNames([ + ...existingSkills, + ...normalizedSkills, + ]); + existing.catalogSource = descriptor; + return { success: true, normalizedPlugin: source }; + } + config.plugins.push({ + source, + skills: normalizedSkills, + catalogSource: descriptor, + }); + return { success: true, normalizedPlugin: source }; + } + const exactIndex = config.plugins.findIndex( + (entry) => getPluginSource(entry) === source, + ); if (exactIndex !== -1) { const entry = ensureObjectPluginEntry(config, exactIndex); entry.source = source; @@ -755,7 +875,10 @@ export async function removeDisabledSkill( const entry = config.plugins[index]; if (!entry) { - return { success: false, error: `Plugin '${pluginName}' not found in workspace config` }; + return { + success: false, + error: `Plugin '${pluginName}' not found in workspace config`, + }; } if ( typeof entry === 'string' || @@ -931,7 +1054,10 @@ export async function removeEnabledSkill( const entry = config.plugins[index]; if (!entry) { - return { success: false, error: `Plugin '${pluginName}' not found in workspace config` }; + return { + success: false, + error: `Plugin '${pluginName}' not found in workspace config`, + }; } if ( typeof entry === 'string' || @@ -1006,7 +1132,8 @@ export async function setPluginSkillsMode( entry.skills = [...skillNames]; } else { // For blocklist, clear the field if no exclusions (= all enabled) - entry.skills = skillNames.length > 0 ? { exclude: [...skillNames] } : undefined; + entry.skills = + skillNames.length > 0 ? { exclude: [...skillNames] } : undefined; } await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); @@ -1023,6 +1150,7 @@ export async function upsertGitHubPluginSourceAllowlist( source: string, skillNames: string[], workspacePath: string = process.cwd(), + options: SourceAllowlistUpsertOptions = {}, ): Promise { await ensureWorkspace(workspacePath); const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); @@ -1040,6 +1168,7 @@ export async function upsertGitHubPluginSourceAllowlist( config, source, skillNames, + options, ); if (!result.success) return result; diff --git a/src/models/skill-catalog.ts b/src/models/skill-catalog.ts new file mode 100644 index 0000000..5dcdbc5 --- /dev/null +++ b/src/models/skill-catalog.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; + +export const SkillCatalogNameSchema = z.literal('recommended'); +export type SkillCatalogName = z.infer; + +export const SkillCatalogClassificationSchema = z.enum([ + 'recommended', + 'optional', + 'external-lifecycle', +]); +export type SkillCatalogClassification = z.infer< + typeof SkillCatalogClassificationSchema +>; + +export const SkillCatalogSourceKindSchema = z.enum([ + 'repository', + 'subtree', + 'marketplace', + 'external-lifecycle', +]); +export type SkillCatalogSourceKind = z.infer< + typeof SkillCatalogSourceKindSchema +>; + +export const SkillCatalogInstallPolicySchema = z.enum([ + 'direct-selective', + 'marketplace-selective', + 'search-only', + 'external-installer', +]); +export type SkillCatalogInstallPolicy = z.infer< + typeof SkillCatalogInstallPolicySchema +>; + +export type SkillCatalogBulkPolicy = 'allowed' | 'explicit-only' | 'forbidden'; + +export type SkillCatalogCategory = + | 'software-engineering' + | 'business-operations' + | 'integrations' + | 'general-purpose' + | 'agent-engineering' + | 'documentation' + | 'knowledge-work'; + +export interface SkillCatalogAuthor { + name: string; + url: string; +} + +export type SkillCatalogWarningCode = + | 'not-security-reviewed' + | 'license-metadata' + | 'license-ambiguous' + | 'external-dependencies' + | 'external-lifecycle' + | 'broken-marketplace' + | 'large-source' + | 'optional-source'; + +export interface SkillCatalogWarning { + code: SkillCatalogWarningCode; + message: string; +} + +export interface SkillCatalogSource { + sourceId: string; + repo: `${string}/${string}`; + effectiveRef: string; + approvedRoot: '.' | string; + installRoot: '.' | string; + installSource: string; + installableSubpath?: string; + displayName: string; + description: string; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + installPolicy: SkillCatalogInstallPolicy; + bulkPolicy: SkillCatalogBulkPolicy; + manifestBoundary: 'none' | 'authoritative'; + warnings: readonly SkillCatalogWarning[]; +} + +export interface SkillCatalog { + schemaVersion: 1; + name: 'recommended'; + label: 'Recommended'; + sources: readonly SkillCatalogSource[]; +} + +export const CatalogInstallDescriptorSchema = z.object({ + catalog: SkillCatalogNameSchema, + catalogVersion: z.literal(1), + sourceId: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + effectiveRef: z.string().min(1), + approvedRoot: z.string().min(1), + installSource: z.string().min(1), + installRoot: z.string().min(1), + sourceKind: SkillCatalogSourceKindSchema, + installPolicy: SkillCatalogInstallPolicySchema, +}); + +export type CatalogInstallDescriptor = z.infer< + typeof CatalogInstallDescriptorSchema +>; + +export interface CatalogDiscoveryProvenance { + catalogIdentity: string; + provider: 'github-code-search'; + repo: `${string}/${string}`; + effectiveRef: string; + catalogVersion: 1; + approvedRoot: '.' | string; + repositoryHeadSha: string; + skillPath: string; + blobSha: string; +} diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index 69dc33a..299d9c0 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CatalogInstallDescriptorSchema } from './skill-catalog.js'; import { ClientTypeSchema } from './workspace-config.js'; /** @@ -20,6 +21,8 @@ export const SyncStateSourceSchema = z.object({ resolvedRef: z.string(), resolvedSha: z.string(), pinnedRef: z.string().optional(), + catalogSource: CatalogInstallDescriptorSchema.optional(), + resolvedRoot: z.string().optional(), }); export type SyncStateSource = z.infer; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index cb00839..22fdfed 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CatalogInstallDescriptorSchema } from './skill-catalog.js'; /** * Managed mode for repositories: @@ -194,6 +195,8 @@ export const PluginEntrySchema = z.union([ * sync resolves the plugin at this ref instead of the default branch. */ pin: z.string().optional(), + /** Exact origin descriptor for an explicit catalog installation. */ + catalogSource: CatalogInstallDescriptorSchema.optional(), }), ]); diff --git a/src/utils/marketplace-manifest-parser.ts b/src/utils/marketplace-manifest-parser.ts index e620c5c..ab026ec 100644 --- a/src/utils/marketplace-manifest-parser.ts +++ b/src/utils/marketplace-manifest-parser.ts @@ -34,9 +34,9 @@ export type ParseResult = export async function parseMarketplaceManifest( marketplacePath: string, ): Promise { - const manifestPath = MANIFEST_PATHS - .map((path) => join(marketplacePath, path)) - .find((path) => existsSync(path)); + const manifestPath = MANIFEST_PATHS.map((path) => + join(marketplacePath, path), + ).find((path) => existsSync(path)); if (!manifestPath) { return { @@ -75,6 +75,82 @@ export async function parseMarketplaceManifest( return parseLeniently(json); } +/** + * Parse only strictly valid, repository-local entries from a catalog + * marketplace. Object sources describe independent remote distributions and + * are deliberately excluded: catalog installation must not widen to them. + */ +export async function parseCatalogLocalMarketplaceManifest( + marketplacePath: string, +): Promise { + const manifestPath = MANIFEST_PATHS.map((path) => + join(marketplacePath, path), + ).find((path) => existsSync(path)); + if (!manifestPath) { + return { + success: false, + error: `Marketplace manifest not found (checked ${MANIFEST_PATHS.join(', ')})`, + }; + } + + let json: unknown; + try { + json = JSON.parse(await readFile(manifestPath, 'utf-8')); + } catch (error) { + return { + success: false, + error: + error instanceof SyntaxError + ? 'Failed to parse marketplace.json as JSON: invalid syntax' + : `Failed to read marketplace manifest: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const manifest = MarketplaceManifestLenientSchema.safeParse(json); + if (!manifest.success) { + return { + success: false, + error: 'Marketplace manifest must contain a "plugins" array', + }; + } + + const plugins: MarketplacePluginEntry[] = []; + for (let index = 0; index < manifest.data.plugins.length; index++) { + const rawPlugin = manifest.data.plugins[index]; + if ( + typeof rawPlugin === 'object' && + rawPlugin !== null && + 'source' in rawPlugin && + typeof rawPlugin.source !== 'string' + ) { + continue; + } + const plugin = MarketplacePluginEntrySchema.safeParse(rawPlugin); + if (!plugin.success || typeof plugin.data.source !== 'string') { + return { + success: false, + error: `Local marketplace plugin at index ${index} is invalid.`, + }; + } + plugins.push(plugin.data); + } + + const rawManifest = json as Record; + return { + success: true, + data: { + name: + typeof manifest.data.name === 'string' ? manifest.data.name : 'unknown', + description: + typeof rawManifest.description === 'string' + ? rawManifest.description + : '', + plugins, + }, + warnings: [], + }; +} + /** * Read a plugin repository's own marketplace manifest and return the file * artifact boundary for the entry whose source is the repository root. @@ -144,7 +220,8 @@ function parseLeniently(json: unknown): ParseResult { // Build a manifest-like object with the valid plugins const data: MarketplaceManifest = { name: typeof raw.name === 'string' ? raw.name : 'unknown', - description: typeof obj.description === 'string' ? obj.description as string : '', + description: + typeof obj.description === 'string' ? (obj.description as string) : '', plugins: validPlugins, }; @@ -182,8 +259,11 @@ function extractPluginEntry( typeof obj.metadata === 'object' && typeof (obj.metadata as Record).description === 'string' ) { - description = (obj.metadata as Record).description as string; - warnings.push(`plugins[${index}] ("${name}"): "description" found in metadata instead of top level`); + description = (obj.metadata as Record) + .description as string; + warnings.push( + `plugins[${index}] ("${name}"): "description" found in metadata instead of top level`, + ); } else { warnings.push(`plugins[${index}] ("${name}"): missing "description" field`); } @@ -194,7 +274,9 @@ function extractPluginEntry( if (sourceResult.success) { source = sourceResult.data; } else { - warnings.push(`plugins[${index}] ("${name}"): missing or invalid "source" field`); + warnings.push( + `plugins[${index}] ("${name}"): missing or invalid "source" field`, + ); } return { diff --git a/src/utils/plugin-path.ts b/src/utils/plugin-path.ts index 8a48df2..4f4f7ec 100644 --- a/src/utils/plugin-path.ts +++ b/src/utils/plugin-path.ts @@ -53,6 +53,51 @@ export interface ParsedPluginSource { branch?: string; } +/** Exact ref-qualified GitHub install source used by catalog descriptors. */ +export interface ExactGitHubInstallSource { + repo: `${string}/${string}`; + ref: string; + root: '.' | string; +} + +/** + * Parse the catalog's unambiguous `owner/repo@ref[/root]` spelling. + * Catalog refs are default branch names and cannot contain path separators. + */ +export function parseExactGitHubInstallSource( + value: string, +): ExactGitHubInstallSource | null { + const match = value.match( + /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)@([A-Za-z0-9_.-]+)(?:\/(.+))?$/, + ); + if (!match?.[1] || !match[2] || !match[3]) return null; + const root = match[4] ?? '.'; + if ( + root !== '.' && + (root.startsWith('/') || + root.endsWith('/') || + root.includes('\\') || + root + .split('/') + .some((segment) => !segment || segment === '.' || segment === '..')) + ) { + return null; + } + return { + repo: `${match[1]}/${match[2]}`, + ref: match[3], + root, + }; +} + +export function renderExactGitHubInstallSource( + descriptor: ExactGitHubInstallSource, +): string { + return descriptor.root === '.' + ? `${descriptor.repo}@${descriptor.ref}` + : `${descriptor.repo}@${descriptor.ref}/${descriptor.root}`; +} + /** * Detect if a plugin source is a GitHub URL or shorthand * Supports: diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index 10335f9..1b5b03a 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -344,4 +344,48 @@ description: Blog watcher } } }); + it('returns exit 2 for an unknown catalog in JSON mode', () => { + const proc = Bun.spawnSync( + [ + 'bun', + 'run', + join(import.meta.dir, '../../src/cli/index.ts'), + '--json', + 'skill', + 'search', + 'docs', + '--catalog', + 'unknown', + ], + { cwd: tmpDir, env: { ...process.env, HOME: tmpDir } }, + ); + expect(proc.exitCode).toBe(2); + const payload = JSON.parse(proc.stdout.toString()); + expect(payload.success).toBe(false); + expect(payload.error).toBe( + 'Unknown skill catalog "unknown". Available catalogs: recommended.', + ); + }); + + it('returns exit 2 when catalog and owner are combined', () => { + const proc = Bun.spawnSync( + [ + 'bun', + 'run', + join(import.meta.dir, '../../src/cli/index.ts'), + 'skill', + 'search', + 'docs', + '--catalog', + 'recommended', + '--owner', + 'anthropics', + ], + { cwd: tmpDir, env: { ...process.env, HOME: tmpDir } }, + ); + expect(proc.exitCode).toBe(2); + expect(proc.stderr.toString()).toContain( + '--catalog and --owner cannot be used together.', + ); + }); }); diff --git a/tests/unit/cli/skill-search-install.test.ts b/tests/unit/cli/skill-search-install.test.ts new file mode 100644 index 0000000..74b4874 --- /dev/null +++ b/tests/unit/cli/skill-search-install.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type SelectedSkillSearchSource, + installSelectedSkillSearchSources, +} from '../../../src/cli/commands/plugin-skills.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, +} from '../../../src/core/skill-catalog.js'; +import type { SyncResult } from '../../../src/core/sync.js'; + +const fixtures: string[] = []; + +function sourceGroup( + sourceId: string, + selectors: string[], +): SelectedSkillSearchSource { + const source = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === sourceId, + )!; + return { + catalogIdentity: `recommended:${source.sourceId}@${source.effectiveRef}#${source.approvedRoot}`, + installDescriptor: catalogInstallDescriptor(source), + installSource: source.installSource, + installPolicy: source.installPolicy, + classification: source.classification, + warnings: source.warnings, + selectors, + }; +} + +function successfulSync(): SyncResult { + return { + success: true, + pluginResults: [], + totalCopied: 2, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + }; +} + +afterEach(async () => { + await Promise.all( + fixtures.splice(0).map((path) => + rm(path, { recursive: true, force: true }), + ), + ); +}); + +describe('installSelectedSkillSearchSources', () => { + it('configures exact Hermes roots and invokes one final sync', async () => { + const cache = await mkdtemp(join(tmpdir(), 'catalog-install-')); + fixtures.push(cache); + await mkdir(join(cache, 'skills/research/wiki/references'), { + recursive: true, + }); + await mkdir(join(cache, 'optional-skills/browser/scripts'), { + recursive: true, + }); + await writeFile(join(cache, 'skills/research/wiki/SKILL.md'), '# wiki'); + await writeFile( + join(cache, 'skills/research/wiki/references/source.md'), + 'asset', + ); + await writeFile( + join(cache, 'optional-skills/browser/SKILL.md'), + '# browser', + ); + await writeFile( + join(cache, 'optional-skills/browser/scripts/run.sh'), + 'echo asset', + ); + + const upsert = mock(async () => ({ success: true as const })); + const sync = mock(async () => successfulSync()); + const result = await installSelectedSkillSearchSources( + [ + sourceGroup('hermes-core', ['research/wiki']), + sourceGroup('hermes-optional', ['browser']), + ], + 'project', + '/workspace', + { + fetchPlugin: async (_source, options) => ({ + success: true, + action: 'fetched', + cachePath: cache, + resolvedRef: options.branch, + resolvedSha: 'shared-sha', + }), + upsertProjectAllowlist: upsert, + syncWorkspace: sync, + }, + ); + + expect(result.success).toBe(true); + expect(sync).toHaveBeenCalledTimes(1); + expect(upsert).toHaveBeenCalledTimes(2); + expect(upsert.mock.calls[0]?.[0]).toBe( + 'NousResearch/hermes-agent@main/skills', + ); + expect(upsert.mock.calls[0]?.[1]).toEqual(['research/wiki']); + expect(upsert.mock.calls[0]?.[3]?.catalogSource?.sourceId).toBe( + 'hermes-core', + ); + expect(upsert.mock.calls[1]?.[0]).toBe( + 'NousResearch/hermes-agent@main/optional-skills', + ); + expect(upsert.mock.calls[1]?.[3]?.catalogSource?.sourceId).toBe( + 'hermes-optional', + ); + }); + + it('rejects search-only and external selections before mutation', async () => { + const add = mock(async () => ({ success: true as const })); + const sync = mock(async () => successfulSync()); + for (const sourceId of ['gstack', 'composio-awesome-claude-skills']) { + const result = await installSelectedSkillSearchSources( + [sourceGroup(sourceId, ['selected'])], + 'project', + '/workspace', + { addPlugin: add, syncWorkspace: sync }, + ); + expect(result.success).toBe(false); + } + expect(add).toHaveBeenCalledTimes(0); + expect(sync).toHaveBeenCalledTimes(0); + }); + + it('resolves marketplace selectors through the authoritative local manifest', async () => { + const cache = await mkdtemp(join(tmpdir(), 'catalog-marketplace-')); + fixtures.push(cache); + await mkdir(join(cache, '.claude-plugin'), { recursive: true }); + await mkdir(join(cache, 'skills/typescript/references'), { + recursive: true, + }); + await writeFile(join(cache, 'skills/typescript/SKILL.md'), '# TypeScript'); + await mkdir(join(cache, 'skills/other'), { recursive: true }); + await writeFile(join(cache, 'skills/other/SKILL.md'), '# Other'); + await writeFile( + join(cache, 'skills/typescript/references/guide.md'), + 'asset', + ); + await writeFile( + join(cache, '.claude-plugin/marketplace.json'), + JSON.stringify({ + name: 'fixture-marketplace', + description: 'Fixture marketplace', + plugins: [ + { + name: 'remote-plugin', + description: 'Independent distribution', + source: { + source: 'git-subdir', + url: 'https://github.com/example/remote.git', + path: 'plugin', + ref: 'main', + }, + }, + { + name: 'fixture-plugin', + description: 'Fixture plugin', + source: '.', + skills: 'skills/typescript', + }, + { + name: 'other-plugin', + description: 'Another root plugin', + source: '.', + skills: 'skills/other', + }, + ], + }), + ); + + const upsert = mock(async () => ({ success: true as const })); + const sync = mock(async () => successfulSync()); + const addMarketplace = mock(async () => ({ + success: true as const, + marketplace: { + name: 'fixture-marketplace', + source: { type: 'github' as const, location: 'mattpocock/skills' }, + path: cache, + }, + })); + const result = await installSelectedSkillSearchSources( + [sourceGroup('mattpocock-skills', ['typescript'])], + 'project', + '/workspace', + { + fetchPlugin: async (_source, options) => ({ + success: true, + action: 'fetched', + cachePath: cache, + resolvedRef: options.branch, + resolvedSha: 'marketplace-sha', + }), + findMarketplaceRegistration: async () => undefined, + addMarketplace, + upsertProjectAllowlist: upsert, + syncWorkspace: sync, + }, + ); + + expect(result.success).toBe(true); + expect(addMarketplace.mock.calls[0]?.slice(0, 3)).toEqual([ + 'mattpocock/skills', + 'mattpocock-skills', + 'main', + ]); + expect(upsert).toHaveBeenCalledTimes(1); + expect(upsert.mock.calls[0]?.[0]).toBe( + 'fixture-plugin@fixture-marketplace', + ); + expect(upsert.mock.calls[0]?.[1]).toEqual(['typescript']); + expect(upsert.mock.calls[0]?.[3]?.catalogSource?.sourceId).toBe( + 'mattpocock-skills', + ); + expect(sync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/cli/skill-search-summary.test.ts b/tests/unit/cli/skill-search-summary.test.ts index 2c2381f..3f45f5b 100644 --- a/tests/unit/cli/skill-search-summary.test.ts +++ b/tests/unit/cli/skill-search-summary.test.ts @@ -1,24 +1,87 @@ import { describe, expect, it } from 'bun:test'; import { - collectSelectedSkillSearchRepos, + collectSelectedSkillSearchSources, formatSkillSearchHint, formatSkillSearchSummary, + skillSearchSelectionKey, } from '../../../src/cli/commands/plugin-skills.js'; +import type { SkillSearchItem } from '../../../src/core/skill-search.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, + catalogSourceIdentity, +} from '../../../src/core/skill-catalog.js'; + +function globalItem(path: string, repo: string): SkillSearchItem { + const name = path.split('/').at(-2) ?? 'skill'; + return { + name, + namespace: '', + repo, + path, + description: '', + sha: `${repo}:${path}`, + stars: 0, + installSource: repo, + installSelector: name, + installation: { policy: 'repository-install', reasonCodes: [] }, + }; +} + +function catalogItem(sourceId: 'hermes-core' | 'hermes-optional'): SkillSearchItem { + const source = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === sourceId, + )!; + const selector = sourceId === 'hermes-core' ? 'research/wiki' : 'browser'; + const path = `${source.approvedRoot}/${selector}/SKILL.md`; + const identity = catalogSourceIdentity({ + catalog: 'recommended', + sourceId, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + }); + return { + ...globalItem(path, source.repo), + installSource: source.installSource, + installSelector: selector, + installation: { policy: source.installPolicy, reasonCodes: [] }, + catalog: { + name: 'recommended', + label: 'Recommended', + version: 1, + identity, + sourceId, + classification: source.classification, + sourceKind: source.sourceKind, + category: source.category, + homepage: source.homepage, + author: source.author, + spdxLicense: source.spdxLicense, + warnings: source.warnings, + discovery: { + catalogIdentity: identity, + provider: 'github-code-search', + repo: source.repo, + effectiveRef: source.effectiveRef, + catalogVersion: 1, + approvedRoot: source.approvedRoot, + repositoryHeadSha: 'head', + skillPath: path, + blobSha: 'blob', + }, + installDescriptor: catalogInstallDescriptor(source), + }, + }; +} describe('formatSkillSearchSummary', () => { - it('uses singular skill wording for one match', () => { + it('uses singular and plural wording with truncation', () => { expect(formatSkillSearchSummary(1, 'skill-source-mapping', false)).toBe( 'Showing 1 skill matching "skill-source-mapping"', ); - }); - - it('uses plural skills wording for multiple matches', () => { expect(formatSkillSearchSummary(2, 'mapping', false)).toBe( 'Showing 2 skills matching "mapping"', ); - }); - - it('includes truncated marker when applicable', () => { expect(formatSkillSearchSummary(15, 'mapping', true)).toBe( 'Showing 15 skills matching "mapping" (truncated)', ); @@ -26,48 +89,68 @@ describe('formatSkillSearchSummary', () => { }); describe('formatSkillSearchHint', () => { - it('includes a space between the star icon and count', () => { - expect(formatSkillSearchHint({ - stars: 1, - description: 'Locate source repositories for AI skills.', - })).toBe('★ 1 Locate source repositories for AI skills.'); + it('formats stars and descriptions for global results', () => { + expect( + formatSkillSearchHint({ + stars: 1, + description: 'Locate source repositories for AI skills.', + }), + ).toBe('★ 1 Locate source repositories for AI skills.'); }); - it('omits the star section when the repo has no stars', () => { - expect(formatSkillSearchHint({ - stars: 0, - description: 'Locate source repositories for AI skills.', - })).toBe('Locate source repositories for AI skills.'); + it('includes catalog policy and warnings', () => { + const item = catalogItem('hermes-optional'); + expect(formatSkillSearchHint(item)).toContain('optional'); + expect(formatSkillSearchHint(item)).toContain( + 'Catalog inclusion is not a security review', + ); }); }); -describe('collectSelectedSkillSearchRepos', () => { - it('deduplicates repos when multiple selected skills come from the same plugin', () => { - expect(collectSelectedSkillSearchRepos([ - { path: 'skills/development/pr-search/SKILL.md', repo: 'WiseTechGlobal/WTG.AI.Prompts' }, - { path: 'skills/pr-search/SKILL.md', repo: 'WiseTechGlobal/PM-Workspaces' }, - { path: 'skills/other-pr-search/SKILL.md', repo: 'WiseTechGlobal/WTG.AI.Prompts' }, - ], [ - 'skills/development/pr-search/SKILL.md', - 'skills/other-pr-search/SKILL.md', - 'skills/pr-search/SKILL.md', - ])).toEqual([ +describe('collectSelectedSkillSearchSources', () => { + it('groups global selections by normalized install source in result order', () => { + const items = [ + globalItem( + 'skills/development/pr-search/SKILL.md', + 'WiseTechGlobal/WTG.AI.Prompts', + ), + globalItem('skills/pr-search/SKILL.md', 'WiseTechGlobal/PM-Workspaces'), + globalItem( + 'skills/other-pr-search/SKILL.md', + 'WiseTechGlobal/WTG.AI.Prompts', + ), + ]; + const selected = items.map(skillSearchSelectionKey); + expect( + collectSelectedSkillSearchSources(items, selected).map( + (source) => source.installSource, + ), + ).toEqual([ 'WiseTechGlobal/WTG.AI.Prompts', 'WiseTechGlobal/PM-Workspaces', ]); }); - it('preserves search result order for the selected repos', () => { - expect(collectSelectedSkillSearchRepos([ - { path: 'skills/a/SKILL.md', repo: 'org/first' }, - { path: 'skills/b/SKILL.md', repo: 'org/second' }, - { path: 'skills/c/SKILL.md', repo: 'org/third' }, - ], [ - 'skills/c/SKILL.md', - 'skills/a/SKILL.md', - ])).toEqual([ - 'org/first', - 'org/third', + it('keeps Hermes core and optional identities separate', () => { + const items = [catalogItem('hermes-core'), catalogItem('hermes-optional')]; + const groups = collectSelectedSkillSearchSources( + items, + items.map(skillSearchSelectionKey), + ); + expect(groups.map((group) => group.catalogIdentity)).toEqual([ + 'recommended:hermes-core@main#skills', + 'recommended:hermes-optional@main#optional-skills', ]); + expect(groups.map((group) => group.selectors)).toEqual([ + ['research/wiki'], + ['browser'], + ]); + }); + + it('rejects stale selection keys', () => { + const item = globalItem('skills/a/SKILL.md', 'org/repo'); + expect(() => + collectSelectedSkillSearchSources([item], ['missing']), + ).toThrow('not present in the current search result'); }); }); diff --git a/tests/unit/core/git.test.ts b/tests/unit/core/git.test.ts index 2d3acb8..5df47d9 100644 --- a/tests/unit/core/git.test.ts +++ b/tests/unit/core/git.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { createGitEnv } from '../../../src/core/git.js'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import simpleGit from 'simple-git'; +import { cloneTo, createGitEnv } from '../../../src/core/git.js'; describe('createGitEnv', () => { const originalHome = process.env.HOME; @@ -32,3 +36,35 @@ describe('createGitEnv', () => { }); }); }); + +describe('cloneTo', () => { + it('clones into an empty destination with controlled LFS filters', async () => { + const fixture = await mkdtemp(join(tmpdir(), 'allagents-git-test-')); + const upstream = join(fixture, 'upstream'); + const remote = join(fixture, 'origin.git'); + const destination = join(fixture, 'clone'); + + try { + await mkdir(upstream); + const git = simpleGit(upstream); + await git.init(); + await git.checkoutLocalBranch('main'); + await git.addConfig('user.name', 'AllAgents Test'); + await git.addConfig('user.email', 'test@allagents.dev'); + await writeFile(join(upstream, 'tracked.txt'), 'clean clone\n'); + await git.add('tracked.txt'); + await git.commit('fixture'); + await simpleGit().raw(['init', '--bare', remote]); + await git.addRemote('origin', remote); + await git.push(['-u', 'origin', 'main']); + + await cloneTo(remote, destination, 'main'); + + expect(await readFile(join(destination, 'tracked.txt'), 'utf8')).toBe( + 'clean clone\n', + ); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/core/github-skill-source-promotion.test.ts b/tests/unit/core/github-skill-source-promotion.test.ts index 1449819..9f344a1 100644 --- a/tests/unit/core/github-skill-source-promotion.test.ts +++ b/tests/unit/core/github-skill-source-promotion.test.ts @@ -6,8 +6,13 @@ import { dump, load } from 'js-yaml'; import { canonicalizeGitHubPluginSource, upsertGitHubPluginSourceAllowlist, + upsertGitHubPluginSourceAllowlistInConfig, } from '../../../src/core/workspace-modify.js'; import type { WorkspaceConfig } from '../../../src/models/workspace-config.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, +} from '../../../src/core/skill-catalog.js'; describe('canonicalizeGitHubPluginSource', () => { it('promotes sibling standalone skills to their shared subtree', () => { @@ -78,3 +83,88 @@ describe('upsertGitHubPluginSourceAllowlist', () => { } }); }); + +describe('catalog-exact source identity', () => { + const core = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'hermes-core', + )!; + const optional = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'hermes-optional', + )!; + + for (const order of [ + [core, optional], + [optional, core], + ]) { + it(`retains both Hermes roots when installed ${order.map((source) => source.sourceId).join(' then ')}`, async () => { + const config: WorkspaceConfig = { + version: 2, + repositories: [], + clients: ['universal'], + plugins: [], + }; + for (const source of order) { + const result = await upsertGitHubPluginSourceAllowlistInConfig( + config, + source.installSource, + [`${source.sourceId}/selected`], + { + identity: 'catalog-exact', + catalogSource: catalogInstallDescriptor(source), + }, + ); + expect(result.success).toBe(true); + } + expect(config.plugins).toHaveLength(2); + expect( + config.plugins.map((entry) => + typeof entry === 'string' ? entry : entry.source, + ), + ).toEqual(order.map((source) => source.installSource)); + expect( + config.plugins.map((entry) => + typeof entry === 'string' + ? undefined + : entry.catalogSource?.sourceId, + ), + ).toEqual(order.map((source) => source.sourceId)); + expect( + config.plugins.some( + (entry) => + typeof entry !== 'string' && + entry.source === 'NousResearch/hermes-agent@main', + ), + ).toBe(false); + }); + } + + it('merges only ordered selectors for an identical full descriptor', async () => { + const config: WorkspaceConfig = { + version: 2, + repositories: [], + clients: ['universal'], + plugins: [], + }; + const options = { + identity: 'catalog-exact' as const, + catalogSource: catalogInstallDescriptor(core), + }; + await upsertGitHubPluginSourceAllowlistInConfig( + config, + core.installSource, + ['research/llm-wiki'], + options, + ); + await upsertGitHubPluginSourceAllowlistInConfig( + config, + core.installSource, + ['research/blogwatcher', 'research/llm-wiki'], + options, + ); + const entry = config.plugins[0]; + expect(typeof entry === 'string' ? undefined : entry?.skills).toEqual([ + 'research/llm-wiki', + 'research/blogwatcher', + ]); + }); +}); diff --git a/tests/unit/core/skill-catalog-health.test.ts b/tests/unit/core/skill-catalog-health.test.ts new file mode 100644 index 0000000..a6770fe --- /dev/null +++ b/tests/unit/core/skill-catalog-health.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'bun:test'; +import { + type CatalogHealthDependencies, + checkSkillCatalogHealth, + validateSkillCatalog, +} from '../../../src/core/skill-catalog-health.js'; +import { RECOMMENDED_SKILL_CATALOG } from '../../../src/core/skill-catalog.js'; +import type { SkillCatalog } from '../../../src/models/skill-catalog.js'; + +const marketplaceCatalog: SkillCatalog = { + schemaVersion: 1, + name: 'recommended', + label: 'Recommended', + sources: [ + RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'mattpocock-skills', + )!, + ], +}; + +function healthDependencies(overrides: Partial = {}) { + const calls: string[] = []; + const deps: CatalogHealthDependencies = { + async getRepository(repo) { + calls.push(`repo:${repo}`); + return { + fullName: 'mattpocock/skills', + defaultBranch: 'main', + headSha: 'abc123', + }; + }, + async getTree(repo, ref) { + calls.push(`tree:${repo}@${ref}`); + return [ + { path: '.claude-plugin/marketplace.json', type: 'blob' }, + { path: 'skills', type: 'tree' }, + { path: 'skills/typescript', type: 'tree' }, + { path: 'skills/typescript/SKILL.md', type: 'blob' }, + ]; + }, + async getTextFile(repo, ref, path) { + calls.push(`file:${repo}@${ref}:${path}`); + return JSON.stringify({ + name: 'fixture', + plugins: [ + { + name: 'fixture', + description: 'Fixture plugin', + source: './', + skills: 'skills/typescript', + }, + ], + }); + }, + ...overrides, + }; + return { deps, calls }; +} + +describe('validateSkillCatalog', () => { + it('rejects duplicate identities, invalid metadata, and policy combinations', () => { + const source = marketplaceCatalog.sources[0]!; + const invalid = { + ...marketplaceCatalog, + sources: [ + { + ...source, + homepage: '', + spdxLicense: 'not a valid SPDX expression!', + bulkPolicy: 'allowed' as const, + installPolicy: 'search-only' as const, + manifestBoundary: 'none' as const, + warnings: [], + }, + source, + ], + } satisfies SkillCatalog; + const codes = validateSkillCatalog(invalid).map((issue) => issue.code); + expect(codes).toContain('duplicate-source-id'); + expect(codes).toContain('duplicate-identity'); + expect(codes).toContain('missing-metadata'); + expect(codes).toContain('invalid-spdx'); + expect(codes).toContain('invalid-non-installable-bulk-policy'); + expect(codes).toContain('missing-common-warning'); + }); +}); + +describe('checkSkillCatalogHealth', () => { + it('validates a local authoritative manifest with GET-only dependencies', async () => { + const { deps, calls } = healthDependencies(); + const report = await checkSkillCatalogHealth(marketplaceCatalog, deps); + expect(report.sources).toEqual([ + { + sourceId: 'mattpocock-skills', + identity: 'recommended:mattpocock-skills@main#.', + status: 'healthy', + reasonCodes: [], + repositoryHeadSha: 'abc123', + }, + ]); + expect(calls).toEqual([ + 'repo:mattpocock/skills', + 'tree:mattpocock/skills@main', + 'file:mattpocock/skills@main:.claude-plugin/marketplace.json', + ]); + expect(Object.keys(deps).sort()).toEqual([ + 'getRepository', + 'getTextFile', + 'getTree', + ]); + }); + + it('reports ref drift and manifest traversal without mutation', async () => { + const { deps } = healthDependencies({ + async getRepository() { + return { + fullName: 'mattpocock/skills', + defaultBranch: 'next', + headSha: 'def456', + }; + }, + async getTextFile() { + return JSON.stringify({ + name: 'fixture', + description: 'Fixture marketplace', + plugins: [ + { + name: 'escape', + description: 'Escaping source', + source: '../outside', + }, + ], + }); + }, + }); + const report = await checkSkillCatalogHealth(marketplaceCatalog, deps); + expect(report.sources[0]?.status).toBe('drifted'); + expect(report.sources[0]?.reasonCodes).toContain('default-ref-drift'); + expect(report.sources[0]?.reasonCodes).toContain('manifest-source-escape'); + }); + + it('rejects symlinked skill paths in an authoritative manifest', async () => { + const { deps } = healthDependencies({ + async getTree() { + return [ + { path: '.claude-plugin/marketplace.json', type: 'blob' }, + { path: 'skills', type: 'tree' }, + { + path: 'skills/typescript', + type: 'blob', + mode: '120000', + }, + { path: 'skills/typescript/SKILL.md', type: 'blob' }, + ]; + }, + }); + const report = await checkSkillCatalogHealth(marketplaceCatalog, deps); + expect(report.sources[0]?.status).toBe('drifted'); + expect(report.sources[0]?.reasonCodes).toContain('manifest-skill-invalid'); + }); + + it('reports unavailable repositories without falling through', async () => { + const { deps } = healthDependencies({ + async getRepository() { + throw new Error('unavailable'); + }, + }); + const report = await checkSkillCatalogHealth(marketplaceCatalog, deps); + expect(report.sources[0]?.status).toBe('unreachable'); + expect(report.sources[0]?.reasonCodes).toEqual(['repository-unreachable']); + }); +}); diff --git a/tests/unit/core/skill-catalog-provenance.test.ts b/tests/unit/core/skill-catalog-provenance.test.ts new file mode 100644 index 0000000..3a530fd --- /dev/null +++ b/tests/unit/core/skill-catalog-provenance.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'bun:test'; +import { + type ValidatedPlugin, + buildSourcesProvenance, +} from '../../../src/core/sync.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, +} from '../../../src/core/skill-catalog.js'; +import type { PluginEntry } from '../../../src/models/workspace-config.js'; +import type { FetchResult } from '../../../src/core/plugin.js'; + +const core = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'hermes-core', +)!; +const optional = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'hermes-optional', +)!; + +describe('catalog source provenance', () => { + for (const order of [ + [core, optional], + [optional, core], + ]) { + it(`writes two Hermes identities when installed ${order.map((source) => source.sourceId).join(' then ')}`, async () => { + const pluginEntries: PluginEntry[] = order.map((source) => ({ + source: source.installSource, + skills: [`${source.sourceId}/selected`], + catalogSource: catalogInstallDescriptor(source), + })); + const validatedPlugins: ValidatedPlugin[] = order.map((source) => ({ + plugin: source.installSource, + resolved: `/cache/hermes-agent/${source.installRoot}`, + success: true, + clients: [], + nativeClients: [], + })); + const physicalFetches = new Set(); + const fetchPlugin = async ( + _source: string, + options?: { branch?: string }, + ): Promise => { + physicalFetches.add(`NousResearch/hermes-agent@${options?.branch}`); + return { + success: true, + action: 'fetched', + cachePath: '/cache/hermes-agent', + resolvedRef: 'main', + resolvedSha: 'shared-hermes-sha', + }; + }; + + const sources = await buildSourcesProvenance( + validatedPlugins, + pluginEntries, + { fetchPlugin }, + ); + + expect(Object.keys(sources).sort()).toEqual([ + 'recommended:hermes-core@main#skills', + 'recommended:hermes-optional@main#optional-skills', + ]); + expect( + sources['recommended:hermes-core@main#skills']?.resolvedRoot, + ).toBe('skills'); + expect( + sources['recommended:hermes-optional@main#optional-skills'] + ?.resolvedRoot, + ).toBe('optional-skills'); + expect( + sources['recommended:hermes-core@main#skills']?.catalogSource + ?.installSource, + ).toBe('NousResearch/hermes-agent@main/skills'); + expect( + sources['recommended:hermes-optional@main#optional-skills'] + ?.catalogSource?.installSource, + ).toBe('NousResearch/hermes-agent@main/optional-skills'); + expect(physicalFetches).toEqual( + new Set(['NousResearch/hermes-agent@main']), + ); + }); + } +}); diff --git a/tests/unit/core/skill-catalog.test.ts b/tests/unit/core/skill-catalog.test.ts new file mode 100644 index 0000000..9aa5130 --- /dev/null +++ b/tests/unit/core/skill-catalog.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'bun:test'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, + catalogSourceIdentity, + isCatalogSkillPath, + matchCatalogSource, + pathWithinCatalogRoot, +} from '../../../src/core/skill-catalog.js'; +import { validateSkillCatalog } from '../../../src/core/skill-catalog-health.js'; + +const EXPECTED_SOURCES = [ + ['gstack', 'garrytan/gstack', 'main', '.', '.', 'external-lifecycle', 'external-lifecycle', 'external-installer', 'forbidden', 'MIT'], + ['paperclip-companies', 'paperclipai/companies', 'main', '.', '.', 'optional', 'repository', 'direct-selective', 'explicit-only', null], + ['mattpocock-skills', 'mattpocock/skills', 'main', '.', '.', 'recommended', 'marketplace', 'marketplace-selective', 'allowed', 'MIT'], + ['composio-awesome-claude-skills', 'ComposioHQ/awesome-claude-skills', 'master', '.', '.', 'optional', 'repository', 'search-only', 'forbidden', null], + ['hermes-core', 'NousResearch/hermes-agent', 'main', 'skills', 'skills', 'recommended', 'subtree', 'direct-selective', 'allowed', 'MIT'], + ['hermes-optional', 'NousResearch/hermes-agent', 'main', 'optional-skills', 'optional-skills', 'optional', 'subtree', 'direct-selective', 'explicit-only', 'MIT'], + ['anthropic-skills', 'anthropics/skills', 'main', 'skills', '.', 'recommended', 'marketplace', 'marketplace-selective', 'allowed', null], + ['addyosmani-agent-skills', 'addyosmani/agent-skills', 'main', 'skills', 'skills', 'recommended', 'subtree', 'direct-selective', 'allowed', 'MIT'], + ['obra-superpowers', 'obra/superpowers', 'main', 'skills', 'skills', 'recommended', 'subtree', 'direct-selective', 'allowed', 'MIT'], + ['context-engineering-skills', 'muratcankoylan/Agent-Skills-for-Context-Engineering', 'main', 'skills', '.', 'recommended', 'repository', 'direct-selective', 'allowed', 'MIT'], + ['elastic-docs-skills', 'elastic/elastic-docs-skills', 'main', 'skills', '.', 'recommended', 'repository', 'direct-selective', 'allowed', 'Apache-2.0'], + ['anthropic-knowledge-work', 'anthropics/knowledge-work-plugins', 'main', '.', '.', 'recommended', 'marketplace', 'marketplace-selective', 'allowed', 'Apache-2.0'], +] as const; + +describe('recommended skill catalog', () => { + it('contains the audited source set in stable order', () => { + expect(RECOMMENDED_SKILL_CATALOG.schemaVersion).toBe(1); + expect(RECOMMENDED_SKILL_CATALOG.label).toBe('Recommended'); + expect( + RECOMMENDED_SKILL_CATALOG.sources.map((source) => [ + source.sourceId, + source.repo, + source.effectiveRef, + source.approvedRoot, + source.installRoot, + source.classification, + source.sourceKind, + source.installPolicy, + source.bulkPolicy, + source.spdxLicense, + ]), + ).toEqual(EXPECTED_SOURCES); + expect(validateSkillCatalog(RECOMMENDED_SKILL_CATALOG)).toEqual([]); + for (const source of RECOMMENDED_SKILL_CATALOG.sources) { + expect(source.warnings[0]?.code).toBe('not-security-reviewed'); + expect(source.homepage).toStartWith('http'); + expect(source.author.url).toStartWith('http'); + expect(catalogInstallDescriptor(source).installSource).toBe( + source.installSource, + ); + } + }); + + it('keeps the two Hermes identities and boundaries distinct', () => { + const core = matchCatalogSource( + 'nousresearch/HERMES-agent', + 'skills/research/SKILL.md', + ); + const optional = matchCatalogSource( + 'NousResearch/hermes-agent', + 'optional-skills/browser/SKILL.md', + ); + expect(core?.sourceId).toBe('hermes-core'); + expect(optional?.sourceId).toBe('hermes-optional'); + expect( + catalogSourceIdentity({ + catalog: 'recommended', + sourceId: core?.sourceId ?? '', + effectiveRef: core?.effectiveRef ?? '', + approvedRoot: core?.approvedRoot ?? '.', + }), + ).toBe('recommended:hermes-core@main#skills'); + expect( + catalogSourceIdentity({ + catalog: 'recommended', + sourceId: optional?.sourceId ?? '', + effectiveRef: optional?.effectiveRef ?? '', + approvedRoot: optional?.approvedRoot ?? '.', + }), + ).toBe('recommended:hermes-optional@main#optional-skills'); + + for (const path of [ + 'optional-skills-old/x/SKILL.md', + 'skills-old/x/SKILL.md', + 'docs/x/SKILL.md', + ]) { + expect(matchCatalogSource('NousResearch/hermes-agent', path)).toBeUndefined(); + } + expect(pathWithinCatalogRoot('skills/x/SKILL.md', 'skills')).toBe(true); + expect(pathWithinCatalogRoot('Skills/x/SKILL.md', 'skills')).toBe(false); + }); + + it('rejects malformed Git paths before matching', () => { + for (const path of [ + '/skills/x/SKILL.md', + 'skills/../x/SKILL.md', + 'skills\\x\\SKILL.md', + 'skills/x/skill.md', + 'skills//x/SKILL.md', + ]) { + expect(isCatalogSkillPath(path)).toBe(false); + expect(matchCatalogSource('NousResearch/hermes-agent', path)).toBeUndefined(); + } + }); + + it('classifies Paperclip, Composio, and gstack honestly', () => { + const paperclip = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'paperclip-companies', + ); + const composio = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'composio-awesome-claude-skills', + ); + const gstack = RECOMMENDED_SKILL_CATALOG.sources.find( + (source) => source.sourceId === 'gstack', + ); + expect(paperclip?.installableSubpath).toBe('skills'); + expect(paperclip?.warnings.map((warning) => warning.code)).toContain( + 'license-ambiguous', + ); + expect(composio?.installPolicy).toBe('search-only'); + expect(gstack?.installPolicy).toBe('external-installer'); + }); +}); diff --git a/tests/unit/core/skill-search.test.ts b/tests/unit/core/skill-search.test.ts index e48e6d4..e5ffb72 100644 --- a/tests/unit/core/skill-search.test.ts +++ b/tests/unit/core/skill-search.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { SkillSearchError, + buildCatalogSearchQueries, buildSearchQueries, couldBeOwner, qualifiedName, @@ -8,6 +9,7 @@ import { searchSkills, validateSkillSearchArgs, } from '../../../src/core/skill-search.js'; +import { RECOMMENDED_SKILL_CATALOG } from '../../../src/core/skill-catalog.js'; /** * Helper: build a fake `fetch` that dispatches per Code Search query. @@ -876,3 +878,295 @@ describe('searchSkills 401 error', () => { } }); }); + +interface CatalogSearchFixtureItem { + path: string; + sha: string; + repository: { full_name: string; description?: string }; +} + +function makeCatalogFetch( + search: (query: string, page: number) => { + items: CatalogSearchFixtureItem[]; + total?: number; + incomplete?: boolean; + status?: number; + }, + options: { mismatchedRepo?: string; calls?: string[] } = {}, +): typeof fetch { + const sourceByRepo = new Map( + RECOMMENDED_SKILL_CATALOG.sources.map((source) => [ + source.repo.toLowerCase(), + source, + ]), + ); + return (async (input: string | URL | Request) => { + const url = new URL(String(input)); + options.calls?.push(url.toString()); + if (url.pathname === '/search/code') { + const response = search( + url.searchParams.get('q') ?? '', + Number(url.searchParams.get('page') ?? '1'), + ); + return { + ok: !response.status || response.status < 400, + status: response.status ?? 200, + json: async () => ({ + total_count: response.total ?? response.items.length, + incomplete_results: response.incomplete ?? false, + items: response.items, + ...(response.status ? { message: 'fixture failure' } : {}), + }), + }; + } + const refMatch = url.pathname.match( + /^\/repos\/([^/]+\/[^/]+)\/git\/ref\/heads\/([^/]+)$/, + ); + if (refMatch?.[1]) { + return { + ok: true, + status: 200, + json: async () => ({ object: { sha: `head-${refMatch[1]}` } }), + }; + } + if (url.pathname.includes('/git/blobs/')) { + return { + ok: true, + status: 200, + json: async () => ({ content: '# no frontmatter', encoding: 'utf-8' }), + }; + } + const repoMatch = url.pathname.match(/^\/repos\/([^/]+\/[^/]+)$/); + const repo = repoMatch?.[1]; + const source = repo ? sourceByRepo.get(repo.toLowerCase()) : undefined; + if (repo && source) { + return { + ok: true, + status: 200, + json: async () => ({ + full_name: source.repo, + default_branch: + source.repo.toLowerCase() === options.mismatchedRepo?.toLowerCase() + ? 'unexpected' + : source.effectiveRef, + stargazers_count: 0, + }), + }; + } + throw new Error(`Unexpected catalog fixture URL: ${url}`); + }) as unknown as typeof fetch; +} + +describe('catalog query construction', () => { + it('batches only exact catalog repositories under the query ceiling', () => { + const queries = buildCatalogSearchQueries('build worker'); + const expectedRepos = new Set( + RECOMMENDED_SKILL_CATALOG.sources.map((source) => + source.repo.toLowerCase(), + ), + ); + for (const query of queries) { + expect(query.q.length).toBeLessThanOrEqual(240); + expect(query.q).not.toContain('user:'); + expect(query.q).not.toContain(' OR '); + expect( + [...query.q.matchAll(/repo:([^\s]+)/g)].map((match) => match[1]), + ).toEqual(query.repositories); + expect(query.repositories.map((repo) => repo.toLowerCase()).every((repo) => expectedRepos.has(repo))).toBe(true); + } + const primaryRepos = queries + .filter((query) => query.required) + .flatMap((query) => query.repositories.map((repo) => repo.toLowerCase())); + expect(new Set(primaryRepos)).toEqual(expectedRepos); + expect(primaryRepos.filter((repo) => repo === 'nousresearch/hermes-agent')).toHaveLength(1); + }); +}); + +describe('catalog search boundaries', () => { + it('rejects unknown catalogs and owner conflicts before token resolution', async () => { + let tokenCalls = 0; + const tokenResolver = async () => { + tokenCalls += 1; + return undefined; + }; + await expect( + searchSkills( + 'docs', + { catalog: 'unknown' as 'recommended' }, + { tokenResolver }, + ), + ).rejects.toThrow('Unknown skill catalog "unknown"'); + await expect( + searchSkills( + 'docs', + { catalog: 'recommended', owner: 'anthropics' }, + { tokenResolver }, + ), + ).rejects.toThrow('--catalog and --owner cannot be used together'); + expect(tokenCalls).toBe(0); + }); + + it('preflights each repository once while preserving both Hermes identities', async () => { + const calls: string[] = []; + const fetch = makeCatalogFetch( + () => ({ + items: [ + { + path: 'skills/browser/SKILL.md', + sha: 'core', + repository: { full_name: 'NousResearch/hermes-agent' }, + }, + { + path: 'optional-skills/browser/SKILL.md', + sha: 'optional', + repository: { full_name: 'NousResearch/hermes-agent' }, + }, + ], + }), + { calls }, + ); + const result = await searchSkills( + 'browser', + { catalog: 'recommended', limit: 20 }, + { fetch, logger: silentLogger, tokenResolver: async () => undefined }, + ); + expect(result.items.map((item) => item.catalog?.identity)).toEqual([ + 'recommended:hermes-core@main#skills', + 'recommended:hermes-optional@main#optional-skills', + ]); + expect( + calls.filter( + (call) => + new URL(call).pathname === '/repos/NousResearch/hermes-agent', + ), + ).toHaveLength(2); + expect(result.items[0]?.installSource).toBe( + 'NousResearch/hermes-agent@main/skills', + ); + expect(result.items[1]?.installSource).toBe( + 'NousResearch/hermes-agent@main/optional-skills', + ); + }); + + it('fails default-ref drift before any code search and never falls back', async () => { + const calls: string[] = []; + const fetch = makeCatalogFetch(() => ({ items: [] }), { + mismatchedRepo: 'NousResearch/hermes-agent', + calls, + }); + await expect( + searchSkills( + 'browser', + { catalog: 'recommended' }, + { fetch, logger: silentLogger, tokenResolver: async () => undefined }, + ), + ).rejects.toThrow('no longer resolves'); + expect(calls.some((call) => new URL(call).pathname === '/search/code')).toBe( + false, + ); + }); + + it('filters forged repositories and segment-prefix paths before processing', async () => { + const invalid = Array.from({ length: 100 }, (_, index) => ({ + path: + index % 2 === 0 + ? `skills-old/browser-${index}/SKILL.md` + : `optional-skills-old/browser-${index}/SKILL.md`, + sha: `invalid-${index}`, + repository: { full_name: 'NousResearch/hermes-agent' }, + })); + const fetch = makeCatalogFetch((query, page) => { + if (query.includes('filename:SKILL.md browser') && page === 2) { + return { + items: [ + { + path: 'skills/browser/SKILL.md', + sha: 'valid-later', + repository: { full_name: 'NousResearch/hermes-agent' }, + }, + { + path: 'skills/browser/SKILL.md', + sha: 'forged', + repository: { full_name: 'attacker/repo' }, + }, + ], + total: 102, + }; + } + return { items: invalid, total: 102 }; + }); + const result = await searchSkills( + 'browser', + { catalog: 'recommended', limit: 40 }, + { fetch, logger: silentLogger, tokenResolver: async () => undefined }, + ); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.path).toBe('skills/browser/SKILL.md'); + expect(result.items[0]?.catalog?.discovery.repositoryHeadSha).toBe( + 'head-NousResearch/hermes-agent', + ); + }); + + it('keeps bounded empty results empty and exposes non-install policies', async () => { + const empty = await searchSkills( + 'nothing', + { catalog: 'recommended' }, + { + fetch: makeCatalogFetch(() => ({ items: [] })), + logger: silentLogger, + tokenResolver: async () => undefined, + }, + ); + expect(empty.items).toEqual([]); + + const policies = await searchSkills( + 'skill', + { catalog: 'recommended', limit: 20 }, + { + fetch: makeCatalogFetch(() => ({ + items: [ + { + path: 'some-skill/SKILL.md', + sha: 'gstack', + repository: { full_name: 'garrytan/gstack' }, + }, + { + path: 'skills/some-skill/SKILL.md', + sha: 'composio', + repository: { + full_name: 'ComposioHQ/awesome-claude-skills', + }, + }, + { + path: 'templates/some-skill/SKILL.md', + sha: 'paperclip-outside', + repository: { full_name: 'paperclipai/companies' }, + }, + { + path: 'skills/company-creator/SKILL.md', + sha: 'paperclip-inside', + repository: { + full_name: 'paperclipai/companies', + description: 'Skill collection', + }, + }, + ], + })), + logger: silentLogger, + tokenResolver: async () => undefined, + }, + ); + const bySha = new Map(policies.items.map((item) => [item.sha, item])); + expect(bySha.get('gstack')?.installation.policy).toBe('external-installer'); + expect(bySha.get('composio')?.installation.policy).toBe('search-only'); + expect(bySha.get('paperclip-outside')?.installation.policy).toBe( + 'search-only', + ); + expect(bySha.get('paperclip-inside')?.installation.policy).toBe( + 'direct-selective', + ); + expect(bySha.get('paperclip-inside')?.installSelector).toBe( + 'company-creator', + ); + }); +}); diff --git a/tests/unit/models/skill-catalog.test.ts b/tests/unit/models/skill-catalog.test.ts new file mode 100644 index 0000000..e5a21c1 --- /dev/null +++ b/tests/unit/models/skill-catalog.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { CatalogInstallDescriptorSchema } from '../../../src/models/skill-catalog.js'; + +const descriptor = { + catalog: 'recommended', + catalogVersion: 1, + sourceId: 'hermes-core', + repo: 'NousResearch/hermes-agent', + effectiveRef: 'main', + approvedRoot: 'skills', + installSource: 'NousResearch/hermes-agent@main/skills', + installRoot: 'skills', + sourceKind: 'subtree', + installPolicy: 'direct-selective', +} as const; + +describe('CatalogInstallDescriptorSchema', () => { + it('round-trips a versioned exact descriptor', () => { + const parsed = CatalogInstallDescriptorSchema.parse(descriptor); + expect(JSON.parse(JSON.stringify(parsed))).toEqual(descriptor); + }); + + it('rejects unknown versions, catalogs, and repository-only identities', () => { + expect( + CatalogInstallDescriptorSchema.safeParse({ + ...descriptor, + catalogVersion: 2, + }).success, + ).toBe(false); + expect( + CatalogInstallDescriptorSchema.safeParse({ + ...descriptor, + catalog: 'unknown', + }).success, + ).toBe(false); + expect( + CatalogInstallDescriptorSchema.safeParse({ + ...descriptor, + sourceId: '', + }).success, + ).toBe(false); + }); +}); diff --git a/tests/unit/models/sync-state-schema.test.ts b/tests/unit/models/sync-state-schema.test.ts index 397f4b2..0af5e08 100644 --- a/tests/unit/models/sync-state-schema.test.ts +++ b/tests/unit/models/sync-state-schema.test.ts @@ -32,3 +32,41 @@ describe('SyncStateSchema vscode workspace fields', () => { } }); }); + +test('accepts catalog install provenance and legacy repo-only records', () => { + const state = SyncStateSchema.parse({ + version: 1, + lastSync: '2026-08-24T00:00:00.000Z', + files: {}, + sources: { + 'recommended:hermes-core@main#skills': { + pluginSpec: 'NousResearch/hermes-agent@main/skills', + resolvedRef: 'main', + resolvedSha: 'abc123', + resolvedRoot: 'skills', + catalogSource: { + catalog: 'recommended', + catalogVersion: 1, + sourceId: 'hermes-core', + repo: 'NousResearch/hermes-agent', + effectiveRef: 'main', + approvedRoot: 'skills', + installSource: 'NousResearch/hermes-agent@main/skills', + installRoot: 'skills', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + }, + }, + 'legacy/repo': { + pluginSpec: 'legacy/repo', + resolvedRef: 'main', + resolvedSha: 'def456', + }, + }, + }); + expect(state.version).toBe(1); + expect( + state.sources?.['recommended:hermes-core@main#skills']?.resolvedRoot, + ).toBe('skills'); + expect(state.sources?.['legacy/repo']?.catalogSource).toBeUndefined(); +}); diff --git a/tests/unit/models/workspace-config.test.ts b/tests/unit/models/workspace-config.test.ts index ff27e6d..c608bd3 100644 --- a/tests/unit/models/workspace-config.test.ts +++ b/tests/unit/models/workspace-config.test.ts @@ -51,6 +51,38 @@ describe('WorkspaceConfigSchema', () => { }); + it('accepts a catalog descriptor without changing schema version 2', () => { + const parsed = WorkspaceConfigSchema.parse({ + version: 2, + repositories: [], + clients: ['universal'], + plugins: [ + { + source: 'NousResearch/hermes-agent@main/skills', + skills: ['research/llm-wiki'], + catalogSource: { + catalog: 'recommended', + catalogVersion: 1, + sourceId: 'hermes-core', + repo: 'NousResearch/hermes-agent', + effectiveRef: 'main', + approvedRoot: 'skills', + installSource: 'NousResearch/hermes-agent@main/skills', + installRoot: 'skills', + sourceKind: 'subtree', + installPolicy: 'direct-selective', + }, + }, + ], + }); + expect(parsed.version).toBe(2); + expect( + typeof parsed.plugins[0] === 'string' + ? undefined + : parsed.plugins[0]?.catalogSource?.sourceId, + ).toBe('hermes-core'); + }); + describe('ClientTypeSchema', () => { it('should accept all valid client types', () => { const validClients = [ From d5a02058a9dd229814ecb769c3983d954eace168 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 11:04:57 +1000 Subject: [PATCH 03/11] docs(skills): document recommended catalog --- CHANGELOG.md | 11 ++++ README.md | 19 +++++++ .../content/docs/docs/guides/marketplaces.mdx | 30 +++++++++++ docs/src/content/docs/docs/reference/cli.mdx | 54 +++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92ea44..e2db851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ **Migration**: Re-run `allagents mcp update` or `allagents update` after upgrading so synced client configs are regenerated with `mcp proxy`. +### Added + +- **Recommended skill catalog**: Added `allagents skill search --catalog recommended`, a hard discovery boundary over curated repository, subtree, marketplace-backed, search-only, and external-lifecycle sources. The catalog includes gstack, Paperclip companies, Matt Pocock skills, Composio awesome skills, distinct Hermes core and optional roots, Anthropic skills and knowledge-work plugins, Addy Osmani skills, obra superpowers, context-engineering skills, and Elastic docs skills. +- Catalog results expose classification, install policy, warnings, source metadata, exact install source and selector, and separate discovery provenance. **Recommended** is a discovery label, not a security, trust, quality, or license guarantee. +- Interactive catalog search supports selective project- or user-scoped installation. Project installs retain exact repository/ref/root/source identity and the selected subset in workspace and sync state; optional sources require confirmation, while search-only and external-installer sources remain non-installable. +- Added authenticated, read-only catalog health validation in CI for repository/ref/root drift, skill presence, local authoritative marketplace paths, and source identity. The check never mutates, vendors, or automatically updates catalog entries. + +### Fixed + +- Clean Git clones now pass Git LFS filter overrides as supported `simple-git` configuration arguments, so project-scoped installs succeed without a pre-seeded cache while still preventing LFS smudge downloads. + ## [1.0.0] - 2026-03-13 ### Breaking Changes diff --git a/README.md b/README.md index f64ae90..182dc99 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ clients: | `allagents plugin uninstall ` | Remove a plugin | | `allagents plugin list` | List available plugins | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | +| `allagents skill search [--catalog recommended]` | Search globally or within the built-in Recommended catalog | | `allagents skill list` | List skills and status | | `allagents mcp add ` | Add an MCP server and sync to clients | | `allagents mcp proxy ` | Bridge a remote HTTP MCP server to local stdio | @@ -104,6 +105,24 @@ clients: See the [full CLI reference](https://allagents.dev/docs/reference/cli/) for all options. +## Recommended Skill Catalog + +`allagents skill search ` keeps the existing global GitHub search. Add +`--catalog recommended` to search only the built-in, versioned Recommended +catalog: + +```bash +allagents skill search testing --catalog recommended +``` + +The catalog is a hard discovery boundary: catalog failures and empty results do +not fall back to global search. **Recommended** is a discovery label, not a +security, trust, quality, or license guarantee; review each result's source, +classification, policy, warnings, and upstream content. In an interactive +terminal, installable results can be selected for project scope (including a +new disposable workspace) or user scope. Exact repository, ref, catalog root, +selected skill subset, and provenance are retained for later syncs. + ## Supported Clients **23 AI coding assistants** across two tiers: diff --git a/docs/src/content/docs/docs/guides/marketplaces.mdx b/docs/src/content/docs/docs/guides/marketplaces.mdx index b697aa0..19cdfef 100644 --- a/docs/src/content/docs/docs/guides/marketplaces.mdx +++ b/docs/src/content/docs/docs/guides/marketplaces.mdx @@ -26,6 +26,36 @@ allagents plugin marketplace add owner/repo --branch feat/v2 --name repo-v2 The bare repo name (e.g., `repo`) is reserved for the default branch. Updates automatically pull the correct branch. +## Recommended Catalog vs. Marketplaces + +The built-in Recommended skill catalog is a static discovery index, not a +`MarketplaceRegistry` entry. Search it with: + +```bash +allagents skill search --catalog recommended +``` + +The catalog includes heterogeneous source types: direct repositories, +subtrees, valid marketplace-backed distributions, search-only repositories, +and projects with an external installation lifecycle. Catalog inclusion does +not make a source a marketplace and does not imply that it is one-click +installable. Search-only sources cannot be selected. External-lifecycle sources +show upstream guidance; AllAgents never executes their installer. + +For a marketplace-backed catalog result, AllAgents validates the authoritative +upstream manifest and registers it only when the user selects an installable +skill. Project selection writes to the project-scoped registry; it does not +mutate the user's global marketplace registry. The catalog's exact repository, +default-branch ref, root, source identity, and selected skill paths are retained +for sync. Sources with broken or ambiguous manifests are represented honestly +as search-only rather than repaired with a generated manifest. + +**Recommended** means curated for discovery only. It is not a security audit, +trust endorsement, quality guarantee, or license assertion. Review upstream +code and the per-result classification, policy, warnings, author, homepage, and +license metadata before installation. Catalog health validation detects source +drift; it does not vendor, rewrite, or automatically update upstream content. + ## Creating a Marketplace ``` diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index eab543f..b1a50e7 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -135,6 +135,7 @@ allagents skill list [--scope ] allagents skill remove [--plugin ] [--scope ] allagents skill add [--from ] [--plugin ] [--scope ] allagents skill update [skill...] [--scope ] [--yes] +allagents skill search [--catalog recommended] [--owner ] [--page ] [--limit ] ``` ### plugin list @@ -258,6 +259,59 @@ allagents skill add brainstorming --plugin superpowers After enabling, the skill is removed from `disabledSkills` and sync is run to restore it. +### skill search + +Search public `SKILL.md` files. The default remains global GitHub Code Search: + +```bash +allagents skill search terraform +allagents skill search terraform --owner hashicorp +``` + +Use the built-in Recommended catalog when discovery must stay within its exact +repository and subtree boundaries: + +```bash +allagents skill search testing --catalog recommended +allagents --json skill search testing --catalog recommended +``` + +| Flag | Description | +|------|-------------| +| `--catalog recommended` | Search only the versioned built-in catalog. Unknown names are rejected. | +| `--owner ` | Limit global search to one GitHub owner. Mutually exclusive with `--catalog`. | +| `--page ` | 1-indexed result page (default: `1`). | +| `--limit ` | Results per page, from 1 to 100 (default: `15`). | + +Unknown catalogs and the `--catalog`/`--owner` conflict exit with status 2. +Catalog mode is a hard boundary: empty results, source-health failures, GitHub +query failures, and manifest failures never fall back to global results. +Repository constraints are applied to every GitHub query and results are also +filtered against catalog roots before ranking and pagination. + +**Recommended** is a discovery label. It is not a security review, trust or +quality guarantee, or license assertion. Results expose their classification +(`recommended`, `optional`, or `external-lifecycle`), source kind, install +policy, author and homepage metadata, SPDX value when known, and warnings. +Optional sources require explicit confirmation. `search-only` results remain +visible but cannot be installed; `external-installer` results point to the +upstream lifecycle and AllAgents does not run it. + +In a TTY, select one or more installable results and choose project or user +scope. Project installation works in a newly initialized disposable folder. +Only the selected skill paths are enabled. Marketplace-backed selections are +checked against their authoritative manifest; direct repositories and subtrees +use the exact catalog repository, default-branch ref, and root. Hermes core +(`skills`) and Hermes optional (`optional-skills`) are separate source +identities and stay separate through installation and sync. + +Discovery provenance (the GitHub result path, blob SHA, repository-head SHA, +query batch, and catalog version) explains how a result was found. Install +provenance (repository, ref, root, source ID, and selected paths) controls later +syncs and is persisted in `workspace.yaml` and `.allagents/sync-state.json`. +These are deliberately distinct: a search blob SHA is evidence, not an +install/update pin. + ### skill update Check installed remote skills for upstream changes, update surviving skills, and safely reconcile skills that were deleted upstream. From 8a455bfa4011d8c88b4ae3afcb9f0dcef6e25978 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 11:28:44 +1000 Subject: [PATCH 04/11] fix(skills): preserve catalog boundaries and provenance --- .claude/plans/recommended-skill-catalog.md | 779 ------------------- CHANGELOG.md | 2 +- docs/src/content/docs/docs/reference/cli.mdx | 10 +- src/cli/commands/plugin-skills.ts | 12 +- src/core/__tests__/plugin-seed-cache.test.ts | 27 + src/core/plugin.ts | 25 +- src/core/skill-search.ts | 24 +- src/core/sync.ts | 2 + tests/unit/cli/skill-search-install.test.ts | 17 + tests/unit/cli/skill-search-summary.test.ts | 45 +- tests/unit/core/skill-search.test.ts | 2 +- tests/unit/core/sync-user.test.ts | 61 ++ 12 files changed, 201 insertions(+), 805 deletions(-) delete mode 100644 .claude/plans/recommended-skill-catalog.md diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md deleted file mode 100644 index 87a9702..0000000 --- a/.claude/plans/recommended-skill-catalog.md +++ /dev/null @@ -1,779 +0,0 @@ -# Recommended Skill Catalog Implementation Plan - -**Status:** Draft PR plan only -**Date:** 2026-08-24 -**Implementation target:** A later feature branch based on then-current `origin/main` - -## Decision - -Add one built-in, typed catalog named `recommended` without changing the default search scope: - -```text -allagents skill search --catalog recommended -searchSkills(query, { catalog: 'recommended' }) -``` - -Without `--catalog`, `skill search` remains global GitHub Code Search. `--catalog` and `--owner` are mutually exclusive and fail validation with exit code 2. When `--catalog recommended` is present, the named catalog is a hard boundary: an empty result, source-health failure, query-batch failure, or manifest failure never falls back to global GitHub results. The catalog is not a marketplace registry; it describes repositories, subtrees, valid marketplace-backed sources, search-only sources, and external-lifecycle distributions without registering any of them in `MarketplaceRegistry`. - -The one user-facing catalog label is exactly **Recommended**. This is a discovery label, not a security, trust, or license assertion. UI, JSON documentation, and release notes must never call catalog entries “verified” or “safe.” Every catalog result instead carries explicit source classification, install policy, metadata, and warnings. Catalog membership must never claim that a source is security-reviewed, license-approved, dependency-complete, compatible with every client, or safe to bulk-install. - -## Problem and observed repository state - -`src/core/skill-search.ts` currently builds up to four global `filename:SKILL.md` GitHub Code Search queries, merges them by priority, filters hidden paths, ranks, enriches, deduplicates, and paginates. `SkillSearchOptions` has only `owner`, `page`, and `limit`. The CLI in `src/cli/commands/plugin-skills.ts` exposes the same three flags. - -The interactive CLI currently loses source boundaries in two places: - -1. `collectSelectedSkillSearchRepos()` reduces selected hits to `item.repo`. -2. `installFromSearch()` passes that repository root to `addPlugin()` or `addUserPlugin()`. - -That is incorrect for two logical sources in one repository, such as `NousResearch/hermes-agent/skills` and `NousResearch/hermes-agent/optional-skills`. It also makes a selected skill-directory URL unsafe as an installation root: `src/core/transform.ts::copySkills()` copies only `SKILL.md` for a root-level standalone skill, while a repository or common subtree plus a qualified allowlist copies the complete selected skill directory recursively, including `references/`, scripts, and other sibling assets inside that directory. - -Subpath identity is also currently collapsed: - -- `src/core/workspace-modify.ts::resolveGitHubIdentity()` returns only lower-cased `owner/repo`. -- `findPluginEntryByGitHubIdentity()` and `canonicalizeGitHubPluginSource()` can promote two subtrees to their repository root. -- `src/cli/commands/plugin-skills.ts::recordSourceProvenance()` keys sync state by repository only. -- `src/core/sync.ts::buildSourcesProvenance()` also writes `sources[owner/repo]`. - -Finally, clean clones currently fail before discovery. `src/core/git.ts::createGit()` supplies `filter.lfs.*` config values to `simple-git@3.30.0` but does not set `allowUnsafeFilter: true`. Clean-cache installs of `mattpocock/skills` and both Hermes subtrees hit simple-git's unsafe-filter rejection. Cache-seeded runs subsequently copied 36, 82, and 117 skills respectively with zero copy failures; those runs do not prove clean installability. - -## Goals - -1. Restrict search to one curated, static, versioned, typed source set when `catalog: 'recommended'` is requested. -2. Enforce canonical repository, effective ref, and exact segment-boundary subtree constraints in application code before ranking or pagination, with no global fallback. -3. Keep Hermes core and optional as different catalog identities even though they share one repository clone/cache. -4. Carry an exact install descriptor from catalog entry to selection, project workspace configuration, installation, and sync provenance. -5. Preserve complete skill directories by installing repository/common-subtree roots with qualified allowlists, never selected skill directories. -6. Respect marketplace manifests only for actual marketplace sources; never coerce plain repositories or subtrees into `MarketplaceRegistry`. -7. Surface category, homepage, author, SPDX metadata, suitability, license, lifecycle, dependency, and bulk-install warnings without a boolean trust field. -8. Separate read-only search/discovery provenance from actual install provenance. -9. Prove project-scoped installation from clean clones in disposable workspaces. -10. Require PR review, mandatory manifest validation, and read-only catalog health checks for catalog changes. - -## Non-goals - -- Replacing global GitHub search or changing its no-option default. -- Falling back to global GitHub when a named-catalog search cannot return a valid bounded result. -- Accepting arbitrary catalog refs in MVP. GitHub Code Search searches the repository default branch; non-default/tag/SHA refs remain out of scope until catalog search becomes ref-aware. -- Adding a user-selected catalog default to `workspace.yaml`; only provenance for an actual catalog install is persisted. -- Turning the catalog into a remote service, user-editable registry, marketplace registry, security scanner, vendored mirror, or repository mirror. -- Creating split generated/manual registries. There is one typed catalog object and one schema version. -- Adding a direct-to-main updater, shell-based catalog sync, or any mutation-capable catalog health command. Catalog changes arrive only through reviewed PRs. -- Adding a boolean `trusted`, `verified`, `safe`, or equivalent field. Suitability is represented by classification, install policy, warning codes, and health results. -- Provisioning MCP servers, Rube, API credentials, binaries, platform packages, browsers, or other skill dependencies. -- Declaring license compatibility or conducting legal/security approval. -- Automatically running upstream lifecycle scripts, especially gstack's setup/update flow. -- Bulk-installing optional or experimental sources by default. -- Making the broken nested Composio manifest installable. -- Adding `numman-ali/n-skills` itself as a source without a separate source-suitability decision; this revision incorporates its audited architectural decisions only. - -## Public CLI and API contract - -### CLI - -Add to `src/cli/commands/plugin-skills.ts::searchCmd`: - -```text ---catalog Restrict results to a built-in catalog. Initially: recommended. -``` - -Examples: - -```text -allagents skill search testing --catalog recommended -allagents skill search testing --catalog recommended --page 2 --limit 10 -allagents --json skill search testing --catalog recommended -``` - -Validation is centralized in `validateSkillSearchArgs()`: - -- Unknown catalog: `Unknown skill catalog "". Available catalogs: recommended.` -- Catalog plus owner: `--catalog and --owner cannot be used together.` -- Both are `SkillSearchError` with `kind: 'validation'`; CLI exit code remains 2 and JSON uses the existing failed command envelope. -- Existing query length, page, limit, owner, API, and rate-limit behavior remains unchanged. - -TTY catalog results use the existing multi-select, but: - -- installable results are grouped by exact `installSource`; -- search-only and external-lifecycle results remain visible but are disabled in the picker; -- warnings are included in the hint and repeated once before an optional/experimental install; -- no source is preselected; -- only explicitly selected skills are enabled; -- one source is fetched/configured once and all selected qualified selectors for it are written in one allowlist update; -- all source mutations complete before one project/user sync, rather than syncing once per selected skill. - -Non-TTY output keeps the table format and appends a concise policy marker only when needed, such as `optional`, `search only`, or `external installer`. JSON is authoritative for full warnings. - -The TUI action `src/cli/tui/actions/skills.ts::runSearchOnlineSkills()` continues using global search because it has no catalog selector in this scope. It must use `item.installSource` rather than reconstructing a repository source, preserving compatibility for global results where `installSource === repo`. - -### Core API - -In `src/core/skill-search.ts`: - -```ts -export type SkillCatalogName = 'recommended'; - -export interface SkillSearchOptions { - owner?: string; - catalog?: SkillCatalogName; - page?: number; - limit?: number; -} -``` - -Extend `SkillSearchItem` additively. Replace boolean installability with an explicit policy enum; it describes supported behavior and is not a trust signal: - -```ts -installSource: string; -installSelector: string; -installation: { - policy: 'repository-install' | SkillCatalogInstallPolicy; - reasonCodes: readonly string[]; -}; -catalog?: { - name: SkillCatalogName; - label: 'Recommended'; - version: 1; - identity: string; - sourceId: string; - classification: SkillCatalogClassification; - sourceKind: SkillCatalogSourceKind; - category: SkillCatalogCategory; - homepage: string; - author: SkillCatalogAuthor; - spdxLicense: string | null; - warnings: readonly SkillCatalogWarning[]; - discovery: CatalogDiscoveryProvenance; - installDescriptor: CatalogInstallDescriptor; -}; -``` - -For global search, `installSource` is canonical `owner/repo`, `installSelector` is the existing qualified name, `installation.policy` is `repository-install`, and `catalog` is absent. This preserves existing default search/install behavior while eliminating downstream source reconstruction. - -For catalog search, all catalog fields come from the matched source plus the bounded GitHub response. `SkillSearchResult` retains `query`, `items`, `total`, and `truncated`; no persistent catalog preference is added. - -## One versioned typed catalog and source identity - -Create `src/models/skill-catalog.ts` for data-independent catalog types plus `CatalogInstallDescriptorSchema`, and `src/core/skill-catalog.ts` for the single immutable catalog object, stable source IDs, metadata, lookup helpers, identity construction, path-boundary helpers, and warning text. This keeps `src/models/workspace-config.ts` from importing a core module. Neither file imports `src/core/marketplace.ts`, and there is no generated registry beside the manual catalog object. - -```ts -export type SkillCatalogName = 'recommended'; -export type SkillCatalogClassification = - | 'recommended' - | 'optional' - | 'external-lifecycle'; -export type SkillCatalogSourceKind = - | 'repository' - | 'subtree' - | 'marketplace' - | 'external-lifecycle'; -export type SkillCatalogInstallPolicy = - | 'direct-selective' - | 'marketplace-selective' - | 'search-only' - | 'external-installer'; -export type SkillCatalogBulkPolicy = 'allowed' | 'explicit-only' | 'forbidden'; -export type SkillCatalogCategory = - | 'software-engineering' - | 'business-operations' - | 'integrations' - | 'general-purpose' - | 'agent-engineering' - | 'documentation' - | 'knowledge-work'; - -export interface SkillCatalogAuthor { - name: string; - url: string; -} - -export interface SkillCatalogWarning { - code: - | 'not-security-reviewed' - | 'license-metadata' - | 'license-ambiguous' - | 'external-dependencies' - | 'external-lifecycle' - | 'broken-marketplace' - | 'large-source' - | 'optional-source'; - message: string; -} - -export interface SkillCatalogSource { - sourceId: string; - repo: `${string}/${string}`; - effectiveRef: string; - approvedRoot: '.' | string; - installRoot: '.' | string; - installSource: string; - installableSubpath?: string; - displayName: string; - description: string; - category: SkillCatalogCategory; - homepage: string; - author: SkillCatalogAuthor; - spdxLicense: string | null; - classification: SkillCatalogClassification; - sourceKind: SkillCatalogSourceKind; - installPolicy: SkillCatalogInstallPolicy; - bulkPolicy: SkillCatalogBulkPolicy; - manifestBoundary: 'none' | 'authoritative'; - warnings: readonly SkillCatalogWarning[]; -} - -export interface SkillCatalog { - schemaVersion: 1; - name: 'recommended'; - label: 'Recommended'; - sources: readonly SkillCatalogSource[]; -} -export const RECOMMENDED_SKILL_CATALOG: SkillCatalog = Object.freeze({ - schemaVersion: 1, - name: 'recommended', - label: 'Recommended', - sources: Object.freeze(RECOMMENDED_SOURCE_ENTRIES), -}); -``` - -`RECOMMENDED_SOURCE_ENTRIES` is a private, same-file typed constant populated exactly from the source and metadata tables below. It is not a second registry and is not emitted by a generator. - -Stable `sourceId` values are borrowed as a design rule from the `numman-ali/n-skills` audit: kebab-case, human-assigned, never derived from display text, never reused, and unchanged when metadata changes. There is one catalog schema version. Increment it only for a catalog schema/semantic change, not ordinary source metadata edits. - -Catalog identity is never repository identity. Define one constructor used by search, selection, config, and sync state: - -```ts -catalogSourceIdentity({ - catalog: 'recommended', - sourceId, - effectiveRef, - approvedRoot, -}): string -``` - -Its canonical serialized form is `recommended:@#`, using `.` for repository root. These four fields are mandatory and are the identity components. `repo` remains an explicit validated descriptor field but is never the sole deduplication, install, or provenance key. - -`effectiveRef` is mandatory and, in MVP, must equal the repository's current default branch because GitHub Code Search is not arbitrary-ref-aware. `approvedRoot` is the hard search boundary; `.` means repository root. `installableSubpath` may narrow installation inside that boundary. `installSource` must encode the same repository, effective ref, and installation root, for example `NousResearch/hermes-agent@main/optional-skills`. - -Invariants checked by unit and manifest validation: - -- Stable source IDs and full catalog identities are unique; Hermes may share `repo` but never identity. -- Paths are normalized POSIX-relative segment paths with no leading/trailing slash, empty segment, `.` segment (except root sentinel), or `..` segment. -- Each `effectiveRef` equals the upstream default branch observed by the read-only health check; arbitrary refs fail validation in MVP. -- `installSource` parses back to the same repo/ref and `installRoot`. -- `external-installer` and `search-only` require `bulkPolicy: 'forbidden'`; optional entries require `explicit-only` or `forbidden`. -- Marketplace entries require `manifestBoundary: 'authoritative'`; plain repository/subtree entries require `none`. -- `spdxLicense: null` means no single catalog-asserted SPDX identifier, not “unlicensed.” -- Every entry includes the common `not-security-reviewed` warning through a shared helper, not duplicated strings. - -`approvedRoot` is the search boundary. `installRoot` is the exact root cloned/resolved for installation and may be broader only when asset preservation or marketplace semantics require it. `installableSubpath` may narrow install eligibility inside the approved search boundary. All matching uses path segments, never string-prefix approximation. - -## Initial source list and classifications - -The `recommended` catalog is the name of the built-in catalog, not a claim that every member is generally recommended. It contains all entries below so users can search a known set while seeing each source's actual classification. - -In this plan, `optional` is the optional/experimental classification requested by the product contract. The final six rows are the useful candidates sourced from EntityProcess's `ai-research-wiki` and rechecked against their upstream repositories: Anthropic Skills, Addy Osmani Agent Skills, Superpowers, Context Engineering Skills, Elastic Docs Skills, and Anthropic Knowledge Work Plugins. - -| Source ID | Repository | Effective ref | Approved root | Classification | Kind / install / bulk policy | Install source and audited facts | -|---|---|---|---|---|---|---| -| `gstack` | `garrytan/gstack` | `main` | `.` | `external-lifecycle` | `external-lifecycle` / `external-installer` / `forbidden` | `garrytan/gstack@main`; no AllAgents install action. 64 `SKILL.md`; MIT. Full distribution requires checkout, build/setup, host-specific generation, and updates. Generic copying is not a substitute. | -| `paperclip-companies` | `paperclipai/companies` | `main` | `.` | `optional` | `repository` / `direct-selective` / `explicit-only` | `paperclipai/companies@main`, installable only under `skills/`. Root-plus-qualified-allowlist preserves assets. 523 `SKILL.md` in the tree; supported root discovery exposes `company-creator` and `readme-updater`; all other hits are search-only. Licensing is ambiguous. | -| `mattpocock-skills` | `mattpocock/skills` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `mattpocock/skills@main`; valid root marketplace manifest is authoritative. 36 recursive discoveries; MIT. | -| `composio-awesome-claude-skills` | `ComposioHQ/awesome-claude-skills` | `master` | `.` | `optional` | `repository` / `search-only` / `forbidden` | `ComposioHQ/awesome-claude-skills@master`; no generic/marketplace install. 864 discoverable directories; nested manifest has 107 missing/misresolved source paths; most skills require Rube/MCP; licensing is incomplete. | -| `hermes-core` | `NousResearch/hermes-agent` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `NousResearch/hermes-agent@main/skills`; 82 skills; MIT. Exact boundary rejects optional and unrelated paths. | -| `hermes-optional` | `NousResearch/hermes-agent` | `main` | `optional-skills` | `optional` | `subtree` / `direct-selective` / `explicit-only` | `NousResearch/hermes-agent@main/optional-skills`; 117 skills; MIT; many platform/API/binary/service requirements. Never default bulk-install. | -| `anthropic-skills` | `anthropics/skills` | `main` | `skills` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/skills@main`, installed from repository root through its authoritative manifest. 20 audited skills; per-skill/repository license metadata caveat. | -| `addyosmani-agent-skills` | `addyosmani/agent-skills` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `addyosmani/agent-skills@main/skills`; 24 audited skills; MIT. | -| `obra-superpowers` | `obra/superpowers` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `obra/superpowers@main/skills`; 14 audited skills; MIT. | -| `context-engineering-skills` | `muratcankoylan/Agent-Skills-for-Context-Engineering` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `muratcankoylan/Agent-Skills-for-Context-Engineering@main`, repository root plus qualified allowlist; 23 repo-wide audited skills; MIT. | -| `elastic-docs-skills` | `elastic/elastic-docs-skills` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `elastic/elastic-docs-skills@main`, repository root plus qualified allowlist; 20 repo-wide audited skills; Apache-2.0. | -| `anthropic-knowledge-work` | `anthropics/knowledge-work-plugins` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/knowledge-work-plugins@main`; local root marketplace plugins only. 212 audited skills; Apache-2.0. Unsupported remote URL/git-subdir entries fail closed. | - -Metadata is mandatory catalog data, not runtime inference: - -| Source ID | Category | Author | Author URL | Homepage | SPDX | -|---|---|---|---|---|---| -| `gstack` | `software-engineering` | Garry Tan | `https://github.com/garrytan` | `https://github.com/garrytan/gstack` | `MIT` | -| `paperclip-companies` | `business-operations` | Paperclip AI | `https://github.com/paperclipai` | `https://github.com/paperclipai/companies` | `null` | -| `mattpocock-skills` | `software-engineering` | Matt Pocock | `https://github.com/mattpocock` | `https://aihero.dev/skills` | `MIT` | -| `composio-awesome-claude-skills` | `integrations` | Composio | `https://github.com/ComposioHQ` | `https://github.com/ComposioHQ/awesome-claude-skills` | `null` | -| `hermes-core` | `general-purpose` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | -| `hermes-optional` | `integrations` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | -| `anthropic-skills` | `general-purpose` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/skills` | `null` | -| `addyosmani-agent-skills` | `software-engineering` | Addy Osmani | `https://github.com/addyosmani` | `https://skills.addy.ie` | `MIT` | -| `obra-superpowers` | `software-engineering` | obra | `https://github.com/obra` | `https://github.com/obra/superpowers` | `MIT` | -| `context-engineering-skills` | `agent-engineering` | Murat Can Koylan | `https://github.com/muratcankoylan` | `https://github.com/muratcankoylan/Agent-Skills-for-Context-Engineering` | `MIT` | -| `elastic-docs-skills` | `documentation` | Elastic | `https://github.com/elastic` | `https://elastic.github.io/elastic-docs-skills/` | `Apache-2.0` | -| `anthropic-knowledge-work` | `knowledge-work` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/knowledge-work-plugins` | `Apache-2.0` | - -`null` SPDX values deliberately preserve licensing ambiguity or mixed/per-skill metadata. The order above is stable catalog order and the final relevance tie-breaker. Counts are audit evidence and drift sentinels, not runtime acceptance limits; upstream additions/removals do not automatically change classification or policy. - -## Catalog search algorithm - -### Query construction - -Keep `buildSearchQueries(query, owner)` for global search. Add catalog-specific construction rather than overloading owner semantics: - -1. Resolve `getSkillCatalog('recommended')` and deduplicate canonical repositories case-insensitively for network efficiency only. This does not deduplicate catalog identities; Hermes remains two sources. -2. Read-only preflight each unique repository through GitHub's repository/ref API. Require its default branch to equal every entry's `effectiveRef` and resolve the current head SHA. A mismatch or unavailable ref fails the named-catalog search with no global fallback. This constraint is why arbitrary refs are out of MVP. -3. Build the same semantic variants used today: path term, hyphenated content when applicable, and primary content. Catalog search omits query-as-owner because repository qualifiers define scope. -4. Batch repository qualifiers deterministically so each unencoded GitHub query stays below a documented 240-character internal ceiling. Each batch is emitted as a parenthesized OR group of exact `repo:owner/name` qualifiers. Never use `user:` or a broad owner qualifier for catalog mode. -5. Fetch page 1 at 100 results for advisory variants. For each primary batch, fetch enough 100-result pages to cover `page * limit * 3`, capped by GitHub's existing 1,000-result ceiling. -6. Dispatch independent query batches with `Promise.allSettled`. Any required repository/ref preflight or primary batch failure fails catalog search. Advisory failures may warn and merge survivors only when all catalog boundaries remain intact; none may trigger global search. - -Unit tests must assert the literal qualifier set, deterministic batching, query-length ceiling, omission of non-catalog repositories, one network repository qualifier for Hermes, two Hermes catalog identities, default-ref enforcement, and no fallback calls. - -### Application-side source enforcement - -GitHub query qualifiers are only a first filter. Before `rankByRelevance()`, `truncateForProcessing()`, enrichment, name deduplication, or pagination: - -1. Canonicalize response `repository.full_name` to lower case and require an exact catalog repository match. -2. Bind the response to the preflighted default branch/head and the entry's `effectiveRef`; never relabel a default-branch result as an arbitrary ref. -3. Normalize API paths as POSIX relative paths; reject empty paths, absolute paths, `.`/`..` segments, backslashes, and any basename other than `SKILL.md`. -4. Match `approvedRoot` by path segments: - - `.` matches any valid path in that repository; - - `skills` matches `skills/<...>/SKILL.md` and never `skills-old/...`; - - `optional-skills` matches `optional-skills/<...>/SKILL.md` and never `optional-skills-old/...`; - - exact boundary checks are case-sensitive because Git paths are case-sensitive. -5. If multiple entries in one repository match, select the entry with the longest matching `approvedRoot`; use stable catalog order only as a final tie-breaker. -6. Derive `installSelector` relative to `installRoot`. For repository roots with a `skills/` directory, strip the leading `skills/`; for subtree roots, strip the root. Preserve intermediate namespace segments so duplicate leaf names remain distinguishable. -7. Derive `installation.policy`, never a boolean: - - `external-installer` and `search-only` remain those exact policies; - - Paperclip hits outside exact `skills/`, or not resolvable by root discovery, become `search-only` with reason codes; - - marketplace hits remain `marketplace-selective`, but the picker enables them only after manifest preflight resolves the selected path to a valid local plugin; - - direct subtree/repository hits inside their installation boundary remain `direct-selective`. -8. Attach catalog identity, metadata, warning codes, discovery provenance, and exact install descriptor, then continue the existing hidden-directory filter, relevance scoring, enrichment, per-name cap, and pagination. - -Catalog deduplication keys are `catalog identity + path`; repository alone is never a catalog key. Global mode retains `repo + qualifiedName`. Stable ordering for equal relevance scores is catalog order, repository, then path; do not depend on network completion order. - -`total` is the application-filtered, de-duplicated working total. `truncated` is true if any GitHub batch is incomplete/capped or if additional filtered pages exist. Zero bounded hits returns zero catalog hits, not global results. - -## Search-to-install design - -Replace `collectSelectedSkillSearchRepos()` with `collectSelectedSkillSearchSources()` in `src/cli/commands/plugin-skills.ts`. A selection key is `catalog identity + repository path`; path or repository alone is insufficient. - -```ts -interface SelectedSkillSearchSource { - catalogIdentity?: string; - installDescriptor?: CatalogInstallDescriptor; - installSource: string; - installPolicy: 'repository-install' | SkillCatalogInstallPolicy; - classification?: SkillCatalogClassification; - warnings: readonly SkillCatalogWarning[]; - selectors: string[]; -} -``` - -Collection rules: - -- Preserve displayed result order. -- Group catalog hits by full catalog identity and exact install descriptor, never by repo. Global hits continue grouping by normalized `installSource`. -- Deduplicate selectors within an identity while preserving order. -- Never merge `hermes-core` and `hermes-optional`. -- Reject selected keys absent from the current bounded result set. -- Global results continue to group at repository root and use the existing whole-repository install behavior. - -For catalog direct-selective groups: - -1. Fetch the repository once, resolving the declared common root. -2. Discover from that root and require every selected qualified selector to resolve exactly. -3. Upsert one plugin entry whose `source` is the catalog `installSource` and whose `skills` array contains selected qualified selectors plus any existing selectors for that exact source. -4. Do not promote the source to a broader common repository root. -5. Sync once after all selected groups are configured. - -For catalog marketplace-selective groups: - -1. Fetch/register the root as a marketplace using existing manifest semantics. -2. Resolve each selected hit to a valid manifest plugin and local skill path. A manifest is authoritative; recursive paths outside it are not installed. -3. Add only the required `plugin@marketplace` entries and set per-plugin qualified allowlists. -4. Reject remote manifest source types unsupported by the current installer rather than treating their paths as local. -5. Sync once after all groups are configured. - -For optional groups, display warnings and require an explicit confirmation after selection. For `search-only` and `external-installer`, disable selection; if a stale/programmatic selection reaches the installer, return a validation-style error before config mutation. The external error includes the upstream lifecycle URL. No lifecycle script is executed by AllAgents. - -## Discovery provenance, install descriptor, and persisted provenance - -Discovery and installation are different observations and must not overwrite each other. - -```ts -export interface CatalogInstallDescriptor { - catalog: 'recommended'; - catalogVersion: 1; - sourceId: string; - repo: `${string}/${string}`; - effectiveRef: string; - approvedRoot: '.' | string; - installSource: string; - installRoot: '.' | string; - sourceKind: SkillCatalogSourceKind; - installPolicy: SkillCatalogInstallPolicy; -} - -export interface CatalogDiscoveryProvenance { - catalogIdentity: string; - provider: 'github-code-search'; - repo: `${string}/${string}`; - effectiveRef: string; - catalogVersion: 1; - approvedRoot: '.' | string; - repositoryHeadSha: string; - skillPath: string; - blobSha: string; -} -``` - -`CatalogDiscoveryProvenance` describes why a search hit was admitted: catalog identity, preflighted default ref/head, approved search root, response path, and Code Search blob SHA. It is returned in the search result and may be logged for diagnostics, but it is not installation proof. - -`CatalogInstallDescriptor` is immutable input to installation. Before mutation, the installer must re-resolve the exact repo/ref/root, reject descriptor drift, and clone/fetch `effectiveRef`. Arbitrary caller-provided ref overrides are rejected in MVP. - -Extend `src/models/workspace-config.ts::PluginEntrySchema` with an optional `catalogSource` object matching `CatalogInstallDescriptor`. Every catalog-originated project install writes it; user-scope installs use the same schema for consistency. The ordinary `source` remains the exact fetch/install spelling, including `@` and subpath. Marketplace plugin entries retain `plugin@marketplace` in `source` and use `catalogSource` to preserve the originating repo/ref/approved root/install root. - -```yaml -plugins: - - source: NousResearch/hermes-agent@main/skills - skills: [research/llm-wiki] - catalogSource: - catalog: recommended - catalogVersion: 1 - sourceId: hermes-core - repo: NousResearch/hermes-agent - effectiveRef: main - approvedRoot: skills - installSource: NousResearch/hermes-agent@main/skills - installRoot: skills - sourceKind: subtree - installPolicy: direct-selective -``` - -This is an additive workspace-schema-v2 field, not a schema-version migration. It prevents later sync/update code from reconstructing a catalog source from repository alone. - -Preserve existing repository-promotion behavior for non-catalog `skill add` by making matching mode explicit in `upsertGitHubPluginSourceAllowlistInConfig()` and project/user wrappers: - -```ts -{ identity: 'repository-promoting' | 'catalog-exact', catalogSource?: CatalogInstallDescriptor } -``` - -The default remains `repository-promoting`. Catalog installs require `catalog-exact`; they match/merge only identical full catalog identities and reject a conflicting descriptor. They never promote sibling catalog roots. - -Extend `src/models/sync-state.ts::SyncStateSourceSchema` additively with optional `catalogSource` and `resolvedRoot`. Catalog sync-state keys are the canonical catalog identities: - -```text -recommended:hermes-core@main#skills -recommended:hermes-optional@main#optional-skills -``` - -Actual install provenance is `{ catalogSource, resolvedRef, resolvedSha, resolvedRoot, pluginSpec }` captured after fetch/manifest resolution. It records the installed commit and root and is deliberately separate from the earlier discovery head/blob. The two Hermes records may share `resolvedSha` and physical repository cache, but never a state key. `src/core/plugin.ts::fetchCache` and `getPluginCachePath()` remain repository+ref keyed only for physical clone reuse. - -`src/core/sync.ts::buildSourcesProvenance()` rebuilds the complete derived `sources` map. For catalog entries it requires persisted `catalogSource`, verifies source/ref/root consistency, and emits catalog-identity keys. For non-catalog entries it preserves existing keys. A legacy repo-only state record still parses and is replaced on the next full sync; workspace config is never inferred from old sync state. - -Tests install core and optional in both orders and assert two workspace descriptors, two catalog-identity provenance keys, one physical cache identity, exact resolved roots/refs, and no promotion to repository root. - -## Clean-clone fix - -In `src/core/git.ts::createGit()`, set simple-git's `allowUnsafeFilter: true` alongside the existing `filter.lfs.*` config. Do not remove `GIT_LFS_SKIP_SMUDGE=1` or the LFS filter overrides; they prevent large LFS downloads during discovery/install. Do not apply a global user/repository git config. - -Add a regression to `tests/unit/core/git.test.ts` that creates a disposable local origin and calls the real `cloneTo()` into an empty destination. The test must exercise the configured `filter.lfs.*` path so it fails with simple-git's unsafe-filter rejection if `allowUnsafeFilter` is removed. Assert clone success and expected tracked content; do not merely snapshot an options object. - -This fix is stage zero for catalog install claims. A cache-seeded run is not acceptable evidence. - -## Manifest validation, read-only health, and review gate - -Create `src/core/skill-catalog-health.ts` with two reusable entry points: - -```ts -validateSkillCatalog(catalog: SkillCatalog): CatalogValidationIssue[] -checkSkillCatalogHealth(catalog: SkillCatalog, deps): Promise -``` - -`validateSkillCatalog()` is deterministic and offline: type/invariant checks, identity uniqueness, source/ref/root/install-source consistency, metadata presence, SPDX syntax when non-null, policy combinations, warning requirements, and absence of arbitrary refs. - -`checkSkillCatalogHealth()` is networked but strictly read-only. It performs only GitHub GET requests and returns per-source statuses `healthy`, `drifted`, or `unreachable` with reason codes; it never clones, installs, writes config, updates refs, opens PRs, or executes source code. It checks: - -1. repository exists and canonical name still matches; -2. default branch equals `effectiveRef` and the ref resolves; -3. `approvedRoot`, `installRoot`, and installable subpath exist at that ref; -4. representative `SKILL.md` paths remain within segment boundaries; -5. valid marketplace-selective entries have a parseable authoritative manifest at the effective ref; -6. every local manifest plugin source resolves inside `installRoot`, every selected skill path resolves inside `approvedRoot`, and no path escapes through `..`, absolute paths, symlinks, or misresolved relative sources; -7. search-only/external entries remain non-installable and retain required warning codes; Composio's broken nested manifest is reported as an expected health warning, never accepted as a marketplace manifest. - -Create `scripts/validate-skill-catalog.ts`, importing the same catalog object and health functions. Add package scripts: - -```json -"catalog:validate": "bun run scripts/validate-skill-catalog.ts --ci", -"catalog:health": "bun run scripts/validate-skill-catalog.ts --report" -``` - -Both modes are read-only. `--ci` fails on any static error, repository/ref/root drift, or invalid authoritative marketplace manifest. `--report` emits the same structured report for maintainers and exits nonzero for drift/unreachable sources; it never repairs anything. - -Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project dependencies and runs `bun run catalog:validate` with the workflow's read-only `GITHUB_TOKEN` (`contents: read`; no write permissions). Configure the repository ruleset/branch protection before implementation merge so this job is required and catalog changes require at least one human approval; record that setting and the health report/upstream SHAs in the PR. Any modification to `src/core/skill-catalog.ts`, catalog validation, or catalog policy must arrive through that path. If the required-check/approval rule cannot be enabled, catalog implementation remains blocked. No direct-to-main updater or automated catalog-writing workflow is added. - -## Exact repository changes - -### New files - -- `src/models/skill-catalog.ts` - - Catalog/source/metadata/provenance types and Zod `CatalogInstallDescriptorSchema`; no catalog entries or registry data. -- `src/core/skill-catalog.ts` - - Single schema-version-1 `Recommended` catalog, stable source IDs, explicit repo/ref/roots, metadata, warnings, identity constructor, and segment-boundary helpers. -- `src/core/skill-catalog-health.ts` - - Offline validation and dependency-injected read-only GitHub health/manifest checks. -- `scripts/validate-skill-catalog.ts` - - Read-only `--ci` and `--report` entry points over the same catalog/validator. -- `tests/unit/core/skill-catalog.test.ts` - - Typed-data invariants, identity, metadata, policies, exact boundaries, and Hermes separation. -- `tests/unit/core/skill-catalog-health.test.ts` - - Ref/root drift, manifest traversal/misresolution, status reporting, and zero-mutation dependency assertions. - -- `tests/unit/models/skill-catalog.test.ts` - - Descriptor schema acceptance/rejection and versioned serialization. - -### Modified production and configuration files - -- `src/core/skill-search.ts` - - Add `catalog` option, policy/metadata/provenance result fields, default-ref preflight, deterministic repository batching, and hard-boundary filtering before rank/pagination. - - Keep no-option global behavior, but never invoke it as catalog fallback. -- `src/cli/commands/plugin-skills.ts` - - Add `--catalog`, catalog-identity selection grouping, exact-descriptor direct/marketplace installers, warnings, and one final sync. - - Persist `catalogSource`; record actual install provenance separately from discovery provenance. -- `src/cli/metadata/plugin-skills.ts::skillsSearchMeta` - - Document `--catalog`, mutual exclusion, Recommended label, policy/metadata/provenance JSON fields, no fallback, and arbitrary-ref exclusion. -- `src/cli/tui/actions/skills.ts::runSearchOnlineSkills` - - Consume `installSource`; keep this TUI surface on global search in this change. -- `src/utils/plugin-path.ts` - - Parse/render exact repo/ref/root descriptors and retain segment-safe path normalization; repository identity is not catalog identity. -- `src/models/workspace-config.ts::PluginEntrySchema` - - Add optional typed `catalogSource: CatalogInstallDescriptor` while retaining workspace schema version 2. -- `src/core/workspace-modify.ts` - - Add repository-promoting versus catalog-exact upsert mode and preserve/validate catalog descriptors. -- `src/core/user-workspace.ts` - - Thread catalog-exact mode and descriptor through user-scoped allowlist upsert. -- `src/models/sync-state.ts::SyncStateSourceSchema` - - Add optional catalog descriptor and resolved root while retaining sync-state schema version 1. -- `src/core/sync.ts::buildSourcesProvenance` - - Emit full catalog-identity keys and exact root/ref install provenance. -- `src/core/git.ts::createGit` - - Enable `allowUnsafeFilter` for the intentional fixed LFS filters. -- `package.json` - - Add `catalog:validate` and `catalog:health` scripts. -- `.github/workflows/ci.yml` - - Add the `Catalog Manifest` validation job with read-only token permissions; mark it required in the repository ruleset before merge. - -`src/core/marketplace.ts` and `src/utils/marketplace-manifest-parser.ts` remain marketplace dependencies rather than catalog registries. Reuse their manifest schemas/resolution rules; do not store catalog entries in `MarketplaceRegistry`. - -### Modified tests - -- `tests/unit/core/skill-search.test.ts` -- `tests/unit/cli/skill-search-summary.test.ts` -- `tests/unit/cli/skills-add-standalone-install.test.ts` -- `tests/unit/core/github-skill-source-promotion.test.ts` -- `tests/unit/core/git.test.ts` -- `tests/unit/models/workspace-config.test.ts` -- `tests/unit/models/sync-state-schema.test.ts` -- `tests/e2e/plugin-skills.test.ts` - -Add a focused TUI unit only if the existing action is first made dependency-injectable without production-only indirection; otherwise cover `installSource` through the exported selection helper and perform the TUI smoke check manually. - -### Documentation and changelog - -- `README.md` command table: add `allagents skill search [--catalog recommended]`, label the catalog Recommended, and state global is the no-option default but never a catalog fallback. -- `docs/src/content/docs/docs/reference/cli.mdx`: add complete search syntax, flags, metadata/policy/provenance JSON fields, mutual exclusion, no-fallback behavior, default-ref-only MVP, and project config descriptor semantics. -- `docs/src/content/docs/docs/guides/marketplaces.mdx`: distinguish catalog sources from marketplaces and document authoritative-manifest validation without registering plain repositories. -- `CHANGELOG.md` under `Unreleased` / `Added`: Recommended catalog search, versioned source identity, exact ref/root descriptors, read-only health/CI validation, and warnings. Under `Fixed`: clean clone failure caused by simple-git unsafe LFS filter validation. - -## Automated test matrix - -### Catalog data and boundaries - -- Every source row above is present with exact source ID, repository, effective ref, approved root, install root/source, classification, kind, install policy, bulk policy, metadata, SPDX value, and warnings. -- Catalog schema version and label are exactly `1` and `Recommended`. -- Full identity serialization includes catalog, source ID, effective ref, and approved root; repository-only keys reject. -- Unknown catalog rejects. -- `catalog + owner` rejects before token resolution or network access. -- `skills/x/SKILL.md` matches Hermes core. -- `optional-skills/x/SKILL.md` matches Hermes optional. -- `optional-skills-old/x/SKILL.md`, `skills-old/x/SKILL.md`, and `docs/x/SKILL.md` match neither Hermes entry. -- Repository case is normalized; Git path case is not. -- Absolute, traversal, backslash, and non-`SKILL.md` paths reject. -- Paperclip `skills/company-creator/SKILL.md` has `direct-selective` policy and ambiguous-license warning; a hit elsewhere in the repository has `search-only` policy. -- Composio and gstack results remain visible with different non-install policies. - -### Query, ref, and merge behavior - -- Catalog queries contain only exact catalog `repo:` qualifiers. -- Unique repository network preflight treats Hermes once while retaining two catalog identities. -- Default branch/ref mismatch fails catalog search; no global search function is called. -- Arbitrary requested ref, tag, or SHA rejects in MVP. -- Long qualifier sets split under the query ceiling without omission or duplication. -- Query-as-owner is absent in catalog mode. -- A forged response from a non-catalog repository is discarded. -- Segment-boundary filtering occurs before truncation, ranking, enrichment, and pagination; fill a first response page with out-of-boundary hits and prove valid later hits survive. -- A required preflight/primary batch failure fails the search. Zero bounded hits stays zero. Neither path falls back globally. -- Equal-score results have deterministic catalog/repository/path ordering. -- Global query literals and existing relevance tests remain unchanged. - -### Selection and install behavior - -- Selection keys distinguish identical paths and identical repositories under different catalog identities. -- Multiple skills from one full catalog identity/descriptor produce one group and ordered unique selectors. -- Hermes core and optional produce two groups. -- Catalog direct installs write an exact ref-qualified common root/subtree source, never a selected skill directory. -- Selected nested skill fixtures copy `SKILL.md`, `references/`, scripts, and nested assets. -- Project workspace entries persist full `catalogSource`; marketplace plugin specs retain originating descriptor. -- Catalog-exact upsert retains two Hermes config entries in both install orders. -- Existing non-catalog sibling skill URLs still pass repository-promoting tests. -- Optional install requires confirmation; cancellation mutates nothing. -- Search-only/external stale selections fail before config/cache mutation. -- Marketplace selections outside an authoritative validated manifest fail closed. -- One selection transaction invokes one final sync. - -### Provenance, validation, and clone behavior - -- Discovery provenance contains bounded path/blob plus preflighted ref/head and is not reused as actual install provenance. -- Actual install provenance contains full descriptor, resolved ref/SHA/root, and catalog-identity state key. -- Real empty-destination clone succeeds with LFS filters enabled. -- Core and optional share physical repository cache/fetch but write two catalog-identity provenance records. -- Legacy repo-only sync-state input parses; next complete sync emits current catalog-identity keys and drops obsolete derived keys. -- Static catalog validation rejects missing metadata, invalid SPDX syntax, duplicate identity, illegal policy combinations, and non-default/arbitrary refs. -- Read-only health tests return `healthy`, `drifted`, or `unreachable`; injected mutating dependencies are never called. -- Authoritative marketplace manifest validation rejects missing/misresolved/escaping paths; Composio remains a warned search-only source. - -### CLI and JSON - -- `--catalog recommended` reaches `searchSkills()` and prints the Recommended label plus policy/warnings. -- Unknown catalog and catalog-owner conflict return exit 2 in text and JSON modes. -- JSON items contain exact install source/selector, policy reason codes, metadata, catalog identity, discovery provenance, and install descriptor; no `trusted`, `verified`, `safe`, or `installable` boolean exists. -- No-flag CLI and TUI searches remain global. -- Non-TTY mode never prompts or installs. - -## Project-scoped disposable install matrix - -Run this only after the clone regression and focused automated tests pass. From the implementation worktree, build once, capture the exact CLI path, then use that built CLI for every row: - -```sh -REPO_ROOT="$(git rev-parse --show-toplevel)" -cd "$REPO_ROOT" -bun run build -CLI="$REPO_ROOT/dist/index.js" -``` - -Each row gets a unique temporary `HOME` and project directory so no user cache, registry, credentials file, or workspace can make a clean clone appear successful. Initialize every row exactly as follows, execute the row's named catalog search and project install exercise from the matrix, inspect the three named outputs, then delete the case root: - -```sh -CASE_ROOT="$(mktemp -d)" -export HOME="$CASE_ROOT/home" -mkdir -p "$HOME" "$CASE_ROOT/project" -cd "$CASE_ROOT/project" -"$CLI" init . -# Execute the matrix row's catalog search and project-scoped install attempt. -# Inspect .allagents/workspace.yaml, .allagents/sync-state.json, -# and .agents/skills/ (the initialized universal-client target). -rm -rf "$CASE_ROOT" -``` - -Do not run `git config` outside a disposable fixture. Record command, upstream HEAD SHA, selected selectors or manifest plugins, copied/failed counts, resulting source strings, warnings, and asset checks in the implementation PR description. - -| Source | Clean project-scoped exercise | Required observation | -|---|---|---| -| gstack | Search catalog and attempt selection | `recommended:gstack@main#.` discovery stays bounded; picker blocks generic install and points to upstream lifecycle. No workspace mutation. | -| paperclip companies | Search, select `company-creator`, project install | Config source is `paperclipai/companies@main` with full descriptor and qualified allowlist; installed directory includes `references/`; ambiguous-license warning shown. | -| mattpocock skills | Search and install through manifest | Descriptor preserves `mattpocock/skills@main`; mandatory authoritative-manifest validation passes; recursive discoveries outside valid manifest resolution do not install. | -| Composio awesome skills | Search and attempt selection | Identity uses `@master#.`; results are optional/search-only with broken-manifest, dependency, and licensing warnings. No registration/install/config mutation. | -| Hermes core | Search and install selected core skills; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/skills`; config descriptor identity is `recommended:hermes-core@main#skills`; clean clone succeeds; discovery count is 82 at audited revision; selected copies have zero failures. | -| Hermes optional | Search and install one selected optional skill after warning; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/optional-skills`; config descriptor identity is `recommended:hermes-optional@main#optional-skills`; no default bulk selection; discovery count is 117; requirements warning shown. | -| Hermes core + optional | Install both in the same disposable project, then reverse order in a second project | Two ref/root-preserving config descriptors and two catalog-identity provenance keys, one physical cache, exact boundaries, zero source promotion. | -| anthropics skills | Search and marketplace-selective install | Descriptor preserves `anthropics/skills@main`, approved root `skills`, and repository install root; only manifest-declared local skill paths install; license-metadata warning remains visible. | -| addyosmani agent skills | Search and project-selective install | Exact `addyosmani/agent-skills@main/skills` source/descriptor, audited discovery count 24, complete selected directories, zero failures. | -| obra superpowers | Search and project-selective install | Exact `obra/superpowers@main/skills` source/descriptor, audited discovery count 14, complete selected directories, zero failures. | -| context engineering | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 23, zero failures. | -| Elastic docs skills | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 20, zero failures. | -| Anthropic knowledge work | Search and marketplace-selective install of local manifest entries | Descriptor preserves `anthropics/knowledge-work-plugins@main` root; audited search count 212; mandatory manifest validation passes for local entries; unsupported remote entries fail closed. | - -Count drift is not automatically a failure. If upstream HEAD differs from the audited revision, record the new count and classify every delta before updating catalog expectations. Missing assets, boundary leakage, manifest misresolution, config collapse, clone failure, or any copy failure is a release blocker. - -## Incorporated `numman-ali/n-skills` audit decisions - -Architectural audit reference: `numman-ali/n-skills` commit `b1c6173aa7f83c569248996e1db1b9ae7afdb76f`. - -Borrowed decisions, now reflected throughout this plan: - -- stable human-assigned source IDs; -- one schema-versioned typed catalog; -- explicit repository, effective ref, approved search root, and install root; -- category, homepage, author, and nullable SPDX metadata; -- separate discovery provenance and actual install provenance; -- catalog identity composed from catalog name, source ID, effective ref, and approved root; -- PR review, mandatory authoritative-manifest validation, and read-only health reporting; -- segment-boundary application filtering and no global fallback; -- exact descriptor/root/ref persistence through project workspace config. - -Explicitly not borrowed: - -- vendoring or mirroring upstream content; -- a direct-to-main updater or automated registry writer; -- shell-based catalog synchronization; -- boolean trust/safety/verification fields; -- dependency installation or lifecycle execution; -- split generated and hand-maintained registries. - -The audit commit informs architecture but does not, by itself, add `numman-ali/n-skills` as a catalog source. Adding it later requires an evidence-backed source row, classification, ref/root, metadata, manifest policy, warnings, disposable project install, health result, and reviewed PR. No finding is invented here. - -## Migration and compatibility - -- Workspace schema remains version 2 with an additive optional `catalogSource` descriptor. Existing entries parse unchanged; catalog-originated entries never drop this descriptor during modify/sync/update. -- Sync-state schema remains version 1 with additive optional catalog descriptor/resolved-root fields. `sources` is derived state; a full sync replaces obsolete repository-only catalog provenance with full catalog-identity keys. -- Existing global search calls receive additive fields and preserve no-option behavior. -- Existing `--owner`, pagination, rate-limit, token lookup, relevance, and global install behavior remain intact. -- Existing non-catalog source promotion remains the default matching mode; catalog installs use catalog-exact mode. -- Marketplace registries are neither seeded nor modified merely by searching or health checking. Registration occurs only after explicit install selection for a valid marketplace entry. -- No migration guesses catalog provenance for pre-existing repository entries. Only a future explicit catalog install/update can attach a descriptor. - -## Risks and mitigations - -| Risk | Mitigation | -|---|---| -| GitHub Code Search ignores/misparses a large repository qualifier expression. | Deterministic short query batches, literal query tests, application-side exact repository/root filtering, fatal required-batch failures, no fallback. | -| Search result comes from a different ref than installation. | MVP requires catalog ref to equal upstream default; read-only preflight binds discovery head; exact descriptor forces install ref; provenance remains separate. | -| Boundary prefix error leaks `optional-skills-old`. | Segment-based approved-root matcher with adversarial cases before ranking/pagination. | -| Catalog grows request count and hits Code Search rate limits. | Deduplicate only network repository preflights, batch qualifiers, retain token resolution, document truncation; never collapse source identities. | -| Search results advertise paths a manifest cannot install. | Mandatory authoritative-manifest CI validation plus install-time preflight; fail closed. | -| Two Hermes entries collapse in config or state. | Full catalog identity, persisted exact descriptors, catalog-exact upsert, two-order E2E matrix. | -| Selected skill loses assets. | Explicit install root plus qualified allowlist; asset-bearing fixture and Paperclip `references/` check. | -| Optional source installs hundreds of skills accidentally. | `bulkPolicy: 'explicit-only'`, no preselection, warning confirmation, stale-selection guard. | -| Recommended label is mistaken for endorsement. | Required warnings and metadata; never expose verified/safe/trusted booleans or wording. | -| Upstream ref/layout/count/license changes. | Read-only health report and required CI validation; record upstream SHA; catalog changes only through reviewed PRs. | -| Health tooling mutates upstream/local state. | GET-only dependency surface, mutation-negative tests, no repair/update mode. | -| LFS workaround weakens git safety globally. | Set `allowUnsafeFilter` only on the controlled `simple-git` instance with fixed filter keys; never accept user-supplied filters or alter global git config. | - -## Staged execution order - -1. **Fix clean clones first.** Add `allowUnsafeFilter`, the real local-clone regression, and confirm clean remote Matt/Hermes clones reach discovery. -2. **Add the one versioned catalog and validator.** Land stable IDs, metadata, explicit refs/roots, full identity, policy enums, static invariants, and audited source rows. -3. **Add read-only health and CI manifest gate.** Implement GET-only checks, package scripts, required `Catalog Manifest` job, and review evidence format. -4. **Extend the core search API.** Add catalog option, default-ref preflight, qualifier batching, segment-boundary enforcement, metadata/discovery provenance, stable ordering, and no-fallback tests. -5. **Persist exact install descriptors.** Extend workspace/sync-state schemas additively; implement catalog-exact upsert and full catalog-identity provenance keys. -6. **Implement catalog-aware CLI selection/install.** Add flag/validation, identity grouping, exact direct/marketplace descriptors, warnings, one-sync transaction, and stale-selection guards. -7. **Update the global TUI consumer.** Use `installSource` without adding catalog selection to the TUI. -8. **Run focused automated suites.** Catalog, health/manifest, search, selection, workspace identity, provenance, clone, and plugin-skills E2E. -9. **Run the disposable project matrix.** Fresh `HOME` per row, built CLI, actual remote sources, exact config/state/filesystem evidence. -10. **Update durable docs and changelog.** README, CLI reference, marketplace distinction, maintenance/review gate, and `Unreleased` entries. -11. **Final reviewed PR gate.** Attach health report/upstream SHAs, pass mandatory manifest CI, obtain human review, and verify the catalog is labeled Recommended without endorsement language. - -## Implementation completion criteria - -Implementation is complete only when: - -- `allagents skill search --catalog recommended` and `searchSkills(query, { catalog: 'recommended' })` enforce catalog/ref/root hard boundaries without global fallback; -- `--catalog` plus `--owner` and any arbitrary ref override fail as specified; -- all source rows expose exact IDs, refs/roots, metadata, classification, policy, warnings, and full identities; -- gstack and Composio cannot enter generic install flows; -- Paperclip preserves `company-creator/references`; -- Hermes core and optional remain distinct in result identity, selection, workspace descriptors, and actual install provenance; -- discovery and install provenance remain separate; -- clean clones succeed without cache seeding; -- authoritative marketplace manifests pass the required CI validator and install-time preflight; -- read-only health checks report drift without mutation; -- project workspace config preserves exact catalog install descriptor/root/ref through subsequent sync/update; -- global search remains the no-option default but is never a named-catalog fallback; -- focused tests and every applicable disposable matrix row pass with evidence recorded in the implementation PR; -- the reviewed PR passes `Catalog Manifest` CI and README, CLI reference, marketplace guide, and changelog match the shipped contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index e2db851..aee27a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - **Recommended skill catalog**: Added `allagents skill search --catalog recommended`, a hard discovery boundary over curated repository, subtree, marketplace-backed, search-only, and external-lifecycle sources. The catalog includes gstack, Paperclip companies, Matt Pocock skills, Composio awesome skills, distinct Hermes core and optional roots, Anthropic skills and knowledge-work plugins, Addy Osmani skills, obra superpowers, context-engineering skills, and Elastic docs skills. - Catalog results expose classification, install policy, warnings, source metadata, exact install source and selector, and separate discovery provenance. **Recommended** is a discovery label, not a security, trust, quality, or license guarantee. -- Interactive catalog search supports selective project- or user-scoped installation. Project installs retain exact repository/ref/root/source identity and the selected subset in workspace and sync state; optional sources require confirmation, while search-only and external-installer sources remain non-installable. +- Interactive catalog search supports selective project- or user-scoped installation. Workspace configuration retains exact repository/ref/root/source identity and the selected subset; sync state retains exact resolved install provenance. Optional sources require confirmation, while search-only and external-installer sources remain non-installable. - Added authenticated, read-only catalog health validation in CI for repository/ref/root drift, skill presence, local authoritative marketplace paths, and source identity. The check never mutates, vendors, or automatically updates catalog entries. ### Fixed diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index b1a50e7..4db1ca2 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -306,11 +306,11 @@ use the exact catalog repository, default-branch ref, and root. Hermes core identities and stay separate through installation and sync. Discovery provenance (the GitHub result path, blob SHA, repository-head SHA, -query batch, and catalog version) explains how a result was found. Install -provenance (repository, ref, root, source ID, and selected paths) controls later -syncs and is persisted in `workspace.yaml` and `.allagents/sync-state.json`. -These are deliberately distinct: a search blob SHA is evidence, not an -install/update pin. +approved boundary, and catalog version) explains how a result was found. +Install provenance (repository, ref, root, source ID, and plugin spec) is +persisted in `.allagents/sync-state.json`; the selected paths and full catalog +descriptor remain in `workspace.yaml` and control later syncs. These are +deliberately distinct: a search blob SHA is evidence, not an install/update pin. ### skill update diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index a4962f8..4210130 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -40,6 +40,7 @@ import { import { RECOMMENDED_SKILL_CATALOG, catalogInstallDescriptor, + catalogSourceIdentity, } from '../../core/skill-catalog.js'; import { type DiscoveredSkillEntry, @@ -2407,7 +2408,7 @@ export function collectSelectedSkillSearchSources( const selectionKey = skillSearchSelectionKey(item); if (!selected.has(selectionKey)) continue; const groupKey = item.catalog - ? `${item.catalog.identity}#${JSON.stringify(item.catalog.installDescriptor)}` + ? `${item.catalog.identity}#${JSON.stringify(item.catalog.installDescriptor)}#${item.installation.policy}` : item.installSource.toLowerCase(); let group = groups.get(groupKey); if (!group) { @@ -2504,8 +2505,17 @@ function validateSelectedCatalogDescriptor( (entry) => entry.sourceId === descriptor.sourceId, ); const parsed = parseExactGitHubInstallSource(descriptor.installSource); + const expectedIdentity = source + ? catalogSourceIdentity({ + catalog: descriptor.catalog, + sourceId: descriptor.sourceId, + effectiveRef: descriptor.effectiveRef, + approvedRoot: descriptor.approvedRoot, + }) + : undefined; if ( !source || + expectedIdentity !== group.catalogIdentity || JSON.stringify(catalogInstallDescriptor(source)) !== JSON.stringify(descriptor) || !parsed || diff --git a/src/core/__tests__/plugin-seed-cache.test.ts b/src/core/__tests__/plugin-seed-cache.test.ts index b037106..f6f7f73 100644 --- a/src/core/__tests__/plugin-seed-cache.test.ts +++ b/src/core/__tests__/plugin-seed-cache.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import simpleGit from 'simple-git'; import { describe, it, expect, beforeEach } from 'bun:test'; import { seedFetchCache, @@ -82,6 +86,29 @@ describe('seedFetchCache', () => { expect(result.cachePath).toBe(marketplacePath); }); + it('retains branch and commit provenance for a seeded repository', async () => { + const repository = await mkdtemp(join(tmpdir(), 'allagents-seed-cache-')); + try { + const git = simpleGit(repository); + await git.init(); + await git.checkoutLocalBranch('main'); + await git.addConfig('user.name', 'AllAgents Test'); + await git.addConfig('user.email', 'test@allagents.dev'); + await writeFile(join(repository, 'tracked.txt'), 'seeded\n'); + await git.add('tracked.txt'); + await git.commit('fixture'); + const sha = (await git.revparse(['HEAD'])).trim(); + + seedFetchCache('owner/provenance', repository, 'main'); + const result = await fetchPlugin('owner/provenance', { branch: 'main' }); + + expect(result.resolvedRef).toBe('main'); + expect(result.resolvedSha).toBe(sha); + } finally { + await rm(repository, { recursive: true, force: true }); + } + }); + it('ignores invalid URLs', () => { // Should not throw, just silently skip seedFetchCache('not-a-valid-url', '/some/path'); diff --git a/src/core/plugin.ts b/src/core/plugin.ts index cfac89a..de815d9 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -103,18 +103,24 @@ export function seedFetchCache( if (!parsed) return; const { owner, repo } = parsed; - const cachePath = getPluginCachePath(owner, repo, branch ?? parsed.branch); + const effectiveBranch = branch ?? parsed.branch; + const cachePath = getPluginCachePath(owner, repo, effectiveBranch); - // Don't overwrite if already populated (e.g. by a prior fetchPlugin call) + // Don't overwrite if already populated (e.g. by a prior fetchPlugin call). if (fetchCache.has(cachePath)) return; fetchCache.set( cachePath, - Promise.resolve({ - success: true, - action: 'skipped' as const, - cachePath: path, - }), + (async (): Promise => { + const sha = await resolveHeadSha(path); + return { + success: true, + action: 'skipped', + cachePath: path, + ...(effectiveBranch && { resolvedRef: effectiveBranch }), + ...(sha && { resolvedSha: sha }), + }; + })(), ); } @@ -204,11 +210,14 @@ async function doFetchPlugin( const isCached = existsSyncFn(cachePath); if (isCached && offline) { - // Offline mode: use cached version without fetching + // Offline provenance still comes from the cached repository itself. + const sha = await resolveHeadSha(cachePath); return { success: true, action: 'skipped', cachePath, + ...(branch && { resolvedRef: branch }), + ...(sha && { resolvedSha: sha }), }; } diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts index 0384dbf..c30ae30 100644 --- a/src/core/skill-search.ts +++ b/src/core/skill-search.ts @@ -557,18 +557,21 @@ async function fetchPrimaryPages( limit: number, token: string | undefined, fetchFn: typeof fetch, + acceptItem?: (item: SkillSearchItem) => boolean, ): Promise { const needed = page * limit * 3; - const numPages = Math.min( + const requestedPages = Math.min( Math.max(1, Math.ceil(needed / SEARCH_PAGE_SIZE)), MAX_RESULTS / SEARCH_PAGE_SIZE, ); + const maxPages = acceptItem ? MAX_RESULTS / SEARCH_PAGE_SIZE : requestedPages; const items: SkillSearchItem[] = []; + let accepted = 0; let total = 0; let truncated = false; - for (let currentPage = 1; currentPage <= numPages; currentPage += 1) { + for (let currentPage = 1; currentPage <= maxPages; currentPage += 1) { const result = await runOneQuery( q, currentPage, @@ -577,12 +580,14 @@ async function fetchPrimaryPages( fetchFn, ); items.push(...result.items); + accepted += acceptItem + ? result.items.filter(acceptItem).length + : result.items.length; total = result.total; truncated = truncated || result.truncated; - if (result.items.length < SEARCH_PAGE_SIZE) { - break; - } + if (result.items.length < SEARCH_PAGE_SIZE) break; + if (currentPage >= requestedPages && accepted >= needed) break; } truncated = truncated || items.length < Math.min(total, MAX_RESULTS); @@ -718,7 +723,14 @@ async function searchCatalogSkills( const settled = await Promise.allSettled( queries.map((entry) => entry.required - ? fetchPrimaryPages(entry.q, page, limit, token, fetchFn) + ? fetchPrimaryPages( + entry.q, + page, + limit, + token, + fetchFn, + (item) => attachCatalogSource(item, preflight) !== null, + ) : runOneQuery(entry.q, 1, SEARCH_PAGE_SIZE, token, fetchFn), ), ); diff --git a/src/core/sync.ts b/src/core/sync.ts index eaa9d76..e9e4266 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -2939,6 +2939,7 @@ export async function syncUserWorkspace( if (!dryRun) { const { pluginsByClient: nativePluginsByClient } = collectNativePluginSources(validPlugins); + const sources = await buildSourcesProvenance(validPlugins, config.plugins); await sw.measure('persist-state', () => persistSyncState( homeDir, @@ -2949,6 +2950,7 @@ export async function syncUserWorkspace( nativeResult, { clientMappings: USER_CLIENT_MAPPINGS, + ...(Object.keys(sources).length > 0 && { sources }), ...(Object.keys(mcpResults).length > 0 && { mcpTrackedServers: Object.fromEntries( Object.entries(mcpResults).map(([scope, r]) => [ diff --git a/tests/unit/cli/skill-search-install.test.ts b/tests/unit/cli/skill-search-install.test.ts index 74b4874..4208f80 100644 --- a/tests/unit/cli/skill-search-install.test.ts +++ b/tests/unit/cli/skill-search-install.test.ts @@ -131,6 +131,23 @@ describe('installSelectedSkillSearchSources', () => { expect(sync).toHaveBeenCalledTimes(0); }); + it('rejects a mismatched catalog identity before fetching or mutation', async () => { + const fetchPlugin = mock(async () => ({ + success: true as const, + action: 'fetched' as const, + cachePath: '/unused', + })); + const group = sourceGroup('hermes-core', ['research/wiki']); + group.catalogIdentity = 'recommended:hermes-optional@main#optional-skills'; + + await expect( + installSelectedSkillSearchSources([group], 'project', '/workspace', { + fetchPlugin, + }), + ).rejects.toThrow('descriptor drift'); + expect(fetchPlugin).toHaveBeenCalledTimes(0); + }); + it('resolves marketplace selectors through the authoritative local manifest', async () => { const cache = await mkdtemp(join(tmpdir(), 'catalog-marketplace-')); fixtures.push(cache); diff --git a/tests/unit/cli/skill-search-summary.test.ts b/tests/unit/cli/skill-search-summary.test.ts index 3f45f5b..6aad7ed 100644 --- a/tests/unit/cli/skill-search-summary.test.ts +++ b/tests/unit/cli/skill-search-summary.test.ts @@ -28,12 +28,26 @@ function globalItem(path: string, repo: string): SkillSearchItem { }; } -function catalogItem(sourceId: 'hermes-core' | 'hermes-optional'): SkillSearchItem { +function catalogItem( + sourceId: 'hermes-core' | 'hermes-optional' | 'paperclip-companies', + options: { + selector?: string; + path?: string; + policy?: SkillSearchItem['installation']['policy']; + } = {}, +): SkillSearchItem { const source = RECOMMENDED_SKILL_CATALOG.sources.find( (entry) => entry.sourceId === sourceId, )!; - const selector = sourceId === 'hermes-core' ? 'research/wiki' : 'browser'; - const path = `${source.approvedRoot}/${selector}/SKILL.md`; + const selector = + options.selector ?? + (sourceId === 'hermes-core' + ? 'research/wiki' + : sourceId === 'hermes-optional' + ? 'browser' + : 'company-creator'); + const path = + options.path ?? `${source.approvedRoot}/${selector}/SKILL.md`; const identity = catalogSourceIdentity({ catalog: 'recommended', sourceId, @@ -44,7 +58,10 @@ function catalogItem(sourceId: 'hermes-core' | 'hermes-optional'): SkillSearchIt ...globalItem(path, source.repo), installSource: source.installSource, installSelector: selector, - installation: { policy: source.installPolicy, reasonCodes: [] }, + installation: { + policy: options.policy ?? source.installPolicy, + reasonCodes: [], + }, catalog: { name: 'recommended', label: 'Recommended', @@ -147,6 +164,26 @@ describe('collectSelectedSkillSearchSources', () => { ]); }); + it('keeps result-specific non-installable policy separate within one source', () => { + const installable = catalogItem('paperclip-companies', { + path: 'skills/company-creator/SKILL.md', + }); + const searchOnly = catalogItem('paperclip-companies', { + selector: 'template-only', + path: 'templates/template-only/SKILL.md', + policy: 'search-only', + }); + const groups = collectSelectedSkillSearchSources( + [installable, searchOnly], + [installable, searchOnly].map(skillSearchSelectionKey), + ); + + expect(groups.map((group) => group.installPolicy)).toEqual([ + 'direct-selective', + 'search-only', + ]); + }); + it('rejects stale selection keys', () => { const item = globalItem('skills/a/SKILL.md', 'org/repo'); expect(() => diff --git a/tests/unit/core/skill-search.test.ts b/tests/unit/core/skill-search.test.ts index e5ffb72..4c59289 100644 --- a/tests/unit/core/skill-search.test.ts +++ b/tests/unit/core/skill-search.test.ts @@ -1097,7 +1097,7 @@ describe('catalog search boundaries', () => { }); const result = await searchSkills( 'browser', - { catalog: 'recommended', limit: 40 }, + { catalog: 'recommended', limit: 15 }, { fetch, logger: silentLogger, tokenResolver: async () => undefined }, ); expect(result.items).toHaveLength(1); diff --git a/tests/unit/core/sync-user.test.ts b/tests/unit/core/sync-user.test.ts index 81fab25..57f3f25 100644 --- a/tests/unit/core/sync-user.test.ts +++ b/tests/unit/core/sync-user.test.ts @@ -1,3 +1,4 @@ +import simpleGit from 'simple-git'; import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; @@ -5,6 +6,12 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { dump } from 'js-yaml'; import { syncUserWorkspace } from '../../../src/core/sync.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, +} from '../../../src/core/skill-catalog.js'; +import { resetFetchCache } from '../../../src/core/plugin.js'; +import { getPluginCachePath } from '../../../src/utils/plugin-path.js'; import { WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; import type { WorkspaceConfig } from '../../../src/models/workspace-config.js'; import { stubHomeDir } from '../../helpers/env.js'; @@ -16,6 +23,7 @@ describe('syncUserWorkspace', () => { beforeEach(async () => { testDir = await mkdtemp(join(tmpdir(), 'allagents-sync-user-test-')); restoreHomeDir = stubHomeDir(testDir); + resetFetchCache(); }); afterEach(async () => { @@ -185,6 +193,59 @@ describe('syncUserWorkspace', () => { expect(stateContent.files.claude.length).toBeGreaterThan(0); }); + it('persists catalog provenance for an offline user-scoped sync', async () => { + const source = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === 'hermes-core', + )!; + const cachePath = getPluginCachePath( + 'NousResearch', + 'hermes-agent', + source.effectiveRef, + ); + const skillDir = join(cachePath, 'skills', 'research', 'wiki'); + await mkdir(join(skillDir, 'references'), { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), '# wiki\n'); + await writeFile(join(skillDir, 'references', 'source.md'), 'asset\n'); + const git = simpleGit(cachePath); + await git.init(); + await git.checkoutLocalBranch('main'); + await git.addConfig('user.name', 'AllAgents Test'); + await git.addConfig('user.email', 'test@allagents.dev'); + await git.add('.'); + await git.commit('fixture'); + const sha = (await git.revparse(['HEAD'])).trim(); + + await writeUserConfig({ + repositories: [], + plugins: [ + { + source: source.installSource, + skills: ['research/wiki'], + catalogSource: catalogInstallDescriptor(source), + }, + ], + clients: ['claude'], + syncMode: 'copy', + }); + + const result = await syncUserWorkspace({ offline: true }); + expect(result.success).toBe(true); + expect( + existsSync( + join(testDir, '.claude', 'skills', 'wiki', 'references', 'source.md'), + ), + ).toBe(true); + const state = JSON.parse( + await readFile(join(testDir, '.allagents', 'sync-state.json'), 'utf-8'), + ); + const provenance = + state.sources?.['recommended:hermes-core@main#skills']; + expect(provenance?.resolvedRef).toBe('main'); + expect(provenance?.resolvedSha).toBe(sha); + expect(provenance?.resolvedRoot).toBe('skills'); + expect(provenance?.catalogSource?.installSource).toBe(source.installSource); + }); + it('does not write files in dry-run mode', async () => { const pluginDir = await createLocalPlugin('my-plugin', 'my-skill'); From 48cfd39161c2a41ee1bd9c5fdd32c9cb6922a6c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 11:44:52 +1000 Subject: [PATCH 05/11] docs(plans): restore recommended catalog context --- .claude/plans/recommended-skill-catalog.md | 779 +++++++++++++++++++++ 1 file changed, 779 insertions(+) create mode 100644 .claude/plans/recommended-skill-catalog.md diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md new file mode 100644 index 0000000..87a9702 --- /dev/null +++ b/.claude/plans/recommended-skill-catalog.md @@ -0,0 +1,779 @@ +# Recommended Skill Catalog Implementation Plan + +**Status:** Draft PR plan only +**Date:** 2026-08-24 +**Implementation target:** A later feature branch based on then-current `origin/main` + +## Decision + +Add one built-in, typed catalog named `recommended` without changing the default search scope: + +```text +allagents skill search --catalog recommended +searchSkills(query, { catalog: 'recommended' }) +``` + +Without `--catalog`, `skill search` remains global GitHub Code Search. `--catalog` and `--owner` are mutually exclusive and fail validation with exit code 2. When `--catalog recommended` is present, the named catalog is a hard boundary: an empty result, source-health failure, query-batch failure, or manifest failure never falls back to global GitHub results. The catalog is not a marketplace registry; it describes repositories, subtrees, valid marketplace-backed sources, search-only sources, and external-lifecycle distributions without registering any of them in `MarketplaceRegistry`. + +The one user-facing catalog label is exactly **Recommended**. This is a discovery label, not a security, trust, or license assertion. UI, JSON documentation, and release notes must never call catalog entries “verified” or “safe.” Every catalog result instead carries explicit source classification, install policy, metadata, and warnings. Catalog membership must never claim that a source is security-reviewed, license-approved, dependency-complete, compatible with every client, or safe to bulk-install. + +## Problem and observed repository state + +`src/core/skill-search.ts` currently builds up to four global `filename:SKILL.md` GitHub Code Search queries, merges them by priority, filters hidden paths, ranks, enriches, deduplicates, and paginates. `SkillSearchOptions` has only `owner`, `page`, and `limit`. The CLI in `src/cli/commands/plugin-skills.ts` exposes the same three flags. + +The interactive CLI currently loses source boundaries in two places: + +1. `collectSelectedSkillSearchRepos()` reduces selected hits to `item.repo`. +2. `installFromSearch()` passes that repository root to `addPlugin()` or `addUserPlugin()`. + +That is incorrect for two logical sources in one repository, such as `NousResearch/hermes-agent/skills` and `NousResearch/hermes-agent/optional-skills`. It also makes a selected skill-directory URL unsafe as an installation root: `src/core/transform.ts::copySkills()` copies only `SKILL.md` for a root-level standalone skill, while a repository or common subtree plus a qualified allowlist copies the complete selected skill directory recursively, including `references/`, scripts, and other sibling assets inside that directory. + +Subpath identity is also currently collapsed: + +- `src/core/workspace-modify.ts::resolveGitHubIdentity()` returns only lower-cased `owner/repo`. +- `findPluginEntryByGitHubIdentity()` and `canonicalizeGitHubPluginSource()` can promote two subtrees to their repository root. +- `src/cli/commands/plugin-skills.ts::recordSourceProvenance()` keys sync state by repository only. +- `src/core/sync.ts::buildSourcesProvenance()` also writes `sources[owner/repo]`. + +Finally, clean clones currently fail before discovery. `src/core/git.ts::createGit()` supplies `filter.lfs.*` config values to `simple-git@3.30.0` but does not set `allowUnsafeFilter: true`. Clean-cache installs of `mattpocock/skills` and both Hermes subtrees hit simple-git's unsafe-filter rejection. Cache-seeded runs subsequently copied 36, 82, and 117 skills respectively with zero copy failures; those runs do not prove clean installability. + +## Goals + +1. Restrict search to one curated, static, versioned, typed source set when `catalog: 'recommended'` is requested. +2. Enforce canonical repository, effective ref, and exact segment-boundary subtree constraints in application code before ranking or pagination, with no global fallback. +3. Keep Hermes core and optional as different catalog identities even though they share one repository clone/cache. +4. Carry an exact install descriptor from catalog entry to selection, project workspace configuration, installation, and sync provenance. +5. Preserve complete skill directories by installing repository/common-subtree roots with qualified allowlists, never selected skill directories. +6. Respect marketplace manifests only for actual marketplace sources; never coerce plain repositories or subtrees into `MarketplaceRegistry`. +7. Surface category, homepage, author, SPDX metadata, suitability, license, lifecycle, dependency, and bulk-install warnings without a boolean trust field. +8. Separate read-only search/discovery provenance from actual install provenance. +9. Prove project-scoped installation from clean clones in disposable workspaces. +10. Require PR review, mandatory manifest validation, and read-only catalog health checks for catalog changes. + +## Non-goals + +- Replacing global GitHub search or changing its no-option default. +- Falling back to global GitHub when a named-catalog search cannot return a valid bounded result. +- Accepting arbitrary catalog refs in MVP. GitHub Code Search searches the repository default branch; non-default/tag/SHA refs remain out of scope until catalog search becomes ref-aware. +- Adding a user-selected catalog default to `workspace.yaml`; only provenance for an actual catalog install is persisted. +- Turning the catalog into a remote service, user-editable registry, marketplace registry, security scanner, vendored mirror, or repository mirror. +- Creating split generated/manual registries. There is one typed catalog object and one schema version. +- Adding a direct-to-main updater, shell-based catalog sync, or any mutation-capable catalog health command. Catalog changes arrive only through reviewed PRs. +- Adding a boolean `trusted`, `verified`, `safe`, or equivalent field. Suitability is represented by classification, install policy, warning codes, and health results. +- Provisioning MCP servers, Rube, API credentials, binaries, platform packages, browsers, or other skill dependencies. +- Declaring license compatibility or conducting legal/security approval. +- Automatically running upstream lifecycle scripts, especially gstack's setup/update flow. +- Bulk-installing optional or experimental sources by default. +- Making the broken nested Composio manifest installable. +- Adding `numman-ali/n-skills` itself as a source without a separate source-suitability decision; this revision incorporates its audited architectural decisions only. + +## Public CLI and API contract + +### CLI + +Add to `src/cli/commands/plugin-skills.ts::searchCmd`: + +```text +--catalog Restrict results to a built-in catalog. Initially: recommended. +``` + +Examples: + +```text +allagents skill search testing --catalog recommended +allagents skill search testing --catalog recommended --page 2 --limit 10 +allagents --json skill search testing --catalog recommended +``` + +Validation is centralized in `validateSkillSearchArgs()`: + +- Unknown catalog: `Unknown skill catalog "". Available catalogs: recommended.` +- Catalog plus owner: `--catalog and --owner cannot be used together.` +- Both are `SkillSearchError` with `kind: 'validation'`; CLI exit code remains 2 and JSON uses the existing failed command envelope. +- Existing query length, page, limit, owner, API, and rate-limit behavior remains unchanged. + +TTY catalog results use the existing multi-select, but: + +- installable results are grouped by exact `installSource`; +- search-only and external-lifecycle results remain visible but are disabled in the picker; +- warnings are included in the hint and repeated once before an optional/experimental install; +- no source is preselected; +- only explicitly selected skills are enabled; +- one source is fetched/configured once and all selected qualified selectors for it are written in one allowlist update; +- all source mutations complete before one project/user sync, rather than syncing once per selected skill. + +Non-TTY output keeps the table format and appends a concise policy marker only when needed, such as `optional`, `search only`, or `external installer`. JSON is authoritative for full warnings. + +The TUI action `src/cli/tui/actions/skills.ts::runSearchOnlineSkills()` continues using global search because it has no catalog selector in this scope. It must use `item.installSource` rather than reconstructing a repository source, preserving compatibility for global results where `installSource === repo`. + +### Core API + +In `src/core/skill-search.ts`: + +```ts +export type SkillCatalogName = 'recommended'; + +export interface SkillSearchOptions { + owner?: string; + catalog?: SkillCatalogName; + page?: number; + limit?: number; +} +``` + +Extend `SkillSearchItem` additively. Replace boolean installability with an explicit policy enum; it describes supported behavior and is not a trust signal: + +```ts +installSource: string; +installSelector: string; +installation: { + policy: 'repository-install' | SkillCatalogInstallPolicy; + reasonCodes: readonly string[]; +}; +catalog?: { + name: SkillCatalogName; + label: 'Recommended'; + version: 1; + identity: string; + sourceId: string; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + warnings: readonly SkillCatalogWarning[]; + discovery: CatalogDiscoveryProvenance; + installDescriptor: CatalogInstallDescriptor; +}; +``` + +For global search, `installSource` is canonical `owner/repo`, `installSelector` is the existing qualified name, `installation.policy` is `repository-install`, and `catalog` is absent. This preserves existing default search/install behavior while eliminating downstream source reconstruction. + +For catalog search, all catalog fields come from the matched source plus the bounded GitHub response. `SkillSearchResult` retains `query`, `items`, `total`, and `truncated`; no persistent catalog preference is added. + +## One versioned typed catalog and source identity + +Create `src/models/skill-catalog.ts` for data-independent catalog types plus `CatalogInstallDescriptorSchema`, and `src/core/skill-catalog.ts` for the single immutable catalog object, stable source IDs, metadata, lookup helpers, identity construction, path-boundary helpers, and warning text. This keeps `src/models/workspace-config.ts` from importing a core module. Neither file imports `src/core/marketplace.ts`, and there is no generated registry beside the manual catalog object. + +```ts +export type SkillCatalogName = 'recommended'; +export type SkillCatalogClassification = + | 'recommended' + | 'optional' + | 'external-lifecycle'; +export type SkillCatalogSourceKind = + | 'repository' + | 'subtree' + | 'marketplace' + | 'external-lifecycle'; +export type SkillCatalogInstallPolicy = + | 'direct-selective' + | 'marketplace-selective' + | 'search-only' + | 'external-installer'; +export type SkillCatalogBulkPolicy = 'allowed' | 'explicit-only' | 'forbidden'; +export type SkillCatalogCategory = + | 'software-engineering' + | 'business-operations' + | 'integrations' + | 'general-purpose' + | 'agent-engineering' + | 'documentation' + | 'knowledge-work'; + +export interface SkillCatalogAuthor { + name: string; + url: string; +} + +export interface SkillCatalogWarning { + code: + | 'not-security-reviewed' + | 'license-metadata' + | 'license-ambiguous' + | 'external-dependencies' + | 'external-lifecycle' + | 'broken-marketplace' + | 'large-source' + | 'optional-source'; + message: string; +} + +export interface SkillCatalogSource { + sourceId: string; + repo: `${string}/${string}`; + effectiveRef: string; + approvedRoot: '.' | string; + installRoot: '.' | string; + installSource: string; + installableSubpath?: string; + displayName: string; + description: string; + category: SkillCatalogCategory; + homepage: string; + author: SkillCatalogAuthor; + spdxLicense: string | null; + classification: SkillCatalogClassification; + sourceKind: SkillCatalogSourceKind; + installPolicy: SkillCatalogInstallPolicy; + bulkPolicy: SkillCatalogBulkPolicy; + manifestBoundary: 'none' | 'authoritative'; + warnings: readonly SkillCatalogWarning[]; +} + +export interface SkillCatalog { + schemaVersion: 1; + name: 'recommended'; + label: 'Recommended'; + sources: readonly SkillCatalogSource[]; +} +export const RECOMMENDED_SKILL_CATALOG: SkillCatalog = Object.freeze({ + schemaVersion: 1, + name: 'recommended', + label: 'Recommended', + sources: Object.freeze(RECOMMENDED_SOURCE_ENTRIES), +}); +``` + +`RECOMMENDED_SOURCE_ENTRIES` is a private, same-file typed constant populated exactly from the source and metadata tables below. It is not a second registry and is not emitted by a generator. + +Stable `sourceId` values are borrowed as a design rule from the `numman-ali/n-skills` audit: kebab-case, human-assigned, never derived from display text, never reused, and unchanged when metadata changes. There is one catalog schema version. Increment it only for a catalog schema/semantic change, not ordinary source metadata edits. + +Catalog identity is never repository identity. Define one constructor used by search, selection, config, and sync state: + +```ts +catalogSourceIdentity({ + catalog: 'recommended', + sourceId, + effectiveRef, + approvedRoot, +}): string +``` + +Its canonical serialized form is `recommended:@#`, using `.` for repository root. These four fields are mandatory and are the identity components. `repo` remains an explicit validated descriptor field but is never the sole deduplication, install, or provenance key. + +`effectiveRef` is mandatory and, in MVP, must equal the repository's current default branch because GitHub Code Search is not arbitrary-ref-aware. `approvedRoot` is the hard search boundary; `.` means repository root. `installableSubpath` may narrow installation inside that boundary. `installSource` must encode the same repository, effective ref, and installation root, for example `NousResearch/hermes-agent@main/optional-skills`. + +Invariants checked by unit and manifest validation: + +- Stable source IDs and full catalog identities are unique; Hermes may share `repo` but never identity. +- Paths are normalized POSIX-relative segment paths with no leading/trailing slash, empty segment, `.` segment (except root sentinel), or `..` segment. +- Each `effectiveRef` equals the upstream default branch observed by the read-only health check; arbitrary refs fail validation in MVP. +- `installSource` parses back to the same repo/ref and `installRoot`. +- `external-installer` and `search-only` require `bulkPolicy: 'forbidden'`; optional entries require `explicit-only` or `forbidden`. +- Marketplace entries require `manifestBoundary: 'authoritative'`; plain repository/subtree entries require `none`. +- `spdxLicense: null` means no single catalog-asserted SPDX identifier, not “unlicensed.” +- Every entry includes the common `not-security-reviewed` warning through a shared helper, not duplicated strings. + +`approvedRoot` is the search boundary. `installRoot` is the exact root cloned/resolved for installation and may be broader only when asset preservation or marketplace semantics require it. `installableSubpath` may narrow install eligibility inside the approved search boundary. All matching uses path segments, never string-prefix approximation. + +## Initial source list and classifications + +The `recommended` catalog is the name of the built-in catalog, not a claim that every member is generally recommended. It contains all entries below so users can search a known set while seeing each source's actual classification. + +In this plan, `optional` is the optional/experimental classification requested by the product contract. The final six rows are the useful candidates sourced from EntityProcess's `ai-research-wiki` and rechecked against their upstream repositories: Anthropic Skills, Addy Osmani Agent Skills, Superpowers, Context Engineering Skills, Elastic Docs Skills, and Anthropic Knowledge Work Plugins. + +| Source ID | Repository | Effective ref | Approved root | Classification | Kind / install / bulk policy | Install source and audited facts | +|---|---|---|---|---|---|---| +| `gstack` | `garrytan/gstack` | `main` | `.` | `external-lifecycle` | `external-lifecycle` / `external-installer` / `forbidden` | `garrytan/gstack@main`; no AllAgents install action. 64 `SKILL.md`; MIT. Full distribution requires checkout, build/setup, host-specific generation, and updates. Generic copying is not a substitute. | +| `paperclip-companies` | `paperclipai/companies` | `main` | `.` | `optional` | `repository` / `direct-selective` / `explicit-only` | `paperclipai/companies@main`, installable only under `skills/`. Root-plus-qualified-allowlist preserves assets. 523 `SKILL.md` in the tree; supported root discovery exposes `company-creator` and `readme-updater`; all other hits are search-only. Licensing is ambiguous. | +| `mattpocock-skills` | `mattpocock/skills` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `mattpocock/skills@main`; valid root marketplace manifest is authoritative. 36 recursive discoveries; MIT. | +| `composio-awesome-claude-skills` | `ComposioHQ/awesome-claude-skills` | `master` | `.` | `optional` | `repository` / `search-only` / `forbidden` | `ComposioHQ/awesome-claude-skills@master`; no generic/marketplace install. 864 discoverable directories; nested manifest has 107 missing/misresolved source paths; most skills require Rube/MCP; licensing is incomplete. | +| `hermes-core` | `NousResearch/hermes-agent` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `NousResearch/hermes-agent@main/skills`; 82 skills; MIT. Exact boundary rejects optional and unrelated paths. | +| `hermes-optional` | `NousResearch/hermes-agent` | `main` | `optional-skills` | `optional` | `subtree` / `direct-selective` / `explicit-only` | `NousResearch/hermes-agent@main/optional-skills`; 117 skills; MIT; many platform/API/binary/service requirements. Never default bulk-install. | +| `anthropic-skills` | `anthropics/skills` | `main` | `skills` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/skills@main`, installed from repository root through its authoritative manifest. 20 audited skills; per-skill/repository license metadata caveat. | +| `addyosmani-agent-skills` | `addyosmani/agent-skills` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `addyosmani/agent-skills@main/skills`; 24 audited skills; MIT. | +| `obra-superpowers` | `obra/superpowers` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `obra/superpowers@main/skills`; 14 audited skills; MIT. | +| `context-engineering-skills` | `muratcankoylan/Agent-Skills-for-Context-Engineering` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `muratcankoylan/Agent-Skills-for-Context-Engineering@main`, repository root plus qualified allowlist; 23 repo-wide audited skills; MIT. | +| `elastic-docs-skills` | `elastic/elastic-docs-skills` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `elastic/elastic-docs-skills@main`, repository root plus qualified allowlist; 20 repo-wide audited skills; Apache-2.0. | +| `anthropic-knowledge-work` | `anthropics/knowledge-work-plugins` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/knowledge-work-plugins@main`; local root marketplace plugins only. 212 audited skills; Apache-2.0. Unsupported remote URL/git-subdir entries fail closed. | + +Metadata is mandatory catalog data, not runtime inference: + +| Source ID | Category | Author | Author URL | Homepage | SPDX | +|---|---|---|---|---|---| +| `gstack` | `software-engineering` | Garry Tan | `https://github.com/garrytan` | `https://github.com/garrytan/gstack` | `MIT` | +| `paperclip-companies` | `business-operations` | Paperclip AI | `https://github.com/paperclipai` | `https://github.com/paperclipai/companies` | `null` | +| `mattpocock-skills` | `software-engineering` | Matt Pocock | `https://github.com/mattpocock` | `https://aihero.dev/skills` | `MIT` | +| `composio-awesome-claude-skills` | `integrations` | Composio | `https://github.com/ComposioHQ` | `https://github.com/ComposioHQ/awesome-claude-skills` | `null` | +| `hermes-core` | `general-purpose` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | +| `hermes-optional` | `integrations` | Nous Research | `https://github.com/NousResearch` | `https://github.com/NousResearch/hermes-agent` | `MIT` | +| `anthropic-skills` | `general-purpose` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/skills` | `null` | +| `addyosmani-agent-skills` | `software-engineering` | Addy Osmani | `https://github.com/addyosmani` | `https://skills.addy.ie` | `MIT` | +| `obra-superpowers` | `software-engineering` | obra | `https://github.com/obra` | `https://github.com/obra/superpowers` | `MIT` | +| `context-engineering-skills` | `agent-engineering` | Murat Can Koylan | `https://github.com/muratcankoylan` | `https://github.com/muratcankoylan/Agent-Skills-for-Context-Engineering` | `MIT` | +| `elastic-docs-skills` | `documentation` | Elastic | `https://github.com/elastic` | `https://elastic.github.io/elastic-docs-skills/` | `Apache-2.0` | +| `anthropic-knowledge-work` | `knowledge-work` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/knowledge-work-plugins` | `Apache-2.0` | + +`null` SPDX values deliberately preserve licensing ambiguity or mixed/per-skill metadata. The order above is stable catalog order and the final relevance tie-breaker. Counts are audit evidence and drift sentinels, not runtime acceptance limits; upstream additions/removals do not automatically change classification or policy. + +## Catalog search algorithm + +### Query construction + +Keep `buildSearchQueries(query, owner)` for global search. Add catalog-specific construction rather than overloading owner semantics: + +1. Resolve `getSkillCatalog('recommended')` and deduplicate canonical repositories case-insensitively for network efficiency only. This does not deduplicate catalog identities; Hermes remains two sources. +2. Read-only preflight each unique repository through GitHub's repository/ref API. Require its default branch to equal every entry's `effectiveRef` and resolve the current head SHA. A mismatch or unavailable ref fails the named-catalog search with no global fallback. This constraint is why arbitrary refs are out of MVP. +3. Build the same semantic variants used today: path term, hyphenated content when applicable, and primary content. Catalog search omits query-as-owner because repository qualifiers define scope. +4. Batch repository qualifiers deterministically so each unencoded GitHub query stays below a documented 240-character internal ceiling. Each batch is emitted as a parenthesized OR group of exact `repo:owner/name` qualifiers. Never use `user:` or a broad owner qualifier for catalog mode. +5. Fetch page 1 at 100 results for advisory variants. For each primary batch, fetch enough 100-result pages to cover `page * limit * 3`, capped by GitHub's existing 1,000-result ceiling. +6. Dispatch independent query batches with `Promise.allSettled`. Any required repository/ref preflight or primary batch failure fails catalog search. Advisory failures may warn and merge survivors only when all catalog boundaries remain intact; none may trigger global search. + +Unit tests must assert the literal qualifier set, deterministic batching, query-length ceiling, omission of non-catalog repositories, one network repository qualifier for Hermes, two Hermes catalog identities, default-ref enforcement, and no fallback calls. + +### Application-side source enforcement + +GitHub query qualifiers are only a first filter. Before `rankByRelevance()`, `truncateForProcessing()`, enrichment, name deduplication, or pagination: + +1. Canonicalize response `repository.full_name` to lower case and require an exact catalog repository match. +2. Bind the response to the preflighted default branch/head and the entry's `effectiveRef`; never relabel a default-branch result as an arbitrary ref. +3. Normalize API paths as POSIX relative paths; reject empty paths, absolute paths, `.`/`..` segments, backslashes, and any basename other than `SKILL.md`. +4. Match `approvedRoot` by path segments: + - `.` matches any valid path in that repository; + - `skills` matches `skills/<...>/SKILL.md` and never `skills-old/...`; + - `optional-skills` matches `optional-skills/<...>/SKILL.md` and never `optional-skills-old/...`; + - exact boundary checks are case-sensitive because Git paths are case-sensitive. +5. If multiple entries in one repository match, select the entry with the longest matching `approvedRoot`; use stable catalog order only as a final tie-breaker. +6. Derive `installSelector` relative to `installRoot`. For repository roots with a `skills/` directory, strip the leading `skills/`; for subtree roots, strip the root. Preserve intermediate namespace segments so duplicate leaf names remain distinguishable. +7. Derive `installation.policy`, never a boolean: + - `external-installer` and `search-only` remain those exact policies; + - Paperclip hits outside exact `skills/`, or not resolvable by root discovery, become `search-only` with reason codes; + - marketplace hits remain `marketplace-selective`, but the picker enables them only after manifest preflight resolves the selected path to a valid local plugin; + - direct subtree/repository hits inside their installation boundary remain `direct-selective`. +8. Attach catalog identity, metadata, warning codes, discovery provenance, and exact install descriptor, then continue the existing hidden-directory filter, relevance scoring, enrichment, per-name cap, and pagination. + +Catalog deduplication keys are `catalog identity + path`; repository alone is never a catalog key. Global mode retains `repo + qualifiedName`. Stable ordering for equal relevance scores is catalog order, repository, then path; do not depend on network completion order. + +`total` is the application-filtered, de-duplicated working total. `truncated` is true if any GitHub batch is incomplete/capped or if additional filtered pages exist. Zero bounded hits returns zero catalog hits, not global results. + +## Search-to-install design + +Replace `collectSelectedSkillSearchRepos()` with `collectSelectedSkillSearchSources()` in `src/cli/commands/plugin-skills.ts`. A selection key is `catalog identity + repository path`; path or repository alone is insufficient. + +```ts +interface SelectedSkillSearchSource { + catalogIdentity?: string; + installDescriptor?: CatalogInstallDescriptor; + installSource: string; + installPolicy: 'repository-install' | SkillCatalogInstallPolicy; + classification?: SkillCatalogClassification; + warnings: readonly SkillCatalogWarning[]; + selectors: string[]; +} +``` + +Collection rules: + +- Preserve displayed result order. +- Group catalog hits by full catalog identity and exact install descriptor, never by repo. Global hits continue grouping by normalized `installSource`. +- Deduplicate selectors within an identity while preserving order. +- Never merge `hermes-core` and `hermes-optional`. +- Reject selected keys absent from the current bounded result set. +- Global results continue to group at repository root and use the existing whole-repository install behavior. + +For catalog direct-selective groups: + +1. Fetch the repository once, resolving the declared common root. +2. Discover from that root and require every selected qualified selector to resolve exactly. +3. Upsert one plugin entry whose `source` is the catalog `installSource` and whose `skills` array contains selected qualified selectors plus any existing selectors for that exact source. +4. Do not promote the source to a broader common repository root. +5. Sync once after all selected groups are configured. + +For catalog marketplace-selective groups: + +1. Fetch/register the root as a marketplace using existing manifest semantics. +2. Resolve each selected hit to a valid manifest plugin and local skill path. A manifest is authoritative; recursive paths outside it are not installed. +3. Add only the required `plugin@marketplace` entries and set per-plugin qualified allowlists. +4. Reject remote manifest source types unsupported by the current installer rather than treating their paths as local. +5. Sync once after all groups are configured. + +For optional groups, display warnings and require an explicit confirmation after selection. For `search-only` and `external-installer`, disable selection; if a stale/programmatic selection reaches the installer, return a validation-style error before config mutation. The external error includes the upstream lifecycle URL. No lifecycle script is executed by AllAgents. + +## Discovery provenance, install descriptor, and persisted provenance + +Discovery and installation are different observations and must not overwrite each other. + +```ts +export interface CatalogInstallDescriptor { + catalog: 'recommended'; + catalogVersion: 1; + sourceId: string; + repo: `${string}/${string}`; + effectiveRef: string; + approvedRoot: '.' | string; + installSource: string; + installRoot: '.' | string; + sourceKind: SkillCatalogSourceKind; + installPolicy: SkillCatalogInstallPolicy; +} + +export interface CatalogDiscoveryProvenance { + catalogIdentity: string; + provider: 'github-code-search'; + repo: `${string}/${string}`; + effectiveRef: string; + catalogVersion: 1; + approvedRoot: '.' | string; + repositoryHeadSha: string; + skillPath: string; + blobSha: string; +} +``` + +`CatalogDiscoveryProvenance` describes why a search hit was admitted: catalog identity, preflighted default ref/head, approved search root, response path, and Code Search blob SHA. It is returned in the search result and may be logged for diagnostics, but it is not installation proof. + +`CatalogInstallDescriptor` is immutable input to installation. Before mutation, the installer must re-resolve the exact repo/ref/root, reject descriptor drift, and clone/fetch `effectiveRef`. Arbitrary caller-provided ref overrides are rejected in MVP. + +Extend `src/models/workspace-config.ts::PluginEntrySchema` with an optional `catalogSource` object matching `CatalogInstallDescriptor`. Every catalog-originated project install writes it; user-scope installs use the same schema for consistency. The ordinary `source` remains the exact fetch/install spelling, including `@` and subpath. Marketplace plugin entries retain `plugin@marketplace` in `source` and use `catalogSource` to preserve the originating repo/ref/approved root/install root. + +```yaml +plugins: + - source: NousResearch/hermes-agent@main/skills + skills: [research/llm-wiki] + catalogSource: + catalog: recommended + catalogVersion: 1 + sourceId: hermes-core + repo: NousResearch/hermes-agent + effectiveRef: main + approvedRoot: skills + installSource: NousResearch/hermes-agent@main/skills + installRoot: skills + sourceKind: subtree + installPolicy: direct-selective +``` + +This is an additive workspace-schema-v2 field, not a schema-version migration. It prevents later sync/update code from reconstructing a catalog source from repository alone. + +Preserve existing repository-promotion behavior for non-catalog `skill add` by making matching mode explicit in `upsertGitHubPluginSourceAllowlistInConfig()` and project/user wrappers: + +```ts +{ identity: 'repository-promoting' | 'catalog-exact', catalogSource?: CatalogInstallDescriptor } +``` + +The default remains `repository-promoting`. Catalog installs require `catalog-exact`; they match/merge only identical full catalog identities and reject a conflicting descriptor. They never promote sibling catalog roots. + +Extend `src/models/sync-state.ts::SyncStateSourceSchema` additively with optional `catalogSource` and `resolvedRoot`. Catalog sync-state keys are the canonical catalog identities: + +```text +recommended:hermes-core@main#skills +recommended:hermes-optional@main#optional-skills +``` + +Actual install provenance is `{ catalogSource, resolvedRef, resolvedSha, resolvedRoot, pluginSpec }` captured after fetch/manifest resolution. It records the installed commit and root and is deliberately separate from the earlier discovery head/blob. The two Hermes records may share `resolvedSha` and physical repository cache, but never a state key. `src/core/plugin.ts::fetchCache` and `getPluginCachePath()` remain repository+ref keyed only for physical clone reuse. + +`src/core/sync.ts::buildSourcesProvenance()` rebuilds the complete derived `sources` map. For catalog entries it requires persisted `catalogSource`, verifies source/ref/root consistency, and emits catalog-identity keys. For non-catalog entries it preserves existing keys. A legacy repo-only state record still parses and is replaced on the next full sync; workspace config is never inferred from old sync state. + +Tests install core and optional in both orders and assert two workspace descriptors, two catalog-identity provenance keys, one physical cache identity, exact resolved roots/refs, and no promotion to repository root. + +## Clean-clone fix + +In `src/core/git.ts::createGit()`, set simple-git's `allowUnsafeFilter: true` alongside the existing `filter.lfs.*` config. Do not remove `GIT_LFS_SKIP_SMUDGE=1` or the LFS filter overrides; they prevent large LFS downloads during discovery/install. Do not apply a global user/repository git config. + +Add a regression to `tests/unit/core/git.test.ts` that creates a disposable local origin and calls the real `cloneTo()` into an empty destination. The test must exercise the configured `filter.lfs.*` path so it fails with simple-git's unsafe-filter rejection if `allowUnsafeFilter` is removed. Assert clone success and expected tracked content; do not merely snapshot an options object. + +This fix is stage zero for catalog install claims. A cache-seeded run is not acceptable evidence. + +## Manifest validation, read-only health, and review gate + +Create `src/core/skill-catalog-health.ts` with two reusable entry points: + +```ts +validateSkillCatalog(catalog: SkillCatalog): CatalogValidationIssue[] +checkSkillCatalogHealth(catalog: SkillCatalog, deps): Promise +``` + +`validateSkillCatalog()` is deterministic and offline: type/invariant checks, identity uniqueness, source/ref/root/install-source consistency, metadata presence, SPDX syntax when non-null, policy combinations, warning requirements, and absence of arbitrary refs. + +`checkSkillCatalogHealth()` is networked but strictly read-only. It performs only GitHub GET requests and returns per-source statuses `healthy`, `drifted`, or `unreachable` with reason codes; it never clones, installs, writes config, updates refs, opens PRs, or executes source code. It checks: + +1. repository exists and canonical name still matches; +2. default branch equals `effectiveRef` and the ref resolves; +3. `approvedRoot`, `installRoot`, and installable subpath exist at that ref; +4. representative `SKILL.md` paths remain within segment boundaries; +5. valid marketplace-selective entries have a parseable authoritative manifest at the effective ref; +6. every local manifest plugin source resolves inside `installRoot`, every selected skill path resolves inside `approvedRoot`, and no path escapes through `..`, absolute paths, symlinks, or misresolved relative sources; +7. search-only/external entries remain non-installable and retain required warning codes; Composio's broken nested manifest is reported as an expected health warning, never accepted as a marketplace manifest. + +Create `scripts/validate-skill-catalog.ts`, importing the same catalog object and health functions. Add package scripts: + +```json +"catalog:validate": "bun run scripts/validate-skill-catalog.ts --ci", +"catalog:health": "bun run scripts/validate-skill-catalog.ts --report" +``` + +Both modes are read-only. `--ci` fails on any static error, repository/ref/root drift, or invalid authoritative marketplace manifest. `--report` emits the same structured report for maintainers and exits nonzero for drift/unreachable sources; it never repairs anything. + +Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project dependencies and runs `bun run catalog:validate` with the workflow's read-only `GITHUB_TOKEN` (`contents: read`; no write permissions). Configure the repository ruleset/branch protection before implementation merge so this job is required and catalog changes require at least one human approval; record that setting and the health report/upstream SHAs in the PR. Any modification to `src/core/skill-catalog.ts`, catalog validation, or catalog policy must arrive through that path. If the required-check/approval rule cannot be enabled, catalog implementation remains blocked. No direct-to-main updater or automated catalog-writing workflow is added. + +## Exact repository changes + +### New files + +- `src/models/skill-catalog.ts` + - Catalog/source/metadata/provenance types and Zod `CatalogInstallDescriptorSchema`; no catalog entries or registry data. +- `src/core/skill-catalog.ts` + - Single schema-version-1 `Recommended` catalog, stable source IDs, explicit repo/ref/roots, metadata, warnings, identity constructor, and segment-boundary helpers. +- `src/core/skill-catalog-health.ts` + - Offline validation and dependency-injected read-only GitHub health/manifest checks. +- `scripts/validate-skill-catalog.ts` + - Read-only `--ci` and `--report` entry points over the same catalog/validator. +- `tests/unit/core/skill-catalog.test.ts` + - Typed-data invariants, identity, metadata, policies, exact boundaries, and Hermes separation. +- `tests/unit/core/skill-catalog-health.test.ts` + - Ref/root drift, manifest traversal/misresolution, status reporting, and zero-mutation dependency assertions. + +- `tests/unit/models/skill-catalog.test.ts` + - Descriptor schema acceptance/rejection and versioned serialization. + +### Modified production and configuration files + +- `src/core/skill-search.ts` + - Add `catalog` option, policy/metadata/provenance result fields, default-ref preflight, deterministic repository batching, and hard-boundary filtering before rank/pagination. + - Keep no-option global behavior, but never invoke it as catalog fallback. +- `src/cli/commands/plugin-skills.ts` + - Add `--catalog`, catalog-identity selection grouping, exact-descriptor direct/marketplace installers, warnings, and one final sync. + - Persist `catalogSource`; record actual install provenance separately from discovery provenance. +- `src/cli/metadata/plugin-skills.ts::skillsSearchMeta` + - Document `--catalog`, mutual exclusion, Recommended label, policy/metadata/provenance JSON fields, no fallback, and arbitrary-ref exclusion. +- `src/cli/tui/actions/skills.ts::runSearchOnlineSkills` + - Consume `installSource`; keep this TUI surface on global search in this change. +- `src/utils/plugin-path.ts` + - Parse/render exact repo/ref/root descriptors and retain segment-safe path normalization; repository identity is not catalog identity. +- `src/models/workspace-config.ts::PluginEntrySchema` + - Add optional typed `catalogSource: CatalogInstallDescriptor` while retaining workspace schema version 2. +- `src/core/workspace-modify.ts` + - Add repository-promoting versus catalog-exact upsert mode and preserve/validate catalog descriptors. +- `src/core/user-workspace.ts` + - Thread catalog-exact mode and descriptor through user-scoped allowlist upsert. +- `src/models/sync-state.ts::SyncStateSourceSchema` + - Add optional catalog descriptor and resolved root while retaining sync-state schema version 1. +- `src/core/sync.ts::buildSourcesProvenance` + - Emit full catalog-identity keys and exact root/ref install provenance. +- `src/core/git.ts::createGit` + - Enable `allowUnsafeFilter` for the intentional fixed LFS filters. +- `package.json` + - Add `catalog:validate` and `catalog:health` scripts. +- `.github/workflows/ci.yml` + - Add the `Catalog Manifest` validation job with read-only token permissions; mark it required in the repository ruleset before merge. + +`src/core/marketplace.ts` and `src/utils/marketplace-manifest-parser.ts` remain marketplace dependencies rather than catalog registries. Reuse their manifest schemas/resolution rules; do not store catalog entries in `MarketplaceRegistry`. + +### Modified tests + +- `tests/unit/core/skill-search.test.ts` +- `tests/unit/cli/skill-search-summary.test.ts` +- `tests/unit/cli/skills-add-standalone-install.test.ts` +- `tests/unit/core/github-skill-source-promotion.test.ts` +- `tests/unit/core/git.test.ts` +- `tests/unit/models/workspace-config.test.ts` +- `tests/unit/models/sync-state-schema.test.ts` +- `tests/e2e/plugin-skills.test.ts` + +Add a focused TUI unit only if the existing action is first made dependency-injectable without production-only indirection; otherwise cover `installSource` through the exported selection helper and perform the TUI smoke check manually. + +### Documentation and changelog + +- `README.md` command table: add `allagents skill search [--catalog recommended]`, label the catalog Recommended, and state global is the no-option default but never a catalog fallback. +- `docs/src/content/docs/docs/reference/cli.mdx`: add complete search syntax, flags, metadata/policy/provenance JSON fields, mutual exclusion, no-fallback behavior, default-ref-only MVP, and project config descriptor semantics. +- `docs/src/content/docs/docs/guides/marketplaces.mdx`: distinguish catalog sources from marketplaces and document authoritative-manifest validation without registering plain repositories. +- `CHANGELOG.md` under `Unreleased` / `Added`: Recommended catalog search, versioned source identity, exact ref/root descriptors, read-only health/CI validation, and warnings. Under `Fixed`: clean clone failure caused by simple-git unsafe LFS filter validation. + +## Automated test matrix + +### Catalog data and boundaries + +- Every source row above is present with exact source ID, repository, effective ref, approved root, install root/source, classification, kind, install policy, bulk policy, metadata, SPDX value, and warnings. +- Catalog schema version and label are exactly `1` and `Recommended`. +- Full identity serialization includes catalog, source ID, effective ref, and approved root; repository-only keys reject. +- Unknown catalog rejects. +- `catalog + owner` rejects before token resolution or network access. +- `skills/x/SKILL.md` matches Hermes core. +- `optional-skills/x/SKILL.md` matches Hermes optional. +- `optional-skills-old/x/SKILL.md`, `skills-old/x/SKILL.md`, and `docs/x/SKILL.md` match neither Hermes entry. +- Repository case is normalized; Git path case is not. +- Absolute, traversal, backslash, and non-`SKILL.md` paths reject. +- Paperclip `skills/company-creator/SKILL.md` has `direct-selective` policy and ambiguous-license warning; a hit elsewhere in the repository has `search-only` policy. +- Composio and gstack results remain visible with different non-install policies. + +### Query, ref, and merge behavior + +- Catalog queries contain only exact catalog `repo:` qualifiers. +- Unique repository network preflight treats Hermes once while retaining two catalog identities. +- Default branch/ref mismatch fails catalog search; no global search function is called. +- Arbitrary requested ref, tag, or SHA rejects in MVP. +- Long qualifier sets split under the query ceiling without omission or duplication. +- Query-as-owner is absent in catalog mode. +- A forged response from a non-catalog repository is discarded. +- Segment-boundary filtering occurs before truncation, ranking, enrichment, and pagination; fill a first response page with out-of-boundary hits and prove valid later hits survive. +- A required preflight/primary batch failure fails the search. Zero bounded hits stays zero. Neither path falls back globally. +- Equal-score results have deterministic catalog/repository/path ordering. +- Global query literals and existing relevance tests remain unchanged. + +### Selection and install behavior + +- Selection keys distinguish identical paths and identical repositories under different catalog identities. +- Multiple skills from one full catalog identity/descriptor produce one group and ordered unique selectors. +- Hermes core and optional produce two groups. +- Catalog direct installs write an exact ref-qualified common root/subtree source, never a selected skill directory. +- Selected nested skill fixtures copy `SKILL.md`, `references/`, scripts, and nested assets. +- Project workspace entries persist full `catalogSource`; marketplace plugin specs retain originating descriptor. +- Catalog-exact upsert retains two Hermes config entries in both install orders. +- Existing non-catalog sibling skill URLs still pass repository-promoting tests. +- Optional install requires confirmation; cancellation mutates nothing. +- Search-only/external stale selections fail before config/cache mutation. +- Marketplace selections outside an authoritative validated manifest fail closed. +- One selection transaction invokes one final sync. + +### Provenance, validation, and clone behavior + +- Discovery provenance contains bounded path/blob plus preflighted ref/head and is not reused as actual install provenance. +- Actual install provenance contains full descriptor, resolved ref/SHA/root, and catalog-identity state key. +- Real empty-destination clone succeeds with LFS filters enabled. +- Core and optional share physical repository cache/fetch but write two catalog-identity provenance records. +- Legacy repo-only sync-state input parses; next complete sync emits current catalog-identity keys and drops obsolete derived keys. +- Static catalog validation rejects missing metadata, invalid SPDX syntax, duplicate identity, illegal policy combinations, and non-default/arbitrary refs. +- Read-only health tests return `healthy`, `drifted`, or `unreachable`; injected mutating dependencies are never called. +- Authoritative marketplace manifest validation rejects missing/misresolved/escaping paths; Composio remains a warned search-only source. + +### CLI and JSON + +- `--catalog recommended` reaches `searchSkills()` and prints the Recommended label plus policy/warnings. +- Unknown catalog and catalog-owner conflict return exit 2 in text and JSON modes. +- JSON items contain exact install source/selector, policy reason codes, metadata, catalog identity, discovery provenance, and install descriptor; no `trusted`, `verified`, `safe`, or `installable` boolean exists. +- No-flag CLI and TUI searches remain global. +- Non-TTY mode never prompts or installs. + +## Project-scoped disposable install matrix + +Run this only after the clone regression and focused automated tests pass. From the implementation worktree, build once, capture the exact CLI path, then use that built CLI for every row: + +```sh +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" +bun run build +CLI="$REPO_ROOT/dist/index.js" +``` + +Each row gets a unique temporary `HOME` and project directory so no user cache, registry, credentials file, or workspace can make a clean clone appear successful. Initialize every row exactly as follows, execute the row's named catalog search and project install exercise from the matrix, inspect the three named outputs, then delete the case root: + +```sh +CASE_ROOT="$(mktemp -d)" +export HOME="$CASE_ROOT/home" +mkdir -p "$HOME" "$CASE_ROOT/project" +cd "$CASE_ROOT/project" +"$CLI" init . +# Execute the matrix row's catalog search and project-scoped install attempt. +# Inspect .allagents/workspace.yaml, .allagents/sync-state.json, +# and .agents/skills/ (the initialized universal-client target). +rm -rf "$CASE_ROOT" +``` + +Do not run `git config` outside a disposable fixture. Record command, upstream HEAD SHA, selected selectors or manifest plugins, copied/failed counts, resulting source strings, warnings, and asset checks in the implementation PR description. + +| Source | Clean project-scoped exercise | Required observation | +|---|---|---| +| gstack | Search catalog and attempt selection | `recommended:gstack@main#.` discovery stays bounded; picker blocks generic install and points to upstream lifecycle. No workspace mutation. | +| paperclip companies | Search, select `company-creator`, project install | Config source is `paperclipai/companies@main` with full descriptor and qualified allowlist; installed directory includes `references/`; ambiguous-license warning shown. | +| mattpocock skills | Search and install through manifest | Descriptor preserves `mattpocock/skills@main`; mandatory authoritative-manifest validation passes; recursive discoveries outside valid manifest resolution do not install. | +| Composio awesome skills | Search and attempt selection | Identity uses `@master#.`; results are optional/search-only with broken-manifest, dependency, and licensing warnings. No registration/install/config mutation. | +| Hermes core | Search and install selected core skills; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/skills`; config descriptor identity is `recommended:hermes-core@main#skills`; clean clone succeeds; discovery count is 82 at audited revision; selected copies have zero failures. | +| Hermes optional | Search and install one selected optional skill after warning; separate explicit full disposable discovery check | Source is exactly `NousResearch/hermes-agent@main/optional-skills`; config descriptor identity is `recommended:hermes-optional@main#optional-skills`; no default bulk selection; discovery count is 117; requirements warning shown. | +| Hermes core + optional | Install both in the same disposable project, then reverse order in a second project | Two ref/root-preserving config descriptors and two catalog-identity provenance keys, one physical cache, exact boundaries, zero source promotion. | +| anthropics skills | Search and marketplace-selective install | Descriptor preserves `anthropics/skills@main`, approved root `skills`, and repository install root; only manifest-declared local skill paths install; license-metadata warning remains visible. | +| addyosmani agent skills | Search and project-selective install | Exact `addyosmani/agent-skills@main/skills` source/descriptor, audited discovery count 24, complete selected directories, zero failures. | +| obra superpowers | Search and project-selective install | Exact `obra/superpowers@main/skills` source/descriptor, audited discovery count 14, complete selected directories, zero failures. | +| context engineering | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 23, zero failures. | +| Elastic docs skills | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 20, zero failures. | +| Anthropic knowledge work | Search and marketplace-selective install of local manifest entries | Descriptor preserves `anthropics/knowledge-work-plugins@main` root; audited search count 212; mandatory manifest validation passes for local entries; unsupported remote entries fail closed. | + +Count drift is not automatically a failure. If upstream HEAD differs from the audited revision, record the new count and classify every delta before updating catalog expectations. Missing assets, boundary leakage, manifest misresolution, config collapse, clone failure, or any copy failure is a release blocker. + +## Incorporated `numman-ali/n-skills` audit decisions + +Architectural audit reference: `numman-ali/n-skills` commit `b1c6173aa7f83c569248996e1db1b9ae7afdb76f`. + +Borrowed decisions, now reflected throughout this plan: + +- stable human-assigned source IDs; +- one schema-versioned typed catalog; +- explicit repository, effective ref, approved search root, and install root; +- category, homepage, author, and nullable SPDX metadata; +- separate discovery provenance and actual install provenance; +- catalog identity composed from catalog name, source ID, effective ref, and approved root; +- PR review, mandatory authoritative-manifest validation, and read-only health reporting; +- segment-boundary application filtering and no global fallback; +- exact descriptor/root/ref persistence through project workspace config. + +Explicitly not borrowed: + +- vendoring or mirroring upstream content; +- a direct-to-main updater or automated registry writer; +- shell-based catalog synchronization; +- boolean trust/safety/verification fields; +- dependency installation or lifecycle execution; +- split generated and hand-maintained registries. + +The audit commit informs architecture but does not, by itself, add `numman-ali/n-skills` as a catalog source. Adding it later requires an evidence-backed source row, classification, ref/root, metadata, manifest policy, warnings, disposable project install, health result, and reviewed PR. No finding is invented here. + +## Migration and compatibility + +- Workspace schema remains version 2 with an additive optional `catalogSource` descriptor. Existing entries parse unchanged; catalog-originated entries never drop this descriptor during modify/sync/update. +- Sync-state schema remains version 1 with additive optional catalog descriptor/resolved-root fields. `sources` is derived state; a full sync replaces obsolete repository-only catalog provenance with full catalog-identity keys. +- Existing global search calls receive additive fields and preserve no-option behavior. +- Existing `--owner`, pagination, rate-limit, token lookup, relevance, and global install behavior remain intact. +- Existing non-catalog source promotion remains the default matching mode; catalog installs use catalog-exact mode. +- Marketplace registries are neither seeded nor modified merely by searching or health checking. Registration occurs only after explicit install selection for a valid marketplace entry. +- No migration guesses catalog provenance for pre-existing repository entries. Only a future explicit catalog install/update can attach a descriptor. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| GitHub Code Search ignores/misparses a large repository qualifier expression. | Deterministic short query batches, literal query tests, application-side exact repository/root filtering, fatal required-batch failures, no fallback. | +| Search result comes from a different ref than installation. | MVP requires catalog ref to equal upstream default; read-only preflight binds discovery head; exact descriptor forces install ref; provenance remains separate. | +| Boundary prefix error leaks `optional-skills-old`. | Segment-based approved-root matcher with adversarial cases before ranking/pagination. | +| Catalog grows request count and hits Code Search rate limits. | Deduplicate only network repository preflights, batch qualifiers, retain token resolution, document truncation; never collapse source identities. | +| Search results advertise paths a manifest cannot install. | Mandatory authoritative-manifest CI validation plus install-time preflight; fail closed. | +| Two Hermes entries collapse in config or state. | Full catalog identity, persisted exact descriptors, catalog-exact upsert, two-order E2E matrix. | +| Selected skill loses assets. | Explicit install root plus qualified allowlist; asset-bearing fixture and Paperclip `references/` check. | +| Optional source installs hundreds of skills accidentally. | `bulkPolicy: 'explicit-only'`, no preselection, warning confirmation, stale-selection guard. | +| Recommended label is mistaken for endorsement. | Required warnings and metadata; never expose verified/safe/trusted booleans or wording. | +| Upstream ref/layout/count/license changes. | Read-only health report and required CI validation; record upstream SHA; catalog changes only through reviewed PRs. | +| Health tooling mutates upstream/local state. | GET-only dependency surface, mutation-negative tests, no repair/update mode. | +| LFS workaround weakens git safety globally. | Set `allowUnsafeFilter` only on the controlled `simple-git` instance with fixed filter keys; never accept user-supplied filters or alter global git config. | + +## Staged execution order + +1. **Fix clean clones first.** Add `allowUnsafeFilter`, the real local-clone regression, and confirm clean remote Matt/Hermes clones reach discovery. +2. **Add the one versioned catalog and validator.** Land stable IDs, metadata, explicit refs/roots, full identity, policy enums, static invariants, and audited source rows. +3. **Add read-only health and CI manifest gate.** Implement GET-only checks, package scripts, required `Catalog Manifest` job, and review evidence format. +4. **Extend the core search API.** Add catalog option, default-ref preflight, qualifier batching, segment-boundary enforcement, metadata/discovery provenance, stable ordering, and no-fallback tests. +5. **Persist exact install descriptors.** Extend workspace/sync-state schemas additively; implement catalog-exact upsert and full catalog-identity provenance keys. +6. **Implement catalog-aware CLI selection/install.** Add flag/validation, identity grouping, exact direct/marketplace descriptors, warnings, one-sync transaction, and stale-selection guards. +7. **Update the global TUI consumer.** Use `installSource` without adding catalog selection to the TUI. +8. **Run focused automated suites.** Catalog, health/manifest, search, selection, workspace identity, provenance, clone, and plugin-skills E2E. +9. **Run the disposable project matrix.** Fresh `HOME` per row, built CLI, actual remote sources, exact config/state/filesystem evidence. +10. **Update durable docs and changelog.** README, CLI reference, marketplace distinction, maintenance/review gate, and `Unreleased` entries. +11. **Final reviewed PR gate.** Attach health report/upstream SHAs, pass mandatory manifest CI, obtain human review, and verify the catalog is labeled Recommended without endorsement language. + +## Implementation completion criteria + +Implementation is complete only when: + +- `allagents skill search --catalog recommended` and `searchSkills(query, { catalog: 'recommended' })` enforce catalog/ref/root hard boundaries without global fallback; +- `--catalog` plus `--owner` and any arbitrary ref override fail as specified; +- all source rows expose exact IDs, refs/roots, metadata, classification, policy, warnings, and full identities; +- gstack and Composio cannot enter generic install flows; +- Paperclip preserves `company-creator/references`; +- Hermes core and optional remain distinct in result identity, selection, workspace descriptors, and actual install provenance; +- discovery and install provenance remain separate; +- clean clones succeed without cache seeding; +- authoritative marketplace manifests pass the required CI validator and install-time preflight; +- read-only health checks report drift without mutation; +- project workspace config preserves exact catalog install descriptor/root/ref through subsequent sync/update; +- global search remains the no-option default but is never a named-catalog fallback; +- focused tests and every applicable disposable matrix row pass with evidence recorded in the implementation PR; +- the reviewed PR passes `Catalog Manifest` CI and README, CLI reference, marketplace guide, and changelog match the shipped contract. From 69032b0c3d3f0c3fe47914eeb92e5a3ece3044eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 11:47:13 +1000 Subject: [PATCH 06/11] fix(skills): remove Elastic from recommended catalog --- .claude/plans/recommended-skill-catalog.md | 5 +---- CHANGELOG.md | 2 +- src/core/skill-catalog.ts | 20 -------------------- tests/unit/core/skill-catalog.test.ts | 1 - 4 files changed, 2 insertions(+), 26 deletions(-) diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md index 87a9702..a7b4622 100644 --- a/.claude/plans/recommended-skill-catalog.md +++ b/.claude/plans/recommended-skill-catalog.md @@ -272,7 +272,7 @@ Invariants checked by unit and manifest validation: The `recommended` catalog is the name of the built-in catalog, not a claim that every member is generally recommended. It contains all entries below so users can search a known set while seeing each source's actual classification. -In this plan, `optional` is the optional/experimental classification requested by the product contract. The final six rows are the useful candidates sourced from EntityProcess's `ai-research-wiki` and rechecked against their upstream repositories: Anthropic Skills, Addy Osmani Agent Skills, Superpowers, Context Engineering Skills, Elastic Docs Skills, and Anthropic Knowledge Work Plugins. +In this plan, `optional` is the optional/experimental classification requested by the product contract. The final five rows are the broadly reusable candidates sourced from EntityProcess's `ai-research-wiki` and rechecked against their upstream repositories: Anthropic Skills, Addy Osmani Agent Skills, Superpowers, Context Engineering Skills, and Anthropic Knowledge Work Plugins. | Source ID | Repository | Effective ref | Approved root | Classification | Kind / install / bulk policy | Install source and audited facts | |---|---|---|---|---|---|---| @@ -286,7 +286,6 @@ In this plan, `optional` is the optional/experimental classification requested b | `addyosmani-agent-skills` | `addyosmani/agent-skills` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `addyosmani/agent-skills@main/skills`; 24 audited skills; MIT. | | `obra-superpowers` | `obra/superpowers` | `main` | `skills` | `recommended` | `subtree` / `direct-selective` / `allowed` | `obra/superpowers@main/skills`; 14 audited skills; MIT. | | `context-engineering-skills` | `muratcankoylan/Agent-Skills-for-Context-Engineering` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `muratcankoylan/Agent-Skills-for-Context-Engineering@main`, repository root plus qualified allowlist; 23 repo-wide audited skills; MIT. | -| `elastic-docs-skills` | `elastic/elastic-docs-skills` | `main` | `skills` | `recommended` | `repository` / `direct-selective` / `allowed` | `elastic/elastic-docs-skills@main`, repository root plus qualified allowlist; 20 repo-wide audited skills; Apache-2.0. | | `anthropic-knowledge-work` | `anthropics/knowledge-work-plugins` | `main` | `.` | `recommended` | `marketplace` / `marketplace-selective` / `allowed` | `anthropics/knowledge-work-plugins@main`; local root marketplace plugins only. 212 audited skills; Apache-2.0. Unsupported remote URL/git-subdir entries fail closed. | Metadata is mandatory catalog data, not runtime inference: @@ -303,7 +302,6 @@ Metadata is mandatory catalog data, not runtime inference: | `addyosmani-agent-skills` | `software-engineering` | Addy Osmani | `https://github.com/addyosmani` | `https://skills.addy.ie` | `MIT` | | `obra-superpowers` | `software-engineering` | obra | `https://github.com/obra` | `https://github.com/obra/superpowers` | `MIT` | | `context-engineering-skills` | `agent-engineering` | Murat Can Koylan | `https://github.com/muratcankoylan` | `https://github.com/muratcankoylan/Agent-Skills-for-Context-Engineering` | `MIT` | -| `elastic-docs-skills` | `documentation` | Elastic | `https://github.com/elastic` | `https://elastic.github.io/elastic-docs-skills/` | `Apache-2.0` | | `anthropic-knowledge-work` | `knowledge-work` | Anthropic | `https://github.com/anthropics` | `https://github.com/anthropics/knowledge-work-plugins` | `Apache-2.0` | `null` SPDX values deliberately preserve licensing ambiguity or mixed/per-skill metadata. The order above is stable catalog order and the final relevance tie-breaker. Counts are audit evidence and drift sentinels, not runtime acceptance limits; upstream additions/removals do not automatically change classification or policy. @@ -686,7 +684,6 @@ Do not run `git config` outside a disposable fixture. Record command, upstream H | addyosmani agent skills | Search and project-selective install | Exact `addyosmani/agent-skills@main/skills` source/descriptor, audited discovery count 24, complete selected directories, zero failures. | | obra superpowers | Search and project-selective install | Exact `obra/superpowers@main/skills` source/descriptor, audited discovery count 14, complete selected directories, zero failures. | | context engineering | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 23, zero failures. | -| Elastic docs skills | Search and project-selective install | Descriptor preserves ref, approved `skills` root, and repository install root; audited discovery count 20, zero failures. | | Anthropic knowledge work | Search and marketplace-selective install of local manifest entries | Descriptor preserves `anthropics/knowledge-work-plugins@main` root; audited search count 212; mandatory manifest validation passes for local entries; unsupported remote entries fail closed. | Count drift is not automatically a failure. If upstream HEAD differs from the audited revision, record the new count and classify every delta before updating catalog expectations. Missing assets, boundary leakage, manifest misresolution, config collapse, clone failure, or any copy failure is a release blocker. diff --git a/CHANGELOG.md b/CHANGELOG.md index aee27a3..166a2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Added -- **Recommended skill catalog**: Added `allagents skill search --catalog recommended`, a hard discovery boundary over curated repository, subtree, marketplace-backed, search-only, and external-lifecycle sources. The catalog includes gstack, Paperclip companies, Matt Pocock skills, Composio awesome skills, distinct Hermes core and optional roots, Anthropic skills and knowledge-work plugins, Addy Osmani skills, obra superpowers, context-engineering skills, and Elastic docs skills. +- **Recommended skill catalog**: Added `allagents skill search --catalog recommended`, a hard discovery boundary over curated repository, subtree, marketplace-backed, search-only, and external-lifecycle sources. The catalog includes gstack, Paperclip companies, Matt Pocock skills, Composio awesome skills, distinct Hermes core and optional roots, Anthropic skills and knowledge-work plugins, Addy Osmani skills, obra superpowers, and context-engineering skills. - Catalog results expose classification, install policy, warnings, source metadata, exact install source and selector, and separate discovery provenance. **Recommended** is a discovery label, not a security, trust, quality, or license guarantee. - Interactive catalog search supports selective project- or user-scoped installation. Workspace configuration retains exact repository/ref/root/source identity and the selected subset; sync state retains exact resolved install provenance. Optional sources require confirmation, while search-only and external-installer sources remain non-installable. - Added authenticated, read-only catalog health validation in CI for repository/ref/root drift, skill presence, local authoritative marketplace paths, and source identity. The check never mutates, vendors, or automatically updates catalog entries. diff --git a/src/core/skill-catalog.ts b/src/core/skill-catalog.ts index 32d919b..eec8d70 100644 --- a/src/core/skill-catalog.ts +++ b/src/core/skill-catalog.ts @@ -264,26 +264,6 @@ const RECOMMENDED_SOURCE_ENTRIES: readonly SkillCatalogSource[] = Object.freeze( manifestBoundary: 'none', warnings: warnings(), }), - source({ - sourceId: 'elastic-docs-skills', - repo: 'elastic/elastic-docs-skills', - effectiveRef: 'main', - approvedRoot: 'skills', - installRoot: '.', - installSource: 'elastic/elastic-docs-skills@main', - displayName: 'Elastic Docs Skills', - description: 'Documentation skills maintained by Elastic.', - category: 'documentation', - homepage: 'https://elastic.github.io/elastic-docs-skills/', - author: { name: 'Elastic', url: 'https://github.com/elastic' }, - spdxLicense: 'Apache-2.0', - classification: 'recommended', - sourceKind: 'repository', - installPolicy: 'direct-selective', - bulkPolicy: 'allowed', - manifestBoundary: 'none', - warnings: warnings(), - }), source({ sourceId: 'anthropic-knowledge-work', repo: 'anthropics/knowledge-work-plugins', diff --git a/tests/unit/core/skill-catalog.test.ts b/tests/unit/core/skill-catalog.test.ts index 9aa5130..2dc523c 100644 --- a/tests/unit/core/skill-catalog.test.ts +++ b/tests/unit/core/skill-catalog.test.ts @@ -20,7 +20,6 @@ const EXPECTED_SOURCES = [ ['addyosmani-agent-skills', 'addyosmani/agent-skills', 'main', 'skills', 'skills', 'recommended', 'subtree', 'direct-selective', 'allowed', 'MIT'], ['obra-superpowers', 'obra/superpowers', 'main', 'skills', 'skills', 'recommended', 'subtree', 'direct-selective', 'allowed', 'MIT'], ['context-engineering-skills', 'muratcankoylan/Agent-Skills-for-Context-Engineering', 'main', 'skills', '.', 'recommended', 'repository', 'direct-selective', 'allowed', 'MIT'], - ['elastic-docs-skills', 'elastic/elastic-docs-skills', 'main', 'skills', '.', 'recommended', 'repository', 'direct-selective', 'allowed', 'Apache-2.0'], ['anthropic-knowledge-work', 'anthropics/knowledge-work-plugins', 'main', '.', '.', 'recommended', 'marketplace', 'marketplace-selective', 'allowed', 'Apache-2.0'], ] as const; From db349533ad836ed34a7f8a77e843c508e5b867ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 12:11:14 +1000 Subject: [PATCH 07/11] feat(skills): group recommended interactive search --- .claude/plans/recommended-skill-catalog.md | 75 ++++--- CHANGELOG.md | 2 +- README.md | 28 +-- .../content/docs/docs/guides/marketplaces.mdx | 10 +- docs/src/content/docs/docs/reference/cli.mdx | 44 +++-- src/cli/commands/plugin-skills.ts | 123 +++++++----- src/cli/metadata/plugin-skills.ts | 8 +- src/cli/skill-search-presentation.ts | 79 ++++++++ src/cli/tui/__tests__/skills.test.ts | 152 +++++++++++++++ src/cli/tui/actions/skills.ts | 103 +++++++--- src/core/skill-search.ts | 142 ++++++++++++++ tests/unit/cli/skill-search-summary.test.ts | 10 + .../core/interactive-skill-search.test.ts | 184 ++++++++++++++++++ 13 files changed, 828 insertions(+), 132 deletions(-) create mode 100644 src/cli/skill-search-presentation.ts create mode 100644 src/cli/tui/__tests__/skills.test.ts create mode 100644 tests/unit/core/interactive-skill-search.test.ts diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md index a7b4622..72ae070 100644 --- a/.claude/plans/recommended-skill-catalog.md +++ b/.claude/plans/recommended-skill-catalog.md @@ -1,21 +1,44 @@ # Recommended Skill Catalog Implementation Plan -**Status:** Draft PR plan only +**Status:** Implemented in draft PR #455; maintained product and verification plan **Date:** 2026-08-24 -**Implementation target:** A later feature branch based on then-current `origin/main` +**Implementation target:** `feat/recommended-skill-catalog`, kept current with `origin/main` ## Decision -Add one built-in, typed catalog named `recommended` without changing the default search scope: +Add one built-in, typed catalog named `recommended`. Preserve global GitHub as +the no-option core API, JSON, redirected-output, and non-TTY contract, while +making human discovery intuitive: ```text allagents skill search --catalog recommended searchSkills(query, { catalog: 'recommended' }) +searchInteractiveSkills(query) // Recommended, then deduplicated All GitHub ``` -Without `--catalog`, `skill search` remains global GitHub Code Search. `--catalog` and `--owner` are mutually exclusive and fail validation with exit code 2. When `--catalog recommended` is present, the named catalog is a hard boundary: an empty result, source-health failure, query-batch failure, or manifest failure never falls back to global GitHub results. The catalog is not a marketplace registry; it describes repositories, subtrees, valid marketplace-backed sources, search-only sources, and external-lifecycle distributions without registering any of them in `MarketplaceRegistry`. - -The one user-facing catalog label is exactly **Recommended**. This is a discovery label, not a security, trust, or license assertion. UI, JSON documentation, and release notes must never call catalog entries “verified” or “safe.” Every catalog result instead carries explicit source classification, install policy, metadata, and warnings. Catalog membership must never claim that a source is security-reviewed, license-approved, dependency-complete, compatible with every client, or safe to bulk-install. +Without an explicit catalog or owner, both interactive search surfaces fetch a +catalog-bounded Recommended result set and the legacy global result set +concurrently after query submission. Recommended is rendered first. All GitHub +is rendered second after removing only identical canonical repository plus +qualified skill-path identities; display names alone never deduplicate. A +failure on one side is visibly reported and leaves the surviving side usable. + +`--catalog` and `--owner` remain mutually exclusive and fail validation with +exit code 2. `--owner` remains global-only. When `--catalog recommended` is +present, the named catalog is a hard boundary: an empty result, source-health +failure, query-batch failure, or manifest failure never falls back to global +GitHub results. The catalog is not a marketplace registry; it describes +repositories, subtrees, valid marketplace-backed sources, search-only sources, +and external-lifecycle distributions without registering any of them in +`MarketplaceRegistry`. + +The one user-facing catalog label is exactly **Recommended**. This is a +discovery label, not a security, trust, or license assertion. UI, JSON +documentation, and release notes must never call catalog entries “verified” or +“safe.” Every catalog result instead carries explicit source classification, +install policy, metadata, and warnings. Catalog membership must never claim +that a source is security-reviewed, license-approved, dependency-complete, +compatible with every client, or safe to bulk-install. ## Problem and observed repository state @@ -92,19 +115,27 @@ Validation is centralized in `validateSkillSearchArgs()`: - Both are `SkillSearchError` with `kind: 'validation'`; CLI exit code remains 2 and JSON uses the existing failed command envelope. - Existing query length, page, limit, owner, API, and rate-limit behavior remains unchanged. -TTY catalog results use the existing multi-select, but: +TTY search uses ordered, visually distinct sections: -- installable results are grouped by exact `installSource`; -- search-only and external-lifecycle results remain visible but are disabled in the picker; -- warnings are included in the hint and repeated once before an optional/experimental install; -- no source is preselected; -- only explicitly selected skills are enabled; -- one source is fetched/configured once and all selected qualified selectors for it are written in one allowlist update; -- all source mutations complete before one project/user sync, rather than syncing once per selected skill. +- no explicit catalog or owner: Recommended first, then deduplicated All GitHub; +- explicit `--catalog recommended`: Recommended only, with strict failures; +- explicit `--owner`: All GitHub only, scoped to that owner; +- installable catalog results remain grouped by exact `installSource` for mutation; +- search-only and external-lifecycle results remain visible but disabled; +- warnings are included in hints and repeated before optional installation; +- no source is preselected, and only explicitly selected skills are enabled; +- one source is fetched/configured once and selected qualified selectors share one allowlist update; +- all source mutations complete before one project/user sync. -Non-TTY output keeps the table format and appends a concise policy marker only when needed, such as `optional`, `search only`, or `external installer`. JSON is authoritative for full warnings. +Non-TTY and JSON no-catalog output keep the legacy global result/table or JSON +envelope. JSON remains authoritative for full warnings. Neither path invokes a +catalog query unless `--catalog recommended` is explicit. -The TUI action `src/cli/tui/actions/skills.ts::runSearchOnlineSkills()` continues using global search because it has no catalog selector in this scope. It must use `item.installSource` rather than reconstructing a repository source, preserving compatibility for global results where `installSource === repo`. +The full-screen TUI action +`src/cli/tui/actions/skills.ts::runSearchOnlineSkills()` uses the same +`searchInteractiveSkills()` provider and presentation rows. Selection keys map +back to the exact result, and catalog selections flow through the shared exact +descriptor transaction rather than reconstructing a repository source. ### Core API @@ -719,9 +750,9 @@ The audit commit informs architecture but does not, by itself, add `numman-ali/n - Workspace schema remains version 2 with an additive optional `catalogSource` descriptor. Existing entries parse unchanged; catalog-originated entries never drop this descriptor during modify/sync/update. - Sync-state schema remains version 1 with additive optional catalog descriptor/resolved-root fields. `sources` is derived state; a full sync replaces obsolete repository-only catalog provenance with full catalog-identity keys. -- Existing global search calls receive additive fields and preserve no-option behavior. -- Existing `--owner`, pagination, rate-limit, token lookup, relevance, and global install behavior remain intact. -- Existing non-catalog source promotion remains the default matching mode; catalog installs use catalog-exact mode. +- Existing global `searchSkills()` calls, JSON no-catalog output, and redirected no-catalog output preserve their behavior. +- TTY no-owner/no-catalog discovery is intentionally additive: it uses the new interactive provider and shows Recommended before deduplicated All GitHub. +- Existing `--owner`, pagination, rate-limit, relevance, and global install behavior remain intact; owner scope never injects catalog sources from other owners. - Marketplace registries are neither seeded nor modified merely by searching or health checking. Registration occurs only after explicit install selection for a valid marketplace entry. - No migration guesses catalog provenance for pre-existing repository entries. Only a future explicit catalog install/update can attach a descriptor. @@ -732,7 +763,7 @@ The audit commit informs architecture but does not, by itself, add `numman-ali/n | GitHub Code Search ignores/misparses a large repository qualifier expression. | Deterministic short query batches, literal query tests, application-side exact repository/root filtering, fatal required-batch failures, no fallback. | | Search result comes from a different ref than installation. | MVP requires catalog ref to equal upstream default; read-only preflight binds discovery head; exact descriptor forces install ref; provenance remains separate. | | Boundary prefix error leaks `optional-skills-old`. | Segment-based approved-root matcher with adversarial cases before ranking/pagination. | -| Catalog grows request count and hits Code Search rate limits. | Deduplicate only network repository preflights, batch qualifiers, retain token resolution, document truncation; never collapse source identities. | +| Combined interactive discovery increases request count and can hit Code Search rate limits. | Resolve authentication once, fetch catalog and global sets concurrently with independent limits, label section-local failures, preserve surviving results, and never reinterpret a failed strict catalog query as global. | | Search results advertise paths a manifest cannot install. | Mandatory authoritative-manifest CI validation plus install-time preflight; fail closed. | | Two Hermes entries collapse in config or state. | Full catalog identity, persisted exact descriptors, catalog-exact upsert, two-order E2E matrix. | | Selected skill loses assets. | Explicit install root plus qualified allowlist; asset-bearing fixture and Paperclip `references/` check. | @@ -750,7 +781,7 @@ The audit commit informs architecture but does not, by itself, add `numman-ali/n 4. **Extend the core search API.** Add catalog option, default-ref preflight, qualifier batching, segment-boundary enforcement, metadata/discovery provenance, stable ordering, and no-fallback tests. 5. **Persist exact install descriptors.** Extend workspace/sync-state schemas additively; implement catalog-exact upsert and full catalog-identity provenance keys. 6. **Implement catalog-aware CLI selection/install.** Add flag/validation, identity grouping, exact direct/marketplace descriptors, warnings, one-sync transaction, and stale-selection guards. -7. **Update the global TUI consumer.** Use `installSource` without adding catalog selection to the TUI. +7. **Update both interactive consumers.** Share concurrent grouped discovery and stable presentation rows; preserve exact catalog selection/install descriptors in the full-screen TUI. 8. **Run focused automated suites.** Catalog, health/manifest, search, selection, workspace identity, provenance, clone, and plugin-skills E2E. 9. **Run the disposable project matrix.** Fresh `HOME` per row, built CLI, actual remote sources, exact config/state/filesystem evidence. 10. **Update durable docs and changelog.** README, CLI reference, marketplace distinction, maintenance/review gate, and `Unreleased` entries. @@ -771,6 +802,6 @@ Implementation is complete only when: - authoritative marketplace manifests pass the required CI validator and install-time preflight; - read-only health checks report drift without mutation; - project workspace config preserves exact catalog install descriptor/root/ref through subsequent sync/update; -- global search remains the no-option default but is never a named-catalog fallback; +- global search remains the core API, JSON, non-TTY, and owner-scoped no-catalog default; interactive no-owner/no-catalog discovery shows Recommended first without becoming a named-catalog fallback; - focused tests and every applicable disposable matrix row pass with evidence recorded in the implementation PR; - the reviewed PR passes `Catalog Manifest` CI and README, CLI reference, marketplace guide, and changelog match the shipped contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index 166a2d6..2e81d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - **Recommended skill catalog**: Added `allagents skill search --catalog recommended`, a hard discovery boundary over curated repository, subtree, marketplace-backed, search-only, and external-lifecycle sources. The catalog includes gstack, Paperclip companies, Matt Pocock skills, Composio awesome skills, distinct Hermes core and optional roots, Anthropic skills and knowledge-work plugins, Addy Osmani skills, obra superpowers, and context-engineering skills. - Catalog results expose classification, install policy, warnings, source metadata, exact install source and selector, and separate discovery provenance. **Recommended** is a discovery label, not a security, trust, quality, or license guarantee. -- Interactive catalog search supports selective project- or user-scoped installation. Workspace configuration retains exact repository/ref/root/source identity and the selected subset; sync state retains exact resolved install provenance. Optional sources require confirmation, while search-only and external-installer sources remain non-installable. +- Interactive `skill search` and full-screen TUI discovery now fetch Recommended and global GitHub results concurrently, render Recommended first and All GitHub second, and let Recommended win exact repository/skill-path duplicates. Partial failures are labeled while surviving results remain usable. JSON, redirected no-catalog output, owner-scoped search, and explicit strict catalog search keep their previous boundaries. Exact catalog install descriptors survive selection in both interactive surfaces. Optional sources require confirmation, while search-only and external-installer sources remain non-installable. - Added authenticated, read-only catalog health validation in CI for repository/ref/root drift, skill presence, local authoritative marketplace paths, and source identity. The check never mutates, vendors, or automatically updates catalog entries. ### Fixed diff --git a/README.md b/README.md index 182dc99..d4fd178 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ clients: | `allagents plugin uninstall ` | Remove a plugin | | `allagents plugin list` | List available plugins | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | -| `allagents skill search [--catalog recommended]` | Search globally or within the built-in Recommended catalog | +| `allagents skill search [--catalog recommended]` | Discover Recommended first, then global GitHub; use `--catalog` for strict catalog-only search | | `allagents skill list` | List skills and status | | `allagents mcp add ` | Add an MCP server and sync to clients | | `allagents mcp proxy ` | Bridge a remote HTTP MCP server to local stdio | @@ -107,21 +107,27 @@ See the [full CLI reference](https://allagents.dev/docs/reference/cli/) for all ## Recommended Skill Catalog -`allagents skill search ` keeps the existing global GitHub search. Add -`--catalog recommended` to search only the built-in, versioned Recommended -catalog: +In an interactive terminal, `allagents skill search ` automatically +shows two ordered sections: **Recommended** first, then **All GitHub**. The +Recommended section is fetched through the built-in catalog boundary rather +than inferred from globally ranked results. Identical repository and skill-path +hits are removed from All GitHub so the Recommended result wins. + +Scripts, redirected output, and `--json` keep the existing global GitHub +no-option response. Use `--catalog recommended` when discovery must be strictly +limited to the built-in, versioned catalog: ```bash allagents skill search testing --catalog recommended ``` -The catalog is a hard discovery boundary: catalog failures and empty results do -not fall back to global search. **Recommended** is a discovery label, not a -security, trust, quality, or license guarantee; review each result's source, -classification, policy, warnings, and upstream content. In an interactive -terminal, installable results can be selected for project scope (including a -new disposable workspace) or user scope. Exact repository, ref, catalog root, -selected skill subset, and provenance are retained for later syncs. +Strict catalog failures and empty results never fall back to global search. In +combined interactive discovery, the two searches run concurrently; if one is +unavailable, its section is visibly marked and the surviving section remains +usable. **Recommended** is a discovery label, not a security, trust, quality, +or license guarantee. Review each result's source, classification, policy, +warnings, and upstream content. Installable selections retain the exact +repository, ref, catalog root, selected skill subset, and provenance. ## Supported Clients diff --git a/docs/src/content/docs/docs/guides/marketplaces.mdx b/docs/src/content/docs/docs/guides/marketplaces.mdx index 19cdfef..26ca13d 100644 --- a/docs/src/content/docs/docs/guides/marketplaces.mdx +++ b/docs/src/content/docs/docs/guides/marketplaces.mdx @@ -29,7 +29,9 @@ The bare repo name (e.g., `repo`) is reserved for the default branch. Updates au ## Recommended Catalog vs. Marketplaces The built-in Recommended skill catalog is a static discovery index, not a -`MarketplaceRegistry` entry. Search it with: +`MarketplaceRegistry` entry. Interactive searches with no explicit owner or +catalog show Recommended first and deduplicated All GitHub results second. Use +the explicit flag for a strict catalog-only search suitable for scripts: ```bash allagents skill search --catalog recommended @@ -38,9 +40,9 @@ allagents skill search --catalog recommended The catalog includes heterogeneous source types: direct repositories, subtrees, valid marketplace-backed distributions, search-only repositories, and projects with an external installation lifecycle. Catalog inclusion does -not make a source a marketplace and does not imply that it is one-click -installable. Search-only sources cannot be selected. External-lifecycle sources -show upstream guidance; AllAgents never executes their installer. +not make a source a marketplace or imply that it is one-click installable. +Search-only sources cannot be selected. External-lifecycle sources show +upstream guidance; AllAgents never executes their installer. For a marketplace-backed catalog result, AllAgents validates the authoritative upstream manifest and registers it only when the user selects an installable diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 4db1ca2..5ce07c5 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -261,15 +261,30 @@ After enabling, the skill is removed from `disabledSkills` and sync is run to re ### skill search -Search public `SKILL.md` files. The default remains global GitHub Code Search: +Search public `SKILL.md` files. In an interactive terminal with no explicit +`--catalog` or `--owner`, results appear in two ordered sections: + +1. **Recommended** — an independent catalog-bounded search, including catalog + classification, policy, warnings, metadata, and exact install descriptors. +2. **All GitHub** — ordinary global results, excluding identical repository and + skill-path identities already shown in Recommended. + +This separate catalog query prevents Recommended matches from being displaced +by global ranking or pagination. Both searches run concurrently after the user +submits a term. If one side fails, its section is marked unavailable and the +surviving results remain selectable rather than being presented as complete. + +Scripts, redirected output, and `--json` retain the existing global GitHub +no-option contract: ```bash allagents skill search terraform +allagents --json skill search terraform allagents skill search terraform --owner hashicorp ``` -Use the built-in Recommended catalog when discovery must stay within its exact -repository and subtree boundaries: +Use the built-in Recommended catalog explicitly when discovery must stay within +its exact repository and subtree boundaries: ```bash allagents skill search testing --catalog recommended @@ -279,14 +294,14 @@ allagents --json skill search testing --catalog recommended | Flag | Description | |------|-------------| | `--catalog recommended` | Search only the versioned built-in catalog. Unknown names are rejected. | -| `--owner ` | Limit global search to one GitHub owner. Mutually exclusive with `--catalog`. | +| `--owner ` | Limit global search to one GitHub owner; no Recommended results from other owners are injected. Mutually exclusive with `--catalog`. | | `--page ` | 1-indexed result page (default: `1`). | -| `--limit ` | Results per page, from 1 to 100 (default: `15`). | +| `--limit ` | Results per page, from 1 to 100 (default: `15`). In combined interactive mode, each section is fetched independently at this limit. | Unknown catalogs and the `--catalog`/`--owner` conflict exit with status 2. -Catalog mode is a hard boundary: empty results, source-health failures, GitHub -query failures, and manifest failures never fall back to global results. -Repository constraints are applied to every GitHub query and results are also +Explicit catalog mode is a hard boundary: empty results, source-health failures, +GitHub query failures, and manifest failures never fall back to global results. +Repository constraints are applied to every catalog query and results are also filtered against catalog roots before ranking and pagination. **Recommended** is a discovery label. It is not a security review, trust or @@ -297,13 +312,12 @@ Optional sources require explicit confirmation. `search-only` results remain visible but cannot be installed; `external-installer` results point to the upstream lifecycle and AllAgents does not run it. -In a TTY, select one or more installable results and choose project or user -scope. Project installation works in a newly initialized disposable folder. -Only the selected skill paths are enabled. Marketplace-backed selections are -checked against their authoritative manifest; direct repositories and subtrees -use the exact catalog repository, default-branch ref, and root. Hermes core -(`skills`) and Hermes optional (`optional-skills`) are separate source -identities and stay separate through installation and sync. +In either interactive surface, select installable results and choose project or +user scope. Catalog selections use the exact catalog install source and +qualified selector. Marketplace-backed selections are checked against their +authoritative manifest; direct repositories and subtrees use the exact catalog +repository, default-branch ref, and root. Hermes core (`skills`) and Hermes +optional (`optional-skills`) remain separate through installation and sync. Discovery provenance (the GitHub result path, blob SHA, repository-head SHA, approved boundary, and catalog version) explains how a result was found. diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 4210130..221a8e9 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -35,6 +35,7 @@ import { type SkillSearchItem, type SkillSearchOptions, qualifiedName, + searchInteractiveSkills, searchSkills, } from '../../core/skill-search.js'; import { @@ -96,6 +97,12 @@ import { } from '../metadata/plugin-skills.js'; import { removeInstalledSkill } from '../skill-removal.js'; import { hasProjectSkillConfig } from '../skill-update.js'; +import { + buildSkillSearchPresentationRows, + skillSearchSelectionKey, +} from '../skill-search-presentation.js'; + +export { skillSearchSelectionKey }; import { skillUpdateCmd } from './skill-update.js'; /** @@ -2335,6 +2342,13 @@ const addCmd = command({ } }, }); +export function shouldUseInteractiveSkillSearch( + json: boolean, + stdinTTY: boolean | undefined, + stdoutTTY: boolean | undefined, +): boolean { + return !json && stdinTTY === true && stdoutTTY === true; +} // ============================================================================= // skill search (GitHub Code Search) @@ -2383,11 +2397,6 @@ export interface SelectedSkillSearchSource { selectors: string[]; } -export function skillSearchSelectionKey(item: SkillSearchItem): string { - const identity = item.catalog?.identity ?? item.installSource.toLowerCase(); - return `${identity}#${item.repo.toLowerCase()}#${item.path}`; -} - export function collectSelectedSkillSearchSources( items: SkillSearchItem[], selectedKeys: string[], @@ -2868,8 +2877,9 @@ export async function installSelectedSkillSearchSources( } /** Interactive wrapper around the single catalog/global install transaction. */ -async function installFromSearch( +export async function installSkillSearchSourcesInteractively( sources: SelectedSkillSearchSource[], + workspacePath: string = process.cwd(), ): Promise { const p = prompts; for (const source of sources) { @@ -2917,7 +2927,7 @@ async function installFromSearch( const result = await installSelectedSkillSearchSources( sources, scopeChoice, - process.cwd(), + workspacePath, ); if (result.installed.length === 0) { spinner.stop('Installation failed'); @@ -2963,7 +2973,7 @@ const searchCmd = command({ type: optional(string), long: 'catalog', description: - 'Restrict results to a built-in catalog. Initially: recommended.', + 'Search only the built-in Recommended catalog (strict; no global fallback).', }), page: option({ type: optional(string), @@ -3011,26 +3021,26 @@ const searchCmd = command({ opts.limit = n; } - const result = await searchSkills(searchQuery, opts); - - if (isJsonMode()) { - jsonOutput({ - success: true, - command: 'skill search', - data: result, - }); - return; - } - - if (result.items.length === 0) { - console.log(`No skills found for "${searchQuery}".`); - return; - } - - const isTTY = process.stdout.isTTY && process.stdin.isTTY; - + const isTTY = shouldUseInteractiveSkillSearch( + isJsonMode(), + process.stdin.isTTY, + process.stdout.isTTY, + ); if (!isTTY) { - // Non-interactive: print table with stars and exit + const result = await searchSkills(searchQuery, opts); + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'skill search', + data: result, + }); + return; + } + + if (result.items.length === 0) { + console.log(`No skills found for "${searchQuery}".`); + return; + } if (result.items.some((item) => item.catalog)) { console.log('Catalog: Recommended'); } @@ -3038,25 +3048,48 @@ const searchCmd = command({ return; } - // Interactive mode: filter-as-you-type multiselect with install support + const interactiveResult = await searchInteractiveSkills( + searchQuery, + opts, + ); + const items = interactiveResult.sections.flatMap( + (section) => section.items, + ); + const truncated = interactiveResult.sections.some( + (section) => section.truncated, + ); const { autocompleteMultiselect, isCancel, log } = prompts; + for (const section of interactiveResult.sections) { + if (section.error) { + log.warn( + `${section.label} unavailable: ${section.error.message} Partial results are shown.`, + ); + } + } + + if (items.length === 0) { + log.info(`No skills found for "${searchQuery}".`); + return; + } + log.success( - `${result.items.some((item) => item.catalog) ? 'Recommended · ' : ''}${formatSkillSearchSummary( - result.items.length, - searchQuery, - result.truncated, - )}`, + formatSkillSearchSummary(items.length, searchQuery, truncated), ); - const options = result.items.map((item) => ({ - label: `${qualifiedName(item)} ${chalk.dim(item.repo)}`, - value: skillSearchSelectionKey(item), - hint: formatSkillSearchHint(item), - disabled: - item.installation.policy === 'search-only' || - item.installation.policy === 'external-installer', - })); + const options = buildSkillSearchPresentationRows(interactiveResult).map( + (row) => ({ + label: + row.kind === 'item' && row.item + ? `${qualifiedName(row.item)} ${chalk.dim(row.item.repo)}` + : chalk.bold(row.label), + value: row.value, + ...(row.kind === 'item' && row.item + ? { hint: formatSkillSearchHint(row.item) } + : {}), + disabled: row.disabled, + }), + ); const selected = await autocompleteMultiselect({ message: 'Select skills to install', @@ -3065,16 +3098,14 @@ const searchCmd = command({ required: false, }); - if (isCancel(selected)) { - return; - } + if (isCancel(selected)) return; const sourcesToInstall = collectSelectedSkillSearchSources( - result.items, + items, selected as string[], ); if (sourcesToInstall.length === 0) return; - await installFromSearch(sourcesToInstall); + await installSkillSearchSourcesInteractively(sourcesToInstall); } catch (error) { if (error instanceof SkillSearchError) { const exitCode = error.kind === 'validation' ? 2 : 1; diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index 6796a83..803a488 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -130,9 +130,9 @@ export const skillsUpdateMeta: AgentCommandMeta = { export const skillsSearchMeta: AgentCommandMeta = { command: 'skill search', description: - 'Search SKILL.md files through global GitHub Code Search, or constrain discovery to the built-in Recommended catalog with exact repository/ref/root boundaries. Catalog searches never fall back to global results. In TTY mode, installable results can be selectively installed.', + 'Discover SKILL.md files interactively with Recommended first and deduplicated global GitHub results second. JSON and redirected no-catalog output remain global-only. Use --catalog recommended for a strict catalog boundary that never falls back. Installable TTY results can be selectively installed with exact catalog descriptors.', whenToUse: - 'To discover public GitHub skills globally, or to search a versioned catalog whose classification, policy, warnings, source metadata, and provenance are explicit. Recommended is a discovery label, not a security or license assertion.', + 'To discover public GitHub skills with catalog context, or to enforce a versioned catalog boundary whose classification, policy, warnings, source metadata, and provenance are explicit. Recommended is a discovery label, not a security or license assertion.', examples: [ 'allagents skill search terraform', 'allagents skill pr-search', @@ -144,7 +144,7 @@ export const skillsSearchMeta: AgentCommandMeta = { 'allagents --json skill search docs --limit 5', ], expectedOutput: - 'Ranked global or Recommended catalog results with exact install source, selector, policy, warnings, metadata, and discovery provenance. Search-only and external-lifecycle entries remain visible but cannot be selected.', + 'Interactive Recommended and All GitHub sections, or strict single-scope machine results, with exact install source, selector, policy, warnings, metadata, and provenance. Search-only and external-lifecycle entries remain visible but cannot be selected.', positionals: [ { name: 'query', @@ -163,7 +163,7 @@ export const skillsSearchMeta: AgentCommandMeta = { flag: '--catalog', type: 'string', description: - 'Restrict to a built-in catalog (currently recommended). Mutually exclusive with --owner; default-branch refs only; never falls back to global search.', + 'Restrict to the built-in Recommended catalog. Mutually exclusive with --owner; strict default-branch/ref/root boundary; never falls back to global search.', }, { flag: '--page', diff --git a/src/cli/skill-search-presentation.ts b/src/cli/skill-search-presentation.ts new file mode 100644 index 0000000..4b10d40 --- /dev/null +++ b/src/cli/skill-search-presentation.ts @@ -0,0 +1,79 @@ +import { + qualifiedName, + type InteractiveSkillSearchResult, + type SkillSearchItem, +} from '../core/skill-search.js'; + +export interface SkillSearchPresentationRow { + kind: 'heading' | 'status' | 'item'; + label: string; + value: string; + disabled: boolean; + item?: SkillSearchItem; +} + +export function skillSearchSelectionKey(item: SkillSearchItem): string { + const identity = item.catalog?.identity ?? item.installSource.toLowerCase(); + return `${identity}#${item.repo.toLowerCase()}#${item.path}`; +} + +/** Build stable, ordered rows shared by both interactive search surfaces. */ +export function buildSkillSearchPresentationRows( + result: InteractiveSkillSearchResult, +): SkillSearchPresentationRow[] { + const rows: SkillSearchPresentationRow[] = []; + for (const section of result.sections) { + rows.push({ + kind: 'heading', + label: `── ${section.label} ──`, + value: `__skill-search-section:${section.id}`, + disabled: true, + }); + + if (section.error) { + rows.push({ + kind: 'status', + label: 'Unavailable — partial results shown', + value: `__skill-search-error:${section.id}`, + disabled: true, + }); + continue; + } + + if (section.items.length === 0) { + rows.push({ + kind: 'status', + label: 'No matching skills', + value: `__skill-search-empty:${section.id}`, + disabled: true, + }); + continue; + } + + for (const item of section.items) { + rows.push({ + kind: 'item', + label: `${qualifiedName(item)} ${item.repo}`, + value: skillSearchSelectionKey(item), + disabled: + item.installation.policy === 'search-only' || + item.installation.policy === 'external-installer', + item, + }); + } + } + return rows; +} + +export function findSkillSearchSelection( + result: InteractiveSkillSearchResult, + selectionKey: string, +): SkillSearchItem | undefined { + for (const section of result.sections) { + const item = section.items.find( + (candidate) => skillSearchSelectionKey(candidate) === selectionKey, + ); + if (item) return item; + } + return undefined; +} diff --git a/src/cli/tui/__tests__/skills.test.ts b/src/cli/tui/__tests__/skills.test.ts new file mode 100644 index 0000000..a83c100 --- /dev/null +++ b/src/cli/tui/__tests__/skills.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'bun:test'; +import { + collectSelectedSkillSearchSources, + skillSearchSelectionKey, +} from '../../commands/plugin-skills.js'; +import { buildOnlineSkillSearchOptions } from '../actions/skills.js'; +import { findSkillSearchSelection } from '../../skill-search-presentation.js'; +import { + RECOMMENDED_SKILL_CATALOG, + catalogInstallDescriptor, + catalogSourceIdentity, +} from '../../../core/skill-catalog.js'; +import type { + InteractiveSkillSearchResult, + SkillSearchItem, +} from '../../../core/skill-search.js'; + +function globalItem(repo: string, path: string): SkillSearchItem { + const name = path.split('/').at(-2) ?? 'skill'; + return { + name, + namespace: '', + repo, + path, + description: `${name} description`, + sha: `${repo}:${path}`, + stars: 0, + installSource: repo, + installSelector: name, + installation: { policy: 'repository-install', reasonCodes: [] }, + }; +} + +function recommendedItem(): SkillSearchItem { + const source = RECOMMENDED_SKILL_CATALOG.sources.find( + (entry) => entry.sourceId === 'hermes-core', + )!; + const path = 'skills/research/wiki/SKILL.md'; + const identity = catalogSourceIdentity({ + catalog: 'recommended', + sourceId: source.sourceId, + effectiveRef: source.effectiveRef, + approvedRoot: source.approvedRoot, + }); + return { + ...globalItem(source.repo, path), + name: 'wiki', + namespace: 'research', + installSource: source.installSource, + installSelector: 'research/wiki', + installation: { policy: source.installPolicy, reasonCodes: [] }, + catalog: { + name: 'recommended', + label: 'Recommended', + version: 1, + identity, + sourceId: source.sourceId, + classification: source.classification, + sourceKind: source.sourceKind, + category: source.category, + homepage: source.homepage, + author: source.author, + spdxLicense: source.spdxLicense, + warnings: source.warnings, + discovery: { + catalogIdentity: identity, + provider: 'github-code-search', + repo: source.repo, + effectiveRef: source.effectiveRef, + catalogVersion: 1, + approvedRoot: source.approvedRoot, + repositoryHeadSha: 'head', + skillPath: path, + blobSha: 'blob', + }, + installDescriptor: catalogInstallDescriptor(source), + }, + }; +} + +describe('online skill search TUI model', () => { + it('renders stable ordered headings and maps selections across both groups', () => { + const recommended = recommendedItem(); + const github = globalItem('other/skills', 'skills/wiki/SKILL.md'); + const result: InteractiveSkillSearchResult = { + query: 'wiki', + sections: [ + { + id: 'recommended', + label: 'Recommended', + items: [recommended], + truncated: false, + }, + { + id: 'github', + label: 'All GitHub', + items: [github], + truncated: false, + }, + ], + }; + + const options = buildOnlineSkillSearchOptions(result); + expect(options.map((option) => option.label)).toEqual([ + '── Recommended ──', + 'research/wiki NousResearch/hermes-agent', + '── All GitHub ──', + 'wiki other/skills', + ]); + expect(options[0]?.disabled).toBe(true); + expect(options[2]?.disabled).toBe(true); + expect( + findSkillSearchSelection(result, skillSearchSelectionKey(recommended)), + ).toBe(recommended); + expect( + findSkillSearchSelection(result, skillSearchSelectionKey(github)), + ).toBe(github); + }); + + it('preserves the selected Recommended subtree descriptor', () => { + const recommended = recommendedItem(); + const result: InteractiveSkillSearchResult = { + query: 'wiki', + sections: [ + { + id: 'recommended', + label: 'Recommended', + items: [recommended], + truncated: false, + }, + ], + }; + const selected = findSkillSearchSelection( + result, + skillSearchSelectionKey(recommended), + ); + expect(selected).toBe(recommended); + + const groups = collectSelectedSkillSearchSources( + [recommended], + [skillSearchSelectionKey(recommended)], + ); + expect(groups).toHaveLength(1); + expect(groups[0]?.installSource).toBe( + 'NousResearch/hermes-agent@main/skills', + ); + expect(groups[0]?.selectors).toEqual(['research/wiki']); + expect(groups[0]?.installDescriptor).toEqual( + recommended.catalog?.installDescriptor, + ); + }); +}); diff --git a/src/cli/tui/actions/skills.ts b/src/cli/tui/actions/skills.ts index e945e83..176d276 100644 --- a/src/cli/tui/actions/skills.ts +++ b/src/cli/tui/actions/skills.ts @@ -21,15 +21,23 @@ import { listMarketplacePlugins, } from '../../../core/marketplace.js'; import { - searchSkills, - qualifiedName, - type SkillSearchItem, + searchInteractiveSkills, + type InteractiveSkillSearchResult, } from '../../../core/skill-search.js'; import { getHomeDir } from '../../../constants.js'; import type { TuiContext } from '../context.js'; import type { TuiCache } from '../cache.js'; import { installSelectedPlugin, runBrowsePluginSkills } from './plugins.js'; import { removeInstalledSkill } from '../../skill-removal.js'; +import { + buildSkillSearchPresentationRows, + findSkillSearchSelection, +} from '../../skill-search-presentation.js'; +import { + collectSelectedSkillSearchSources, + formatSkillSearchHint, + installSkillSearchSourcesInteractively, +} from '../../commands/plugin-skills.js'; const { multiselect, select, autocomplete, text } = p; @@ -395,29 +403,48 @@ async function runBrowseMarketplaceSkills( } } +export interface OnlineSkillSearchOption { + label: string; + value: string; + hint?: string; + disabled?: boolean; +} + +/** Stable render model for the full-screen skill search picker. */ +export function buildOnlineSkillSearchOptions( + result: InteractiveSkillSearchResult, +): OnlineSkillSearchOption[] { + return buildSkillSearchPresentationRows(result).map((row) => ({ + label: row.label, + value: row.value, + ...(row.kind === 'item' && row.item + ? { hint: formatSkillSearchHint(row.item) } + : {}), + disabled: row.disabled, + })); +} + /** - * Search GitHub for skills by keyword, display results, and install a selected plugin. + * Search Recommended and GitHub by keyword, then install the exact selection. */ async function runSearchOnlineSkills( context: TuiContext, cache?: TuiCache, ): Promise { const query = await text({ - message: 'Search for skills on GitHub', + message: 'Search for skills', placeholder: 'e.g. commit, deploy, aws', }); - if (p.isCancel(query) || !query || query.trim().length === 0) { - return; - } + if (p.isCancel(query) || !query || query.trim().length === 0) return; + const searchQuery = query.trim(); const s = p.spinner(); - s.start('Searching GitHub...'); + s.start('Searching Recommended and GitHub...'); - let items: SkillSearchItem[]; + let result: InteractiveSkillSearchResult; try { - const result = await searchSkills(query.trim()); - items = result.items; + result = await searchInteractiveSkills(searchQuery); } catch (error) { s.stop('Search failed'); p.note( @@ -427,49 +454,67 @@ async function runSearchOnlineSkills( return; } + const items = result.sections.flatMap((section) => section.items); s.stop(`Found ${items.length} skill${items.length !== 1 ? 's' : ''}`); + for (const section of result.sections) { + if (section.error) { + p.note( + `${section.error.message}\nResults from the other section are still available.`, + `${section.label} unavailable`, + ); + } + } + if (items.length === 0) { - p.note(`No skills found for "${query.trim()}".`, 'Search'); + p.note(`No skills found for "${searchQuery}".`, 'Search'); return; } - // One option per skill, showing name and repo - const options: Array<{ label: string; value: string; hint?: string }> = - items.map((item) => ({ - label: qualifiedName(item), - value: item.installSource, - hint: item.repo + (item.description ? ` · ${item.description}` : ''), - })); + const options = buildOnlineSkillSearchOptions(result); options.push({ label: 'Back', value: '__back__' }); const selected = await autocomplete({ - message: `Results for "${query.trim()}"`, + message: `Results for "${searchQuery}"`, options, placeholder: 'Type to filter...', }); - if (p.isCancel(selected) || selected === '__back__') { + if (p.isCancel(selected) || selected === '__back__') return; + + const item = findSkillSearchSelection(result, selected); + if (!item) { + p.note('The selected skill is no longer in these results.', 'Search Error'); return; } - // Check if plugin is already installed in either scope + if (item.catalog) { + const sources = collectSelectedSkillSearchSources(items, [selected]); + const installed = await installSkillSearchSourcesInteractively( + sources, + context.workspacePath ?? process.cwd(), + ); + if (installed) cache?.invalidate(); + return; + } + + const installSource = item.installSource; const workspacePath = context.workspacePath ?? process.cwd(); const isInstalledProject = context.workspacePath - ? await hasPlugin(selected, workspacePath) + ? await hasPlugin(installSource, workspacePath) : false; - const isInstalledUser = await hasUserPlugin(selected); + const isInstalledUser = await hasUserPlugin(installSource); if (isInstalledProject || isInstalledUser) { const scope = isInstalledUser ? 'user' : 'project'; - await runBrowsePluginSkills(selected, scope, context, cache); + await runBrowsePluginSkills(installSource, scope, context, cache); return; } - const installed = await installSelectedPlugin(selected, context, cache); + const installed = await installSelectedPlugin(installSource, context, cache); if (installed) { - const nowInstalledUser = await hasUserPlugin(selected); + const nowInstalledUser = await hasUserPlugin(installSource); const scope = nowInstalledUser ? 'user' : 'project'; - await runBrowsePluginSkills(selected, scope, context, cache); + await runBrowsePluginSkills(installSource, scope, context, cache); } } diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts index c30ae30..24f65da 100644 --- a/src/core/skill-search.ts +++ b/src/core/skill-search.ts @@ -106,6 +106,31 @@ export interface SkillSearchOptions { limit?: number; } +export type InteractiveSkillSearchSectionId = 'recommended' | 'github'; + +export interface InteractiveSkillSearchSection { + id: InteractiveSkillSearchSectionId; + label: 'Recommended' | 'All GitHub'; + items: SkillSearchItem[]; + truncated: boolean; + error?: { + kind: SkillSearchError['kind'] | 'unknown'; + message: string; + }; +} + +export interface InteractiveSkillSearchResult { + query: string; + sections: InteractiveSkillSearchSection[]; +} + +type InteractiveSkillSearchDeps = { + search?: ( + query: string, + options: SkillSearchOptions, + ) => Promise; +}; + const ENRICHMENT_CONCURRENCY = 10; export class SkillSearchError extends Error { @@ -704,6 +729,123 @@ export async function searchSkills( }; } +/** + * Search provider for human-driven discovery. + * + * With no explicit catalog or owner, Recommended and global GitHub are fetched + * independently and concurrently. This keeps catalog matches outside global + * ranking/page limits. Explicit catalog and owner searches remain single-scope + * and fail with the strict `searchSkills` contract. + */ +export async function searchInteractiveSkills( + query: string, + options: SkillSearchOptions = {}, + deps: InteractiveSkillSearchDeps = {}, +): Promise { + const strictSearch = deps.search ?? searchSkills; + + if (options.catalog !== undefined) { + const result = await strictSearch(query, options); + return { + query: result.query, + sections: [ + { + id: 'recommended', + label: 'Recommended', + items: result.items, + truncated: result.truncated, + }, + ], + }; + } + + if (options.owner !== undefined) { + const result = await strictSearch(query, options); + return { + query: result.query, + sections: [ + { + id: 'github', + label: 'All GitHub', + items: result.items, + truncated: result.truncated, + }, + ], + }; + } + + const recommendedOptions: SkillSearchOptions = { + ...options, + catalog: 'recommended', + }; + const sharedToken = deps.search ? undefined : resolveGhToken(); + const combinedSearch = + deps.search ?? + ((searchQuery: string, searchOptions: SkillSearchOptions) => + searchSkills(searchQuery, searchOptions, { + tokenResolver: () => sharedToken as Promise, + })); + const [recommended, github] = await Promise.allSettled([ + combinedSearch(query, recommendedOptions), + combinedSearch(query, options), + ]); + + const recommendedItems = + recommended.status === 'fulfilled' ? recommended.value.items : []; + const recommendedIdentities = new Set( + recommendedItems.map(interactiveSkillIdentity), + ); + const githubItems = + github.status === 'fulfilled' + ? github.value.items.filter( + (item) => !recommendedIdentities.has(interactiveSkillIdentity(item)), + ) + : []; + + return { + query, + sections: [ + { + id: 'recommended', + label: 'Recommended', + items: recommendedItems, + truncated: + recommended.status === 'fulfilled' + ? recommended.value.truncated + : false, + ...(recommended.status === 'rejected' && { + error: interactiveSearchError(recommended.reason), + }), + }, + { + id: 'github', + label: 'All GitHub', + items: githubItems, + truncated: + github.status === 'fulfilled' ? github.value.truncated : false, + ...(github.status === 'rejected' && { + error: interactiveSearchError(github.reason), + }), + }, + ], + }; +} + +/** Canonical repository plus exact discovered skill path. */ +export function interactiveSkillIdentity(item: SkillSearchItem): string { + const path = item.path.replace(/\\/g, '/').replace(/^\/+/, ''); + return `${item.repo.trim().toLowerCase()}#${path}`; +} + +function interactiveSearchError( + error: unknown, +): NonNullable { + return { + kind: error instanceof SkillSearchError ? error.kind : 'unknown', + message: error instanceof Error ? error.message : String(error), + }; +} + interface CatalogRepositoryPreflight { repo: string; effectiveRef: string; diff --git a/tests/unit/cli/skill-search-summary.test.ts b/tests/unit/cli/skill-search-summary.test.ts index 6aad7ed..ffa5b04 100644 --- a/tests/unit/cli/skill-search-summary.test.ts +++ b/tests/unit/cli/skill-search-summary.test.ts @@ -3,6 +3,7 @@ import { collectSelectedSkillSearchSources, formatSkillSearchHint, formatSkillSearchSummary, + shouldUseInteractiveSkillSearch, skillSearchSelectionKey, } from '../../../src/cli/commands/plugin-skills.js'; import type { SkillSearchItem } from '../../../src/core/skill-search.js'; @@ -105,6 +106,15 @@ describe('formatSkillSearchSummary', () => { }); }); +describe('interactive search mode selection', () => { + it('keeps JSON and non-TTY no-catalog output on the legacy provider', () => { + expect(shouldUseInteractiveSkillSearch(true, true, true)).toBe(false); + expect(shouldUseInteractiveSkillSearch(false, false, true)).toBe(false); + expect(shouldUseInteractiveSkillSearch(false, true, false)).toBe(false); + expect(shouldUseInteractiveSkillSearch(false, true, true)).toBe(true); + }); +}); + describe('formatSkillSearchHint', () => { it('formats stars and descriptions for global results', () => { expect( diff --git a/tests/unit/core/interactive-skill-search.test.ts b/tests/unit/core/interactive-skill-search.test.ts new file mode 100644 index 0000000..0be47e8 --- /dev/null +++ b/tests/unit/core/interactive-skill-search.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'bun:test'; +import { + SkillSearchError, + searchInteractiveSkills, + type SkillSearchItem, + type SkillSearchOptions, + type SkillSearchResult, +} from '../../../src/core/skill-search.js'; + +function item( + repo: string, + path: string, + name: string = path.split('/').at(-2) ?? 'skill', +): SkillSearchItem { + return { + name, + namespace: '', + repo, + path, + description: '', + sha: `${repo}:${path}`, + stars: 0, + installSource: repo, + installSelector: path.replace(/\/SKILL\.md$/, ''), + installation: { policy: 'repository-install', reasonCodes: [] }, + }; +} + +function result(query: string, items: SkillSearchItem[]): SkillSearchResult { + return { query, items, total: items.length, truncated: false }; +} + +describe('searchInteractiveSkills', () => { + it('orders Recommended first, lets it win exact duplicates, and retains same-name distinct identities', async () => { + const recommended = item('Acme/skills', 'skills/testing/SKILL.md', 'testing'); + const duplicate = item('acme/skills', 'skills/testing/SKILL.md', 'testing'); + const sameNameDifferentPath = item( + 'acme/skills', + 'optional-skills/testing/SKILL.md', + 'testing', + ); + const sameNameDifferentRepo = item( + 'Other/skills', + 'skills/testing/SKILL.md', + 'testing', + ); + + const combined = await searchInteractiveSkills('testing', {}, { + search: async (_query, options) => + options.catalog + ? result('testing', [recommended]) + : result('testing', [ + duplicate, + sameNameDifferentPath, + sameNameDifferentRepo, + ]), + }); + + expect(combined.sections.map((section) => section.label)).toEqual([ + 'Recommended', + 'All GitHub', + ]); + expect(combined.sections[0]?.items).toEqual([recommended]); + expect(combined.sections[1]?.items).toEqual([ + sameNameDifferentPath, + sameNameDifferentRepo, + ]); + }); + + it('runs catalog-bounded and global searches concurrently with independent limits', async () => { + const calls: SkillSearchOptions[] = []; + let release: (() => void) | undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + + const pending = searchInteractiveSkills('testing', { limit: 7 }, { + search: async (_query, options) => { + calls.push(options); + await blocked; + return result('testing', []); + }, + }); + + await Promise.resolve(); + expect(calls).toEqual([ + { limit: 7, catalog: 'recommended' }, + { limit: 7 }, + ]); + release?.(); + await pending; + }); + + it('keeps explicit catalog search strict and Recommended-only', async () => { + const calls: SkillSearchOptions[] = []; + const strict = await searchInteractiveSkills( + 'testing', + { catalog: 'recommended' }, + { + search: async (_query, options) => { + calls.push(options); + return result('testing', []); + }, + }, + ); + + expect(calls).toEqual([{ catalog: 'recommended' }]); + expect(strict.sections.map((section) => section.label)).toEqual([ + 'Recommended', + ]); + }); + + it('keeps owner-scoped search global-only', async () => { + const calls: SkillSearchOptions[] = []; + const scoped = await searchInteractiveSkills( + 'testing', + { owner: 'acme' }, + { + search: async (_query, options) => { + calls.push(options); + return result('testing', []); + }, + }, + ); + + expect(calls).toEqual([{ owner: 'acme' }]); + expect(scoped.sections.map((section) => section.label)).toEqual([ + 'All GitHub', + ]); + }); + + it('shows global results and a visible Recommended failure when catalog search fails', async () => { + const github = item('acme/skills', 'skills/testing/SKILL.md'); + const combined = await searchInteractiveSkills('testing', {}, { + search: async (_query, options) => { + if (options.catalog) { + throw new SkillSearchError('catalog rate limit', 'rate-limit'); + } + return result('testing', [github]); + }, + }); + + expect(combined.sections[0]?.items).toEqual([]); + expect(combined.sections[0]?.error).toEqual({ + kind: 'rate-limit', + message: 'catalog rate limit', + }); + expect(combined.sections[1]?.items).toEqual([github]); + }); + + it('shows Recommended results and a visible global failure when GitHub search fails', async () => { + const recommended = item('acme/skills', 'skills/testing/SKILL.md'); + const combined = await searchInteractiveSkills('testing', {}, { + search: async (_query, options) => { + if (!options.catalog) throw new SkillSearchError('GitHub down', 'api'); + return result('testing', [recommended]); + }, + }); + + expect(combined.sections[0]?.items).toEqual([recommended]); + expect(combined.sections[1]?.items).toEqual([]); + expect(combined.sections[1]?.error).toEqual({ + kind: 'api', + message: 'GitHub down', + }); + }); + + it('does not broaden explicit catalog failures', async () => { + let calls = 0; + await expect( + searchInteractiveSkills( + 'testing', + { catalog: 'recommended' }, + { + search: async () => { + calls += 1; + throw new SkillSearchError('catalog unavailable', 'api'); + }, + }, + ), + ).rejects.toThrow('catalog unavailable'); + expect(calls).toBe(1); + }); +}); From c5478b3c5c2a76fa776087b3642a40ea2ba1c7b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 14:12:33 +1000 Subject: [PATCH 08/11] fix(ci): isolate real git integration tests --- src/cli/tui/__tests__/skills.test.ts | 7 ++++- src/core/__tests__/plugin-seed-cache.test.ts | 8 +++++ tests/e2e/skill-update.test.ts | 7 +++-- tests/helpers/isolation.ts | 31 ++++++++++++++++++++ tests/unit/cli/skill-search-install.test.ts | 7 ++++- tests/unit/core/git.test.ts | 8 +++++ tests/unit/core/sync-user.test.ts | 15 +++++++++- 7 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/helpers/isolation.ts diff --git a/src/cli/tui/__tests__/skills.test.ts b/src/cli/tui/__tests__/skills.test.ts index a83c100..608f6b8 100644 --- a/src/cli/tui/__tests__/skills.test.ts +++ b/src/cli/tui/__tests__/skills.test.ts @@ -34,7 +34,12 @@ function globalItem(repo: string, path: string): SkillSearchItem { function recommendedItem(): SkillSearchItem { const source = RECOMMENDED_SKILL_CATALOG.sources.find( (entry) => entry.sourceId === 'hermes-core', - )!; + ); + if (!source) { + throw new Error( + 'Missing hermes-core source in the recommended catalog fixture.', + ); + } const path = 'skills/research/wiki/SKILL.md'; const identity = catalogSourceIdentity({ catalog: 'recommended', diff --git a/src/core/__tests__/plugin-seed-cache.test.ts b/src/core/__tests__/plugin-seed-cache.test.ts index f6f7f73..c858624 100644 --- a/src/core/__tests__/plugin-seed-cache.test.ts +++ b/src/core/__tests__/plugin-seed-cache.test.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import simpleGit from 'simple-git'; +import { + isIsolatedTestRun, + runTestFileIsolated, +} from '../../../tests/helpers/isolation.js'; import { describe, it, expect, beforeEach } from 'bun:test'; import { seedFetchCache, @@ -87,6 +91,10 @@ describe('seedFetchCache', () => { }); it('retains branch and commit provenance for a seeded repository', async () => { + if (!isIsolatedTestRun(import.meta.path)) { + runTestFileIsolated(import.meta.path); + return; + } const repository = await mkdtemp(join(tmpdir(), 'allagents-seed-cache-')); try { const git = simpleGit(repository); diff --git a/tests/e2e/skill-update.test.ts b/tests/e2e/skill-update.test.ts index eee9c4c..c05a742 100644 --- a/tests/e2e/skill-update.test.ts +++ b/tests/e2e/skill-update.test.ts @@ -1,6 +1,5 @@ import { afterEach, - beforeAll, beforeEach, describe, expect, @@ -45,7 +44,7 @@ interface RemoteSourceFixture { const decoder = new TextDecoder(); const cliEntry = join(import.meta.dir, '..', '..', 'dist', 'index.js'); -beforeAll(() => { +function buildCli(): void { const build = Bun.spawnSync(['bun', 'run', 'build'], { cwd: join(import.meta.dir, '..', '..'), stderr: 'pipe', @@ -59,7 +58,9 @@ beforeAll(() => { if (!existsSync(cliEntry)) { throw new Error(`Built CLI not found at ${cliEntry}`); } -}); +} + +buildCli(); function cliEnv(fixture: SkillUpdateFixture): Record { return { diff --git a/tests/helpers/isolation.ts b/tests/helpers/isolation.ts new file mode 100644 index 0000000..c330aac --- /dev/null +++ b/tests/helpers/isolation.ts @@ -0,0 +1,31 @@ +const ISOLATED_TEST_FILE_ENV = 'ALLAGENTS_ISOLATED_TEST_FILE'; + +export function isIsolatedTestRun(file: string): boolean { + return process.env[ISOLATED_TEST_FILE_ENV] === file; +} + +export function runTestFileIsolated(file: string): void { + const result = Bun.spawnSync([process.execPath, 'test', file], { + cwd: process.cwd(), + env: { + ...process.env, + [ISOLATED_TEST_FILE_ENV]: file, + } as Record, + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode === 0) return; + + const stdout = result.stdout.toString().trim(); + const stderr = result.stderr.toString().trim(); + throw new Error( + [ + `Isolated test process failed for ${file} (exit ${result.exitCode}).`, + stdout, + stderr, + ] + .filter(Boolean) + .join('\n'), + ); +} diff --git a/tests/unit/cli/skill-search-install.test.ts b/tests/unit/cli/skill-search-install.test.ts index 4208f80..25cd701 100644 --- a/tests/unit/cli/skill-search-install.test.ts +++ b/tests/unit/cli/skill-search-install.test.ts @@ -20,7 +20,12 @@ function sourceGroup( ): SelectedSkillSearchSource { const source = RECOMMENDED_SKILL_CATALOG.sources.find( (entry) => entry.sourceId === sourceId, - )!; + ); + if (!source) { + throw new Error( + `Missing ${sourceId} source in the recommended catalog fixture.`, + ); + } return { catalogIdentity: `recommended:${source.sourceId}@${source.effectiveRef}#${source.approvedRoot}`, installDescriptor: catalogInstallDescriptor(source), diff --git a/tests/unit/core/git.test.ts b/tests/unit/core/git.test.ts index 5df47d9..e993c49 100644 --- a/tests/unit/core/git.test.ts +++ b/tests/unit/core/git.test.ts @@ -3,6 +3,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import simpleGit from 'simple-git'; +import { + isIsolatedTestRun, + runTestFileIsolated, +} from '../../helpers/isolation.js'; import { cloneTo, createGitEnv } from '../../../src/core/git.js'; describe('createGitEnv', () => { @@ -39,6 +43,10 @@ describe('createGitEnv', () => { describe('cloneTo', () => { it('clones into an empty destination with controlled LFS filters', async () => { + if (!isIsolatedTestRun(import.meta.path)) { + runTestFileIsolated(import.meta.path); + return; + } const fixture = await mkdtemp(join(tmpdir(), 'allagents-git-test-')); const upstream = join(fixture, 'upstream'); const remote = join(fixture, 'origin.git'); diff --git a/tests/unit/core/sync-user.test.ts b/tests/unit/core/sync-user.test.ts index 57f3f25..cb54871 100644 --- a/tests/unit/core/sync-user.test.ts +++ b/tests/unit/core/sync-user.test.ts @@ -1,4 +1,8 @@ import simpleGit from 'simple-git'; +import { + isIsolatedTestRun, + runTestFileIsolated, +} from '../../helpers/isolation.js'; import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; @@ -194,9 +198,18 @@ describe('syncUserWorkspace', () => { }); it('persists catalog provenance for an offline user-scoped sync', async () => { + if (!isIsolatedTestRun(import.meta.path)) { + runTestFileIsolated(import.meta.path); + return; + } const source = RECOMMENDED_SKILL_CATALOG.sources.find( (entry) => entry.sourceId === 'hermes-core', - )!; + ); + if (!source) { + throw new Error( + 'Missing hermes-core source in the recommended catalog fixture.', + ); + } const cachePath = getPluginCachePath( 'NousResearch', 'hermes-agent', From 6eadf0ec09b729b3106cdc5f0d070efd76e24d98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 14:27:04 +1000 Subject: [PATCH 09/11] fix(review): harden catalog installs and CI isolation --- .claude/plans/recommended-skill-catalog.md | 60 +++++++++++++++------ CHANGELOG.md | 4 -- README.md | 2 +- scripts/validate-skill-catalog.ts | 2 + src/cli/commands/plugin-skills.ts | 9 ++-- src/cli/skill-search-presentation.ts | 3 +- src/cli/tui/__tests__/skills.test.ts | 31 +++++++++++ src/cli/tui/actions/skills.ts | 4 +- src/core/git.ts | 8 ++- tests/e2e/skill-update.test.ts | 3 +- tests/helpers/isolation.ts | 26 +++++++-- tests/unit/cli/skill-search-install.test.ts | 17 ++++++ tests/unit/core/git.test.ts | 24 ++++++++- 13 files changed, 153 insertions(+), 40 deletions(-) diff --git a/.claude/plans/recommended-skill-catalog.md b/.claude/plans/recommended-skill-catalog.md index 72ae070..28f3ef9 100644 --- a/.claude/plans/recommended-skill-catalog.md +++ b/.claude/plans/recommended-skill-catalog.md @@ -58,7 +58,7 @@ Subpath identity is also currently collapsed: - `src/cli/commands/plugin-skills.ts::recordSourceProvenance()` keys sync state by repository only. - `src/core/sync.ts::buildSourcesProvenance()` also writes `sources[owner/repo]`. -Finally, clean clones currently fail before discovery. `src/core/git.ts::createGit()` supplies `filter.lfs.*` config values to `simple-git@3.30.0` but does not set `allowUnsafeFilter: true`. Clean-cache installs of `mattpocock/skills` and both Hermes subtrees hit simple-git's unsafe-filter rejection. Cache-seeded runs subsequently copied 36, 82, and 117 skills respectively with zero copy failures; those runs do not prove clean installability. +Clean clones already use per-instance `simple-git@3.30.0` `config` values to disable LFS smudge/process handling. Those supported configuration arguments need a real hostile-global-config regression; an untyped `allowUnsafeFilter` property is not part of `SimpleGitOptions` in the pinned version and must not be used as a security bypass. ## Goals @@ -497,13 +497,13 @@ Actual install provenance is `{ catalogSource, resolvedRef, resolvedSha, resolve Tests install core and optional in both orders and assert two workspace descriptors, two catalog-identity provenance keys, one physical cache identity, exact resolved roots/refs, and no promotion to repository root. -## Clean-clone fix +## Controlled clean-clone LFS behavior -In `src/core/git.ts::createGit()`, set simple-git's `allowUnsafeFilter: true` alongside the existing `filter.lfs.*` config. Do not remove `GIT_LFS_SKIP_SMUDGE=1` or the LFS filter overrides; they prevent large LFS downloads during discovery/install. Do not apply a global user/repository git config. +In `src/core/git.ts::createGit()`, retain the supported per-instance `config` values for `filter.lfs.*` together with `GIT_LFS_SKIP_SMUDGE=1`. Do not add unsupported unsafe-option casts, accept caller-supplied filter configuration, or alter global/user/repository git config. -Add a regression to `tests/unit/core/git.test.ts` that creates a disposable local origin and calls the real `cloneTo()` into an empty destination. The test must exercise the configured `filter.lfs.*` path so it fails with simple-git's unsafe-filter rejection if `allowUnsafeFilter` is removed. Assert clone success and expected tracked content; do not merely snapshot an options object. +The regression in `tests/unit/core/git.test.ts` creates a disposable local origin, supplies hostile required LFS filters through an isolated global config, and calls the real `cloneTo()` into an empty destination. It asserts clone success and unchanged pointer content, so removing the controlled per-instance filter overrides fails the contract. -This fix is stage zero for catalog install claims. A cache-seeded run is not acceptable evidence. +This check is stage zero for clean catalog install claims. A cache-seeded run is not acceptable evidence. ## Manifest validation, read-only health, and review gate @@ -547,6 +547,18 @@ Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project - Single schema-version-1 `Recommended` catalog, stable source IDs, explicit repo/ref/roots, metadata, warnings, identity constructor, and segment-boundary helpers. - `src/core/skill-catalog-health.ts` - Offline validation and dependency-injected read-only GitHub health/manifest checks. +- `src/cli/skill-search-presentation.ts` + - Shared stable section/status/item rows and exact selection-key mapping for both interactive search surfaces. +- `src/cli/tui/__tests__/skills.test.ts` + - Full-screen TUI section order, failure labels, selection mapping, and descriptor preservation. +- `tests/helpers/isolation.ts` + - Bounded subprocess isolation for real-git tests that mutate process-global state. +- `tests/unit/cli/skill-search-install.test.ts` + - Exact direct/marketplace install transactions, policy enforcement, spoof resistance, and one-sync coverage. +- `tests/unit/core/interactive-skill-search.test.ts` + - Concurrent section ordering, deduplication, strict scopes, and partial-failure behavior. +- `tests/unit/core/skill-catalog-provenance.test.ts` + - Exact dual-root catalog provenance and shared physical-cache behavior. - `scripts/validate-skill-catalog.ts` - Read-only `--ci` and `--report` entry points over the same catalog/validator. - `tests/unit/core/skill-catalog.test.ts` @@ -568,7 +580,7 @@ Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project - `src/cli/metadata/plugin-skills.ts::skillsSearchMeta` - Document `--catalog`, mutual exclusion, Recommended label, policy/metadata/provenance JSON fields, no fallback, and arbitrary-ref exclusion. - `src/cli/tui/actions/skills.ts::runSearchOnlineSkills` - - Consume `installSource`; keep this TUI surface on global search in this change. + - Use the same combined interactive provider and stable presentation rows, then pass catalog selections through the shared exact-descriptor transaction. - `src/utils/plugin-path.ts` - Parse/render exact repo/ref/root descriptors and retain segment-safe path normalization; repository identity is not catalog identity. - `src/models/workspace-config.ts::PluginEntrySchema` @@ -577,12 +589,16 @@ Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project - Add repository-promoting versus catalog-exact upsert mode and preserve/validate catalog descriptors. - `src/core/user-workspace.ts` - Thread catalog-exact mode and descriptor through user-scoped allowlist upsert. +- `src/core/plugin.ts` + - Preserve resolved ref/SHA provenance for seeded, cached, online, and offline fetches. - `src/models/sync-state.ts::SyncStateSourceSchema` - Add optional catalog descriptor and resolved root while retaining sync-state schema version 1. - `src/core/sync.ts::buildSourcesProvenance` - Emit full catalog-identity keys and exact root/ref install provenance. - `src/core/git.ts::createGit` - - Enable `allowUnsafeFilter` for the intentional fixed LFS filters. + - Keep controlled LFS filters on the supported per-instance `config` API without enabling unrelated unsafe git behavior. +- `src/utils/marketplace-manifest-parser.ts` + - Parse only strictly valid repository-local catalog marketplace entries; exclude unsupported remote lifecycle entries from catalog installation. - `package.json` - Add `catalog:validate` and `catalog:health` scripts. - `.github/workflows/ci.yml` @@ -590,25 +606,35 @@ Add a `Catalog Manifest` job to `.github/workflows/ci.yml` that installs project `src/core/marketplace.ts` and `src/utils/marketplace-manifest-parser.ts` remain marketplace dependencies rather than catalog registries. Reuse their manifest schemas/resolution rules; do not store catalog entries in `MarketplaceRegistry`. -### Modified tests +### Modified and added tests -- `tests/unit/core/skill-search.test.ts` +- `src/cli/tui/__tests__/skills.test.ts` +- `src/core/__tests__/plugin-seed-cache.test.ts` +- `tests/e2e/plugin-skills.test.ts` +- `tests/e2e/skill-update.test.ts` +- `tests/helpers/isolation.ts` +- `tests/unit/cli/skill-search-install.test.ts` - `tests/unit/cli/skill-search-summary.test.ts` -- `tests/unit/cli/skills-add-standalone-install.test.ts` -- `tests/unit/core/github-skill-source-promotion.test.ts` - `tests/unit/core/git.test.ts` -- `tests/unit/models/workspace-config.test.ts` +- `tests/unit/core/github-skill-source-promotion.test.ts` +- `tests/unit/core/interactive-skill-search.test.ts` +- `tests/unit/core/skill-catalog-health.test.ts` +- `tests/unit/core/skill-catalog-provenance.test.ts` +- `tests/unit/core/skill-catalog.test.ts` +- `tests/unit/core/skill-search.test.ts` +- `tests/unit/core/sync-user.test.ts` +- `tests/unit/models/skill-catalog.test.ts` - `tests/unit/models/sync-state-schema.test.ts` -- `tests/e2e/plugin-skills.test.ts` +- `tests/unit/models/workspace-config.test.ts` -Add a focused TUI unit only if the existing action is first made dependency-injectable without production-only indirection; otherwise cover `installSource` through the exported selection helper and perform the TUI smoke check manually. +The real-git tests use bounded isolated subprocesses so process-global git/cache state cannot create suite-order dependence. The skill-update E2E build stays in an explicitly timed `beforeAll` hook rather than running as a module-import side effect. ### Documentation and changelog - `README.md` command table: add `allagents skill search [--catalog recommended]`, label the catalog Recommended, and state global is the no-option default but never a catalog fallback. - `docs/src/content/docs/docs/reference/cli.mdx`: add complete search syntax, flags, metadata/policy/provenance JSON fields, mutual exclusion, no-fallback behavior, default-ref-only MVP, and project config descriptor semantics. - `docs/src/content/docs/docs/guides/marketplaces.mdx`: distinguish catalog sources from marketplaces and document authoritative-manifest validation without registering plain repositories. -- `CHANGELOG.md` under `Unreleased` / `Added`: Recommended catalog search, versioned source identity, exact ref/root descriptors, read-only health/CI validation, and warnings. Under `Fixed`: clean clone failure caused by simple-git unsafe LFS filter validation. +- `CHANGELOG.md` under `Unreleased` / `Added`: Recommended catalog search, versioned source identity, exact ref/root descriptors, read-only health/CI validation, and warnings. ## Automated test matrix @@ -771,11 +797,11 @@ The audit commit informs architecture but does not, by itself, add `numman-ali/n | Recommended label is mistaken for endorsement. | Required warnings and metadata; never expose verified/safe/trusted booleans or wording. | | Upstream ref/layout/count/license changes. | Read-only health report and required CI validation; record upstream SHA; catalog changes only through reviewed PRs. | | Health tooling mutates upstream/local state. | GET-only dependency surface, mutation-negative tests, no repair/update mode. | -| LFS workaround weakens git safety globally. | Set `allowUnsafeFilter` only on the controlled `simple-git` instance with fixed filter keys; never accept user-supplied filters or alter global git config. | +| LFS handling weakens git safety globally. | Use only fixed, per-instance `SimpleGitOptions.config` entries; never accept user-supplied filters, set unsafe protocol/pack options, or alter global git config. | ## Staged execution order -1. **Fix clean clones first.** Add `allowUnsafeFilter`, the real local-clone regression, and confirm clean remote Matt/Hermes clones reach discovery. +1. **Prove clean clones first.** Keep the supported per-instance LFS config, add the hostile-global-config real clone regression, and confirm clean remote Matt/Hermes clones reach discovery. 2. **Add the one versioned catalog and validator.** Land stable IDs, metadata, explicit refs/roots, full identity, policy enums, static invariants, and audited source rows. 3. **Add read-only health and CI manifest gate.** Implement GET-only checks, package scripts, required `Catalog Manifest` job, and review evidence format. 4. **Extend the core search API.** Add catalog option, default-ref preflight, qualifier batching, segment-boundary enforcement, metadata/discovery provenance, stable ordering, and no-fallback tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e81d8b..ba19bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,6 @@ - Interactive `skill search` and full-screen TUI discovery now fetch Recommended and global GitHub results concurrently, render Recommended first and All GitHub second, and let Recommended win exact repository/skill-path duplicates. Partial failures are labeled while surviving results remain usable. JSON, redirected no-catalog output, owner-scoped search, and explicit strict catalog search keep their previous boundaries. Exact catalog install descriptors survive selection in both interactive surfaces. Optional sources require confirmation, while search-only and external-installer sources remain non-installable. - Added authenticated, read-only catalog health validation in CI for repository/ref/root drift, skill presence, local authoritative marketplace paths, and source identity. The check never mutates, vendors, or automatically updates catalog entries. -### Fixed - -- Clean Git clones now pass Git LFS filter overrides as supported `simple-git` configuration arguments, so project-scoped installs succeed without a pre-seeded cache while still preventing LFS smudge downloads. - ## [1.0.0] - 2026-03-13 ### Breaking Changes diff --git a/README.md b/README.md index d4fd178..e7c3fea 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ clients: | `allagents plugin uninstall ` | Remove a plugin | | `allagents plugin list` | List available plugins | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | -| `allagents skill search [--catalog recommended]` | Discover Recommended first, then global GitHub; use `--catalog` for strict catalog-only search | +| `allagents skill search [--catalog recommended]` | Interactive discovery shows Recommended then global GitHub; `--catalog` is strict catalog-only search | | `allagents skill list` | List skills and status | | `allagents mcp add ` | Add an MCP server and sync to clients | | `allagents mcp proxy ` | Bridge a remote HTTP MCP server to local stdio | diff --git a/scripts/validate-skill-catalog.ts b/scripts/validate-skill-catalog.ts index e84ada7..4972988 100644 --- a/scripts/validate-skill-catalog.ts +++ b/scripts/validate-skill-catalog.ts @@ -7,6 +7,7 @@ import { } from '../src/core/skill-catalog-health.js'; import { RECOMMENDED_SKILL_CATALOG } from '../src/core/skill-catalog.js'; +const GITHUB_REQUEST_TIMEOUT_MS = 30_000; const mode = process.argv[2]; if (mode !== '--ci' && mode !== '--report') { console.error('Usage: bun run scripts/validate-skill-catalog.ts --ci|--report'); @@ -31,6 +32,7 @@ async function githubGet(path: string): Promise { const response = await fetch(`https://api.github.com${path}`, { method: 'GET', headers, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`GitHub GET ${path} failed: ${response.status}`); diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 221a8e9..ce25844 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -2527,6 +2527,9 @@ function validateSelectedCatalogDescriptor( expectedIdentity !== group.catalogIdentity || JSON.stringify(catalogInstallDescriptor(source)) !== JSON.stringify(descriptor) || + group.installPolicy !== descriptor.installPolicy || + group.classification !== source.classification || + JSON.stringify(group.warnings) !== JSON.stringify(source.warnings) || !parsed || parsed.repo.toLowerCase() !== descriptor.repo.toLowerCase() || parsed.ref !== descriptor.effectiveRef || @@ -3062,9 +3065,9 @@ const searchCmd = command({ for (const section of interactiveResult.sections) { if (section.error) { - log.warn( - `${section.label} unavailable: ${section.error.message} Partial results are shown.`, - ); + const suffix = + items.length > 0 ? ' Partial results are shown.' : ''; + log.warn(`${section.label} unavailable: ${section.error.message}${suffix}`); } } diff --git a/src/cli/skill-search-presentation.ts b/src/cli/skill-search-presentation.ts index 4b10d40..192f146 100644 --- a/src/cli/skill-search-presentation.ts +++ b/src/cli/skill-search-presentation.ts @@ -22,6 +22,7 @@ export function buildSkillSearchPresentationRows( result: InteractiveSkillSearchResult, ): SkillSearchPresentationRow[] { const rows: SkillSearchPresentationRow[] = []; + const hasResults = result.sections.some((section) => section.items.length > 0); for (const section of result.sections) { rows.push({ kind: 'heading', @@ -33,7 +34,7 @@ export function buildSkillSearchPresentationRows( if (section.error) { rows.push({ kind: 'status', - label: 'Unavailable — partial results shown', + label: hasResults ? 'Unavailable — partial results shown' : 'Unavailable', value: `__skill-search-error:${section.id}`, disabled: true, }); diff --git a/src/cli/tui/__tests__/skills.test.ts b/src/cli/tui/__tests__/skills.test.ts index 608f6b8..504de7b 100644 --- a/src/cli/tui/__tests__/skills.test.ts +++ b/src/cli/tui/__tests__/skills.test.ts @@ -122,6 +122,37 @@ describe('online skill search TUI model', () => { ).toBe(github); }); + it('does not claim partial results when every section failed', () => { + const result: InteractiveSkillSearchResult = { + query: 'wiki', + sections: [ + { + id: 'recommended', + label: 'Recommended', + items: [], + truncated: false, + error: { kind: 'rate-limit', message: 'Catalog unavailable' }, + }, + { + id: 'github', + label: 'All GitHub', + items: [], + truncated: false, + error: { kind: 'api', message: 'GitHub unavailable' }, + }, + ], + }; + + expect( + buildOnlineSkillSearchOptions(result).map((option) => option.label), + ).toEqual([ + '── Recommended ──', + 'Unavailable', + '── All GitHub ──', + 'Unavailable', + ]); + }); + it('preserves the selected Recommended subtree descriptor', () => { const recommended = recommendedItem(); const result: InteractiveSkillSearchResult = { diff --git a/src/cli/tui/actions/skills.ts b/src/cli/tui/actions/skills.ts index 176d276..586d63e 100644 --- a/src/cli/tui/actions/skills.ts +++ b/src/cli/tui/actions/skills.ts @@ -460,7 +460,9 @@ async function runSearchOnlineSkills( for (const section of result.sections) { if (section.error) { p.note( - `${section.error.message}\nResults from the other section are still available.`, + items.length > 0 + ? `${section.error.message}\nResults from the other section are still available.` + : section.error.message, `${section.label} unavailable`, ); } diff --git a/src/core/git.ts b/src/core/git.ts index 9f9be53..5738e22 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -1,4 +1,4 @@ -import simpleGit, { type SimpleGitOptions } from 'simple-git'; +import simpleGit from 'simple-git'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, normalize, resolve, sep } from 'node:path'; @@ -25,17 +25,15 @@ export function createGitEnv(): NodeJS.ProcessEnv { } function createGit(baseDir?: string) { - const options = { + return simpleGit(baseDir, { timeout: { block: CLONE_TIMEOUT_MS }, - allowUnsafeFilter: true, config: [ 'filter.lfs.required=false', 'filter.lfs.smudge=', 'filter.lfs.clean=', 'filter.lfs.process=', ], - } as Partial & { allowUnsafeFilter: true }; - return simpleGit(baseDir, options).env(createGitEnv()); + }).env(createGitEnv()); } /** diff --git a/tests/e2e/skill-update.test.ts b/tests/e2e/skill-update.test.ts index c05a742..0e6b699 100644 --- a/tests/e2e/skill-update.test.ts +++ b/tests/e2e/skill-update.test.ts @@ -1,5 +1,6 @@ import { afterEach, + beforeAll, beforeEach, describe, expect, @@ -60,7 +61,7 @@ function buildCli(): void { } } -buildCli(); +beforeAll(buildCli, 60_000); function cliEnv(fixture: SkillUpdateFixture): Record { return { diff --git a/tests/helpers/isolation.ts b/tests/helpers/isolation.ts index c330aac..a6c686d 100644 --- a/tests/helpers/isolation.ts +++ b/tests/helpers/isolation.ts @@ -1,27 +1,43 @@ +import { resolve } from 'node:path'; + const ISOLATED_TEST_FILE_ENV = 'ALLAGENTS_ISOLATED_TEST_FILE'; +const ISOLATED_TEST_TIMEOUT_MS = 60_000; +const ISOLATED_TEST_MAX_BUFFER_BYTES = 8 * 1024 * 1024; export function isIsolatedTestRun(file: string): boolean { - return process.env[ISOLATED_TEST_FILE_ENV] === file; + const isolatedFile = process.env[ISOLATED_TEST_FILE_ENV]; + return isolatedFile !== undefined && resolve(isolatedFile) === resolve(file); } export function runTestFileIsolated(file: string): void { - const result = Bun.spawnSync([process.execPath, 'test', file], { + const resolvedFile = resolve(file); + const result = Bun.spawnSync([process.execPath, 'test', resolvedFile], { cwd: process.cwd(), env: { ...process.env, - [ISOLATED_TEST_FILE_ENV]: file, + [ISOLATED_TEST_FILE_ENV]: resolvedFile, } as Record, stdout: 'pipe', stderr: 'pipe', + timeout: ISOLATED_TEST_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: ISOLATED_TEST_MAX_BUFFER_BYTES, }); - if (result.exitCode === 0) return; + if (result.success) return; const stdout = result.stdout.toString().trim(); const stderr = result.stderr.toString().trim(); + const status = result.exitedDueToTimeout + ? `timed out after ${ISOLATED_TEST_TIMEOUT_MS}ms` + : result.exitedDueToMaxBuffer + ? `exceeded ${ISOLATED_TEST_MAX_BUFFER_BYTES} bytes of output` + : result.signalCode + ? `signal ${result.signalCode}` + : `exit ${result.exitCode}`; throw new Error( [ - `Isolated test process failed for ${file} (exit ${result.exitCode}).`, + `Isolated test process failed for ${resolvedFile} (${status}).`, stdout, stderr, ] diff --git a/tests/unit/cli/skill-search-install.test.ts b/tests/unit/cli/skill-search-install.test.ts index 25cd701..985dcc4 100644 --- a/tests/unit/cli/skill-search-install.test.ts +++ b/tests/unit/cli/skill-search-install.test.ts @@ -153,6 +153,23 @@ describe('installSelectedSkillSearchSources', () => { expect(fetchPlugin).toHaveBeenCalledTimes(0); }); + it('rejects spoofed catalog policy metadata before fetching or mutation', async () => { + const fetchPlugin = mock(async () => ({ + success: true as const, + action: 'fetched' as const, + cachePath: '/unused', + })); + const group = sourceGroup('mattpocock-skills', ['typescript']); + group.installPolicy = 'direct-selective'; + + await expect( + installSelectedSkillSearchSources([group], 'project', '/workspace', { + fetchPlugin, + }), + ).rejects.toThrow('descriptor drift'); + expect(fetchPlugin).toHaveBeenCalledTimes(0); + }); + it('resolves marketplace selectors through the authoritative local manifest', async () => { const cache = await mkdtemp(join(tmpdir(), 'catalog-marketplace-')); fixtures.push(cache); diff --git a/tests/unit/core/git.test.ts b/tests/unit/core/git.test.ts index e993c49..043e6f5 100644 --- a/tests/unit/core/git.test.ts +++ b/tests/unit/core/git.test.ts @@ -42,7 +42,7 @@ describe('createGitEnv', () => { }); describe('cloneTo', () => { - it('clones into an empty destination with controlled LFS filters', async () => { + it('clones with controlled LFS filters despite hostile global config', async () => { if (!isIsolatedTestRun(import.meta.path)) { runTestFileIsolated(import.meta.path); return; @@ -51,6 +51,8 @@ describe('cloneTo', () => { const upstream = join(fixture, 'upstream'); const remote = join(fixture, 'origin.git'); const destination = join(fixture, 'clone'); + const gitConfig = join(fixture, 'gitconfig'); + const originalGitConfig = process.env.GIT_CONFIG_GLOBAL; try { await mkdir(upstream); @@ -60,18 +62,36 @@ describe('cloneTo', () => { await git.addConfig('user.name', 'AllAgents Test'); await git.addConfig('user.email', 'test@allagents.dev'); await writeFile(join(upstream, 'tracked.txt'), 'clean clone\n'); - await git.add('tracked.txt'); + await writeFile(join(upstream, '.gitattributes'), '*.bin filter=lfs\n'); + const pointer = + 'version https://git-lfs.github.com/spec/v1\noid sha256:fixture\nsize 7\n'; + await writeFile(join(upstream, 'asset.bin'), pointer); + await git.add(['tracked.txt', '.gitattributes', 'asset.bin']); await git.commit('fixture'); await simpleGit().raw(['init', '--bare', remote]); await git.addRemote('origin', remote); await git.push(['-u', 'origin', 'main']); + await writeFile( + gitConfig, + '[filter "lfs"]\n\trequired = true\n\tprocess = allagents-lfs-filter-must-not-run\n\tsmudge = allagents-lfs-filter-must-not-run\n', + ); + process.env.GIT_CONFIG_GLOBAL = gitConfig; + await cloneTo(remote, destination, 'main'); expect(await readFile(join(destination, 'tracked.txt'), 'utf8')).toBe( 'clean clone\n', ); + expect(await readFile(join(destination, 'asset.bin'), 'utf8')).toBe( + pointer, + ); } finally { + if (originalGitConfig === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = originalGitConfig; + } await rm(fixture, { recursive: true, force: true }); } }); From 92146c98f451895b2256ce44f2f1eda81356cb07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 14:38:45 +1000 Subject: [PATCH 10/11] fix(ci): assert committed LFS pointer in clone test --- tests/unit/core/git.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/unit/core/git.test.ts b/tests/unit/core/git.test.ts index 043e6f5..39c94e7 100644 --- a/tests/unit/core/git.test.ts +++ b/tests/unit/core/git.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -63,11 +64,14 @@ describe('cloneTo', () => { await git.addConfig('user.email', 'test@allagents.dev'); await writeFile(join(upstream, 'tracked.txt'), 'clean clone\n'); await writeFile(join(upstream, '.gitattributes'), '*.bin filter=lfs\n'); - const pointer = - 'version https://git-lfs.github.com/spec/v1\noid sha256:fixture\nsize 7\n'; + const payload = Buffer.from('fixture payload\n'); + const pointer = `version https://git-lfs.github.com/spec/v1\noid sha256:${createHash('sha256').update(payload).digest('hex')}\nsize ${payload.byteLength}\n`; await writeFile(join(upstream, 'asset.bin'), pointer); await git.add(['tracked.txt', '.gitattributes', 'asset.bin']); await git.commit('fixture'); + const committedPointer = Buffer.from( + await git.show(['HEAD:asset.bin']), + ); await simpleGit().raw(['init', '--bare', remote]); await git.addRemote('origin', remote); await git.push(['-u', 'origin', 'main']); @@ -83,9 +87,12 @@ describe('cloneTo', () => { expect(await readFile(join(destination, 'tracked.txt'), 'utf8')).toBe( 'clean clone\n', ); - expect(await readFile(join(destination, 'asset.bin'), 'utf8')).toBe( - pointer, + const clonedAsset = await readFile(join(destination, 'asset.bin')); + expect(clonedAsset).toEqual(committedPointer); + expect(clonedAsset.toString('utf8')).toMatch( + /^version https:\/\/git-lfs\.github\.com\/spec\/v1\noid sha256:[0-9a-f]{64}\nsize \d+\n$/, ); + expect(clonedAsset).not.toEqual(payload); } finally { if (originalGitConfig === undefined) { delete process.env.GIT_CONFIG_GLOBAL; From 40c8f1e2c1672806428e969a20c043b28dc91451 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 14:46:57 +1000 Subject: [PATCH 11/11] fix(ci): avoid LFS hooks in clone fixture --- tests/unit/core/git.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/unit/core/git.test.ts b/tests/unit/core/git.test.ts index 39c94e7..345752b 100644 --- a/tests/unit/core/git.test.ts +++ b/tests/unit/core/git.test.ts @@ -50,7 +50,6 @@ describe('cloneTo', () => { } const fixture = await mkdtemp(join(tmpdir(), 'allagents-git-test-')); const upstream = join(fixture, 'upstream'); - const remote = join(fixture, 'origin.git'); const destination = join(fixture, 'clone'); const gitConfig = join(fixture, 'gitconfig'); const originalGitConfig = process.env.GIT_CONFIG_GLOBAL; @@ -72,9 +71,6 @@ describe('cloneTo', () => { const committedPointer = Buffer.from( await git.show(['HEAD:asset.bin']), ); - await simpleGit().raw(['init', '--bare', remote]); - await git.addRemote('origin', remote); - await git.push(['-u', 'origin', 'main']); await writeFile( gitConfig, @@ -82,7 +78,7 @@ describe('cloneTo', () => { ); process.env.GIT_CONFIG_GLOBAL = gitConfig; - await cloneTo(remote, destination, 'main'); + await cloneTo(upstream, destination, 'main'); expect(await readFile(join(destination, 'tracked.txt'), 'utf8')).toBe( 'clean clone\n',