diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c49d03f..989bd9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,11 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.32.1 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies diff --git a/.gitignore b/.gitignore index bfd2575..3b765e9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules/ dist/ *.tsbuildinfo .pnpm-store/ +.agents +skills-lock.json diff --git a/AGENTS.md b/AGENTS.md index b12855f..74021f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,25 @@ ## Project Overview -Skillpack is a unified TUI manager for agent skills across Codex, Cursor, Claude, and Global (`~/.agents/skills`). It's a pnpm monorepo with two packages: +Skillpack is a unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agents/skills`). It's a pnpm monorepo with two packages: - `packages/core` (`@skillpack/core`) — platform-agnostic library: skill scanning, providers, install sources, parser, lockfile - `packages/tui` (`@skillpack/tui`) — Ink (React) terminal UI with keyboard-driven navigation +## Agent skills + +### Issue tracker + +Issues are tracked in GitHub Issues; external PRs are not a triage request surface. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The default triage label vocabulary is used: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`. + +### Domain docs + +This repo uses a single-context domain docs layout. See `docs/agents/domain.md`. + ## Tech Stack - **Language**: TypeScript (ES2022, Node16 modules, strict mode) @@ -34,16 +48,15 @@ node packages/tui/dist/bin/skillpack.js | File | Purpose | |------|---------| | `manager.ts` | `SkillManager` — central orchestrator for scan, toggle, install, uninstall, update | -| `providers/provider.ts` | `ISkillProvider` interface + `BaseProvider` with `.disabled-` prefix toggle | -| `providers/{codex,cursor,claude,global}.ts` | Per-agent provider implementations | +| `providers/provider.ts` | `ISkillProvider` interface + `BaseProvider` with fallback `.disabled-` prefix toggle | +| `providers/{codex,claude,global}.ts` | Default provider implementations | | `duplicates.ts` | `DuplicateDetector` — finds same-name skills across providers (symlink-aware) | -| `models/skill.ts` | `Skill`, `SkillTemplate`, `SkillSource` types (no `readonly` flag — all skills are editable/deletable) | +| `models/skill.ts` | `Skill`, `SkillTemplate`, `SkillSource` types | | `models/duplicate.ts` | `DuplicateInfo`, `DuplicateInstance` types | | `parser.ts` | SKILL.md YAML frontmatter parser | | `config.ts` | Configuration manager (`~/.config/skillpack/config.json`) | -| `lockfile.ts` | `LockfileManager` — `~/.config/skillpack/skillpack.lock` for GitHub-installed skills | | `skills-lock.ts` | `SkillsLockReader` — read-only reader for skills.sh's `~/.agents/.skill-lock.json` | -| `sources/` | Remote install sources (GitHub, skills.sh) | +| `sources/` | Remote install sources (skills.sh only in v1) | ### TUI (`packages/tui/src/`) @@ -51,16 +64,18 @@ node packages/tui/dist/bin/skillpack.js |------|---------| | `app.tsx` | App shell, router by `view` state | | `context/app-context.tsx` | Global state: skills, duplicates, selectedSkill, view, refresh | -| `views/list-view.tsx` | Main list with tabs, search, scroll | -| `views/detail-view.tsx` | Skill detail: metadata, source info, update check (`u`), toggle/edit/delete | -| `views/install-view.tsx` | Remote install flow (source → query → results → install to Global) | -| `views/create-view.tsx` | Skill creation wizard | +| `views/list-view.tsx` | Main inventory with tabs, search, scroll | +| `views/detail-view.tsx` | Skill detail: metadata, source info, toggle, skills.sh update/remove | +| `views/project-skills-view.tsx` | Read-only Project Skills view | +| `views/settings-view.tsx` | Read-only Settings view for Scan Roots, providers, and sources | +| `views/install-view.tsx` | skills.sh install flow (query → results → install to Global) | +| `views/updates-view.tsx` | Manual skills.sh update checks | | `components/` | StatusBar (context-aware shortcuts), ConfirmDialog, SkillRow, TabBar, SearchInput | | `bin/skillpack.ts` | CLI entry point with alternate screen buffer | ### Routing -The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'create'`) in `app-context.tsx`, not a router library. The `Router` component in `app.tsx` switches on this state. +The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates'`) in `app-context.tsx`, not a router library. The `Router` component in `app.tsx` switches on this state. ## Conventions @@ -71,24 +86,20 @@ The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'create'`) in `app - Prefer `useMemo` for derived state in React components - Use `useInput` from Ink for keyboard handling with `isActive` to scope input -### Skill Toggle Mechanism +### Skill Availability And Toggle -Skills are toggled by renaming their directory with a `.disabled-` prefix. The `enable`/`disable` methods on providers handle this. When calling toggle from the manager, always use the actual directory name from `skill.path` (via `path.basename()`), never `skill.name`, because the SKILL.md `name` field can differ from the directory name. +Skill discovery and Skill Availability are separate facts. A skill can exist on disk while a provider config marks it unavailable. Providers should read their provider-native config files to decide `skill.enabled` and should toggle by editing provider config when a known config mechanism exists. Codex provider-local skills use `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path. Codex Plugin-Owned Skills use `[plugins."plugin-name@marketplace-name"]` in `~/.codex/config.toml`; their availability is `plugin enabled AND skill config not false`, and toggling one toggles the owning plugin for all sibling skills. Claude regular skills use `skillOverrides` in Claude `settings.json`, and Claude plugin skills use `enabledPlugins` for the owning plugin. Use `.disabled-` directory renaming only as a fallback when a scanned provider-owned location has no known config or native disable mechanism. Never use `.disabled-` renaming to toggle Global Skills, because Global Skill directories are Shared Skill Content that Codex or Claude may reference through symlinks. When a fallback rename is used, target the actual directory from `skill.path`, never `skill.name`, because the `SKILL.md` `name` field can differ from the directory name. -### Edit, Delete, and Update +### Delete And Update -All skills can be edited (`e` opens `$EDITOR`), opened in the system file manager (`o` — uses `open` on macOS, `xdg-open` on Linux), and deleted (`d` with confirmation). There is no `readonly` flag — every skill is fully manageable. +Skills can be opened in the system file manager (`o` — uses `open` on macOS, `xdg-open` on Linux). Skillpack v1 does not edit or create skills. -**Delete routing** depends on source type: -- `skillssh`: delegates to `npx skills remove -g -y` (skills CLI manages its own lock) -- `github`: removes the skill directory + removes the entry from `skillpack.lock` -- `local` / other: delegates to the provider's `uninstall` or directly removes the directory +Delete is available only for skills.sh-managed Global Skills and delegates to `npx skills remove -g -y` so the skills CLI manages its own lock state. -**Update** is available only for `skillssh` and `github` sources. In the detail view, press `u` to first check for updates, then `u` again to apply. Update routing: +**Update** is available only for `skillssh` sources. In the detail view, press `u` to first check for updates, then `u` again to apply. The Updates view performs manual bulk checks. Update routing: - `skillssh`: delegates to `npx skills update -g -y` -- `github`: uninstalls then re-installs via `installFromSource` -Skills with `local` or other source types show no update UI. +Skills with `source.type === 'local'` are unmanaged on-disk skills: Skillpack found them in a provider/project directory but did not match them to skills.sh metadata. They are not necessarily created by Skillpack. They show no update or remove UI. ### TUI Alternate Screen Buffer @@ -98,17 +109,11 @@ The TUI runs in the terminal's alternate screen buffer (like lazygit, vim). The The `skills` CLI (`skills.sh`) installs skill files to `~/.agents/skills/` and creates symlinks in each agent directory (e.g. `~/.claude/skills/foo → ../../.agents/skills/foo`). Providers resolve symlinks via `realpath()` during scan and store the result in `skill.resolvedPath`. The `DuplicateDetector` uses resolved paths to avoid false duplicates — two skills pointing to the same real path are **not** duplicates. The detail view shows symlinks with `→` notation on the path line. -### Two Lock Systems - -Skillpack reads from two separate lock systems on startup: - -1. **`~/.agents/.skill-lock.json`** (skills.sh, read-only) — maintained by the `skills` CLI. Contains `source`, `sourceUrl`, `skillFolderHash`, `installedAt`, `updatedAt` per skill. Read by `SkillsLockReader` in `skills-lock.ts`. Used to hydrate `source.type = 'skillssh'` for skills whose resolved path lives under `~/.agents/skills/`. +### skills.sh Lock Metadata -2. **`~/.config/skillpack/skillpack.lock`** (skillpack, read-write) — maintained by `LockfileManager`. Only stores GitHub-installed skills (`source: 'github'`). Contains `repo`, `commit`, `ref`, `identifier`, `installedAt`. Stale entries (skills no longer on disk) and leftover `skillssh` entries are pruned on each scan. +Skillpack reads **`~/.agents/.skill-lock.json`** (skills.sh, read-only) on startup. The file is maintained by the `skills` CLI and contains `source`, `sourceUrl`, `skillFolderHash`, `installedAt`, and `updatedAt` per skill. `SkillsLockReader` in `skills-lock.ts` hydrates `source.type = 'skillssh'` for skills whose resolved path lives under `~/.agents/skills/`. -During `scanAll()`, hydration happens in two passes: -- **Pass 1**: Skills under `~/.agents/skills/` are hydrated from `.skill-lock.json` (skillssh source info) -- **Pass 2**: Remaining skills are hydrated from `skillpack.lock` (GitHub source info) +All other discovered skills use `source.type = 'local'`, meaning unmanaged on-disk provenance. ### skills.sh Install Identifiers @@ -116,11 +121,11 @@ The `skills find` output uses `owner/repo@skillName` format (e.g. `onmax/nuxt-sk ### Install Flow -Install is global-only (to `~/.agents/skills/`). The install view has 3 steps: source → query → results. On result select, `installFromSource()` is called with `providerId = 'global'`. For skillssh, the skills CLI handles placement; for GitHub, skills are sparse-cloned and copied. GitHub installs record `commit`/`ref`/`repo` to `skillpack.lock` for update tracking. +Install is global-only (to `~/.agents/skills/`). The install view has 3 steps: source → query → results. On result select, `installFromSource()` is called with `providerId = 'global'`. The skills CLI handles placement. GitHub installs are not supported in v1. ### Project Skills (Read-Only) -Project-level skills are scanned from `projectSkillsDirs` (configured in `config.ts`, defaults: `.codex/skills`, `.cursor/skills-cursor`, `.claude/skills`, `.agents/skills`) relative to `cwd`. They appear with `scope: 'project'` and `provider: 'project'` in the TUI's "Project" tab. Project skills are read-only in v1 — no install, update, or lockfile management. Same-name project skills take priority over global skills during scan. +Project-level skills are scanned from `projectSkillsDirs` (configured in `config.ts`, defaults: `.codex/skills`, `.claude/skills`, `.agents/skills`) relative to `cwd`. They appear with `scope: 'project'` and `provider: 'project'` in the TUI's "Project" tab. Project skills are read-only in v1 — no install, update, toggle, or lockfile management. ### State After Mutations @@ -139,4 +144,4 @@ After any mutation (toggle, edit, delete, update), `refresh()` must be called. T - **Import extensions**: Must use `.js` in imports (`'./foo.js'`), not `.ts` — Node16 module resolution requires it - **Async in `useInput`**: Fire-and-forget promises must have `.catch()` to avoid unhandled rejections crashing Ink - **`.pnpm-store/`**: Never commit — it's in `.gitignore` -- **ClaudeProvider custom scan**: `ClaudeProvider` overrides `scan()` with its own `scanFlat()` / `scanDeep()` — changes to `BaseProvider.scan()` don't apply to Claude skills. Any scan-level feature (symlink resolution, metadata enrichment) must also be added to both Claude scan methods. +- **ClaudeProvider custom scan**: `ClaudeProvider` overrides `scan()` with its own `scanFlat()` / `scanDeep()` — changes to `BaseProvider.scan()` don't apply to Claude skills. Any scan-level feature (symlink resolution, metadata enrichment, provider-native availability) must also be added to both Claude scan methods. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..700e741 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,73 @@ +# Skillpack + +Skillpack is a unified management console for reusable agent skills across agent platforms and shared skill locations. + +## Language + +**Skill**: +A reusable instruction package that an agent can load and apply during work. +_Avoid_: Prompt, plugin, rule + +**Skill Provider**: +An agent platform or shared skill location whose own rules determine where skills live and how they are loaded. +_Avoid_: Skill Library, projection target + +**Provider-Native State**: +The skill inventory and availability state as represented by a Skill Provider's own files, metadata, and conventions. +_Avoid_: Skillpack state, canonical state + +**Skill Availability**: +Whether a discovered skill is currently loadable by a specific Skill Provider according to that provider's own configuration, metadata, and loading rules. +_Avoid_: Directory exists, installed state + +**Install Source**: +A place Skillpack can search or fetch skills from before placing them into a Skill Provider. +_Avoid_: Skill Provider, registry + +**Global Skill**: +A skill in the shared global skills location managed through the skills.sh ecosystem. +_Avoid_: Universal skill, Skill Library entry + +**Shared Skill Content**: +A skill directory that may be referenced by more than one Skill Provider. Availability for one provider must not be expressed by moving or renaming shared content. +_Avoid_: Provider toggle target, per-agent state + +**Project Skill**: +A skill stored inside a project repository and maintained by that repository's authors through git. +_Avoid_: Global Skill, managed skill + +**Plugin-Owned Skill**: +A skill distributed as part of a provider plugin. Its availability may depend on the owning plugin's access state as well as any provider-specific per-skill state. +_Avoid_: Provider-local skill, independent toggle target + +**skills.sh**: +The external skill ecosystem and CLI used for installing, updating, and removing Global Skills. +_Avoid_: GitHub install source, package manager + +**Unmanaged On-Disk Skill**: +A discovered skill whose source is not identified from skills.sh metadata. These skills may live in Codex, Claude, Global, or Project skill directories, but Skillpack treats their content as provider-owned or repository-owned. +_Avoid_: Installed skill, Skillpack-created skill + +**Skill Inventory**: +The cross-provider view of discovered skills, their provenance, availability, and health. +_Avoid_: Skill editor, authoring workspace + +**Scan Root**: +A directory Skillpack inspects to discover provider, shared global, or project skills. +_Avoid_: Skill, provider, install source + +**Health Signal**: +A deterministic inventory finding that helps the user understand a skill's provider coverage, provenance, or loadability. +_Avoid_: Security score, quality rating + +**Skill Group**: +The inventory row that collects provider-specific instances believed to represent the same skill. +_Avoid_: Duplicate, provider row + +**Skill Identity**: +The evidence Skillpack uses to decide which provider-specific instances belong to the same Skill Group. +_Avoid_: Display name, directory name + +**Disable Strategy**: +The provider-specific mechanism Skillpack uses to make a skill unavailable to an agent. +_Avoid_: Universal toggle, hidden directory rule diff --git a/README.md b/README.md index de74e11..dd32709 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,15 @@ # Skillpack -Unified TUI manager for agent skills across Codex, Cursor, Claude, and Global (`~/.agents/skills`). +Unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agents/skills`). ## Features -- **Multi-platform scanning** — discovers skills from Codex, Cursor, Claude, and Global provider directories automatically, with symlink support -- **Project-level skills** — scans per-project skill directories (`.codex/skills`, `.cursor/skills-cursor`, `.claude/skills`, `.agents/skills`) with override priority over global skills -- **Enable / disable toggle** — disable any skill via `.disabled-` directory prefix rename; re-enable restores it instantly -- **Install from remote sources** — fetch skills from GitHub repos or skills.sh registry -- **Fork to local** — copy any read-only skill into a writable provider for customization -- **Duplicate detection** — highlights skills with the same name across different providers -- **Create skills** — scaffold new SKILL.md templates with frontmatter and structure -- **Lock file tracking** — records installed skill provenance in `skillpack.lock` +- **Multi-platform scanning** — discovers skills from Codex, Codex plugins, Claude, and Global provider locations automatically, with symlink support +- **Project Skills** — scans read-only project skill directories (`.codex/skills`, `.claude/skills`, `.agents/skills`) in a separate view +- **Provider-native availability** — reads provider config when available and uses `.disabled-` renaming only as a fallback +- **Skill Inventory** — groups provider instances by Skill Identity and surfaces deterministic Health Signals +- **skills.sh installs** — installs, updates, and removes Global Skills through the skills.sh CLI +- **Manual updates** — checks updates only when requested - **Fuzzy search** — filter skills by name or description - **Keyboard-driven** — full TUI navigation without a mouse @@ -41,9 +39,9 @@ node packages/tui/dist/skillpack.js ## Quick Start -Launch `skillpack` to see all discovered skills grouped by provider. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to switch between provider tabs (All, Codex, Cursor, Claude, Global, Project), and `/` to search. +Launch `skillpack` to see all discovered skills grouped by provider. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to switch between provider tabs (All, Codex, Claude, Global), and `/` to search. -Press `Space` to toggle a skill on or off, `Enter` to view its details, `i` to install from a remote source, or `c` to create one from scratch. +Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a skills.sh Global Skill, or `u` to open manual updates. Plugin-owned skills require confirmation because the action toggles the owning plugin and affects sibling skills from the same plugin. ## Keyboard Shortcuts @@ -52,14 +50,15 @@ Press `Space` to toggle a skill on or off, `Enter` to view its details, `i` to i | Key | Action | Description | |-----|--------|-------------| | `↑` / `↓` | Navigate | Move selection up / down | -| `Space` | Toggle | Enable or disable the selected skill | +| `Space` | Toggle | Enable or disable a selected Codex or Claude skill; plugin-owned skills ask for confirmation | | `Enter` | Detail | Open skill detail view | -| `Tab` / `Shift+Tab` | Switch tab | Cycle through All / Codex / Cursor / Claude / Global / Project | +| `Tab` / `Shift+Tab` | Switch tab | Cycle through All / Codex / Claude / Global | | `/` | Search | Fuzzy match on name + description | | `Esc` | Clear search | Clear the active search filter | -| `i` | Install | Install from GitHub or skills.sh | -| `c` | Create | Scaffold a new skill | -| `u` | Update | Check for updates | +| `p` | Project Skills | Open read-only Project Skills view | +| `s` | Settings | Open read-only Settings and Scan Roots view | +| `i` | Install | Install a Global Skill through skills.sh | +| `u` | Updates | Open manual skills.sh updates | | `q` | Quit | Exit skillpack | ### Detail View @@ -67,10 +66,9 @@ Press `Space` to toggle a skill on or off, `Enter` to view its details, `i` to i | Key | Action | Description | |-----|--------|-------------| | `Esc` | Back | Return to list view | -| `Space` | Toggle | Enable or disable the skill | -| `e` / `E` | Edit | Open SKILL.md in `$EDITOR` | +| `Space` | Toggle | Enable or disable a Codex or Claude skill; plugin-owned skills ask for confirmation | | `o` / `O` | Open folder | Open skill directory in system file manager | -| `d` | Delete | Uninstall skill with confirmation | +| `d` | Delete | Remove a skills.sh-managed Global Skill with confirmation | | `↑` / `↓` | Scroll | Scroll the description when it overflows | ## Configuration @@ -83,28 +81,23 @@ Skillpack stores its configuration at `~/.config/skillpack/config.json`. On firs "autoCheckUpdates": true, "projectSkillsDirs": [ ".codex/skills", - ".cursor/skills-cursor", ".claude/skills", ".agents/skills" ], "providers": { - "codex": { "enabled": true, "paths": ["~/.codex/skills"] }, - "cursor": { "enabled": true, "paths": ["~/.cursor/skills-cursor"] }, + "codex": { "enabled": true, "paths": ["~/.codex/skills", "~/.codex/plugins/cache"] }, "claude": { "enabled": true, "paths": ["~/.claude/plugins/cache", "~/.claude/skills"] }, "global": { "enabled": true, "paths": ["~/.agents/skills"] } }, "sources": { - "github": { "enabled": true }, "skillssh": { "enabled": true } } } ``` -The lock file lives at `~/.config/skillpack/skillpack.lock` and records the source, identifier, and install timestamp for each remotely installed skill. - ## Adding a Provider -Extend the `BaseProvider` class from `@skillpack/core` (which implements `ISkillProvider` with default `scan`, `enable`, `disable` via `.disabled-` prefix rename): +Extend the `BaseProvider` class from `@skillpack/core` (which implements `ISkillProvider` with default scanning and a fallback `.disabled-` prefix rename strategy). Providers with native availability config should override `scan`, `setEnabled`, and `getDisableStrategy` so Skillpack reflects the provider's own loading rules. ```typescript import { BaseProvider, type ProviderCapabilities } from '@skillpack/core'; @@ -140,8 +133,8 @@ skillpack/ │ ├── core/ # @skillpack/core — platform-agnostic library │ │ ├── src/ │ │ │ ├── models/ # Skill, DuplicateInfo, RemoteSkill, etc. -│ │ │ ├── providers/ # Codex, Cursor, Claude, Global providers + BaseProvider -│ │ │ ├── sources/ # GitHub and skills.sh install sources +│ │ │ ├── providers/ # Codex, Claude, Global providers + BaseProvider +│ │ │ ├── sources/ # skills.sh install source │ │ │ ├── config.ts # Configuration manager │ │ │ ├── duplicates.ts # Duplicate detection logic │ │ │ ├── lockfile.ts # Lock file manager @@ -150,7 +143,7 @@ skillpack/ │ │ └── tests/ │ └── tui/ # @skillpack/tui — Ink-based terminal UI │ └── src/ -│ ├── views/ # ListView, DetailView, InstallView, CreateView, UpdateView +│ ├── views/ # ListView, DetailView, InstallView, ProjectSkillsView, SettingsView, UpdatesView │ ├── components/ # StatusBar, ConfirmDialog, SearchInput, SkillRow, TabBar │ ├── context/ # React context for app state │ ├── hooks/ # useSkillManager, useSkills, useSearch, useTerminalSize diff --git a/docs/adr/0001-skillpack-owns-the-skill-library.md b/docs/adr/0001-skillpack-owns-the-skill-library.md new file mode 100644 index 0000000..8c1c1ef --- /dev/null +++ b/docs/adr/0001-skillpack-owns-the-skill-library.md @@ -0,0 +1,7 @@ +--- +status: superseded by ADR-0005 +--- + +# Skillpack owns the Skill Library + +Skillpack will treat its own Skill Library as the source of truth for installed skill content and provenance, while agent platform directories become generated projections of activations. This replaces the current provider-centric model where Skillpack scans provider folders and infers ownership afterward, trading some migration complexity for cleaner duplicate handling, update tracking, and cross-agent enablement. diff --git a/docs/adr/0002-use-symlink-projections-by-default.md b/docs/adr/0002-use-symlink-projections-by-default.md new file mode 100644 index 0000000..0b6e924 --- /dev/null +++ b/docs/adr/0002-use-symlink-projections-by-default.md @@ -0,0 +1,7 @@ +--- +status: superseded by ADR-0005 +--- + +# Use symlink projections by default + +Skillpack will project activated skills into provider directories as symlinks by default, falling back to physical copies only when a provider cannot follow symlinks. This keeps the Skill Library as the single canonical content location and avoids copy drift, while still leaving an escape hatch for provider-specific filesystem constraints. diff --git a/docs/adr/0003-make-profiles-first-class.md b/docs/adr/0003-make-profiles-first-class.md new file mode 100644 index 0000000..6791733 --- /dev/null +++ b/docs/adr/0003-make-profiles-first-class.md @@ -0,0 +1,7 @@ +--- +status: superseded by ADR-0005 +--- + +# Make Profiles first-class + +Skillpack will make Profiles a first-class management concept, with individual activations editable inside each profile. This shifts the user's day-to-day model from scattered provider folder state to named sets of intended skill availability, at the cost of needing explicit profile selection and projection behavior. diff --git a/docs/adr/0004-support-layered-profiles.md b/docs/adr/0004-support-layered-profiles.md new file mode 100644 index 0000000..2a65a64 --- /dev/null +++ b/docs/adr/0004-support-layered-profiles.md @@ -0,0 +1,7 @@ +--- +status: superseded by ADR-0005 +--- + +# Support layered Profiles + +Skillpack will allow Profiles to be layered, so a workspace can compose a default Profile, a user-selected Profile, and project-specific Profile layers. This supports baseline-plus-context workflows without duplicating Profile definitions, but requires deterministic precedence rules when layers disagree. diff --git a/docs/adr/0005-use-provider-native-management.md b/docs/adr/0005-use-provider-native-management.md new file mode 100644 index 0000000..3d10b3f --- /dev/null +++ b/docs/adr/0005-use-provider-native-management.md @@ -0,0 +1,3 @@ +# Use provider-native management + +Skillpack will be a unified management console over provider-native skill state, not the canonical owner of a central Skill Library. Each Skill Provider remains the source of truth for what its agent can load, while Skillpack scans provider-specific locations and offers safe management, provenance, cleanup, and advisory workflows across Codex, Claude, and Global skills. diff --git a/docs/adr/0006-make-skill-inventory-the-primary-experience.md b/docs/adr/0006-make-skill-inventory-the-primary-experience.md new file mode 100644 index 0000000..173115f --- /dev/null +++ b/docs/adr/0006-make-skill-inventory-the-primary-experience.md @@ -0,0 +1,3 @@ +# Make Skill Inventory the primary experience + +Skillpack will optimize the TUI around understanding the cross-provider Skill Inventory before mutation. The main experience should explain what skills exist, where they come from, which providers can load them, and what health or duplication issues need attention, with lifecycle actions available from focused detail views. diff --git a/docs/adr/0007-exclude-skill-authoring.md b/docs/adr/0007-exclude-skill-authoring.md new file mode 100644 index 0000000..35183c7 --- /dev/null +++ b/docs/adr/0007-exclude-skill-authoring.md @@ -0,0 +1,3 @@ +# Exclude skill authoring + +Skillpack will not support editing existing skills or creating new skills. This keeps the product focused on inventory, provenance, installation, update, removal, and advisory workflows rather than becoming a skill authoring environment, and avoids implying Skillpack owns the content model for every provider. diff --git a/docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md b/docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md new file mode 100644 index 0000000..b06b9af --- /dev/null +++ b/docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md @@ -0,0 +1,3 @@ +# Limit v1 mutations to provider lifecycle actions + +Skillpack v1 will support enable/disable only for provider-owned Codex and Claude availability state. Global Skills are managed through skills.sh install, update, and remove operations only. Skillpack will not copy, import, or fork skills between providers in v1, because those workflows blur provider ownership and recreate duplicate drift that the inventory experience should instead make visible. diff --git a/docs/adr/0009-use-skillssh-only-for-v1-installs.md b/docs/adr/0009-use-skillssh-only-for-v1-installs.md new file mode 100644 index 0000000..87f707c --- /dev/null +++ b/docs/adr/0009-use-skillssh-only-for-v1-installs.md @@ -0,0 +1,3 @@ +# Use skills.sh only for v1 installs + +Skillpack v1 will install skills only through the skills.sh ecosystem into Global Skills. Arbitrary GitHub installs are out of scope for v1 because they would make Skillpack responsible for repository layout, provenance, update, and lock semantics that are better handled by a dedicated skill ecosystem. diff --git a/docs/adr/0010-restrict-provider-local-mutations.md b/docs/adr/0010-restrict-provider-local-mutations.md new file mode 100644 index 0000000..497f167 --- /dev/null +++ b/docs/adr/0010-restrict-provider-local-mutations.md @@ -0,0 +1,3 @@ +# Restrict provider-local mutations + +Skillpack v1 will treat Codex and Claude as scan plus enable/disable Skill Providers only. Global Skills managed through skills.sh are the only skills with install, update, and remove lifecycle actions, keeping destructive operations tied to external provenance and avoiding accidental deletion of provider-local content. diff --git a/docs/adr/0011-use-provider-specific-disable-strategies.md b/docs/adr/0011-use-provider-specific-disable-strategies.md new file mode 100644 index 0000000..793fd06 --- /dev/null +++ b/docs/adr/0011-use-provider-specific-disable-strategies.md @@ -0,0 +1,11 @@ +# Use provider-specific Disable Strategies + +Skillpack will implement enable/disable through provider-specific Disable Strategies. A provider adapter should use a known provider configuration mechanism when one exists, and use `.disabled-` directory renaming only as a fallback when a scanned provider-owned location has no known config or native disable mechanism for skills. + +For Codex provider-local skills, Skill Availability is controlled through `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path, so Codex toggles must patch that config instead of renaming skill directories. + +For Codex Plugin-Owned Skills, availability is the conjunction of the owning plugin's access state and any per-skill config override. The owning plugin's access state is controlled through `[plugins."plugin-name@marketplace-name"]` in `~/.codex/config.toml`. Skillpack toggles plugin-owned Codex skills by writing that plugin entry, and the TUI must make clear that the action affects every skill distributed by the same plugin. + +For Claude, regular skills under `~/.claude/skills` are controlled by `skillOverrides` in Claude `settings.json`; Skillpack disables them with `"off"` and enables them with `"on"`. Claude plugin skills are not affected by `skillOverrides`, so Skillpack reads and writes the owning plugin's `enabledPlugins["plugin-name@marketplace-name"]` setting when it can infer the plugin ID from the cache path. + +Global Skills are Shared Skill Content. Skillpack must not disable a Global Skill by renaming its directory, because Codex or Claude provider instances may be symlinks to that same directory. Global Skills expose install, update, and remove lifecycle actions only. diff --git a/docs/adr/0012-group-inventory-by-skill.md b/docs/adr/0012-group-inventory-by-skill.md new file mode 100644 index 0000000..e303cca --- /dev/null +++ b/docs/adr/0012-group-inventory-by-skill.md @@ -0,0 +1,3 @@ +# Group inventory by Skill + +Skillpack's primary inventory view will group provider-specific instances by normalized Skill identity rather than rendering each instance as a separate top-level row. This makes cross-provider coverage, duplication, provenance, and drift visible first, while detailed instance-level actions remain available inside the Skill Group detail view. diff --git a/docs/adr/0013-use-provenance-aware-skill-identity.md b/docs/adr/0013-use-provenance-aware-skill-identity.md new file mode 100644 index 0000000..b9ca248 --- /dev/null +++ b/docs/adr/0013-use-provenance-aware-skill-identity.md @@ -0,0 +1,3 @@ +# Use provenance-aware Skill Identity + +Skillpack will group provider-specific skill instances using provenance-aware Skill Identity: shared real paths, skills.sh metadata, and source identifiers are stronger evidence than names. When provenance is unavailable, Skillpack may fall back to normalized names, but the UI should make inferred grouping visible so users can spot possible false positives. diff --git a/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md b/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md new file mode 100644 index 0000000..f853a84 --- /dev/null +++ b/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md @@ -0,0 +1,3 @@ +# Limit v1 Health Signals to structure and provenance + +Skillpack v1 will surface deterministic Health Signals: provider coverage, inferred versus confirmed Skill Identity, enabled or disabled state, missing or invalid `SKILL.md`, broken symlinks, available skills.sh updates, and unmanaged Global Skills. Security or risk scoring is out of scope for v1 because it requires a separate threat model and would be easy to overstate. diff --git a/docs/adr/0015-treat-project-skills-as-read-only.md b/docs/adr/0015-treat-project-skills-as-read-only.md new file mode 100644 index 0000000..674edf3 --- /dev/null +++ b/docs/adr/0015-treat-project-skills-as-read-only.md @@ -0,0 +1,3 @@ +# Treat Project Skills as read-only + +Skillpack may scan Project Skills for inventory and context, but it will not control, enable, disable, install, update, or remove them. Project Skills are maintained by repository authors through git, so Skillpack should preserve project ownership and treat them as read-only evidence in the Skill Inventory. diff --git a/docs/adr/0016-show-project-skills-in-a-separate-view.md b/docs/adr/0016-show-project-skills-in-a-separate-view.md new file mode 100644 index 0000000..acc128a --- /dev/null +++ b/docs/adr/0016-show-project-skills-in-a-separate-view.md @@ -0,0 +1,3 @@ +# Show Project Skills in a separate view + +Skillpack will show Project Skills in a separate read-only view rather than grouping them into the main controllable Skill Inventory. This avoids confusing project-owned git-maintained skills with provider-native skills that Skillpack can enable, disable, install, update, or remove. diff --git a/docs/adr/0017-use-sectioned-tui-navigation.md b/docs/adr/0017-use-sectioned-tui-navigation.md new file mode 100644 index 0000000..73d322d --- /dev/null +++ b/docs/adr/0017-use-sectioned-tui-navigation.md @@ -0,0 +1,3 @@ +# Use sectioned TUI navigation + +Skillpack will use top-level TUI sections for Inventory, Project Skills, Settings, Install, and Updates. Inventory remains the primary controllable view for provider-native skills and must not spend vertical space on inline Scan Root summaries. Project Skills is a separate read-only view, Settings shows read-only configuration and Scan Root diagnostics, Install is limited to skills.sh Global Skill installation, and Updates focuses on skills.sh-managed Global Skills. diff --git a/docs/adr/0018-defer-llm-advisory-guidance.md b/docs/adr/0018-defer-llm-advisory-guidance.md new file mode 100644 index 0000000..b318e68 --- /dev/null +++ b/docs/adr/0018-defer-llm-advisory-guidance.md @@ -0,0 +1,3 @@ +# Defer LLM advisory guidance + +Skillpack v1 will show deterministic inventory facts and Health Signals only, not LLM-generated best-practice recommendations. Advisory workflows such as overlap analysis, content quality review, and risk suggestions are deferred until the inventory model is stable enough for users to distinguish facts from recommendations. diff --git a/docs/adr/0019-use-manual-update-checks.md b/docs/adr/0019-use-manual-update-checks.md new file mode 100644 index 0000000..f971cb1 --- /dev/null +++ b/docs/adr/0019-use-manual-update-checks.md @@ -0,0 +1,3 @@ +# Use manual update checks + +Skillpack v1 will not check skills.sh updates automatically on startup. The TUI should load local inventory first and let users explicitly run update checks from the Updates section or a focused action, avoiding startup delays and network failures in the primary inventory experience. diff --git a/docs/adr/0020-use-auto-detected-configurable-provider-paths.md b/docs/adr/0020-use-auto-detected-configurable-provider-paths.md new file mode 100644 index 0000000..48b6d07 --- /dev/null +++ b/docs/adr/0020-use-auto-detected-configurable-provider-paths.md @@ -0,0 +1,3 @@ +# Use auto-detected configurable provider paths + +Skillpack will auto-detect default provider paths for Codex, Claude, Global, and Project Skills, while allowing users to override or add paths in configuration. Cursor is out of the v1 provider set. Skillpack will not use a first-run wizard, because the inventory should open quickly for common setups while still supporting custom provider layouts. diff --git a/docs/adr/0021-use-explain-before-action-detail-views.md b/docs/adr/0021-use-explain-before-action-detail-views.md new file mode 100644 index 0000000..614bb2d --- /dev/null +++ b/docs/adr/0021-use-explain-before-action-detail-views.md @@ -0,0 +1,3 @@ +# Use explain-before-action detail views + +Skillpack detail views will show summary, Skill Identity confidence, provider instances, paths and resolved paths, Disable Strategy, Health Signals, and skills.sh metadata before exposing valid actions. This keeps lifecycle operations grounded in visible provider-native state instead of blind toggles. diff --git a/docs/adr/0022-do-not-toggle-shared-global-skill-content.md b/docs/adr/0022-do-not-toggle-shared-global-skill-content.md new file mode 100644 index 0000000..093328e --- /dev/null +++ b/docs/adr/0022-do-not-toggle-shared-global-skill-content.md @@ -0,0 +1,11 @@ +# Do not toggle shared Global Skill content + +Skillpack will not expose enable/disable actions for Global Skills. Global Skill directories are Shared Skill Content: Codex and Claude provider instances may reference them through symlinks or other provider-native projections. Renaming a Global Skill directory to `.disabled-` would mutate the shared content path and can accidentally break every provider that points at it. + +Provider-specific availability must be controlled through provider-specific state instead: + +- Codex availability is controlled through `[[skills.config]]` in `~/.codex/config.toml`. +- Claude regular skill availability is controlled through `skillOverrides` in Claude `settings.json`. +- Claude plugin availability is controlled through `enabledPlugins` in Claude `settings.json`. + +Global Skills remain manageable through skills.sh lifecycle actions: install, update, and remove. A future "disable everywhere" action, if needed, should be explicit and should update each provider's availability config rather than renaming the shared Global Skill directory. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..d523859 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,33 @@ +# Domain Docs + +Configured layout: single-context. + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill creates them lazily when terms or decisions actually get resolved. + +## File structure + +```text +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-example-decision.md +│ └── 0002-example-decision.md +└── packages/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept, use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, either you're inventing language the project doesn't use or there's a real gap to note for `/domain-modeling`. + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..4eef9d4 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** + +When a skill says "publish to the issue tracker", create a GitHub issue. + +When a skill says "fetch the relevant ticket", run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..0806b2f --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,13 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role, use the corresponding label string from this table. diff --git a/docs/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md new file mode 100644 index 0000000..13adba0 --- /dev/null +++ b/docs/prds/provider-native-skillpack.md @@ -0,0 +1,104 @@ +# PRD: Provider-Native Skillpack + +## Problem Statement + +Users have skills scattered across Codex, Claude, Global skills from skills.sh, and project repositories. Each provider has different loading rules, filesystem locations, metadata conventions, and lifecycle behavior. The current Skillpack experience mixes inventory, authoring, remote installs, and provider-local destructive actions, which makes it harder for users to understand what skills they actually have and what is safe to change. + +Users need a trustworthy TUI that first explains their Skill Inventory, then exposes only provider-safe lifecycle actions. + +## Solution + +Refactor Skillpack into an understanding-first, provider-native management console. + +Skillpack will scan each Skill Provider according to that provider's own rules, group provider instances into Skill Groups using provenance-aware Skill Identity, and surface deterministic Health Signals. It will not edit or create skills. It will install, update, and remove only Global Skills managed by skills.sh. Codex and Claude provider-local skills can be scanned and enabled/disabled, but not removed, updated, edited, or created. Project Skills are shown in a separate read-only view because they are maintained by repository authors through git. + +## User Stories + +1. As a Skillpack user, I want to open the TUI and immediately see my cross-provider Skill Inventory, so that I understand what skills exist before changing anything. +2. As a Skillpack user, I want related provider instances grouped into one Skill Group, so that duplicate or shared skills are obvious. +3. As a Skillpack user, I want each Skill Group to show provider badges, so that I can see whether Codex, Claude, or Global can load it. +4. As a Skillpack user, I want enabled and disabled states shown per provider, so that I know which agents can currently load a skill. +5. As a Skillpack user, I want Skill Identity confidence shown, so that I know whether a group is confirmed by provenance or inferred by name. +6. As a Skillpack user, I want Health Signals shown in the inventory, so that broken or stale skills are visible without opening every detail view. +7. As a Skillpack user, I want to open a Skill Group detail view, so that I can inspect each provider instance before acting. +8. As a Skillpack user, I want detail views to show paths and resolved paths, so that symlinked or shared skills are understandable. +9. As a Skillpack user, I want detail views to show skills.sh metadata when available, so that Global Skill provenance is clear. +10. As a Skillpack user, I want actions to appear only when valid for the selected provider instance, so that I do not accidentally perform unsupported operations. +11. As a Skillpack user, I want to enable or disable a Codex skill using the safest known provider strategy, so that Codex loading behavior changes predictably. +12. As a Skillpack user, I want to enable or disable a Claude skill using the safest known provider strategy, so that Claude loading behavior changes predictably. +13. As a Skillpack user, I want `.disabled-` renaming used only as a fallback, so that Skillpack does not ignore known provider configuration mechanisms. +14. As a Skillpack user, I want Project Skills in a separate read-only section, so that project-owned git-maintained skills are not confused with controllable provider skills. +15. As a Skillpack user, I want Project Skills to show basic validity and paths, so that I can understand what the current repository contributes. +16. As a Skillpack user, I want Skillpack to never mutate Project Skills, so that repository authors remain responsible for them through git. +17. As a Skillpack user, I want to install Global Skills through skills.sh, so that installation follows the ecosystem's lifecycle and metadata. +18. As a Skillpack user, I want arbitrary GitHub installs excluded from v1, so that Skillpack does not become a second package manager. +19. As a Skillpack user, I want to remove only skills.sh-managed Global Skills, so that destructive removal is tied to external provenance. +20. As a Skillpack user, I want to manually check for updates, so that opening the TUI is fast and does not depend on network calls. +21. As a Skillpack user, I want the Updates section to show "not checked" before I run a check, so that update freshness is not implied. +22. As a Skillpack user, I want to update skills.sh-managed Global Skills after a manual check, so that I control when remote changes are applied. +23. As a Skillpack user, I want unmanaged Global Skills called out, so that I know which Global Skills lack skills.sh lock metadata. +24. As a Skillpack user, I want invalid `SKILL.md` files surfaced as Health Signals, so that broken skills are visible. +25. As a Skillpack user, I want broken symlinks surfaced as Health Signals, so that filesystem problems are visible. +26. As a Skillpack user, I want provider paths auto-detected, so that Skillpack works without setup for common installations. +27. As a Skillpack user, I want provider paths configurable, so that custom installations can still be scanned. +28. As a Skillpack user, I want Scan Roots shown in a separate Settings section, so that Inventory stays focused on skills. +29. As a Skillpack user, I want Settings to show provider and project Scan Roots with exists/missing status, so that I can diagnose why skills are or are not discovered. +30. As a Skillpack user, I want Settings to be read-only in v1, so that configuration editing is not mixed with inventory operations before validation and persistence rules are designed. +31. As a Skillpack user, I want no skill editing UI, so that Skillpack stays focused on management and inventory. +32. As a Skillpack user, I want no skill creation UI, so that authoring remains outside Skillpack. +33. As a maintainer, I want core inventory derivation tested independently of the TUI, so that behavior remains stable as the interface changes. +34. As a maintainer, I want provider lifecycle capabilities represented explicitly, so that unsupported actions cannot leak into the UI. +35. As a maintainer, I want skills.sh interactions isolated, so that external CLI behavior is easy to test and mock. +36. As a maintainer, I want Project Skill scanning separated from controllable inventory scanning, so that read-only ownership is enforced in code. + +## Implementation Decisions + +- Skillpack is a provider-native management console, not the canonical owner of skill content. +- The primary TUI experience is the Skill Inventory. +- The top-level TUI sections are Inventory, Project Skills, Settings, Install, and Updates. +- Inventory rows are Skill Groups, not provider-instance rows. +- Skill Groups are built from provenance-aware Skill Identity. +- Normalized-name grouping is a fallback and should be marked as inferred. +- Health Signals are deterministic only in v1. +- LLM-generated advisory guidance is deferred. +- Project Skills are read-only and shown in a separate section. +- Editing and creating skills are out of scope. +- GitHub installs are out of scope. +- V1 installs use skills.sh only and install into Global Skills only. +- Codex and Claude support scan plus enable/disable only. +- Global Skills support scan, install, update, and remove when managed through skills.sh. They do not support enable/disable because their directories are Shared Skill Content that provider-specific instances may reference. +- Provider-local destructive removal is out of scope. +- Enable/disable uses provider-specific Disable Strategies. Codex provider-local skills use `~/.codex/config.toml` `[[skills.config]]` entries keyed by absolute `SKILL.md` path. Codex plugin skills use the owning plugin's `[plugins."plugin-name@marketplace-name"]` entry, with availability determined by `plugin enabled AND skill config not false`. Claude regular skills use `skillOverrides` in Claude `settings.json`; Claude plugin skills use the owning plugin's `enabledPlugins` setting when Skillpack can infer the plugin ID. `.disabled-` renaming is fallback behavior only for provider-owned locations with no known provider mechanism; it is not used for Global Skills. +- Update checks are manual and should not block startup. +- Provider paths are auto-detected with configurable overrides. +- Scan Roots are shown in read-only Settings, not inline in Inventory. +- Detail views follow explain-before-action. + +## Testing Decisions + +- The highest-value testing seam is the core inventory derivation API: provider instances in, Skill Groups, Project Skills, actions, and Health Signals out. +- Provider tests should cover scan behavior, enabled/disabled detection, symlink handling, invalid skills, and disable strategies. +- Manager-level tests should verify action availability and lifecycle routing, not implementation details. +- skills.sh source tests should mock CLI behavior and verify install/update/remove/check semantics. +- Project Skill tests should assert that Project Skills are discoverable but expose no mutation actions. +- Existing Vitest coverage in core should be extended and reshaped around the new inventory model. +- TUI verification can remain manual for v1 unless a dedicated Ink test harness is introduced. + +## Out of Scope + +- Editing existing skills. +- Creating new skills. +- Arbitrary GitHub installs. +- Copying, importing, or forking skills between providers. +- Removing provider-local Codex or Claude skills. +- Mutating Project Skills. +- LLM best-practice recommendations. +- Security or risk scoring. +- Automatic update checks on startup. +- Full TUI automated test harness. + +## Further Notes + +This PRD supersedes the earlier central Skill Library direction. The accepted direction is provider-native state with a trustworthy inventory and constrained lifecycle actions. + +The corresponding refactor spec is `docs/specs/provider-native-skillpack-refactor.md`. diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md new file mode 100644 index 0000000..24a1dd4 --- /dev/null +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -0,0 +1,252 @@ +# Provider-Native Skillpack Refactor Spec + +## Purpose + +Refactor Skillpack into an understanding-first TUI for managing agent skills across Codex, Claude, Global skills from skills.sh, and read-only Project Skills. + +Skillpack should not become the canonical owner of skill content. Each Skill Provider keeps its provider-native state. Skillpack scans that state, groups related instances into a Skill Inventory, exposes deterministic Health Signals, and allows only the lifecycle actions that are safe for each provider. + +## Product Shape + +The TUI has five top-level sections: + +- **Inventory**: grouped view of provider-native skills from Codex, Claude, and Global. +- **Project Skills**: read-only inventory of skills stored in the current project repository. +- **Settings**: read-only configuration diagnostics, including Scan Roots, providers, and sources. +- **Install**: skills.sh search and install into Global Skills only. +- **Updates**: manual update checks and updates for skills.sh-managed Global Skills. + +The main experience is Inventory. It should answer: + +- Which skills exist? +- Which providers can load them? +- Are they enabled or disabled? +- Are grouped instances confirmed by provenance or inferred by name? +- Are any provider instances broken, unmanaged, duplicated, or stale? + +## Scope + +In scope: + +- Scan Codex, Claude, Global, and Project Skill locations using provider-specific rules. +- Scan Codex provider-local skills from `~/.codex/skills` and active-looking Codex plugin skills from `~/.codex/plugins/cache` by default. Stale or duplicate cached plugin copies should not appear in the main inventory by default. +- Build Skill Groups from provider instances using provenance-aware Skill Identity. +- Show provider badges, Health Signals, and provenance summaries in the main Inventory. +- Show Project Skills in a separate read-only view. +- Show Scan Roots in a separate read-only Settings view instead of inline in Inventory. +- Enable or disable Codex and Claude provider instances through provider-specific Disable Strategies. +- Use `.disabled-` renaming only when no known provider config or native disable mechanism exists. +- Install Global Skills through skills.sh only. +- Remove and update skills.sh-managed Global Skills through skills.sh only. +- Manual update checks only. +- Configurable provider paths with auto-detected defaults. + +Out of scope: + +- Editing skills. +- Creating new skills. +- Arbitrary GitHub installs. +- Copying, importing, or forking skills between providers. +- Removing provider-local Codex or Claude skills. +- LLM-generated advisory guidance or security scoring. +- Automatic update checks on startup. +- Controlling Project Skills. + +## Domain Model + +### Skill Provider + +A provider-native source of skill state. V1 providers: + +- Codex +- Claude +- Global / skills.sh +- Project Skills + +Project Skills are inventory-only and must not expose lifecycle actions. + +### Provider Instance + +A discovered skill in one provider. It includes: + +- provider ID +- provider display name +- path +- resolved path when symlinked +- parsed SKILL.md metadata when valid +- enabled/disabled state +- source/provenance metadata +- Health Signals +- supported actions + +### Scan Root + +A configured or default directory Skillpack inspects during scan. Settings should show: + +- provider or project scope +- path +- exists/missing state +- root kind +- provider enabled state when applicable + +Settings is read-only in v1. Editing Scan Roots requires a separate design for validation, persistence, and reset-to-default behavior. + +Read-only Settings should show only configuration diagnostics already available in memory: + +- Scan Roots with scope, provider when present, kind, path, and exists/missing state +- Providers with provider ID, enabled/disabled config state, and Scan Root count +- Sources with source ID and enabled/disabled config state + +Settings should not show editable JSON, raw config file contents, or provider-native config internals in v1. + +### Skill Group + +The primary Inventory row. A Skill Group contains provider instances that likely represent the same Skill. + +Grouping confidence should be explicit: + +- **confirmed**: shared real path, skills.sh lock metadata, or known source identity ties instances together. +- **inferred**: normalized names match, but provenance is unavailable. + +### Health Signal + +V1 Health Signals are deterministic: + +- grouped across multiple providers +- inferred identity +- enabled or disabled per provider +- missing or invalid `SKILL.md` +- broken symlink +- skills.sh update available +- unmanaged Global Skill without skills.sh lock metadata + +Security/risk scoring is out of scope. + +### Disable Strategy + +Each provider adapter owns enable/disable behavior. A strategy should prefer known provider-native config mechanisms. Provider config determines Skill Availability when a provider has a known config file or native disable mechanism. `.disabled-` directory renaming is a fallback only when a scanned location has no provider-specific mechanism. + +Known v1 strategies: + +- Codex provider-local skills: read/write `[[skills.config]]` entries in `~/.codex/config.toml`, keyed by absolute `SKILL.md` path. +- Codex plugin skills: read/write `[plugins."plugin-name@marketplace-name"]` in `~/.codex/config.toml`, when the plugin ID is inferable from the cache path. Skill Availability is `plugin enabled AND skill config not false`; toggling a plugin-owned skill toggles the owning plugin and affects all skills from that plugin. +- Claude regular skills: read/write `skillOverrides` in Claude `settings.json`, keyed by skill name. +- Claude plugin skills: read/write `enabledPlugins` in Claude `settings.json`, keyed by `plugin-name@marketplace-name`, when the plugin ID is inferable from the cache path. +- Global / skills.sh: no enable/disable strategy. Global Skills are Shared Skill Content and expose install, update, and remove lifecycle actions only. + +Plugin-level toggles require confirmation in the TUI. The confirmation must name the owning plugin and show the sibling skills affected by the same plugin availability gate. + +Codex plugin cache scanning should select active-looking plugin roots for the main inventory: + +- Build candidates from `~/.codex/plugins/cache////`. +- Include candidates whose `plugin@marketplace` appears in `~/.codex/config.toml`, even when disabled. +- Include remote-installed or bundled candidates without a config entry only when another root has not already been selected for the same plugin name. +- If multiple versions exist for the same `plugin@marketplace`, select the highest semantic version; for non-semver versions, select the newest modified root. +- Hide non-selected cached copies from the main inventory for now. + +A missing Codex plugin config entry means the active-looking plugin root is enabled by default. Disabling such a plugin writes a new `[plugins."plugin-name@marketplace-name"]` table with `enabled = false`. + +## Provider Capabilities + +| Provider | Scan | Enable/disable | Install | Update | Remove | Edit/Create | +| --- | --- | --- | --- | --- | --- | --- | +| Codex | yes | yes | no | no | no | no | +| Claude | yes | yes | no | no | no | no | +| Global / skills.sh | yes | no | yes | yes | yes | no | +| Project Skills | yes | no | no | no | no | no | + +## TUI Behavior + +### Inventory + +Rows are Skill Groups, not individual provider instances. + +Recommended columns: + +- name +- provider badges with enabled/disabled state +- Health Signals +- source/provenance summary + +Provider filters can exist inside Inventory: + +- All +- Codex +- Claude +- Global + +### Detail View + +The detail view uses an explain-before-action pattern: + +- summary +- Skill Identity confidence +- description +- provider instances +- path and resolved path +- enabled/disabled state +- Disable Strategy +- Health Signals +- skills.sh metadata when present +- actions valid for the selected provider instance + +No action should appear unless the selected provider instance supports it. + +### Project Skills + +Project Skills appear in a separate read-only section. They should show: + +- name +- project-relative path +- description +- validity of `SKILL.md` +- whether the skill name overlaps with non-project Skill Groups + +No mutation actions are available. + +### Install + +Install uses skills.sh only: + +- search through skills.sh +- select result +- install into Global Skills +- refresh inventory + +No GitHub install path exists in v1. + +### Updates + +Updates are manual: + +- initial state is "not checked" +- user triggers a skills.sh update check +- results show available updates for skills.sh-managed Global Skills +- user applies updates explicitly +- inventory refreshes after update + +## Core Refactor Notes + +The current core model is provider-centric and action-heavy. The refactor should reshape it around inventory facts: + +- Replace duplicate-only grouping with Skill Group construction. +- Replace provider capabilities with action availability per provider instance. +- Remove create/edit APIs from the manager and TUI. +- Remove GitHub install source from v1 registration and UI. +- Restrict provider-local uninstall/update. +- Preserve skills.sh metadata hydration for Global Skills. +- Make Project Skill scanning read-only and visually separate from Inventory. + +## Testing Strategy + +Use core tests as the primary seam. The most valuable external behavior tests are: + +- provider scanning returns provider instances with availability state and paths +- disable strategies toggle through provider-supported config behavior, falling back to `.disabled-` only when no provider config mechanism is known +- Skill Identity groups confirmed and inferred instances correctly +- Project Skills are excluded from controllable Inventory and appear in read-only Project Skills output +- skills.sh-managed Global Skills expose install/update/remove actions +- provider-local Codex/Claude skills do not expose remove/update/install/edit/create actions +- invalid skills and broken symlinks produce Health Signals instead of silently disappearing + +TUI tests do not exist yet. V1 can keep TUI verification manual unless a test harness is introduced, but the core should expose enough derived state that the TUI remains thin. diff --git a/package.json b/package.json index 5e80eaf..8fa4e10 100644 --- a/package.json +++ b/package.json @@ -6,5 +6,6 @@ "build": "pnpm -r run build", "test": "pnpm -r run test", "dev": "pnpm --filter @skillpack/tui run dev" - } + }, + "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b" } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c8e9c19..b9e99eb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -19,37 +19,54 @@ export interface SkillpackConfig { sources: Record; } -const DEFAULT_CONFIG: SkillpackConfig = { - editor: process.env.EDITOR || 'vi', - autoCheckUpdates: true, - projectSkillsDirs: ['.codex/skills', '.cursor/skills-cursor', '.claude/skills', '.agents/skills'], - providers: { - codex: { enabled: true, paths: [path.join(os.homedir(), '.codex', 'skills')] }, - cursor: { enabled: true, paths: [path.join(os.homedir(), '.cursor', 'skills-cursor')] }, - claude: { enabled: true, paths: [path.join(os.homedir(), '.claude', 'plugins', 'cache'), path.join(os.homedir(), '.claude', 'skills')] }, - global: { enabled: true, paths: [path.join(os.homedir(), '.agents', 'skills')] }, - }, - sources: { - github: { enabled: true }, - skillssh: { enabled: true }, - }, +type PartialSkillpackConfig = Partial> & { + providers?: Record>; + sources?: Record>; }; +export interface ConfigManagerOptions { + configDir?: string; + homeDir?: string; +} + +export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { + return { + editor: process.env.EDITOR || 'vi', + autoCheckUpdates: true, + projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], + providers: { + codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills'), path.join(homeDir, '.codex', 'plugins', 'cache')] }, + claude: { enabled: true, paths: [path.join(homeDir, '.claude', 'plugins', 'cache'), path.join(homeDir, '.claude', 'skills')] }, + global: { enabled: true, paths: [path.join(homeDir, '.agents', 'skills')] }, + }, + sources: { + skillssh: { enabled: true }, + }, + }; +} + export class ConfigManager { private configPath: string; - private config: SkillpackConfig = { ...DEFAULT_CONFIG }; + private defaultConfig: SkillpackConfig; + private config: SkillpackConfig; - constructor(configDir?: string) { - const dir = configDir ?? path.join(os.homedir(), '.config', 'skillpack'); + constructor(configDirOrOptions?: string | ConfigManagerOptions) { + const options = typeof configDirOrOptions === 'string' + ? { configDir: configDirOrOptions } + : (configDirOrOptions ?? {}); + const homeDir = options.homeDir ?? os.homedir(); + this.defaultConfig = createDefaultConfig(homeDir); + this.config = cloneConfig(this.defaultConfig); + const dir = options.configDir ?? path.join(homeDir, '.config', 'skillpack'); this.configPath = path.join(dir, 'config.json'); } async load(): Promise { try { const raw = await readFile(this.configPath, 'utf-8'); - this.config = { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + this.config = mergeConfig(this.defaultConfig, JSON.parse(raw) as PartialSkillpackConfig); } catch { - this.config = { ...DEFAULT_CONFIG }; + this.config = cloneConfig(this.defaultConfig); await this.autoDetectProviders(); } return this.config; @@ -78,3 +95,48 @@ export class ConfigManager { } } } + +function cloneConfig(config: SkillpackConfig): SkillpackConfig { + return { + editor: config.editor, + autoCheckUpdates: config.autoCheckUpdates, + projectSkillsDirs: [...config.projectSkillsDirs], + providers: Object.fromEntries( + Object.entries(config.providers).map(([id, provider]) => [ + id, + { enabled: provider.enabled, paths: [...provider.paths] }, + ]), + ), + sources: Object.fromEntries( + Object.entries(config.sources).map(([id, source]) => [ + id, + { enabled: source.enabled }, + ]), + ), + }; +} + +function mergeConfig(defaultConfig: SkillpackConfig, userConfig: PartialSkillpackConfig): SkillpackConfig { + const config = cloneConfig(defaultConfig); + + if (userConfig.editor !== undefined) config.editor = userConfig.editor; + if (userConfig.autoCheckUpdates !== undefined) config.autoCheckUpdates = userConfig.autoCheckUpdates; + if (userConfig.projectSkillsDirs !== undefined) config.projectSkillsDirs = [...userConfig.projectSkillsDirs]; + + for (const [id, override] of Object.entries(userConfig.providers ?? {})) { + const base = config.providers[id] ?? { enabled: true, paths: [] }; + config.providers[id] = { + enabled: override.enabled ?? base.enabled, + paths: override.paths ? [...override.paths] : [...base.paths], + }; + } + + for (const [id, override] of Object.entries(userConfig.sources ?? {})) { + const base = config.sources[id] ?? { enabled: true }; + config.sources[id] = { + enabled: override.enabled ?? base.enabled, + }; + } + + return config; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df57bb4..fd0fa29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,9 @@ export * from './models/index.js'; export { parseSkillMd, generateSkillMd } from './parser.js'; -export { ConfigManager, type SkillpackConfig } from './config.js'; -export { LockfileManager, type LockEntry } from './lockfile.js'; +export { ConfigManager, createDefaultConfig, type ConfigManagerOptions, type SkillpackConfig } from './config.js'; export { SkillsLockReader, type SkillsLockEntry } from './skills-lock.js'; export * from './providers/index.js'; export { DuplicateDetector } from './duplicates.js'; export * from './sources/index.js'; export { SkillManager } from './manager.js'; +export { buildSkillInventory, normalizeSkillName } from './models/inventory.js'; diff --git a/packages/core/src/lockfile.ts b/packages/core/src/lockfile.ts deleted file mode 100644 index dbf5ed1..0000000 --- a/packages/core/src/lockfile.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import path from 'node:path'; - -export interface LockEntry { - source: string; - identifier?: string; - repo?: string; - ref?: string; - commit?: string; - path?: string; - version?: string; - installedAt: string; - integrity: string; -} - -interface LockfileData { - lockfileVersion: number; - skills: Record; -} - -export class LockfileManager { - private filePath: string; - private data: LockfileData = { lockfileVersion: 1, skills: {} }; - - constructor(filePath: string) { - this.filePath = filePath; - } - - async load(): Promise { - try { - const raw = await readFile(this.filePath, 'utf-8'); - this.data = JSON.parse(raw); - } catch { - this.data = { lockfileVersion: 1, skills: {} }; - } - } - - async save(): Promise { - await mkdir(path.dirname(this.filePath), { recursive: true }); - await writeFile(this.filePath, JSON.stringify(this.data, null, 2) + '\n', 'utf-8'); - } - - getEntries(): Record { - return { ...this.data.skills }; - } - - getEntry(name: string): LockEntry | undefined { - return this.data.skills[name]; - } - - setEntry(name: string, entry: LockEntry): void { - this.data.skills[name] = entry; - } - - removeEntry(name: string): void { - delete this.data.skills[name]; - } -} diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index b75319e..ea8aedf 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -1,23 +1,25 @@ -import { cp, readdir, readFile, access, rm } from 'node:fs/promises'; +import { readdir, readFile, access } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import type { ISkillProvider } from './providers/provider.js'; import type { IInstallSource } from './sources/source.js'; -import type { Skill, SkillTemplate } from './models/index.js'; +import type { ScanPathDiagnostic, Skill } from './models/index.js'; import type { DuplicateInfo } from './models/duplicate.js'; import type { RemoteSkill, UpdateInfo } from './models/source.js'; +import type { SkillGroup, SkillInventoryInstance } from './models/inventory.js'; import { DuplicateDetector } from './duplicates.js'; -import { LockfileManager } from './lockfile.js'; import { SkillsLockReader } from './skills-lock.js'; import { parseSkillMd } from './parser.js'; +import { buildSkillInventory } from './models/inventory.js'; export class SkillManager { private providers = new Map(); private sources = new Map(); private skills: Skill[] = []; + private projectSkills: Skill[] = []; + private scanPathDiagnostics: ScanPathDiagnostic[] = []; private duplicates: DuplicateInfo[] = []; private duplicateDetector = new DuplicateDetector(); - private globalLock?: LockfileManager; private skillsLock = new SkillsLockReader(); private globalSkillsDir = path.join(os.homedir(), '.agents', 'skills'); @@ -27,38 +29,31 @@ export class SkillManager { getProviders(): ISkillProvider[] { return [...this.providers.values()]; } getSources(): IInstallSource[] { return [...this.sources.values()]; } - async init(configDir?: string): Promise { - const lockPath = path.join(configDir ?? path.join(os.homedir(), '.config', 'skillpack'), 'skillpack.lock'); - this.globalLock = new LockfileManager(lockPath); - await Promise.all([ - this.globalLock.load(), - this.skillsLock.load(), - ]); + async init(_configDir?: string): Promise { + await this.skillsLock.load(); } async scanAll(cwd?: string, projectSkillsDirs?: string[]): Promise { + this.scanPathDiagnostics = await this.collectScanPathDiagnostics(cwd, projectSkillsDirs); const results = await Promise.all([...this.providers.values()].map((p) => p.scan())); let allSkills = results.flat(); if (cwd && projectSkillsDirs?.length) { const projectSkills = await this.scanProjectSkills(cwd, projectSkillsDirs); - const projectNames = new Set(projectSkills.map((s) => s.name)); - allSkills = allSkills.filter((s) => !projectNames.has(s.name)); allSkills = [...allSkills, ...projectSkills]; + this.projectSkills = projectSkills; + } else { + this.projectSkills = []; } await this.skillsLock.load(); const skillsLockEntries = this.skillsLock.getEntries(); - const hydratedBySkillsLock = new Set(); - - // Pass 1: hydrate skills whose real path lives under ~/.agents/skills/ from .skill-lock.json for (const skill of allSkills) { const realPath = skill.resolvedPath ?? skill.path; if (!realPath.startsWith(this.globalSkillsDir + path.sep)) continue; const entry = skillsLockEntries[skill.name]; if (!entry) continue; - hydratedBySkillsLock.add(skill.name); skill.source = { ...skill.source, type: 'skillssh', @@ -68,43 +63,6 @@ export class SkillManager { }; } - // Pass 2: hydrate remaining skills from skillpack.lock (GitHub installs) - if (this.globalLock) { - const lockEntries = this.globalLock.getEntries(); - const scannedNames = new Set(allSkills.map((s) => s.name)); - let pruned = false; - - for (const skill of allSkills) { - if (hydratedBySkillsLock.has(skill.name)) continue; - const lockEntry = lockEntries[skill.name]; - if (!lockEntry || lockEntry.source !== 'github') continue; - skill.source = { - ...skill.source, - type: 'github', - repo: lockEntry.repo ?? lockEntry.identifier, - ref: lockEntry.ref ?? skill.source?.ref, - commit: lockEntry.commit ?? skill.source?.commit, - installedAt: lockEntry.installedAt, - }; - } - - // Prune stale entries from skillpack.lock - for (const name of Object.keys(lockEntries)) { - if (!scannedNames.has(name)) { - this.globalLock.removeEntry(name); - pruned = true; - } - } - // Remove skillssh entries that should no longer be tracked by skillpack.lock - for (const name of Object.keys(lockEntries)) { - if (lockEntries[name].source === 'skillssh') { - this.globalLock.removeEntry(name); - pruned = true; - } - } - if (pruned) await this.globalLock.save(); - } - this.skills = allSkills; this.duplicates = this.duplicateDetector.detect(this.skills); } @@ -113,7 +71,7 @@ export class SkillManager { const skills: Skill[] = []; const seen = new Set(); for (const dir of projectSkillsDirs) { - const projectPath = path.join(cwd, dir); + const projectPath = resolveProjectSkillsPath(cwd, dir); try { await access(projectPath); } catch { continue; } const entries = await readdir(projectPath, { withFileTypes: true }); for (const entry of entries) { @@ -123,77 +81,104 @@ export class SkillManager { const skillMdPath = path.join(skillDir, 'SKILL.md'); try { const content = await readFile(skillMdPath, 'utf-8'); - const parsed = parseSkillMd(content); - const name = parsed.name || entry.name; - if (seen.has(name)) continue; - seen.add(name); - skills.push({ - name, - description: parsed.description, - provider: 'project', - path: skillDir, - version: parsed.raw.version as string | undefined, - enabled: true, - scope: 'project', - metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, - source: { type: 'local' }, - }); - } catch { /* skip */ } + try { + const parsed = parseSkillMd(content); + const name = parsed.name || entry.name; + if (seen.has(name)) continue; + seen.add(name); + skills.push({ + name, + description: parsed.description, + provider: 'project', + path: skillDir, + version: parsed.raw.version as string | undefined, + enabled: true, + scope: 'project', + metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, + source: { type: 'local' }, + }); + } catch (err) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + skills.push({ + name: entry.name, + description: '', + provider: 'project', + path: skillDir, + enabled: true, + scope: 'project', + metadata: {}, + source: { type: 'local' }, + scanIssues: [{ + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }], + }); + } + } catch { /* skip missing SKILL.md */ } } } return skills; } getAllSkills(): Skill[] { return this.skills; } + getScanPathDiagnostics(): ScanPathDiagnostic[] { return this.scanPathDiagnostics; } + getInventory(): SkillGroup[] { + return buildSkillInventory(this.skills, { + getDisableStrategy: (skill) => this.providers.get(skill.provider)?.getDisableStrategy(skill), + }); + } + getProjectSkills(): SkillInventoryInstance[] { + return this.projectSkills.map((skill) => ({ + name: skill.name, + description: skill.description, + provider: skill.provider, + path: skill.path, + resolvedPath: skill.resolvedPath, + version: skill.version, + enabled: skill.enabled, + origin: skill.origin, + source: skill.source, + actions: [], + healthSignals: (skill.scanIssues ?? []).map((issue) => ({ + code: issue.code, + message: issue.message, + })), + })); + } getSkillsByProvider(providerId: string): Skill[] { return this.skills.filter((s) => s.provider === providerId); } getDuplicates(): DuplicateInfo[] { return this.duplicates; } isDuplicate(skillName: string): boolean { return this.duplicates.some((d) => d.skillName === skillName); } - async createSkill(providerId: string, template: SkillTemplate): Promise { - const provider = this.providers.get(providerId); - if (!provider) throw new Error(`Provider not found: ${providerId}`); - if (!provider.capabilities.canCreate) throw new Error(`Provider ${providerId} does not support creating skills`); - return provider.create(template); - } - async toggleSkill(skill: Skill): Promise { const provider = this.providers.get(skill.provider); if (!provider) throw new Error(`Provider not found: ${skill.provider}`); if (!provider.capabilities.canToggle) { throw new Error(`${provider.displayName} does not support toggle`); } - const dirName = path.basename(skill.path).replace(/^\.disabled-/, ''); - if (skill.enabled) { - await provider.disable(dirName); - } else { - await provider.enable(dirName); + const targetEnabled = skill.origin?.type === 'plugin' ? !skill.origin.pluginEnabled : !skill.enabled; + await provider.setEnabled(skill, targetEnabled); + } + + async toggleInventoryInstance(instance: SkillInventoryInstance): Promise { + const provider = this.providers.get(instance.provider); + if (!provider) throw new Error(`Provider not found: ${instance.provider}`); + if (!provider.capabilities.canToggle) { + throw new Error(`${provider.displayName} does not support toggle`); } + const targetEnabled = instance.origin?.type === 'plugin' ? !instance.origin.pluginEnabled : !instance.enabled; + await provider.setEnabled(instance, targetEnabled); } async uninstallSkill(skill: Skill): Promise { - if (skill.source?.type === 'skillssh') { + if (skill.provider === 'global' && skill.source?.type === 'skillssh') { const source = this.sources.get('skillssh') as import('./sources/skillssh.js').SkillsShSource | undefined; - if (source) { - await source.removeViaCli(skill.name); - } else { - await rm(skill.path, { recursive: true, force: true }); - } - return; - } - - if (skill.source?.type === 'github') { - await rm(skill.path, { recursive: true, force: true }); - this.globalLock?.removeEntry(skill.name); - await this.globalLock?.save(); + if (!source) throw new Error('skills.sh source not registered'); + await source.removeViaCli(skill.name); return; } - const provider = this.providers.get(skill.provider); - if (provider?.capabilities.canUninstall) { - await provider.uninstall(path.basename(skill.path)); - } else { - await rm(skill.path, { recursive: true, force: true }); - } + throw new Error('Only skills.sh-managed Global Skills can be removed'); } async searchRemote(sourceId: string, query: string): Promise { @@ -202,69 +187,17 @@ export class SkillManager { return source.search(query); } - async forkToLocal(skill: Skill, targetProviderId: string): Promise { - const provider = this.providers.get(targetProviderId); - if (!provider) throw new Error(`Provider not found: ${targetProviderId}`); - if (!provider.capabilities.canCreate) { - throw new Error(`Provider ${targetProviderId} does not support creating skills`); + async installFromSource(sourceId: string, identifier: string, providerId: string): Promise { + if (sourceId !== 'skillssh' || providerId !== 'global') { + throw new Error('Only skills.sh installs into Global Skills are supported'); } - const destDir = path.join(provider.basePaths[0], skill.name); - await cp(skill.path, destDir, { recursive: true }); - return { - ...skill, - provider: targetProviderId, - path: destDir, - source: { - type: 'local', - createdAt: new Date().toISOString(), - forkedFrom: skill.source?.type !== 'local' - ? { source: skill.source!.type as 'github' | 'skillssh', identifier: skill.source!.repo ?? skill.name } - : undefined, - }, - }; - } - async installFromSource(sourceId: string, identifier: string, providerId: string): Promise { const source = this.sources.get(sourceId); if (!source) throw new Error(`Source not found: ${sourceId}`); - const provider = this.providers.get(providerId); - if (!provider) throw new Error(`Provider not found: ${providerId}`); - const result = await source.fetch(identifier); + if (!this.providers.has(providerId)) throw new Error(`Provider not found: ${providerId}`); - if (sourceId === 'skillssh' && providerId === 'global') { - // npx skills add already placed it in ~/.agents/skills/, just rescan - await this.scanAll(); - } else if (sourceId === 'skillssh') { - // Installed to ~/.agents/skills/ by npx, but user wants it in a different provider. - // Find the newly installed skill and copy it to the target. - const globalProvider = this.providers.get('global'); - if (globalProvider) { - const srcDir = path.join(globalProvider.basePaths[0], result.skillName); - try { - await access(srcDir); - await provider.install(result.skillName, { sourceType: 'skillssh', identifier, tempDir: srcDir }); - } catch { - // Fallback: skill name might differ from identifier, just rescan - } - } - await this.scanAll(); - } else { - await provider.install(result.skillName, { sourceType: sourceId as 'github' | 'skillssh', identifier, tempDir: result.tempDir }); - await this.scanAll(); - } - - if (this.globalLock && sourceId === 'github') { - this.globalLock.setEntry(result.skillName, { - source: 'github', - identifier, - repo: identifier.replace(/@.*$/, ''), - ref: result.ref, - commit: result.commit, - installedAt: new Date().toISOString(), - integrity: '', - }); - await this.globalLock.save(); - } + await source.fetch(identifier); + await this.scanAll(); } async checkUpdates(): Promise> { @@ -287,19 +220,56 @@ export class SkillManager { async updateSkill(skill: Skill): Promise { if (!skill.source || skill.source.type === 'local') { - throw new Error('Cannot update a locally-created skill'); + throw new Error('Cannot update an unmanaged on-disk skill'); } - if (skill.source.type === 'skillssh') { - const source = this.sources.get('skillssh') as import('./sources/skillssh.js').SkillsShSource | undefined; - if (!source) throw new Error('skills.sh source not registered'); - await source.updateViaCli(skill.name); - return; + if (skill.source.type !== 'skillssh') { + throw new Error('Only skills.sh-managed Global Skills can be updated'); } - const lockEntry = this.globalLock?.getEntry(skill.name); - const identifier = lockEntry?.identifier ?? skill.source.repo ?? skill.name; - await this.uninstallSkill(skill); - await this.installFromSource('github', identifier, skill.provider); + const source = this.sources.get('skillssh') as import('./sources/skillssh.js').SkillsShSource | undefined; + if (!source) throw new Error('skills.sh source not registered'); + await source.updateViaCli(skill.name); + } + + private async collectScanPathDiagnostics(cwd?: string, projectSkillsDirs?: string[]): Promise { + const providerDiagnostics = await Promise.all( + [...this.providers.values()].flatMap((provider) => provider.getScanPaths().map(async (scanPath) => ({ + scope: 'provider' as const, + provider: provider.id, + path: scanPath.path, + exists: await pathExists(scanPath.path), + kind: scanPath.kind, + label: scanPath.label, + }))), + ); + + const projectDiagnostics = cwd && projectSkillsDirs?.length + ? await Promise.all(projectSkillsDirs.map(async (dir) => { + const projectPath = resolveProjectSkillsPath(cwd, dir); + return { + scope: 'project' as const, + path: projectPath, + exists: await pathExists(projectPath), + kind: 'project-root' as const, + label: 'Project Skills root', + }; + })) + : []; + + return [...providerDiagnostics, ...projectDiagnostics]; + } +} + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; } } + +function resolveProjectSkillsPath(cwd: string, dir: string): string { + return path.isAbsolute(dir) ? dir : path.join(cwd, dir); +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index ff0d7bf..934f043 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -1,3 +1,5 @@ export * from './skill.js'; export * from './duplicate.js'; export * from './source.js'; +export * from './inventory.js'; +export * from './scan-path.js'; diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts new file mode 100644 index 0000000..f6c77c6 --- /dev/null +++ b/packages/core/src/models/inventory.ts @@ -0,0 +1,189 @@ +import type { Skill, SkillSource } from './skill.js'; + +export type SkillIdentityConfidence = 'confirmed' | 'inferred'; + +export type HealthSignalCode = + | 'grouped-across-providers' + | 'inferred-identity' + | 'invalid-skill-md' + | 'broken-symlink' + | 'plugin-identity-mismatch' + | 'unmanaged-global-skill' + | 'update-available'; + +export interface HealthSignal { + code: HealthSignalCode; + message: string; +} + +export type SkillAction = 'enable' | 'disable' | 'update' | 'remove'; + +export interface DisableStrategy { + type: 'disabled-directory' | 'provider-config'; + description: string; +} + +export interface SkillInventoryInstance { + name: string; + description: string; + provider: string; + path: string; + resolvedPath?: string; + version?: string; + enabled: boolean; + origin?: Skill['origin']; + source?: SkillSource; + disableStrategy?: DisableStrategy; + actions: SkillAction[]; + healthSignals: HealthSignal[]; +} + +export interface SkillGroup { + id: string; + name: string; + identity: { + confidence: SkillIdentityConfidence; + reasons: string[]; + }; + providers: Array<{ + provider: string; + enabled: boolean; + }>; + instances: SkillInventoryInstance[]; + healthSignals: HealthSignal[]; +} + +export function normalizeSkillName(name: string): string { + return name.trim().toLowerCase().replace(/[\s_]+/g, '-'); +} + +function strongIdentityFor(skill: Skill): { key: string; reason: string } | null { + const realPath = skill.resolvedPath ?? skill.path; + if (skill.resolvedPath) { + return { key: `realpath:${realPath}`, reason: 'shared real path' }; + } + if (skill.source?.type === 'skillssh' && (skill.source.skillFolderHash || skill.source.repo)) { + return { + key: `skillssh:${skill.source.skillFolderHash ?? skill.source.repo}`, + reason: 'skills.sh provenance', + }; + } + return null; +} + +function inferredIdentityFor(skill: Skill): { key: string; confidence: SkillIdentityConfidence; reason: string } { + return { + key: `name:${normalizeSkillName(skill.name)}`, + confidence: 'inferred', + reason: 'normalized name', + }; +} + +function actionsFor(skill: Skill): SkillAction[] { + const toggleState = skill.origin?.type === 'plugin' ? skill.origin.pluginEnabled : skill.enabled; + const toggleAction: SkillAction = toggleState ? 'disable' : 'enable'; + if (skill.scope === 'project') return []; + if (skill.provider === 'global' && skill.source?.type === 'skillssh') { + return ['update', 'remove']; + } + if (skill.provider === 'global') return []; + return [toggleAction]; +} + +function healthSignalsFor(skill: Skill): HealthSignal[] { + const signals: HealthSignal[] = (skill.scanIssues ?? []).map((issue) => ({ + code: issue.code, + message: issue.message, + })); + if (skill.provider === 'global' && skill.source?.type !== 'skillssh') { + signals.push({ code: 'unmanaged-global-skill', message: 'Global Skill is not managed by skills.sh metadata' }); + } + return signals; +} + +export interface BuildSkillInventoryOptions { + getDisableStrategy?: (skill: Skill) => DisableStrategy | undefined; +} + +function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInventoryInstance { + return { + name: skill.name, + description: skill.description, + provider: skill.provider, + path: skill.path, + resolvedPath: skill.resolvedPath, + version: skill.version, + enabled: skill.enabled, + origin: skill.origin, + source: skill.source, + disableStrategy: options.getDisableStrategy?.(skill), + actions: actionsFor(skill), + healthSignals: healthSignalsFor(skill), + }; +} + +export function buildSkillInventory(skills: Skill[], options: BuildSkillInventoryOptions = {}): SkillGroup[] { + const inventorySkills = skills.filter((s) => s.scope !== 'project'); + const strongGroups = new Map(); + for (const skill of inventorySkills) { + const identity = strongIdentityFor(skill); + if (!identity) continue; + const existing = strongGroups.get(identity.key); + if (existing) { + existing.skills.push(skill); + } else { + strongGroups.set(identity.key, { reason: identity.reason, skills: [skill] }); + } + } + + const assigned = new Set(); + const groups = new Map(); + for (const [key, group] of strongGroups) { + if (group.skills.length < 2) continue; + for (const skill of group.skills) assigned.add(skill); + groups.set(key, { identity: { confidence: 'confirmed', reason: group.reason }, skills: group.skills }); + } + + for (const skill of inventorySkills) { + if (assigned.has(skill)) continue; + const identity = inferredIdentityFor(skill); + const existing = groups.get(identity.key); + if (existing) { + existing.skills.push(skill); + } else { + groups.set(identity.key, { identity, skills: [skill] }); + } + } + + return [...groups.entries()].map(([id, group]) => { + const instances = group.skills.map((skill) => toInstance(skill, options)); + const healthSignals = instances.flatMap((instance) => instance.healthSignals); + if (instances.length > 1) { + healthSignals.push({ + code: 'grouped-across-providers', + message: 'Skill appears in multiple providers', + }); + } + if (group.identity.confidence === 'inferred') { + healthSignals.push({ + code: 'inferred-identity', + message: 'Skill identity is inferred from normalized name', + }); + } + + return { + id, + name: group.skills[0].name, + identity: { + confidence: group.identity.confidence, + reasons: [group.identity.reason], + }, + providers: instances.map((instance) => ({ + provider: instance.provider, + enabled: instance.enabled, + })), + instances, + healthSignals, + }; + }); +} diff --git a/packages/core/src/models/scan-path.ts b/packages/core/src/models/scan-path.ts new file mode 100644 index 0000000..0c27c0d --- /dev/null +++ b/packages/core/src/models/scan-path.ts @@ -0,0 +1,17 @@ +export type ScanPathScope = 'provider' | 'project'; +export type ScanPathKind = 'skill-root' | 'plugin-cache' | 'project-root'; + +export interface ProviderScanPath { + path: string; + kind: Exclude; + label: string; +} + +export interface ScanPathDiagnostic { + scope: ScanPathScope; + path: string; + exists: boolean; + kind: ScanPathKind; + label: string; + provider?: string; +} diff --git a/packages/core/src/models/skill.ts b/packages/core/src/models/skill.ts index a11b9d8..47c8c69 100644 --- a/packages/core/src/models/skill.ts +++ b/packages/core/src/models/skill.ts @@ -4,8 +4,22 @@ export interface SkillMetadata { tags?: string[]; } +export interface PluginSkillOrigin { + type: 'plugin'; + pluginId: string; + pluginName: string; + marketplace: string; + version?: string; + displayName?: string; + pluginEnabled: boolean; + skillConfigEnabled?: boolean; + identityStatus?: 'confirmed' | 'mismatched'; +} + +export type SkillOrigin = PluginSkillOrigin; + export interface SkillSource { - type: 'github' | 'skillssh' | 'local'; + type: 'skillssh' | 'local'; repo?: string; ref?: string; commit?: string; @@ -13,11 +27,18 @@ export interface SkillSource { createdAt?: string; installedAt?: string; forkedFrom?: { - source: 'github' | 'skillssh'; + source: 'skillssh'; identifier: string; }; } +export type SkillScanIssueCode = 'invalid-skill-md' | 'broken-symlink' | 'plugin-identity-mismatch'; + +export interface SkillScanIssue { + code: SkillScanIssueCode; + message: string; +} + export interface Skill { name: string; description: string; @@ -28,7 +49,9 @@ export interface Skill { enabled: boolean; scope: 'global' | 'project'; metadata: SkillMetadata; + origin?: SkillOrigin; source?: SkillSource; + scanIssues?: SkillScanIssue[]; } export interface SkillTemplate { diff --git a/packages/core/src/models/source.ts b/packages/core/src/models/source.ts index e866044..46b1043 100644 --- a/packages/core/src/models/source.ts +++ b/packages/core/src/models/source.ts @@ -1,5 +1,5 @@ export interface InstallRequest { - sourceType: 'github' | 'skillssh'; + sourceType: 'skillssh'; identifier: string; tempDir: string; } @@ -7,7 +7,7 @@ export interface InstallRequest { export interface RemoteSkill { name: string; description: string; - source: 'github' | 'skillssh'; + source: 'skillssh'; identifier: string; stars?: number; installs?: number; diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index f44403c..64bc040 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -1,5 +1,6 @@ import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill } from '../models/index.js'; +import type { DisableStrategy, ProviderScanPath, Skill } from '../models/index.js'; +import { readJsonSettings, writeJsonSettings } from './provider-settings.js'; import { readdir, readFile, access, rename, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -21,21 +22,80 @@ export class ClaudeProvider extends BaseProvider { canInstall: false, canUninstall: false, canUpdate: false, canToggle: true, canCreate: false, }; readonly flatPaths: string[]; + readonly settingsPath: string; - constructor(basePaths?: string[], flatPaths?: string[]) { + constructor(basePaths?: string[], flatPaths?: string[], settingsPath?: string) { super(); this.basePaths = basePaths ?? [path.join(os.homedir(), '.claude', 'plugins', 'cache')]; this.flatPaths = flatPaths ?? [path.join(os.homedir(), '.claude', 'skills')]; + this.settingsPath = settingsPath ?? path.join(os.homedir(), '.claude', 'settings.json'); } override async scan(): Promise { - const flatSkills = await this.scanFlat(); - const deepSkills = await this.scanDeep(); + const settings = await readClaudeSettings(this.settingsPath); + const flatSkills = await this.scanFlat(settings); + const deepSkills = await this.scanDeep(settings); return [...flatSkills, ...deepSkills]; } - private async scanFlat(): Promise { + override getScanPaths(): ProviderScanPath[] { + return [ + ...this.flatPaths.map((flatPath) => ({ + path: flatPath, + kind: 'skill-root' as const, + label: 'Claude skill root', + })), + ...this.basePaths.map((basePath) => ({ + path: basePath, + kind: 'plugin-cache' as const, + label: 'Claude plugin cache', + })), + ]; + } + + override getDisableStrategy(skill: Pick): DisableStrategy | undefined { + if (!this.capabilities.canToggle) return undefined; + if (path.basename(skill.path).startsWith('.disabled-')) return super.getDisableStrategy(skill); + if (this.getFlatSkillName(skill.path)) { + return { + type: 'provider-config', + description: 'Writes skillOverrides in Claude settings.json', + }; + } + if (this.getPluginId(skill.path)) { + return { + type: 'provider-config', + description: 'Writes enabledPlugins in Claude settings.json for the owning plugin', + }; + } + return super.getDisableStrategy(skill); + } + + override async setEnabled(skill: Pick, enabled: boolean): Promise { + if (skill.enabled === enabled) return; + if (path.basename(skill.path).startsWith('.disabled-')) { + await super.setEnabled(skill, enabled); + return; + } + + const flatSkillName = this.getFlatSkillName(skill.path); + if (flatSkillName) { + await setClaudeSkillOverride(this.settingsPath, skill.name, enabled ? 'on' : 'off'); + return; + } + + const pluginId = this.getPluginId(skill.path); + if (pluginId) { + await setClaudePluginEnabled(this.settingsPath, pluginId, enabled); + return; + } + + await super.setEnabled(skill, enabled); + } + + private async scanFlat(settings: ClaudeSettings): Promise { const skills: Skill[] = []; + const skillOverrides = getStringRecord(settings.skillOverrides); for (const basePath of this.flatPaths) { try { await access(basePath); } catch { continue; } const entries = await readdir(basePath, { withFileTypes: true }); @@ -55,14 +115,16 @@ export class ClaudeProvider extends BaseProvider { const parsed = parseSkillMd(content); const resolved = await realpath(skillDir); const dirStat = await stat(resolved); + const skillName = parsed.name || skillDirName; + const override = skillOverrides[skillName]; skills.push({ - name: parsed.name || skillDirName, + name: skillName, description: parsed.description, provider: this.id, path: skillDir, resolvedPath: resolved !== skillDir ? resolved : undefined, version: parsed.raw.version as string | undefined, - enabled: !isDisabled, + enabled: !isDisabled && override !== 'off', scope: 'global', metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, @@ -73,8 +135,9 @@ export class ClaudeProvider extends BaseProvider { return skills; } - private async scanDeep(): Promise { + private async scanDeep(settings: ClaudeSettings): Promise { const skills: Skill[] = []; + const enabledPlugins = getBooleanRecord(settings.enabledPlugins); for (const basePath of this.basePaths) { try { await access(basePath); } catch { continue; } const publishers = await readdir(basePath, { withFileTypes: true }); @@ -85,6 +148,7 @@ export class ClaudeProvider extends BaseProvider { for (const plugin of plugins) { if (!(await isDirEntry(plugin, pubPath))) continue; const pluginPath = path.join(pubPath, plugin.name); + const pluginEnabled = enabledPlugins[`${plugin.name}@${pub.name}`] !== false; const versions = await readdir(pluginPath, { withFileTypes: true }); for (const ver of versions) { if (!(await isDirEntry(ver, pluginPath))) continue; @@ -108,7 +172,7 @@ export class ClaudeProvider extends BaseProvider { provider: this.id, path: skillPath, resolvedPath: resolved !== skillPath ? resolved : undefined, version: ver.name !== 'unknown' ? ver.name : undefined, - enabled: !isDisabled, scope: 'global', + enabled: !isDisabled && pluginEnabled, scope: 'global', metadata: { license: parsed.metadata.license, author: parsed.metadata.author ?? pub.name, tags: parsed.metadata.tags }, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, }); @@ -121,7 +185,35 @@ export class ClaudeProvider extends BaseProvider { return skills; } + private getFlatSkillName(skillPath: string): string | undefined { + for (const flatPath of this.flatPaths) { + const rel = path.relative(path.resolve(flatPath), path.resolve(skillPath)); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue; + const segments = rel.split(path.sep); + if (segments.length === 1) return segments[0].replace(/^\.disabled-/, ''); + } + return undefined; + } + + private getPluginId(skillPath: string): string | undefined { + for (const basePath of this.basePaths) { + const rel = path.relative(path.resolve(basePath), path.resolve(skillPath)); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue; + const segments = rel.split(path.sep); + if (segments.length >= 5 && segments[3] === 'skills') { + return `${segments[1]}@${segments[0]}`; + } + } + return undefined; + } + override async disable(name: string): Promise { + const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + if (skill) { + await this.setEnabled(skill, false); + return; + } + // Try flat paths first (e.g. ~/.claude/skills/) for (const fp of this.flatPaths) { const src = path.join(fp, name); @@ -175,6 +267,12 @@ export class ClaudeProvider extends BaseProvider { } override async enable(name: string): Promise { + const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + if (skill) { + await this.setEnabled(skill, true); + return; + } + // Try flat paths first for (const fp of this.flatPaths) { const src = path.join(fp, `.disabled-${name}`); @@ -215,3 +313,46 @@ export class ClaudeProvider extends BaseProvider { throw new Error(`Disabled skill "${name}" not found in ${this.displayName}`); } } + +type ClaudeSkillOverride = 'on' | 'name-only' | 'user-invocable-only' | 'off'; + +interface ClaudeSettings extends Record { + skillOverrides?: unknown; + enabledPlugins?: unknown; +} + +async function readClaudeSettings(settingsPath: string): Promise { + return await readJsonSettings(settingsPath); +} + +function getStringRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ); +} + +function getBooleanRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'), + ); +} + +async function setClaudeSkillOverride(settingsPath: string, skillName: string, state: ClaudeSkillOverride): Promise { + const settings = await readJsonSettings(settingsPath); + settings.skillOverrides = { + ...getStringRecord(settings.skillOverrides), + [skillName]: state, + }; + await writeJsonSettings(settingsPath, settings); +} + +async function setClaudePluginEnabled(settingsPath: string, pluginId: string, enabled: boolean): Promise { + const settings = await readJsonSettings(settingsPath); + settings.enabledPlugins = { + ...getBooleanRecord(settings.enabledPlugins), + [pluginId]: enabled, + }; + await writeJsonSettings(settingsPath, settings); +} diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index 0a56809..ae41b1b 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -1,35 +1,170 @@ import { BaseProvider, type ProviderCapabilities } from './provider.js'; import type { Skill, SkillTemplate } from '../models/index.js'; import type { InstallRequest } from '../models/source.js'; -import { generateSkillMd } from '../parser.js'; -import { mkdir, writeFile, rm, cp } from 'node:fs/promises'; +import { generateSkillMd, parseSkillMd } from '../parser.js'; +import { + readCodexPluginConfig, + readCodexSkillConfigEnabled, + writeCodexPluginEnabled, + writeCodexSkillConfigEnabled, +} from './provider-settings.js'; +import { mkdir, writeFile, rm, cp, readdir, access, readFile, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; +interface CodexPluginRoot { + marketplace: string; + pluginName: string; + pluginId: string; + version: string; + path: string; + mtimeMs: number; + manifestName?: string; + displayName?: string; +} + +interface CodexPluginManifest { + name?: unknown; + interface?: { + displayName?: unknown; + }; +} + +async function isDirectory(filePath: string): Promise { + try { + return (await stat(filePath)).isDirectory(); + } catch { + return false; + } +} + +function looksLikePluginCache(basePath: string): boolean { + const segments = path.resolve(basePath).split(path.sep); + return segments.at(-1) === 'cache' && segments.at(-2) === 'plugins'; +} + +function parseSemver(version: string): [number, number, number] | undefined { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareSemver(left: [number, number, number], right: [number, number, number]): number { + for (let i = 0; i < 3; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; +} + +function isBetterPluginRoot(candidate: CodexPluginRoot, current: CodexPluginRoot): boolean { + const candidateSemver = parseSemver(candidate.version); + const currentSemver = parseSemver(current.version); + if (candidateSemver && currentSemver) return compareSemver(candidateSemver, currentSemver) > 0; + if (candidateSemver && !currentSemver) return true; + if (!candidateSemver && currentSemver) return false; + return candidate.mtimeMs > current.mtimeMs; +} + export class CodexProvider extends BaseProvider { readonly id = 'codex'; readonly displayName = 'Codex'; readonly basePaths: string[]; + readonly skillPaths: string[]; + readonly pluginCachePaths: string[]; + readonly configPath: string; readonly capabilities: ProviderCapabilities = { canInstall: true, canUninstall: true, canUpdate: true, canToggle: true, canCreate: true, }; - constructor(basePaths?: string[]) { + constructor(basePaths?: string[], configPath?: string) { super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.codex', 'skills')]; + this.basePaths = basePaths ?? [ + path.join(os.homedir(), '.codex', 'skills'), + path.join(os.homedir(), '.codex', 'plugins', 'cache'), + ]; + this.pluginCachePaths = this.basePaths.filter(looksLikePluginCache); + this.skillPaths = this.basePaths.filter((basePath) => !looksLikePluginCache(basePath)); + this.configPath = configPath ?? path.join(os.homedir(), '.codex', 'config.toml'); + } + + override async scan(): Promise { + const flatSkills = await this.scanBasePaths(this.skillPaths); + const hydratedFlatSkills = await Promise.all(flatSkills.map(async (skill) => { + const configEnabled = await readCodexSkillConfigEnabled(this.configPath, path.join(skill.path, 'SKILL.md')); + return { + ...skill, + enabled: skill.enabled && configEnabled !== false, + }; + })); + const pluginSkills = await this.scanPluginCache(); + return [...hydratedFlatSkills, ...pluginSkills]; + } + + override getScanPaths() { + return [ + ...this.skillPaths.map((basePath) => ({ + path: basePath, + kind: 'skill-root' as const, + label: 'Codex skill root', + })), + ...this.pluginCachePaths.map((basePath) => ({ + path: basePath, + kind: 'plugin-cache' as const, + label: 'Codex plugin cache', + })), + ]; + } + + override getDisableStrategy(skill: Pick): { type: 'provider-config'; description: string } | undefined { + if (skill.origin?.type === 'plugin') { + if (skill.origin.identityStatus === 'mismatched') return undefined; + return { + type: 'provider-config', + description: `Writes [plugins."${skill.origin.pluginId}"] in Codex config.toml for the owning plugin`, + }; + } + return { + type: 'provider-config', + description: 'Writes [[skills.config]] in Codex config.toml', + }; + } + + override async setEnabled(skill: Pick, enabled: boolean): Promise { + if (skill.origin?.type === 'plugin') { + if (skill.origin.identityStatus === 'mismatched') { + throw new Error(`Cannot toggle Codex plugin "${skill.origin.pluginId}" because its manifest identity does not match its cache path`); + } + if (skill.origin.pluginEnabled === enabled) return; + await writeCodexPluginEnabled(this.configPath, skill.origin.pluginId, enabled); + return; + } + if (skill.enabled === enabled) return; + await writeCodexSkillConfigEnabled(this.configPath, path.join(skill.path, 'SKILL.md'), enabled); + } + + override async disable(name: string): Promise { + const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + if (!skill) throw new Error(`Skill "${name}" not found in ${this.displayName}`); + await this.setEnabled(skill, false); + } + + override async enable(name: string): Promise { + const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + if (!skill) throw new Error(`Skill "${name}" not found in ${this.displayName}`); + await this.setEnabled(skill, true); } override async install(name: string, request: InstallRequest): Promise { - const dest = path.join(this.basePaths[0], name); + const dest = path.join(this.skillPaths[0] ?? this.basePaths[0], name); await cp(request.tempDir, dest, { recursive: true }); } override async uninstall(name: string): Promise { - await rm(path.join(this.basePaths[0], name), { recursive: true, force: true }); + await rm(path.join(this.skillPaths[0] ?? this.basePaths[0], name), { recursive: true, force: true }); } override async create(template: SkillTemplate): Promise { - const skillDir = path.join(this.basePaths[0], template.name); + const skillDir = path.join(this.skillPaths[0] ?? this.basePaths[0], template.name); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), generateSkillMd(template), 'utf-8'); return { @@ -39,4 +174,133 @@ export class CodexProvider extends BaseProvider { source: { type: 'local', createdAt: new Date().toISOString() }, }; } + + private async scanPluginCache(): Promise { + const pluginConfig = await readCodexPluginConfig(this.configPath); + const pluginRoots = await this.selectPluginRoots(pluginConfig); + const skills = await Promise.all(pluginRoots.map((pluginRoot) => this.scanPluginRoot(pluginRoot, pluginConfig))); + return skills.flat(); + } + + private async selectPluginRoots(pluginConfig: Record): Promise { + const candidates = await this.findPluginRoots(); + const configuredById = new Map(); + const configuredIds = new Set(Object.keys(pluginConfig)); + + for (const candidate of candidates) { + if (!configuredIds.has(candidate.pluginId)) continue; + const current = configuredById.get(candidate.pluginId); + if (!current || isBetterPluginRoot(candidate, current)) configuredById.set(candidate.pluginId, candidate); + } + + const selected = [...configuredById.values()]; + const selectedPluginNames = new Set(selected.map((root) => root.pluginName)); + const unconfiguredByName = new Map(); + + for (const candidate of candidates) { + if (configuredIds.has(candidate.pluginId)) continue; + if (selectedPluginNames.has(candidate.pluginName)) continue; + const current = unconfiguredByName.get(candidate.pluginName); + if (!current || isBetterPluginRoot(candidate, current)) unconfiguredByName.set(candidate.pluginName, candidate); + } + + return [...selected, ...unconfiguredByName.values()]; + } + + private async findPluginRoots(): Promise { + const roots: CodexPluginRoot[] = []; + for (const cachePath of this.pluginCachePaths) { + try { await access(cachePath); } catch { continue; } + const marketplaces = await readdir(cachePath, { withFileTypes: true }); + for (const marketplace of marketplaces) { + if (!marketplace.isDirectory()) continue; + const marketplacePath = path.join(cachePath, marketplace.name); + const plugins = await readdir(marketplacePath, { withFileTypes: true }); + for (const plugin of plugins) { + if (!plugin.isDirectory()) continue; + const pluginPath = path.join(marketplacePath, plugin.name); + const versions = await readdir(pluginPath, { withFileTypes: true }); + for (const version of versions) { + if (!version.isDirectory()) continue; + const rootPath = path.join(pluginPath, version.name); + const manifestPath = path.join(rootPath, '.codex-plugin', 'plugin.json'); + const skillsPath = path.join(rootPath, 'skills'); + if (!(await isDirectory(skillsPath))) continue; + const manifest = await this.readPluginManifest(manifestPath); + const rootStat = await stat(rootPath); + roots.push({ + marketplace: marketplace.name, + pluginName: plugin.name, + pluginId: `${plugin.name}@${marketplace.name}`, + version: version.name, + path: rootPath, + mtimeMs: rootStat.mtimeMs, + manifestName: typeof manifest?.name === 'string' ? manifest.name : undefined, + displayName: typeof manifest?.interface?.displayName === 'string' ? manifest.interface.displayName : undefined, + }); + } + } + } + } + return roots; + } + + private async readPluginManifest(manifestPath: string): Promise { + try { + return JSON.parse(await readFile(manifestPath, 'utf-8')) as CodexPluginManifest; + } catch { + return undefined; + } + } + + private async scanPluginRoot(pluginRoot: CodexPluginRoot, pluginConfig: Record): Promise { + const skills: Skill[] = []; + const skillsPath = path.join(pluginRoot.path, 'skills'); + const pluginEnabled = pluginConfig[pluginRoot.pluginId] !== false; + const identityStatus = pluginRoot.manifestName && pluginRoot.manifestName !== pluginRoot.pluginName ? 'mismatched' : 'confirmed'; + const entries = await readdir(skillsPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + const skillPath = path.join(skillsPath, entry.name); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + try { + const content = await readFile(skillMdPath, 'utf-8'); + const parsed = parseSkillMd(content); + const resolved = await realpath(skillPath); + const dirStat = await stat(resolved); + const skillConfigEnabled = await readCodexSkillConfigEnabled(this.configPath, skillMdPath); + skills.push({ + name: parsed.name || entry.name, + description: parsed.description, + provider: this.id, + path: skillPath, + resolvedPath: resolved !== skillPath ? resolved : undefined, + version: pluginRoot.version, + enabled: pluginEnabled && skillConfigEnabled !== false, + scope: 'global', + metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, + origin: { + type: 'plugin', + pluginId: pluginRoot.pluginId, + pluginName: pluginRoot.pluginName, + marketplace: pluginRoot.marketplace, + version: pluginRoot.version, + displayName: pluginRoot.displayName, + pluginEnabled, + skillConfigEnabled, + identityStatus, + }, + source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, + scanIssues: identityStatus === 'mismatched' ? [{ + code: 'plugin-identity-mismatch', + message: `Codex plugin manifest name "${pluginRoot.manifestName}" does not match cache plugin "${pluginRoot.pluginName}"`, + }] : undefined, + }); + } catch { /* skip invalid plugin skills for now */ } + } + + return skills; + } } diff --git a/packages/core/src/providers/cursor.ts b/packages/core/src/providers/cursor.ts deleted file mode 100644 index cf099c3..0000000 --- a/packages/core/src/providers/cursor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import path from 'node:path'; -import os from 'node:os'; - -export class CursorProvider extends BaseProvider { - readonly id = 'cursor'; - readonly displayName = 'Cursor'; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities = { - canInstall: false, canUninstall: false, canUpdate: false, canToggle: true, canCreate: false, - }; - constructor(basePaths?: string[]) { - super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.cursor', 'skills-cursor')]; - } -} diff --git a/packages/core/src/providers/global.ts b/packages/core/src/providers/global.ts index eea3c32..d2a4c4a 100644 --- a/packages/core/src/providers/global.ts +++ b/packages/core/src/providers/global.ts @@ -11,7 +11,7 @@ export class GlobalProvider extends BaseProvider { readonly displayName = 'Global'; readonly basePaths: string[]; readonly capabilities: ProviderCapabilities = { - canInstall: true, canUninstall: true, canUpdate: true, canToggle: true, canCreate: true, + canInstall: true, canUninstall: true, canUpdate: true, canToggle: false, canCreate: true, }; constructor(basePaths?: string[]) { super(); diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index b1f18e0..36c25d2 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -1,5 +1,4 @@ export { type ISkillProvider, type ProviderCapabilities, BaseProvider } from './provider.js'; export { CodexProvider } from './codex.js'; -export { CursorProvider } from './cursor.js'; export { ClaudeProvider } from './claude.js'; export { GlobalProvider } from './global.js'; diff --git a/packages/core/src/providers/provider-settings.ts b/packages/core/src/providers/provider-settings.ts new file mode 100644 index 0000000..0131683 --- /dev/null +++ b/packages/core/src/providers/provider-settings.ts @@ -0,0 +1,215 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +function isMissingFile(err: unknown): boolean { + return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export async function readJsonSettings(filePath: string): Promise> { + try { + const content = await readFile(filePath, 'utf-8'); + if (!content.trim()) return {}; + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch (err) { + if (isMissingFile(err)) return {}; + throw err; + } +} + +export async function writeJsonSettings(filePath: string, settings: Record): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(settings, null, 2)}\n`, 'utf-8'); +} + +interface CodexSkillConfigEntry { + path?: string; + enabled?: boolean; + startLine: number; + endLine: number; + pathLine?: number; + enabledLine?: number; +} + +interface CodexPluginConfigEntry { + pluginId: string; + enabled?: boolean; + startLine: number; + endLine: number; + enabledLine?: number; +} + +function parseTomlString(raw: string): string | undefined { + const value = raw.trim(); + if (value.startsWith('"')) { + const match = value.match(/^"(?:\\.|[^"\\])*"/); + if (!match) return undefined; + try { + return JSON.parse(match[0]) as string; + } catch { + return undefined; + } + } + if (value.startsWith("'")) { + const match = value.match(/^'([^']*)'/); + return match?.[1]; + } + return undefined; +} + +function parseCodexSkillConfigEntries(text: string): CodexSkillConfigEntry[] { + const lines = text.split('\n'); + const entries: CodexSkillConfigEntry[] = []; + let current: CodexSkillConfigEntry | undefined; + + for (const [index, line] of lines.entries()) { + const trimmed = line.trim(); + if (trimmed === '[[skills.config]]') { + if (current) { + current.endLine = index; + entries.push(current); + } + current = { startLine: index, endLine: lines.length }; + continue; + } + if (current && /^\[{1,2}[^\]]+\]{1,2}$/.test(trimmed)) { + current.endLine = index; + entries.push(current); + current = undefined; + continue; + } + if (!current) continue; + + const pathMatch = line.match(/^\s*path\s*=\s*(.+)$/); + if (pathMatch) { + current.path = parseTomlString(pathMatch[1]); + current.pathLine = index; + continue; + } + + const enabledMatch = line.match(/^\s*enabled\s*=\s*(true|false)\b/); + if (enabledMatch) { + current.enabled = enabledMatch[1] === 'true'; + current.enabledLine = index; + } + } + + if (current) entries.push(current); + return entries; +} + +function parseCodexPluginHeader(trimmed: string): string | undefined { + const match = trimmed.match(/^\[plugins\.((?:"(?:\\.|[^"\\])*")|(?:'[^']*'))\]$/); + if (!match) return undefined; + return parseTomlString(match[1]); +} + +function parseCodexPluginConfigEntries(text: string): CodexPluginConfigEntry[] { + const lines = text.split('\n'); + const entries: CodexPluginConfigEntry[] = []; + let current: CodexPluginConfigEntry | undefined; + + for (const [index, line] of lines.entries()) { + const trimmed = line.trim(); + const pluginId = parseCodexPluginHeader(trimmed); + if (pluginId) { + if (current) { + current.endLine = index; + entries.push(current); + } + current = { pluginId, startLine: index, endLine: lines.length }; + continue; + } + if (current && /^\[{1,2}[^\]]+\]{1,2}$/.test(trimmed)) { + current.endLine = index; + entries.push(current); + current = undefined; + continue; + } + if (!current) continue; + + const enabledMatch = line.match(/^\s*enabled\s*=\s*(true|false)\b/); + if (enabledMatch) { + current.enabled = enabledMatch[1] === 'true'; + current.enabledLine = index; + } + } + + if (current) entries.push(current); + return entries; +} + +async function readTextIfExists(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch (err) { + if (isMissingFile(err)) return ''; + throw err; + } +} + +export async function readCodexSkillConfigEnabled(configPath: string, skillMdPath: string): Promise { + const text = await readTextIfExists(configPath); + const target = path.resolve(skillMdPath); + const entry = parseCodexSkillConfigEntries(text).find((item) => item.path && path.resolve(item.path) === target); + return entry?.enabled; +} + +export async function readCodexPluginConfig(configPath: string): Promise> { + const text = await readTextIfExists(configPath); + return Object.fromEntries(parseCodexPluginConfigEntries(text).map((entry) => [entry.pluginId, entry.enabled])); +} + +export async function readCodexPluginEnabled(configPath: string, pluginId: string): Promise { + const config = await readCodexPluginConfig(configPath); + return config[pluginId]; +} + +export async function writeCodexSkillConfigEnabled(configPath: string, skillMdPath: string, enabled: boolean): Promise { + const text = await readTextIfExists(configPath); + const lines = text ? text.split('\n') : []; + const target = path.resolve(skillMdPath); + const entry = parseCodexSkillConfigEntries(text).find((item) => item.path && path.resolve(item.path) === target); + const enabledLine = `enabled = ${enabled ? 'true' : 'false'}`; + + if (entry) { + if (entry.enabledLine !== undefined) { + const indent = lines[entry.enabledLine]?.match(/^\s*/)?.[0] ?? ''; + lines[entry.enabledLine] = `${indent}${enabledLine}`; + } else { + lines.splice((entry.pathLine ?? entry.startLine) + 1, 0, enabledLine); + } + } else { + if (lines.length > 0 && lines[lines.length - 1] !== '') lines.push(''); + lines.push('[[skills.config]]', `path = ${JSON.stringify(target)}`, enabledLine); + } + + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${lines.join('\n').replace(/\n+$/, '')}\n`, 'utf-8'); +} + +export async function writeCodexPluginEnabled(configPath: string, pluginId: string, enabled: boolean): Promise { + const text = await readTextIfExists(configPath); + const lines = text ? text.split('\n') : []; + const entry = parseCodexPluginConfigEntries(text).find((item) => item.pluginId === pluginId); + const enabledLine = `enabled = ${enabled ? 'true' : 'false'}`; + + if (entry) { + if (entry.enabledLine !== undefined) { + const indent = lines[entry.enabledLine]?.match(/^\s*/)?.[0] ?? ''; + lines[entry.enabledLine] = `${indent}${enabledLine}`; + } else { + lines.splice(entry.startLine + 1, 0, enabledLine); + } + } else { + if (lines.length > 0 && lines[lines.length - 1] !== '') lines.push(''); + lines.push(`[plugins.${JSON.stringify(pluginId)}]`, enabledLine); + } + + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${lines.join('\n').replace(/\n+$/, '')}\n`, 'utf-8'); +} diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index f1d47a0..073361c 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -1,4 +1,4 @@ -import type { Skill, SkillTemplate } from '../models/index.js'; +import type { DisableStrategy, ProviderScanPath, Skill, SkillTemplate } from '../models/index.js'; import type { InstallRequest } from '../models/source.js'; import { readdir, access, readFile, rename, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; @@ -12,6 +12,8 @@ export interface ProviderCapabilities { canCreate: boolean; } +export type SkillToggleTarget = Pick; + export interface ISkillProvider { readonly id: string; readonly displayName: string; @@ -24,6 +26,9 @@ export interface ISkillProvider { update(name: string): Promise; enable(name: string): Promise; disable(name: string): Promise; + setEnabled(skill: SkillToggleTarget, enabled: boolean): Promise; + getDisableStrategy(skill: SkillToggleTarget): DisableStrategy | undefined; + getScanPaths(): ProviderScanPath[]; create(template: SkillTemplate): Promise; } @@ -33,24 +38,48 @@ export abstract class BaseProvider implements ISkillProvider { abstract readonly basePaths: string[]; abstract readonly capabilities: ProviderCapabilities; - async scan(): Promise { + protected async scanBasePaths(basePaths: string[]): Promise { const skills: Skill[] = []; - for (const basePath of this.basePaths) { + for (const basePath of basePaths) { try { await access(basePath); } catch { continue; } const entries = await readdir(basePath, { withFileTypes: true }); for (const entry of entries) { + const isDisabled = entry.name.startsWith('.disabled-'); + const skillDirName = isDisabled ? entry.name.slice('.disabled-'.length) : entry.name; + if (entry.name.startsWith('.') && !isDisabled) continue; let isDir = entry.isDirectory(); if (!isDir && entry.isSymbolicLink()) { - try { isDir = (await stat(path.join(basePath, entry.name))).isDirectory(); } catch { /* broken symlink */ } + try { + isDir = (await stat(path.join(basePath, entry.name))).isDirectory(); + } catch (err) { + const skillDir = path.join(basePath, entry.name); + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillDir, + enabled: !isDisabled, + scope: 'global', + metadata: {}, + source: { type: 'local' }, + scanIssues: [{ + code: 'broken-symlink', + message: err instanceof Error ? err.message : 'Broken skill symlink', + }], + }); + continue; + } } if (!isDir) continue; - const isDisabled = entry.name.startsWith('.disabled-'); - const skillDirName = isDisabled ? entry.name.slice('.disabled-'.length) : entry.name; - if (entry.name.startsWith('.') && !isDisabled) continue; const skillDir = path.join(basePath, entry.name); const skillMdPath = path.join(skillDir, 'SKILL.md'); + let content: string; + try { + content = await readFile(skillMdPath, 'utf-8'); + } catch { + continue; + } try { - const content = await readFile(skillMdPath, 'utf-8'); const parsed = parseSkillMd(content); const resolved = await realpath(skillDir); const dirStat = await stat(resolved); @@ -66,15 +95,71 @@ export abstract class BaseProvider implements ISkillProvider { metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, }); - } catch { /* no SKILL.md — skip */ } + } catch (err) { + const resolved = await realpath(skillDir).catch(() => skillDir); + const dirStat = await stat(resolved).catch(() => undefined); + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillDir, + resolvedPath: resolved !== skillDir ? resolved : undefined, + enabled: !isDisabled, + scope: 'global', + metadata: {}, + source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, + scanIssues: [{ + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }], + }); + } } } return skills; } + async scan(): Promise { + return this.scanBasePaths(this.basePaths); + } + async install(_name: string, _request: InstallRequest): Promise { throw new Error(`${this.displayName} does not support install`); } async uninstall(_name: string): Promise { throw new Error(`${this.displayName} does not support uninstall`); } async update(_name: string): Promise { throw new Error(`${this.displayName} does not support update`); } + getDisableStrategy(_skill: SkillToggleTarget): DisableStrategy | undefined { + if (!this.capabilities.canToggle) return undefined; + return { + type: 'disabled-directory', + description: 'Renames the skill directory with a .disabled- prefix', + }; + } + + getScanPaths(): ProviderScanPath[] { + return this.basePaths.map((basePath) => ({ + path: basePath, + kind: 'skill-root', + label: `${this.displayName} skill root`, + })); + } + + async setEnabled(skill: SkillToggleTarget, enabled: boolean): Promise { + if (!this.capabilities.canToggle) { + throw new Error(`${this.displayName} does not support toggle`); + } + if (skill.enabled === enabled) return; + + const currentName = path.basename(skill.path); + const bareName = currentName.replace(/^\.disabled-/, ''); + const destName = enabled ? bareName : `.disabled-${bareName}`; + const dest = path.join(path.dirname(skill.path), destName); + if (dest === skill.path) return; + await access(dest).then( + () => { throw new Error(`Target ${dest} already exists`); }, + () => { /* dest doesn't exist, good */ }, + ); + await rename(skill.path, dest); + } + async enable(name: string): Promise { for (const basePath of this.basePaths) { const src = path.join(basePath, `.disabled-${name}`); diff --git a/packages/core/src/sources/github.ts b/packages/core/src/sources/github.ts deleted file mode 100644 index 0ed8c19..0000000 --- a/packages/core/src/sources/github.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { IInstallSource } from './source.js'; -import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; -import type { Skill } from '../models/skill.js'; -import { execFile } from 'node:child_process'; -import { mkdtemp, access } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -export function parseGitHubIdentifier(identifier: string): { owner: string; repo: string; ref: string; path: string } { - const urlMatch = identifier.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/); - if (urlMatch) return { owner: urlMatch[1], repo: urlMatch[2], ref: urlMatch[3], path: urlMatch[4] }; - const atMatch = identifier.match(/^([^/]+)\/([^@]+)@(.+)$/); - if (atMatch) return { owner: atMatch[1], repo: atMatch[2], ref: 'main', path: atMatch[3] }; - const slashMatch = identifier.match(/^([^/]+)\/([^/]+)$/); - if (slashMatch) return { owner: slashMatch[1], repo: slashMatch[2], ref: 'main', path: '.' }; - throw new Error(`Cannot parse GitHub identifier: ${identifier}`); -} - -export class GitHubSource implements IInstallSource { - readonly id = 'github'; - readonly displayName = 'GitHub'; - - async search(query: string): Promise { - try { - const parsed = parseGitHubIdentifier(query); - const skillName = parsed.path === '.' ? parsed.repo : path.basename(parsed.path); - return [{ - name: skillName, - description: `${parsed.owner}/${parsed.repo}` + (parsed.path !== '.' ? ` → ${parsed.path}` : ''), - source: 'github', - identifier: query, - }]; - } catch { - return []; - } - } - - async fetch(identifier: string): Promise { - const parsed = parseGitHubIdentifier(identifier); - const tempDir = await mkdtemp(path.join(tmpdir(), 'skillpack-gh-')); - const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`; - await execFileAsync('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', '--branch', parsed.ref, repoUrl, tempDir]); - - const { stdout: commitOut } = await execFileAsync('git', ['-C', tempDir, 'rev-parse', 'HEAD']); - const commit = commitOut.trim(); - - if (parsed.path !== '.') { - const candidates = [parsed.path, `skills/${parsed.path}`]; - await execFileAsync('git', ['-C', tempDir, 'sparse-checkout', 'set', ...candidates]); - - const resolvedPath = (await Promise.all( - candidates.map(async (c) => { - try { await access(path.join(tempDir, c, 'SKILL.md')); return c; } catch { return null; } - }), - )).find((c) => c !== null); - - if (!resolvedPath) { - throw new Error(`Skill not found at "${parsed.path}" or "skills/${parsed.path}" in ${parsed.owner}/${parsed.repo}`); - } - - const skillName = path.basename(resolvedPath); - return { tempDir: path.join(tempDir, resolvedPath), skillName, files: [], commit, ref: parsed.ref }; - } - - return { tempDir, skillName: parsed.repo, files: [], commit, ref: parsed.ref }; - } - - async checkUpdate(skill: Skill): Promise { - if (!skill.source?.repo || !skill.source?.commit) return null; - try { - const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${skill.source.repo}.git`, skill.source.ref || 'HEAD']); - const latestCommit = stdout.split('\t')[0]; - if (latestCommit && latestCommit !== skill.source.commit) { - return { currentVersion: skill.source.commit.slice(0, 7), latestVersion: latestCommit.slice(0, 7), hasUpdate: true }; - } - } catch { /* skip */ } - return null; - } -} diff --git a/packages/core/src/sources/index.ts b/packages/core/src/sources/index.ts index e929c57..ac7fe05 100644 --- a/packages/core/src/sources/index.ts +++ b/packages/core/src/sources/index.ts @@ -1,3 +1,2 @@ export { type IInstallSource } from './source.js'; -export { GitHubSource, parseGitHubIdentifier } from './github.js'; export { SkillsShSource } from './skillssh.js'; diff --git a/packages/core/src/sources/skillssh.ts b/packages/core/src/sources/skillssh.ts index 13c21b6..9efeda5 100644 --- a/packages/core/src/sources/skillssh.ts +++ b/packages/core/src/sources/skillssh.ts @@ -6,6 +6,12 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +type CommandResult = { stdout: string; stderr: string }; +type CommandRunner = (command: string, args: string[], options: { + timeout: number; + env: NodeJS.ProcessEnv; +}) => Promise; + function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*m/g, ''); } @@ -45,9 +51,11 @@ export class SkillsShSource implements IInstallSource { readonly id = 'skillssh'; readonly displayName = 'skills.sh'; + constructor(private readonly runCommand: CommandRunner = execFileAsync) {} + async search(query: string): Promise { try { - const { stdout, stderr } = await execFileAsync('npx', ['skills', 'find', query], { + const { stdout, stderr } = await this.runCommand('npx', ['skills', 'find', query], { timeout: 30_000, env: { ...process.env, NO_COLOR: '1' }, }); @@ -67,7 +75,7 @@ export class SkillsShSource implements IInstallSource { if (skill) args.push('--skill', skill); try { - await execFileAsync('npx', args, { + await this.runCommand('npx', args, { timeout: 60_000, env: { ...process.env, NO_COLOR: '1' }, }); @@ -86,7 +94,7 @@ export class SkillsShSource implements IInstallSource { async checkUpdate(skill: Skill): Promise { if (skill.source?.type !== 'skillssh') return null; try { - const { stdout, stderr } = await execFileAsync('npx', ['skills', 'check'], { + const { stdout, stderr } = await this.runCommand('npx', ['skills', 'check'], { timeout: 30_000, env: { ...process.env, NO_COLOR: '1' }, }); @@ -112,7 +120,7 @@ export class SkillsShSource implements IInstallSource { async updateViaCli(skillName: string): Promise { try { - await execFileAsync('npx', ['skills', 'update', skillName, '-g', '-y'], { + await this.runCommand('npx', ['skills', 'update', skillName, '-g', '-y'], { timeout: 60_000, env: { ...process.env, NO_COLOR: '1' }, }); @@ -124,7 +132,7 @@ export class SkillsShSource implements IInstallSource { async removeViaCli(skillName: string): Promise { try { - await execFileAsync('npx', ['skills', 'remove', skillName, '-g', '-y'], { + await this.runCommand('npx', ['skills', 'remove', skillName, '-g', '-y'], { timeout: 60_000, env: { ...process.env, NO_COLOR: '1' }, }); diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts new file mode 100644 index 0000000..2218f0c --- /dev/null +++ b/packages/core/tests/config.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ConfigManager } from '../src/config.js'; + +describe('ConfigManager', () => { + let root: string; + let configDir: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'skillpack-config-')); + configDir = path.join(root, 'config'); + await mkdir(configDir); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('preserves default providers and sources when a user overrides one provider path', async () => { + const customCodexPath = path.join(root, 'custom-codex-skills'); + await writeFile(path.join(configDir, 'config.json'), JSON.stringify({ + providers: { + codex: { paths: [customCodexPath] }, + }, + }), 'utf-8'); + + const config = await new ConfigManager(configDir).load(); + + expect(config.providers.codex.paths).toEqual([customCodexPath]); + expect(config.providers.codex.enabled).toBe(true); + expect(Object.keys(config.providers).sort()).toEqual(['claude', 'codex', 'global']); + expect(config.providers.claude.paths).toEqual( + expect.arrayContaining([ + expect.stringContaining(path.join('.claude', 'plugins', 'cache')), + expect.stringContaining(path.join('.claude', 'skills')), + ]), + ); + expect(config.providers.global.paths[0]).toContain(path.join('.agents', 'skills')); + expect(config.sources.github).toBeUndefined(); + expect(config.sources.skillssh.enabled).toBe(true); + }); + + it('auto-detects default provider paths against the configured home directory', async () => { + const homeDir = path.join(root, 'home'); + await mkdir(path.join(homeDir, '.codex', 'skills'), { recursive: true }); + await mkdir(path.join(homeDir, '.codex', 'plugins', 'cache'), { recursive: true }); + await mkdir(path.join(homeDir, '.agents', 'skills'), { recursive: true }); + + const config = await new ConfigManager({ configDir, homeDir }).load(); + + expect(config.providers.codex).toEqual({ + enabled: true, + paths: [ + path.join(homeDir, '.codex', 'skills'), + path.join(homeDir, '.codex', 'plugins', 'cache'), + ], + }); + expect(config.providers.global).toEqual({ + enabled: true, + paths: [path.join(homeDir, '.agents', 'skills')], + }); + expect(config.providers.claude.enabled).toBe(false); + expect(config.projectSkillsDirs).toEqual(['.codex/skills', '.claude/skills', '.agents/skills']); + }); +}); diff --git a/packages/core/tests/duplicates.test.ts b/packages/core/tests/duplicates.test.ts index 222dce6..c0ab5c6 100644 --- a/packages/core/tests/duplicates.test.ts +++ b/packages/core/tests/duplicates.test.ts @@ -10,18 +10,18 @@ describe('DuplicateDetector', () => { const detector = new DuplicateDetector(); it('detects no duplicates when names are unique', () => { - expect(detector.detect([makeSkill('a', 'codex'), makeSkill('b', 'cursor')])).toHaveLength(0); + expect(detector.detect([makeSkill('a', 'codex'), makeSkill('b', 'claude')])).toHaveLength(0); }); it('detects duplicate when same name across providers', () => { - const duplicates = detector.detect([makeSkill('figma', 'codex'), makeSkill('figma', 'cursor'), makeSkill('other', 'codex')]); + const duplicates = detector.detect([makeSkill('figma', 'codex'), makeSkill('figma', 'claude'), makeSkill('other', 'codex')]); expect(duplicates).toHaveLength(1); expect(duplicates[0].skillName).toBe('figma'); expect(duplicates[0].instances).toHaveLength(2); }); it('detects multiple duplicates', () => { - const duplicates = detector.detect([makeSkill('a', 'codex'), makeSkill('a', 'cursor'), makeSkill('b', 'codex'), makeSkill('b', 'claude'), makeSkill('b', 'global')]); + const duplicates = detector.detect([makeSkill('a', 'codex'), makeSkill('a', 'global'), makeSkill('b', 'codex'), makeSkill('b', 'claude'), makeSkill('b', 'global')]); expect(duplicates).toHaveLength(2); expect(duplicates.find((d) => d.skillName === 'b')?.instances).toHaveLength(3); }); diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts new file mode 100644 index 0000000..0825ff4 --- /dev/null +++ b/packages/core/tests/inventory.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { SkillManager } from '../src/manager.js'; +import { buildSkillInventory } from '../src/models/inventory.js'; +import { CodexProvider } from '../src/providers/codex.js'; +import { GlobalProvider } from '../src/providers/global.js'; + +async function writeSkill(dir: string, name: string, description = 'Test skill'): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n`); +} + +describe('Skill Inventory', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'skillpack-inventory-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('groups provider instances that resolve to the same skill path as confirmed', async () => { + const canonicalSkill = path.join(root, 'shared', 'figma'); + const codexDir = path.join(root, 'codex'); + const globalDir = path.join(root, 'global'); + await writeSkill(canonicalSkill, 'figma'); + await mkdir(codexDir); + await mkdir(globalDir); + await symlink(canonicalSkill, path.join(codexDir, 'figma')); + await symlink(canonicalSkill, path.join(globalDir, 'figma')); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + manager.registerProvider(new GlobalProvider([globalDir])); + + await manager.scanAll(); + + const inventory = manager.getInventory(); + expect(inventory).toHaveLength(1); + expect(inventory[0].name).toBe('figma'); + expect(inventory[0].identity.confidence).toBe('confirmed'); + expect(inventory[0].instances.map((instance) => instance.provider).sort()).toEqual(['codex', 'global']); + }); + + it('groups provider instances by normalized name as inferred when provenance is unavailable', async () => { + const codexDir = path.join(root, 'codex'); + const globalDir = path.join(root, 'global'); + await writeSkill(path.join(codexDir, 'figma-tool'), 'figma-tool'); + await writeSkill(path.join(globalDir, 'figma_tool'), 'Figma Tool'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + manager.registerProvider(new GlobalProvider([globalDir])); + + await manager.scanAll(); + + const inventory = manager.getInventory(); + expect(inventory).toHaveLength(1); + expect(inventory[0].identity.confidence).toBe('inferred'); + expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('inferred-identity'); + }); + + it('keeps project skills out of controllable inventory without shadowing provider skills', async () => { + const codexDir = path.join(root, 'codex'); + const projectDir = path.join(root, 'project'); + await writeSkill(path.join(codexDir, 'repo-helper'), 'repo-helper'); + await writeSkill(path.join(projectDir, '.agents', 'skills', 'repo-helper'), 'repo-helper'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + + await manager.scanAll(projectDir, ['.agents/skills']); + + const inventory = manager.getInventory(); + expect(inventory).toHaveLength(1); + expect(inventory[0].instances).toHaveLength(1); + expect(inventory[0].instances[0].provider).toBe('codex'); + expect(manager.getProjectSkills()).toHaveLength(1); + expect(manager.getProjectSkills()[0].actions).toEqual([]); + }); + + it('surfaces invalid SKILL.md files as health signals', async () => { + const codexDir = path.join(root, 'codex'); + const brokenSkill = path.join(codexDir, 'broken'); + await mkdir(brokenSkill, { recursive: true }); + await writeFile(path.join(brokenSkill, 'SKILL.md'), '---\nname: [unterminated\n---\n'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + + await manager.scanAll(); + + const inventory = manager.getInventory(); + expect(inventory).toHaveLength(1); + expect(inventory[0].name).toBe('broken'); + expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('invalid-skill-md'); + }); + + it('surfaces broken skill symlinks as health signals', async () => { + const codexDir = path.join(root, 'codex'); + await mkdir(codexDir); + await symlink(path.join(root, 'missing-skill'), path.join(codexDir, 'missing-skill')); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + + await manager.scanAll(); + + const inventory = manager.getInventory(); + expect(inventory).toHaveLength(1); + expect(inventory[0].name).toBe('missing-skill'); + expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('broken-symlink'); + }); + + it('exposes provider-safe actions and flags unmanaged Global Skills', async () => { + const codexDir = path.join(root, 'codex'); + const globalDir = path.join(root, 'global'); + await writeSkill(path.join(codexDir, 'local-codex'), 'local-codex'); + await writeSkill(path.join(globalDir, 'local-global'), 'local-global'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + manager.registerProvider(new GlobalProvider([globalDir])); + + await manager.scanAll(); + + const inventory = manager.getInventory(); + const codex = inventory.find((group) => group.name === 'local-codex')!; + const global = inventory.find((group) => group.name === 'local-global')!; + + expect(codex.instances[0].actions).toEqual(['disable']); + expect(global.instances[0].actions).toEqual([]); + expect(global.healthSignals.map((signal) => signal.code)).toContain('unmanaged-global-skill'); + }); + + it('does not expose enable or disable actions for skills.sh-managed Global Skills', () => { + const inventory = buildSkillInventory([{ + name: 'managed-global', + description: '', + provider: 'global', + path: path.join(root, 'global', 'managed-global'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh' }, + }]); + + expect(inventory).toHaveLength(1); + expect(inventory[0].instances[0].actions).toEqual(['update', 'remove']); + }); + + it('exposes the provider Disable Strategy for mutable inventory instances', async () => { + const codexDir = path.join(root, 'codex'); + const codexConfig = path.join(root, 'codex-config.toml'); + await writeSkill(path.join(codexDir, 'toggle-me'), 'toggle-me'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir], codexConfig)); + + await manager.scanAll(); + + const instance = manager.getInventory()[0].instances[0]; + expect(instance.disableStrategy).toEqual({ + type: 'provider-config', + description: 'Writes [[skills.config]] in Codex config.toml', + }); + }); + + it('toggles the selected inventory instance through provider config without changing same-name siblings', async () => { + const firstCodexDir = path.join(root, 'codex-one'); + const secondCodexDir = path.join(root, 'codex-two'); + const codexConfig = path.join(root, 'codex-config.toml'); + await writeSkill(path.join(firstCodexDir, 'shared'), 'shared'); + await writeSkill(path.join(secondCodexDir, 'shared'), 'shared'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([firstCodexDir, secondCodexDir], codexConfig)); + + await manager.scanAll(); + const before = manager.getInventory()[0].instances; + const target = before.find((instance) => instance.path.startsWith(secondCodexDir))!; + + await manager.toggleInventoryInstance(target); + await manager.scanAll(); + + const after = manager.getInventory()[0].instances; + expect(after.find((instance) => instance.path.startsWith(firstCodexDir))?.enabled).toBe(true); + expect(after.find((instance) => instance.path.startsWith(secondCodexDir))?.enabled).toBe(false); + }); + + it('keeps invalid Project Skills visible as read-only project inventory', async () => { + const projectDir = path.join(root, 'project'); + const brokenProjectSkill = path.join(projectDir, '.agents', 'skills', 'broken-project'); + await mkdir(brokenProjectSkill, { recursive: true }); + await writeFile(path.join(brokenProjectSkill, 'SKILL.md'), '---\nname: [unterminated\n---\n'); + + const manager = new SkillManager(); + + await manager.scanAll(projectDir, ['.agents/skills']); + + expect(manager.getInventory()).toHaveLength(0); + const projectSkills = manager.getProjectSkills(); + expect(projectSkills).toHaveLength(1); + expect(projectSkills[0].name).toBe('broken-project'); + expect(projectSkills[0].actions).toEqual([]); + expect(projectSkills[0].healthSignals.map((signal) => signal.code)).toContain('invalid-skill-md'); + }); +}); diff --git a/packages/core/tests/lockfile.test.ts b/packages/core/tests/lockfile.test.ts deleted file mode 100644 index dec3cc0..0000000 --- a/packages/core/tests/lockfile.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { LockfileManager } from '../src/lockfile.js'; - -describe('LockfileManager', () => { - let dir: string; - let lm: LockfileManager; - - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'skillpack-lock-')); - lm = new LockfileManager(path.join(dir, 'skillpack.lock')); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('creates a new lock file when none exists', async () => { - await lm.load(); - expect(lm.getEntries()).toEqual({}); - }); - - it('adds and persists an entry', async () => { - await lm.load(); - lm.setEntry('my-skill', { - source: 'github', - repo: 'owner/repo', - commit: 'abc123', - installedAt: '2026-04-09T00:00:00Z', - integrity: 'sha256-test', - }); - await lm.save(); - - const raw = JSON.parse(await readFile(path.join(dir, 'skillpack.lock'), 'utf-8')); - expect(raw.skills['my-skill'].commit).toBe('abc123'); - }); - - it('removes an entry', async () => { - await lm.load(); - lm.setEntry('my-skill', { - source: 'github', - repo: 'owner/repo', - commit: 'abc123', - installedAt: '2026-04-09T00:00:00Z', - integrity: 'sha256-test', - }); - lm.removeEntry('my-skill'); - await lm.save(); - - const raw = JSON.parse(await readFile(path.join(dir, 'skillpack.lock'), 'utf-8')); - expect(raw.skills['my-skill']).toBeUndefined(); - }); - - it('roundtrips load -> save -> load', async () => { - await lm.load(); - lm.setEntry('a', { source: 'skillssh', identifier: 'pkg@a', version: '1.0.0', installedAt: '2026-04-09T00:00:00Z', integrity: 'sha256-aaa' }); - await lm.save(); - - const lm2 = new LockfileManager(path.join(dir, 'skillpack.lock')); - await lm2.load(); - const entry = lm2.getEntry('a'); - expect(entry?.version).toBe('1.0.0'); - }); -}); diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index b6e2fe5..68dc2c9 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { SkillManager } from '../src/manager.js'; import { CodexProvider } from '../src/providers/codex.js'; +import { GlobalProvider } from '../src/providers/global.js'; +import { SkillsShSource } from '../src/sources/skillssh.js'; +import type { Skill } from '../src/models/index.js'; +import type { IInstallSource } from '../src/sources/source.js'; describe('SkillManager', () => { let dir: string; @@ -41,10 +45,113 @@ describe('SkillManager', () => { await rm(dir2, { recursive: true, force: true }); }); - it('creates a skill', async () => { - const skill = await manager.createSkill('codex', { name: 'new', description: 'New skill' }); - expect(skill.name).toBe('new'); + it('does not remove provider-local skills', async () => { + const skillDir = path.join(dir, 'local-skill'); + await mkdir(skillDir); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: local-skill\ndescription: local\n---\n'); await manager.scanAll(); - expect(manager.getAllSkills()).toHaveLength(1); + + await expect(manager.uninstallSkill(manager.getAllSkills()[0])).rejects.toThrow('Only skills.sh-managed Global Skills can be removed'); + await expect(access(skillDir)).resolves.toBeUndefined(); + }); + + it('removes skills.sh-managed Global Skills through the skills CLI', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + manager.registerSource(new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + })); + const skill: Skill = { + name: 'managed-skill', + description: '', + provider: 'global', + path: path.join(dir, 'managed-skill'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh' }, + }; + + await manager.uninstallSkill(skill); + + expect(calls).toEqual([{ + command: 'npx', + args: ['skills', 'remove', 'managed-skill', '-g', '-y'], + }]); + }); + + it('does not toggle Global Skills by renaming shared content', async () => { + const globalDir = path.join(dir, 'global'); + const skillDir = path.join(globalDir, 'shared-skill'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: shared-skill\ndescription: shared\n---\n'); + + const customManager = new SkillManager(); + customManager.registerProvider(new GlobalProvider([globalDir])); + await customManager.scanAll(); + + const [skill] = customManager.getAllSkills(); + await expect(customManager.toggleSkill(skill)).rejects.toThrow('does not support toggle'); + await expect(access(skillDir)).resolves.toBeUndefined(); + }); + + it('rejects installs to non-Global providers before fetching from a source', async () => { + let fetchCount = 0; + const source: IInstallSource = { + id: 'skillssh', + displayName: 'skills.sh', + search: async () => [], + fetch: async () => { + fetchCount += 1; + return { tempDir: dir, skillName: 'managed-skill', files: [] }; + }, + checkUpdate: async () => null, + }; + manager.registerSource(source); + + await expect(manager.installFromSource('skillssh', 'owner/repo', 'codex')).rejects.toThrow('Global Skills'); + expect(fetchCount).toBe(0); + }); + + it('rejects non-skills.sh install sources before fetching', async () => { + let fetchCount = 0; + const source: IInstallSource = { + id: 'github', + displayName: 'GitHub', + search: async () => [], + fetch: async () => { + fetchCount += 1; + return { tempDir: dir, skillName: 'github-skill', files: [] }; + }, + checkUpdate: async () => null, + }; + manager.registerSource(source); + + await expect(manager.installFromSource('github', 'owner/repo/path', 'global')).rejects.toThrow('skills.sh'); + expect(fetchCount).toBe(0); + }); + + it('reports provider and project scan paths while scanning custom paths and skipping missing paths', async () => { + const missingProviderPath = path.join(dir, 'missing-provider'); + const customProviderPath = path.join(dir, 'custom-provider'); + const projectRoot = path.join(dir, 'repo'); + await mkdir(path.join(customProviderPath, 'custom-skill'), { recursive: true }); + await writeFile(path.join(customProviderPath, 'custom-skill', 'SKILL.md'), '---\nname: custom-skill\ndescription: custom\n---\n'); + await mkdir(path.join(projectRoot, '.custom', 'skills', 'project-skill'), { recursive: true }); + await writeFile(path.join(projectRoot, '.custom', 'skills', 'project-skill', 'SKILL.md'), '---\nname: project-skill\ndescription: project\n---\n'); + + const customManager = new SkillManager(); + customManager.registerProvider(new CodexProvider([missingProviderPath, customProviderPath])); + + await customManager.scanAll(projectRoot, ['missing-project-skills', '.custom/skills']); + + expect(customManager.getAllSkills().map((skill) => skill.name)).toContain('custom-skill'); + expect(customManager.getProjectSkills().map((skill) => skill.name)).toEqual(['project-skill']); + expect(customManager.getScanPathDiagnostics()).toEqual(expect.arrayContaining([ + expect.objectContaining({ scope: 'provider', provider: 'codex', path: missingProviderPath, exists: false }), + expect.objectContaining({ scope: 'provider', provider: 'codex', path: customProviderPath, exists: true }), + expect.objectContaining({ scope: 'project', path: path.join(projectRoot, 'missing-project-skills'), exists: false }), + expect.objectContaining({ scope: 'project', path: path.join(projectRoot, '.custom', 'skills'), exists: true }), + ])); }); }); diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts new file mode 100644 index 0000000..5ba2a39 --- /dev/null +++ b/packages/core/tests/providers/claude.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile, readFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ClaudeProvider } from '../../src/providers/claude.js'; + +async function writeSkill(dir: string, name: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n`); +} + +describe('ClaudeProvider', () => { + let root: string; + let flatDir: string; + let cacheDir: string; + let settingsPath: string; + let provider: ClaudeProvider; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'skillpack-claude-')); + flatDir = path.join(root, 'skills'); + cacheDir = path.join(root, 'plugins', 'cache'); + settingsPath = path.join(root, 'settings.json'); + provider = new ClaudeProvider([cacheDir], [flatDir], settingsPath); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('reads flat skill availability from skillOverrides', async () => { + await writeSkill(path.join(flatDir, 'deploy'), 'deploy'); + await writeSkill(path.join(flatDir, 'legacy-context'), 'legacy-context'); + await writeFile(settingsPath, JSON.stringify({ + skillOverrides: { + deploy: 'off', + 'legacy-context': 'name-only', + }, + }, null, 2)); + + const skills = await provider.scan(); + + expect(skills.find((skill) => skill.name === 'deploy')?.enabled).toBe(false); + expect(skills.find((skill) => skill.name === 'legacy-context')?.enabled).toBe(true); + }); + + it('keys flat skillOverrides by parsed Claude skill name, not directory name', async () => { + const skillDir = path.join(flatDir, 'deploy-dir'); + await writeSkill(skillDir, 'deploy'); + await writeFile(settingsPath, JSON.stringify({ + skillOverrides: { + deploy: 'off', + }, + }, null, 2)); + + const [skill] = await provider.scan(); + + expect(skill.name).toBe('deploy'); + expect(skill.enabled).toBe(false); + }); + + it('toggles flat skills through skillOverrides without renaming directories', async () => { + const skillDir = path.join(flatDir, 'deploy'); + await writeSkill(skillDir, 'deploy'); + + const [skill] = await provider.scan(); + await provider.setEnabled(skill, false); + + await expect(access(skillDir)).resolves.toBeUndefined(); + await expect(access(path.join(flatDir, '.disabled-deploy'))).rejects.toThrow(); + expect(JSON.parse(await readFile(settingsPath, 'utf-8')).skillOverrides.deploy).toBe('off'); + expect((await provider.scan())[0].enabled).toBe(false); + + await provider.setEnabled({ ...skill, enabled: false }, true); + + expect(JSON.parse(await readFile(settingsPath, 'utf-8')).skillOverrides.deploy).toBe('on'); + expect((await provider.scan())[0].enabled).toBe(true); + }); + + it('writes flat skillOverrides with the parsed Claude skill name', async () => { + const skillDir = path.join(flatDir, 'deploy-dir'); + await writeSkill(skillDir, 'deploy'); + + const [skill] = await provider.scan(); + await provider.setEnabled(skill, false); + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')); + expect(settings.skillOverrides.deploy).toBe('off'); + expect(settings.skillOverrides['deploy-dir']).toBeUndefined(); + expect((await provider.scan())[0].enabled).toBe(false); + }); + + it('reads plugin skill availability from enabledPlugins', async () => { + await writeSkill(path.join(cacheDir, 'team-tools', 'deploy-plugin', '1.0.0', 'skills', 'deploy'), 'deploy-plugin:deploy'); + await writeFile(settingsPath, JSON.stringify({ + enabledPlugins: { + 'deploy-plugin@team-tools': false, + }, + }, null, 2)); + + const skills = await provider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0].enabled).toBe(false); + }); + + it('toggles plugin skills through enabledPlugins for the owning plugin', async () => { + const skillDir = path.join(cacheDir, 'team-tools', 'deploy-plugin', '1.0.0', 'skills', 'deploy'); + await writeSkill(skillDir, 'deploy-plugin:deploy'); + + const [skill] = await provider.scan(); + await provider.setEnabled(skill, false); + + await expect(access(skillDir)).resolves.toBeUndefined(); + expect(JSON.parse(await readFile(settingsPath, 'utf-8')).enabledPlugins['deploy-plugin@team-tools']).toBe(false); + + await provider.setEnabled({ ...skill, enabled: false }, true); + + expect(JSON.parse(await readFile(settingsPath, 'utf-8')).enabledPlugins['deploy-plugin@team-tools']).toBe(true); + }); +}); diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index 0a37f69..f9e584f 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -1,26 +1,52 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile, access } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, writeFile, readFile, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { CodexProvider } from '../../src/providers/codex.js'; describe('CodexProvider', () => { let dir: string; + let configPath: string; let provider: CodexProvider; beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), 'skillpack-codex-')); - provider = new CodexProvider([dir]); + configPath = path.join(dir, 'config.toml'); + provider = new CodexProvider([dir], configPath); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + async function writeSkill(skillDir: string, name: string, description = 'A test skill') { + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n`); + } + + async function writePluginSkill( + pluginCacheDir: string, + marketplace: string, + pluginName: string, + version: string, + skillName: string, + manifestName = pluginName, + ) { + const pluginRoot = path.join(pluginCacheDir, marketplace, pluginName, version); + await mkdir(path.join(pluginRoot, '.codex-plugin'), { recursive: true }); + await writeFile(path.join(pluginRoot, '.codex-plugin', 'plugin.json'), JSON.stringify({ + name: manifestName, + version, + skills: './skills/', + interface: { displayName: manifestName.toUpperCase() }, + }), 'utf-8'); + await writeSkill(path.join(pluginRoot, 'skills', skillName), skillName); + return pluginRoot; + } + it('scans skills from directory', async () => { const skillDir = path.join(dir, 'test-skill'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\n\n# Test\n'); + await writeSkill(skillDir, 'test-skill', 'A test'); const skills = await provider.scan(); expect(skills).toHaveLength(1); expect(skills[0].name).toBe('test-skill'); @@ -41,42 +67,189 @@ describe('CodexProvider', () => { it('uninstalls a skill', async () => { const skillDir = path.join(dir, 'to-delete'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: to-delete\ndescription: Delete me\n---\n'); + await writeSkill(skillDir, 'to-delete', 'Delete me'); await provider.uninstall('to-delete'); const skills = await provider.scan(); expect(skills).toHaveLength(0); }); - it('disables a skill by renaming directory', async () => { + it('reads enabled state from Codex config entries', async () => { const skillDir = path.join(dir, 'my-skill'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: Toggle me\n---\n'); + await writeSkill(skillDir, 'my-skill', 'Toggle me'); + await writeFile(configPath, `[[skills.config]]\npath = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false\n`); - await provider.disable('my-skill'); + const skills = await provider.scan(); - await expect(access(path.join(dir, 'my-skill'))).rejects.toThrow(); - await expect(access(path.join(dir, '.disabled-my-skill'))).resolves.toBeUndefined(); + expect(skills).toHaveLength(1); + expect(skills[0].enabled).toBe(false); + }); + + it('does not let later TOML tables overwrite Codex skill config state', async () => { + const skillDir = path.join(dir, 'my-skill'); + await writeSkill(skillDir, 'my-skill', 'Toggle me'); + await writeFile(configPath, [ + '[[skills.config]]', + `path = "${path.join(skillDir, 'SKILL.md')}"`, + 'enabled = false', + '', + '[mcp_servers.example]', + 'command = "example"', + 'enabled = true', + '', + ].join('\n')); const skills = await provider.scan(); + expect(skills).toHaveLength(1); - expect(skills[0].name).toBe('my-skill'); expect(skills[0].enabled).toBe(false); }); - it('enables a disabled skill', async () => { - const disabledDir = path.join(dir, '.disabled-my-skill'); - await mkdir(disabledDir); - await writeFile(path.join(disabledDir, 'SKILL.md'), '---\nname: my-skill\ndescription: Toggle me\n---\n'); + it('disables a skill by writing Codex config without renaming the directory', async () => { + const skillDir = path.join(dir, 'my-skill'); + await writeSkill(skillDir, 'my-skill', 'Toggle me'); - await provider.enable('my-skill'); + const [skill] = await provider.scan(); + await provider.setEnabled(skill, false); + await expect(access(path.join(dir, 'my-skill'))).resolves.toBeUndefined(); await expect(access(path.join(dir, '.disabled-my-skill'))).rejects.toThrow(); + expect(await readFile(configPath, 'utf-8')).toContain(`path = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false`); + + const skills = await provider.scan(); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('my-skill'); + expect(skills[0].enabled).toBe(false); + }); + + it('enables a disabled skill by writing Codex config', async () => { + const skillDir = path.join(dir, 'my-skill'); + await writeSkill(skillDir, 'my-skill', 'Toggle me'); + await writeFile(configPath, `[[skills.config]]\npath = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false\n`); + + const [skill] = await provider.scan(); + await provider.setEnabled(skill, true); + await expect(access(path.join(dir, 'my-skill'))).resolves.toBeUndefined(); + expect(await readFile(configPath, 'utf-8')).toContain(`path = "${path.join(skillDir, 'SKILL.md')}"\nenabled = true`); const skills = await provider.scan(); expect(skills).toHaveLength(1); expect(skills[0].name).toBe('my-skill'); expect(skills[0].enabled).toBe(true); }); + + it('scans Codex plugin-owned skills from active plugin cache roots', async () => { + const skillRoot = path.join(dir, 'skills'); + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginConfig = path.join(dir, 'config.toml'); + const pluginProvider = new CodexProvider([skillRoot, pluginCache], pluginConfig); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '1.2.3', 'yeet', 'github'); + await writeFile(pluginConfig, '[plugins."github@openai-curated"]\nenabled = true\n'); + + const skills = await pluginProvider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + name: 'yeet', + provider: 'codex', + enabled: true, + origin: { + type: 'plugin', + pluginId: 'github@openai-curated', + pluginName: 'github', + marketplace: 'openai-curated', + version: '1.2.3', + displayName: 'GITHUB', + pluginEnabled: true, + }, + }); + }); + + it('combines Codex plugin access and per-skill config for availability', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '1.2.3', 'yeet'); + await writeFile(configPath, [ + '[plugins."github@openai-curated"]', + 'enabled = true', + '', + '[[skills.config]]', + `path = "${path.join(pluginCache, 'openai-curated', 'github', '1.2.3', 'skills', 'yeet', 'SKILL.md')}"`, + 'enabled = false', + '', + ].join('\n')); + + const [skill] = await pluginProvider.scan(); + + expect(skill.enabled).toBe(false); + expect(skill.origin).toMatchObject({ + type: 'plugin', + pluginEnabled: true, + skillConfigEnabled: false, + }); + }); + + it('treats missing Codex plugin config as enabled and writes plugin config when toggled', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + await writePluginSkill(pluginCache, 'openai-curated-remote', 'product-design', '0.1.47', 'audit'); + + const [skill] = await pluginProvider.scan(); + expect(skill.enabled).toBe(true); + expect(skill.origin).toMatchObject({ + type: 'plugin', + pluginId: 'product-design@openai-curated-remote', + pluginEnabled: true, + }); + + await pluginProvider.setEnabled(skill, false); + + expect(await readFile(configPath, 'utf-8')).toContain('[plugins."product-design@openai-curated-remote"]\nenabled = false'); + }); + + it('toggles Codex plugin-owned skills through the owning plugin config', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '1.2.3', 'yeet'); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '1.2.3', 'github'); + await writeFile(configPath, '[plugins."github@openai-curated"]\nenabled = true\n'); + + const [skill] = await pluginProvider.scan(); + await pluginProvider.setEnabled(skill, false); + + expect(await readFile(configPath, 'utf-8')).toContain('[plugins."github@openai-curated"]\nenabled = false'); + const rescanned = await pluginProvider.scan(); + expect(rescanned.map((item) => item.enabled)).toEqual([false, false]); + }); + + it('hides duplicate Codex plugin cache roots from the main scan when a configured root exists for the same plugin', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '3fdeeb49', 'yeet'); + await writePluginSkill(pluginCache, 'openai-curated-remote', 'github', '0.1.5', 'yeet'); + await writeFile(configPath, '[plugins."github@openai-curated"]\nenabled = true\n'); + + const skills = await pluginProvider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0].origin).toMatchObject({ + type: 'plugin', + pluginId: 'github@openai-curated', + }); + }); + + it('does not expose a plugin toggle when plugin path and manifest identity disagree', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + await writePluginSkill(pluginCache, 'openai-curated', 'github', '1.2.3', 'yeet', 'not-github'); + await writeFile(configPath, '[plugins."github@openai-curated"]\nenabled = true\n'); + + const [skill] = await pluginProvider.scan(); + + expect(skill.scanIssues).toEqual([{ + code: 'plugin-identity-mismatch', + message: 'Codex plugin manifest name "not-github" does not match cache plugin "github"', + }]); + expect(pluginProvider.getDisableStrategy(skill)).toBeUndefined(); + }); }); diff --git a/packages/core/tests/sources/skillssh.test.ts b/packages/core/tests/sources/skillssh.test.ts new file mode 100644 index 0000000..a12a9ff --- /dev/null +++ b/packages/core/tests/sources/skillssh.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { SkillsShSource } from '../../src/sources/skillssh.js'; +import type { Skill } from '../../src/models/index.js'; + +describe('SkillsShSource', () => { + it('installs a selected skills.sh skill into Global Skills through the skills CLI', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const source = new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }); + + const result = await source.fetch('owner/repo@skill-name'); + + expect(result.skillName).toBe('skill-name'); + expect(calls).toEqual([{ + command: 'npx', + args: ['skills', 'add', 'owner/repo', '-g', '-y', '--skill', 'skill-name'], + }]); + }); + + it('checks for updates through the skills CLI only when requested', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const source = new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: 'demo-skill update available\n', stderr: '' }; + }); + const skill: Skill = { + name: 'demo-skill', + description: '', + provider: 'global', + path: '/fake/demo-skill', + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', skillFolderHash: 'abcdef123456' }, + }; + + const update = await source.checkUpdate(skill); + + expect(update).toEqual({ + currentVersion: 'abcdef1', + latestVersion: 'latest', + hasUpdate: true, + }); + expect(calls).toEqual([{ command: 'npx', args: ['skills', 'check'] }]); + }); + + it('updates a skills.sh-managed Global Skill through the skills CLI', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const source = new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }); + + await source.updateViaCli('demo-skill'); + + expect(calls).toEqual([{ + command: 'npx', + args: ['skills', 'update', 'demo-skill', '-g', '-y'], + }]); + }); +}); diff --git a/packages/tui/README.md b/packages/tui/README.md index 890780a..51f1263 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -1,6 +1,6 @@ # Skillpack -Unified TUI manager for agent skills across Codex, Cursor, Claude, and Global (`~/.agents/skills`). +Unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agents/skills`). ## Installation @@ -19,21 +19,18 @@ npx skillpack-tui ## Features -- Multi-platform scanning for Codex, Cursor, Claude, Global, and project-level skills +- Multi-platform scanning for Codex, Codex plugins, Claude, Global, and project-level skills - Symlink-aware duplicate detection -- Enable and disable skills with the `.disabled-` directory prefix -- Install skills from GitHub repos or the skills.sh registry -- Create new skills from a TUI wizard -- Edit skill files in `$EDITOR` -- Delete skills with confirmation -- Check and apply updates for supported remote sources +- Enable and disable Codex and Claude skills through provider-native availability mechanisms +- Install Global Skills through the skills.sh registry +- Check and apply manual updates for skills.sh-managed Global Skills - Fuzzy search by name and description ## Quick Start Launch `skillpack` to see discovered skills grouped by provider. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to switch provider tabs, and `/` to search. -Press `Space` to toggle a skill on or off, `Enter` to view details, `i` to install from a remote source, or `c` to create a new skill. +Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a Global Skill, or `u` to open manual updates. Plugin-owned skills ask for confirmation because the toggle affects every skill from the owning plugin. ## Keyboard Shortcuts @@ -42,14 +39,15 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `i` to insta | Key | Action | | --- | --- | | `↑` / `↓` | Navigate skills | -| `Space` | Enable or disable selected skill | +| `Space` | Enable or disable selected Codex or Claude skill; plugin-owned skills ask for confirmation | | `Enter` | Open skill detail view | | `Tab` / `Shift+Tab` | Switch provider tab | | `/` | Search | | `Esc` | Clear search | -| `i` | Install from remote | -| `c` | Create skill | -| `u` | Check for updates | +| `p` | Open Project Skills | +| `s` | Open Settings | +| `i` | Install through skills.sh | +| `u` | Open manual updates | | `q` | Quit | ### Detail View @@ -57,10 +55,9 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `i` to insta | Key | Action | | --- | --- | | `Esc` | Return to list view | -| `Space` | Enable or disable skill | -| `e` / `E` | Edit `SKILL.md` in `$EDITOR` | +| `Space` | Enable or disable Codex or Claude skill; plugin-owned skills ask for confirmation | | `o` / `O` | Open skill folder | -| `d` | Delete skill | +| `d` | Remove a skills.sh-managed Global Skill | | `↑` / `↓` | Scroll description | ## Configuration @@ -75,17 +72,10 @@ It auto-detects provider directories on first run and can scan project-level ski ```text .codex/skills -.cursor/skills-cursor .claude/skills .agents/skills ``` -Remote install provenance is stored in: - -```text -~/.config/skillpack/skillpack.lock -``` - ## Requirements - Node.js >= 18 diff --git a/packages/tui/package.json b/packages/tui/package.json index 5a86c05..7efd0ff 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "name": "skillpack-tui", "version": "0.1.2", - "description": "Unified TUI manager for agent skills across Codex, Cursor, Claude, and Global", + "description": "Unified TUI manager for agent skills across Codex, Claude, and Global", "type": "module", "bin": { "skillpack": "./dist/skillpack.js" @@ -17,7 +17,6 @@ "tui", "agent", "skills", - "cursor", "claude", "codex", "ink" diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 84b9714..bd6cc68 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -5,7 +5,9 @@ import { useSkillManager } from './hooks/use-skill-manager.js'; import { ListView } from './views/list-view.js'; import { DetailView } from './views/detail-view.js'; import { InstallView } from './views/install-view.js'; -import { CreateView } from './views/create-view.js'; +import { ProjectSkillsView } from './views/project-skills-view.js'; +import { SettingsView } from './views/settings-view.js'; +import { UpdatesView } from './views/updates-view.js'; import { useTerminalSize } from './hooks/use-terminal-size.js'; function Router() { @@ -15,7 +17,9 @@ function Router() { case 'list': return ; case 'detail': return ; case 'install': return ; - case 'create': return ; + case 'project': return ; + case 'settings': return ; + case 'updates': return ; } } diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index 35524c0..ccc672d 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -9,12 +9,14 @@ interface Shortcut { const SHORTCUTS: Record = { list: [ { key: '↑↓', label: 'navigate' }, - { key: 'space', label: 'toggle' }, + { key: 'space', label: 'toggle when supported' }, { key: 'enter', label: 'detail' }, { key: '/', label: 'search' }, { key: 'tab', label: 'tabs' }, + { key: 'p', label: 'project' }, + { key: 's', label: 'settings' }, + { key: 'u', label: 'updates' }, { key: 'i', label: 'install' }, - { key: 'c', label: 'create' }, { key: 'q', label: 'quit' }, ], install: [ @@ -22,33 +24,51 @@ const SHORTCUTS: Record = { { key: '↑↓', label: 'navigate' }, { key: 'enter', label: 'select' }, ], - create: [ + project: [ { key: 'esc', label: 'back' }, - { key: 'enter', label: 'confirm' }, + { key: '↑↓', label: 'navigate' }, + { key: 'q', label: 'quit' }, + ], + settings: [ + { key: 'esc', label: 'back' }, + { key: '↑↓', label: 'scroll' }, + { key: 'q', label: 'quit' }, + ], + updates: [ + { key: 'esc', label: 'back' }, + { key: 'enter', label: 'check/apply' }, + { key: 'r', label: 'recheck' }, + { key: '↑↓', label: 'navigate' }, + { key: 'q', label: 'quit' }, ], }; const DETAIL_BASE: Shortcut[] = [ { key: 'esc', label: 'back' }, - { key: 'space', label: 'toggle' }, ]; const DETAIL_TAIL: Shortcut[] = [ - { key: 'e', label: 'edit' }, { key: 'o', label: 'open folder' }, - { key: 'd', label: 'delete' }, ]; export function StatusBar() { - const { view, selectedSkill } = useAppContext(); + const { view, selectedSkill, manager } = useAppContext(); let shortcuts: Shortcut[]; if (view === 'detail') { const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh' || sourceType === 'github'; + const isUpdatable = sourceType === 'skillssh'; + const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + const canToggle = selectedSkill + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) + : false; + const detailBase = canToggle + ? [...DETAIL_BASE, { key: 'space', label: selectedSkill?.origin?.type === 'plugin' ? 'toggle plugin' : 'toggle' }] + : DETAIL_BASE; + const tail = isRemovable ? [...DETAIL_TAIL, { key: 'd', label: 'delete' }] : DETAIL_TAIL; shortcuts = isUpdatable - ? [...DETAIL_BASE, { key: 'u', label: 'check update' }, ...DETAIL_TAIL] - : [...DETAIL_BASE, ...DETAIL_TAIL]; + ? [...detailBase, { key: 'u', label: 'check update' }, ...tail] + : [...detailBase, ...tail]; } else { shortcuts = SHORTCUTS[view] ?? []; } diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 0e9f36b..d6aa561 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,12 +1,14 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import type { SkillManager, Skill, DuplicateInfo, SkillpackConfig } from '@skillpack/core'; +import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig, ScanPathDiagnostic } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'create'; +export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates'; interface AppState { manager: SkillManager; config: SkillpackConfig; skills: Skill[]; + projectSkills: SkillInventoryInstance[]; + scanPaths: ScanPathDiagnostic[]; duplicates: DuplicateInfo[]; activeTab: string; view: ViewType; @@ -40,6 +42,8 @@ interface AppProviderProps { export function AppProvider({ manager, config, children }: AppProviderProps) { const [skills, setSkills] = useState(manager.getAllSkills()); + const [projectSkills, setProjectSkills] = useState(manager.getProjectSkills()); + const [scanPaths, setScanPaths] = useState(manager.getScanPathDiagnostics()); const [duplicates, setDuplicates] = useState(manager.getDuplicates()); const [activeTab, setActiveTab] = useState('All'); const [view, setView] = useState('list'); @@ -52,6 +56,8 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { await manager.scanAll(process.cwd(), config.projectSkillsDirs); const newSkills = manager.getAllSkills(); setSkills(newSkills); + setProjectSkills(manager.getProjectSkills()); + setScanPaths(manager.getScanPathDiagnostics()); setDuplicates(manager.getDuplicates()); setSelectedSkill((prev) => { if (!prev) return null; @@ -62,7 +68,7 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { return ( {children} diff --git a/packages/tui/src/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index 6c33451..5df61fc 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react'; import { SkillManager, ConfigManager, - CodexProvider, CursorProvider, ClaudeProvider, GlobalProvider, - GitHubSource, SkillsShSource, + CodexProvider, ClaudeProvider, GlobalProvider, + SkillsShSource, type SkillpackConfig, } from '@skillpack/core'; @@ -26,9 +26,8 @@ export function useSkillManager(): SkillManagerResult { const cfg = await configManager.load(); const mgr = new SkillManager(); - const providerFactories: Record InstanceType> = { + const providerFactories: Record InstanceType> = { codex: () => new CodexProvider(cfg.providers.codex?.paths), - cursor: () => new CursorProvider(cfg.providers.cursor?.paths), claude: () => { const allPaths = cfg.providers.claude?.paths ?? []; const cachePaths = allPaths.filter((p) => p.includes('plugins') || p.includes('cache')); @@ -47,7 +46,6 @@ export function useSkillManager(): SkillManagerResult { } } - if (cfg.sources.github?.enabled) mgr.registerSource(new GitHubSource()); if (cfg.sources.skillssh?.enabled) mgr.registerSource(new SkillsShSource()); await mgr.init(); diff --git a/packages/tui/src/hooks/use-skills.ts b/packages/tui/src/hooks/use-skills.ts index 199b939..5ccea9f 100644 --- a/packages/tui/src/hooks/use-skills.ts +++ b/packages/tui/src/hooks/use-skills.ts @@ -6,10 +6,8 @@ import type { Skill } from '@skillpack/core'; const TAB_PROVIDER_MAP: Record = { All: null, Codex: 'codex', - Cursor: 'cursor', Claude: 'claude', Global: 'global', - Project: 'project', }; export const TABS = Object.keys(TAB_PROVIDER_MAP); @@ -19,9 +17,9 @@ export function useFilteredSkills(): { skills: Skill[]; tabs: string[] } { const tabFiltered = useMemo(() => { const provider = TAB_PROVIDER_MAP[activeTab]; - if (provider === null) return skills; - if (provider === 'project') return skills.filter((s) => s.scope === 'project'); - return skills.filter((s) => s.provider === provider); + const inventorySkills = skills.filter((s) => s.scope !== 'project'); + if (provider === null) return inventorySkills; + return inventorySkills.filter((s) => s.provider === provider); }, [skills, activeTab]); const filtered = useSearch(tabFiltered, searchQuery); diff --git a/packages/tui/src/lib/format-path.ts b/packages/tui/src/lib/format-path.ts new file mode 100644 index 0000000..af28a31 --- /dev/null +++ b/packages/tui/src/lib/format-path.ts @@ -0,0 +1,10 @@ +import path from 'node:path'; + +export function formatDisplayPath(value: string): string { + const relativePath = path.relative(process.cwd(), value); + const homeDir = process.env.HOME; + const homePath = homeDir && value.startsWith(`${homeDir}${path.sep}`) + ? value.replace(homeDir, '~') + : value; + return relativePath && !relativePath.startsWith('..') ? relativePath : homePath; +} diff --git a/packages/tui/src/lib/plugin-toggle.ts b/packages/tui/src/lib/plugin-toggle.ts new file mode 100644 index 0000000..e9deeef --- /dev/null +++ b/packages/tui/src/lib/plugin-toggle.ts @@ -0,0 +1,25 @@ +import type { Skill } from '@skillpack/core'; + +export function isPluginOwnedSkill(skill: Skill | null | undefined): skill is Skill & { origin: NonNullable } { + return skill?.origin?.type === 'plugin'; +} + +export function getAffectedPluginSkills(skills: Skill[], skill: Skill): Skill[] { + if (!isPluginOwnedSkill(skill)) return []; + return skills + .filter((candidate) => ( + candidate.provider === skill.provider + && candidate.origin?.type === 'plugin' + && candidate.origin.pluginId === skill.origin.pluginId + )) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function formatPluginToggleMessage(skill: Skill, skills: Skill[]): string { + if (!isPluginOwnedSkill(skill)) return ''; + const action = skill.origin.pluginEnabled ? 'Disable' : 'Enable'; + const affected = getAffectedPluginSkills(skills, skill); + const names = affected.map((item) => item.name).join(', '); + const count = affected.length; + return `${action} plugin ${skill.origin.pluginId}? This affects ${count} Codex skill${count === 1 ? '' : 's'}: ${names}.`; +} diff --git a/packages/tui/src/views/create-view.tsx b/packages/tui/src/views/create-view.tsx deleted file mode 100644 index 78eff6f..0000000 --- a/packages/tui/src/views/create-view.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { TextInput, Spinner } from '@inkjs/ui'; -import { execSync } from 'node:child_process'; -import path from 'node:path'; -import { useAppContext } from '../context/app-context.js'; -import { StatusBar } from '../components/status-bar.js'; - -type CreateStep = 'provider' | 'name' | 'description' | 'creating'; - -export function CreateView() { - const { setView, manager, refresh } = useAppContext(); - const [step, setStep] = useState('provider'); - const [cursor, setCursor] = useState(0); - const [selectedProvider, setSelectedProvider] = useState(''); - const [name, setName] = useState(''); - const [error, setError] = useState(''); - - const providers = manager.getProviders().filter((p) => p.capabilities.canCreate); - - useInput((_input, key) => { - if (key.escape) { - if (step === 'provider') { setView('list'); return; } - if (step === 'name') { setStep('provider'); return; } - if (step === 'description') { setStep('name'); return; } - return; - } - - if (step === 'name' || step === 'description') return; - - if (step === 'provider') { - if (key.downArrow) setCursor((c) => Math.min(c + 1, providers.length - 1)); - if (key.upArrow) setCursor((c) => Math.max(c - 1, 0)); - if (key.return && providers[cursor]) { - setSelectedProvider(providers[cursor].id); - setStep('name'); - } - } - }); - - const handleNameSubmit = (value: string) => { - if (!value.trim()) { - setError('Name cannot be empty'); - return; - } - setName(value.trim()); - setError(''); - setStep('description'); - }; - - const handleDescriptionSubmit = async (description: string) => { - setStep('creating'); - try { - const skill = await manager.createSkill(selectedProvider, { - name, - description: description.trim(), - }); - const editor = process.env.EDITOR || 'vi'; - const skillMd = path.join(skill.path, 'SKILL.md'); - try { - execSync(`${editor} "${skillMd}"`, { stdio: 'inherit' }); - } catch { /* editor exited non-zero */ } - await refresh(); - setView('list'); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStep('provider'); - } - }; - - const stepLabels = ['provider', 'name', 'description']; - const currentStepIdx = stepLabels.indexOf(step === 'creating' ? 'description' : step); - - return ( - - {/* Header */} - - ‹ esc - Create Skill - - - {/* Progress */} - - {stepLabels.map((s, i) => ( - - {i > 0 && } - - {i + 1}. {s} - - - ))} - - - {error !== '' && ( - - ✗ {error} - - )} - - {step === 'provider' && ( - - Where to create this skill? - - {providers.map((p, i) => ( - - - {i === cursor ? '❯' : ' '} - - - {p.displayName} - - - ))} - - - )} - - {step === 'name' && ( - - Skill name - - - - - - )} - - {step === 'description' && ( - - Brief description - - - - - - )} - - {step === 'creating' && ( - - - - )} - - - - - ); -} diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index de70a95..2fc5cb2 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -2,11 +2,11 @@ import { useState, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { execSync } from 'node:child_process'; -import path from 'node:path'; import { useAppContext } from '../context/app-context.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { ConfirmDialog } from '../components/confirm-dialog.js'; import { StatusBar } from '../components/status-bar.js'; +import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; import type { UpdateInfo } from '@skillpack/core'; function formatRelativeTime(iso: string): string { @@ -27,7 +27,7 @@ function formatRelativeTime(iso: string): string { export function DetailView() { const { selectedSkill, setView, refresh, manager } = useAppContext(); const { rows } = useTerminalSize(); - const [confirming, setConfirming] = useState(false); + const [confirming, setConfirming] = useState<'remove' | 'plugin-toggle' | null>(null); const [descScroll, setDescScroll] = useState(0); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -36,7 +36,14 @@ export function DetailView() { const [updating, setUpdating] = useState(false); const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh' || sourceType === 'github'; + const isUpdatable = sourceType === 'skillssh'; + const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + const canToggle = selectedSkill + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) + : false; + const disableStrategy = selectedSkill + ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) + : undefined; const duplicate = selectedSkill ? manager.getDuplicates().find((d) => d.skillName === selectedSkill.name) @@ -57,6 +64,8 @@ export function DetailView() { used += 3; // agent, path, status if (selectedSkill.version) used += 1; if (selectedSkill.source) used += 1; + if (selectedSkill.origin?.type === 'plugin') used += 2; + if (disableStrategy) used += 1; if (isUpdatable) used += 1; // update row if (addedAt) used += 1; if (duplicate) used += 1 + 1 + duplicate.instances.length; // gap + heading + instances @@ -66,19 +75,10 @@ export function DetailView() { used += 1; // status bar if (error) used += 1; return Math.max(0, rows - used); - }, [selectedSkill, duplicate, error, rows, isUpdatable]); + }, [selectedSkill, duplicate, error, rows, isUpdatable, disableStrategy]); useInput((input, key) => { if (key.escape) { setView('list'); return; } - if ((input === 'e' || input === 'E') && selectedSkill) { - const editor = process.env.EDITOR || 'vi'; - const skillMd = path.join(selectedSkill.path, 'SKILL.md'); - try { - execSync(`${editor} "${skillMd}"`, { stdio: 'inherit' }); - } catch { /* editor exited non-zero */ } - refresh(); - return; - } if ((input === 'o' || input === 'O') && selectedSkill) { const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; try { @@ -86,7 +86,11 @@ export function DetailView() { } catch { /* opener failed */ } return; } - if (input === ' ' && selectedSkill && !busy) { + if (input === ' ' && selectedSkill && canToggle && !busy) { + if (isPluginOwnedSkill(selectedSkill)) { + setConfirming('plugin-toggle'); + return; + } setError(null); setBusy(true); manager.toggleSkill(selectedSkill) @@ -95,9 +99,9 @@ export function DetailView() { .finally(() => setBusy(false)); return; } - if (input === 'd' && selectedSkill) { + if (input === 'd' && selectedSkill && isRemovable) { setError(null); - setConfirming(true); + setConfirming('remove'); } if (input === 'u' && selectedSkill && isUpdatable && !busy && !checkingUpdate && !updating) { setError(null); @@ -133,7 +137,7 @@ export function DetailView() { return No skill selected; } - if (confirming) { + if (confirming === 'remove') { return ( setConfirming(false)} + onCancel={() => setConfirming(null)} + /> + + ); + } + + if (confirming === 'plugin-toggle') { + return ( + + { + setError(null); + setBusy(true); + manager.toggleSkill(selectedSkill) + .then(() => refresh()) + .catch((err: Error) => setError(err.message)) + .finally(() => { + setBusy(false); + setConfirming(null); + }); + }} + onCancel={() => setConfirming(null)} /> ); @@ -186,14 +212,36 @@ export function DetailView() { {'source'.padEnd(10)} {selectedSkill.source.type}{selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} - {selectedSkill.source.type === 'github' && selectedSkill.source.commit && ( - @{selectedSkill.source.commit.slice(0, 7)} - )} {selectedSkill.source.type === 'skillssh' && selectedSkill.source.skillFolderHash && ( #{selectedSkill.source.skillFolderHash.slice(0, 7)} )} )} + {selectedSkill.origin?.type === 'plugin' && ( + <> + + {'plugin'.padEnd(10)} + {selectedSkill.origin.displayName ?? selectedSkill.origin.pluginName} + {selectedSkill.origin.pluginId} + + + {'plugin on'.padEnd(10)} + + {selectedSkill.origin.pluginEnabled ? '● enabled' : '○ disabled'} + + skill override + + {selectedSkill.origin.skillConfigEnabled === false ? 'disabled' : 'default'} + + + + )} + {disableStrategy && ( + + {'toggle'.padEnd(10)} + {disableStrategy.description} + + )} {isUpdatable && ( {'update'.padEnd(10)} diff --git a/packages/tui/src/views/install-view.tsx b/packages/tui/src/views/install-view.tsx index ae73e87..7d4604c 100644 --- a/packages/tui/src/views/install-view.tsx +++ b/packages/tui/src/views/install-view.tsx @@ -5,40 +5,25 @@ import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; import type { RemoteSkill } from '@skillpack/core'; -type InstallStep = 'source' | 'query' | 'results' | 'installing'; +type InstallStep = 'query' | 'results' | 'installing'; export function InstallView() { const { setView, manager, refresh } = useAppContext(); - const [step, setStep] = useState('source'); - const [selectedSource, setSelectedSource] = useState(''); + const [step, setStep] = useState('query'); const [results, setResults] = useState([]); const [cursor, setCursor] = useState(0); const [error, setError] = useState(''); const [searching, setSearching] = useState(false); - const sources = manager.getSources(); - useInput((_input, key) => { if (key.escape) { - if (step === 'source') { setView('list'); return; } - if (step === 'query') { setStep('source'); return; } + if (step === 'query') { setView('list'); return; } if (step === 'results') { setStep('query'); return; } return; } if (step === 'query') return; - if (step === 'source') { - if (key.downArrow) setCursor((c) => Math.min(c + 1, sources.length - 1)); - if (key.upArrow) setCursor((c) => Math.max(c - 1, 0)); - if (key.return && sources[cursor]) { - setSelectedSource(sources[cursor].id); - setCursor(0); - setStep('query'); - } - return; - } - if (step === 'results') { if (key.downArrow) setCursor((c) => Math.min(c + 1, results.length - 1)); if (key.upArrow) setCursor((c) => Math.max(c - 1, 0)); @@ -52,7 +37,7 @@ export function InstallView() { setError(''); setSearching(true); try { - const found = await manager.searchRemote(selectedSource, value); + const found = await manager.searchRemote('skillssh', value); setResults(found); setCursor(0); if (found.length === 0) { @@ -69,7 +54,7 @@ export function InstallView() { const doInstall = async (identifier: string) => { setStep('installing'); try { - await manager.installFromSource(selectedSource, identifier, 'global'); + await manager.installFromSource('skillssh', identifier, 'global'); await refresh(); setView('list'); } catch (err) { @@ -78,7 +63,7 @@ export function InstallView() { } }; - const stepLabels = ['source', 'query', 'results']; + const stepLabels = ['query', 'results']; const currentStepIdx = stepLabels.indexOf(step === 'installing' ? 'results' : step); return ( @@ -111,36 +96,16 @@ export function InstallView() { )} - {step === 'source' && ( - - Where to search? - - {sources.map((source, i) => ( - - - {i === cursor ? '❯' : ' '} - - - {source.displayName} - - - ))} - - - )} - {step === 'query' && ( - - {selectedSource === 'github' ? 'Enter owner/repo or owner/repo@path' : 'Search skills.sh'} - + Search skills.sh {searching ? ( ) : ( diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index bf63ac5..bf67cb8 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -8,6 +8,9 @@ import { TabBar } from '../components/tab-bar.js'; import { SkillRow, COL_NAME_WIDTH, COL_AGENT_WIDTH } from '../components/skill-row.js'; import { SearchInput } from '../components/search-input.js'; import { StatusBar } from '../components/status-bar.js'; +import { ConfirmDialog } from '../components/confirm-dialog.js'; +import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; +import type { Skill } from '@skillpack/core'; const CHROME_LINES = 7; @@ -22,6 +25,7 @@ export function ListView() { const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [searching, setSearching] = useState(false); + const [confirmingPluginToggle, setConfirmingPluginToggle] = useState(null); const prevSkillsLenRef = useRef(skills.length); @@ -55,16 +59,15 @@ export function ListView() { const tabCounts = useMemo(() => { const counts: Record = {}; + const inventorySkills = allSkills.filter((s) => s.scope !== 'project'); for (const tab of TABS) { if (tab === 'All') { - counts[tab] = allSkills.length; - } else if (tab === 'Project') { - counts[tab] = allSkills.filter((s) => s.scope === 'project').length; + counts[tab] = inventorySkills.length; } else { const providerMap: Record = { - Codex: 'codex', Cursor: 'cursor', Claude: 'claude', Global: 'global', + Codex: 'codex', Claude: 'claude', Global: 'global', }; - counts[tab] = allSkills.filter((s) => s.provider === providerMap[tab]).length; + counts[tab] = inventorySkills.filter((s) => s.provider === providerMap[tab]).length; } } return counts; @@ -85,11 +88,21 @@ export function ListView() { return; } if (input === '/') { setSearching(true); return; } + if (input === 'p') { setView('project'); return; } + if (input === 's') { setView('settings'); return; } + if (input === 'u') { setView('updates'); return; } if (input === 'i') { setView('install'); return; } - if (input === 'c') { setView('create'); return; } if (input === ' ' && skills[cursor]) { - manager.toggleSkill(skills[cursor]).then(() => refresh()).catch(() => {}); + const selected = skills[cursor]; + const canToggle = Boolean(manager.getProvider(selected.provider)?.getDisableStrategy(selected)); + if (canToggle) { + if (isPluginOwnedSkill(selected)) { + setConfirmingPluginToggle(selected); + return; + } + manager.toggleSkill(selected).then(() => refresh()).catch(() => {}); + } return; } if (key.return && skills[cursor]) { @@ -104,12 +117,29 @@ export function ListView() { : (idx + 1) % tabs.length; setActiveTab(tabs[next]); } - }, { isActive: !searching }); + }, { isActive: !searching && !confirmingPluginToggle }); if (loading) { return ; } + if (confirmingPluginToggle) { + return ( + + { + manager.toggleSkill(confirmingPluginToggle) + .then(() => refresh()) + .catch(() => {}) + .finally(() => setConfirmingPluginToggle(null)); + }} + onCancel={() => setConfirmingPluginToggle(null)} + /> + + ); + } + const showScroll = skills.length > visibleRows; const scrollBarHeight = Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))); const scrollBarOffset = skills.length <= visibleRows @@ -181,8 +211,8 @@ export function ListView() { No skills found. Press i to install or - c - to create one. + p + for Project Skills. )} diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx new file mode 100644 index 0000000..b2c05a5 --- /dev/null +++ b/packages/tui/src/views/project-skills-view.tsx @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import { useAppContext } from '../context/app-context.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { StatusBar } from '../components/status-bar.js'; +import { formatDisplayPath } from '../lib/format-path.js'; + +const CHROME_LINES = 5; +const NAME_WIDTH = 30; +const PATH_WIDTH = 54; + +function truncate(value: string, max: number): string { + if (value.length <= max) return value.padEnd(max); + return value.slice(0, max - 1) + '…'; +} + +export function ProjectSkillsView() { + const { exit } = useApp(); + const { projectSkills, setView } = useAppContext(); + const { rows } = useTerminalSize(); + const [cursor, setCursor] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + + const visibleRows = Math.max(1, rows - CHROME_LINES); + + useInput((input, key) => { + if (input === 'q') { exit(); return; } + if (key.escape) { setView('list'); return; } + if (key.downArrow) { + setCursor((c) => Math.min(c + 1, projectSkills.length - 1)); + setScrollOffset((offset) => { + const next = Math.min(cursor + 1, projectSkills.length - 1); + return next >= offset + visibleRows ? next - visibleRows + 1 : offset; + }); + } + if (key.upArrow) { + setCursor((c) => Math.max(c - 1, 0)); + setScrollOffset((offset) => { + const next = Math.max(cursor - 1, 0); + return next < offset ? next : offset; + }); + } + }); + + const visibleSkills = useMemo( + () => projectSkills.slice(scrollOffset, scrollOffset + visibleRows), + [projectSkills, scrollOffset, visibleRows], + ); + const showScroll = projectSkills.length > visibleRows; + + return ( + + + ‹ esc + Project Skills + {projectSkills.length} skill{projectSkills.length !== 1 ? 's' : ''} + {showScroll && ( + {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, projectSkills.length)} of {projectSkills.length} + )} + + + + + {' '} + {'NAME'.padEnd(NAME_WIDTH)} + {'PROJECT PATH'.padEnd(PATH_WIDTH)} + STATE + + + + + {projectSkills.length === 0 ? ( + + No Project Skills found for this repository. + + ) : ( + visibleSkills.map((skill, index) => { + const selected = scrollOffset + index === cursor; + const hasIssues = skill.healthSignals.length > 0; + return ( + + + {selected ? '❯' : ' '} + + + {truncate(skill.name, NAME_WIDTH)} + + {truncate(formatDisplayPath(skill.path), PATH_WIDTH)} + + {hasIssues ? 'needs attention' : 'read-only'} + + + ); + }) + )} + + + + + + ); +} diff --git a/packages/tui/src/views/settings-view.tsx b/packages/tui/src/views/settings-view.tsx new file mode 100644 index 0000000..a99469e --- /dev/null +++ b/packages/tui/src/views/settings-view.tsx @@ -0,0 +1,150 @@ +import { Box, Text, useApp, useInput } from 'ink'; +import { useMemo, useState, type ReactNode } from 'react'; +import { useAppContext } from '../context/app-context.js'; +import { StatusBar } from '../components/status-bar.js'; +import { formatDisplayPath } from '../lib/format-path.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; + +const PROVIDER_WIDTH = 12; +const KIND_WIDTH = 13; +const STATUS_WIDTH = 8; +const PATH_WIDTH = 64; +const CHROME_LINES = 3; + +interface SettingsRow { + key: string; + element: ReactNode; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value.padEnd(max); + return value.slice(0, max - 1) + '…'; +} + +function formatEnabled(enabled: boolean): string { + return enabled ? 'enabled' : 'disabled'; +} + +export function SettingsView() { + const { exit } = useApp(); + const { config, scanPaths, setView } = useAppContext(); + const { rows } = useTerminalSize(); + const [scrollOffset, setScrollOffset] = useState(0); + + useInput((input, key) => { + if (input === 'q') { exit(); return; } + if (key.escape) { setView('list'); } + if (key.downArrow) { + setScrollOffset((offset) => Math.min(offset + 1, Math.max(0, contentRows.length - visibleRows))); + } + if (key.upArrow) { + setScrollOffset((offset) => Math.max(0, offset - 1)); + } + }); + + const providerRows = useMemo(() => Object.entries(config.providers).map(([id, provider]) => ({ + id, + enabled: provider.enabled, + rootCount: provider.paths.length, + })), [config.providers]); + + const sourceRows = useMemo(() => Object.entries(config.sources).map(([id, source]) => ({ + id, + enabled: source.enabled, + })), [config.sources]); + + const contentRows: SettingsRow[] = useMemo(() => { + const result: SettingsRow[] = [ + { key: 'scan-heading', element: Scan Roots }, + { + key: 'scan-header', + element: ( + + {truncate('SCOPE', PROVIDER_WIDTH)} + {truncate('KIND', KIND_WIDTH)} + {truncate('STATUS', STATUS_WIDTH)} + {truncate('PATH', PATH_WIDTH)} + + ), + }, + ]; + + if (scanPaths.length === 0) { + result.push({ key: 'scan-empty', element: No Scan Roots reported yet. }); + } else { + for (const scanPath of scanPaths) { + const scope = scanPath.provider ?? scanPath.scope; + result.push({ + key: `scan:${scanPath.scope}:${scanPath.provider ?? 'project'}:${scanPath.path}`, + element: ( + + {truncate(scope, PROVIDER_WIDTH)} + {truncate(scanPath.kind, KIND_WIDTH)} + + {truncate(scanPath.exists ? 'exists' : 'missing', STATUS_WIDTH)} + + {truncate(formatDisplayPath(scanPath.path), PATH_WIDTH)} + + ), + }); + } + } + + result.push({ key: 'provider-gap', element: }); + result.push({ key: 'provider-heading', element: Providers }); + for (const provider of providerRows) { + result.push({ + key: `provider:${provider.id}`, + element: ( + + {truncate(provider.id, PROVIDER_WIDTH)} + {formatEnabled(provider.enabled)} + {provider.rootCount} root{provider.rootCount === 1 ? '' : 's'} + + ), + }); + } + + result.push({ key: 'source-gap', element: }); + result.push({ key: 'source-heading', element: Sources }); + for (const source of sourceRows) { + result.push({ + key: `source:${source.id}`, + element: ( + + {truncate(source.id, PROVIDER_WIDTH)} + {formatEnabled(source.enabled)} + + ), + }); + } + + return result; + }, [providerRows, scanPaths, sourceRows]); + + const visibleRows = Math.max(1, rows - CHROME_LINES); + const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); + const showScroll = contentRows.length > visibleRows; + + return ( + + + ‹ esc + Settings + read-only + {showScroll && ( + {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + )} + + + + {visibleContent.map((row) => ( + {row.element} + ))} + + + + + + ); +} diff --git a/packages/tui/src/views/updates-view.tsx b/packages/tui/src/views/updates-view.tsx new file mode 100644 index 0000000..f654511 --- /dev/null +++ b/packages/tui/src/views/updates-view.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import { Spinner } from '@inkjs/ui'; +import { useAppContext } from '../context/app-context.js'; +import { StatusBar } from '../components/status-bar.js'; +import type { Skill, UpdateInfo } from '@skillpack/core'; + +type UpdatesState = 'idle' | 'checking' | 'checked' | 'updating'; + +interface UpdateRow { + skill: Skill; + update: UpdateInfo; +} + +export function UpdatesView() { + const { exit } = useApp(); + const { manager, refresh, setView } = useAppContext(); + const [state, setState] = useState('idle'); + const [updates, setUpdates] = useState([]); + const [cursor, setCursor] = useState(0); + const [error, setError] = useState(''); + + const runCheck = async () => { + setError(''); + setState('checking'); + try { + const found = await manager.checkUpdates(); + setUpdates(found); + setCursor(0); + setState('checked'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setState('idle'); + } + }; + + const applySelected = async () => { + const selected = updates[cursor]; + if (!selected) return; + setError(''); + setState('updating'); + try { + await manager.updateSkill(selected.skill); + await refresh(); + await runCheck(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setState('checked'); + } + }; + + useInput((input, key) => { + if (input === 'q') { exit(); return; } + if (key.escape) { setView('list'); return; } + if (state === 'checking' || state === 'updating') return; + if (input === 'r') { + void runCheck(); + return; + } + if (key.return) { + if (state === 'idle') { + void runCheck(); + } else { + void applySelected(); + } + return; + } + if (key.downArrow) { + setCursor((c) => Math.min(c + 1, updates.length - 1)); + } + if (key.upArrow) { + setCursor((c) => Math.max(c - 1, 0)); + } + }); + + return ( + + + ‹ esc + Updates + + + {error !== '' && ( + + ✗ {error} + + )} + + {state === 'idle' && ( + + Not checked. Press + enter + to check skills.sh-managed Global Skills. + + )} + + {state === 'checking' && ( + + + + )} + + {state === 'updating' && ( + + + + )} + + {state === 'checked' && ( + + {updates.length} update{updates.length !== 1 ? 's' : ''} available + + {updates.length === 0 ? ( + No skills.sh updates found. Press r to check again. + ) : ( + updates.map((row, index) => { + const selected = index === cursor; + return ( + + {selected ? '❯' : ' '} + {row.skill.name} + {row.update.currentVersion ?? '?'} + + {row.update.latestVersion ?? 'latest'} + + ); + }) + )} + + + )} + + + + + ); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..7e24990 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - "packages/*" +allowBuilds: + esbuild: true