From b17a66eb2d2afacd97e27b02466ad56516f3558e Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:29:35 +0800 Subject: [PATCH 01/21] docs: define provider-native skillpack refactor --- AGENTS.md | 14 ++ CONTEXT.md | 53 +++++ .../0001-skillpack-owns-the-skill-library.md | 7 + ...0002-use-symlink-projections-by-default.md | 7 + docs/adr/0003-make-profiles-first-class.md | 7 + docs/adr/0004-support-layered-profiles.md | 7 + .../0005-use-provider-native-management.md | 3 + ...-skill-inventory-the-primary-experience.md | 3 + docs/adr/0007-exclude-skill-authoring.md | 3 + ...mutations-to-provider-lifecycle-actions.md | 3 + .../0009-use-skillssh-only-for-v1-installs.md | 3 + .../0010-restrict-provider-local-mutations.md | 3 + ...se-provider-specific-disable-strategies.md | 3 + docs/adr/0012-group-inventory-by-skill.md | 3 + ...013-use-provenance-aware-skill-identity.md | 3 + ...health-signals-to-structural-provenance.md | 3 + .../0015-treat-project-skills-as-read-only.md | 3 + ...-show-project-skills-in-a-separate-view.md | 3 + docs/adr/0017-use-sectioned-tui-navigation.md | 3 + docs/adr/0018-defer-llm-advisory-guidance.md | 3 + docs/adr/0019-use-manual-update-checks.md | 3 + ...to-detected-configurable-provider-paths.md | 3 + ...-use-explain-before-action-detail-views.md | 3 + docs/agents/domain.md | 33 +++ docs/agents/issue-tracker.md | 22 ++ docs/agents/triage-labels.md | 13 ++ docs/prds/provider-native-skillpack.md | 101 +++++++++ .../provider-native-skillpack-refactor.md | 212 ++++++++++++++++++ 28 files changed, 527 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-skillpack-owns-the-skill-library.md create mode 100644 docs/adr/0002-use-symlink-projections-by-default.md create mode 100644 docs/adr/0003-make-profiles-first-class.md create mode 100644 docs/adr/0004-support-layered-profiles.md create mode 100644 docs/adr/0005-use-provider-native-management.md create mode 100644 docs/adr/0006-make-skill-inventory-the-primary-experience.md create mode 100644 docs/adr/0007-exclude-skill-authoring.md create mode 100644 docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md create mode 100644 docs/adr/0009-use-skillssh-only-for-v1-installs.md create mode 100644 docs/adr/0010-restrict-provider-local-mutations.md create mode 100644 docs/adr/0011-use-provider-specific-disable-strategies.md create mode 100644 docs/adr/0012-group-inventory-by-skill.md create mode 100644 docs/adr/0013-use-provenance-aware-skill-identity.md create mode 100644 docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md create mode 100644 docs/adr/0015-treat-project-skills-as-read-only.md create mode 100644 docs/adr/0016-show-project-skills-in-a-separate-view.md create mode 100644 docs/adr/0017-use-sectioned-tui-navigation.md create mode 100644 docs/adr/0018-defer-llm-advisory-guidance.md create mode 100644 docs/adr/0019-use-manual-update-checks.md create mode 100644 docs/adr/0020-use-auto-detected-configurable-provider-paths.md create mode 100644 docs/adr/0021-use-explain-before-action-detail-views.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 docs/prds/provider-native-skillpack.md create mode 100644 docs/specs/provider-native-skillpack-refactor.md diff --git a/AGENTS.md b/AGENTS.md index b12855f..6c2d328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,20 @@ Skillpack is a unified TUI manager for agent skills across Codex, Cursor, Claude - `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) diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d5b9b56 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,53 @@ +# 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 + +**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 + +**Project Skill**: +A skill stored inside a project repository and maintained by that repository's authors through git. +_Avoid_: Global Skill, managed skill + +**skills.sh**: +The external skill ecosystem and CLI used for installing, updating, and removing Global Skills. +_Avoid_: GitHub install source, package manager + +**Skill Inventory**: +The cross-provider view of discovered skills, their provenance, availability, and health. +_Avoid_: Skill editor, authoring workspace + +**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/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..cd37f28 --- /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, Cursor, 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..4ea1461 --- /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, skills.sh installation into Global skills, uninstall/removal, and update operations where the underlying provider supports them. It 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..4241419 --- /dev/null +++ b/docs/adr/0010-restrict-provider-local-mutations.md @@ -0,0 +1,3 @@ +# Restrict provider-local mutations + +Skillpack v1 will treat Codex, Cursor, 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..d857374 --- /dev/null +++ b/docs/adr/0011-use-provider-specific-disable-strategies.md @@ -0,0 +1,3 @@ +# 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 the provider has no known config file or native disable mechanism for skills. 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..8cc6b53 --- /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, Install, and Updates. Inventory remains the primary controllable view for provider-native skills, Project Skills is a separate read-only view, 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..e93f1c5 --- /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, Cursor, Claude, Global, and Project Skills, while allowing users to override or add paths in configuration. It 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/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..f648d8d --- /dev/null +++ b/docs/prds/provider-native-skillpack.md @@ -0,0 +1,101 @@ +# PRD: Provider-Native Skillpack + +## Problem Statement + +Users have skills scattered across Codex, Cursor, 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, Cursor, 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, Cursor, 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 Cursor skill using the safest known provider strategy, so that Cursor loading behavior changes predictably. +13. 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. +14. As a Skillpack user, I want `.disabled-` renaming used only as a fallback, so that Skillpack does not ignore known provider configuration mechanisms. +15. 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. +16. As a Skillpack user, I want Project Skills to show basic validity and paths, so that I can understand what the current repository contributes. +17. As a Skillpack user, I want Skillpack to never mutate Project Skills, so that repository authors remain responsible for them through git. +18. As a Skillpack user, I want to install Global Skills through skills.sh, so that installation follows the ecosystem's lifecycle and metadata. +19. As a Skillpack user, I want arbitrary GitHub installs excluded from v1, so that Skillpack does not become a second package manager. +20. As a Skillpack user, I want to remove only skills.sh-managed Global Skills, so that destructive removal is tied to external provenance. +21. 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. +22. 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. +23. 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. +24. As a Skillpack user, I want unmanaged Global Skills called out, so that I know which Global Skills lack skills.sh lock metadata. +25. As a Skillpack user, I want invalid `SKILL.md` files surfaced as Health Signals, so that broken skills are visible. +26. As a Skillpack user, I want broken symlinks surfaced as Health Signals, so that filesystem problems are visible. +27. As a Skillpack user, I want provider paths auto-detected, so that Skillpack works without setup for common installations. +28. As a Skillpack user, I want provider paths configurable, so that custom installations can still be scanned. +29. As a Skillpack user, I want no skill editing UI, so that Skillpack stays focused on management and inventory. +30. As a Skillpack user, I want no skill creation UI, so that authoring remains outside Skillpack. +31. As a maintainer, I want core inventory derivation tested independently of the TUI, so that behavior remains stable as the interface changes. +32. As a maintainer, I want provider lifecycle capabilities represented explicitly, so that unsupported actions cannot leak into the UI. +33. As a maintainer, I want skills.sh interactions isolated, so that external CLI behavior is easy to test and mock. +34. 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, 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, Cursor, and Claude support scan plus enable/disable only. +- Global Skills support scan, enable/disable, install, update, and remove when managed through skills.sh. +- Provider-local destructive removal is out of scope. +- Enable/disable uses provider-specific Disable Strategies; `.disabled-` renaming is fallback behavior only when no known provider mechanism exists. +- Update checks are manual and should not block startup. +- Provider paths are auto-detected with configurable overrides. +- 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, Cursor, 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..1acb4da --- /dev/null +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -0,0 +1,212 @@ +# Provider-Native Skillpack Refactor Spec + +## Purpose + +Refactor Skillpack into an understanding-first TUI for managing agent skills across Codex, Cursor, 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 four top-level sections: + +- **Inventory**: grouped view of provider-native skills from Codex, Cursor, Claude, and Global. +- **Project Skills**: read-only inventory of skills stored in the current project repository. +- **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, Cursor, Claude, Global, and Project Skill locations using provider-specific rules. +- 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. +- Enable or disable Codex, Cursor, Claude, and Global 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, Cursor, 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 +- Cursor +- 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 + +### 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. `.disabled-` directory renaming is a fallback only when no provider-specific mechanism is known. + +## Provider Capabilities + +| Provider | Scan | Enable/disable | Install | Update | Remove | Edit/Create | +| --- | --- | --- | --- | --- | --- | --- | +| Codex | yes | yes | no | no | no | no | +| Cursor | yes | yes | no | no | no | no | +| Claude | yes | yes | no | no | no | no | +| Global / skills.sh | yes | yes | 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 +- Cursor +- 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 enabled state and paths +- disable strategies toggle through provider-supported behavior +- 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/Cursor/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. From c87c6499c180122b8df6ea1223aa2b3c05cd85ad Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:33:04 +0800 Subject: [PATCH 02/21] chore: update .gitignore to include .agents and skills-lock.json --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 2492dc0405d5819f544682855d594860c792c631 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:36:28 +0800 Subject: [PATCH 03/21] chore: configure pnpm build approvals --- package.json | 3 ++- pnpm-workspace.yaml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) 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/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 From d96f1d2c50cae6fa311639b3583efe309a28c576 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:41:14 +0800 Subject: [PATCH 04/21] feat(core): build grouped skill inventory --- packages/core/src/index.ts | 1 + packages/core/src/manager.ts | 23 +++- packages/core/src/models/index.ts | 1 + packages/core/src/models/inventory.ts | 173 ++++++++++++++++++++++++ packages/core/src/models/skill.ts | 8 ++ packages/core/src/providers/provider.ts | 54 +++++++- packages/core/tests/inventory.test.ts | 139 +++++++++++++++++++ 7 files changed, 391 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/models/inventory.ts create mode 100644 packages/core/tests/inventory.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df57bb4..6003a2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,4 @@ 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/manager.ts b/packages/core/src/manager.ts index b75319e..460d743 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -6,15 +6,18 @@ import type { IInstallSource } from './sources/source.js'; import type { Skill, SkillTemplate } 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 duplicates: DuplicateInfo[] = []; private duplicateDetector = new DuplicateDetector(); private globalLock?: LockfileManager; @@ -42,9 +45,10 @@ export class SkillManager { 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(); @@ -145,6 +149,21 @@ export class SkillManager { } getAllSkills(): Skill[] { return this.skills; } + getInventory(): SkillGroup[] { return buildSkillInventory(this.skills); } + 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, + source: skill.source, + actions: [], + healthSignals: [], + })); + } 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); } diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index ff0d7bf..76e45b5 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -1,3 +1,4 @@ export * from './skill.js'; export * from './duplicate.js'; export * from './source.js'; +export * from './inventory.js'; diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts new file mode 100644 index 0000000..72257bc --- /dev/null +++ b/packages/core/src/models/inventory.ts @@ -0,0 +1,173 @@ +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' + | 'unmanaged-global-skill' + | 'update-available'; + +export interface HealthSignal { + code: HealthSignalCode; + message: string; +} + +export type SkillAction = 'enable' | 'disable' | 'update' | 'remove'; + +export interface SkillInventoryInstance { + name: string; + description: string; + provider: string; + path: string; + resolvedPath?: string; + version?: string; + enabled: boolean; + source?: SkillSource; + 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 toggleAction: SkillAction = skill.enabled ? 'disable' : 'enable'; + if (skill.provider === 'global' && skill.source?.type === 'skillssh') { + return [toggleAction, 'update', 'remove']; + } + if (skill.scope === 'project') 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; +} + +function toInstance(skill: Skill): SkillInventoryInstance { + return { + name: skill.name, + description: skill.description, + provider: skill.provider, + path: skill.path, + resolvedPath: skill.resolvedPath, + version: skill.version, + enabled: skill.enabled, + source: skill.source, + actions: actionsFor(skill), + healthSignals: healthSignalsFor(skill), + }; +} + +export function buildSkillInventory(skills: Skill[]): 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(toInstance); + 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/skill.ts b/packages/core/src/models/skill.ts index a11b9d8..34c8cbd 100644 --- a/packages/core/src/models/skill.ts +++ b/packages/core/src/models/skill.ts @@ -18,6 +18,13 @@ export interface SkillSource { }; } +export type SkillScanIssueCode = 'invalid-skill-md' | 'broken-symlink'; + +export interface SkillScanIssue { + code: SkillScanIssueCode; + message: string; +} + export interface Skill { name: string; description: string; @@ -29,6 +36,7 @@ export interface Skill { scope: 'global' | 'project'; metadata: SkillMetadata; source?: SkillSource; + scanIssues?: SkillScanIssue[]; } export interface SkillTemplate { diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index f1d47a0..144fa24 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -39,18 +39,42 @@ export abstract class BaseProvider implements ISkillProvider { 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,7 +90,25 @@ 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; diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts new file mode 100644 index 0000000..61f22f3 --- /dev/null +++ b/packages/core/tests/inventory.test.ts @@ -0,0 +1,139 @@ +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 { CodexProvider } from '../src/providers/codex.js'; +import { CursorProvider } from '../src/providers/cursor.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 cursorDir = path.join(root, 'cursor'); + await writeSkill(canonicalSkill, 'figma'); + await mkdir(codexDir); + await mkdir(cursorDir); + await symlink(canonicalSkill, path.join(codexDir, 'figma')); + await symlink(canonicalSkill, path.join(cursorDir, 'figma')); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + manager.registerProvider(new CursorProvider([cursorDir])); + + 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', 'cursor']); + }); + + it('groups provider instances by normalized name as inferred when provenance is unavailable', async () => { + const codexDir = path.join(root, 'codex'); + const cursorDir = path.join(root, 'cursor'); + await writeSkill(path.join(codexDir, 'figma-tool'), 'figma-tool'); + await writeSkill(path.join(cursorDir, 'figma_tool'), 'Figma Tool'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + manager.registerProvider(new CursorProvider([cursorDir])); + + 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(['disable']); + expect(global.healthSignals.map((signal) => signal.code)).toContain('unmanaged-global-skill'); + }); +}); From c981e5564b070423526c335614167e488a2f21c0 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:45:21 +0800 Subject: [PATCH 05/21] feat(tui): add read-only project skills view --- packages/core/src/manager.ts | 56 +++++++--- packages/core/tests/inventory.test.ts | 18 +++ packages/tui/src/app.tsx | 2 + packages/tui/src/components/status-bar.tsx | 6 + packages/tui/src/context/app-context.tsx | 9 +- packages/tui/src/hooks/use-skills.ts | 7 +- packages/tui/src/views/list-view.tsx | 12 +- .../tui/src/views/project-skills-view.tsx | 104 ++++++++++++++++++ 8 files changed, 184 insertions(+), 30 deletions(-) create mode 100644 packages/tui/src/views/project-skills-view.tsx diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 460d743..6b9721e 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -127,22 +127,41 @@ 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; @@ -161,7 +180,10 @@ export class SkillManager { enabled: skill.enabled, source: skill.source, actions: [], - healthSignals: [], + healthSignals: (skill.scanIssues ?? []).map((issue) => ({ + code: issue.code, + message: issue.message, + })), })); } getSkillsByProvider(providerId: string): Skill[] { return this.skills.filter((s) => s.provider === providerId); } diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index 61f22f3..a6b79c8 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -136,4 +136,22 @@ describe('Skill Inventory', () => { expect(global.instances[0].actions).toEqual(['disable']); expect(global.healthSignals.map((signal) => signal.code)).toContain('unmanaged-global-skill'); }); + + 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/tui/src/app.tsx b/packages/tui/src/app.tsx index 84b9714..1cb96ee 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ 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 { useTerminalSize } from './hooks/use-terminal-size.js'; function Router() { @@ -16,6 +17,7 @@ function Router() { case 'detail': return ; case 'install': return ; case 'create': return ; + case 'project': return ; } } diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index 35524c0..30ae292 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -13,6 +13,7 @@ const SHORTCUTS: Record = { { key: 'enter', label: 'detail' }, { key: '/', label: 'search' }, { key: 'tab', label: 'tabs' }, + { key: 'p', label: 'project' }, { key: 'i', label: 'install' }, { key: 'c', label: 'create' }, { key: 'q', label: 'quit' }, @@ -26,6 +27,11 @@ const SHORTCUTS: Record = { { key: 'esc', label: 'back' }, { key: 'enter', label: 'confirm' }, ], + project: [ + { key: 'esc', label: 'back' }, + { key: '↑↓', label: 'navigate' }, + { key: 'q', label: 'quit' }, + ], }; const DETAIL_BASE: Shortcut[] = [ diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 0e9f36b..5093d54 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,12 +1,13 @@ 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 } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'create'; +export type ViewType = 'list' | 'detail' | 'install' | 'create' | 'project'; interface AppState { manager: SkillManager; config: SkillpackConfig; skills: Skill[]; + projectSkills: SkillInventoryInstance[]; duplicates: DuplicateInfo[]; activeTab: string; view: ViewType; @@ -40,6 +41,7 @@ interface AppProviderProps { export function AppProvider({ manager, config, children }: AppProviderProps) { const [skills, setSkills] = useState(manager.getAllSkills()); + const [projectSkills, setProjectSkills] = useState(manager.getProjectSkills()); const [duplicates, setDuplicates] = useState(manager.getDuplicates()); const [activeTab, setActiveTab] = useState('All'); const [view, setView] = useState('list'); @@ -52,6 +54,7 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { await manager.scanAll(process.cwd(), config.projectSkillsDirs); const newSkills = manager.getAllSkills(); setSkills(newSkills); + setProjectSkills(manager.getProjectSkills()); setDuplicates(manager.getDuplicates()); setSelectedSkill((prev) => { if (!prev) return null; @@ -62,7 +65,7 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { return ( {children} diff --git a/packages/tui/src/hooks/use-skills.ts b/packages/tui/src/hooks/use-skills.ts index 199b939..44bddf5 100644 --- a/packages/tui/src/hooks/use-skills.ts +++ b/packages/tui/src/hooks/use-skills.ts @@ -9,7 +9,6 @@ const TAB_PROVIDER_MAP: Record = { Cursor: 'cursor', Claude: 'claude', Global: 'global', - Project: 'project', }; export const TABS = Object.keys(TAB_PROVIDER_MAP); @@ -19,9 +18,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/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index bf63ac5..b44c7f1 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -55,16 +55,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', }; - counts[tab] = allSkills.filter((s) => s.provider === providerMap[tab]).length; + counts[tab] = inventorySkills.filter((s) => s.provider === providerMap[tab]).length; } } return counts; @@ -85,6 +84,7 @@ export function ListView() { return; } if (input === '/') { setSearching(true); return; } + if (input === 'p') { setView('project'); return; } if (input === 'i') { setView('install'); return; } if (input === 'c') { setView('create'); return; } @@ -181,8 +181,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..5db97a8 --- /dev/null +++ b/packages/tui/src/views/project-skills-view.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import path from 'node:path'; +import { useAppContext } from '../context/app-context.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { StatusBar } from '../components/status-bar.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 relativePath = path.relative(process.cwd(), skill.path) || skill.path; + const hasIssues = skill.healthSignals.length > 0; + return ( + + + {selected ? '❯' : ' '} + + + {truncate(skill.name, NAME_WIDTH)} + + {truncate(relativePath, PATH_WIDTH)} + + {hasIssues ? 'needs attention' : 'read-only'} + + + ); + }) + )} + + + + + + ); +} From 797b8d30e591855c298753b068b643e2ff33be1a Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:48:36 +0800 Subject: [PATCH 06/21] refactor: remove skill authoring flows --- packages/core/src/manager.ts | 33 +---- packages/core/tests/manager.test.ts | 7 - packages/tui/src/app.tsx | 2 - packages/tui/src/components/status-bar.tsx | 8 +- packages/tui/src/context/app-context.tsx | 2 +- packages/tui/src/hooks/use-skill-manager.ts | 3 +- packages/tui/src/views/create-view.tsx | 152 -------------------- packages/tui/src/views/detail-view.tsx | 12 +- packages/tui/src/views/install-view.tsx | 6 +- packages/tui/src/views/list-view.tsx | 1 - 10 files changed, 8 insertions(+), 218 deletions(-) delete mode 100644 packages/tui/src/views/create-view.tsx diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 6b9721e..8dfc11a 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -1,9 +1,9 @@ -import { cp, readdir, readFile, access, rm } from 'node:fs/promises'; +import { readdir, readFile, access, rm } 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 { 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'; @@ -190,13 +190,6 @@ export class SkillManager { 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}`); @@ -243,28 +236,6 @@ 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`); - } - 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}`); diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index b6e2fe5..3831da9 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -40,11 +40,4 @@ describe('SkillManager', () => { expect(manager.getDuplicates()[0].skillName).toBe('same-skill'); 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'); - await manager.scanAll(); - expect(manager.getAllSkills()).toHaveLength(1); - }); }); diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1cb96ee..ac079ee 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -5,7 +5,6 @@ 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 { useTerminalSize } from './hooks/use-terminal-size.js'; @@ -16,7 +15,6 @@ function Router() { case 'list': return ; case 'detail': return ; case 'install': return ; - case 'create': return ; case 'project': return ; } } diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index 30ae292..dca4186 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -15,7 +15,6 @@ const SHORTCUTS: Record = { { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, { key: 'i', label: 'install' }, - { key: 'c', label: 'create' }, { key: 'q', label: 'quit' }, ], install: [ @@ -23,10 +22,6 @@ const SHORTCUTS: Record = { { key: '↑↓', label: 'navigate' }, { key: 'enter', label: 'select' }, ], - create: [ - { key: 'esc', label: 'back' }, - { key: 'enter', label: 'confirm' }, - ], project: [ { key: 'esc', label: 'back' }, { key: '↑↓', label: 'navigate' }, @@ -40,7 +35,6 @@ const DETAIL_BASE: Shortcut[] = [ ]; const DETAIL_TAIL: Shortcut[] = [ - { key: 'e', label: 'edit' }, { key: 'o', label: 'open folder' }, { key: 'd', label: 'delete' }, ]; @@ -51,7 +45,7 @@ export function StatusBar() { let shortcuts: Shortcut[]; if (view === 'detail') { const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh' || sourceType === 'github'; + const isUpdatable = sourceType === 'skillssh'; shortcuts = isUpdatable ? [...DETAIL_BASE, { key: 'u', label: 'check update' }, ...DETAIL_TAIL] : [...DETAIL_BASE, ...DETAIL_TAIL]; diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 5093d54..25a8176 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'create' | 'project'; +export type ViewType = 'list' | 'detail' | 'install' | 'project'; interface AppState { manager: SkillManager; diff --git a/packages/tui/src/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index 6c33451..24f32bd 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { SkillManager, ConfigManager, CodexProvider, CursorProvider, ClaudeProvider, GlobalProvider, - GitHubSource, SkillsShSource, + SkillsShSource, type SkillpackConfig, } from '@skillpack/core'; @@ -47,7 +47,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/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..ac16015 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -2,7 +2,6 @@ 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'; @@ -36,7 +35,7 @@ export function DetailView() { const [updating, setUpdating] = useState(false); const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh' || sourceType === 'github'; + const isUpdatable = sourceType === 'skillssh'; const duplicate = selectedSkill ? manager.getDuplicates().find((d) => d.skillName === selectedSkill.name) @@ -70,15 +69,6 @@ export function DetailView() { 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 { diff --git a/packages/tui/src/views/install-view.tsx b/packages/tui/src/views/install-view.tsx index ae73e87..25fd83b 100644 --- a/packages/tui/src/views/install-view.tsx +++ b/packages/tui/src/views/install-view.tsx @@ -131,16 +131,14 @@ export function InstallView() { {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 b44c7f1..48f0839 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -86,7 +86,6 @@ export function ListView() { if (input === '/') { setSearching(true); return; } if (input === 'p') { setView('project'); return; } if (input === 'i') { setView('install'); return; } - if (input === 'c') { setView('create'); return; } if (input === ' ' && skills[cursor]) { manager.toggleSkill(skills[cursor]).then(() => refresh()).catch(() => {}); From 81896ec11238e4ae91c4e2f826121fd8239ac8c4 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:52:38 +0800 Subject: [PATCH 07/21] feat(core): route toggles by provider instance --- packages/core/src/manager.ts | 20 +++++++++---- packages/core/src/models/inventory.ts | 17 ++++++++++-- packages/core/src/providers/provider.ts | 32 ++++++++++++++++++++- packages/core/tests/inventory.test.ts | 37 +++++++++++++++++++++++++ packages/tui/src/views/detail-view.tsx | 12 +++++++- 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 8dfc11a..45681dd 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -168,7 +168,11 @@ export class SkillManager { } getAllSkills(): Skill[] { return this.skills; } - getInventory(): SkillGroup[] { return buildSkillInventory(this.skills); } + 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, @@ -196,12 +200,16 @@ export class SkillManager { 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); + await provider.setEnabled(skill, !skill.enabled); + } + + 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`); } + await provider.setEnabled(instance, !instance.enabled); } async uninstallSkill(skill: Skill): Promise { diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index 72257bc..298c55e 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -17,6 +17,11 @@ export interface HealthSignal { export type SkillAction = 'enable' | 'disable' | 'update' | 'remove'; +export interface DisableStrategy { + type: 'disabled-directory' | 'provider-config'; + description: string; +} + export interface SkillInventoryInstance { name: string; description: string; @@ -26,6 +31,7 @@ export interface SkillInventoryInstance { version?: string; enabled: boolean; source?: SkillSource; + disableStrategy?: DisableStrategy; actions: SkillAction[]; healthSignals: HealthSignal[]; } @@ -91,7 +97,11 @@ function healthSignalsFor(skill: Skill): HealthSignal[] { return signals; } -function toInstance(skill: Skill): SkillInventoryInstance { +export interface BuildSkillInventoryOptions { + getDisableStrategy?: (skill: Skill) => DisableStrategy | undefined; +} + +function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInventoryInstance { return { name: skill.name, description: skill.description, @@ -101,12 +111,13 @@ function toInstance(skill: Skill): SkillInventoryInstance { version: skill.version, enabled: skill.enabled, source: skill.source, + disableStrategy: options.getDisableStrategy?.(skill), actions: actionsFor(skill), healthSignals: healthSignalsFor(skill), }; } -export function buildSkillInventory(skills: Skill[]): SkillGroup[] { +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) { @@ -140,7 +151,7 @@ export function buildSkillInventory(skills: Skill[]): SkillGroup[] { } return [...groups.entries()].map(([id, group]) => { - const instances = group.skills.map(toInstance); + const instances = group.skills.map((skill) => toInstance(skill, options)); const healthSignals = instances.flatMap((instance) => instance.healthSignals); if (instances.length > 1) { healthSignals.push({ diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index 144fa24..37c8d6e 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, 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,8 @@ 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; create(template: SkillTemplate): Promise; } @@ -117,6 +121,32 @@ export abstract class BaseProvider implements ISkillProvider { 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', + }; + } + + 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/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index a6b79c8..4984cbc 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -137,6 +137,43 @@ describe('Skill Inventory', () => { expect(global.healthSignals.map((signal) => signal.code)).toContain('unmanaged-global-skill'); }); + it('exposes the provider Disable Strategy for mutable inventory instances', async () => { + const codexDir = path.join(root, 'codex'); + await writeSkill(path.join(codexDir, 'toggle-me'), 'toggle-me'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([codexDir])); + + await manager.scanAll(); + + const instance = manager.getInventory()[0].instances[0]; + expect(instance.disableStrategy).toEqual({ + type: 'disabled-directory', + description: 'Renames the skill directory with a .disabled- prefix', + }); + }); + + it('toggles the selected inventory instance path without changing same-name siblings', async () => { + const firstCodexDir = path.join(root, 'codex-one'); + const secondCodexDir = path.join(root, 'codex-two'); + await writeSkill(path.join(firstCodexDir, 'shared'), 'shared'); + await writeSkill(path.join(secondCodexDir, 'shared'), 'shared'); + + const manager = new SkillManager(); + manager.registerProvider(new CodexProvider([firstCodexDir, secondCodexDir])); + + 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.includes(`${path.sep}.disabled-shared`))?.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'); diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index ac16015..7a7c9fe 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -36,6 +36,9 @@ export function DetailView() { const sourceType = selectedSkill?.source?.type; const isUpdatable = sourceType === 'skillssh'; + const disableStrategy = selectedSkill + ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) + : undefined; const duplicate = selectedSkill ? manager.getDuplicates().find((d) => d.skillName === selectedSkill.name) @@ -56,6 +59,7 @@ export function DetailView() { used += 3; // agent, path, status if (selectedSkill.version) used += 1; if (selectedSkill.source) used += 1; + 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 @@ -65,7 +69,7 @@ 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; } @@ -184,6 +188,12 @@ export function DetailView() { )} )} + {disableStrategy && ( + + {'toggle'.padEnd(10)} + {disableStrategy.description} + + )} {isUpdatable && ( {'update'.padEnd(10)} From a7029b529fd65b36d1c0b650aba960c868715cd6 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:55:23 +0800 Subject: [PATCH 08/21] feat(tui): install skills through skillssh only --- packages/core/src/sources/skillssh.ts | 18 +++++--- packages/core/tests/sources/skillssh.test.ts | 20 +++++++++ packages/tui/src/views/install-view.tsx | 45 +++----------------- 3 files changed, 39 insertions(+), 44 deletions(-) create mode 100644 packages/core/tests/sources/skillssh.test.ts 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/sources/skillssh.test.ts b/packages/core/tests/sources/skillssh.test.ts new file mode 100644 index 0000000..0e3a425 --- /dev/null +++ b/packages/core/tests/sources/skillssh.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { SkillsShSource } from '../../src/sources/skillssh.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'], + }]); + }); +}); diff --git a/packages/tui/src/views/install-view.tsx b/packages/tui/src/views/install-view.tsx index 25fd83b..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,24 +96,6 @@ export function InstallView() { )} - {step === 'source' && ( - - Where to search? - - {sources.map((source, i) => ( - - - {i === cursor ? '❯' : ' '} - - - {source.displayName} - - - ))} - - - )} - {step === 'query' && ( Search skills.sh From ce4100413e131012db928394094e04b75e697062 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 15:57:48 +0800 Subject: [PATCH 09/21] feat(tui): add manual skillssh updates view --- packages/core/tests/sources/skillssh.test.ts | 43 ++++++ packages/tui/src/app.tsx | 2 + packages/tui/src/components/status-bar.tsx | 8 ++ packages/tui/src/context/app-context.tsx | 2 +- packages/tui/src/views/list-view.tsx | 1 + packages/tui/src/views/updates-view.tsx | 137 +++++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/views/updates-view.tsx diff --git a/packages/core/tests/sources/skillssh.test.ts b/packages/core/tests/sources/skillssh.test.ts index 0e3a425..a12a9ff 100644 --- a/packages/core/tests/sources/skillssh.test.ts +++ b/packages/core/tests/sources/skillssh.test.ts @@ -1,5 +1,6 @@ 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 () => { @@ -17,4 +18,46 @@ describe('SkillsShSource', () => { 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/src/app.tsx b/packages/tui/src/app.tsx index ac079ee..c4c76a1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ import { ListView } from './views/list-view.js'; import { DetailView } from './views/detail-view.js'; import { InstallView } from './views/install-view.js'; import { ProjectSkillsView } from './views/project-skills-view.js'; +import { UpdatesView } from './views/updates-view.js'; import { useTerminalSize } from './hooks/use-terminal-size.js'; function Router() { @@ -16,6 +17,7 @@ function Router() { case 'detail': return ; case 'install': return ; case 'project': return ; + case 'updates': return ; } } diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index dca4186..f0bec76 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -14,6 +14,7 @@ const SHORTCUTS: Record = { { key: '/', label: 'search' }, { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, + { key: 'u', label: 'updates' }, { key: 'i', label: 'install' }, { key: 'q', label: 'quit' }, ], @@ -27,6 +28,13 @@ const SHORTCUTS: Record = { { key: '↑↓', label: 'navigate' }, { 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[] = [ diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 25a8176..fa00ba5 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'project'; +export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'updates'; interface AppState { manager: SkillManager; diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 48f0839..b12bfa9 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -85,6 +85,7 @@ export function ListView() { } if (input === '/') { setSearching(true); return; } if (input === 'p') { setView('project'); return; } + if (input === 'u') { setView('updates'); return; } if (input === 'i') { setView('install'); return; } if (input === ' ' && skills[cursor]) { 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'} + + ); + }) + )} + + + )} + + + + + ); +} From 3d11ea14fce2d4f92386570939e4d4cc20e3a04f Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 16:00:51 +0800 Subject: [PATCH 10/21] fix(core): restrict removal to skillssh globals --- packages/core/src/manager.ts | 25 +++----------- packages/core/tests/manager.test.ts | 39 +++++++++++++++++++++- packages/tui/src/components/status-bar.tsx | 7 ++-- packages/tui/src/views/detail-view.tsx | 3 +- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 45681dd..f555d94 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -1,4 +1,4 @@ -import { 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'; @@ -213,29 +213,14 @@ export class SkillManager { } 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 { diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index 3831da9..2b35efb 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -1,9 +1,11 @@ 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 { SkillsShSource } from '../src/sources/skillssh.js'; +import type { Skill } from '../src/models/index.js'; describe('SkillManager', () => { let dir: string; @@ -40,4 +42,39 @@ describe('SkillManager', () => { expect(manager.getDuplicates()[0].skillName).toBe('same-skill'); await rm(dir2, { recursive: true, force: true }); }); + + 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(); + + 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'], + }]); + }); }); diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index f0bec76..debb453 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -44,7 +44,6 @@ const DETAIL_BASE: Shortcut[] = [ const DETAIL_TAIL: Shortcut[] = [ { key: 'o', label: 'open folder' }, - { key: 'd', label: 'delete' }, ]; export function StatusBar() { @@ -54,9 +53,11 @@ export function StatusBar() { if (view === 'detail') { const sourceType = selectedSkill?.source?.type; const isUpdatable = sourceType === 'skillssh'; + const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + 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]; + ? [...DETAIL_BASE, { key: 'u', label: 'check update' }, ...tail] + : [...DETAIL_BASE, ...tail]; } else { shortcuts = SHORTCUTS[view] ?? []; } diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 7a7c9fe..55f1e6d 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -36,6 +36,7 @@ export function DetailView() { const sourceType = selectedSkill?.source?.type; const isUpdatable = sourceType === 'skillssh'; + const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const disableStrategy = selectedSkill ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) : undefined; @@ -89,7 +90,7 @@ export function DetailView() { .finally(() => setBusy(false)); return; } - if (input === 'd' && selectedSkill) { + if (input === 'd' && selectedSkill && isRemovable) { setError(null); setConfirming(true); } From 446544c8c943f8e2fd7a321b5db8aab63f2bd100 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 16:08:35 +0800 Subject: [PATCH 11/21] feat(core): expose configurable scan roots --- packages/core/src/config.ts | 102 ++++++++++++++---- packages/core/src/index.ts | 2 +- packages/core/src/manager.ts | 48 ++++++++- packages/core/src/models/index.ts | 1 + packages/core/src/models/scan-path.ts | 17 +++ packages/core/src/providers/claude.ts | 17 ++- packages/core/src/providers/provider.ts | 11 +- packages/core/tests/config.test.ts | 63 +++++++++++ packages/core/tests/manager.test.ts | 24 +++++ packages/tui/src/context/app-context.tsx | 7 +- packages/tui/src/views/list-view.tsx | 27 ++++- .../tui/src/views/project-skills-view.tsx | 25 ++++- 12 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/models/scan-path.ts create mode 100644 packages/core/tests/config.test.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c8e9c19..f378e26 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -19,37 +19,56 @@ 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', '.cursor/skills-cursor', '.claude/skills', '.agents/skills'], + providers: { + codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills')] }, + cursor: { enabled: true, paths: [path.join(homeDir, '.cursor', 'skills-cursor')] }, + 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: { + github: { enabled: true }, + 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 +97,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 6003a2c..26e34c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export * from './models/index.js'; export { parseSkillMd, generateSkillMd } from './parser.js'; -export { ConfigManager, type SkillpackConfig } from './config.js'; +export { ConfigManager, createDefaultConfig, type ConfigManagerOptions, type SkillpackConfig } from './config.js'; export { LockfileManager, type LockEntry } from './lockfile.js'; export { SkillsLockReader, type SkillsLockEntry } from './skills-lock.js'; export * from './providers/index.js'; diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index f555d94..cc36e66 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -3,7 +3,7 @@ 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 } 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'; @@ -18,6 +18,7 @@ export class SkillManager { private sources = new Map(); private skills: Skill[] = []; private projectSkills: Skill[] = []; + private scanPathDiagnostics: ScanPathDiagnostic[] = []; private duplicates: DuplicateInfo[] = []; private duplicateDetector = new DuplicateDetector(); private globalLock?: LockfileManager; @@ -40,6 +41,7 @@ export class SkillManager { } 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(); @@ -117,7 +119,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) { @@ -168,6 +170,7 @@ export class SkillManager { } 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), @@ -307,4 +310,45 @@ export class SkillManager { await this.uninstallSkill(skill); await this.installFromSource('github', identifier, skill.provider); } + + 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 76e45b5..934f043 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -2,3 +2,4 @@ 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/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/providers/claude.ts b/packages/core/src/providers/claude.ts index f44403c..43040fb 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -1,5 +1,5 @@ import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill } from '../models/index.js'; +import type { ProviderScanPath, Skill } from '../models/index.js'; import { readdir, readFile, access, rename, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -34,6 +34,21 @@ export class ClaudeProvider extends BaseProvider { return [...flatSkills, ...deepSkills]; } + 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', + })), + ]; + } + private async scanFlat(): Promise { const skills: Skill[] = []; for (const basePath of this.flatPaths) { diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index 37c8d6e..3562392 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -1,4 +1,4 @@ -import type { DisableStrategy, 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'; @@ -28,6 +28,7 @@ export interface ISkillProvider { disable(name: string): Promise; setEnabled(skill: SkillToggleTarget, enabled: boolean): Promise; getDisableStrategy(skill: SkillToggleTarget): DisableStrategy | undefined; + getScanPaths(): ProviderScanPath[]; create(template: SkillTemplate): Promise; } @@ -129,6 +130,14 @@ export abstract class BaseProvider implements ISkillProvider { }; } + 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`); diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts new file mode 100644 index 0000000..1c3fca3 --- /dev/null +++ b/packages/core/tests/config.test.ts @@ -0,0 +1,63 @@ +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(config.providers.cursor.paths[0]).toContain(path.join('.cursor', 'skills-cursor')); + 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.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, '.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')], + }); + expect(config.providers.global).toEqual({ + enabled: true, + paths: [path.join(homeDir, '.agents', 'skills')], + }); + expect(config.providers.cursor.enabled).toBe(false); + expect(config.providers.claude.enabled).toBe(false); + expect(config.projectSkillsDirs).toEqual(['.codex/skills', '.cursor/skills-cursor', '.claude/skills', '.agents/skills']); + }); +}); diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index 2b35efb..db6582f 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -77,4 +77,28 @@ describe('SkillManager', () => { args: ['skills', 'remove', 'managed-skill', '-g', '-y'], }]); }); + + 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/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index fa00ba5..39f8aa1 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig } from '@skillpack/core'; +import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig, ScanPathDiagnostic } from '@skillpack/core'; export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'updates'; @@ -8,6 +8,7 @@ interface AppState { config: SkillpackConfig; skills: Skill[]; projectSkills: SkillInventoryInstance[]; + scanPaths: ScanPathDiagnostic[]; duplicates: DuplicateInfo[]; activeTab: string; view: ViewType; @@ -42,6 +43,7 @@ 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'); @@ -55,6 +57,7 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { const newSkills = manager.getAllSkills(); setSkills(newSkills); setProjectSkills(manager.getProjectSkills()); + setScanPaths(manager.getScanPathDiagnostics()); setDuplicates(manager.getDuplicates()); setSelectedSkill((prev) => { if (!prev) return null; @@ -65,7 +68,7 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { return ( {children} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index b12bfa9..a25697a 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; +import path from 'node:path'; import { useAppContext } from '../context/app-context.js'; import { useFilteredSkills, TABS } from '../hooks/use-skills.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; @@ -9,13 +10,23 @@ import { SkillRow, COL_NAME_WIDTH, COL_AGENT_WIDTH } from '../components/skill-r import { SearchInput } from '../components/search-input.js'; import { StatusBar } from '../components/status-bar.js'; -const CHROME_LINES = 7; +const CHROME_LINES = 8; + +function formatScanPath(scanPath: { provider?: string; path: string; exists: boolean }): string { + const relativePath = path.relative(process.cwd(), scanPath.path); + const homeDir = process.env.HOME; + const homePath = homeDir && scanPath.path.startsWith(`${homeDir}${path.sep}`) + ? scanPath.path.replace(homeDir, '~') + : scanPath.path; + const displayPath = relativePath && !relativePath.startsWith('..') ? relativePath : homePath; + return `${scanPath.provider ?? 'project'}:${displayPath}${scanPath.exists ? '' : ' (missing)'}`; +} export function ListView() { const { exit } = useApp(); const { loading, activeTab, setActiveTab, setView, setSelectedSkill, - searchQuery, setSearchQuery, refresh, manager, skills: allSkills, + searchQuery, setSearchQuery, refresh, manager, skills: allSkills, scanPaths, } = useAppContext(); const { skills, tabs } = useFilteredSkills(); const { rows } = useTerminalSize(); @@ -69,6 +80,11 @@ export function ListView() { return counts; }, [allSkills]); + const providerRoots = useMemo( + () => scanPaths.filter((scanPath) => scanPath.scope === 'provider').map(formatScanPath).join(' '), + [scanPaths], + ); + useInput((input, key) => { if (input === 'q') { exit(); return; } if (key.escape && searchQuery) { @@ -132,6 +148,13 @@ export function ListView() { + {providerRoots && ( + + roots + {providerRoots} + + )} + {/* Search input (active) */} {searching && ( diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index 5db97a8..59b5c76 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -5,7 +5,7 @@ import { useAppContext } from '../context/app-context.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { StatusBar } from '../components/status-bar.js'; -const CHROME_LINES = 5; +const CHROME_LINES = 6; const NAME_WIDTH = 30; const PATH_WIDTH = 54; @@ -14,9 +14,19 @@ function truncate(value: string, max: number): string { return value.slice(0, max - 1) + '…'; } +function formatScanPath(scanPath: { path: string; exists: boolean }): string { + const relativePath = path.relative(process.cwd(), scanPath.path); + const homeDir = process.env.HOME; + const homePath = homeDir && scanPath.path.startsWith(`${homeDir}${path.sep}`) + ? scanPath.path.replace(homeDir, '~') + : scanPath.path; + const displayPath = relativePath && !relativePath.startsWith('..') ? relativePath : homePath; + return `${displayPath}${scanPath.exists ? '' : ' (missing)'}`; +} + export function ProjectSkillsView() { const { exit } = useApp(); - const { projectSkills, setView } = useAppContext(); + const { projectSkills, scanPaths, setView } = useAppContext(); const { rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); @@ -46,6 +56,10 @@ export function ProjectSkillsView() { () => projectSkills.slice(scrollOffset, scrollOffset + visibleRows), [projectSkills, scrollOffset, visibleRows], ); + const projectRoots = useMemo( + () => scanPaths.filter((scanPath) => scanPath.scope === 'project').map(formatScanPath).join(' '), + [scanPaths], + ); const showScroll = projectSkills.length > visibleRows; @@ -60,6 +74,13 @@ export function ProjectSkillsView() { )} + {projectRoots && ( + + roots + {projectRoots} + + )} + {' '} From 424e380f40cf6da8fbd04e73bb48a32aac3b5750 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 17:07:21 +0800 Subject: [PATCH 12/21] feat(core): use provider-native skill availability --- packages/core/src/config.ts | 4 +- packages/core/src/providers/claude.ts | 141 +++++++++++++++++- packages/core/src/providers/codex.ts | 40 ++++- packages/core/src/providers/cursor.ts | 16 -- packages/core/src/providers/index.ts | 1 - .../core/src/providers/provider-settings.ts | 127 ++++++++++++++++ packages/core/tests/config.test.ts | 6 +- packages/core/tests/duplicates.test.ts | 6 +- packages/core/tests/inventory.test.ts | 31 ++-- packages/core/tests/providers/claude.test.ts | 93 ++++++++++++ packages/core/tests/providers/codex.test.ts | 42 ++++-- packages/tui/package.json | 3 +- packages/tui/src/hooks/use-skill-manager.ts | 5 +- packages/tui/src/hooks/use-skills.ts | 1 - packages/tui/src/views/list-view.tsx | 2 +- 15 files changed, 449 insertions(+), 69 deletions(-) delete mode 100644 packages/core/src/providers/cursor.ts create mode 100644 packages/core/src/providers/provider-settings.ts create mode 100644 packages/core/tests/providers/claude.test.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f378e26..ce92ce2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -33,15 +33,13 @@ export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { return { editor: process.env.EDITOR || 'vi', autoCheckUpdates: true, - projectSkillsDirs: ['.codex/skills', '.cursor/skills-cursor', '.claude/skills', '.agents/skills'], + projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], providers: { codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills')] }, - cursor: { enabled: true, paths: [path.join(homeDir, '.cursor', 'skills-cursor')] }, 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: { - github: { enabled: true }, skillssh: { enabled: true }, }, }; diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index 43040fb..574e72a 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 { ProviderScanPath, 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,16 +22,19 @@ 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]; } @@ -49,8 +53,49 @@ export class ClaudeProvider extends BaseProvider { ]; } - private async scanFlat(): Promise { + 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, flatSkillName, 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 }); @@ -70,6 +115,7 @@ export class ClaudeProvider extends BaseProvider { const parsed = parseSkillMd(content); const resolved = await realpath(skillDir); const dirStat = await stat(resolved); + const override = skillOverrides[skillDirName]; skills.push({ name: parsed.name || skillDirName, description: parsed.description, @@ -77,7 +123,7 @@ export class ClaudeProvider extends BaseProvider { 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() }, @@ -88,8 +134,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 }); @@ -100,6 +147,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; @@ -123,7 +171,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() }, }); @@ -136,7 +184,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); @@ -190,6 +266,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}`); @@ -230,3 +312,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..869fa50 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -2,6 +2,7 @@ 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 { readCodexSkillConfigEnabled, writeCodexSkillConfigEnabled } from './provider-settings.js'; import { mkdir, writeFile, rm, cp } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -10,13 +11,50 @@ export class CodexProvider extends BaseProvider { readonly id = 'codex'; readonly displayName = 'Codex'; readonly basePaths: 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.configPath = configPath ?? path.join(os.homedir(), '.codex', 'config.toml'); + } + + override async scan(): Promise { + const skills = await super.scan(); + return Promise.all(skills.map(async (skill) => { + const configEnabled = await readCodexSkillConfigEnabled(this.configPath, path.join(skill.path, 'SKILL.md')); + return { + ...skill, + enabled: skill.enabled && configEnabled !== false, + }; + })); + } + + override getDisableStrategy(): { type: 'provider-config'; description: string } { + return { + type: 'provider-config', + description: 'Writes [[skills.config]] in Codex config.toml', + }; + } + + override async setEnabled(skill: Pick, enabled: boolean): Promise { + 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 { 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/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..0b66fd8 --- /dev/null +++ b/packages/core/src/providers/provider-settings.ts @@ -0,0 +1,127 @@ +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; +} + +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()) { + if (line.trim() === '[[skills.config]]') { + if (current) { + current.endLine = index; + entries.push(current); + } + current = { startLine: index, endLine: lines.length }; + 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; +} + +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 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'); +} diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 1c3fca3..a6639e9 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -30,7 +30,7 @@ describe('ConfigManager', () => { expect(config.providers.codex.paths).toEqual([customCodexPath]); expect(config.providers.codex.enabled).toBe(true); - expect(config.providers.cursor.paths[0]).toContain(path.join('.cursor', 'skills-cursor')); + 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')), @@ -38,6 +38,7 @@ describe('ConfigManager', () => { ]), ); expect(config.providers.global.paths[0]).toContain(path.join('.agents', 'skills')); + expect(config.sources.github).toBeUndefined(); expect(config.sources.skillssh.enabled).toBe(true); }); @@ -56,8 +57,7 @@ describe('ConfigManager', () => { enabled: true, paths: [path.join(homeDir, '.agents', 'skills')], }); - expect(config.providers.cursor.enabled).toBe(false); expect(config.providers.claude.enabled).toBe(false); - expect(config.projectSkillsDirs).toEqual(['.codex/skills', '.cursor/skills-cursor', '.claude/skills', '.agents/skills']); + 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 index 4984cbc..9165d92 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { SkillManager } from '../src/manager.js'; import { CodexProvider } from '../src/providers/codex.js'; -import { CursorProvider } from '../src/providers/cursor.js'; import { GlobalProvider } from '../src/providers/global.js'; async function writeSkill(dir: string, name: string, description = 'Test skill'): Promise { @@ -26,16 +25,16 @@ describe('Skill Inventory', () => { 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 cursorDir = path.join(root, 'cursor'); + const globalDir = path.join(root, 'global'); await writeSkill(canonicalSkill, 'figma'); await mkdir(codexDir); - await mkdir(cursorDir); + await mkdir(globalDir); await symlink(canonicalSkill, path.join(codexDir, 'figma')); - await symlink(canonicalSkill, path.join(cursorDir, 'figma')); + await symlink(canonicalSkill, path.join(globalDir, 'figma')); const manager = new SkillManager(); manager.registerProvider(new CodexProvider([codexDir])); - manager.registerProvider(new CursorProvider([cursorDir])); + manager.registerProvider(new GlobalProvider([globalDir])); await manager.scanAll(); @@ -43,18 +42,18 @@ describe('Skill Inventory', () => { 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', 'cursor']); + 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 cursorDir = path.join(root, 'cursor'); + const globalDir = path.join(root, 'global'); await writeSkill(path.join(codexDir, 'figma-tool'), 'figma-tool'); - await writeSkill(path.join(cursorDir, '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 CursorProvider([cursorDir])); + manager.registerProvider(new GlobalProvider([globalDir])); await manager.scanAll(); @@ -139,28 +138,30 @@ describe('Skill Inventory', () => { 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])); + manager.registerProvider(new CodexProvider([codexDir], codexConfig)); await manager.scanAll(); const instance = manager.getInventory()[0].instances[0]; expect(instance.disableStrategy).toEqual({ - type: 'disabled-directory', - description: 'Renames the skill directory with a .disabled- prefix', + type: 'provider-config', + description: 'Writes [[skills.config]] in Codex config.toml', }); }); - it('toggles the selected inventory instance path without changing same-name siblings', async () => { + 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])); + manager.registerProvider(new CodexProvider([firstCodexDir, secondCodexDir], codexConfig)); await manager.scanAll(); const before = manager.getInventory()[0].instances; @@ -171,7 +172,7 @@ describe('Skill Inventory', () => { const after = manager.getInventory()[0].instances; expect(after.find((instance) => instance.path.startsWith(firstCodexDir))?.enabled).toBe(true); - expect(after.find((instance) => instance.path.includes(`${path.sep}.disabled-shared`))?.enabled).toBe(false); + expect(after.find((instance) => instance.path.startsWith(secondCodexDir))?.enabled).toBe(false); }); it('keeps invalid Project Skills visible as read-only project inventory', async () => { diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts new file mode 100644 index 0000000..38cfab5 --- /dev/null +++ b/packages/core/tests/providers/claude.test.ts @@ -0,0 +1,93 @@ +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('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('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..739c62c 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -1,16 +1,18 @@ 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 () => { @@ -48,15 +50,29 @@ describe('CodexProvider', () => { 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 writeFile(configPath, `[[skills.config]]\npath = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false\n`); - await provider.disable('my-skill'); + const skills = await provider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0].enabled).toBe(false); + }); + + it('disables a skill by writing Codex config without renaming the directory', 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'); + + const [skill] = await provider.scan(); + await provider.setEnabled(skill, false); - await expect(access(path.join(dir, 'my-skill'))).rejects.toThrow(); - await expect(access(path.join(dir, '.disabled-my-skill'))).resolves.toBeUndefined(); + 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); @@ -64,15 +80,17 @@ describe('CodexProvider', () => { 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('enables a disabled skill by writing Codex config', 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 writeFile(configPath, `[[skills.config]]\npath = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false\n`); - await provider.enable('my-skill'); + const [skill] = await provider.scan(); + await provider.setEnabled(skill, true); - await expect(access(path.join(dir, '.disabled-my-skill'))).rejects.toThrow(); 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); 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/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index 24f32bd..5df61fc 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { SkillManager, ConfigManager, - CodexProvider, CursorProvider, ClaudeProvider, GlobalProvider, + 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')); diff --git a/packages/tui/src/hooks/use-skills.ts b/packages/tui/src/hooks/use-skills.ts index 44bddf5..5ccea9f 100644 --- a/packages/tui/src/hooks/use-skills.ts +++ b/packages/tui/src/hooks/use-skills.ts @@ -6,7 +6,6 @@ import type { Skill } from '@skillpack/core'; const TAB_PROVIDER_MAP: Record = { All: null, Codex: 'codex', - Cursor: 'cursor', Claude: 'claude', Global: 'global', }; diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index a25697a..887d940 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -72,7 +72,7 @@ export function ListView() { counts[tab] = inventorySkills.length; } else { const providerMap: Record = { - Codex: 'codex', Cursor: 'cursor', Claude: 'claude', Global: 'global', + Codex: 'codex', Claude: 'claude', Global: 'global', }; counts[tab] = inventorySkills.filter((s) => s.provider === providerMap[tab]).length; } From b56fafaf8ceddcb6d848fc306b9b4001f42d4ad3 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 17:07:38 +0800 Subject: [PATCH 13/21] docs: clarify provider-native skill management --- AGENTS.md | 39 ++++++------- CONTEXT.md | 4 ++ README.md | 42 ++++++-------- .../0005-use-provider-native-management.md | 2 +- .../0010-restrict-provider-local-mutations.md | 2 +- ...se-provider-specific-disable-strategies.md | 6 +- ...to-detected-configurable-provider-paths.md | 2 +- docs/prds/provider-native-skillpack.md | 57 +++++++++---------- .../provider-native-skillpack-refactor.md | 28 +++++---- packages/tui/README.md | 25 ++++---- 10 files changed, 102 insertions(+), 105 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c2d328..36d03df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## 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 @@ -48,10 +48,10 @@ 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`) | @@ -65,16 +65,17 @@ 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/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' | 'updates'`) in `app-context.tsx`, not a router library. The `Router` component in `app.tsx` switches on this state. ## Conventions @@ -85,22 +86,18 @@ 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 uses `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path. 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 location has no known config or native disable mechanism. 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. @@ -134,7 +131,7 @@ Install is global-only (to `~/.agents/skills/`). The install view has 3 steps: s ### 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 @@ -153,4 +150,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 index d5b9b56..569cce1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,10 @@ _Avoid_: Skill Library, projection target 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 diff --git a/README.md b/README.md index de74e11..729fe3d 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, 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 provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a skills.sh Global Skill, or `u` to open manual updates. ## Keyboard Shortcuts @@ -54,12 +52,12 @@ Press `Space` to toggle a skill on or off, `Enter` to view its details, `i` to i | `↑` / `↓` | Navigate | Move selection up / down | | `Space` | Toggle | Enable or disable the selected skill | | `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 | +| `i` | Install | Install a Global Skill through skills.sh | +| `u` | Updates | Open manual skills.sh updates | | `q` | Quit | Exit skillpack | ### Detail View @@ -68,9 +66,8 @@ Press `Space` to toggle a skill on or off, `Enter` to view its details, `i` to i |-----|--------|-------------| | `Esc` | Back | Return to list view | | `Space` | Toggle | Enable or disable the skill | -| `e` / `E` | Edit | Open SKILL.md in `$EDITOR` | | `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,18 +80,15 @@ 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"] }, "claude": { "enabled": true, "paths": ["~/.claude/plugins/cache", "~/.claude/skills"] }, "global": { "enabled": true, "paths": ["~/.agents/skills"] } }, "sources": { - "github": { "enabled": true }, "skillssh": { "enabled": true } } } @@ -104,7 +98,7 @@ The lock file lives at `~/.config/skillpack/skillpack.lock` and records the sour ## 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 +134,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 +144,7 @@ skillpack/ │ │ └── tests/ │ └── tui/ # @skillpack/tui — Ink-based terminal UI │ └── src/ -│ ├── views/ # ListView, DetailView, InstallView, CreateView, UpdateView +│ ├── views/ # ListView, DetailView, InstallView, ProjectSkillsView, UpdatesView │ ├── components/ # StatusBar, ConfirmDialog, SearchInput, SkillRow, TabBar │ ├── context/ # React context for app state │ ├── hooks/ # useSkillManager, useSkills, useSearch, useTerminalSize diff --git a/docs/adr/0005-use-provider-native-management.md b/docs/adr/0005-use-provider-native-management.md index cd37f28..3d10b3f 100644 --- a/docs/adr/0005-use-provider-native-management.md +++ b/docs/adr/0005-use-provider-native-management.md @@ -1,3 +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, Cursor, Claude, and Global skills. +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/0010-restrict-provider-local-mutations.md b/docs/adr/0010-restrict-provider-local-mutations.md index 4241419..497f167 100644 --- a/docs/adr/0010-restrict-provider-local-mutations.md +++ b/docs/adr/0010-restrict-provider-local-mutations.md @@ -1,3 +1,3 @@ # Restrict provider-local mutations -Skillpack v1 will treat Codex, Cursor, 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. +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 index d857374..22b30a9 100644 --- a/docs/adr/0011-use-provider-specific-disable-strategies.md +++ b/docs/adr/0011-use-provider-specific-disable-strategies.md @@ -1,3 +1,7 @@ # 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 the provider has no known config file or native disable mechanism for skills. +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 location has no known config or native disable mechanism for skills. + +For Codex, 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 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. diff --git a/docs/adr/0020-use-auto-detected-configurable-provider-paths.md b/docs/adr/0020-use-auto-detected-configurable-provider-paths.md index e93f1c5..48b6d07 100644 --- a/docs/adr/0020-use-auto-detected-configurable-provider-paths.md +++ b/docs/adr/0020-use-auto-detected-configurable-provider-paths.md @@ -1,3 +1,3 @@ # Use auto-detected configurable provider paths -Skillpack will auto-detect default provider paths for Codex, Cursor, Claude, Global, and Project Skills, while allowing users to override or add paths in configuration. It will not use a first-run wizard, because the inventory should open quickly for common setups while still supporting custom provider layouts. +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/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md index f648d8d..5ea33a4 100644 --- a/docs/prds/provider-native-skillpack.md +++ b/docs/prds/provider-native-skillpack.md @@ -2,7 +2,7 @@ ## Problem Statement -Users have skills scattered across Codex, Cursor, 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 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. @@ -10,13 +10,13 @@ Users need a trustworthy TUI that first explains their Skill Inventory, then exp 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, Cursor, 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. +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, Cursor, Claude, or Global can load it. +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. @@ -25,29 +25,28 @@ Skillpack will scan each Skill Provider according to that provider's own rules, 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 Cursor skill using the safest known provider strategy, so that Cursor loading behavior changes predictably. -13. 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. -14. As a Skillpack user, I want `.disabled-` renaming used only as a fallback, so that Skillpack does not ignore known provider configuration mechanisms. -15. 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. -16. As a Skillpack user, I want Project Skills to show basic validity and paths, so that I can understand what the current repository contributes. -17. As a Skillpack user, I want Skillpack to never mutate Project Skills, so that repository authors remain responsible for them through git. -18. As a Skillpack user, I want to install Global Skills through skills.sh, so that installation follows the ecosystem's lifecycle and metadata. -19. As a Skillpack user, I want arbitrary GitHub installs excluded from v1, so that Skillpack does not become a second package manager. -20. As a Skillpack user, I want to remove only skills.sh-managed Global Skills, so that destructive removal is tied to external provenance. -21. 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. -22. 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. -23. 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. -24. As a Skillpack user, I want unmanaged Global Skills called out, so that I know which Global Skills lack skills.sh lock metadata. -25. As a Skillpack user, I want invalid `SKILL.md` files surfaced as Health Signals, so that broken skills are visible. -26. As a Skillpack user, I want broken symlinks surfaced as Health Signals, so that filesystem problems are visible. -27. As a Skillpack user, I want provider paths auto-detected, so that Skillpack works without setup for common installations. -28. As a Skillpack user, I want provider paths configurable, so that custom installations can still be scanned. -29. As a Skillpack user, I want no skill editing UI, so that Skillpack stays focused on management and inventory. -30. As a Skillpack user, I want no skill creation UI, so that authoring remains outside Skillpack. -31. As a maintainer, I want core inventory derivation tested independently of the TUI, so that behavior remains stable as the interface changes. -32. As a maintainer, I want provider lifecycle capabilities represented explicitly, so that unsupported actions cannot leak into the UI. -33. As a maintainer, I want skills.sh interactions isolated, so that external CLI behavior is easy to test and mock. -34. As a maintainer, I want Project Skill scanning separated from controllable inventory scanning, so that read-only ownership is enforced in code. +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 no skill editing UI, so that Skillpack stays focused on management and inventory. +29. As a Skillpack user, I want no skill creation UI, so that authoring remains outside Skillpack. +30. As a maintainer, I want core inventory derivation tested independently of the TUI, so that behavior remains stable as the interface changes. +31. As a maintainer, I want provider lifecycle capabilities represented explicitly, so that unsupported actions cannot leak into the UI. +32. As a maintainer, I want skills.sh interactions isolated, so that external CLI behavior is easy to test and mock. +33. As a maintainer, I want Project Skill scanning separated from controllable inventory scanning, so that read-only ownership is enforced in code. ## Implementation Decisions @@ -63,10 +62,10 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - 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, Cursor, and Claude support scan plus enable/disable only. +- Codex and Claude support scan plus enable/disable only. - Global Skills support scan, enable/disable, install, update, and remove when managed through skills.sh. - Provider-local destructive removal is out of scope. -- Enable/disable uses provider-specific Disable Strategies; `.disabled-` renaming is fallback behavior only when no known provider mechanism exists. +- Enable/disable uses provider-specific Disable Strategies. Codex uses `~/.codex/config.toml` `[[skills.config]]` entries keyed by absolute `SKILL.md` path. 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 when no known provider mechanism exists for the scanned location. - Update checks are manual and should not block startup. - Provider paths are auto-detected with configurable overrides. - Detail views follow explain-before-action. @@ -87,7 +86,7 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - Creating new skills. - Arbitrary GitHub installs. - Copying, importing, or forking skills between providers. -- Removing provider-local Codex, Cursor, or Claude skills. +- Removing provider-local Codex or Claude skills. - Mutating Project Skills. - LLM best-practice recommendations. - Security or risk scoring. diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md index 1acb4da..bb0eb4c 100644 --- a/docs/specs/provider-native-skillpack-refactor.md +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -2,7 +2,7 @@ ## Purpose -Refactor Skillpack into an understanding-first TUI for managing agent skills across Codex, Cursor, Claude, Global skills from skills.sh, and read-only Project Skills. +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. @@ -10,7 +10,7 @@ Skillpack should not become the canonical owner of skill content. Each Skill Pro The TUI has four top-level sections: -- **Inventory**: grouped view of provider-native skills from Codex, Cursor, Claude, and Global. +- **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. - **Install**: skills.sh search and install into Global Skills only. - **Updates**: manual update checks and updates for skills.sh-managed Global Skills. @@ -27,11 +27,11 @@ The main experience is Inventory. It should answer: In scope: -- Scan Codex, Cursor, Claude, Global, and Project Skill locations using provider-specific rules. +- Scan Codex, Claude, Global, and Project Skill locations using provider-specific rules. - 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. -- Enable or disable Codex, Cursor, Claude, and Global provider instances through provider-specific Disable Strategies. +- Enable or disable Codex, Claude, and Global 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. @@ -44,7 +44,7 @@ Out of scope: - Creating new skills. - Arbitrary GitHub installs. - Copying, importing, or forking skills between providers. -- Removing provider-local Codex, Cursor, or Claude skills. +- Removing provider-local Codex or Claude skills. - LLM-generated advisory guidance or security scoring. - Automatic update checks on startup. - Controlling Project Skills. @@ -56,7 +56,6 @@ Out of scope: A provider-native source of skill state. V1 providers: - Codex -- Cursor - Claude - Global / skills.sh - Project Skills @@ -102,14 +101,20 @@ 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. `.disabled-` directory renaming is a fallback only when no provider-specific mechanism is known. +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: read/write `[[skills.config]]` entries in `~/.codex/config.toml`, keyed by absolute `SKILL.md` path. +- 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: use the default `.disabled-` directory strategy unless skills.sh exposes a native toggle later. ## Provider Capabilities | Provider | Scan | Enable/disable | Install | Update | Remove | Edit/Create | | --- | --- | --- | --- | --- | --- | --- | | Codex | yes | yes | no | no | no | no | -| Cursor | yes | yes | no | no | no | no | | Claude | yes | yes | no | no | no | no | | Global / skills.sh | yes | yes | yes | yes | yes | no | | Project Skills | yes | no | no | no | no | no | @@ -131,7 +136,6 @@ Provider filters can exist inside Inventory: - All - Codex -- Cursor - Claude - Global @@ -201,12 +205,12 @@ The current core model is provider-centric and action-heavy. The refactor should Use core tests as the primary seam. The most valuable external behavior tests are: -- provider scanning returns provider instances with enabled state and paths -- disable strategies toggle through provider-supported behavior +- 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/Cursor/Claude skills do not expose remove/update/install/edit/create 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/packages/tui/README.md b/packages/tui/README.md index 890780a..29148c5 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, 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 skills through provider-native availability mechanisms, with `.disabled-` as a fallback +- 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 skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a Global Skill, or `u` to open manual updates. ## Keyboard Shortcuts @@ -47,9 +44,9 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `i` to insta | `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 | +| `i` | Install through skills.sh | +| `u` | Open manual updates | | `q` | Quit | ### Detail View @@ -58,9 +55,8 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `i` to insta | --- | --- | | `Esc` | Return to list view | | `Space` | Enable or disable skill | -| `e` / `E` | Edit `SKILL.md` in `$EDITOR` | | `o` / `O` | Open skill folder | -| `d` | Delete skill | +| `d` | Remove a skills.sh-managed Global Skill | | `↑` / `↓` | Scroll description | ## Configuration @@ -75,7 +71,6 @@ It auto-detects provider directories on first run and can scan project-level ski ```text .codex/skills -.cursor/skills-cursor .claude/skills .agents/skills ``` From 4ac4c139caee7bdd7704ae6e48d73dffcf058b5b Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 17:29:30 +0800 Subject: [PATCH 14/21] fix(core): remove unsupported github source paths --- AGENTS.md | 19 +-- CONTEXT.md | 4 + README.md | 2 - packages/core/src/index.ts | 1 - packages/core/src/lockfile.ts | 58 --------- packages/core/src/manager.ts | 112 +++--------------- packages/core/src/models/skill.ts | 4 +- packages/core/src/models/source.ts | 4 +- packages/core/src/providers/claude.ts | 7 +- .../core/src/providers/provider-settings.ts | 9 +- packages/core/src/sources/github.ts | 82 ------------- packages/core/src/sources/index.ts | 1 - packages/core/tests/lockfile.test.ts | 66 ----------- packages/core/tests/manager.test.ts | 37 ++++++ packages/core/tests/providers/claude.test.ts | 28 +++++ packages/core/tests/providers/codex.test.ts | 21 ++++ packages/tui/src/views/detail-view.tsx | 3 - 17 files changed, 127 insertions(+), 331 deletions(-) delete mode 100644 packages/core/src/lockfile.ts delete mode 100644 packages/core/src/sources/github.ts delete mode 100644 packages/core/tests/lockfile.test.ts diff --git a/AGENTS.md b/AGENTS.md index 36d03df..25cf82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,9 +55,8 @@ node packages/tui/dist/bin/skillpack.js | `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/`) @@ -99,7 +98,7 @@ Delete is available only for skills.sh-managed Global Skills and delegates to `n **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` -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 @@ -109,17 +108,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 +### skills.sh Lock Metadata -Skillpack reads from two separate lock systems on startup: +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/`. -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/`. - -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. - -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 @@ -127,7 +120,7 @@ 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) diff --git a/CONTEXT.md b/CONTEXT.md index 569cce1..3fbca2f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,6 +36,10 @@ _Avoid_: Global Skill, managed skill 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 diff --git a/README.md b/README.md index 729fe3d..a4c80dd 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,6 @@ Skillpack stores its configuration at `~/.config/skillpack/config.json`. On firs } ``` -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 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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26e34c3..fd0fa29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,6 @@ export * from './models/index.js'; export { parseSkillMd, generateSkillMd } from './parser.js'; export { ConfigManager, createDefaultConfig, type ConfigManagerOptions, type SkillpackConfig } from './config.js'; -export { LockfileManager, type LockEntry } from './lockfile.js'; export { SkillsLockReader, type SkillsLockEntry } from './skills-lock.js'; export * from './providers/index.js'; export { DuplicateDetector } from './duplicates.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 cc36e66..92c9d38 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -8,7 +8,6 @@ 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'; @@ -21,7 +20,6 @@ export class SkillManager { 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'); @@ -31,13 +29,8 @@ 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 { @@ -56,15 +49,11 @@ export class SkillManager { 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', @@ -74,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); } @@ -233,46 +185,16 @@ export class SkillManager { } 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 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 (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.providers.has(providerId)) throw new Error(`Provider not found: ${providerId}`); - 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> { @@ -295,20 +217,16 @@ 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 { diff --git a/packages/core/src/models/skill.ts b/packages/core/src/models/skill.ts index 34c8cbd..e36f45b 100644 --- a/packages/core/src/models/skill.ts +++ b/packages/core/src/models/skill.ts @@ -5,7 +5,7 @@ export interface SkillMetadata { } export interface SkillSource { - type: 'github' | 'skillssh' | 'local'; + type: 'skillssh' | 'local'; repo?: string; ref?: string; commit?: string; @@ -13,7 +13,7 @@ export interface SkillSource { createdAt?: string; installedAt?: string; forkedFrom?: { - source: 'github' | 'skillssh'; + source: 'skillssh'; identifier: string; }; } 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 574e72a..64bc040 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -80,7 +80,7 @@ export class ClaudeProvider extends BaseProvider { const flatSkillName = this.getFlatSkillName(skill.path); if (flatSkillName) { - await setClaudeSkillOverride(this.settingsPath, flatSkillName, enabled ? 'on' : 'off'); + await setClaudeSkillOverride(this.settingsPath, skill.name, enabled ? 'on' : 'off'); return; } @@ -115,9 +115,10 @@ export class ClaudeProvider extends BaseProvider { const parsed = parseSkillMd(content); const resolved = await realpath(skillDir); const dirStat = await stat(resolved); - const override = skillOverrides[skillDirName]; + 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, diff --git a/packages/core/src/providers/provider-settings.ts b/packages/core/src/providers/provider-settings.ts index 0b66fd8..268bf2e 100644 --- a/packages/core/src/providers/provider-settings.ts +++ b/packages/core/src/providers/provider-settings.ts @@ -59,7 +59,8 @@ function parseCodexSkillConfigEntries(text: string): CodexSkillConfigEntry[] { let current: CodexSkillConfigEntry | undefined; for (const [index, line] of lines.entries()) { - if (line.trim() === '[[skills.config]]') { + const trimmed = line.trim(); + if (trimmed === '[[skills.config]]') { if (current) { current.endLine = index; entries.push(current); @@ -67,6 +68,12 @@ function parseCodexSkillConfigEntries(text: string): CodexSkillConfigEntry[] { 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*(.+)$/); 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/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 db6582f..acaa1eb 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -6,6 +6,7 @@ import { SkillManager } from '../src/manager.js'; import { CodexProvider } from '../src/providers/codex.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; @@ -78,6 +79,42 @@ describe('SkillManager', () => { }]); }); + 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'); diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts index 38cfab5..5ba2a39 100644 --- a/packages/core/tests/providers/claude.test.ts +++ b/packages/core/tests/providers/claude.test.ts @@ -44,6 +44,21 @@ describe('ClaudeProvider', () => { 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'); @@ -62,6 +77,19 @@ describe('ClaudeProvider', () => { 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({ diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index 739c62c..5225bec 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -62,6 +62,27 @@ describe('CodexProvider', () => { 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 mkdir(skillDir); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: Toggle me\n---\n'); + 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].enabled).toBe(false); + }); + it('disables a skill by writing Codex config without renaming the directory', async () => { const skillDir = path.join(dir, 'my-skill'); await mkdir(skillDir); diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 55f1e6d..db6660a 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -181,9 +181,6 @@ 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)} )} From 474fbfb6d2c15523fca95aa04e236d391606c860 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 30 Jun 2026 17:46:21 +0800 Subject: [PATCH 15/21] fix(core): prevent toggling shared global skills --- AGENTS.md | 2 +- CONTEXT.md | 4 ++++ README.md | 6 +++--- ...mutations-to-provider-lifecycle-actions.md | 2 +- ...se-provider-specific-disable-strategies.md | 4 +++- ...-not-toggle-shared-global-skill-content.md | 11 +++++++++++ docs/prds/provider-native-skillpack.md | 4 ++-- .../provider-native-skillpack-refactor.md | 4 ++-- packages/core/src/models/inventory.ts | 5 +++-- packages/core/src/providers/global.ts | 2 +- packages/core/tests/inventory.test.ts | 19 ++++++++++++++++++- packages/core/tests/manager.test.ts | 16 ++++++++++++++++ packages/tui/README.md | 14 ++++---------- packages/tui/src/components/status-bar.tsx | 14 ++++++++++---- packages/tui/src/views/detail-view.tsx | 5 ++++- packages/tui/src/views/list-view.tsx | 6 +++++- 16 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0022-do-not-toggle-shared-global-skill-content.md diff --git a/AGENTS.md b/AGENTS.md index 25cf82f..1f70468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'project' | 'updat ### Skill Availability And Toggle -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 uses `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path. 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 location has no known config or native disable mechanism. 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. +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 uses `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path. 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. ### Delete And Update diff --git a/CONTEXT.md b/CONTEXT.md index 3fbca2f..4daea10 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,10 @@ _Avoid_: Skill Provider, registry 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 diff --git a/README.md b/README.md index a4c80dd..b826101 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ node packages/tui/dist/skillpack.js 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 provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a skills.sh Global Skill, or `u` to open manual updates. +Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a skills.sh Global Skill, or `u` to open manual updates. ## Keyboard Shortcuts @@ -50,7 +50,7 @@ Press `Space` to toggle a provider instance on or off, `Enter` to view details, | 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 | | `Enter` | Detail | Open skill detail view | | `Tab` / `Shift+Tab` | Switch tab | Cycle through All / Codex / Claude / Global | | `/` | Search | Fuzzy match on name + description | @@ -65,7 +65,7 @@ Press `Space` to toggle a provider instance on or off, `Enter` to view details, | Key | Action | Description | |-----|--------|-------------| | `Esc` | Back | Return to list view | -| `Space` | Toggle | Enable or disable the skill | +| `Space` | Toggle | Enable or disable a Codex or Claude skill | | `o` / `O` | Open folder | Open skill directory in system file manager | | `d` | Delete | Remove a skills.sh-managed Global Skill with confirmation | | `↑` / `↓` | Scroll | Scroll the description when it overflows | 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 index 4ea1461..b06b9af 100644 --- a/docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md +++ b/docs/adr/0008-limit-v1-mutations-to-provider-lifecycle-actions.md @@ -1,3 +1,3 @@ # Limit v1 mutations to provider lifecycle actions -Skillpack v1 will support enable/disable, skills.sh installation into Global skills, uninstall/removal, and update operations where the underlying provider supports them. It 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. +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/0011-use-provider-specific-disable-strategies.md b/docs/adr/0011-use-provider-specific-disable-strategies.md index 22b30a9..29cfcab 100644 --- a/docs/adr/0011-use-provider-specific-disable-strategies.md +++ b/docs/adr/0011-use-provider-specific-disable-strategies.md @@ -1,7 +1,9 @@ # 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 location has no known config or native disable mechanism for skills. +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, 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 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/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/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md index 5ea33a4..c27bdc8 100644 --- a/docs/prds/provider-native-skillpack.md +++ b/docs/prds/provider-native-skillpack.md @@ -63,9 +63,9 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - 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, enable/disable, install, update, and remove when managed through skills.sh. +- 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 uses `~/.codex/config.toml` `[[skills.config]]` entries keyed by absolute `SKILL.md` path. 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 when no known provider mechanism exists for the scanned location. +- Enable/disable uses provider-specific Disable Strategies. Codex uses `~/.codex/config.toml` `[[skills.config]]` entries keyed by absolute `SKILL.md` path. 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. - Detail views follow explain-before-action. diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md index bb0eb4c..a8659f1 100644 --- a/docs/specs/provider-native-skillpack-refactor.md +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -108,7 +108,7 @@ Known v1 strategies: - Codex: read/write `[[skills.config]]` entries in `~/.codex/config.toml`, keyed by absolute `SKILL.md` path. - 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: use the default `.disabled-` directory strategy unless skills.sh exposes a native toggle later. +- Global / skills.sh: no enable/disable strategy. Global Skills are Shared Skill Content and expose install, update, and remove lifecycle actions only. ## Provider Capabilities @@ -116,7 +116,7 @@ Known v1 strategies: | --- | --- | --- | --- | --- | --- | --- | | Codex | yes | yes | no | no | no | no | | Claude | yes | yes | no | no | no | no | -| Global / skills.sh | yes | yes | yes | yes | yes | no | +| Global / skills.sh | yes | no | yes | yes | yes | no | | Project Skills | yes | no | no | no | no | no | ## TUI Behavior diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index 298c55e..60b30e0 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -79,10 +79,11 @@ function inferredIdentityFor(skill: Skill): { key: string; confidence: SkillIden function actionsFor(skill: Skill): SkillAction[] { const toggleAction: SkillAction = skill.enabled ? 'disable' : 'enable'; + if (skill.scope === 'project') return []; if (skill.provider === 'global' && skill.source?.type === 'skillssh') { - return [toggleAction, 'update', 'remove']; + return ['update', 'remove']; } - if (skill.scope === 'project') return []; + if (skill.provider === 'global') return []; return [toggleAction]; } 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/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index 9165d92..0825ff4 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -3,6 +3,7 @@ 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'; @@ -132,10 +133,26 @@ describe('Skill Inventory', () => { const global = inventory.find((group) => group.name === 'local-global')!; expect(codex.instances[0].actions).toEqual(['disable']); - expect(global.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'); diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index acaa1eb..68dc2c9 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -4,6 +4,7 @@ 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'; @@ -79,6 +80,21 @@ describe('SkillManager', () => { }]); }); + 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 = { diff --git a/packages/tui/README.md b/packages/tui/README.md index 29148c5..7295c80 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -21,7 +21,7 @@ npx skillpack-tui - Multi-platform scanning for Codex, Claude, Global, and project-level skills - Symlink-aware duplicate detection -- Enable and disable skills through provider-native availability mechanisms, with `.disabled-` as a fallback +- 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 @@ -30,7 +30,7 @@ npx skillpack-tui 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, `p` to inspect read-only Project Skills, `i` to install a Global Skill, or `u` to open manual updates. +Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a Global Skill, or `u` to open manual updates. ## Keyboard Shortcuts @@ -39,7 +39,7 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `p` to inspe | Key | Action | | --- | --- | | `↑` / `↓` | Navigate skills | -| `Space` | Enable or disable selected skill | +| `Space` | Enable or disable selected Codex or Claude skill | | `Enter` | Open skill detail view | | `Tab` / `Shift+Tab` | Switch provider tab | | `/` | Search | @@ -54,7 +54,7 @@ Press `Space` to toggle a skill on or off, `Enter` to view details, `p` to inspe | Key | Action | | --- | --- | | `Esc` | Return to list view | -| `Space` | Enable or disable skill | +| `Space` | Enable or disable Codex or Claude skill | | `o` / `O` | Open skill folder | | `d` | Remove a skills.sh-managed Global Skill | | `↑` / `↓` | Scroll description | @@ -75,12 +75,6 @@ It auto-detects provider directories on first run and can scan project-level ski .agents/skills ``` -Remote install provenance is stored in: - -```text -~/.config/skillpack/skillpack.lock -``` - ## Requirements - Node.js >= 18 diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index debb453..c16c7b2 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -9,7 +9,7 @@ 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' }, @@ -47,17 +47,23 @@ const DETAIL_TAIL: Shortcut[] = [ ]; 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'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + const canToggle = selectedSkill + ? manager.getProvider(selectedSkill.provider)?.capabilities.canToggle ?? false + : false; + const detailBase = canToggle + ? [...DETAIL_BASE] + : DETAIL_BASE.filter((shortcut) => shortcut.key !== 'space'); const tail = isRemovable ? [...DETAIL_TAIL, { key: 'd', label: 'delete' }] : DETAIL_TAIL; shortcuts = isUpdatable - ? [...DETAIL_BASE, { key: 'u', label: 'check update' }, ...tail] - : [...DETAIL_BASE, ...tail]; + ? [...detailBase, { key: 'u', label: 'check update' }, ...tail] + : [...detailBase, ...tail]; } else { shortcuts = SHORTCUTS[view] ?? []; } diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index db6660a..6cb36e8 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -37,6 +37,9 @@ export function DetailView() { const sourceType = selectedSkill?.source?.type; const isUpdatable = sourceType === 'skillssh'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + const canToggle = selectedSkill + ? manager.getProvider(selectedSkill.provider)?.capabilities.canToggle ?? false + : false; const disableStrategy = selectedSkill ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) : undefined; @@ -81,7 +84,7 @@ export function DetailView() { } catch { /* opener failed */ } return; } - if (input === ' ' && selectedSkill && !busy) { + if (input === ' ' && selectedSkill && canToggle && !busy) { setError(null); setBusy(true); manager.toggleSkill(selectedSkill) diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 887d940..b95f809 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -105,7 +105,11 @@ export function ListView() { if (input === 'i') { setView('install'); return; } if (input === ' ' && skills[cursor]) { - manager.toggleSkill(skills[cursor]).then(() => refresh()).catch(() => {}); + const selected = skills[cursor]; + const canToggle = manager.getProvider(selected.provider)?.capabilities.canToggle ?? false; + if (canToggle) { + manager.toggleSkill(selected).then(() => refresh()).catch(() => {}); + } return; } if (key.return && skills[cursor]) { From d63fcd00ef48dbf2c31740f1cf08abfe54d183c3 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 13:53:20 +0800 Subject: [PATCH 16/21] docs: define Codex plugin skill ownership --- CONTEXT.md | 4 ++++ ...use-provider-specific-disable-strategies.md | 4 +++- docs/prds/provider-native-skillpack.md | 2 +- .../provider-native-skillpack-refactor.md | 18 ++++++++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4daea10..67babe3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,6 +36,10 @@ _Avoid_: Provider toggle target, per-agent state 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 diff --git a/docs/adr/0011-use-provider-specific-disable-strategies.md b/docs/adr/0011-use-provider-specific-disable-strategies.md index 29cfcab..793fd06 100644 --- a/docs/adr/0011-use-provider-specific-disable-strategies.md +++ b/docs/adr/0011-use-provider-specific-disable-strategies.md @@ -2,7 +2,9 @@ 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, 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 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. diff --git a/docs/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md index c27bdc8..72554d9 100644 --- a/docs/prds/provider-native-skillpack.md +++ b/docs/prds/provider-native-skillpack.md @@ -65,7 +65,7 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - 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 uses `~/.codex/config.toml` `[[skills.config]]` entries keyed by absolute `SKILL.md` path. 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. +- 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. - Detail views follow explain-before-action. diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md index a8659f1..08b1922 100644 --- a/docs/specs/provider-native-skillpack-refactor.md +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -28,10 +28,11 @@ The main experience is Inventory. It should answer: 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. -- Enable or disable Codex, Claude, and Global provider instances through provider-specific Disable Strategies. +- 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. @@ -105,11 +106,24 @@ Each provider adapter owns enable/disable behavior. A strategy should prefer kno Known v1 strategies: -- Codex: read/write `[[skills.config]]` entries in `~/.codex/config.toml`, keyed by absolute `SKILL.md` path. +- 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 | From 5dea3cbbb95b67de92caad124c6eb43572ff184c Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 13:53:36 +0800 Subject: [PATCH 17/21] feat(core): manage Codex plugin skills --- packages/core/src/config.ts | 2 +- packages/core/src/manager.ts | 7 +- packages/core/src/models/inventory.ts | 6 +- packages/core/src/models/skill.ts | 17 +- packages/core/src/providers/codex.ts | 248 +++++++++++++++++- .../core/src/providers/provider-settings.ts | 81 ++++++ packages/core/src/providers/provider.ts | 10 +- packages/core/tests/config.test.ts | 6 +- packages/core/tests/providers/codex.test.ts | 158 ++++++++++- 9 files changed, 503 insertions(+), 32 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ce92ce2..b9e99eb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -35,7 +35,7 @@ export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { autoCheckUpdates: true, projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], providers: { - codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills')] }, + 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')] }, }, diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 92c9d38..ea8aedf 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -137,6 +137,7 @@ export class SkillManager { resolvedPath: skill.resolvedPath, version: skill.version, enabled: skill.enabled, + origin: skill.origin, source: skill.source, actions: [], healthSignals: (skill.scanIssues ?? []).map((issue) => ({ @@ -155,7 +156,8 @@ export class SkillManager { if (!provider.capabilities.canToggle) { throw new Error(`${provider.displayName} does not support toggle`); } - await provider.setEnabled(skill, !skill.enabled); + const targetEnabled = skill.origin?.type === 'plugin' ? !skill.origin.pluginEnabled : !skill.enabled; + await provider.setEnabled(skill, targetEnabled); } async toggleInventoryInstance(instance: SkillInventoryInstance): Promise { @@ -164,7 +166,8 @@ export class SkillManager { if (!provider.capabilities.canToggle) { throw new Error(`${provider.displayName} does not support toggle`); } - await provider.setEnabled(instance, !instance.enabled); + const targetEnabled = instance.origin?.type === 'plugin' ? !instance.origin.pluginEnabled : !instance.enabled; + await provider.setEnabled(instance, targetEnabled); } async uninstallSkill(skill: Skill): Promise { diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index 60b30e0..f6c77c6 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -7,6 +7,7 @@ export type HealthSignalCode = | 'inferred-identity' | 'invalid-skill-md' | 'broken-symlink' + | 'plugin-identity-mismatch' | 'unmanaged-global-skill' | 'update-available'; @@ -30,6 +31,7 @@ export interface SkillInventoryInstance { resolvedPath?: string; version?: string; enabled: boolean; + origin?: Skill['origin']; source?: SkillSource; disableStrategy?: DisableStrategy; actions: SkillAction[]; @@ -78,7 +80,8 @@ function inferredIdentityFor(skill: Skill): { key: string; confidence: SkillIden } function actionsFor(skill: Skill): SkillAction[] { - const toggleAction: SkillAction = skill.enabled ? 'disable' : 'enable'; + 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']; @@ -111,6 +114,7 @@ function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInv resolvedPath: skill.resolvedPath, version: skill.version, enabled: skill.enabled, + origin: skill.origin, source: skill.source, disableStrategy: options.getDisableStrategy?.(skill), actions: actionsFor(skill), diff --git a/packages/core/src/models/skill.ts b/packages/core/src/models/skill.ts index e36f45b..47c8c69 100644 --- a/packages/core/src/models/skill.ts +++ b/packages/core/src/models/skill.ts @@ -4,6 +4,20 @@ 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: 'skillssh' | 'local'; repo?: string; @@ -18,7 +32,7 @@ export interface SkillSource { }; } -export type SkillScanIssueCode = 'invalid-skill-md' | 'broken-symlink'; +export type SkillScanIssueCode = 'invalid-skill-md' | 'broken-symlink' | 'plugin-identity-mismatch'; export interface SkillScanIssue { code: SkillScanIssueCode; @@ -35,6 +49,7 @@ export interface Skill { enabled: boolean; scope: 'global' | 'project'; metadata: SkillMetadata; + origin?: SkillOrigin; source?: SkillSource; scanIssues?: SkillScanIssue[]; } diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index 869fa50..ae41b1b 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -1,16 +1,76 @@ 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 { readCodexSkillConfigEnabled, writeCodexSkillConfigEnabled } from './provider-settings.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, @@ -18,29 +78,66 @@ export class CodexProvider extends BaseProvider { 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 skills = await super.scan(); - return Promise.all(skills.map(async (skill) => { + 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(): { type: 'provider-config'; description: string } { + 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 { + 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); } @@ -58,16 +155,16 @@ export class CodexProvider extends BaseProvider { } 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 { @@ -77,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/provider-settings.ts b/packages/core/src/providers/provider-settings.ts index 268bf2e..0131683 100644 --- a/packages/core/src/providers/provider-settings.ts +++ b/packages/core/src/providers/provider-settings.ts @@ -35,6 +35,14 @@ interface CodexSkillConfigEntry { 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('"')) { @@ -94,6 +102,47 @@ function parseCodexSkillConfigEntries(text: string): CodexSkillConfigEntry[] { 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'); @@ -110,6 +159,16 @@ export async function readCodexSkillConfigEnabled(configPath: string, skillMdPat 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') : []; @@ -132,3 +191,25 @@ export async function writeCodexSkillConfigEnabled(configPath: string, skillMdPa 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 3562392..073361c 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -12,7 +12,7 @@ export interface ProviderCapabilities { canCreate: boolean; } -export type SkillToggleTarget = Pick; +export type SkillToggleTarget = Pick; export interface ISkillProvider { readonly id: string; @@ -38,9 +38,9 @@ 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) { @@ -119,6 +119,10 @@ export abstract class BaseProvider implements ISkillProvider { 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`); } diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index a6639e9..2218f0c 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -45,13 +45,17 @@ describe('ConfigManager', () => { 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')], + paths: [ + path.join(homeDir, '.codex', 'skills'), + path.join(homeDir, '.codex', 'plugins', 'cache'), + ], }); expect(config.providers.global).toEqual({ enabled: true, diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index 5225bec..f9e584f 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -19,10 +19,34 @@ describe('CodexProvider', () => { 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'); @@ -43,8 +67,7 @@ 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); @@ -52,8 +75,7 @@ describe('CodexProvider', () => { 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`); const skills = await provider.scan(); @@ -64,8 +86,7 @@ describe('CodexProvider', () => { it('does not let later TOML tables overwrite Codex skill config state', 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]]', `path = "${path.join(skillDir, 'SKILL.md')}"`, @@ -85,8 +106,7 @@ describe('CodexProvider', () => { it('disables a skill by writing Codex config without renaming the directory', 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'); const [skill] = await provider.scan(); await provider.setEnabled(skill, false); @@ -103,8 +123,7 @@ describe('CodexProvider', () => { it('enables a disabled skill by writing Codex config', 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`); const [skill] = await provider.scan(); @@ -118,4 +137,119 @@ describe('CodexProvider', () => { 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(); + }); }); From 85a8d499c9c92dd97f2081a3045c999fc18af61d Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 13:53:57 +0800 Subject: [PATCH 18/21] feat(tui): confirm Codex plugin toggles --- AGENTS.md | 2 +- README.md | 10 ++-- packages/tui/README.md | 8 +-- packages/tui/src/components/status-bar.tsx | 7 ++- packages/tui/src/lib/plugin-toggle.ts | 25 +++++++++ packages/tui/src/views/detail-view.tsx | 59 +++++++++++++++++++--- packages/tui/src/views/list-view.tsx | 29 ++++++++++- 7 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 packages/tui/src/lib/plugin-toggle.ts diff --git a/AGENTS.md b/AGENTS.md index 1f70468..cd35eda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'project' | 'updat ### Skill Availability And Toggle -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 uses `[[skills.config]]` entries in `~/.codex/config.toml` keyed by absolute `SKILL.md` path. 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. +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. ### Delete And Update diff --git a/README.md b/README.md index b826101..8bf0d6b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agent ## Features -- **Multi-platform scanning** — discovers skills from Codex, Claude, and Global provider locations automatically, with symlink support +- **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 @@ -41,7 +41,7 @@ node packages/tui/dist/skillpack.js 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 Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a skills.sh Global Skill, or `u` to open manual updates. +Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `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 @@ -50,7 +50,7 @@ Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` t | Key | Action | Description | |-----|--------|-------------| | `↑` / `↓` | Navigate | Move selection up / down | -| `Space` | Toggle | Enable or disable a selected Codex or Claude 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 / Claude / Global | | `/` | Search | Fuzzy match on name + description | @@ -65,7 +65,7 @@ Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` t | Key | Action | Description | |-----|--------|-------------| | `Esc` | Back | Return to list view | -| `Space` | Toggle | Enable or disable a Codex or Claude skill | +| `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 | Remove a skills.sh-managed Global Skill with confirmation | | `↑` / `↓` | Scroll | Scroll the description when it overflows | @@ -84,7 +84,7 @@ Skillpack stores its configuration at `~/.config/skillpack/config.json`. On firs ".agents/skills" ], "providers": { - "codex": { "enabled": true, "paths": ["~/.codex/skills"] }, + "codex": { "enabled": true, "paths": ["~/.codex/skills", "~/.codex/plugins/cache"] }, "claude": { "enabled": true, "paths": ["~/.claude/plugins/cache", "~/.claude/skills"] }, "global": { "enabled": true, "paths": ["~/.agents/skills"] } }, diff --git a/packages/tui/README.md b/packages/tui/README.md index 7295c80..ea61ec9 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -19,7 +19,7 @@ npx skillpack-tui ## Features -- Multi-platform scanning for Codex, 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 Codex and Claude skills through provider-native availability mechanisms - Install Global Skills through the skills.sh registry @@ -30,7 +30,7 @@ npx skillpack-tui 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 Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `i` to install a Global Skill, or `u` to open manual updates. +Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `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 @@ -39,7 +39,7 @@ Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view detai | Key | Action | | --- | --- | | `↑` / `↓` | Navigate skills | -| `Space` | Enable or disable selected Codex or Claude 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 | @@ -54,7 +54,7 @@ Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view detai | Key | Action | | --- | --- | | `Esc` | Return to list view | -| `Space` | Enable or disable Codex or Claude skill | +| `Space` | Enable or disable Codex or Claude skill; plugin-owned skills ask for confirmation | | `o` / `O` | Open skill folder | | `d` | Remove a skills.sh-managed Global Skill | | `↑` / `↓` | Scroll description | diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index c16c7b2..eb0e03b 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -39,7 +39,6 @@ const SHORTCUTS: Record = { const DETAIL_BASE: Shortcut[] = [ { key: 'esc', label: 'back' }, - { key: 'space', label: 'toggle' }, ]; const DETAIL_TAIL: Shortcut[] = [ @@ -55,11 +54,11 @@ export function StatusBar() { const isUpdatable = sourceType === 'skillssh'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const canToggle = selectedSkill - ? manager.getProvider(selectedSkill.provider)?.capabilities.canToggle ?? false + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) : false; const detailBase = canToggle - ? [...DETAIL_BASE] - : DETAIL_BASE.filter((shortcut) => shortcut.key !== 'space'); + ? [...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 ? [...detailBase, { key: 'u', label: 'check update' }, ...tail] 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/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 6cb36e8..2fc5cb2 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -6,6 +6,7 @@ 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 { @@ -26,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); @@ -38,7 +39,7 @@ export function DetailView() { const isUpdatable = sourceType === 'skillssh'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const canToggle = selectedSkill - ? manager.getProvider(selectedSkill.provider)?.capabilities.canToggle ?? false + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) : false; const disableStrategy = selectedSkill ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) @@ -63,6 +64,7 @@ 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; @@ -85,6 +87,10 @@ export function DetailView() { return; } if (input === ' ' && selectedSkill && canToggle && !busy) { + if (isPluginOwnedSkill(selectedSkill)) { + setConfirming('plugin-toggle'); + return; + } setError(null); setBusy(true); manager.toggleSkill(selectedSkill) @@ -95,7 +101,7 @@ export function DetailView() { } if (input === 'd' && selectedSkill && isRemovable) { setError(null); - setConfirming(true); + setConfirming('remove'); } if (input === 'u' && selectedSkill && isUpdatable && !busy && !checkingUpdate && !updating) { setError(null); @@ -131,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)} /> ); @@ -189,6 +217,25 @@ export function DetailView() { )} )} + {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)} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index b95f809..21f2400 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -9,6 +9,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 = 8; @@ -33,6 +36,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); @@ -106,8 +110,12 @@ export function ListView() { if (input === ' ' && skills[cursor]) { const selected = skills[cursor]; - const canToggle = manager.getProvider(selected.provider)?.capabilities.canToggle ?? false; + const canToggle = Boolean(manager.getProvider(selected.provider)?.getDisableStrategy(selected)); if (canToggle) { + if (isPluginOwnedSkill(selected)) { + setConfirmingPluginToggle(selected); + return; + } manager.toggleSkill(selected).then(() => refresh()).catch(() => {}); } return; @@ -124,12 +132,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 From 4a9ac17ab0fcd512bf8d6957b7cfe7858427ec9e Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 15:01:07 +0800 Subject: [PATCH 19/21] feat(tui): add read-only settings view --- AGENTS.md | 3 +- CONTEXT.md | 4 + README.md | 5 +- docs/adr/0017-use-sectioned-tui-navigation.md | 2 +- docs/prds/provider-native-skillpack.md | 18 ++- .../provider-native-skillpack-refactor.md | 24 ++- packages/tui/README.md | 3 +- packages/tui/src/app.tsx | 2 + packages/tui/src/components/status-bar.tsx | 6 + packages/tui/src/context/app-context.tsx | 2 +- packages/tui/src/lib/format-path.ts | 10 ++ packages/tui/src/views/list-view.tsx | 28 +--- .../tui/src/views/project-skills-view.tsx | 31 +--- packages/tui/src/views/settings-view.tsx | 150 ++++++++++++++++++ 14 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 packages/tui/src/lib/format-path.ts create mode 100644 packages/tui/src/views/settings-view.tsx diff --git a/AGENTS.md b/AGENTS.md index cd35eda..74021f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ node packages/tui/dist/bin/skillpack.js | `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 | @@ -74,7 +75,7 @@ node packages/tui/dist/bin/skillpack.js ### Routing -The TUI uses a `view` state (`'list' | 'detail' | 'install' | 'project' | 'updates'`) 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 diff --git a/CONTEXT.md b/CONTEXT.md index 67babe3..700e741 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,10 @@ _Avoid_: Installed skill, Skillpack-created skill 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 diff --git a/README.md b/README.md index 8bf0d6b..dd32709 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ node packages/tui/dist/skillpack.js 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 Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `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. +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 @@ -56,6 +56,7 @@ Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` t | `/` | Search | Fuzzy match on name + description | | `Esc` | Clear search | Clear the active search filter | | `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 | @@ -142,7 +143,7 @@ skillpack/ │ │ └── tests/ │ └── tui/ # @skillpack/tui — Ink-based terminal UI │ └── src/ -│ ├── views/ # ListView, DetailView, InstallView, ProjectSkillsView, UpdatesView +│ ├── 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/0017-use-sectioned-tui-navigation.md b/docs/adr/0017-use-sectioned-tui-navigation.md index 8cc6b53..73d322d 100644 --- a/docs/adr/0017-use-sectioned-tui-navigation.md +++ b/docs/adr/0017-use-sectioned-tui-navigation.md @@ -1,3 +1,3 @@ # Use sectioned TUI navigation -Skillpack will use top-level TUI sections for Inventory, Project Skills, Install, and Updates. Inventory remains the primary controllable view for provider-native skills, Project Skills is a separate read-only view, Install is limited to skills.sh Global Skill installation, and Updates focuses on skills.sh-managed Global Skills. +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/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md index 72554d9..13adba0 100644 --- a/docs/prds/provider-native-skillpack.md +++ b/docs/prds/provider-native-skillpack.md @@ -41,18 +41,21 @@ Skillpack will scan each Skill Provider according to that provider's own rules, 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 no skill editing UI, so that Skillpack stays focused on management and inventory. -29. As a Skillpack user, I want no skill creation UI, so that authoring remains outside Skillpack. -30. As a maintainer, I want core inventory derivation tested independently of the TUI, so that behavior remains stable as the interface changes. -31. As a maintainer, I want provider lifecycle capabilities represented explicitly, so that unsupported actions cannot leak into the UI. -32. As a maintainer, I want skills.sh interactions isolated, so that external CLI behavior is easy to test and mock. -33. As a maintainer, I want Project Skill scanning separated from controllable inventory scanning, so that read-only ownership is enforced in code. +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, Install, and Updates. +- 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. @@ -68,6 +71,7 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - 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 diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md index 08b1922..24a1dd4 100644 --- a/docs/specs/provider-native-skillpack-refactor.md +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -8,10 +8,11 @@ Skillpack should not become the canonical owner of skill content. Each Skill Pro ## Product Shape -The TUI has four top-level sections: +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. @@ -32,6 +33,7 @@ In scope: - 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. @@ -77,6 +79,26 @@ A discovered skill in one provider. It includes: - 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. diff --git a/packages/tui/README.md b/packages/tui/README.md index ea61ec9..51f1263 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -30,7 +30,7 @@ npx skillpack-tui 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 Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `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. +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 @@ -45,6 +45,7 @@ Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view detai | `/` | Search | | `Esc` | Clear search | | `p` | Open Project Skills | +| `s` | Open Settings | | `i` | Install through skills.sh | | `u` | Open manual updates | | `q` | Quit | diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c4c76a1..bd6cc68 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ import { ListView } from './views/list-view.js'; import { DetailView } from './views/detail-view.js'; import { InstallView } from './views/install-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'; @@ -17,6 +18,7 @@ function Router() { case 'detail': return ; case 'install': 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 eb0e03b..ccc672d 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -14,6 +14,7 @@ const SHORTCUTS: Record = { { key: '/', label: 'search' }, { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, + { key: 's', label: 'settings' }, { key: 'u', label: 'updates' }, { key: 'i', label: 'install' }, { key: 'q', label: 'quit' }, @@ -28,6 +29,11 @@ const SHORTCUTS: Record = { { 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' }, diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 39f8aa1..d6aa561 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig, ScanPathDiagnostic } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'updates'; +export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates'; interface AppState { manager: SkillManager; 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/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 21f2400..bf67cb8 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; -import path from 'node:path'; import { useAppContext } from '../context/app-context.js'; import { useFilteredSkills, TABS } from '../hooks/use-skills.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; @@ -13,23 +12,13 @@ import { ConfirmDialog } from '../components/confirm-dialog.js'; import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; import type { Skill } from '@skillpack/core'; -const CHROME_LINES = 8; - -function formatScanPath(scanPath: { provider?: string; path: string; exists: boolean }): string { - const relativePath = path.relative(process.cwd(), scanPath.path); - const homeDir = process.env.HOME; - const homePath = homeDir && scanPath.path.startsWith(`${homeDir}${path.sep}`) - ? scanPath.path.replace(homeDir, '~') - : scanPath.path; - const displayPath = relativePath && !relativePath.startsWith('..') ? relativePath : homePath; - return `${scanPath.provider ?? 'project'}:${displayPath}${scanPath.exists ? '' : ' (missing)'}`; -} +const CHROME_LINES = 7; export function ListView() { const { exit } = useApp(); const { loading, activeTab, setActiveTab, setView, setSelectedSkill, - searchQuery, setSearchQuery, refresh, manager, skills: allSkills, scanPaths, + searchQuery, setSearchQuery, refresh, manager, skills: allSkills, } = useAppContext(); const { skills, tabs } = useFilteredSkills(); const { rows } = useTerminalSize(); @@ -84,11 +73,6 @@ export function ListView() { return counts; }, [allSkills]); - const providerRoots = useMemo( - () => scanPaths.filter((scanPath) => scanPath.scope === 'provider').map(formatScanPath).join(' '), - [scanPaths], - ); - useInput((input, key) => { if (input === 'q') { exit(); return; } if (key.escape && searchQuery) { @@ -105,6 +89,7 @@ export function ListView() { } 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; } @@ -177,13 +162,6 @@ export function ListView() { - {providerRoots && ( - - roots - {providerRoots} - - )} - {/* Search input (active) */} {searching && ( diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index 59b5c76..b2c05a5 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; -import path from 'node:path'; 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 = 6; +const CHROME_LINES = 5; const NAME_WIDTH = 30; const PATH_WIDTH = 54; @@ -14,19 +14,9 @@ function truncate(value: string, max: number): string { return value.slice(0, max - 1) + '…'; } -function formatScanPath(scanPath: { path: string; exists: boolean }): string { - const relativePath = path.relative(process.cwd(), scanPath.path); - const homeDir = process.env.HOME; - const homePath = homeDir && scanPath.path.startsWith(`${homeDir}${path.sep}`) - ? scanPath.path.replace(homeDir, '~') - : scanPath.path; - const displayPath = relativePath && !relativePath.startsWith('..') ? relativePath : homePath; - return `${displayPath}${scanPath.exists ? '' : ' (missing)'}`; -} - export function ProjectSkillsView() { const { exit } = useApp(); - const { projectSkills, scanPaths, setView } = useAppContext(); + const { projectSkills, setView } = useAppContext(); const { rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); @@ -56,11 +46,6 @@ export function ProjectSkillsView() { () => projectSkills.slice(scrollOffset, scrollOffset + visibleRows), [projectSkills, scrollOffset, visibleRows], ); - const projectRoots = useMemo( - () => scanPaths.filter((scanPath) => scanPath.scope === 'project').map(formatScanPath).join(' '), - [scanPaths], - ); - const showScroll = projectSkills.length > visibleRows; return ( @@ -74,13 +59,6 @@ export function ProjectSkillsView() { )} - {projectRoots && ( - - roots - {projectRoots} - - )} - {' '} @@ -98,7 +76,6 @@ export function ProjectSkillsView() { ) : ( visibleSkills.map((skill, index) => { const selected = scrollOffset + index === cursor; - const relativePath = path.relative(process.cwd(), skill.path) || skill.path; const hasIssues = skill.healthSignals.length > 0; return ( @@ -108,7 +85,7 @@ export function ProjectSkillsView() { {truncate(skill.name, NAME_WIDTH)} - {truncate(relativePath, PATH_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} + ))} + + + + + + ); +} From bc540fc980c7f25c002093e1b20d9baeff78e390 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 15:56:57 +0800 Subject: [PATCH 20/21] fix: Remove pinned pnpm version from CI setup --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c49d03f..23ebe80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.32.1 - name: Setup Node.js uses: actions/setup-node@v4 From a23252750b58911dcdd52cedddeda08d0b6a666d Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 15:59:11 +0800 Subject: [PATCH 21/21] fix: Bump CI Node.js version to 22 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ebe80..989bd9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies