From ab9643b43d361aa13285317a34ce2ba0e22a9cbc Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Mon, 13 Jul 2026 16:08:06 +0800 Subject: [PATCH 1/5] feat: add Claude skill usage import Parse structured Claude Skill tool_use records with user and plugin source attribution, failure revisions, diagnostics, and symlink-safe artifact deduplication. Harden source identity aggregation, bounded partition reads, coverage messaging, and Windows artifact containment across core and TUI. Closes #27 Refs #33, #34, #35 --- CONTEXT.md | 32 +- README.md | 4 +- ...rive-skill-usage-from-provider-sessions.md | 4 +- ...ersist-minimal-skill-invocation-records.md | 2 +- ...bounded-incremental-skill-usage-imports.md | 4 + .../0043-use-jsonl-for-skill-usage-records.md | 4 + ...-skill-invocation-status-evidence-based.md | 2 + ...049-use-source-path-in-skill-usage-rows.md | 8 + ...uilt-in-claude-skills-from-usage-import.md | 3 + ...ount-name-only-claude-skill-invocations.md | 5 + .../0052-count-each-claude-skill-tool-use.md | 3 + ...-deduplicate-claude-symlinked-artifacts.md | 3 + ...aude-usage-to-configured-artifact-roots.md | 5 + ...ructured-claude-tool-results-for-status.md | 3 + ...-identity-for-claude-invocation-records.md | 3 + ...57-use-append-only-invocation-revisions.md | 5 + ...ouple-claude-usage-from-inventory-state.md | 5 + ...-claude-plugins-from-runtime-scan-roots.md | 5 + ...ld-codex-plugin-cache-versions-in-usage.md | 9 + docs/prds/skill-usage-tracing.md | 57 +- packages/core/src/manager.ts | 8 +- packages/core/src/usage-paths.ts | 18 + packages/core/src/usage.ts | 451 +++++++- packages/core/tests/manager.test.ts | 14 + packages/core/tests/usage-paths.test.ts | 31 + packages/core/tests/usage.test.ts | 1012 ++++++++++++++++- packages/tui/src/hooks/use-skill-manager.ts | 3 +- packages/tui/src/views/usage-view.tsx | 28 +- packages/tui/tests/helpers/mock-manager.ts | 2 + packages/tui/tests/ui/app-surface.test.tsx | 145 +++ 30 files changed, 1782 insertions(+), 96 deletions(-) create mode 100644 docs/adr/0050-exclude-built-in-claude-skills-from-usage-import.md create mode 100644 docs/adr/0051-count-name-only-claude-skill-invocations.md create mode 100644 docs/adr/0052-count-each-claude-skill-tool-use.md create mode 100644 docs/adr/0053-deduplicate-claude-symlinked-artifacts.md create mode 100644 docs/adr/0054-scope-claude-usage-to-configured-artifact-roots.md create mode 100644 docs/adr/0055-use-structured-claude-tool-results-for-status.md create mode 100644 docs/adr/0056-use-tool-use-identity-for-claude-invocation-records.md create mode 100644 docs/adr/0057-use-append-only-invocation-revisions.md create mode 100644 docs/adr/0058-decouple-claude-usage-from-inventory-state.md create mode 100644 docs/adr/0059-resolve-claude-plugins-from-runtime-scan-roots.md create mode 100644 docs/adr/0060-fold-codex-plugin-cache-versions-in-usage.md create mode 100644 packages/core/src/usage-paths.ts create mode 100644 packages/core/tests/usage-paths.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index e082062..c7ffb2d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: Global Skill, managed 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 +**Built-In Skill**: +A Skill Provider bundled skill that has no user-level or plugin-provided source directory managed by the user. +_Avoid_: User Skill, Plugin-Owned Skill + **skills.sh**: The external skill ecosystem and CLI used for installing, updating, and removing Global Skills. _Avoid_: GitHub install source, package manager @@ -61,11 +65,11 @@ A minimal normalized fact derived from provider session artifacts about one Skil _Avoid_: Session transcript, prompt, response **Invocation Record ID**: -A deterministic identifier for a Skill Invocation Record, derived from provider session evidence so repeated imports can be idempotent. +A deterministic identifier for a Skill Invocation, derived from provider session evidence so repeated imports are idempotent and later evidence can revise the same invocation. _Avoid_: Random event ID, row number **Skill Usage Log**: -The append-only JSONL storage for Skill Invocation Records, partitioned by Session-Producing Provider and month. +The append-only JSONL storage for Skill Invocation Records, partitioned by Session-Producing Provider and invocation month. Revisions of one Invocation Record ID remain in the same provider/month partition, and the latest revision is the current fact used by aggregates. _Avoid_: Session transcript store, provider log, database **Invocation Identity Confidence**: @@ -77,9 +81,13 @@ The evidence-based runtime state for a Skill Invocation, indicating whether prov _Avoid_: Skill Availability, Inventory Issue, inventory status **Skill Source Path**: -The `SKILL.md` path recorded from provider session evidence for an invoked skill, falling back to a resolved path only when the evidence path is unavailable. +The optional `SKILL.md` path shown for an invocation or aggregate. An aggregate shows a common provider-facing path when one exists, otherwise a common resolved target, and otherwise leaves the path blank. _Avoid_: Historical status, Scan Root, inventory path +**Skill Source Identity**: +The stable source-level identity used to group Skill Invocation Records within one Session-Producing Provider. A recognized Plugin-Owned Skill cache uses its normalized plugin identity first; other sources prefer the resolved target when known and fall back to the provider-facing Skill Source Path. +_Avoid_: Display path, skill name, Provider Instance + **Counted Invocation**: A Skill Invocation Record included in default Skill Usage aggregates. Failed invocations are excluded from default intensity and ranking counts but can be shown as separate failure context. _Avoid_: Successful invocation, completed task @@ -89,11 +97,11 @@ User-facing aggregates derived from Skill Invocations, used to understand which _Avoid_: Skill Inventory, Skill Availability, inventory status, session history, cross-provider skill rollup **Provider Skill Ranking**: -A Skill Usage aggregate that ranks invoked skill source paths within a Skill Provider by exact Counted Invocation count, with failed invocation count and recency as supporting context. +A Skill Usage aggregate that ranks invoked skills within a Skill Provider by exact Counted Invocation count, separating rows by Skill Source Identity while treating versioned cache paths for one Plugin-Owned Skill as one source identity. _Avoid_: Global leaderboard, inventory order **Selected-Day Usage Detail**: -The Skill Usage detail for one selected heatmap day, scoped to one Session-Producing Provider and grouped by skill source path with exact Counted Invocation and failure context for that day. +The Skill Usage detail for one selected heatmap day, scoped to one Session-Producing Provider and grouped by skill name and stable source identity with exact Counted Invocation and failure context for that day. _Avoid_: Session transcript, raw invocation history, provider-wide total **Skill Usage Heatmap**: @@ -109,7 +117,7 @@ The read-only process that derives Skill Invocation Records from provider-owned _Avoid_: Runtime instrumentation, session sync **Usage Import Diagnostic**: -A non-inventory finding from Skill Usage Import, such as skipped ambiguous provider evidence or unsupported session artifacts. +A non-inventory finding from Skill Usage Import, such as skipped provider evidence or unsupported session artifacts. Repeated findings may be summarized with a count. _Avoid_: Inventory Issue, provider warning **Usage Provider Adapter**: @@ -120,6 +128,10 @@ _Avoid_: Heuristic log parser, runtime hook Whether Skillpack can derive Skill Usage for a Skill Provider, distinguishing supported providers with zero Counted Invocations from providers that are unsupported or not configured for import. _Avoid_: Skill Availability, zero usage +**Usage Coverage Reason**: +One specific missing prerequisite that explains a `not-configured` Usage Coverage State, such as missing Usage Artifact Roots or Skill Attribution Roots. Every Provider Skill Usage Overview carries a `coverageReasons` collection: it contains every applicable reason for `not-configured` coverage and is empty for other states. These are configuration context, not findings from an attempted import. +_Avoid_: Usage Import Diagnostic, import error + **Usage Import Consent**: The user's persisted opt-in that allows Skillpack to read provider-owned session artifacts for Skill Usage Import. _Avoid_: Config default, provider permission @@ -138,7 +150,11 @@ _Avoid_: Session state, Skillpack-owned provider state **Usage Artifact Root**: A provider-specific directory or file root Skillpack reads during Skill Usage Import to find supported session artifacts. -_Avoid_: Scan Root, skill directory +_Avoid_: Scan Root, Skill Attribution Root, skill directory + +**Skill Attribution Root**: +A configured skill-content root a Usage Provider Adapter uses to match invocation evidence to eligible user-level or plugin-provided Skill sources. The same directory may also serve as an Inventory Scan Root, but attribution and inventory discovery are separate responsibilities. +_Avoid_: Usage Artifact Root, session directory **Provider Instance**: A discovered skill as represented by one Skill Provider, including that provider's availability and provenance for the skill. @@ -162,7 +178,7 @@ _Avoid_: Terminal UI Test, component test **Scan Root**: A directory Skillpack inspects to discover provider, shared global, or project skills. -_Avoid_: Skill, provider, install source +_Avoid_: Skill, provider, install source, Usage Artifact Root **Inventory Notice**: A non-problem inventory fact that helps the user understand provider coverage, provenance, or grouping confidence. diff --git a/README.md b/README.md index 2f40fd0..407a7d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agent - **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 -- **Skill Usage** — derives aggregate Codex skill usage from local session artifacts after explicit consent +- **Skill Usage** — derives aggregate Codex and Claude skill usage from local session artifacts after explicit consent - **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 @@ -129,7 +129,7 @@ Skillpack stores its configuration at `~/.config/skillpack/config.json`. On firs } ``` -`usage.importConsent` defaults to `false`. When enabled from the TUI, Skillpack imports supported provider artifacts into derived JSONL records under `~/.local/share/skillpack/usage/`. The current importer supports Codex session artifacts. Claude appears in the Usage view for coverage, but Claude usage import is not supported yet. +`usage.importConsent` defaults to `false`. When enabled from the TUI, Skillpack imports supported Codex and Claude artifacts into derived JSONL records under `~/.local/share/skillpack/usage/`. Claude usage is derived from structured `Skill` tool calls that match user-level or plugin-provided skills in configured Claude Scan Roots. Built-In Skills and Claude Project Skills are not counted in the current Claude adapter. The Settings view shows both Scan Roots and Usage Artifact Roots so you can verify which local directories Skillpack will inspect. diff --git a/docs/adr/0039-derive-skill-usage-from-provider-sessions.md b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md index de0203d..3e0cdea 100644 --- a/docs/adr/0039-derive-skill-usage-from-provider-sessions.md +++ b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md @@ -2,4 +2,6 @@ Skillpack will derive Skill Usage by reading provider-owned session artifacts read-only, not by adding runtime hooks or instrumentation to agent platforms. This keeps Skillpack aligned with provider-native state: providers remain the source of truth for Skill Invocations, while Skillpack normalizes their session evidence into usage history and aggregates. -The first supported adapter is Codex. It reads the real Codex session JSONL event streams under `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`, using `session_meta` and `turn_context` for session/turn/cwd context and `response_item` `function_call` tool calls for evidence that the agent directly read a `.../skills//SKILL.md` file. Codex usage is global across configured artifact roots and is not scoped to Skillpack's startup cwd. `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. Claude remains unsupported for Skill Usage until its real session artifact format is implemented conservatively. +The first supported adapter is Codex. It reads the real Codex session JSONL event streams under `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`, using `session_meta` and `turn_context` for session/turn/cwd context and `response_item` `function_call` tool calls for evidence that the agent directly read a `.../skills//SKILL.md` file. Codex usage is global across configured artifact roots and is not scoped to Skillpack's startup cwd. `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. + +The Claude adapter reads known main-session and subagent JSONL transcript paths under configured Claude artifact roots and treats assistant `tool_use` blocks with `name === "Skill"` as invocation evidence. It follows JSONL file symlinks, deduplicates real artifacts, and does not parse unrelated JSONL path shapes or loader-log text as invocation evidence. Malformed lines are skipped with aggregated diagnostics, while valid auto-compact summaries are ignored because they cannot restore earlier structured calls. diff --git a/docs/adr/0040-persist-minimal-skill-invocation-records.md b/docs/adr/0040-persist-minimal-skill-invocation-records.md index 8a7eebb..c83b00f 100644 --- a/docs/adr/0040-persist-minimal-skill-invocation-records.md +++ b/docs/adr/0040-persist-minimal-skill-invocation-records.md @@ -2,4 +2,4 @@ Skillpack will persist only minimal Skill Invocation Records derived from provider session artifacts: provider, skill identity evidence, session identity, timestamps, and Skill Invocation Status. It will not persist prompts, responses, tool arguments, file contents, or full transcripts, because usage analysis should not turn Skillpack into a secondary session-log store. -Provider adapters may inspect session event envelopes and tool-call arguments transiently to find Skill Invocation evidence, but persisted records must remain aggregate-ready facts only. For Codex, the adapter persists the normalized skill name/path, session id, turn id, timestamp, identity confidence, and status derived from the tool call and its output, not the command string or command output. Codex records are deduplicated by `(turn_id, skill_name)`. +Provider adapters may inspect session event envelopes and tool-call arguments transiently to find Skill Invocation evidence, but persisted records must remain aggregate-ready facts only. For Codex, the adapter persists the normalized skill name/path, session id, turn id, timestamp, identity confidence, and status derived from the tool call and its output, not the command string or command output. Codex records are deduplicated by `(turn_id, skill_name, Skill Source Identity)`, and their deterministic Invocation Record IDs use that same identity. This collapses segmented and aliased reads of one logical source without merging same-named skills backed by distinct sources. diff --git a/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md index e746f3d..7081885 100644 --- a/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md +++ b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md @@ -4,4 +4,8 @@ Skillpack will run a cheap incremental Skill Usage Import on startup using provi Cursors include an importer version as well as source artifact size and mtime. When a parser bug is fixed or a provider adapter starts recognizing additional real session evidence, Skillpack can invalidate old cursors and rescan unchanged artifacts once without requiring users to reset derived usage data. +Provider invocation identity and deduplication changes also increment the importer version. The stale provider partitions are rebuilt before reimport so obsolete Invocation Record IDs cannot coexist with records generated under the corrected identity rule. + +After consent, a provider's first supported import scans all known artifacts and retains every recoverable invocation, even when the current Usage view shows a shorter date range. Later imports use cursors; display ranges are query boundaries, not retention boundaries. + Startup import failures are recorded in the Usage view state and surfaced when the user opens Skill Usage; they do not block normal inventory startup. diff --git a/docs/adr/0043-use-jsonl-for-skill-usage-records.md b/docs/adr/0043-use-jsonl-for-skill-usage-records.md index a407245..6e4e86b 100644 --- a/docs/adr/0043-use-jsonl-for-skill-usage-records.md +++ b/docs/adr/0043-use-jsonl-for-skill-usage-records.md @@ -1,3 +1,7 @@ # Use JSONL for Skill Usage Records Skillpack will store Skill Invocation Records in an append-only Skill Usage Log using JSONL files partitioned by Session-Producing Provider and month. JSONL avoids native database dependencies for the `npx`-distributed TUI, is easy to inspect and test, and is sufficient for the default 7-day top-10 usage aggregate; a future migration to SQLite can be considered if range queries become too slow. + +Range queries will derive the overlapping `YYYY-MM.jsonl` partition names and read only those files. Skillpack owns partition writes, so query performance will not be made unbounded to recover records manually placed in the wrong month. + +All revisions of one Invocation Record ID must preserve the provider and `startedAt` month that select its partition. A parser correction that changes either partition key requires importer-version invalidation and a provider partition rebuild. diff --git a/docs/adr/0044-keep-skill-invocation-status-evidence-based.md b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md index 37cb5cc..24a5144 100644 --- a/docs/adr/0044-keep-skill-invocation-status-evidence-based.md +++ b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md @@ -3,3 +3,5 @@ Skillpack will normalize Skill Invocation Status to provider-evidence states such as loaded, used, failed, and unknown. It will not infer task success from later session outcomes, because usage analytics should report what provider session artifacts prove rather than imply that a skill solved the user's work. For Codex, a direct `SKILL.md` read with a zero exit code is recorded as `used`; a direct `SKILL.md` read with a non-zero exit code is recorded as `failed`; missing output evidence remains `unknown`. This means failed reads are preserved as facts but do not inflate normal invocation counts. + +For Claude, a structured `Skill` tool use is recorded as `used` unless a matching structured tool result has `is_error === true`, which records `failed`. Claude records without a valid assistant timestamp are skipped rather than dated from the transcript file's modification time. diff --git a/docs/adr/0049-use-source-path-in-skill-usage-rows.md b/docs/adr/0049-use-source-path-in-skill-usage-rows.md index 66408ce..55675c2 100644 --- a/docs/adr/0049-use-source-path-in-skill-usage-rows.md +++ b/docs/adr/0049-use-source-path-in-skill-usage-rows.md @@ -3,3 +3,11 @@ Skillpack will group Skill Usage table rows by skill name and Skill Source Path, and will show Source Path instead of current/historical status. Skill Usage should report what provider session evidence says was invoked, not whether the invoked skill is currently discovered by Skill Inventory or Project Skills scanning. This supersedes ADR-0048. Current/historical labeling was too dependent on configured scan roots: a valid Project Skill invoked from a repository outside the current scan set could be labeled historical even though the invocation evidence contained the exact skill path. Source Path keeps deleted, moved, unscanned, and project-local skills visible without turning Usage into an Inventory status view. + +When a provider-facing skill path is a symlink, Skillpack retains that path for display and retains the resolved target separately for identity and deduplication. Skill Source Identity prefers the resolved target whenever available and falls back to the provider-facing path. The provider-facing path explains where the provider discovered the skill; the resolved path prevents aliases to the same content from becoming false duplicate identities. + +Source attribution collapses candidate aliases by resolved target. If several provider-facing paths resolve to one target, Skillpack retains the common resolved path and leaves the provider-facing path blank unless it is unique; only distinct resolved targets make the source fully ambiguous. + +Aggregation applies the same rule across invocation records: show one common provider-facing path when every record agrees, otherwise show one common resolved target when every record agrees, and otherwise leave Source Path blank. Skill Source Identity remains separate from this display choice. + +ADR-0060 narrows path-based grouping for versioned Codex plugin caches: cache versions of one stable Plugin-Owned Skill aggregate into one row rather than appearing as separate sources. diff --git a/docs/adr/0050-exclude-built-in-claude-skills-from-usage-import.md b/docs/adr/0050-exclude-built-in-claude-skills-from-usage-import.md new file mode 100644 index 0000000..38d4e96 --- /dev/null +++ b/docs/adr/0050-exclude-built-in-claude-skills-from-usage-import.md @@ -0,0 +1,3 @@ +# Exclude built-in Claude skills from usage import + +Claude Skill Usage Import will count real Claude `Skill` tool calls only when the invoked skill can be resolved to at least one current user-level or plugin-provided skill. Calls with no eligible match are skipped with a Usage Import Diagnostic because name-only transcript evidence cannot distinguish deleted user content from Built-In or deferred Project Skills. Built-In Skills are excluded because Skillpack usage is meant to explain user-controlled skill content and plugin-provided skill behavior, not provider-bundled capabilities with no user-managed source path. diff --git a/docs/adr/0051-count-name-only-claude-skill-invocations.md b/docs/adr/0051-count-name-only-claude-skill-invocations.md new file mode 100644 index 0000000..a08570d --- /dev/null +++ b/docs/adr/0051-count-name-only-claude-skill-invocations.md @@ -0,0 +1,5 @@ +# Count name-only Claude Skill Invocations + +Claude Skill Usage Import will count a `Skill` tool call when the invoked name matches user-level or plugin-provided skill content, even if that name maps to multiple possible source paths. In that ambiguous-source case, Skillpack persists the invocation with no Skill Source Path rather than choosing a precedence order, because the provider transcript proves the invocation happened but does not prove which same-named source supplied it. + +Invocation names are trimmed and matched exactly against parsed `SKILL.md` names. Skillpack will not lowercase names, strip namespaces, or use directory-name guesses because those transformations can merge distinct skills. diff --git a/docs/adr/0052-count-each-claude-skill-tool-use.md b/docs/adr/0052-count-each-claude-skill-tool-use.md new file mode 100644 index 0000000..290f83d --- /dev/null +++ b/docs/adr/0052-count-each-claude-skill-tool-use.md @@ -0,0 +1,3 @@ +# Count each Claude Skill tool use + +Claude Skill Usage Import will treat each assistant `tool_use` block with `name === "Skill"` as one Skill Invocation. This intentionally differs from Codex's turn-and-skill deduplication because Codex infers usage from file reads that may split one skill load across multiple commands, while Claude exposes a structured invocation event directly. diff --git a/docs/adr/0053-deduplicate-claude-symlinked-artifacts.md b/docs/adr/0053-deduplicate-claude-symlinked-artifacts.md new file mode 100644 index 0000000..ca2af25 --- /dev/null +++ b/docs/adr/0053-deduplicate-claude-symlinked-artifacts.md @@ -0,0 +1,3 @@ +# Deduplicate Claude symlinked artifacts + +Claude Skill Usage Import will follow JSONL file symlinks under configured Claude artifact roots, resolve each candidate with `realpath`, and parse each real artifact once. This avoids dropping valid subagent transcripts while preventing shared symlinked transcripts from being double-counted. diff --git a/docs/adr/0054-scope-claude-usage-to-configured-artifact-roots.md b/docs/adr/0054-scope-claude-usage-to-configured-artifact-roots.md new file mode 100644 index 0000000..f657530 --- /dev/null +++ b/docs/adr/0054-scope-claude-usage-to-configured-artifact-roots.md @@ -0,0 +1,5 @@ +# Scope Claude usage to configured artifact roots + +Claude Skill Usage Import will scan configured Claude artifact roots rather than only the project directory matching Skillpack's startup cwd. Skill Usage is provider-wide analytics, so Claude should use the same scope model as Codex and avoid making provider comparisons depend on the directory from which the TUI was launched. + +Artifact discovery must reject files outside the configured Usage Artifact Root before evaluating supported path shapes. This containment check includes absolute results from `path.relative`, because Windows cross-volume and cross-share paths can remain absolute instead of beginning with `..`. diff --git a/docs/adr/0055-use-structured-claude-tool-results-for-status.md b/docs/adr/0055-use-structured-claude-tool-results-for-status.md new file mode 100644 index 0000000..534eb38 --- /dev/null +++ b/docs/adr/0055-use-structured-claude-tool-results-for-status.md @@ -0,0 +1,3 @@ +# Use structured Claude tool results for status + +Claude Skill Usage Import will record valid assistant `Skill` tool_use blocks as `used` by default, and will mark them `failed` only when a matching structured `tool_result` has `is_error === true`. Skillpack will not parse loader logs, stderr text, or natural-language summaries for Claude failure status because those are not stable provider status evidence. diff --git a/docs/adr/0056-use-tool-use-identity-for-claude-invocation-records.md b/docs/adr/0056-use-tool-use-identity-for-claude-invocation-records.md new file mode 100644 index 0000000..6e43643 --- /dev/null +++ b/docs/adr/0056-use-tool-use-identity-for-claude-invocation-records.md @@ -0,0 +1,3 @@ +# Use tool use identity for Claude invocation records + +Claude Skill Usage Import will derive Invocation Record IDs from the provider, real artifact path, session identity, message or line identity, and `tool_use.id`, with stable fallbacks when transcript fields are missing. This keeps repeated imports idempotent while preserving the rule that each structured Claude `Skill` tool_use counts as its own Skill Invocation. diff --git a/docs/adr/0057-use-append-only-invocation-revisions.md b/docs/adr/0057-use-append-only-invocation-revisions.md new file mode 100644 index 0000000..df58cef --- /dev/null +++ b/docs/adr/0057-use-append-only-invocation-revisions.md @@ -0,0 +1,5 @@ +# Use append-only invocation revisions + +Skill Usage Import will append a revised Skill Invocation Record with the same Invocation Record ID when later provider evidence changes a previously imported fact, and readers will use only the latest record for each ID. This preserves append-only JSONL storage while allowing an active Claude transcript's initial `used` record to become `failed` when a later matching structured tool result reports an error. + +Revisions must preserve the record's provider and `startedAt` month so every version of one ID remains in one authoritative partition. Changes to either partition key are importer changes that rebuild provider records under a new importer version rather than ordinary revisions. diff --git a/docs/adr/0058-decouple-claude-usage-from-inventory-state.md b/docs/adr/0058-decouple-claude-usage-from-inventory-state.md new file mode 100644 index 0000000..6f475ee --- /dev/null +++ b/docs/adr/0058-decouple-claude-usage-from-inventory-state.md @@ -0,0 +1,5 @@ +# Decouple Claude usage from inventory state + +Claude Skill Usage Import will run after Usage Import Consent even when the Claude Skill Inventory provider is disabled. Inventory availability and runtime usage are separate facts, so the usage adapter will use its configured Usage Artifact Roots and Skill Attribution Roots directly; missing roots produce `not-configured` coverage with all applicable Usage Coverage Reasons rather than silently reporting zero usage. + +Current Skill Availability also does not filter source candidates during historical attribution. Disabled user-level skills and disabled plugins remain eligible while their source content exists, because disabling content now must not erase evidence of earlier use. diff --git a/docs/adr/0059-resolve-claude-plugins-from-runtime-scan-roots.md b/docs/adr/0059-resolve-claude-plugins-from-runtime-scan-roots.md new file mode 100644 index 0000000..7384431 --- /dev/null +++ b/docs/adr/0059-resolve-claude-plugins-from-runtime-scan-roots.md @@ -0,0 +1,5 @@ +# Use runtime provider paths as Claude Skill Attribution Roots + +Claude Skill Usage Import will resolve plugin-provided skill candidates from configured runtime Skill Attribution Roots, such as `~/.claude/plugins/cache`, and will not inspect marketplace checkout directories unless the user explicitly configures them as a Skill Attribution Root. The initial configuration reuses Claude provider paths, but the Usage role is attribution rather than Inventory scanning. This avoids attributing invocations to catalog copies or duplicate plugin versions that Claude may not load at runtime. + +When several cached versions expose the same skill name, Skillpack will not infer the invoked version from semver or file timestamps. Different resolved targets remain source-ambiguous, while aliases to one resolved target are collapsed. diff --git a/docs/adr/0060-fold-codex-plugin-cache-versions-in-usage.md b/docs/adr/0060-fold-codex-plugin-cache-versions-in-usage.md new file mode 100644 index 0000000..14aaf2e --- /dev/null +++ b/docs/adr/0060-fold-codex-plugin-cache-versions-in-usage.md @@ -0,0 +1,9 @@ +# Fold Codex plugin cache versions in Skill Usage + +Skillpack will aggregate Codex Plugin-Owned Skill usage by stable marketplace, plugin, and skill-relative path, ignoring the version or content-hash directory between the plugin name and its `skills` directory. Plugin upgrades replace cache locations without changing the invoked skill's logical identity, so grouping by the complete cache path creates duplicate ranking and selected-day rows. + +This normalized plugin identity takes precedence over a record's resolved target. Cache versions are distinct real directories by construction, so generic resolved-path identity would defeat version folding. + +This exception applies only to recognized paths under `.codex/plugins/cache////skills/`. Ordinary user, project, and provider-local paths continue to use the complete Skill Source Path, and different marketplaces remain distinct. + +When an aggregate contains records from more than one concrete cache path, Skillpack leaves its displayed Skill Source Path blank. Every invocation record retains its original path; only aggregate identity and presentation change. diff --git a/docs/prds/skill-usage-tracing.md b/docs/prds/skill-usage-tracing.md index 4b2bf53..01d314d 100644 --- a/docs/prds/skill-usage-tracing.md +++ b/docs/prds/skill-usage-tracing.md @@ -31,7 +31,7 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl 13. As a Skillpack user, I want the Provider Skill Ranking capped to the top 10 skills, so that the output stays focused and scannable. 14. As a Skillpack user, I want Project Skills included when they are invoked, so that repository-owned skills are not omitted from usage analysis. 15. As a Skillpack user, I want usage rows to show the Skill Source Path, so that unscanned Project Skills and moved skills remain understandable without relying on Inventory status. -16. As a Skillpack user, I want same-named skills from different source paths separated in usage aggregates, so that project-local skills are not collapsed into misleading totals. +16. As a Skillpack user, I want same-named skills with different Skill Source Identities separated in usage aggregates, while aliases and versioned cache locations for one logical source remain combined, so that project-local skills are not collapsed and source aliases or plugin upgrades do not create duplicate rows. 17. As a Skillpack user, I want unsupported providers shown differently from zero-usage providers, so that missing coverage is not mistaken for inactivity. 18. As a Skillpack user, I want Usage Import Diagnostics when artifacts are skipped, so that I can understand why usage may be incomplete. 19. As a Skillpack user, I want Skillpack to skip ambiguous provider evidence, so that false invocation records are not created. @@ -53,36 +53,63 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - Runtime hooks and instrumentation are out of scope. - Usage Import requires explicit Usage Import Consent before the first read of provider session artifacts. - After consent, Skillpack may run bounded incremental Skill Usage Import on startup using Usage Import Cursors. +- The first Claude import scans all supported artifacts and retains every recoverable invocation; subsequent imports use artifact cursors. Display range limits do not discard older derived records. - The first shippable implementation should prove Codex end to end before adding Claude. - The Codex adapter reads the real `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl` event formats, not a synthetic `skill_invocation` format. - `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. - Codex skill usage is derived only from `response_item` events with `payload.type === "function_call"` where an `exec_command` directly reads `.../skills//SKILL.md`; `session_meta` and `turn_context` provide session, turn, and cwd context for session identity, turn identity, and relative path resolution. - Codex import excludes system/developer text, AGENTS.md text, available-skill lists, rendered skill metadata, ordinary text mentions, and search/script output such as `rg`, `grep`, or `python` commands that merely mention `SKILL.md`. - Codex usage is global across configured Codex artifact roots and is not scoped to Skillpack's startup cwd. -- Codex records are deduplicated by `(turn_id, skill_name, source_path)` so segmented reads of the same `SKILL.md` in one turn count as one invocation while same-named skills from different paths stay separate. +- Codex records are deduplicated by `(turn_id, skill_name, Skill Source Identity)`, and Codex Invocation Record IDs use the same identity, so segmented or aliased reads of one logical Skill in one turn count once while same-named skills with distinct source identities remain separate. - Codex skill names are normalized from the skill path directory, with plugin-owned Codex skills represented as `plugin-name:skill-name`. +- Codex plugin cache paths are aggregated by stable marketplace, plugin, and skill identity while ignoring the cache version directory. This normalized Plugin-Owned Skill identity takes precedence over `resolvedPath`; otherwise distinct real cache targets would recreate one row per version. If one aggregate contains more than one concrete cache path, its displayed Skill Source Path is blank rather than choosing an arbitrary historical version. - Codex `function_call_output` exit-code evidence maps successful reads to `used` and failed reads to `failed`. -- Claude Usage Import is intentionally unsupported in the first Codex-first slice and appears as unsupported coverage until a real Claude session adapter is added. +- Claude usage is derived from assistant `tool_use` blocks where `name === "Skill"` in Claude project JSONL transcripts and subagent JSONL transcripts. +- The initial Claude Usage Provider Adapter counts only user-level and plugin-provided skills found through configured Skill Attribution Roots. Project Skills and Built-In Skills are excluded. +- Claude source paths are resolved best-effort from current known Claude skill locations. If an invocation name matches more than one user-level or plugin-provided source, Skillpack counts the invocation with a blank source path rather than assigning it to an arbitrary source. If it matches no eligible source, Skillpack skips it with a Usage Import Diagnostic because the transcript cannot distinguish deleted user content from Built-In or deferred Project Skills. +- Claude invocation names are trimmed and matched exactly against parsed `SKILL.md` names. The adapter does not lowercase names, strip namespaces, or infer identity from directory names. +- Each Claude `Skill` tool_use block counts as one Skill Invocation. Claude records are not deduplicated by turn and skill because the structured tool_use block is already the invocation evidence. +- Claude Invocation Record IDs are derived from provider, real artifact path, session identity, message or line identity, and `tool_use.id`, with stable fallbacks when transcript fields are missing. +- Claude Skill Invocations are recorded as `used` by default. If a matching structured `tool_result` has `is_error === true`, the invocation is recorded as `failed`; stderr text, loader logs, and natural-language summaries are not status evidence. +- If later incremental import evidence changes a Claude invocation's status, Skillpack appends a revision with the same Invocation Record ID. Reads and aggregates use only the latest record for each ID. +- Otherwise-valid Claude `Skill` tool calls with a non-empty invoked name but no valid assistant-record timestamp are skipped with a Usage Import Diagnostic. Malformed calls and empty skill names are not counted as missing-timestamp Skill Invocations. File modification time is not invocation-time evidence; a matching tool result timestamp is used only as optional `endedAt`. +- Repeated skipped Claude invocations are aggregated into one Usage Import Diagnostic per real artifact, skill name, and reason, with the skipped invocation count included. +- Claude artifact discovery follows JSONL file symlinks, resolves them with `realpath`, and parses each real artifact once so shared subagent transcripts are not double-counted. +- Claude artifact discovery accepts only `/.jsonl` and `//subagents/agent-*.jsonl`; other JSONL path shapes are skipped with aggregated diagnostics. +- Claude artifact discovery rejects paths outside the configured Usage Artifact Root before evaluating those shapes. The containment guard rejects empty, parent-traversing, and absolute `path.relative` results so Windows cross-volume or cross-share paths cannot be treated as root members. +- Malformed Claude JSONL lines are skipped without failing the artifact, with one diagnostic per artifact containing the malformed-line count and no raw content. Valid auto-compact summaries are ignored without diagnostics, accepting that compacted structured calls cannot be recovered. +- Claude usage is global across configured Claude artifact roots and is not scoped to Skillpack's startup cwd. +- Claude Usage Import is independent of the Claude Skill Inventory provider's enabled state. Usage Import Consent authorizes artifact reads, while configured Claude Skill Attribution Roots provide eligible user-level and plugin source candidates for attribution. These roots currently reuse the Claude provider's configured paths but have a separate Usage responsibility. +- Current Skill Availability does not filter Claude attribution candidates; disabled user-level skills and disabled plugins remain eligible evidence for historical usage while their source content exists. +- Claude plugin attribution uses configured runtime Skill Attribution Roots such as `~/.claude/plugins/cache`; marketplace checkout directories are not considered unless explicitly configured as a Skill Attribution Root. +- Multiple cached plugin versions are not resolved by semver or file timestamps. Distinct real targets remain source-ambiguous and produce a name-only Counted Invocation; aliases sharing one real target are collapsed normally. +- For a symlinked Claude skill, `skillPath` retains the provider-facing `SKILL.md` path and `resolvedPath` retains its real target. Outside the normalized Plugin-Owned Skill cache exception, Skill Source Identity prefers `resolvedPath` when available and falls back to `skillPath`; presentation considers `skillPath` separately. +- Claude source candidates are collapsed by realpath before attribution. Multiple aliases to one real target retain that `resolvedPath` and leave `skillPath` blank unless exactly one provider-facing path exists; multiple distinct real targets remain source-ambiguous. - Global Skills and Project Skills are skill scopes in usage identity, not session-producing runtimes. -- Skill Invocation Records persist minimal facts only: provider, raw skill evidence, best resolved skill identity evidence, session/turn identity, timestamps, Skill Invocation Status, and deterministic Invocation Record ID. +- Skill Invocation Records persist minimal facts only: provider, normalized provider-facing Skill Source Path evidence, best resolved skill identity evidence, session/turn identity, timestamps, Skill Invocation Status, and deterministic Invocation Record ID. Literal path tokens from provider tool arguments are transient parser input and are not retained separately. - Prompts, responses, tool arguments, file contents, and full transcripts are never persisted by Skillpack. - Skill Invocation Status is evidence-based: loaded, used, failed, or unknown. - Failed invocation records are stored but excluded from default Counted Invocation totals. -- Provider Skill Ranking ranks by exact Counted Invocation count within one Skill Provider and keeps same-named skills from different Skill Source Paths as separate rows. +- Provider Skill Ranking ranks by exact Counted Invocation count within one Skill Provider and keeps same-named skills with different Skill Source Identities as separate rows, except for versioned cache paths belonging to one stable Plugin-Owned Skill identity. - Skill Usage Heatmap intensity uses exact Counted Invocation volume across the selected range with one scale across providers. - Skill Usage does not roll up same-name skills across providers in v1. -- Skill Usage includes Project Skills when session evidence identifies them. +- Skill Usage can include Project Skills when a supported Usage Provider Adapter identifies them; Project Skills are deferred for the initial Claude adapter. - Skill Usage rows show Skill Source Path instead of current/historical status, because Usage should not depend on current scan-root coverage. -- Usage Coverage State distinguishes supported zero usage from unsupported or not configured providers. +- An aggregate displays its provider-facing `skillPath` when all records share one, otherwise its common `resolvedPath` when all records share one, and otherwise a blank Skill Source Path. Skillpack never selects one path arbitrarily from conflicting evidence. +- Usage Coverage State distinguishes supported zero usage from unsupported or not configured providers. Every Provider Skill Usage Overview carries `coverageReasons: UsageCoverageReason[]`; the array is empty for `active`, `zero`, and `unsupported` coverage, while a `not-configured` provider includes every applicable `missing-artifact-roots`, `missing-attribution-roots`, or `provider-not-configured` reason without hiding simultaneous gaps behind a priority order. +- `importAllProviders()` omits providers whose import did not run because required configuration is missing. `SkillUsageImportResult` represents an attempted import; configuration gaps are exposed through Usage Coverage State and Usage Coverage Reasons instead of synthetic import results or Usage Import Diagnostics. - Usage Provider Adapters parse only stable, known provider artifact formats. -- Ambiguous or unsupported provider evidence is skipped with Usage Import Diagnostics. +- Unsupported provider evidence is skipped with Usage Import Diagnostics. Ambiguous source attribution may still be counted when the provider evidence proves a real Skill Invocation by name. - Usage Import Cursors include an importer version so parser fixes can force a one-time rescan of unchanged artifacts. -- The Skill Usage Log is append-only JSONL partitioned by Session-Producing Provider and month. +- Changes to provider invocation identity or deduplication semantics increment the importer version and rebuild that provider's derived records so obsolete record IDs do not remain alongside corrected records. +- The Skill Usage Log is append-only JSONL partitioned by Session-Producing Provider and invocation month; repeated Invocation Record IDs are revisions and only the latest record is current. Every revision of one Invocation Record ID preserves its provider and `startedAt` month. Range queries treat the `YYYY-MM.jsonl` partition name as authoritative and read only months overlapping the requested date range; parser changes that would move records across months require an importer-version rebuild. - Skill Usage Records are retained until Skill Usage Reset. - Skill Usage Reset deletes Skillpack's derived Skill Usage Log and Usage Import Cursors only. - The TUI gets a top-level Skill Usage View reachable with `g`. - The Skill Usage View uses provider tabs to switch the selected Session-Producing Provider. -- Unsupported and not-configured provider tabs remain selectable, but they show an explanatory empty state instead of fabricated heatmap or table data. +- Claude uses the existing Skill Usage View without provider-specific screens or controls; enabling its adapter changes coverage and aggregate data only. +- Unsupported and not-configured provider tabs remain selectable, but they show an explanatory empty state instead of fabricated heatmap or table data. A not-configured empty state uses all of its Usage Coverage Reasons rather than assuming Usage Artifact Roots are always the missing prerequisite. +- Not-configured empty-state copy lists every missing prerequisite in one sentence: missing artifact roots use ` Usage Artifact Roots are not configured.`, missing attribution roots use ` Skill Attribution Roots are not configured.`, both use ` Usage Artifact Roots and Skill Attribution Roots are not configured.`, and `provider-not-configured` alone uses ` Usage Import is not configured.` Configuration reasons are not repeated as Usage Import Diagnostics. - The Skill Usage View combines a GitHub-style provider/day heatmap, selected-day usage detail, and an always-present Provider Skill Ranking table. - The Usage view uses `tab` and `shift+tab` to switch providers, `1`/`2`/`3` to select the 7/30/90 day ranges, up/down arrows to move the selected heatmap day by one day, and left/right arrows to move by one week. - Heatmap navigation does not wrap at range boundaries. @@ -103,7 +130,7 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - The heatmap uses a GitHub-style calendar layout with weeks as columns and days aligned by weekday rows. - The heatmap uses a high-contrast colored palette for activity levels; zero-count cells should be visually quiet but not rendered as a grayscale intensity ramp. - The selected heatmap cell shows exact date, provider, Counted Invocation count, failed invocation count, and per-skill aggregate usage for that day. -- Selected-Day Usage Detail is grouped by skill source path and does not expose session-level or turn-level raw invocation history. +- Selected-Day Usage Detail is grouped by stable skill source identity and does not expose session-level or turn-level raw invocation history. - Selected-Day Usage Detail appears to the right of the heatmap on wide terminals and below the heatmap on narrower terminals. - Selected-Day Usage Detail columns are Skill, Counted, Failed, and Source Path. - Selected-Day Usage Detail should show all skills used on the selected day whenever possible. @@ -140,7 +167,7 @@ interface SkillInvocationRecord { - Provider session artifacts are local-substitutable dependencies: tests should use fixture directories and files that represent supported provider formats. - Usage Provider Adapter tests should verify observable import results and diagnostics, not internal parser steps. - JSONL store tests should verify idempotent repeated imports, provider/month partitioning, retention until reset, and range-bounded reads. -- Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, source-path separation, and provider-specific rankings. +- Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, distinct source-identity separation, resolved-target alias folding, plugin cache version folding, and provider-specific rankings. - TUI tests can be added where the existing Ink test harness supports them; otherwise the first TUI slice should keep rendering logic factored so core aggregate behavior carries most coverage. - Do not mock Skillpack's own modules from each other in core tests. - Do not assert call counts between internal collaborators. @@ -153,16 +180,16 @@ interface SkillInvocationRecord { - Raw invocation history browsing. - Exporting usage aggregates. - Cross-provider skill rollups. -- Claude Skill Usage Import before a real Claude session artifact adapter exists. +- Claude Project Skill attribution in the initial Claude adapter. - SQLite or another database dependency. - Inferring task success from session outcomes. - Mutating provider-owned session artifacts. - Deleting provider-owned session history. - Treating Usage Import Diagnostics as Inventory Issues. -- Adding support for providers beyond Codex in the first implementation wave. +- Adding support for providers beyond Codex and Claude. ## Further Notes This PRD builds on the provider-native direction: Skillpack remains a management and analysis console, not the canonical owner of provider runtime state. -Relevant ADRs: ADR-0039 through ADR-0048. +Relevant ADRs: ADR-0039 through ADR-0060. diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 91f4eaf..2eeabb0 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -358,6 +358,12 @@ function defaultUsageProviders(): SkillUsageProviderConfig[] { supported: true, artifactRoots: [path.join(os.homedir(), '.codex', 'sessions'), path.join(os.homedir(), '.codex', 'archived_sessions')], }, - { provider: 'claude', displayName: 'Claude', supported: false, artifactRoots: [path.join(os.homedir(), '.claude', 'projects')] }, + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [path.join(os.homedir(), '.claude', 'projects')], + skillRoots: [path.join(os.homedir(), '.claude', 'plugins', 'cache'), path.join(os.homedir(), '.claude', 'skills')], + }, ]; } diff --git a/packages/core/src/usage-paths.ts b/packages/core/src/usage-paths.ts new file mode 100644 index 0000000..d0e627d --- /dev/null +++ b/packages/core/src/usage-paths.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; + +type PathOperations = Pick; + +export function isSupportedClaudeArtifactPath( + root: string, + file: string, + pathOperations: PathOperations = path, +): boolean { + const relative = pathOperations.relative(root, file); + // Windows cross-volume and cross-share relative paths can remain absolute. + if (!relative || relative.startsWith(`..${pathOperations.sep}`) || pathOperations.isAbsolute(relative)) return false; + const parts = relative.split(pathOperations.sep); + if (parts.length === 2) return parts[1].endsWith('.jsonl'); + return parts.length === 4 + && parts[2] === 'subagents' + && /^agent-.*\.jsonl$/.test(parts[3]); +} diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index 900fa5e..d143694 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -2,10 +2,13 @@ import { createHash } from 'node:crypto'; import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { parseSkillMd } from './parser.js'; +import { isSupportedClaudeArtifactPath } from './usage-paths.js'; -const USAGE_IMPORTER_VERSION = 6; +const USAGE_IMPORTER_VERSION = 8; export type UsageCoverageState = 'unsupported' | 'not-configured' | 'zero' | 'active'; +export type UsageCoverageReason = 'missing-artifact-roots' | 'missing-attribution-roots' | 'provider-not-configured'; export type SkillInvocationStatus = 'loaded' | 'used' | 'failed' | 'unknown'; export interface SkillUsageProviderConfig { @@ -14,6 +17,7 @@ export interface SkillUsageProviderConfig { supported: boolean; configured?: boolean; artifactRoots?: string[]; + skillRoots?: string[]; } export interface SkillUsageOverviewInput { @@ -109,6 +113,7 @@ interface UsageImportCursor { interface ParsedInvocationResult { records: SkillInvocationRecord[]; ambiguousLineNumbers: number[]; + diagnostics?: UsageImportDiagnostic[]; } interface CodexSkillReadCandidate { @@ -117,16 +122,35 @@ interface CodexSkillReadCandidate { sessionId: string; turnId: string; startedAt: string; - endedAt?: string; - rawSkillPath: string; skillPath: string; statusHint?: SkillInvocationStatus; } +interface ClaudeSkillCandidate { + name: string; + skillPath: string; + resolvedPath: string; +} + +interface ParsedJsonlRecord { + lineIndex: number; + value: Record; +} + +interface ProviderSkillUsageAccumulator { + row: Omit; + recordCount: number; + providerPathCount: number; + providerPaths: Set; + resolvedPathCount: number; + resolvedPaths: Set; +} + export interface ProviderSkillUsageOverview { provider: string; displayName: string; coverageState: UsageCoverageState; + coverageReasons: UsageCoverageReason[]; heatmap: SkillUsageHeatmapCell[]; dailySkillUsage: ProviderSkillDailyUsage[]; ranking: ProviderSkillRankingRow[]; @@ -157,12 +181,16 @@ export class SkillUsageManager { const diagnostics: UsageImportDiagnostic[] = []; let skippedArtifacts = 0; const records: SkillInvocationRecord[] = []; + const claudeSkills = provider.provider === 'claude' + ? await readClaudeSkillCandidates(provider.skillRoots ?? []) + : []; const cursor = await this.readCursor(provider.provider); if (cursor.stale) await rm(path.join(this.dataDir, 'invocations', provider.provider), { recursive: true, force: true }); const nextCursor: UsageImportCursor = { provider: provider.provider, artifacts: { ...cursor.artifacts }, }; + const seenRealArtifacts = new Set(); for (const root of provider.artifactRoots ?? []) { const files = await listJsonlFiles(root).catch((err) => { @@ -179,6 +207,26 @@ export class SkillUsageManager { if (!metadata) continue; const cursorEntry = cursor.artifacts[file]; if (cursorEntry?.size === metadata.size && cursorEntry.mtimeMs === metadata.mtimeMs) continue; + if (provider.provider === 'claude' && !isSupportedClaudeArtifactPath(root, file)) { + skippedArtifacts += 1; + diagnostics.push({ + provider: provider.provider, + message: `Skipped unsupported Claude artifact path: ${path.relative(root, file)}`, + }); + nextCursor.artifacts[file] = { size: metadata.size, mtimeMs: metadata.mtimeMs }; + continue; + } + const realArtifactPath = await realpath(file).catch((err) => { + diagnostics.push({ provider: provider.provider, message: err instanceof Error ? err.message : String(err) }); + skippedArtifacts += 1; + return null; + }); + if (!realArtifactPath) continue; + if (seenRealArtifacts.has(realArtifactPath)) { + nextCursor.artifacts[file] = { size: metadata.size, mtimeMs: metadata.mtimeMs }; + continue; + } + seenRealArtifacts.add(realArtifactPath); const content = await readFile(file, 'utf-8').catch((err) => { diagnostics.push({ provider: provider.provider, message: err instanceof Error ? err.message : String(err) }); @@ -188,8 +236,11 @@ export class SkillUsageManager { if (!content) continue; const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); - const result = await parseCodexInvocationLines(provider.provider, file, lines, currentSkills); + const result = provider.provider === 'claude' + ? await parseClaudeInvocationLines(provider.provider, file, lines, claudeSkills, currentSkills) + : await parseCodexInvocationLines(provider.provider, file, lines, currentSkills); records.push(...result.records); + diagnostics.push(...(result.diagnostics ?? [])); for (const lineNumber of result.ambiguousLineNumbers) { skippedArtifacts += 1; diagnostics.push({ @@ -217,6 +268,7 @@ export class SkillUsageManager { const results: SkillUsageImportResult[] = []; for (const provider of this.providers) { if (!provider.supported || !provider.artifactRoots?.length) continue; + if (provider.provider === 'claude' && !provider.skillRoots?.length) continue; results.push(await this.importProvider(provider.provider, currentSkills)); } return results; @@ -233,11 +285,13 @@ export class SkillUsageManager { return { range, providers: await Promise.all(this.providers.map(async (provider) => { + const coverageReasons = coverageReasonsFor(provider); if (!provider.supported) { return { provider: provider.provider, displayName: provider.displayName, coverageState: coverageStateFor(provider, 0), + coverageReasons, heatmap: [], dailySkillUsage: [], ranking: [], @@ -252,6 +306,7 @@ export class SkillUsageManager { provider: provider.provider, displayName: provider.displayName, coverageState: coverageStateFor(provider, records.length), + coverageReasons, heatmap, dailySkillUsage, ranking, @@ -273,10 +328,11 @@ export class SkillUsageManager { for (const [partition, partitionRecords] of byPartition) { await mkdir(path.dirname(partition), { recursive: true }); const existing = await readFile(partition, 'utf-8').catch(() => ''); - const seen = existingRecordIds(existing); + const latest = latestSerializedRecords(existing); const newRecords = partitionRecords.filter((record) => { - if (seen.has(record.recordId)) return false; - seen.add(record.recordId); + const serialized = JSON.stringify(record); + if (latest.get(record.recordId) === serialized) return false; + latest.set(record.recordId, serialized); return true; }); if (newRecords.length === 0) continue; @@ -290,24 +346,25 @@ export class SkillUsageManager { private async readProviderRecords(provider: string, range: SkillUsageRange): Promise { const dir = path.join(this.dataDir, 'invocations', provider); - const files = await listJsonlFiles(dir).catch(() => []); - const records: SkillInvocationRecord[] = []; + const files = [...new Set(datesInRange(range).map((date) => date.slice(0, 7)))] + .map((month) => path.join(dir, `${month}.jsonl`)); + const latestRecords = new Map(); - for (const file of files) { + for (const file of files.sort()) { const content = await readFile(file, 'utf-8').catch(() => ''); for (const line of content.split(/\r?\n/)) { if (!line.trim()) continue; try { const record = JSON.parse(line) as SkillInvocationRecord; const date = record.startedAt.slice(0, 10); - if (date >= range.from && date <= range.to) records.push(record); + if (date >= range.from && date <= range.to) latestRecords.set(record.recordId, record); } catch { // Ignore malformed derived records; provider import diagnostics cover source artifacts. } } } - return records; + return [...latestRecords.values()]; } private async readCursor(provider: string): Promise { @@ -341,12 +398,20 @@ export class SkillUsageManager { function coverageStateFor(provider: SkillUsageProviderConfig, recordCount = 0): UsageCoverageState { if (!provider.supported) return 'unsupported'; - if (!provider.artifactRoots?.length) return 'not-configured'; - if (provider.configured === false) return 'not-configured'; + if (coverageReasonsFor(provider).length > 0) return 'not-configured'; if (recordCount > 0) return 'active'; return 'zero'; } +function coverageReasonsFor(provider: SkillUsageProviderConfig): UsageCoverageReason[] { + if (!provider.supported) return []; + const reasons: UsageCoverageReason[] = []; + if (!provider.artifactRoots?.length) reasons.push('missing-artifact-roots'); + if (provider.provider === 'claude' && !provider.skillRoots?.length) reasons.push('missing-attribution-roots'); + if (provider.configured === false) reasons.push('provider-not-configured'); + return reasons; +} + function buildRange(days: 7 | 30 | 90, now: Date): SkillUsageRange { const end = toUtcDate(now); const start = new Date(end); @@ -367,23 +432,18 @@ function formatDate(date: Date): string { return date.toISOString().slice(0, 10); } -function recordInRange(record: SkillInvocationRecord, range: SkillUsageRange): boolean { - const date = record.startedAt.slice(0, 10); - return date >= range.from && date <= range.to; -} - -function existingRecordIds(content: string): Set { - const ids = new Set(); +function latestSerializedRecords(content: string): Map { + const records = new Map(); for (const line of content.split(/\r?\n/)) { if (!line.trim()) continue; try { const value = JSON.parse(line) as { recordId?: unknown }; - if (typeof value.recordId === 'string') ids.add(value.recordId); + if (typeof value.recordId === 'string') records.set(value.recordId, JSON.stringify(value)); } catch { // Ignore malformed derived records when deduplicating new writes. } } - return ids; + return records; } async function listJsonlFiles(root: string): Promise { @@ -394,7 +454,7 @@ async function listJsonlFiles(root: string): Promise { const files = await Promise.all(entries.map(async (entry) => { const entryPath = path.join(root, entry.name); if (entry.isDirectory()) return listJsonlFiles(entryPath); - return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : []; + return (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.jsonl') ? [entryPath] : []; })); return files.flat(); } @@ -460,7 +520,6 @@ async function parseCodexInvocationLines( sessionId, turnId: currentTurnId, startedAt, - rawSkillPath, skillPath, statusHint: normalizeStatus(stringValue(payload.status)), }); @@ -475,26 +534,32 @@ async function parseCodexInvocationLines( const recordsByTurnAndSkill = new Map(); for (const candidate of candidates) { - const skillIdentity = await readSkillIdentity(candidate.skillPath, candidate.rawSkillPath); + const skillIdentity = await readSkillIdentity(candidate.skillPath); const skillName = skillIdentity.name; if (!skillName || !candidate.sessionId || !candidate.turnId || !candidate.startedAt) { ambiguousLineNumbers.push(candidate.lineIndex + 1); continue; } const evidence = statusFromToolOutput(outputs.get(candidate.callId), candidate.statusHint); - const sourcePath = sourcePathForRecord({ + const sourceIdentity = skillSourceIdentity({ + provider, skillPath: candidate.skillPath, resolvedPath: skillIdentity.resolvedPath, }); - const recordKey = `${candidate.turnId}\0${skillName}\0${sourcePath ?? ''}`; + const recordKey = `${candidate.turnId}\0${skillName}\0${sourceIdentity}`; const existing = recordsByTurnAndSkill.get(recordKey); if (existing) { existing.status = mergeInvocationStatus(existing.status, evidence.status); if (candidate.startedAt < existing.startedAt) existing.startedAt = candidate.startedAt; if (evidence.endedAt && (!existing.endedAt || evidence.endedAt > existing.endedAt)) existing.endedAt = evidence.endedAt; if (skillIdentity.confirmed) { - existing.skillPath = candidate.skillPath; - existing.resolvedPath = skillIdentity.resolvedPath; + if (existing.identityConfidence === 'confirmed') { + existing.skillPath = commonPathEvidence(existing.skillPath, candidate.skillPath); + existing.resolvedPath = commonPathEvidence(existing.resolvedPath, skillIdentity.resolvedPath); + } else { + existing.skillPath = candidate.skillPath; + existing.resolvedPath = skillIdentity.resolvedPath; + } existing.identityConfidence = 'confirmed'; } existing.source ??= findCurrentSkillSource({ @@ -520,7 +585,7 @@ async function parseCodexInvocationLines( candidate.sessionId, candidate.turnId, skillName, - sourcePath, + sourceIdentity, ), provider, sessionId: candidate.sessionId, @@ -539,6 +604,201 @@ async function parseCodexInvocationLines( return { records: [...recordsByTurnAndSkill.values()], ambiguousLineNumbers }; } +async function parseClaudeInvocationLines( + provider: string, + sourceFile: string, + lines: string[], + skillCandidates: ClaudeSkillCandidate[], + currentSkills?: CurrentSkillReference[], +): Promise { + const records: SkillInvocationRecord[] = []; + const unmatchedCounts = new Map(); + const realArtifactPath = await realpath(sourceFile).catch(() => sourceFile); + const toolResults = new Map(); + let missingTimestampCount = 0; + const parsed = parseJsonlRecords(lines); + + for (const { value } of parsed.records) { + const message = isRecord(value.message) ? value.message : undefined; + if (!message || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (!isRecord(block) || block.type !== 'tool_result') continue; + const toolUseId = stringValue(block.tool_use_id); + if (!toolUseId) continue; + toolResults.set(toolUseId, { + failed: block.is_error === true, + endedAt: stringValue(value.timestamp), + }); + } + } + + for (const { lineIndex, value } of parsed.records) { + if (value.type !== 'assistant') continue; + const message = isRecord(value.message) ? value.message : undefined; + if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) continue; + const startedAt = stringValue(value.timestamp); + if (!isValidInvocationTimestamp(startedAt)) { + missingTimestampCount += message.content.filter((block) => claudeSkillName(block) !== undefined).length; + continue; + } + const sessionId = stringValue(value.sessionId) + ?? stringValue(value.session_id) + ?? path.basename(sourceFile, '.jsonl'); + const messageId = stringValue(value.uuid) + ?? stringValue(message.id) + ?? `${sessionId}:${lineIndex + 1}`; + + for (const [blockIndex, block] of message.content.entries()) { + const skillName = claudeSkillName(block); + if (!skillName) continue; + const matches = skillCandidates.filter((candidate) => candidate.name === skillName); + if (matches.length === 0) { + unmatchedCounts.set(skillName, (unmatchedCounts.get(skillName) ?? 0) + 1); + continue; + } + const sourceMatch = resolveClaudeSkillSource(matches); + const toolUseId = stringValue(block.id) ?? `${messageId}:${blockIndex}`; + const toolResult = toolResults.get(toolUseId); + records.push({ + schemaVersion: 1, + recordId: claudeInvocationRecordId(provider, realArtifactPath, sessionId, messageId, toolUseId), + provider, + sessionId, + turnId: messageId, + skillName, + skillPath: sourceMatch.skillPath, + resolvedPath: sourceMatch.resolvedPath, + source: sourceMatch.confirmed + ? findCurrentSkillSource({ provider, skillName, ...sourceMatch }, currentSkills) + : undefined, + identityConfidence: sourceMatch.confirmed ? 'confirmed' : 'inferred', + status: toolResult?.failed ? 'failed' : 'used', + startedAt, + endedAt: toolResult?.endedAt ?? null, + }); + } + } + + const diagnostics: UsageImportDiagnostic[] = []; + if (parsed.malformedLineCount > 0) { + diagnostics.push({ + provider, + message: `Skipped ${parsed.malformedLineCount} malformed JSONL line${parsed.malformedLineCount === 1 ? '' : 's'} in ${path.basename(sourceFile)}`, + }); + } + if (missingTimestampCount > 0) { + diagnostics.push({ + provider, + message: `Skipped ${missingTimestampCount} Claude Skill invocation${missingTimestampCount === 1 ? '' : 's'} in ${path.basename(sourceFile)}: missing or invalid assistant timestamp`, + }); + } + diagnostics.push(...[...unmatchedCounts.entries()].map(([skillName, count]) => ({ + provider, + message: `Skipped ${count} Claude Skill invocation${count === 1 ? '' : 's'} for "${skillName}" in ${path.basename(sourceFile)}: no eligible user-level or plugin source`, + }))); + + return { + records, + ambiguousLineNumbers: [], + diagnostics, + }; +} + +function claudeSkillName(block: unknown): string | undefined { + if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Skill' || !isRecord(block.input)) { + return undefined; + } + return stringValue(block.input.skill)?.trim() || undefined; +} + +function parseJsonlRecords(lines: string[]): { records: ParsedJsonlRecord[]; malformedLineCount: number } { + const records: ParsedJsonlRecord[] = []; + let malformedLineCount = 0; + for (const [lineIndex, line] of lines.entries()) { + try { + const value: unknown = JSON.parse(line); + if (isRecord(value)) records.push({ lineIndex, value }); + } catch { + malformedLineCount += 1; + } + } + return { records, malformedLineCount }; +} + +function resolveClaudeSkillSource(matches: ClaudeSkillCandidate[]): { + skillPath?: string; + resolvedPath?: string; + confirmed: boolean; +} { + const resolvedPaths = [...new Set(matches.map((candidate) => candidate.resolvedPath))]; + if (resolvedPaths.length !== 1) return { confirmed: false }; + return { + skillPath: matches.length === 1 ? matches[0].skillPath : undefined, + resolvedPath: resolvedPaths[0], + confirmed: true, + }; +} + +function claudeInvocationRecordId( + provider: string, + realArtifactPath: string, + sessionId: string, + messageId: string, + toolUseId: string, +): string { + return createHash('sha256') + .update(JSON.stringify({ provider, realArtifactPath, sessionId, messageId, toolUseId })) + .digest('hex'); +} + +async function readClaudeSkillCandidates(roots: string[]): Promise { + const candidates: ClaudeSkillCandidate[] = []; + for (const root of roots) { + for (const skillPath of await listSkillMdFiles(root)) { + const content = await readFile(skillPath, 'utf-8').catch(() => ''); + if (!content) continue; + try { + const parsed = parseSkillMd(content); + const name = parsed.name.trim(); + if (!name) continue; + candidates.push({ + name, + skillPath, + resolvedPath: await realpath(skillPath).catch(() => skillPath), + }); + } catch { + // Invalid skill metadata is not eligible Claude runtime source evidence. + } + } + } + return candidates; +} + +async function listSkillMdFiles(root: string, ancestorRealPaths = new Set()): Promise { + const resolvedRoot = await realpath(root).catch(() => root); + if (ancestorRealPaths.has(resolvedRoot)) return []; + const nextAncestors = new Set(ancestorRealPaths).add(resolvedRoot); + const entries = await readdir(root, { withFileTypes: true }).catch((err) => { + if (isNodeError(err) && err.code === 'ENOENT') return []; + throw err; + }); + const files = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return listSkillMdFiles(entryPath, nextAncestors); + if (entry.isSymbolicLink()) { + const metadata = await stat(entryPath).catch(() => undefined); + if (metadata?.isDirectory()) { + const skillPath = path.join(entryPath, 'SKILL.md'); + const skillMetadata = await stat(skillPath).catch(() => undefined); + return skillMetadata?.isFile() ? [skillPath] : listSkillMdFiles(entryPath, nextAncestors); + } + return metadata?.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; + } + return entry.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; + })); + return files.flat(); +} + function parseExecCommand(payload: Record): { cmd: string; workdir?: string } | undefined { const raw = stringValue(payload.arguments) ?? stringValue(payload.input); if (!raw) return undefined; @@ -609,7 +869,7 @@ function resolveSkillPath(rawSkillPath: string, cwd: string | undefined): string return path.resolve(cwd ?? process.cwd(), rawSkillPath); } -async function readSkillIdentity(skillPath: string, rawSkillPath: string): Promise<{ +async function readSkillIdentity(skillPath: string): Promise<{ name: string; resolvedPath?: string; confirmed: boolean; @@ -625,12 +885,29 @@ async function readSkillIdentity(skillPath: string, rawSkillPath: string): Promi function normalizeSkillNameFromPath(skillPath: string): string { const normalized = path.normalize(skillPath); const skillName = path.basename(path.dirname(normalized)); + const pluginName = parseCodexPluginSkillPath(normalized)?.plugin; + return pluginName ? `${pluginName}:${skillName}` : skillName; +} + +function parseCodexPluginSkillPath(skillPath: string): { + marketplace: string; + plugin: string; + relativeSkillPath: string; +} | undefined { + const normalized = path.normalize(skillPath); const parts = normalized.split(path.sep).filter(Boolean); const cacheIndex = parts.findIndex((part, index) => ( part === 'cache' && parts[index - 1] === 'plugins' && parts[index - 2] === '.codex' )); - const pluginName = cacheIndex >= 0 ? parts[cacheIndex + 2] : undefined; - return pluginName ? `${pluginName}:${skillName}` : skillName; + if (cacheIndex < 0) return undefined; + + const marketplace = parts[cacheIndex + 1]; + const plugin = parts[cacheIndex + 2]; + const skillsIndex = parts.indexOf('skills', cacheIndex + 3); + const relativeSkillPath = skillsIndex >= 0 ? parts.slice(skillsIndex + 1).join('/') : ''; + if (!marketplace || !plugin || !relativeSkillPath) return undefined; + + return { marketplace, plugin, relativeSkillPath }; } function statusFromToolOutput( @@ -652,15 +929,19 @@ function mergeInvocationStatus(left: SkillInvocationStatus, right: SkillInvocati return 'unknown'; } +function commonPathEvidence(left: string | undefined, right: string | undefined): string | undefined { + return left === right ? left : undefined; +} + function invocationRecordId( provider: string, sessionId: string, turnId: string, skillName: string, - sourcePath: string | undefined, + sourceIdentity: string, ): string { return createHash('sha256') - .update(JSON.stringify({ provider, sessionId, turnId, skillName, sourcePath })) + .update(JSON.stringify({ provider, sessionId, turnId, skillName, sourceIdentity })) .digest('hex'); } @@ -694,63 +975,107 @@ function datesInRange(range: SkillUsageRange): string[] { } function buildRanking(records: SkillInvocationRecord[]): ProviderSkillRankingRow[] { - const rows = new Map(); + const rows = new Map(); for (const record of records) { const rowKey = usageRowKey(record); - const row = rows.get(rowKey) ?? createUsageRow(record); - addRecordToUsageRow(row, record); - rows.set(rowKey, row); + const accumulator = rows.get(rowKey) ?? createUsageAccumulator(record); + addRecordToUsageAccumulator(accumulator, record); + rows.set(rowKey, accumulator); } - return [...rows.values()].sort(compareUsageRows).slice(0, 10); + return [...rows.values()].map(finalizeUsageRow).sort(compareUsageRows).slice(0, 10); } function buildDailySkillUsage( records: SkillInvocationRecord[], range: SkillUsageRange, ): ProviderSkillDailyUsage[] { - const byDate = new Map>(); + const byDate = new Map>(); for (const date of datesInRange(range)) byDate.set(date, new Map()); for (const record of records) { const date = record.startedAt.slice(0, 10); - const rows = byDate.get(date) ?? new Map(); + const rows = byDate.get(date) ?? new Map(); const rowKey = usageRowKey(record); - const row = rows.get(rowKey) ?? createUsageRow(record); - addRecordToUsageRow(row, record); - rows.set(rowKey, row); + const accumulator = rows.get(rowKey) ?? createUsageAccumulator(record); + addRecordToUsageAccumulator(accumulator, record); + rows.set(rowKey, accumulator); byDate.set(date, rows); } return [...byDate.entries()].map(([date, rows]) => ({ date, - rows: [...rows.values()].sort(compareUsageRows), + rows: [...rows.values()].map(finalizeUsageRow).sort(compareUsageRows), })).sort((a, b) => a.date.localeCompare(b.date)); } function usageRowKey(record: SkillInvocationRecord): string { - return `${record.skillName}\0${sourcePathForRecord(record) ?? ''}`; + return `${record.skillName}\0${skillSourceIdentity(record)}`; } -function createUsageRow(record: SkillInvocationRecord): ProviderSkillUsageRow { +function skillSourceIdentity( + record: Pick, +): string { + const pluginSkill = record.provider === 'codex' + ? parseCodexPluginSkillPath(record.skillPath ?? '') ?? parseCodexPluginSkillPath(record.resolvedPath ?? '') + : undefined; + if (pluginSkill) { + return `codex-plugin\0${pluginSkill.marketplace}\0${pluginSkill.plugin}\0${pluginSkill.relativeSkillPath}`; + } + return record.resolvedPath ?? record.skillPath ?? ''; +} + +function createUsageAccumulator(record: SkillInvocationRecord): ProviderSkillUsageAccumulator { return { - skillName: record.skillName, - sourcePath: sourcePathForRecord(record), - countedInvocations: 0, - failedInvocations: 0, + row: { + skillName: record.skillName, + countedInvocations: 0, + failedInvocations: 0, + }, + recordCount: 0, + providerPathCount: 0, + providerPaths: new Set(), + resolvedPathCount: 0, + resolvedPaths: new Set(), }; } -function addRecordToUsageRow( - row: ProviderSkillUsageRow, +function addRecordToUsageAccumulator( + accumulator: ProviderSkillUsageAccumulator, record: SkillInvocationRecord, ): void { + accumulator.recordCount += 1; + if (record.skillPath) { + accumulator.providerPathCount += 1; + accumulator.providerPaths.add(record.skillPath); + } + if (record.resolvedPath) { + accumulator.resolvedPathCount += 1; + accumulator.resolvedPaths.add(record.resolvedPath); + } if (record.status === 'failed') { - row.failedInvocations += 1; + accumulator.row.failedInvocations += 1; } else { - row.countedInvocations += 1; + accumulator.row.countedInvocations += 1; + } + if (!accumulator.row.lastInvokedAt || record.startedAt > accumulator.row.lastInvokedAt) { + accumulator.row.lastInvokedAt = record.startedAt; } - if (!row.lastInvokedAt || record.startedAt > row.lastInvokedAt) row.lastInvokedAt = record.startedAt; +} + +function finalizeUsageRow(accumulator: ProviderSkillUsageAccumulator): ProviderSkillUsageRow { + const commonProviderPath = accumulator.providerPathCount === accumulator.recordCount + && accumulator.providerPaths.size === 1 + ? [...accumulator.providerPaths][0] + : undefined; + const commonResolvedPath = accumulator.resolvedPathCount === accumulator.recordCount + && accumulator.resolvedPaths.size === 1 + ? [...accumulator.resolvedPaths][0] + : undefined; + return { + ...accumulator.row, + sourcePath: commonProviderPath ?? commonResolvedPath, + }; } function compareUsageRows(a: ProviderSkillUsageRow, b: ProviderSkillUsageRow): number { @@ -767,10 +1092,6 @@ function compareOptionalTimestampDesc(left: string | undefined, right: string | return right.localeCompare(left); } -function sourcePathForRecord(record: Pick): string | undefined { - return record.skillPath ?? record.resolvedPath; -} - function findCurrentSkillSource( record: Pick, currentSkills: CurrentSkillReference[] | undefined, @@ -808,6 +1129,12 @@ function stringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function isValidInvocationTimestamp(value: string | undefined): value is string { + return value !== undefined + && /^\d{4}-\d{2}-\d{2}T/.test(value) + && !Number.isNaN(Date.parse(value)); +} + function isNodeError(value: unknown): value is NodeJS.ErrnoException { return value instanceof Error && 'code' in value; } diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index 0bcf80c..13ca46c 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -29,6 +29,20 @@ describe('SkillManager', () => { expect(manager.getAllSkills()).toHaveLength(1); }); + it('enables Claude in the default Skill Usage overview', async () => { + const usageManager = new SkillManager({ usageDataDir: path.join(dir, 'usage') }); + + const overview = await usageManager.getSkillUsageOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + + expect(overview.providers.find((provider) => provider.provider === 'claude')).toMatchObject({ + displayName: 'Claude', + coverageState: 'zero', + }); + }); + it('detects duplicates', async () => { const dir2 = await mkdtemp(path.join(tmpdir(), 'skillpack-mgr2-')); const provider2 = new CodexProvider([dir2]); diff --git a/packages/core/tests/usage-paths.test.ts b/packages/core/tests/usage-paths.test.ts new file mode 100644 index 0000000..30c2585 --- /dev/null +++ b/packages/core/tests/usage-paths.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { isSupportedClaudeArtifactPath } from '../src/usage-paths.js'; + +describe('Claude usage artifact paths', () => { + it('accepts supported shapes only within the configured Windows artifact root', () => { + const root = 'C:\\Users\\agent\\.claude\\projects'; + + expect(isSupportedClaudeArtifactPath( + root, + 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a.jsonl', + path.win32, + )).toBe(true); + expect(isSupportedClaudeArtifactPath( + root, + 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a\\subagents\\agent-a.jsonl', + path.win32, + )).toBe(true); + expect(isSupportedClaudeArtifactPath(root, 'D:\\session-a.jsonl', path.win32)).toBe(false); + expect(isSupportedClaudeArtifactPath( + root, + 'D:\\session-a\\subagents\\agent-a.jsonl', + path.win32, + )).toBe(false); + expect(isSupportedClaudeArtifactPath( + '\\\\server-a\\claude\\projects', + '\\\\server-b\\claude\\session-a.jsonl', + path.win32, + )).toBe(false); + }); +}); diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts index 4ce45c1..aaa8819 100644 --- a/packages/core/tests/usage.test.ts +++ b/packages/core/tests/usage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { SkillUsageManager } from '../src/usage.js'; @@ -41,6 +41,7 @@ describe('SkillUsageManager', () => { provider: 'codex', displayName: 'Codex', coverageState: 'unsupported', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -52,6 +53,74 @@ describe('SkillUsageManager', () => { } }); + it('returns not-configured Claude coverage when Skill Attribution Roots are missing', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + await mkdir(artifactRoot, { recursive: true }); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [], + }], + }); + + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + })).resolves.toMatchObject({ + providers: [{ + provider: 'claude', + coverageState: 'not-configured', + coverageReasons: ['missing-attribution-roots'], + diagnostics: [], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('reports every missing usage configuration prerequisite without an import result', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + configured: false, + artifactRoots: [], + skillRoots: [], + }], + }); + + await expect(usage.importAllProviders()).resolves.toEqual([]); + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + })).resolves.toMatchObject({ + providers: [{ + provider: 'claude', + coverageState: 'not-configured', + coverageReasons: [ + 'missing-artifact-roots', + 'missing-attribution-roots', + 'provider-not-configured', + ], + diagnostics: [], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('imports Codex skill reads from session and archived session JSONL using the stable scan rules', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -161,6 +230,117 @@ describe('SkillUsageManager', () => { } }); + it('reads only usage log months that overlap the requested range', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usageRoot = path.join(root, 'usage'); + const partitionRoot = path.join(usageRoot, 'invocations', 'codex'); + const historicalPartition = path.join(partitionRoot, '2025-12.jsonl'); + const requestedPartition = path.join(partitionRoot, '2026-07.jsonl'); + await mkdir(partitionRoot, { recursive: true }); + await writeFile(historicalPartition, JSON.stringify({ + schemaVersion: 1, + recordId: 'historical', + provider: 'codex', + sessionId: 'session-old', + turnId: 'turn-old', + skillName: 'historical-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2025-12-15T08:00:00Z', + }) + '\n'); + await writeFile(requestedPartition, JSON.stringify({ + schemaVersion: 1, + recordId: 'requested', + provider: 'codex', + sessionId: 'session-current', + turnId: 'turn-current', + skillName: 'requested-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-07T08:00:00Z', + }) + '\n'); + const oldAccessTime = new Date('2000-01-01T00:00:00.000Z'); + const oldModifiedTime = new Date('2020-01-01T00:00:00.000Z'); + await utimes(historicalPartition, oldAccessTime, oldModifiedTime); + await utimes(requestedPartition, oldAccessTime, oldModifiedTime); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [path.join(root, 'sessions')], + }], + }); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + + expect(overview.providers[0].ranking).toMatchObject([ + { skillName: 'requested-skill', countedInvocations: 1 }, + ]); + expect((await stat(requestedPartition)).atimeMs).toBeGreaterThan(oldAccessTime.getTime()); + expect((await stat(historicalPartition)).atimeMs).toBe(oldAccessTime.getTime()); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('reads every usage log month crossed by the requested range', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usageRoot = path.join(root, 'usage'); + const partitionRoot = path.join(usageRoot, 'invocations', 'codex'); + await mkdir(partitionRoot, { recursive: true }); + await writeFile(path.join(partitionRoot, '2026-07.jsonl'), JSON.stringify({ + schemaVersion: 1, + recordId: 'july', + provider: 'codex', + sessionId: 'session-july', + turnId: 'turn-july', + skillName: 'july-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-31T08:00:00Z', + }) + '\n'); + await writeFile(path.join(partitionRoot, '2026-08.jsonl'), JSON.stringify({ + schemaVersion: 1, + recordId: 'august', + provider: 'codex', + sessionId: 'session-august', + turnId: 'turn-august', + skillName: 'august-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-08-01T08:00:00Z', + }) + '\n'); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [path.join(root, 'sessions')], + }], + }); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-08-03T12:00:00Z'), + }); + + expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual([ + 'august-skill', + 'july-skill', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('records failed skill reads but excludes them from counted totals', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -381,6 +561,184 @@ describe('SkillUsageManager', () => { } }); + it('deduplicates Codex reads of one Skill Source Identity within a turn', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const aliasRoot = path.join(root, '.agents', 'skills'); + const sharedSkillDir = path.join(root, 'shared', 'skills', 'frontend-testing'); + const resolvedSkillPath = path.join(sharedSkillDir, 'SKILL.md'); + const aliasSkillDir = path.join(aliasRoot, 'frontend-testing'); + const aliasSkillPath = path.join(aliasSkillDir, 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(aliasRoot, { recursive: true }); + await mkdir(sharedSkillDir, { recursive: true }); + await writeFile(resolvedSkillPath, '---\nname: frontend-testing\n---\n'); + await symlink(sharedSkillDir, aliasSkillDir); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-alias', `cat ${aliasSkillPath}`, '2026-07-07T08:01:00Z'), + functionOutput('call-alias', 0, '2026-07-07T08:01:01Z'), + functionCall('call-resolved', `cat ${resolvedSkillPath}`, '2026-07-07T08:01:02Z'), + functionOutput('call-resolved', 0, '2026-07-07T08:01:03Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + const [storedRecord] = (await readFile( + path.join(root, 'usage', 'invocations', 'codex', '2026-07.jsonl'), + 'utf-8', + )).trim().split('\n').map((line) => JSON.parse(line) as { + skillPath?: string; + resolvedPath?: string; + }); + expect(storedRecord.skillPath).toBeUndefined(); + expect(storedRecord.resolvedPath).toBe(await realpath(resolvedSkillPath)); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toMatchObject([ + { + skillName: 'frontend-testing', + sourcePath: await realpath(resolvedSkillPath), + countedInvocations: 1, + }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('combines usage aliases by their resolved Skill Source Identity', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usageRoot = path.join(root, 'usage'); + const partitionRoot = path.join(usageRoot, 'invocations', 'claude'); + const resolvedPath = path.join(root, 'shared-skills', 'frontend-testing', 'SKILL.md'); + await mkdir(partitionRoot, { recursive: true }); + await writeFile(path.join(partitionRoot, '2026-07.jsonl'), jsonl([ + { + schemaVersion: 1, + recordId: 'alias-invocation', + provider: 'claude', + sessionId: 'session-a', + turnId: 'message-a', + skillName: 'frontend-testing', + skillPath: path.join(root, 'claude-skills', 'frontend-testing', 'SKILL.md'), + resolvedPath, + identityConfidence: 'confirmed', + status: 'used', + startedAt: '2026-07-07T09:00:00Z', + }, + { + schemaVersion: 1, + recordId: 'resolved-invocation', + provider: 'claude', + sessionId: 'session-b', + turnId: 'message-b', + skillName: 'frontend-testing', + resolvedPath, + identityConfidence: 'confirmed', + status: 'used', + startedAt: '2026-07-07T10:00:00Z', + }, + ])); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [path.join(root, 'claude-projects')], + skillRoots: [path.join(root, 'claude-skills')], + }], + }); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + + expect(overview.providers[0].ranking).toEqual([{ + skillName: 'frontend-testing', + sourcePath: resolvedPath, + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T10:00:00Z', + }]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('combines plugin skill usage across cached plugin versions', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const firstVersion = path.join(root, '.codex', 'plugins', 'cache', 'openai-curated', 'github', 'version-a', 'skills', 'github', 'SKILL.md'); + const secondVersion = path.join(root, '.codex', 'plugins', 'cache', 'openai-curated', 'github', 'version-b', 'skills', 'github', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(firstVersion), { recursive: true }); + await mkdir(path.dirname(secondVersion), { recursive: true }); + await writeFile(firstVersion, '---\nname: github\n---\n'); + await writeFile(secondVersion, '---\nname: github\n---\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-a', `cat ${firstVersion}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', root, '2026-07-07T09:00:02Z'), + functionCall('call-b', `cat ${secondVersion}`, '2026-07-07T09:01:00Z'), + functionOutput('call-b', 0, '2026-07-07T09:01:01Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await usage.importProvider('codex'); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toEqual([{ + skillName: 'github:github', + sourcePath: undefined, + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T09:01:00Z', + }]); + expect(overview.providers[0].dailySkillUsage.find((day) => day.date === '2026-07-07')?.rows).toEqual([ + { + skillName: 'github:github', + sourcePath: undefined, + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T09:01:00Z', + }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('limits Provider Skill Ranking to the top 10 counted skills', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -468,7 +826,609 @@ describe('SkillUsageManager', () => { const stored = await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'); expect(stored.trim().split('\n')).toHaveLength(1); - await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 6'); + await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 8'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports one Claude Skill tool use from a supported main session transcript', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(skillMdPath, '---\nname: frontend-testing\ndescription: Test frontend behavior\n---\n'); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toEqual({ + provider: 'claude', + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [], + }); + + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + })).resolves.toMatchObject({ + providers: [{ + provider: 'claude', + coverageState: 'active', + ranking: [{ + skillName: 'frontend-testing', + sourcePath: skillMdPath, + countedInvocations: 1, + failedInvocations: 0, + }], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('counts each Claude Skill tool use block in one assistant message', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + const assistant = claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }); + const message = assistant.message as { content: unknown[] }; + message.content.push({ + type: 'tool_use', + id: 'toolu-b', + name: 'Skill', + input: { skill: 'frontend-testing' }, + }); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([assistant])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 2 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + countedInvocations: 2, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('aggregates diagnostics for Claude Skill calls without an eligible source', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillRoot, { recursive: true }); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'statusline-setup', + timestamp: '2026-07-07T09:00:00Z', + }), + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-b', + skillName: 'statusline-setup', + timestamp: '2026-07-07T09:01:00Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toEqual({ + provider: 'claude', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'claude', + message: 'Skipped 2 Claude Skill invocations for "statusline-setup" in session-a.jsonl: no eligible user-level or plugin source', + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('records a Claude Skill invocation as failed from its structured tool result', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + claudeToolResult({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-a', + isError: true, + timestamp: '2026-07-07T09:00:05Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await usage.importProvider('claude'); + + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + })).resolves.toMatchObject({ + providers: [{ + heatmap: expect.arrayContaining([{ + date: '2026-07-07', + countedInvocations: 0, + failedInvocations: 1, + }]), + ranking: [{ + skillName: 'frontend-testing', + countedInvocations: 0, + failedInvocations: 1, + }], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports only supported Claude transcript path shapes', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const unsupportedDir = path.join(projectArtifacts, 'logs'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(unsupportedDir, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + await writeFile(path.join(unsupportedDir, 'trace.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-b', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:01:00Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toEqual({ + provider: 'claude', + importedRecords: 1, + skippedArtifacts: 1, + diagnostics: [{ + provider: 'claude', + message: `Skipped unsupported Claude artifact path: ${path.join('-tmp-project', 'logs', 'trace.jsonl')}`, + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('continues importing Claude transcripts with malformed JSONL lines', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + `${JSON.stringify(claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }))}\n{not-json\n[also-not-json\n`, + ); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toEqual({ + provider: 'claude', + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'claude', + message: 'Skipped 2 malformed JSONL lines in session-a.jsonl', + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('follows symlinked Claude subagent transcripts and imports each real artifact once', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const firstSubagents = path.join(artifactRoot, '-tmp-project', 'session-a', 'subagents'); + const secondSubagents = path.join(artifactRoot, '-tmp-project', 'session-b', 'subagents'); + const sharedArtifact = path.join(root, 'shared-agent.jsonl'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(firstSubagents, { recursive: true }); + await mkdir(secondSubagents, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + await writeFile(sharedArtifact, jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + await symlink(sharedArtifact, path.join(firstSubagents, 'agent-shared.jsonl')); + await symlink(sharedArtifact, path.join(secondSubagents, 'agent-shared.jsonl')); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [], + }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + countedInvocations: 1, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('collapses Claude skill aliases that resolve to the same source', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const sharedSkillDir = path.join(root, 'shared-skills', 'frontend-testing'); + const resolvedSkillMdPath = path.join(sharedSkillDir, 'SKILL.md'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillRoot, { recursive: true }); + await mkdir(sharedSkillDir, { recursive: true }); + await writeFile(resolvedSkillMdPath, '---\nname: frontend-testing\n---\n'); + await symlink(sharedSkillDir, path.join(skillRoot, 'frontend-testing-a')); + await symlink(sharedSkillDir, path.join(skillRoot, 'frontend-testing-b')); + const expectedResolvedSkillMdPath = await realpath(resolvedSkillMdPath); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + sourcePath: expectedResolvedSkillMdPath, + countedInvocations: 1, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('counts a Claude invocation without a source path when distinct sources share its name', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const firstSkillRoot = path.join(root, 'claude-skills-a'); + const secondSkillRoot = path.join(root, 'claude-skills-b'); + await mkdir(projectArtifacts, { recursive: true }); + for (const skillRoot of [firstSkillRoot, secondSkillRoot]) { + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + } + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [firstSkillRoot, secondSkillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + sourcePath: undefined, + countedInvocations: 1, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('revises a Claude invocation when a failed tool result arrives later', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const artifact = path.join(projectArtifacts, 'session-a.jsonl'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + await writeFile(artifact, jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); + await appendFile(artifact, jsonl([ + claudeToolResult({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-a', + isError: true, + timestamp: '2026-07-07T09:00:05Z', + }), + ])); + + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); + const revisions = (await readFile( + path.join(root, 'usage', 'invocations', 'claude', '2026-07.jsonl'), + 'utf-8', + )).trim().split('\n').map((line) => JSON.parse(line) as { + recordId: string; + provider: string; + startedAt: string; + }); + expect(revisions).toHaveLength(2); + expect(new Set(revisions.map((record) => record.recordId))).toHaveLength(1); + expect(new Set(revisions.map((record) => record.provider))).toEqual(new Set(['claude'])); + expect(new Set(revisions.map((record) => record.startedAt.slice(0, 7)))).toEqual(new Set(['2026-07'])); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].heatmap.find((cell) => cell.date === '2026-07-07')).toMatchObject({ + countedInvocations: 0, + failedInvocations: 1, + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + countedInvocations: 0, + failedInvocations: 1, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips Claude Skill calls with missing or invalid assistant timestamps', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const artifactRoot = path.join(root, 'claude-projects'); + const projectArtifacts = path.join(artifactRoot, '-tmp-project'); + const skillRoot = path.join(root, 'claude-skills'); + const skillDir = path.join(skillRoot, 'frontend-testing'); + await mkdir(projectArtifacts, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); + const untimedAssistant = claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }); + delete untimedAssistant.timestamp; + const untimedContent = (untimedAssistant.message as { content: unknown[] }).content; + untimedContent.push( + { type: 'tool_use', id: 'toolu-empty', name: 'Skill', input: { skill: ' ' } }, + { type: 'tool_use', id: 'toolu-invalid', name: 'Skill', input: 'frontend-testing' }, + ); + const invalidTimestampAssistant = claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-b', + skillName: 'frontend-testing', + timestamp: 'not-a-timestamp', + }); + await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ + untimedAssistant, + invalidTimestampAssistant, + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }], + }); + + await expect(usage.importProvider('claude')).resolves.toEqual({ + provider: 'claude', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'claude', + message: 'Skipped 2 Claude Skill invocations in session-a.jsonl: missing or invalid assistant timestamp', + }], + }); } finally { await rm(root, { recursive: true, force: true }); } @@ -593,3 +1553,51 @@ function customToolCall(callId: string, cmd: string, timestamp: string): Record< }, }; } + +function claudeAssistant(input: { + sessionId: string; + messageId: string; + toolUseId: string; + skillName: string; + timestamp: string; +}): Record { + return { + type: 'assistant', + uuid: input.messageId, + sessionId: input.sessionId, + timestamp: input.timestamp, + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: input.toolUseId, + name: 'Skill', + input: { skill: input.skillName }, + }], + }, + }; +} + +function claudeToolResult(input: { + sessionId: string; + messageId: string; + toolUseId: string; + isError: boolean; + timestamp: string; +}): Record { + return { + type: 'user', + uuid: input.messageId, + sessionId: input.sessionId, + timestamp: input.timestamp, + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: input.toolUseId, + is_error: input.isError, + content: 'Launching skill: frontend-testing', + }], + }, + }; +} diff --git a/packages/tui/src/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index be35a71..7b86d6e 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -37,8 +37,9 @@ export function useSkillManager(): SkillManagerResult { { provider: 'claude', displayName: 'Claude', - supported: false, + supported: true, artifactRoots: cfg.usage.artifactRoots.claude ?? [], + skillRoots: cfg.providers.claude?.paths ?? [], }, ], }); diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx index d770dc5..64531d7 100644 --- a/packages/tui/src/views/usage-view.tsx +++ b/packages/tui/src/views/usage-view.tsx @@ -123,9 +123,14 @@ export function UsageView() { const visibleRows = layout.visibleRows; const maxScrollOffset = Math.max(0, contentRows.length - visibleRows); - const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); + const boundedScrollOffset = Math.min(scrollOffset, maxScrollOffset); + const visibleContent = contentRows.slice(boundedScrollOffset, boundedScrollOffset + visibleRows); const showScroll = hasConsent && contentRows.length > visibleRows; + useEffect(() => { + if (scrollOffset > maxScrollOffset) setScrollOffset(maxScrollOffset); + }, [maxScrollOffset, scrollOffset]); + useInput((input, key) => { if (confirmingReset) return; if (input === 'q') { exit(); return; } @@ -151,18 +156,22 @@ export function UsageView() { return; } if (key.leftArrow && hasConsent) { + setScrollOffset(0); setSelectedDate((date) => moveDateWithinRange(date, overview, -WEEK_MS, selectedProvider?.heatmap, true)); return; } if (key.rightArrow && hasConsent) { + setScrollOffset(0); setSelectedDate((date) => moveDateWithinRange(date, overview, WEEK_MS, selectedProvider?.heatmap, true)); return; } if (key.upArrow && hasConsent) { + setScrollOffset(0); setSelectedDate((date) => moveDateWithinRange(date, overview, -DAY_MS, selectedProvider?.heatmap)); return; } if (key.downArrow && hasConsent) { + setScrollOffset(0); setSelectedDate((date) => moveDateWithinRange(date, overview, DAY_MS, selectedProvider?.heatmap)); return; } @@ -225,7 +234,7 @@ export function UsageView() { Skill Usage {rangeDays} days {showScroll && ( - {scrollOffset + 1}-{Math.min(scrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + {boundedScrollOffset + 1}-{Math.min(boundedScrollOffset + visibleRows, contentRows.length)} of {contentRows.length} )} @@ -351,7 +360,20 @@ function renderProviderTab(provider: ProviderSkillUsageOverview, selected: boole function emptyStateForProvider(provider: ProviderSkillUsageOverview): string { if (provider.coverageState === 'unsupported') return `Usage import is not supported for ${provider.displayName} yet.`; - if (provider.coverageState === 'not-configured') return `${provider.displayName} Usage Artifact Roots are not configured.`; + if (provider.coverageState === 'not-configured') { + const missingArtifactRoots = provider.coverageReasons.includes('missing-artifact-roots'); + const missingAttributionRoots = provider.coverageReasons.includes('missing-attribution-roots'); + if (missingArtifactRoots && missingAttributionRoots) { + return `${provider.displayName} Usage Artifact Roots and Skill Attribution Roots are not configured.`; + } + if (missingAttributionRoots) { + return `${provider.displayName} Skill Attribution Roots are not configured.`; + } + if (missingArtifactRoots) { + return `${provider.displayName} Usage Artifact Roots are not configured.`; + } + return `${provider.displayName} Usage Import is not configured.`; + } return `No usage records for ${provider.displayName}.`; } diff --git a/packages/tui/tests/helpers/mock-manager.ts b/packages/tui/tests/helpers/mock-manager.ts index d3a7888..ebc8fbf 100644 --- a/packages/tui/tests/helpers/mock-manager.ts +++ b/packages/tui/tests/helpers/mock-manager.ts @@ -59,6 +59,7 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk provider: 'codex', displayName: 'Codex', coverageState: 'unsupported', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -68,6 +69,7 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk provider: 'claude', displayName: 'Claude', coverageState: 'unsupported', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index fa67313..add50c4 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -252,6 +252,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'zero', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -261,6 +262,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'claude', displayName: 'Claude', coverageState: 'unsupported', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -283,6 +285,92 @@ describe.sequential('Terminal UI Tests', () => { expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 7 }); }); + it('explains missing Skill Attribution Roots in Skill Usage', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, + providers: [{ + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['missing-attribution-roots'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 30 }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => output.includes('Claude Skill Attribution Roots are not configured.')); + }); + + it('lists every missing Skill Usage configuration prerequisite', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, + providers: [{ + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['missing-artifact-roots', 'missing-attribution-roots'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 30 }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => ( + output.includes('Claude Usage Artifact Roots and Skill Attribution Roots are not configured.') + )); + }); + + it('shows generic Skill Usage configuration context without a missing root', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, + providers: [{ + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['provider-not-configured'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 30 }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => output.includes('Claude Usage Import is not configured.')); + }); + it('renders the redesigned Skill Usage layout and keyboard model', async () => { const manager = createMockManager(); manager.getSkillUsageOverview.mockImplementation(async ({ rangeDays }) => ({ @@ -292,6 +380,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'active', + coverageReasons: [], heatmap: [ { date: '2026-07-02', countedInvocations: 0, failedInvocations: 0 }, { date: '2026-07-03', countedInvocations: 1, failedInvocations: 0 }, @@ -326,6 +415,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'claude', displayName: 'Claude', coverageState: 'unsupported', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -385,6 +475,56 @@ describe.sequential('Terminal UI Tests', () => { )); }); + it('clamps Skill Usage scrolling when the selected day has fewer rows', async () => { + const longDayRows = Array.from({ length: 30 }, (_, index) => ({ + skillName: `skill-${String(index).padStart(2, '0')}`, + sourcePath: `/Users/twwu/.agents/skills/skill-${String(index).padStart(2, '0')}/SKILL.md`, + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-08T10:00:00Z', + })); + const manager = createMockManager({ + usageOverview: { + range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, + providers: [{ + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + coverageReasons: [], + heatmap: [ + { date: '2026-07-07', countedInvocations: 0, failedInvocations: 0 }, + { date: '2026-07-08', countedInvocations: 30, failedInvocations: 0 }, + ], + dailySkillUsage: [ + { date: '2026-07-07', rows: [] }, + { date: '2026-07-08', rows: longDayRows }, + ], + ranking: longDayRows.slice(0, 10), + diagnostics: [], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 18 }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('2026-07-08 · Codex')); + + app.stdin.write(keypress.end); + await waitForFrame(app, (output) => output.includes('skill-09') && output.includes('36-50 of 50')); + + app.stdin.write(keypress.up); + const frame = await waitForFrame(app, (output) => ( + output.includes('No skill usage on this day.') + && output.includes('1-15 of 25') + )); + expect(frame).not.toContain('36-25 of 25'); + }); + it('shows Skill Usage heatmap counts and Provider Skill Ranking', async () => { const manager = createMockManager({ usageOverview: { @@ -393,6 +533,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'active', + coverageReasons: [], heatmap: [ { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, @@ -450,6 +591,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'active', + coverageReasons: [], heatmap: [ { date: '2026-07-06', countedInvocations: 1, failedInvocations: 1 }, ], @@ -498,6 +640,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'zero', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], @@ -507,6 +650,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'claude', displayName: 'Claude', coverageState: 'active', + coverageReasons: [], heatmap: [ { date: '2026-07-07', countedInvocations: 3, failedInvocations: 0 }, ], @@ -552,6 +696,7 @@ describe.sequential('Terminal UI Tests', () => { provider: 'codex', displayName: 'Codex', coverageState: 'zero', + coverageReasons: [], heatmap: [], dailySkillUsage: [], ranking: [], From 1f35d30fb70beed9ea76b73a4fae70acec420f88 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 14 Jul 2026 11:44:13 +0800 Subject: [PATCH 2/5] fix: import orchestrated Codex skill usage Support current custom_tool_call exec envelopes with bounded Acorn-based static evidence extraction while retaining legacy imports. Document provider-specific cursor migration, evidence status, diagnostics, and safety limits. --- ...rive-skill-usage-from-provider-sessions.md | 13 +- ...bounded-incremental-skill-usage-imports.md | 6 +- ...-skill-invocation-status-evidence-based.md | 2 + docs/prds/skill-usage-tracing.md | 18 +- packages/core/package.json | 1 + packages/core/src/codex-usage-evidence.ts | 1276 ++++++++++++ packages/core/src/usage.ts | 197 +- packages/core/tests/usage.test.ts | 1744 ++++++++++++++++- pnpm-lock.yaml | 10 + 9 files changed, 3086 insertions(+), 181 deletions(-) create mode 100644 packages/core/src/codex-usage-evidence.ts diff --git a/docs/adr/0039-derive-skill-usage-from-provider-sessions.md b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md index 3e0cdea..b293389 100644 --- a/docs/adr/0039-derive-skill-usage-from-provider-sessions.md +++ b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md @@ -2,6 +2,17 @@ Skillpack will derive Skill Usage by reading provider-owned session artifacts read-only, not by adding runtime hooks or instrumentation to agent platforms. This keeps Skillpack aligned with provider-native state: providers remain the source of truth for Skill Invocations, while Skillpack normalizes their session evidence into usage history and aggregates. -The first supported adapter is Codex. It reads the real Codex session JSONL event streams under `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`, using `session_meta` and `turn_context` for session/turn/cwd context and `response_item` `function_call` tool calls for evidence that the agent directly read a `.../skills//SKILL.md` file. Codex usage is global across configured artifact roots and is not scoped to Skillpack's startup cwd. `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. +The first supported adapter is Codex. It reads the real Codex session JSONL event streams under `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`, using `session_meta` and `turn_context` for session/turn/cwd context. Direct `.../skills//SKILL.md` reads are invocation evidence in both historical `response_item` `function_call` / `exec_command` envelopes and current orchestrated `response_item` `custom_tool_call` / `exec` envelopes. Both envelopes remain supported because importer-version rebuilds reprocess historical sessions; the envelope is provider evidence, not part of Skill Invocation identity. Codex usage is global across configured artifact roots and is not scoped to Skillpack's startup cwd. `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. + +Representative provider shapes make the compatibility boundary explicit: + +```json +{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"legacy-call","arguments":"{\"cmd\":\"cat .agents/skills/tdd/SKILL.md\"}"}} +{"type":"response_item","payload":{"type":"custom_tool_call","name":"exec","call_id":"current-call","input":"await tools.exec_command({ cmd: \"cat .agents/skills/tdd/SKILL.md\" });"}} +``` + +Orchestrated `exec` input is executable-looking provider evidence, but Skillpack treats it as untrusted data. The core uses Acorn only to parse that input into an ESTree syntax tree, then a Skillpack-owned evaluator resolves the observed static subset: literals and static string composition, statically initialized `const` string/object/array bindings whose evaluated values are not observably mutated, property reads and shorthand properties, multiple direct calls, statically known array `.map()` and `for...of` iterations, and `Promise.all()` wrappers. Unshadowed Codex orchestration helpers (`text`, `image`, `generatedImage`, `store`, `load`, `notify`, and `yield_control`) are transparent only for ordered argument inspection; unknown calls, spread arguments, shadowed helpers or built-ins, implicit object coercion, thenable assimilation, observable mutation, unsupported control flow, and otherwise unprovable behavior stop the remaining path. Existing direct-read matching runs only after static evaluation has produced a concrete shell command, and ignores paths after an unquoted shell-comment marker. A raw-source `SKILL.md` check may inform diagnostics but must not gate parsing, because supported static composition can construct that token without containing it contiguously in source. Skillpack never evaluates or executes session input. Acorn does not identify skills, run commands, or provide general JavaScript interpretation; mutable bindings, runtime function results, dynamic properties, and otherwise unprovable commands are not Skill Invocation evidence. + +Static interpretation is bounded per orchestrated envelope: at most 1,000,000 source code units, 10,000 evaluation steps, 10,000 static iterations, 100 nested evaluation frames, 256 resolved commands, 256 extracted Skill paths per command, 1,000,000 code units in one derived string, and 10,000,000 cumulatively retained derived-string code units. String limits are checked before concatenation so exponential static composition cannot allocate an oversized intermediate value. Exceeding any budget fails closed by discarding partial command evidence from that envelope. The adapter emits high-signal Usage Import Diagnostics only when parsing fails with potential Skill evidence, a real nested `tools.exec_command` with potential Skill evidence cannot resolve its command or working directory inside the supported static subset, or a budget is exceeded while fixed Skill evidence is present. Findings are aggregated by artifact and reason (`invalid-javascript`, `unresolved-command`, `unresolved-workdir`, or `analysis-budget-exceeded`) without retaining source JavaScript, commands, or output. Unrelated `exec` programs and dynamic calls with no Skill evidence are ignored so ordinary orchestration does not create diagnostic noise. The Claude adapter reads known main-session and subagent JSONL transcript paths under configured Claude artifact roots and treats assistant `tool_use` blocks with `name === "Skill"` as invocation evidence. It follows JSONL file symlinks, deduplicates real artifacts, and does not parse unrelated JSONL path shapes or loader-log text as invocation evidence. Malformed lines are skipped with aggregated diagnostics, while valid auto-compact summaries are ignored because they cannot restore earlier structured calls. diff --git a/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md index 7081885..78e4e51 100644 --- a/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md +++ b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md @@ -2,9 +2,11 @@ Skillpack will run a cheap incremental Skill Usage Import on startup using provider-specific Usage Import Cursors, and expose deeper rescans from the Usage view for selected time ranges. This keeps usage data current during normal app launches without making inventory refresh depend on repeatedly parsing all provider session artifacts. -Cursors include an importer version as well as source artifact size and mtime. When a parser bug is fixed or a provider adapter starts recognizing additional real session evidence, Skillpack can invalidate old cursors and rescan unchanged artifacts once without requiring users to reset derived usage data. +Cursors include a provider-specific importer version as well as source artifact size and mtime. When a parser bug is fixed or one provider adapter starts recognizing additional real session evidence, Skillpack invalidates only that provider's old cursor and rescans its unchanged artifacts once without requiring users to reset derived usage data or rebuild unrelated providers. -Provider invocation identity and deduplication changes also increment the importer version. The stale provider partitions are rebuilt before reimport so obsolete Invocation Record IDs cannot coexist with records generated under the corrected identity rule. +The current importer-version mapping is Codex `9` and Claude `8`. Codex moved from `8` to `9` when current orchestrated `custom_tool_call` / `exec` envelopes became supported; Claude remains at `8` because its parser and identity rules did not change. + +Provider invocation identity and deduplication changes also increment that provider's importer version. Its stale provider partitions are rebuilt before reimport so obsolete Invocation Record IDs cannot coexist with records generated under the corrected identity rule. After consent, a provider's first supported import scans all known artifacts and retains every recoverable invocation, even when the current Usage view shows a shorter date range. Later imports use cursors; display ranges are query boundaries, not retention boundaries. diff --git a/docs/adr/0044-keep-skill-invocation-status-evidence-based.md b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md index 24a5144..a4f61d2 100644 --- a/docs/adr/0044-keep-skill-invocation-status-evidence-based.md +++ b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md @@ -4,4 +4,6 @@ Skillpack will normalize Skill Invocation Status to provider-evidence states suc For Codex, a direct `SKILL.md` read with a zero exit code is recorded as `used`; a direct `SKILL.md` read with a non-zero exit code is recorded as `failed`; missing output evidence remains `unknown`. This means failed reads are preserved as facts but do not inflate normal invocation counts. +For orchestrated Codex `exec` envelopes, the outer `completed` status and script-level output describe the JavaScript wrapper rather than any individual nested shell command. A statically confirmed `SKILL.md` read therefore remains `unknown` unless provider evidence supplies a reliable one-to-one nested exit result. Skillpack does not infer nested success or failure from wrapper status, human-readable output, or error text; `unknown` remains a Counted Invocation. + For Claude, a structured `Skill` tool use is recorded as `used` unless a matching structured tool result has `is_error === true`, which records `failed`. Claude records without a valid assistant timestamp are skipped rather than dated from the transcript file's modification time. diff --git a/docs/prds/skill-usage-tracing.md b/docs/prds/skill-usage-tracing.md index 01d314d..0467f31 100644 --- a/docs/prds/skill-usage-tracing.md +++ b/docs/prds/skill-usage-tracing.md @@ -57,13 +57,15 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - The first shippable implementation should prove Codex end to end before adding Claude. - The Codex adapter reads the real `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl` event formats, not a synthetic `skill_invocation` format. - `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. -- Codex skill usage is derived only from `response_item` events with `payload.type === "function_call"` where an `exec_command` directly reads `.../skills//SKILL.md`; `session_meta` and `turn_context` provide session, turn, and cwd context for session identity, turn identity, and relative path resolution. +- Codex skill usage is derived only from supported `response_item` execution evidence where an `exec_command` directly reads `.../skills//SKILL.md`. The adapter supports both historical `payload.type === "function_call"` / `name === "exec_command"` envelopes and current orchestrated `payload.type === "custom_tool_call"` / `name === "exec"` envelopes. Both normalize to the same Skill Invocation candidate model; `session_meta` and `turn_context` provide session, turn, and cwd context for session identity, turn identity, and relative path resolution. +- Orchestrated `exec` input is parsed as untrusted data with Acorn, which is a direct core runtime dependency used only to produce an ESTree syntax tree. A Skillpack-owned static evaluator never executes session JavaScript and supports literals and static string composition, statically initialized `const` string/object/array bindings whose evaluated values are not observably mutated, property reads and shorthand properties, multiple direct calls, statically known array `.map()` and `for...of` iterations, and `Promise.all()` wrappers. Unshadowed Codex orchestration helpers (`text`, `image`, `generatedImage`, `store`, `load`, `notify`, and `yield_control`) are transparent only for ordered argument inspection. Direct `SKILL.md` path matching is applied only to concrete shell commands produced by static evaluation and ignores paths after an unquoted shell-comment marker. Raw-source `SKILL.md` checks may inform diagnostics but must not skip AST parsing, because supported static composition can construct that token without containing it contiguously in source. Unknown calls, spread arguments, shadowed helpers or built-ins, implicit object coercion, thenable assimilation, observable mutation, unsupported control flow, and otherwise unprovable paths stop inference. Acorn does not identify skills or run commands. +- Static interpretation is bounded per orchestrated envelope to 1,000,000 source code units, 10,000 evaluation steps, 10,000 static iterations, 100 nested evaluation frames, 256 resolved commands, 256 extracted Skill paths per command, 1,000,000 code units in one derived string, and 10,000,000 cumulatively retained derived-string code units. String limits are checked before concatenation. A budget breach fails closed and discards partial command evidence from that envelope. Orchestrated parsing produces high-signal Usage Import Diagnostics only when parsing fails with potential Skill evidence, a real nested `tools.exec_command` with potential Skill evidence cannot resolve its command or working directory inside the supported static subset, or a budget is exceeded while fixed Skill evidence is present. Diagnostics are aggregated by artifact and reason and never retain source JavaScript, commands, or output. Unrelated programs and dynamic calls with no Skill evidence are silently ignored. - Codex import excludes system/developer text, AGENTS.md text, available-skill lists, rendered skill metadata, ordinary text mentions, and search/script output such as `rg`, `grep`, or `python` commands that merely mention `SKILL.md`. - Codex usage is global across configured Codex artifact roots and is not scoped to Skillpack's startup cwd. - Codex records are deduplicated by `(turn_id, skill_name, Skill Source Identity)`, and Codex Invocation Record IDs use the same identity, so segmented or aliased reads of one logical Skill in one turn count once while same-named skills with distinct source identities remain separate. - Codex skill names are normalized from the skill path directory, with plugin-owned Codex skills represented as `plugin-name:skill-name`. - Codex plugin cache paths are aggregated by stable marketplace, plugin, and skill identity while ignoring the cache version directory. This normalized Plugin-Owned Skill identity takes precedence over `resolvedPath`; otherwise distinct real cache targets would recreate one row per version. If one aggregate contains more than one concrete cache path, its displayed Skill Source Path is blank rather than choosing an arbitrary historical version. -- Codex `function_call_output` exit-code evidence maps successful reads to `used` and failed reads to `failed`. +- Historical Codex `function_call_output` exit-code evidence maps successful reads to `used` and failed reads to `failed`. For orchestrated `exec` envelopes, outer wrapper completion and script-level output are not nested command status evidence; a statically confirmed read remains `unknown` unless a reliable one-to-one nested exit result is present. Skillpack does not infer nested status from wrapper status, human-readable output, or error text. - Claude usage is derived from assistant `tool_use` blocks where `name === "Skill"` in Claude project JSONL transcripts and subagent JSONL transcripts. - The initial Claude Usage Provider Adapter counts only user-level and plugin-provided skills found through configured Skill Attribution Roots. Project Skills and Built-In Skills are excluded. - Claude source paths are resolved best-effort from current known Claude skill locations. If an invocation name matches more than one user-level or plugin-provided source, Skillpack counts the invocation with a blank source path rather than assigning it to an arbitrary source. If it matches no eligible source, Skillpack skips it with a Usage Import Diagnostic because the transcript cannot distinguish deleted user content from Built-In or deferred Project Skills. @@ -100,8 +102,9 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - `importAllProviders()` omits providers whose import did not run because required configuration is missing. `SkillUsageImportResult` represents an attempted import; configuration gaps are exposed through Usage Coverage State and Usage Coverage Reasons instead of synthetic import results or Usage Import Diagnostics. - Usage Provider Adapters parse only stable, known provider artifact formats. - Unsupported provider evidence is skipped with Usage Import Diagnostics. Ambiguous source attribution may still be counted when the provider evidence proves a real Skill Invocation by name. -- Usage Import Cursors include an importer version so parser fixes can force a one-time rescan of unchanged artifacts. -- Changes to provider invocation identity or deduplication semantics increment the importer version and rebuild that provider's derived records so obsolete record IDs do not remain alongside corrected records. +- Usage Import Cursors include a provider-specific importer version so one adapter's parser fixes can force a one-time rescan of its unchanged artifacts without rebuilding unrelated providers. +- The current importer-version mapping is Codex `9` and Claude `8`; Codex `9` introduces current orchestrated `custom_tool_call` / `exec` evidence while Claude's parser remains unchanged at `8`. +- Changes to provider invocation identity or deduplication semantics increment that provider's importer version and rebuild only that provider's derived records so obsolete record IDs do not remain alongside corrected records. - The Skill Usage Log is append-only JSONL partitioned by Session-Producing Provider and invocation month; repeated Invocation Record IDs are revisions and only the latest record is current. Every revision of one Invocation Record ID preserves its provider and `startedAt` month. Range queries treat the `YYYY-MM.jsonl` partition name as authoritative and read only months overlapping the requested date range; parser changes that would move records across months require an importer-version rebuild. - Skill Usage Records are retained until Skill Usage Reset. - Skill Usage Reset deletes Skillpack's derived Skill Usage Log and Usage Import Cursors only. @@ -166,7 +169,14 @@ interface SkillInvocationRecord { - Tests should exercise public interfaces through real temporary files rather than mocking internal storage functions. - Provider session artifacts are local-substitutable dependencies: tests should use fixture directories and files that represent supported provider formats. - Usage Provider Adapter tests should verify observable import results and diagnostics, not internal parser steps. +- Codex adapter fixtures should retain regression coverage for both historical direct execution envelopes and current orchestrated execution envelopes so compatibility cannot regress independently. +- Codex execution evidence extraction should live behind one pure batch internal module interface that accepts one artifact's `sourceFile` and JSONL `lines`, then returns minimal Skill read evidence plus aggregated finding reasons. The module owns session/turn/cwd state, envelope dispatch, Acorn parsing, static evaluation, final-command Skill path matching, output correlation, status evidence, and diagnostic aggregation; it must not expose AST nodes, source JavaScript, shell commands, or output to callers. +- Orchestrated execution fixtures should cover the supported static subset plus strings, comments, dynamic expressions, lexical shadowing, unsupported control flow, mutation, helper argument ordering, analysis-budget exhaustion, and malformed JavaScript that must not be executed or mistaken for invocation evidence. +- Static-subset fixtures should include multiple direct calls, statically initialized `const` bindings without observable mutation, static template interpolation, static array `.map()`, static `for...of`, and `Promise.all()` wrappers, while mutable or runtime-derived values remain negative cases. +- A fixture that statically constructs `SKILL.md` from separate string fragments must remain countable, proving that raw-source pattern checks are not a parsing gate. +- Diagnostic fixtures should verify aggregation by artifact and reason, omit raw source/command/output, and avoid findings for unrelated or dynamically unresolvable exec calls with no Skill evidence. - JSONL store tests should verify idempotent repeated imports, provider/month partitioning, retention until reset, and range-bounded reads. +- Cursor tests should verify that a provider-specific importer version change rebuilds only the affected provider while preserving unrelated provider partitions and cursors. - Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, distinct source-identity separation, resolved-target alias folding, plugin cache version folding, and provider-specific rankings. - TUI tests can be added where the existing Ink test harness supports them; otherwise the first TUI slice should keep rendering logic factored so core aggregate behavior carries most coverage. - Do not mock Skillpack's own modules from each other in core tests. diff --git a/packages/core/package.json b/packages/core/package.json index 97d1281..50b5916 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,7 @@ "test:watch": "vitest" }, "dependencies": { + "acorn": "^8.17.0", "yaml": "2.9.0" }, "devDependencies": { diff --git a/packages/core/src/codex-usage-evidence.ts b/packages/core/src/codex-usage-evidence.ts new file mode 100644 index 0000000..6c63379 --- /dev/null +++ b/packages/core/src/codex-usage-evidence.ts @@ -0,0 +1,1276 @@ +import { + parse, + type BlockStatement, + type CallExpression, + type Expression, + type ModuleDeclaration, + type Pattern, + type Program, + type Statement, +} from 'acorn'; +import os from 'node:os'; +import path from 'node:path'; + +export type CodexSkillReadStatus = 'loaded' | 'used' | 'failed' | 'unknown'; + +export type CodexEvidenceFindingReason = + | 'analysis-budget-exceeded' + | 'invalid-javascript' + | 'unresolved-command' + | 'unresolved-workdir'; + +export interface CodexEvidenceFinding { + lineIndex: number; + reason: CodexEvidenceFindingReason; + count: number; +} + +export interface CodexSkillReadEvidence { + callId: string; + lineIndex: number; + sessionId: string; + turnId: string; + skillPath: string; + status: CodexSkillReadStatus; + startedAt: string; + endedAt?: string; +} + +export interface CodexUsageEvidenceResult { + reads: CodexSkillReadEvidence[]; + findings: CodexEvidenceFinding[]; +} + +interface ExecCommand { + cmd: string; + workdir?: string; + workdirUnresolved?: boolean; +} + +interface ExecCommandResolution { + command?: ExecCommand; + reason?: CodexEvidenceFindingReason; + potentialSkillEvidence: boolean; +} + +interface ExecCandidate extends ExecCommand { + callId: string; + format: 'legacy' | 'orchestrated'; + lineIndex: number; + sessionId: string; + turnId: string; + contextCwd?: string; + startedAt: string; + statusHint?: CodexSkillReadStatus; +} + +interface OutputEvidence { + endedAt?: string; + output?: string; +} + +interface StaticObject { + kind: 'object'; + properties: Map; +} + +interface StaticArray { + kind: 'array'; + items: StaticValue[]; +} + +interface StaticFunction { + kind: 'function'; + params: Pattern[]; + body: BlockStatement | Expression; + environment: StaticEnvironment; + localName?: string; +} + +interface StaticOpaqueObject { + kind: 'opaque-object'; +} + +interface StaticOpaqueLeaf { + kind: 'opaque-leaf'; +} + +type StaticValue = string | StaticObject | StaticArray | StaticFunction | StaticOpaqueObject | StaticOpaqueLeaf; +const UNRESOLVED = Symbol('unresolved'); +const UNINITIALIZED = Symbol('uninitialized'); +const STATIC_OPAQUE_OBJECT: StaticOpaqueObject = { kind: 'opaque-object' }; +const STATIC_OPAQUE_LEAF: StaticOpaqueLeaf = { kind: 'opaque-leaf' }; +type StaticEvaluation = StaticValue | typeof UNRESOLVED; +type StaticBinding = StaticEvaluation | typeof UNINITIALIZED; +type StaticEnvironment = Map; +const ARRAY_MUTATOR_METHODS = new Set([ + 'copyWithin', + 'fill', + 'pop', + 'push', + 'reverse', + 'shift', + 'sort', + 'splice', + 'unshift', +]); +const CODEX_ORCHESTRATION_HELPERS = new Set([ + 'generatedImage', + 'image', + 'load', + 'notify', + 'store', + 'text', + 'yield_control', +]); +const MAX_PROGRAM_SOURCE_LENGTH = 1_000_000; +const MAX_STATIC_EVALUATION_STEPS = 10_000; +const MAX_STATIC_ITERATIONS = 10_000; +const MAX_STATIC_COMMANDS = 256; +const MAX_STATIC_EVALUATION_DEPTH = 100; +const MAX_STATIC_STRING_LENGTH = 1_000_000; +const MAX_STATIC_STRING_CODE_UNITS = 10_000_000; +const MAX_SKILL_PATHS_PER_COMMAND = 256; +const UNSUPPORTED_RUNTIME_EXPRESSIONS = new Set([ + 'BinaryExpression', + 'ChainExpression', + 'ClassExpression', + 'ConditionalExpression', + 'ImportExpression', + 'LogicalExpression', + 'NewExpression', + 'SequenceExpression', + 'TaggedTemplateExpression', + 'UnaryExpression', + 'YieldExpression', +]); + +interface EvaluationContext { + budgetExceeded: boolean; + commands: ExecCommand[]; + commandCount: number; + evaluationDepth: number; + evaluationSteps: number; + findings: CodexEvidenceFinding[]; + iterations: number; + lineIndex: number; + pathUnsupported: boolean; + source: string; + staticStringCodeUnits: number; +} + +type ExecutionCompletion = + | { kind: 'normal' } + | { kind: 'return'; value: StaticEvaluation } + | { kind: 'break' } + | { kind: 'continue' } + | { kind: 'unsupported' }; + +export function deriveCodexUsageEvidence(input: { + sourceFile: string; + lines: readonly string[]; +}): CodexUsageEvidenceResult { + const candidates: ExecCandidate[] = []; + const outputs = new Map(); + const findings: CodexEvidenceFinding[] = []; + let sessionId = path.basename(input.sourceFile, '.jsonl'); + let turnId = sessionId; + let cwd: string | undefined; + + for (const [lineIndex, line] of input.lines.entries()) { + const event = parseJsonRecord(line); + if (!event) continue; + const payload = isRecord(event.payload) ? event.payload : {}; + + if (event.type === 'session_meta') { + sessionId = stringValue(payload.session_id) ?? stringValue(payload.id) ?? sessionId; + turnId = sessionId; + cwd = stringValue(payload.cwd) ?? cwd; + continue; + } + if (event.type === 'event_msg' && stringValue(payload.type) === 'task_started') { + turnId = stringValue(payload.turn_id) ?? turnId; + continue; + } + if (event.type === 'turn_context') { + turnId = stringValue(payload.turn_id) ?? turnId; + cwd = stringValue(payload.cwd) ?? cwd; + continue; + } + if (event.type !== 'response_item') continue; + + const payloadType = stringValue(payload.type); + const callId = stringValue(payload.call_id); + if (payloadType === 'function_call' && stringValue(payload.name) === 'exec_command') { + const startedAt = stringValue(event.timestamp); + const command = parseLegacyExecCommand(payload); + if (!callId || !startedAt || !command) continue; + candidates.push({ + ...command, + callId, + format: 'legacy', + lineIndex, + sessionId, + turnId, + contextCwd: cwd, + startedAt, + statusHint: normalizeStatus(stringValue(payload.status)), + }); + continue; + } + if (payloadType === 'custom_tool_call' && stringValue(payload.name) === 'exec') { + const startedAt = stringValue(event.timestamp); + const source = stringValue(payload.input); + if (!callId || !startedAt || !source) continue; + const parsed = parseOrchestratedExecCommands(source, lineIndex); + findings.push(...parsed.findings); + for (const command of parsed.commands) { + candidates.push({ + ...command, + callId, + format: 'orchestrated', + lineIndex, + sessionId, + turnId, + contextCwd: cwd, + startedAt, + }); + } + continue; + } + if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') { + if (!callId) continue; + outputs.set(callId, { + endedAt: stringValue(event.timestamp), + output: payloadType === 'function_call_output' ? stringValue(payload.output) : undefined, + }); + } + } + + const reads: CodexSkillReadEvidence[] = []; + for (const candidate of candidates) { + const commandCwd = resolveCommandCwd(candidate.workdir, candidate.contextCwd); + const rawSkillPaths = extractDirectSkillReadPaths(candidate.cmd); + if (rawSkillPaths.length > MAX_SKILL_PATHS_PER_COMMAND) { + findings.push({ + lineIndex: candidate.lineIndex, + reason: 'analysis-budget-exceeded', + count: 1, + }); + continue; + } + const unresolvedRelativePathCount = rawSkillPaths.filter(skillPathNeedsCwd).length; + const relativePathsAreUnresolved = candidate.workdirUnresolved === true || commandCwd === undefined; + if (relativePathsAreUnresolved && unresolvedRelativePathCount > 0) { + findings.push({ + lineIndex: candidate.lineIndex, + reason: 'unresolved-workdir', + count: unresolvedRelativePathCount, + }); + } + const output = outputs.get(candidate.callId); + const status = candidate.format === 'orchestrated' + ? 'unknown' + : statusFromLegacyOutput(output?.output, candidate.statusHint); + for (const rawSkillPath of rawSkillPaths) { + if (relativePathsAreUnresolved && skillPathNeedsCwd(rawSkillPath)) continue; + const skillPath = resolveSkillPath(rawSkillPath, commandCwd); + if (!skillPath) continue; + reads.push({ + callId: candidate.callId, + lineIndex: candidate.lineIndex, + sessionId: candidate.sessionId, + turnId: candidate.turnId, + skillPath, + status, + startedAt: candidate.startedAt, + endedAt: output?.endedAt, + }); + } + } + + return { reads, findings: aggregateFindings(findings) }; +} + +function parseLegacyExecCommand(payload: Record): ExecCommand | undefined { + const raw = stringValue(payload.arguments) ?? stringValue(payload.input); + if (!raw) return undefined; + const value = parseJsonRecord(raw); + if (!value) return undefined; + const cmd = stringValue(value.cmd); + if (!cmd) return undefined; + return { cmd, workdir: stringValue(value.workdir) }; +} + +function parseOrchestratedExecCommands(source: string, lineIndex: number): { + commands: ExecCommand[]; + findings: CodexEvidenceFinding[]; +} { + if (source.length > MAX_PROGRAM_SOURCE_LENGTH) { + return { + commands: [], + findings: hasPotentialSkillEvidence(source) + ? [{ lineIndex, reason: 'analysis-budget-exceeded', count: 1 }] + : [], + }; + } + let program: Program; + try { + program = parse(source, { ecmaVersion: 'latest', sourceType: 'module' }); + } catch { + return { + commands: [], + findings: hasPotentialSkillEvidence(source) + ? [{ lineIndex, reason: 'invalid-javascript', count: 1 }] + : [], + }; + } + + const context: EvaluationContext = { + budgetExceeded: false, + commands: [], + commandCount: 0, + evaluationDepth: 0, + evaluationSteps: 0, + findings: [], + iterations: 0, + lineIndex, + pathUnsupported: false, + source, + staticStringCodeUnits: 0, + }; + executeStatements(program.body, new Map(), context, true); + return { commands: context.commands, findings: context.findings }; +} + +function enterStaticEvaluation(context: EvaluationContext): boolean { + if (context.budgetExceeded) return false; + context.evaluationSteps += 1; + if (context.evaluationSteps > MAX_STATIC_EVALUATION_STEPS + || context.evaluationDepth >= MAX_STATIC_EVALUATION_DEPTH) { + exceedAnalysisBudget(context); + return false; + } + context.evaluationDepth += 1; + return true; +} + +function consumeStatementStep(context: EvaluationContext): boolean { + if (context.budgetExceeded) return false; + context.evaluationSteps += 1; + if (context.evaluationSteps > MAX_STATIC_EVALUATION_STEPS) { + exceedAnalysisBudget(context); + return false; + } + return true; +} + +function consumeIteration(context: EvaluationContext): boolean { + if (context.budgetExceeded) return false; + context.iterations += 1; + if (context.iterations > MAX_STATIC_ITERATIONS) { + exceedAnalysisBudget(context); + return false; + } + return true; +} + +function recordResolvedCommand(command: ExecCommand, context: EvaluationContext): void { + if (context.budgetExceeded) return; + if (extractDirectSkillReadPaths(command.cmd).length > MAX_SKILL_PATHS_PER_COMMAND) { + exceedAnalysisBudget(context); + return; + } + context.commandCount += 1; + if (context.commandCount > MAX_STATIC_COMMANDS) { + exceedAnalysisBudget(context); + return; + } + context.commands.push(command); +} + +function retainStaticString(value: string, context: EvaluationContext): StaticEvaluation { + if (value.length > MAX_STATIC_STRING_LENGTH + || context.staticStringCodeUnits > MAX_STATIC_STRING_CODE_UNITS - value.length) { + exceedAnalysisBudget(context); + return UNRESOLVED; + } + context.staticStringCodeUnits += value.length; + return value; +} + +function composeStaticString(parts: readonly string[], context: EvaluationContext): StaticEvaluation { + let length = 0; + for (const part of parts) { + if (part.length > MAX_STATIC_STRING_LENGTH - length) { + exceedAnalysisBudget(context); + return UNRESOLVED; + } + length += part.length; + } + if (context.staticStringCodeUnits > MAX_STATIC_STRING_CODE_UNITS - length) { + exceedAnalysisBudget(context); + return UNRESOLVED; + } + context.staticStringCodeUnits += length; + return parts.join(''); +} + +function exceedAnalysisBudget(context: EvaluationContext): void { + if (context.budgetExceeded) return; + context.budgetExceeded = true; + context.pathUnsupported = true; + context.commands.length = 0; + context.findings.length = 0; + if (hasPotentialSkillEvidence(context.source)) { + context.findings.push({ + lineIndex: context.lineIndex, + reason: 'analysis-budget-exceeded', + count: 1, + }); + } +} + +function resolveExecCommand( + call: CallExpression, + environment: StaticEnvironment, + context: EvaluationContext, +): ExecCommandResolution { + const argument = call.arguments[0]; + if (!argument || argument.type === 'SpreadElement') { + return { reason: 'unresolved-command', potentialSkillEvidence: false }; + } + const options = evaluateStatic(argument, environment, context); + if (!isStaticObject(options)) return { reason: 'unresolved-command', potentialSkillEvidence: false }; + const cmd = options.properties.get('cmd'); + if (typeof cmd !== 'string') return { reason: 'unresolved-command', potentialSkillEvidence: false }; + const potentialSkillEvidence = hasPotentialSkillEvidence(cmd); + const hasWorkdir = options.properties.has('workdir'); + const workdir = options.properties.get('workdir'); + if (hasWorkdir && typeof workdir !== 'string') { + return { + command: { cmd, workdirUnresolved: true }, + potentialSkillEvidence, + }; + } + return { command: { + cmd, + workdir: typeof workdir === 'string' ? workdir : undefined, + }, potentialSkillEvidence }; +} + +function recordUnresolvedExecFinding( + call: CallExpression, + reason: CodexEvidenceFindingReason | undefined, + potentialSkillEvidence: boolean, + context: EvaluationContext, +): void { + if (!reason || (!potentialSkillEvidence && !hasPotentialSkillEvidence(context.source.slice(call.start, call.end)))) { + return; + } + context.findings.push({ + lineIndex: context.lineIndex, + reason, + count: 1, + }); +} + +function recordPathUnsupportedExecFinding( + call: CallExpression, + resolution: ExecCommandResolution, + context: EvaluationContext, +): void { + if (context.budgetExceeded) return; + if (!resolution.command) { + recordUnresolvedExecFinding( + call, + resolution.reason ?? 'unresolved-command', + resolution.potentialSkillEvidence, + context, + ); + return; + } + const skillPaths = extractDirectSkillReadPaths(resolution.command.cmd); + if (skillPaths.length > MAX_SKILL_PATHS_PER_COMMAND) { + exceedAnalysisBudget(context); + return; + } + const relativeSkillPathCount = skillPaths.filter(skillPathNeedsCwd).length; + if (resolution.command.workdirUnresolved && relativeSkillPathCount > 0) { + context.findings.push({ + lineIndex: context.lineIndex, + reason: 'unresolved-workdir', + count: relativeSkillPathCount, + }); + } else if (skillPaths.length > 0) { + context.findings.push({ + lineIndex: context.lineIndex, + reason: 'unresolved-command', + count: skillPaths.length, + }); + } +} + +function hasPotentialSkillEvidence(value: string): boolean { + return value.includes('SKILL.md'); +} + +function aggregateFindings(findings: CodexEvidenceFinding[]): CodexEvidenceFinding[] { + const aggregated = new Map(); + for (const finding of findings) { + const existing = aggregated.get(finding.reason); + if (existing) { + existing.count += finding.count; + } else { + aggregated.set(finding.reason, { ...finding }); + } + } + return [...aggregated.values()].sort((left, right) => ( + left.lineIndex - right.lineIndex || left.reason.localeCompare(right.reason) + )); +} + +function isToolsExecCommand(call: CallExpression, environment: StaticEnvironment): boolean { + if (environment.has('tools')) return false; + if (call.callee.type !== 'MemberExpression' + || call.callee.object.type !== 'Identifier' + || call.callee.object.name !== 'tools') { + return false; + } + if (!call.callee.computed) { + return call.callee.property.type === 'Identifier' + && call.callee.property.name === 'exec_command'; + } + return call.callee.property.type === 'Literal' + && call.callee.property.value === 'exec_command'; +} + +function propertyName(key: Expression): string | undefined { + if (key.type === 'Identifier') return key.name; + return key.type === 'Literal' && (typeof key.value === 'string' || typeof key.value === 'number') + ? String(key.value) + : undefined; +} + +function evaluateStatic( + expression: Expression, + environment: StaticEnvironment, + context: EvaluationContext, +): StaticEvaluation { + if (!enterStaticEvaluation(context)) return UNRESOLVED; + try { + return evaluateStaticExpression(expression, environment, context); + } finally { + context.evaluationDepth -= 1; + } +} + +function evaluateStaticExpression( + expression: Expression, + environment: StaticEnvironment, + context: EvaluationContext, +): StaticEvaluation { + if (expression.type === 'Literal') { + return typeof expression.value === 'string' + ? retainStaticString(expression.value, context) + : UNRESOLVED; + } + if (expression.type === 'Identifier') { + if (environment.has(expression.name)) { + const binding = environment.get(expression.name); + if (binding === UNINITIALIZED) { + context.pathUnsupported = true; + return UNRESOLVED; + } + return binding ?? UNRESOLVED; + } + context.pathUnsupported = true; + return UNRESOLVED; + } + if (expression.type === 'AssignmentExpression') { + context.pathUnsupported = true; + return UNRESOLVED; + } + if (expression.type === 'UpdateExpression') { + context.pathUnsupported = true; + return UNRESOLVED; + } + if (expression.type === 'UnaryExpression' && expression.operator === 'delete') { + context.pathUnsupported = true; + return UNRESOLVED; + } + if (expression.type === 'BinaryExpression' && expression.operator === '+') { + if (expression.left.type === 'PrivateIdentifier') return UNRESOLVED; + const left = evaluateStatic(expression.left, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + const right = evaluateStatic(expression.right, environment, context); + if (typeof left !== 'string' || typeof right !== 'string') { + context.pathUnsupported = true; + return UNRESOLVED; + } + return composeStaticString([left, right], context); + } + if (expression.type === 'TemplateLiteral') { + const parts: string[] = []; + for (const [index, quasi] of expression.quasis.entries()) { + parts.push(quasi.value.cooked ?? quasi.value.raw); + const substitution = expression.expressions[index]; + if (!substitution) continue; + const resolved = evaluateStatic(substitution, environment, context); + if (typeof resolved !== 'string') { + context.pathUnsupported = true; + return UNRESOLVED; + } + parts.push(resolved); + } + return composeStaticString(parts, context); + } + if (expression.type === 'ObjectExpression') { + const properties = new Map(); + for (const candidate of expression.properties) { + if (candidate.type !== 'Property' || candidate.kind !== 'init' || candidate.computed) { + context.pathUnsupported = true; + return UNRESOLVED; + } + const name = propertyName(candidate.key); + if (name === undefined) { + context.pathUnsupported = true; + return UNRESOLVED; + } + properties.set(name, evaluateStatic(candidate.value, environment, context)); + if (context.pathUnsupported) return { kind: 'object', properties }; + } + return { kind: 'object', properties }; + } + if (expression.type === 'ArrayExpression') { + const items: StaticValue[] = []; + let allItemsResolved = true; + for (const element of expression.elements) { + if (!element) { + allItemsResolved = false; + continue; + } + if (element.type === 'SpreadElement') { + context.pathUnsupported = true; + return UNRESOLVED; + } + const value = evaluateStatic(element, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + if (value === UNRESOLVED) allItemsResolved = false; + else items.push(value); + } + return allItemsResolved ? { kind: 'array', items } : UNRESOLVED; + } + if (expression.type === 'MemberExpression') { + if (expression.object.type === 'Super') return UNRESOLVED; + const object = evaluateStatic(expression.object, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + const name = memberPropertyName(expression.property, expression.computed, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + if (object === UNRESOLVED) { + context.pathUnsupported = true; + return UNRESOLVED; + } + if (isStaticOpaqueObject(object)) return STATIC_OPAQUE_LEAF; + if (isStaticOpaqueLeaf(object)) { + context.pathUnsupported = true; + return UNRESOLVED; + } + if (!isStaticObject(object)) return UNRESOLVED; + if (name === undefined) { + context.pathUnsupported = true; + return UNRESOLVED; + } + return object.properties.get(name) ?? UNRESOLVED; + } + if (expression.type === 'ArrowFunctionExpression' || expression.type === 'FunctionExpression') { + if (expression.generator) return UNRESOLVED; + return { + kind: 'function', + params: expression.params, + body: expression.body, + environment, + localName: expression.type === 'FunctionExpression' ? expression.id?.name : undefined, + }; + } + if (expression.type === 'AwaitExpression') { + const awaited = evaluateStatic(expression.argument, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + if (awaited === UNRESOLVED + || isStaticOpaqueLeaf(awaited) + || (isStaticObject(awaited) && awaited.properties.has('then'))) { + context.pathUnsupported = true; + return UNRESOLVED; + } + return awaited; + } + if (expression.type === 'CallExpression') { + if (isToolsExecCommand(expression, environment)) { + if (expression.arguments.length !== 1 || expression.arguments[0].type === 'SpreadElement') { + context.pathUnsupported = true; + recordUnresolvedExecFinding( + expression, + 'unresolved-command', + false, + context, + ); + return UNRESOLVED; + } + const resolution = resolveExecCommand(expression, environment, context); + if (context.pathUnsupported) { + recordPathUnsupportedExecFinding(expression, resolution, context); + return UNRESOLVED; + } + if (resolution.command) { + recordResolvedCommand(resolution.command, context); + return context.pathUnsupported ? UNRESOLVED : STATIC_OPAQUE_OBJECT; + } + recordUnresolvedExecFinding( + expression, + resolution.reason, + resolution.potentialSkillEvidence, + context, + ); + context.pathUnsupported = true; + return UNRESOLVED; + } + if (invalidateKnownMutationCall(expression, environment, context)) return UNRESOLVED; + if (isPromiseAllCall(expression, environment)) { + const argument = expression.arguments[0]; + if (expression.arguments.length !== 1 + || !argument + || argument.type === 'SpreadElement') { + context.pathUnsupported = true; + return UNRESOLVED; + } + const iterable = evaluateStatic(argument, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + if (!isStaticArray(iterable) || iterable.items.some(isUnprovenPromiseInput)) { + context.pathUnsupported = true; + return UNRESOLVED; + } + return STATIC_OPAQUE_OBJECT; + } + if (isArrayMapCall(expression)) { + const receiver = evaluateStatic(expression.callee.object as Expression, environment, context); + const callbackArgument = expression.arguments[0]; + if (context.pathUnsupported) return UNRESOLVED; + if (!isStaticArray(receiver) + || expression.arguments.length !== 1 + || !callbackArgument + || callbackArgument.type === 'SpreadElement') { + context.pathUnsupported = true; + return UNRESOLVED; + } + const callback = evaluateStatic(callbackArgument, environment, context); + if (context.pathUnsupported) return UNRESOLVED; + if (!isStaticFunction(callback)) { + context.pathUnsupported = true; + return UNRESOLVED; + } + const results: StaticValue[] = []; + let allResultsResolved = true; + for (const item of receiver.items) { + if (!consumeIteration(context)) return UNRESOLVED; + const result = invokeStaticFunction(callback, [item], context); + if (context.pathUnsupported) return UNRESOLVED; + if (result === UNRESOLVED) { + allResultsResolved = false; + } else { + results.push(result); + } + } + return allResultsResolved ? { kind: 'array', items: results } : UNRESOLVED; + } + if (isKnownOrchestrationHelperCall(expression, environment)) { + for (const argument of expression.arguments) { + if (argument.type === 'SpreadElement') { + context.pathUnsupported = true; + break; + } + evaluateStatic(argument, environment, context); + if (context.pathUnsupported) break; + } + return UNRESOLVED; + } + context.pathUnsupported = true; + } + if (UNSUPPORTED_RUNTIME_EXPRESSIONS.has(expression.type)) context.pathUnsupported = true; + return UNRESOLVED; +} + +function executeStatements( + statements: readonly (Statement | ModuleDeclaration)[], + environment: StaticEnvironment, + context: EvaluationContext, + functionScope = false, +): ExecutionCompletion { + if (functionScope) predeclareFunctionScopedBindings(statements, environment); + predeclareBindings(statements, environment); + for (const statement of statements) { + if (!consumeStatementStep(context)) return { kind: 'unsupported' }; + if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + const value = declaration.init + ? evaluateStatic(declaration.init, environment, context) + : UNRESOLVED; + if (context.pathUnsupported) return { kind: 'unsupported' }; + if (declaration.id.type === 'Identifier') { + environment.set(declaration.id.name, statement.kind === 'const' ? value : UNRESOLVED); + } + } + continue; + } + if (statement.type === 'ExpressionStatement') { + evaluateStatic(statement.expression, environment, context); + if (context.pathUnsupported) return { kind: 'unsupported' }; + continue; + } + if (statement.type === 'BlockStatement') { + const completion = executeStatements(statement.body, new Map(environment), context); + if (completion.kind !== 'normal') return completion; + continue; + } + if (statement.type === 'ForOfStatement') { + const completion = executeForOf(statement, environment, context); + if (completion.kind !== 'normal') return completion; + continue; + } + if (statement.type === 'ReturnStatement') { + const value = statement.argument + ? evaluateStatic(statement.argument, environment, context) + : UNRESOLVED; + if (context.pathUnsupported) return { kind: 'unsupported' }; + return { + kind: 'return', + value, + }; + } + if (statement.type === 'BreakStatement') return { kind: 'break' }; + if (statement.type === 'ContinueStatement') return { kind: 'continue' }; + if (statement.type === 'EmptyStatement' + || statement.type === 'DebuggerStatement' + || statement.type === 'FunctionDeclaration') { + continue; + } + return { kind: 'unsupported' }; + } + return { kind: 'normal' }; +} + +function predeclareBindings( + statements: readonly (Statement | ModuleDeclaration)[], + environment: StaticEnvironment, +): void { + for (const statement of statements) { + if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + bindPattern( + declaration.id, + environment, + statement.kind === 'var' ? UNRESOLVED : UNINITIALIZED, + ); + } + } else if (statement.type === 'FunctionDeclaration') { + environment.set(statement.id.name, UNRESOLVED); + } else if (statement.type === 'ClassDeclaration') { + environment.set(statement.id.name, UNINITIALIZED); + } else if (statement.type === 'ImportDeclaration') { + for (const specifier of statement.specifiers) environment.set(specifier.local.name, UNRESOLVED); + } + } +} + +function predeclareFunctionScopedBindings( + statements: readonly (Statement | ModuleDeclaration)[], + environment: StaticEnvironment, +): void { + for (const statement of statements) collectFunctionScopedBindings(statement, environment); +} + +function collectFunctionScopedBindings( + statement: Statement | ModuleDeclaration, + environment: StaticEnvironment, +): void { + if (statement.type === 'VariableDeclaration') { + if (statement.kind !== 'var') return; + for (const declaration of statement.declarations) { + bindPattern(declaration.id, environment, UNRESOLVED); + } + return; + } + if (statement.type === 'BlockStatement') { + for (const child of statement.body) collectFunctionScopedBindings(child, environment); + return; + } + if (statement.type === 'IfStatement') { + collectFunctionScopedBindings(statement.consequent, environment); + if (statement.alternate) collectFunctionScopedBindings(statement.alternate, environment); + return; + } + if (statement.type === 'ForStatement') { + if (statement.init?.type === 'VariableDeclaration') { + collectFunctionScopedBindings(statement.init, environment); + } + collectFunctionScopedBindings(statement.body, environment); + return; + } + if (statement.type === 'ForInStatement' || statement.type === 'ForOfStatement') { + if (statement.left.type === 'VariableDeclaration') { + collectFunctionScopedBindings(statement.left, environment); + } + collectFunctionScopedBindings(statement.body, environment); + return; + } + if (statement.type === 'WhileStatement' || statement.type === 'DoWhileStatement') { + collectFunctionScopedBindings(statement.body, environment); + return; + } + if (statement.type === 'SwitchStatement') { + for (const switchCase of statement.cases) { + for (const child of switchCase.consequent) { + collectFunctionScopedBindings(child, environment); + } + } + return; + } + if (statement.type === 'TryStatement') { + collectFunctionScopedBindings(statement.block, environment); + if (statement.handler) collectFunctionScopedBindings(statement.handler.body, environment); + if (statement.finalizer) collectFunctionScopedBindings(statement.finalizer, environment); + return; + } + if (statement.type === 'LabeledStatement' || statement.type === 'WithStatement') { + collectFunctionScopedBindings(statement.body, environment); + return; + } + if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { + collectFunctionScopedBindings(statement.declaration, environment); + } +} + +function bindPattern( + pattern: Pattern, + environment: StaticEnvironment, + value: StaticBinding, +): void { + if (pattern.type === 'Identifier') { + environment.set(pattern.name, value); + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + bindPattern( + property.type === 'RestElement' ? property.argument : property.value, + environment, + value, + ); + } + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + if (element) bindPattern(element, environment, value); + } + } else if (pattern.type === 'RestElement') { + bindPattern(pattern.argument, environment, value); + } else if (pattern.type === 'AssignmentPattern') { + bindPattern(pattern.left, environment, value); + } +} + +function invalidateStaticValue(value: StaticEvaluation): void { + if (isStaticObject(value)) { + value.properties.clear(); + } else if (isStaticArray(value)) { + value.items.length = 0; + } +} + +function invalidateKnownMutationCall( + call: CallExpression, + environment: StaticEnvironment, + context: EvaluationContext, +): boolean { + if (isObjectAssignCall(call, environment)) { + const target = call.arguments[0]; + if (!target || target.type === 'SpreadElement') { + context.pathUnsupported = true; + return true; + } + const value = evaluateStatic(target, environment, context); + if (context.pathUnsupported) return true; + for (const argument of call.arguments.slice(1)) { + if (argument.type === 'SpreadElement') { + context.pathUnsupported = true; + return true; + } + evaluateStatic(argument, environment, context); + if (context.pathUnsupported) return true; + } + invalidateStaticValue(value); + const rootName = target.type === 'Identifier' ? target.name : memberRootIdentifier(target); + if (rootName) environment.set(rootName, UNRESOLVED); + context.pathUnsupported = true; + return true; + } + if (call.callee.type !== 'MemberExpression' || call.callee.object.type === 'Super') return false; + const method = memberPropertyName(call.callee.property, call.callee.computed, environment, context); + if (context.pathUnsupported) return true; + if (!method || !ARRAY_MUTATOR_METHODS.has(method)) return false; + const receiver = evaluateStatic(call.callee.object, environment, context); + if (context.pathUnsupported) return true; + if (!isStaticArray(receiver)) return false; + for (const argument of call.arguments) { + if (argument.type === 'SpreadElement') { + context.pathUnsupported = true; + return true; + } + evaluateStatic(argument, environment, context); + if (context.pathUnsupported) return true; + } + invalidateStaticValue(receiver); + const rootName = memberRootIdentifier(call.callee.object); + if (rootName) environment.set(rootName, UNRESOLVED); + context.pathUnsupported = true; + return true; +} + +function isObjectAssignCall(call: CallExpression, environment: StaticEnvironment): boolean { + if (environment.has('Object')) return false; + return call.callee.type === 'MemberExpression' + && !call.callee.computed + && call.callee.object.type === 'Identifier' + && call.callee.object.name === 'Object' + && call.callee.property.type === 'Identifier' + && call.callee.property.name === 'assign'; +} + +function memberRootIdentifier(expression: Expression): string | undefined { + if (expression.type === 'Identifier') return expression.name; + if (expression.type !== 'MemberExpression' || expression.object.type === 'Super') return undefined; + return memberRootIdentifier(expression.object); +} + +function executeForOf( + statement: Extract, + environment: StaticEnvironment, + context: EvaluationContext, +): ExecutionCompletion { + if (statement.left.type !== 'VariableDeclaration' + || statement.left.kind !== 'const' + || statement.left.declarations.length !== 1 + || statement.left.declarations[0].id.type !== 'Identifier') { + return { kind: 'unsupported' }; + } + const iterable = evaluateStatic(statement.right, environment, context); + if (context.pathUnsupported) return { kind: 'unsupported' }; + if (!isStaticArray(iterable)) return { kind: 'unsupported' }; + const binding = statement.left.declarations[0].id.name; + for (const item of iterable.items) { + if (!consumeIteration(context)) return { kind: 'unsupported' }; + const iterationEnvironment = new Map(environment); + iterationEnvironment.set(binding, item); + const completion = executeStatements([statement.body], iterationEnvironment, context); + if (completion.kind === 'normal' || completion.kind === 'continue') continue; + if (completion.kind === 'break') return { kind: 'normal' }; + return completion; + } + return { kind: 'normal' }; +} + +function invokeStaticFunction( + fn: StaticFunction, + arguments_: StaticEvaluation[], + context: EvaluationContext, +): StaticEvaluation { + const functionEnvironment = new Map(fn.environment); + if (fn.localName) functionEnvironment.set(fn.localName, UNRESOLVED); + for (const [index, parameter] of fn.params.entries()) { + if (parameter.type !== 'Identifier') { + context.pathUnsupported = true; + return UNRESOLVED; + } + functionEnvironment.set(parameter.name, arguments_[index] ?? UNRESOLVED); + } + if (fn.body.type !== 'BlockStatement') return evaluateStatic(fn.body, functionEnvironment, context); + const completion = executeStatements(fn.body.body, functionEnvironment, context, true); + if (completion.kind === 'unsupported') context.pathUnsupported = true; + return completion.kind === 'return' ? completion.value : UNRESOLVED; +} + +function memberPropertyName( + property: Expression | { type: 'PrivateIdentifier' }, + computed: boolean, + environment: StaticEnvironment, + context: EvaluationContext, +): string | undefined { + if (!computed) return property.type === 'Identifier' ? property.name : undefined; + if (property.type === 'PrivateIdentifier') return undefined; + const value = evaluateStatic(property, environment, context); + return typeof value === 'string' ? value : undefined; +} + +function isPromiseAllCall(call: CallExpression, environment: StaticEnvironment): boolean { + if (environment.has('Promise')) return false; + return call.callee.type === 'MemberExpression' + && !call.callee.computed + && call.callee.object.type === 'Identifier' + && call.callee.object.name === 'Promise' + && call.callee.property.type === 'Identifier' + && call.callee.property.name === 'all'; +} + +function isKnownOrchestrationHelperCall( + call: CallExpression, + environment: StaticEnvironment, +): boolean { + return call.callee.type === 'Identifier' + && !environment.has(call.callee.name) + && CODEX_ORCHESTRATION_HELPERS.has(call.callee.name); +} + +function isArrayMapCall(call: CallExpression): call is CallExpression & { + callee: Extract; +} { + return call.callee.type === 'MemberExpression' + && !call.callee.computed + && call.callee.object.type !== 'Super' + && call.callee.property.type === 'Identifier' + && call.callee.property.name === 'map'; +} + +function isStaticObject(value: StaticEvaluation): value is StaticObject { + return typeof value !== 'string' && value !== UNRESOLVED && value.kind === 'object'; +} + +function isStaticArray(value: StaticEvaluation): value is StaticArray { + return typeof value !== 'string' && value !== UNRESOLVED && value.kind === 'array'; +} + +function isStaticFunction(value: StaticEvaluation): value is StaticFunction { + return typeof value !== 'string' && value !== UNRESOLVED && value.kind === 'function'; +} + +function isStaticOpaqueObject(value: StaticEvaluation): value is StaticOpaqueObject { + return typeof value !== 'string' && value !== UNRESOLVED && value.kind === 'opaque-object'; +} + +function isStaticOpaqueLeaf(value: StaticEvaluation): value is StaticOpaqueLeaf { + return typeof value !== 'string' && value !== UNRESOLVED && value.kind === 'opaque-leaf'; +} + +function isUnprovenPromiseInput(value: StaticValue): boolean { + return isStaticOpaqueLeaf(value) + || (isStaticObject(value) && value.properties.has('then')); +} + +function extractDirectSkillReadPaths(command: string): string[] { + const paths: string[] = []; + const uncommentedCommand = stripUnquotedShellComments(command); + const segments = uncommentedCommand.split(/\s*(?:&&|\|\||;|\n)\s*/); + for (const segment of segments) { + const commandName = path.basename(segment.trim().match(/^(\S+)/)?.[1] ?? ''); + if (!['cat', 'sed', 'nl', 'head', 'tail', 'bat'].includes(commandName)) continue; + const pathPattern = /(?:^|\s|['"])(~?\.?\.?\/?[^'"\s]*\/skills\/[^'"\s]+\/SKILL\.md)(?=$|\s|['"])/g; + for (const match of segment.matchAll(pathPattern)) { + const skillPath = match[1]; + if (!skillPath.includes('<') && !skillPath.includes('>')) { + paths.push(skillPath); + if (paths.length > MAX_SKILL_PATHS_PER_COMMAND) return paths; + } + } + } + return paths; +} + +function stripUnquotedShellComments(command: string): string { + let uncommented = ''; + let quote: "'" | '"' | undefined; + let escaped = false; + let inComment = false; + let atWordStart = true; + for (const character of command) { + if (inComment) { + if (character === '\n') { + inComment = false; + atWordStart = true; + uncommented += character; + } + continue; + } + if (escaped) { + escaped = false; + atWordStart = false; + uncommented += character; + continue; + } + if (quote) { + if (character === quote) quote = undefined; + else if (quote === '"' && character === '\\') escaped = true; + uncommented += character; + continue; + } + if (character === '\\') { + escaped = true; + atWordStart = false; + uncommented += character; + } else if (character === "'" || character === '"') { + quote = character; + atWordStart = false; + uncommented += character; + } else if (character === '#' && atWordStart) { + inComment = true; + } else { + atWordStart = /\s/.test(character); + uncommented += character; + } + } + return uncommented; +} + +function resolveCommandCwd(workdir: string | undefined, cwd: string | undefined): string | undefined { + if (!workdir) return cwd && path.isAbsolute(cwd) ? cwd : undefined; + if (path.isAbsolute(workdir)) return workdir; + return cwd && path.isAbsolute(cwd) ? path.resolve(cwd, workdir) : undefined; +} + +function skillPathNeedsCwd(skillPath: string): boolean { + return !path.isAbsolute(skillPath) && skillPath !== '~' && !skillPath.startsWith('~/'); +} + +function resolveSkillPath(rawSkillPath: string, cwd: string | undefined): string | undefined { + if (rawSkillPath === '~') return os.homedir(); + if (rawSkillPath.startsWith('~/')) return path.join(os.homedir(), rawSkillPath.slice(2)); + if (path.isAbsolute(rawSkillPath)) return path.normalize(rawSkillPath); + return cwd ? path.resolve(cwd, rawSkillPath) : undefined; +} + +function statusFromLegacyOutput( + output: string | undefined, + statusHint: CodexSkillReadStatus | undefined, +): CodexSkillReadStatus { + const exitCode = output?.match(/Process exited with code\s+(\d+)/)?.[1] + ?? output?.match(/Exit code:\s+(\d+)/)?.[1] + ?? output?.match(/Exit status\s+(\d+)/)?.[1]; + if (exitCode !== undefined) return exitCode === '0' ? 'used' : 'failed'; + return statusHint ?? 'unknown'; +} + +function normalizeStatus(value: string | undefined): CodexSkillReadStatus { + if (value === 'loaded' || value === 'used' || value === 'failed') return value; + return 'unknown'; +} + +function parseJsonRecord(value: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index d143694..e92b077 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -2,10 +2,14 @@ import { createHash } from 'node:crypto'; import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { deriveCodexUsageEvidence } from './codex-usage-evidence.js'; import { parseSkillMd } from './parser.js'; import { isSupportedClaudeArtifactPath } from './usage-paths.js'; -const USAGE_IMPORTER_VERSION = 8; +const USAGE_IMPORTER_VERSIONS: Readonly> = { + codex: 9, + claude: 8, +}; export type UsageCoverageState = 'unsupported' | 'not-configured' | 'zero' | 'active'; export type UsageCoverageReason = 'missing-artifact-roots' | 'missing-attribution-roots' | 'provider-not-configured'; @@ -116,16 +120,6 @@ interface ParsedInvocationResult { diagnostics?: UsageImportDiagnostic[]; } -interface CodexSkillReadCandidate { - callId: string; - lineIndex: number; - sessionId: string; - turnId: string; - startedAt: string; - skillPath: string; - statusHint?: SkillInvocationStatus; -} - interface ClaudeSkillCandidate { name: string; skillPath: string; @@ -371,12 +365,13 @@ export class SkillUsageManager { const cursorPath = path.join(this.dataDir, 'cursors', `${provider}.json`); const content = await readFile(cursorPath, 'utf-8').catch(() => ''); if (!content) return { provider, artifacts: {} }; + const importerVersion = usageImporterVersion(provider); try { const cursor = JSON.parse(content) as UsageImportCursor; - if (cursor.importerVersion !== USAGE_IMPORTER_VERSION) return { provider, stale: true, artifacts: {} }; + if (cursor.importerVersion !== importerVersion) return { provider, stale: true, artifacts: {} }; return { provider, - importerVersion: USAGE_IMPORTER_VERSION, + importerVersion, artifacts: cursor.artifacts ?? {}, }; } catch { @@ -390,12 +385,16 @@ export class SkillUsageManager { await writeFile(cursorPath, JSON.stringify({ ...cursor, provider, - importerVersion: USAGE_IMPORTER_VERSION, + importerVersion: usageImporterVersion(provider), }, null, 2) + '\n', 'utf-8'); } } +function usageImporterVersion(provider: string): number { + return USAGE_IMPORTER_VERSIONS[provider] ?? 8; +} + function coverageStateFor(provider: SkillUsageProviderConfig, recordCount = 0): UsageCoverageState { if (!provider.supported) return 'unsupported'; if (coverageReasonsFor(provider).length > 0) return 'not-configured'; @@ -466,81 +465,16 @@ async function parseCodexInvocationLines( currentSkills?: CurrentSkillReference[], ): Promise { const ambiguousLineNumbers: number[] = []; - const candidates: CodexSkillReadCandidate[] = []; - const outputs = new Map(); - let sessionId = path.basename(sourceFile, '.jsonl'); - let currentTurnId = sessionId; - let cwd: string | undefined; - - for (const [index, line] of lines.entries()) { - let value: unknown; - try { - value = JSON.parse(line); - } catch { - continue; - } - if (!isRecord(value)) continue; - - const payload = isRecord(value.payload) ? value.payload : {}; - if (value.type === 'session_meta') { - sessionId = stringValue(payload.session_id) ?? stringValue(payload.id) ?? sessionId; - currentTurnId = sessionId; - cwd = stringValue(payload.cwd) ?? cwd; - continue; - } - if (value.type === 'event_msg' && stringValue(payload.type) === 'task_started') { - currentTurnId = stringValue(payload.turn_id) ?? currentTurnId; - continue; - } - if (value.type === 'turn_context') { - currentTurnId = stringValue(payload.turn_id) ?? currentTurnId; - cwd = stringValue(payload.cwd) ?? cwd; - continue; - } - if (value.type !== 'response_item') continue; - - const payloadType = stringValue(payload.type); - if (payloadType === 'function_call') { - const callId = stringValue(payload.call_id); - const toolName = stringValue(payload.name); - const startedAt = stringValue(value.timestamp); - if (!callId || toolName !== 'exec_command' || !startedAt) continue; - - const command = parseExecCommand(payload); - if (!command?.cmd) continue; - const skillPaths = extractDirectSkillReadPaths(command.cmd); - if (skillPaths.length === 0) continue; - - const commandCwd = resolveCommandCwd(command.workdir, cwd); - for (const rawSkillPath of skillPaths) { - const skillPath = resolveSkillPath(rawSkillPath, commandCwd); - candidates.push({ - callId, - lineIndex: index, - sessionId, - turnId: currentTurnId, - startedAt, - skillPath, - statusHint: normalizeStatus(stringValue(payload.status)), - }); - } - } else if (payloadType === 'function_call_output') { - const callId = stringValue(payload.call_id); - const output = stringValue(payload.output); - const endedAt = stringValue(value.timestamp); - if (callId && output) outputs.set(callId, { output, endedAt }); - } - } + const evidence = deriveCodexUsageEvidence({ sourceFile, lines }); const recordsByTurnAndSkill = new Map(); - for (const candidate of candidates) { + for (const candidate of evidence.reads) { const skillIdentity = await readSkillIdentity(candidate.skillPath); const skillName = skillIdentity.name; if (!skillName || !candidate.sessionId || !candidate.turnId || !candidate.startedAt) { ambiguousLineNumbers.push(candidate.lineIndex + 1); continue; } - const evidence = statusFromToolOutput(outputs.get(candidate.callId), candidate.statusHint); const sourceIdentity = skillSourceIdentity({ provider, skillPath: candidate.skillPath, @@ -549,9 +483,9 @@ async function parseCodexInvocationLines( const recordKey = `${candidate.turnId}\0${skillName}\0${sourceIdentity}`; const existing = recordsByTurnAndSkill.get(recordKey); if (existing) { - existing.status = mergeInvocationStatus(existing.status, evidence.status); + existing.status = mergeInvocationStatus(existing.status, candidate.status); if (candidate.startedAt < existing.startedAt) existing.startedAt = candidate.startedAt; - if (evidence.endedAt && (!existing.endedAt || evidence.endedAt > existing.endedAt)) existing.endedAt = evidence.endedAt; + if (candidate.endedAt && (!existing.endedAt || candidate.endedAt > existing.endedAt)) existing.endedAt = candidate.endedAt; if (skillIdentity.confirmed) { if (existing.identityConfidence === 'confirmed') { existing.skillPath = commonPathEvidence(existing.skillPath, candidate.skillPath); @@ -595,13 +529,17 @@ async function parseCodexInvocationLines( resolvedPath: skillIdentity.resolvedPath, source, identityConfidence: skillIdentity.confirmed ? 'confirmed' : 'inferred', - status: evidence.status, + status: candidate.status, startedAt: candidate.startedAt, - endedAt: evidence.endedAt ?? null, + endedAt: candidate.endedAt ?? null, }); } - return { records: [...recordsByTurnAndSkill.values()], ambiguousLineNumbers }; + const diagnostics = evidence.findings.map((finding) => ({ + provider, + message: `Skipped ${finding.count} potential Codex Skill invocation${finding.count === 1 ? '' : 's'} in ${path.basename(sourceFile)}: ${finding.reason}`, + })); + return { records: [...recordsByTurnAndSkill.values()], ambiguousLineNumbers, diagnostics }; } async function parseClaudeInvocationLines( @@ -799,76 +737,6 @@ async function listSkillMdFiles(root: string, ancestorRealPaths = new Set): { cmd: string; workdir?: string } | undefined { - const raw = stringValue(payload.arguments) ?? stringValue(payload.input); - if (!raw) return undefined; - let value: unknown; - try { - value = JSON.parse(raw); - } catch { - return undefined; - } - if (!isRecord(value)) return undefined; - const cmd = stringValue(value.cmd); - if (!cmd) return undefined; - return { - cmd, - workdir: stringValue(value.workdir), - }; -} - -function extractDirectSkillReadPaths(command: string): string[] { - const paths: string[] = []; - const segments = command.split(/\s*(?:&&|\|\||;|\n)\s*/); - for (const segment of segments) { - const commandName = basenameOfCommand(firstShellToken(segment)); - if (!isDirectFileReadCommand(commandName)) continue; - paths.push(...extractSkillPaths(segment)); - } - return paths; -} - -function firstShellToken(segment: string): string | undefined { - return segment.trim().match(/^(\S+)/)?.[1]; -} - -function basenameOfCommand(command: string | undefined): string | undefined { - if (!command) return undefined; - return path.basename(command); -} - -function isDirectFileReadCommand(command: string | undefined): boolean { - return command === 'cat' - || command === 'sed' - || command === 'nl' - || command === 'head' - || command === 'tail' - || command === 'bat'; -} - -function extractSkillPaths(segment: string): string[] { - const paths: string[] = []; - const pathPattern = /(?:^|\s|['"])(~?\.?\.?\/?[^'"\s]*\/skills\/[^'"\s]+\/SKILL\.md)(?=$|\s|['"])/g; - for (const match of segment.matchAll(pathPattern)) { - const skillPath = match[1]; - if (!skillPath.includes('<') && !skillPath.includes('>')) paths.push(skillPath); - } - return paths; -} - -function resolveCommandCwd(workdir: string | undefined, cwd: string | undefined): string | undefined { - if (!workdir) return cwd; - if (path.isAbsolute(workdir)) return workdir; - return cwd ? path.resolve(cwd, workdir) : path.resolve(workdir); -} - -function resolveSkillPath(rawSkillPath: string, cwd: string | undefined): string { - if (rawSkillPath === '~') return os.homedir(); - if (rawSkillPath.startsWith('~/')) return path.join(os.homedir(), rawSkillPath.slice(2)); - if (path.isAbsolute(rawSkillPath)) return path.normalize(rawSkillPath); - return path.resolve(cwd ?? process.cwd(), rawSkillPath); -} - async function readSkillIdentity(skillPath: string): Promise<{ name: string; resolvedPath?: string; @@ -910,18 +778,6 @@ function parseCodexPluginSkillPath(skillPath: string): { return { marketplace, plugin, relativeSkillPath }; } -function statusFromToolOutput( - output: { output: string; endedAt?: string } | undefined, - statusHint: SkillInvocationStatus | undefined, -): { status: SkillInvocationStatus; endedAt?: string } { - const exitCode = output?.output.match(/Process exited with code\s+(\d+)/)?.[1] - ?? output?.output.match(/Exit code:\s+(\d+)/)?.[1] - ?? output?.output.match(/Exit status\s+(\d+)/)?.[1]; - if (exitCode !== undefined) return { status: exitCode === '0' ? 'used' : 'failed', endedAt: output?.endedAt }; - if (statusHint && statusHint !== 'unknown') return { status: statusHint, endedAt: output?.endedAt }; - return { status: 'unknown', endedAt: output?.endedAt }; -} - function mergeInvocationStatus(left: SkillInvocationStatus, right: SkillInvocationStatus): SkillInvocationStatus { if (left === 'used' || right === 'used') return 'used'; if (left === 'failed' || right === 'failed') return 'failed'; @@ -1116,11 +972,6 @@ function matchesSkillMdPath(recordPath: string, skillReferencePath: string): boo || normalizedRecordPath === path.join(normalizedSkillPath, 'SKILL.md'); } -function normalizeStatus(value: string | undefined): SkillInvocationStatus { - if (value === 'loaded' || value === 'used' || value === 'failed') return value; - return 'unknown'; -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts index aaa8819..47738f6 100644 --- a/packages/core/tests/usage.test.ts +++ b/packages/core/tests/usage.test.ts @@ -230,6 +230,1596 @@ describe('SkillUsageManager', () => { } }); + it('imports a Codex skill read from an orchestrated exec envelope', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const usageRoot = path.join(root, 'usage'); + const projectRoot = path.join(root, 'project'); + const skillPath = path.join(projectRoot, '.agents', 'skills', 'diagnosing-bugs', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, '---\nname: diagnosing-bugs\n---\n'); + + const command = "sed -n '1,240p' .agents/skills/diagnosing-bugs/SKILL.md"; + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: `const result = await tools.exec_command(${JSON.stringify({ + cmd: command, + workdir: projectRoot, + })}); text(result.output);`, + }, + }, + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: [ + { type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' }, + { type: 'input_text', text: '---\nname: diagnosing-bugs\n---\n' }, + ], + }, + }, + ])); + + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [], + }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toEqual([{ + skillName: 'diagnosing-bugs', + sourcePath: skillPath, + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-13T08:01:00Z', + }]); + + const stored = JSON.parse(await readFile( + path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), + 'utf-8', + )) as { status: string; endedAt?: string | null }; + expect(stored).toMatchObject({ + status: 'unknown', + endedAt: '2026-07-13T08:01:01Z', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('continues across a Codex output helper between direct skill reads', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + const program = [ + 'const first = await tools.exec_command({ cmd: "cat .agents/skills/diagnosing-bugs/SKILL.md" });', + 'text(first.output);', + 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 2 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking.map((row) => row.skillName).sort()).toEqual([ + 'diagnosing-bugs', + 'tdd', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('stops after an unsupported logical expression can mutate static command evidence', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'runtimeCondition && (options.cmd = runtimeCommand);', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('evaluates object initializers after a static numeric property key', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + '({ 0: "safe", next: mutateAtRuntime(options) });', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('evaluates a computed member key after its static receiver', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const values = [];', + 'values[mutateAtRuntime(options)];', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops after a member read from an unresolved value', async () => { + const program = [ + 'const value = null;', + 'value.output;', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops when a lexical binding is read inside its temporal dead zone', async () => { + const program = [ + 'const before = after;', + 'const after = "initialized";', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it.each([ + ['binary plus coercion', '"" + mutator;'], + ['template coercion', '`${mutator}`;'], + ])('stops before %s can mutate static evidence', async (_name, expression) => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const mutator = {', + ' valueOf() { options.cmd = runtimeCommand; return 0; },', + ' toString() { options.cmd = runtimeCommand; return ""; },', + '};', + expression, + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops before awaiting a static thenable can mutate evidence', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const thenable = { then(resolve) { options.cmd = runtimeCommand; resolve(); } };', + 'await thenable;', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops before Promise.all assimilates a static thenable', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const thenable = { then(resolve) { options.cmd = runtimeCommand; resolve(); } };', + 'await Promise.all([thenable]);', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops after an array mutator with a runtime callback', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const values = ["b", "a"];', + 'values.sort(() => { options.cmd = runtimeCommand; return 0; });', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops after a computed assignment can mutate evidence', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const target = {};', + 'target[mutateAtRuntime(options)] = "value";', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('allows one tool-result property read but stops an unproven deeper chain', async () => { + const program = [ + 'const first = await tools.exec_command({ cmd: "echo ok" });', + 'first.missing.deep;', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('does not treat a skill path in an unquoted shell comment as a read', async () => { + const program = [ + 'tools.exec_command({', + ' cmd: "cat /dev/null # ignored ; cat /tmp/skills/not-read/SKILL.md",', + '});', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops evaluating helper arguments after an unsupported call', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'text(mutateAtRuntime(options), tools.exec_command(options));', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('evaluates every direct Promise.all array element from left to right', async () => { + const program = [ + 'await Promise.all([', + ' tools.exec_command({ cmd: "cat .agents/skills/diagnosing-bugs/SKILL.md" }),', + ' tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" }),', + ']);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 2, + diagnostics: [], + }); + }); + + it('recognizes a statically computed tools exec_command property', async () => { + const program = 'tools["exec_command"]({ cmd: "cat .agents/skills/tdd/SKILL.md" });'; + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 1, + diagnostics: [], + }); + }); + + it('fails closed when tools exec_command has an extra argument', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'tools.exec_command(options, mutateAtRuntime(options));', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + }); + }); + + it.each([ + [ + 'Promise.all', + [ + 'const Promise = { all: () => { throw new Error("stop"); } };', + 'Promise.all([]);', + ], + ], + [ + 'Object.assign', + [ + 'const Object = { assign: () => { throw new Error("stop"); } };', + 'Object.assign({}, {});', + ], + ], + ])('does not treat shadowed %s as a known builtin', async (_name, prefix) => { + const program = [ + ...prefix, + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('hoists var bindings through unsupported callback control flow', async () => { + const program = [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'commands.map((command) => {', + ' tools.exec_command(command);', + ' if (false) { var tools = fakeTools; }', + '});', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('propagates unsupported control flow from a static map callback', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + '[options].map((item) => {', + ' if (runtimeCondition) item.cmd = runtimeCommand;', + '});', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops when a static map callback uses an unsupported parameter pattern', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'const items = [{ value: "safe" }];', + 'items.map(({ value }) => { options.cmd = runtimeCommand; });', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('stops when a map receiver is not a proven static array', async () => { + const program = [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'runtimeCollection.map((item) => mutateAtRuntime(item, options));', + 'tools.exec_command(options);', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('binds a named function expression inside its static map callback', async () => { + const program = [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'commands.map(function tools(command) {', + ' tools.exec_command(command);', + '});', + ].join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ + importedRecords: 0, + diagnostics: [], + }); + }); + + it('fails closed when one orchestrated program exceeds the command budget', async () => { + const program = Array.from( + { length: 257 }, + () => 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ).join('\n'); + + await expect(importOrchestratedProgram(program)).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }], + }); + }); + + it('fails closed before static string composition can grow without bound', async () => { + const statements = [`const value0 = "${'x'.repeat(1024)}";`]; + for (let index = 1; index <= 10; index += 1) { + statements.push(`const value${index} = value${index - 1} + value${index - 1};`); + } + statements.push('tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });'); + + await expect(importOrchestratedProgram(statements.join('\n'))).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }], + }); + }); + + it('fails closed when one concrete command contains too many skill paths', async () => { + const paths = Array.from( + { length: 257 }, + (_, index) => `.agents/skills/skill-${index}/SKILL.md`, + ); + const program = `tools.exec_command({ cmd: ${JSON.stringify(`cat ${paths.join(' ')}`)} });`; + + await expect(importOrchestratedProgram(program)).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }], + }); + }); + + it('keeps absolute reads while diagnosing relative reads without a cwd', async () => { + const program = [ + 'tools.exec_command({', + ' cmd: "cat /tmp/skills/absolute/SKILL.md .agents/skills/relative/SKILL.md",', + '});', + ].join('\n'); + + await expect(importOrchestratedProgram(program, false)).resolves.toEqual({ + provider: 'codex', + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }], + }); + }); + + it('imports an orchestrated Codex skill read assembled from static constants', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const skillPath = path.join(projectRoot, '.agents', 'skills', 'tdd', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, '---\nname: tdd\n---\n'); + + const program = [ + 'const skillDir = ".agents/skills/tdd";', + 'const filename = "SKILL" + ".md";', + `const workdir = ${JSON.stringify(projectRoot)};`, + 'const cmd = `cat ${skillDir}/${filename}`;', + 'const result = await tools.exec_command({ cmd, workdir });', + 'text(result.output);', + ].join('\n'); + expect(program).not.toContain('SKILL.md'); + + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: program, + }, + }, + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: [{ type: 'input_text', text: 'Script completed\n' }], + }, + }, + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ + importedRecords: 1, + diagnostics: [], + }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + countedInvocations: 1, + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports multiple orchestrated Codex skill reads from a static array map', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const skillNames = ['diagnosing-bugs', 'tdd']; + await mkdir(sessionsRoot, { recursive: true }); + await Promise.all(skillNames.map(async (skillName) => { + const skillPath = path.join(projectRoot, '.agents', 'skills', skillName, 'SKILL.md'); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, `---\nname: ${skillName}\n---\n`); + })); + + const program = [ + `const workdir = ${JSON.stringify(projectRoot)};`, + 'const commands = [', + ' { cmd: "cat .agents/skills/diagnosing-bugs/SKILL.md", workdir },', + ' { cmd: "cat .agents/skills/tdd/SKILL.md", workdir },', + '];', + 'const results = await Promise.all(', + ' commands.map((command) => tools.exec_command(command)),', + ');', + 'results.forEach((result) => text(result.output));', + ].join('\n'); + + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: program, + }, + }, + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: 'Script completed\n', + }, + }, + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ + importedRecords: 2, + diagnostics: [], + }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking.map((row) => row.skillName).sort()).toEqual(skillNames); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('uses a static map callback lexical environment instead of the call-site scope', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const wrongRoot = path.join(root, 'wrong-project'); + const skillPath = path.join(projectRoot, '.agents', 'skills', 'tdd', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, '---\nname: tdd\n---\n'); + const program = [ + `const workdir = ${JSON.stringify(projectRoot)};`, + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'const run = (command) => tools.exec_command({ cmd: command.cmd, workdir });', + '{', + ` const workdir = ${JSON.stringify(wrongRoot)};`, + ' await Promise.all(commands.map(run));', + '}', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('allows an unused index parameter in a static array map callback', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const skillPath = path.join(projectRoot, '.agents', 'skills', 'tdd', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, '---\nname: tdd\n---\n'); + const program = [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'await Promise.all(commands.map((command, index) => tools.exec_command(command)));', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not execute a generator body used as an array map callback', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + const program = [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'commands.map(function* (command) { tools.exec_command(command); });', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports orchestrated Codex skill reads from a static for-of loop and property reads', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const skillPath = path.join(projectRoot, '.agents', 'skills', 'grilling', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, '---\nname: grilling\n---\n'); + + const program = [ + 'const config = {', + ` workdir: ${JSON.stringify(projectRoot)},`, + ' commands: [{ cmd: "cat .agents/skills/grilling/SKILL.md" }],', + '};', + 'for (const command of config.commands) {', + ' const options = { cmd: command.cmd, workdir: config.workdir };', + ' await tools.exec_command(options);', + '}', + ].join('\n'); + + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + orchestratedExecOutput('call-a', '2026-07-13T08:01:01Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ + importedRecords: 1, + diagnostics: [], + }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'grilling', + sourcePath: skillPath, + countedInvocations: 1, + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('respects break while interpreting a static for-of loop', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + const firstSkillPath = path.join(projectRoot, '.agents', 'skills', 'grilling', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(path.dirname(firstSkillPath), { recursive: true }); + await writeFile(firstSkillPath, '---\nname: grilling\n---\n'); + const program = [ + 'const commands = [', + ' { cmd: "cat .agents/skills/grilling/SKILL.md" },', + ' { cmd: "cat .agents/skills/tdd/SKILL.md" },', + '];', + 'for (const command of commands) {', + ' await tools.exec_command(command);', + ' break;', + '}', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual(['grilling']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('aggregates only high-signal orchestrated Codex parsing diagnostics without raw input', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-unresolved-command-a', + 'tools.exec_command({ cmd: secretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', + '2026-07-13T08:01:00Z', + ), + orchestratedExecCall( + 'call-unresolved-command-b', + 'tools.exec_command({ cmd: anotherSecretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', + '2026-07-13T08:01:01Z', + ), + orchestratedExecCall( + 'call-unresolved-workdir', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md", workdir: resolveSecretWorkdir() });', + '2026-07-13T08:01:02Z', + ), + orchestratedExecCall( + 'call-unrelated-dynamic', + 'tools.exec_command({ cmd: ordinaryRuntimeCommand, workdir: ordinaryRuntimeWorkdir });', + '2026-07-13T08:01:03Z', + ), + orchestratedExecCall( + 'call-invalid-a', + 'const brokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', + '2026-07-13T08:01:04Z', + ), + orchestratedExecCall( + 'call-invalid-b', + 'const otherBrokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', + '2026-07-13T08:01:05Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + const result = await usage.importProvider('codex'); + expect(result).toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 2 potential Codex Skill invocations in session.jsonl: unresolved-command', + }, + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }, + { + provider: 'codex', + message: 'Skipped 2 potential Codex Skill invocations in session.jsonl: invalid-javascript', + }, + ], + }); + const diagnosticText = result.diagnostics.map((diagnostic) => diagnostic.message).join('\n'); + expect(diagnosticText).not.toContain('secretCommand'); + expect(diagnosticText).not.toContain('resolveSecretWorkdir'); + expect(diagnosticText).not.toContain('/skills/tdd/SKILL.md'); + expect(diagnosticText).not.toContain('ordinaryRuntimeCommand'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not attribute an orchestrated relative skill read without a reliable working directory', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + orchestratedExecCall( + 'call-a', + 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not attribute a historical relative skill read without a reliable working directory', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + functionCall( + 'call-a', + 'cat .agents/skills/tdd/SKILL.md', + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [{ + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not treat a shadowed tools object as Codex execution evidence', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const tools = { exec_command: (options) => options };', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('respects function-scoped var shadowing of the Codex tools binding', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + '{ var tools = fakeTools; }', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('respects destructured shadowing of the Codex tools binding', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const { tools } = { tools: fakeTools };', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('ignores orchestrated strings, comments, uncalled functions, and runtime-derived commands', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + const program = [ + 'const example = `tools.exec_command({ cmd: "cat .agents/skills/string/SKILL.md" })`;', + '// tools.exec_command({ cmd: "cat .agents/skills/comment/SKILL.md" });', + 'function neverCalled() {', + ' return tools.exec_command({ cmd: "cat .agents/skills/dead-code/SKILL.md" });', + '}', + 'const runtimeCommand = makeCommand(".agents/skills/runtime/SKILL.md");', + 'tools.exec_command({ cmd: runtimeCommand });', + ].join('\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('stops after a separately bound options object evaluates a runtime workdir', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = {', + ' cmd: "cat .agents/skills/tdd/SKILL.md",', + ' workdir: resolveRuntimeWorkdir(),', + '};', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not diagnose a non-read command merely because it mentions SKILL.md', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'tools.exec_command({ cmd: "rg SKILL.md .", workdir: resolveRuntimeWorkdir() });', + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not use stale static evidence after an options object is mutated', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'options.cmd = runtimeCommand;', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not use static object evidence after it escapes to an unknown function', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'mutateAtRuntime(options);', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not execute nested tool arguments of an unsupported call', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'missingFunction(tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" }));', + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('stops static interpretation after unsupported control flow can change evidence', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'if (runtimeCondition) options.cmd = runtimeCommand;', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('stops before a class declaration can run static side effects', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'class Mutator { static { options.cmd = runtimeCommand; } }', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not retain an outer static binding after an unsupported block assignment', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const cmd = "cat .agents/skills/tdd/SKILL.md";', + '{ cmd = runtimeCommand; }', + 'tools.exec_command({ cmd });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('does not use stale static evidence after a command array is mutated', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'commands.splice(0, 1);', + 'await Promise.all(commands.map((command) => tools.exec_command(command)));', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ])); + + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 0, + skippedArtifacts: 0, + diagnostics: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('reads only usage log months that overlap the requested range', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -826,7 +2416,100 @@ describe('SkillUsageManager', () => { const stored = await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'); expect(stored.trim().split('\n')).toHaveLength(1); - await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 8'); + await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 9'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rebuilds Codex v8 usage without rebuilding Claude v8 usage', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usageRoot = path.join(root, 'usage'); + const codexRoot = path.join(root, 'codex-sessions'); + const codexProject = path.join(root, 'project'); + const codexSkillPath = path.join(codexProject, '.agents', 'skills', 'tdd', 'SKILL.md'); + const codexArtifact = path.join(codexRoot, 'session.jsonl'); + const claudeRoot = path.join(root, 'claude-projects'); + const claudeProject = path.join(claudeRoot, '-tmp-project'); + const claudeArtifact = path.join(claudeProject, 'session-a.jsonl'); + const claudeSkillRoot = path.join(root, 'claude-skills'); + const claudeSkillPath = path.join(claudeSkillRoot, 'preserved-claude', 'SKILL.md'); + await mkdir(codexRoot, { recursive: true }); + await mkdir(path.dirname(codexSkillPath), { recursive: true }); + await mkdir(claudeProject, { recursive: true }); + await mkdir(path.dirname(claudeSkillPath), { recursive: true }); + await writeFile(codexSkillPath, '---\nname: tdd\n---\n'); + await writeFile(claudeSkillPath, '---\nname: preserved-claude\n---\n'); + await writeFile(codexArtifact, jsonl([ + sessionMeta('session-a', codexProject, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', codexProject, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + '2026-07-13T08:01:00Z', + ), + ])); + await writeFile(claudeArtifact, jsonl([ + claudeAssistant({ + sessionId: 'session-claude', + messageId: 'message-claude', + toolUseId: 'toolu-claude', + skillName: 'preserved-claude', + timestamp: '2026-07-13T09:00:00Z', + }), + ])); + + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [codexRoot] }, + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [claudeRoot], + skillRoots: [claudeSkillRoot], + }, + ], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); + + const codexMetadata = await stat(codexArtifact); + const claudeMetadata = await stat(claudeArtifact); + await writeFile(path.join(usageRoot, 'cursors', 'codex.json'), JSON.stringify({ + provider: 'codex', + importerVersion: 8, + artifacts: { + [codexArtifact]: { size: codexMetadata.size, mtimeMs: codexMetadata.mtimeMs }, + }, + }) + '\n'); + await writeFile(path.join(usageRoot, 'cursors', 'claude.json'), JSON.stringify({ + provider: 'claude', + importerVersion: 8, + artifacts: { + [claudeArtifact]: { size: claudeMetadata.size, mtimeMs: claudeMetadata.mtimeMs }, + }, + }) + '\n'); + await rm(claudeArtifact); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 0 }); + await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 0 }); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-13T12:00:00Z'), + }); + expect(overview.providers.find((provider) => provider.provider === 'codex')?.ranking).toContainEqual( + expect.objectContaining({ skillName: 'tdd', countedInvocations: 1 }), + ); + expect(overview.providers.find((provider) => provider.provider === 'claude')?.ranking).toContainEqual( + expect.objectContaining({ skillName: 'preserved-claude', countedInvocations: 1 }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -1541,6 +3224,65 @@ function functionOutput(callId: string, exitCode: number, timestamp: string): Re }; } +function orchestratedExecCall(callId: string, input: string, timestamp: string): Record { + return { + timestamp, + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: callId, + status: 'completed', + input, + }, + }; +} + +function orchestratedExecOutput(callId: string, timestamp: string): Record { + return { + timestamp, + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: callId, + output: 'Script completed\n', + }, + }; +} + +async function importOrchestratedProgram(program: string, includeCwd = true) { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + const events = includeCwd + ? [ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ] + : [ + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]; + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl(events)); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + providers: [{ + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }], + }); + return await usage.importProvider('codex'); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + function customToolCall(callId: string, cmd: string, timestamp: string): Record { return { timestamp, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86e3094..87ac1a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: packages/core: dependencies: + acorn: + specifier: ^8.17.0 + version: 8.17.0 yaml: specifier: 2.9.0 version: 2.9.0 @@ -435,6 +438,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1222,6 +1230,8 @@ snapshots: acorn@8.16.0: {} + acorn@8.17.0: {} + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 From 96aff82c9e2b3c0ff3b6d5e3c1faf4e13310162b Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 14 Jul 2026 16:35:03 +0800 Subject: [PATCH 3/5] chore: adopt Vite+ toolchain --- .github/workflows/ci.yml | 13 +- AGENTS.md | 23 +- README.md | 15 + ...-vite-plus-as-the-development-toolchain.md | 9 + docs/releasing.md | 30 +- package.json | 22 +- packages/core/package.json | 10 +- packages/core/src/codex-usage-evidence.ts | 210 +- packages/core/src/config.ts | 24 +- packages/core/src/manager.ts | 127 +- packages/core/src/models/inventory.ts | 22 +- packages/core/src/providers/claude.ts | 160 +- packages/core/src/providers/codex.ts | 77 +- .../core/src/providers/provider-settings.ts | 11 +- packages/core/src/providers/provider.ts | 46 +- packages/core/src/sources/skillssh.ts | 34 +- packages/core/src/usage-paths.ts | 4 +- packages/core/src/usage.ts | 290 +- packages/core/tests/config.test.ts | 37 +- packages/core/tests/duplicates.test.ts | 26 +- packages/core/tests/inventory.test.ts | 118 +- packages/core/tests/manager.test.ts | 100 +- packages/core/tests/parser.test.ts | 2 +- packages/core/tests/providers/claude.test.ts | 80 +- packages/core/tests/providers/codex.test.ts | 108 +- packages/core/tests/sources/skillssh.test.ts | 22 +- packages/core/tests/usage-paths.test.ts | 44 +- packages/core/tests/usage.test.ts | 2948 ++++++++++------- packages/core/vite.config.ts | 24 + packages/tui/package.json | 43 +- packages/tui/src/app.tsx | 36 +- packages/tui/src/cli.tsx | 6 +- .../tui/src/components/confirm-dialog.tsx | 14 +- packages/tui/src/components/help-overlay.tsx | 6 +- packages/tui/src/components/search-input.tsx | 11 +- packages/tui/src/components/skill-row.tsx | 25 +- packages/tui/src/components/status-bar.tsx | 6 +- packages/tui/src/components/tab-bar.tsx | 10 +- packages/tui/src/context/app-context.tsx | 74 +- packages/tui/src/hooks/use-search.ts | 5 +- packages/tui/src/hooks/use-skill-manager.ts | 108 +- packages/tui/src/hooks/use-skills.ts | 7 +- packages/tui/src/hooks/use-terminal-size.ts | 4 +- packages/tui/src/lib/format-path.ts | 4 +- packages/tui/src/lib/plugin-toggle.ts | 11 +- packages/tui/src/lib/responsive-layout.ts | 9 +- packages/tui/src/lib/shortcuts.ts | 18 +- packages/tui/src/views/detail-view.tsx | 277 +- packages/tui/src/views/install-view.tsx | 66 +- packages/tui/src/views/list-view.tsx | 163 +- .../tui/src/views/project-skills-view.tsx | 34 +- packages/tui/src/views/settings-view.tsx | 84 +- packages/tui/src/views/updates-view.tsx | 28 +- packages/tui/src/views/usage-view.tsx | 149 +- .../tui/tests/cli/run-skillpack-cli.test.ts | 20 +- packages/tui/tests/fixtures/inventory.ts | 45 +- packages/tui/tests/helpers/mock-manager.ts | 2 +- packages/tui/tests/helpers/render-tui.tsx | 3 +- packages/tui/tests/plugin-toggle.test.ts | 7 +- packages/tui/tests/responsive-layout.test.ts | 31 +- packages/tui/tests/shortcuts.test.ts | 2 +- packages/tui/tests/ui/app-surface.test.tsx | 482 +-- packages/tui/tsup.config.ts | 13 - packages/tui/vite.config.ts | 36 + pnpm-lock.yaml | 1471 +++++--- pnpm-workspace.yaml | 11 +- scripts/verify-package.mjs | 66 + tsconfig.json | 1 + vite.config.ts | 49 + 69 files changed, 4934 insertions(+), 3139 deletions(-) create mode 100644 docs/adr/0061-adopt-vite-plus-as-the-development-toolchain.md create mode 100644 packages/core/vite.config.ts delete mode 100644 packages/tui/tsup.config.ts create mode 100644 packages/tui/vite.config.ts create mode 100644 scripts/verify-package.mjs create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 989bd9e..2971d3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: build-and-test: - name: Build and test + name: Check, test, and build runs-on: ubuntu-latest steps: @@ -28,14 +28,17 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.18.0 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build - run: pnpm build + - name: Check + run: pnpm check:ci - name: Test - run: pnpm test + run: pnpm test:ci + + - name: Build and verify package + run: pnpm build:ci diff --git a/AGENTS.md b/AGENTS.md index 74021f3..26dcc1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,20 +25,27 @@ This repo uses a single-context domain docs layout. See `docs/agents/domain.md`. - **Language**: TypeScript (ES2022, Node16 modules, strict mode) - **Package manager**: pnpm with workspaces +- **Toolchain**: Vite+ (formatting, type-aware linting, tests, packaging, task orchestration) - **TUI framework**: Ink 6 + React 19 (terminal UI rendered via Yoga layout) -- **Testing**: Vitest +- **Testing**: Vite+ Test (Vitest-compatible) - **Parser**: YAML frontmatter in SKILL.md files ## Commands ```bash pnpm install # install dependencies -pnpm build # build all packages (tsc) -pnpm test # run all tests -pnpm dev # watch mode for TUI +pnpm check # formatting, type-aware lint, and type checks +pnpm build # package Core and the TUI with Vite+ Pack +pnpm test # run all tests once +pnpm dev # build Core, then watch the TUI package + +# CI-only entry points +pnpm check:ci +pnpm test:ci +pnpm build:ci # also verifies artifacts and npm package contents # Run the app -node packages/tui/dist/bin/skillpack.js +node packages/tui/dist/skillpack.js ``` ## Architecture @@ -134,14 +141,16 @@ After any mutation (toggle, edit, delete, update), `refresh()` must be called. T ## Testing - Tests live in `packages/core/tests/` -- Use `vitest` with `describe`/`it`/`expect` +- TUI tests live in `packages/tui/tests/` +- Import `describe`/`it`/`expect` and other test APIs from `vite-plus/test` - Provider tests use temp directories (`mkdtemp`) cleaned up in `afterEach` -- TUI has no tests yet +- `pnpm test` and `pnpm test:ci` are one-shot commands; neither starts watch mode ## Common Pitfalls - **Stale `selectedSkill`**: Always update `selectedSkill` after `refresh()` — it's a separate state from `skills[]` - **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 +- **Ink JSX whitespace**: Encode intentional runs of spaces as expressions such as `{' '}` so formatting cannot change terminal alignment - **`.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, provider-native availability) must also be added to both Claude scan methods. diff --git a/README.md b/README.md index 407a7d6..5c7859b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,21 @@ pnpm build node packages/tui/dist/skillpack.js ``` +## Development + +Contributing requires a Vite+-supported Node.js release (20.19+, 22.18+, or 24.11+) and pnpm 11. The published CLI still supports Node.js 18 and newer. + +```bash +pnpm check # format check, type-aware lint, and type check +pnpm format # format supported source and config files +pnpm lint # type-aware lint and type check +pnpm test # run all package tests once +pnpm build # package Core and the TUI +pnpm dev # build Core, then watch the TUI package +``` + +CI uses the explicit `pnpm check:ci`, `pnpm test:ci`, and `pnpm build:ci` entry points. The final command also verifies the Core exports, CLI shebang and executable bit, ESM syntax, source-map policy, and npm package contents. + ## Quick Start Launch `skillpack` to see discovered skills grouped into Skill Groups with provider status and Health Signals. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider (All, Codex, Claude, Global), and `/` to search. diff --git a/docs/adr/0061-adopt-vite-plus-as-the-development-toolchain.md b/docs/adr/0061-adopt-vite-plus-as-the-development-toolchain.md new file mode 100644 index 0000000..d88b0c2 --- /dev/null +++ b/docs/adr/0061-adopt-vite-plus-as-the-development-toolchain.md @@ -0,0 +1,9 @@ +--- +status: accepted +--- + +# Adopt Vite+ as the development toolchain + +Skillpack will keep pnpm as its dependency and workspace manager while adopting an exact-pinned Vite+ version for shared formatting, type-aware linting and checks, package-scoped tests, and package builds. Every layer will be tried, but test and build migration are conditional on preserving existing behavior, the package-root `@skillpack/core` contract, and the single-file Node.js TUI artifact; a layer that fails parity keeps its current Vitest, `tsc`, or `tsup` implementation without a Vite+-specific workaround. CI will use explicit `:ci` package-script entry points, while the root Vite+ configuration owns shared checks and package configurations own test and build behavior. + +All parity gates passed. Core packaging now emits only its supported root `index.js` and `index.d.ts` contract, while TUI packaging inlines Core into one unminified executable ESM file with a Node.js shebang and no source map. Tests use `vite-plus/test` and remain package-scoped and non-watching. Artifact shape and package contents are executable CI checks, and the standalone Vitest and tsup dependencies and configuration have been removed. diff --git a/docs/releasing.md b/docs/releasing.md index 937c1d1..3454830 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,14 +5,16 @@ Skillpack is published to npm as a single package `skillpack-tui`. Users install ## Build Architecture ``` -packages/core (source) ──┐ - ├─ tsup bundle ──▶ dist/skillpack.js (single-file ESM) -packages/tui (source) ──┘ +packages/core (source) ── Vite+ Pack ──▶ packages/core/dist/index.js + index.d.ts + │ + └────────────── inlined by Vite+ Pack ──▶ packages/tui/dist/skillpack.js +packages/tui (source) ────────────────────────────▶ (single-file ESM CLI) ``` - `@skillpack/core` source is inlined into the bundle — it is not published separately -- npm dependencies (`yaml`) remain external as runtime dependencies -- No sourcemaps in production; no minification (add `minify: true` in `tsup.config.ts` if needed) +- Package runtime dependencies remain external; Core's `acorn` implementation is bundled into the CLI +- Production artifacts are unminified and contain no sourcemaps +- `pnpm build:ci` verifies the exact Core and CLI artifact shape, executable CLI shebang, ESM syntax, and package dry-run contents ## Prerequisites @@ -35,14 +37,15 @@ npm view skillpack-tui ```bash git status # ensure clean working tree -pnpm install # ensure dependencies are up to date -pnpm test # run all tests +pnpm install --frozen-lockfile +pnpm check:ci +pnpm test:ci ``` ### 2. Build and verify output ```bash -pnpm build # triggers tsup bundling +pnpm build:ci # builds and verifies both packages # verify output ls -lh packages/tui/dist/skillpack.js # confirm single-file output @@ -52,11 +55,10 @@ node packages/tui/dist/skillpack.js # local smoke test (requires TTY) ### 3. Preview package contents ```bash -cd packages/tui -npm pack --dry-run # preview files that will be published +pnpm --dir packages/tui pack --dry-run --json ``` -Expected output should contain `dist/skillpack.js`, `package.json`, and `README.md`. +Expected output contains only `dist/skillpack.js`, `LICENSE`, `package.json`, and `README.md`. ### 4. Bump version @@ -78,7 +80,7 @@ cd packages/tui npm publish ``` -The `prepublishOnly` script automatically runs `tsup` before publishing. +The `prepublishOnly` script automatically runs the same check, test, build, and artifact-verification flow used by CI before publishing. ### 6. Push tag to GitHub @@ -118,7 +120,9 @@ npm deprecate skillpack-tui@ "reason" # mark as deprecated (preferred) Full release from the project root: ```bash -pnpm test && pnpm build +pnpm check:ci +pnpm test:ci +pnpm build:ci cd packages/tui npm version patch npm publish diff --git a/package.json b/package.json index 8fa4e10..91c7a1e 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,26 @@ "name": "skillpack", "version": "0.1.0", "private": true, + "type": "module", "scripts": { - "build": "pnpm -r run build", - "test": "pnpm -r run test", - "dev": "pnpm --filter @skillpack/tui run dev" + "build": "vp run -r build", + "build:ci": "vp run -r build:ci", + "check": "vp check", + "check:ci": "vp check", + "dev": "vp run @skillpack/core#build && vp run skillpack-tui#dev", + "fix": "vp check --fix", + "format": "vp fmt . --write", + "format:check": "vp fmt . --check", + "lint": "vp lint", + "lint:fix": "vp lint --fix", + "test": "vp run -r test", + "test:ci": "vp run -r test:ci" + }, + "devDependencies": { + "@types/node": "25.9.3", + "typescript": "6.0.3", + "vite": "npm:@voidzero-dev/vite-plus-core@0.2.4", + "vite-plus": "0.2.4" }, "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b" } diff --git a/packages/core/package.json b/packages/core/package.json index 50b5916..0412549 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,9 +5,10 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest" + "build": "vp pack", + "build:ci": "vp pack", + "test": "vp test", + "test:ci": "vp test" }, "dependencies": { "acorn": "^8.17.0", @@ -15,7 +16,6 @@ }, "devDependencies": { "@types/node": "25.9.3", - "typescript": "6.0.3", - "vitest": "4.1.8" + "typescript": "6.0.3" } } diff --git a/packages/core/src/codex-usage-evidence.ts b/packages/core/src/codex-usage-evidence.ts index 6c63379..79355c2 100644 --- a/packages/core/src/codex-usage-evidence.ts +++ b/packages/core/src/codex-usage-evidence.ts @@ -269,9 +269,8 @@ export function deriveCodexUsageEvidence(input: { }); } const output = outputs.get(candidate.callId); - const status = candidate.format === 'orchestrated' - ? 'unknown' - : statusFromLegacyOutput(output?.output, candidate.statusHint); + const status = + candidate.format === 'orchestrated' ? 'unknown' : statusFromLegacyOutput(output?.output, candidate.statusHint); for (const rawSkillPath of rawSkillPaths) { if (relativePathsAreUnresolved && skillPathNeedsCwd(rawSkillPath)) continue; const skillPath = resolveSkillPath(rawSkillPath, commandCwd); @@ -302,16 +301,17 @@ function parseLegacyExecCommand(payload: Record): ExecCommand | return { cmd, workdir: stringValue(value.workdir) }; } -function parseOrchestratedExecCommands(source: string, lineIndex: number): { +function parseOrchestratedExecCommands( + source: string, + lineIndex: number, +): { commands: ExecCommand[]; findings: CodexEvidenceFinding[]; } { if (source.length > MAX_PROGRAM_SOURCE_LENGTH) { return { commands: [], - findings: hasPotentialSkillEvidence(source) - ? [{ lineIndex, reason: 'analysis-budget-exceeded', count: 1 }] - : [], + findings: hasPotentialSkillEvidence(source) ? [{ lineIndex, reason: 'analysis-budget-exceeded', count: 1 }] : [], }; } let program: Program; @@ -320,9 +320,7 @@ function parseOrchestratedExecCommands(source: string, lineIndex: number): { } catch { return { commands: [], - findings: hasPotentialSkillEvidence(source) - ? [{ lineIndex, reason: 'invalid-javascript', count: 1 }] - : [], + findings: hasPotentialSkillEvidence(source) ? [{ lineIndex, reason: 'invalid-javascript', count: 1 }] : [], }; } @@ -346,8 +344,7 @@ function parseOrchestratedExecCommands(source: string, lineIndex: number): { function enterStaticEvaluation(context: EvaluationContext): boolean { if (context.budgetExceeded) return false; context.evaluationSteps += 1; - if (context.evaluationSteps > MAX_STATIC_EVALUATION_STEPS - || context.evaluationDepth >= MAX_STATIC_EVALUATION_DEPTH) { + if (context.evaluationSteps > MAX_STATIC_EVALUATION_STEPS || context.evaluationDepth >= MAX_STATIC_EVALUATION_DEPTH) { exceedAnalysisBudget(context); return false; } @@ -390,8 +387,10 @@ function recordResolvedCommand(command: ExecCommand, context: EvaluationContext) } function retainStaticString(value: string, context: EvaluationContext): StaticEvaluation { - if (value.length > MAX_STATIC_STRING_LENGTH - || context.staticStringCodeUnits > MAX_STATIC_STRING_CODE_UNITS - value.length) { + if ( + value.length > MAX_STATIC_STRING_LENGTH || + context.staticStringCodeUnits > MAX_STATIC_STRING_CODE_UNITS - value.length + ) { exceedAnalysisBudget(context); return UNRESOLVED; } @@ -453,10 +452,13 @@ function resolveExecCommand( potentialSkillEvidence, }; } - return { command: { - cmd, - workdir: typeof workdir === 'string' ? workdir : undefined, - }, potentialSkillEvidence }; + return { + command: { + cmd, + workdir: typeof workdir === 'string' ? workdir : undefined, + }, + potentialSkillEvidence, + }; } function recordUnresolvedExecFinding( @@ -525,24 +527,24 @@ function aggregateFindings(findings: CodexEvidenceFinding[]): CodexEvidenceFindi aggregated.set(finding.reason, { ...finding }); } } - return [...aggregated.values()].sort((left, right) => ( - left.lineIndex - right.lineIndex || left.reason.localeCompare(right.reason) - )); + return [...aggregated.values()].sort( + (left, right) => left.lineIndex - right.lineIndex || left.reason.localeCompare(right.reason), + ); } function isToolsExecCommand(call: CallExpression, environment: StaticEnvironment): boolean { if (environment.has('tools')) return false; - if (call.callee.type !== 'MemberExpression' - || call.callee.object.type !== 'Identifier' - || call.callee.object.name !== 'tools') { + if ( + call.callee.type !== 'MemberExpression' || + call.callee.object.type !== 'Identifier' || + call.callee.object.name !== 'tools' + ) { return false; } if (!call.callee.computed) { - return call.callee.property.type === 'Identifier' - && call.callee.property.name === 'exec_command'; + return call.callee.property.type === 'Identifier' && call.callee.property.name === 'exec_command'; } - return call.callee.property.type === 'Literal' - && call.callee.property.value === 'exec_command'; + return call.callee.property.type === 'Literal' && call.callee.property.value === 'exec_command'; } function propertyName(key: Expression): string | undefined { @@ -571,9 +573,7 @@ function evaluateStaticExpression( context: EvaluationContext, ): StaticEvaluation { if (expression.type === 'Literal') { - return typeof expression.value === 'string' - ? retainStaticString(expression.value, context) - : UNRESOLVED; + return typeof expression.value === 'string' ? retainStaticString(expression.value, context) : UNRESOLVED; } if (expression.type === 'Identifier') { if (environment.has(expression.name)) { @@ -696,9 +696,11 @@ function evaluateStaticExpression( if (expression.type === 'AwaitExpression') { const awaited = evaluateStatic(expression.argument, environment, context); if (context.pathUnsupported) return UNRESOLVED; - if (awaited === UNRESOLVED - || isStaticOpaqueLeaf(awaited) - || (isStaticObject(awaited) && awaited.properties.has('then'))) { + if ( + awaited === UNRESOLVED || + isStaticOpaqueLeaf(awaited) || + (isStaticObject(awaited) && awaited.properties.has('then')) + ) { context.pathUnsupported = true; return UNRESOLVED; } @@ -708,12 +710,7 @@ function evaluateStaticExpression( if (isToolsExecCommand(expression, environment)) { if (expression.arguments.length !== 1 || expression.arguments[0].type === 'SpreadElement') { context.pathUnsupported = true; - recordUnresolvedExecFinding( - expression, - 'unresolved-command', - false, - context, - ); + recordUnresolvedExecFinding(expression, 'unresolved-command', false, context); return UNRESOLVED; } const resolution = resolveExecCommand(expression, environment, context); @@ -725,21 +722,14 @@ function evaluateStaticExpression( recordResolvedCommand(resolution.command, context); return context.pathUnsupported ? UNRESOLVED : STATIC_OPAQUE_OBJECT; } - recordUnresolvedExecFinding( - expression, - resolution.reason, - resolution.potentialSkillEvidence, - context, - ); + recordUnresolvedExecFinding(expression, resolution.reason, resolution.potentialSkillEvidence, context); context.pathUnsupported = true; return UNRESOLVED; } if (invalidateKnownMutationCall(expression, environment, context)) return UNRESOLVED; if (isPromiseAllCall(expression, environment)) { const argument = expression.arguments[0]; - if (expression.arguments.length !== 1 - || !argument - || argument.type === 'SpreadElement') { + if (expression.arguments.length !== 1 || !argument || argument.type === 'SpreadElement') { context.pathUnsupported = true; return UNRESOLVED; } @@ -755,10 +745,12 @@ function evaluateStaticExpression( const receiver = evaluateStatic(expression.callee.object as Expression, environment, context); const callbackArgument = expression.arguments[0]; if (context.pathUnsupported) return UNRESOLVED; - if (!isStaticArray(receiver) - || expression.arguments.length !== 1 - || !callbackArgument - || callbackArgument.type === 'SpreadElement') { + if ( + !isStaticArray(receiver) || + expression.arguments.length !== 1 || + !callbackArgument || + callbackArgument.type === 'SpreadElement' + ) { context.pathUnsupported = true; return UNRESOLVED; } @@ -811,9 +803,7 @@ function executeStatements( if (!consumeStatementStep(context)) return { kind: 'unsupported' }; if (statement.type === 'VariableDeclaration') { for (const declaration of statement.declarations) { - const value = declaration.init - ? evaluateStatic(declaration.init, environment, context) - : UNRESOLVED; + const value = declaration.init ? evaluateStatic(declaration.init, environment, context) : UNRESOLVED; if (context.pathUnsupported) return { kind: 'unsupported' }; if (declaration.id.type === 'Identifier') { environment.set(declaration.id.name, statement.kind === 'const' ? value : UNRESOLVED); @@ -837,9 +827,7 @@ function executeStatements( continue; } if (statement.type === 'ReturnStatement') { - const value = statement.argument - ? evaluateStatic(statement.argument, environment, context) - : UNRESOLVED; + const value = statement.argument ? evaluateStatic(statement.argument, environment, context) : UNRESOLVED; if (context.pathUnsupported) return { kind: 'unsupported' }; return { kind: 'return', @@ -848,9 +836,11 @@ function executeStatements( } if (statement.type === 'BreakStatement') return { kind: 'break' }; if (statement.type === 'ContinueStatement') return { kind: 'continue' }; - if (statement.type === 'EmptyStatement' - || statement.type === 'DebuggerStatement' - || statement.type === 'FunctionDeclaration') { + if ( + statement.type === 'EmptyStatement' || + statement.type === 'DebuggerStatement' || + statement.type === 'FunctionDeclaration' + ) { continue; } return { kind: 'unsupported' }; @@ -865,11 +855,7 @@ function predeclareBindings( for (const statement of statements) { if (statement.type === 'VariableDeclaration') { for (const declaration of statement.declarations) { - bindPattern( - declaration.id, - environment, - statement.kind === 'var' ? UNRESOLVED : UNINITIALIZED, - ); + bindPattern(declaration.id, environment, statement.kind === 'var' ? UNRESOLVED : UNINITIALIZED); } } else if (statement.type === 'FunctionDeclaration') { environment.set(statement.id.name, UNRESOLVED); @@ -888,10 +874,7 @@ function predeclareFunctionScopedBindings( for (const statement of statements) collectFunctionScopedBindings(statement, environment); } -function collectFunctionScopedBindings( - statement: Statement | ModuleDeclaration, - environment: StaticEnvironment, -): void { +function collectFunctionScopedBindings(statement: Statement | ModuleDeclaration, environment: StaticEnvironment): void { if (statement.type === 'VariableDeclaration') { if (statement.kind !== 'var') return; for (const declaration of statement.declarations) { @@ -949,20 +932,12 @@ function collectFunctionScopedBindings( } } -function bindPattern( - pattern: Pattern, - environment: StaticEnvironment, - value: StaticBinding, -): void { +function bindPattern(pattern: Pattern, environment: StaticEnvironment, value: StaticBinding): void { if (pattern.type === 'Identifier') { environment.set(pattern.name, value); } else if (pattern.type === 'ObjectPattern') { for (const property of pattern.properties) { - bindPattern( - property.type === 'RestElement' ? property.argument : property.value, - environment, - value, - ); + bindPattern(property.type === 'RestElement' ? property.argument : property.value, environment, value); } } else if (pattern.type === 'ArrayPattern') { for (const element of pattern.elements) { @@ -1034,12 +1009,14 @@ function invalidateKnownMutationCall( function isObjectAssignCall(call: CallExpression, environment: StaticEnvironment): boolean { if (environment.has('Object')) return false; - return call.callee.type === 'MemberExpression' - && !call.callee.computed - && call.callee.object.type === 'Identifier' - && call.callee.object.name === 'Object' - && call.callee.property.type === 'Identifier' - && call.callee.property.name === 'assign'; + return ( + call.callee.type === 'MemberExpression' && + !call.callee.computed && + call.callee.object.type === 'Identifier' && + call.callee.object.name === 'Object' && + call.callee.property.type === 'Identifier' && + call.callee.property.name === 'assign' + ); } function memberRootIdentifier(expression: Expression): string | undefined { @@ -1053,10 +1030,12 @@ function executeForOf( environment: StaticEnvironment, context: EvaluationContext, ): ExecutionCompletion { - if (statement.left.type !== 'VariableDeclaration' - || statement.left.kind !== 'const' - || statement.left.declarations.length !== 1 - || statement.left.declarations[0].id.type !== 'Identifier') { + if ( + statement.left.type !== 'VariableDeclaration' || + statement.left.kind !== 'const' || + statement.left.declarations.length !== 1 || + statement.left.declarations[0].id.type !== 'Identifier' + ) { return { kind: 'unsupported' }; } const iterable = evaluateStatic(statement.right, environment, context); @@ -1109,31 +1088,34 @@ function memberPropertyName( function isPromiseAllCall(call: CallExpression, environment: StaticEnvironment): boolean { if (environment.has('Promise')) return false; - return call.callee.type === 'MemberExpression' - && !call.callee.computed - && call.callee.object.type === 'Identifier' - && call.callee.object.name === 'Promise' - && call.callee.property.type === 'Identifier' - && call.callee.property.name === 'all'; + return ( + call.callee.type === 'MemberExpression' && + !call.callee.computed && + call.callee.object.type === 'Identifier' && + call.callee.object.name === 'Promise' && + call.callee.property.type === 'Identifier' && + call.callee.property.name === 'all' + ); } -function isKnownOrchestrationHelperCall( - call: CallExpression, - environment: StaticEnvironment, -): boolean { - return call.callee.type === 'Identifier' - && !environment.has(call.callee.name) - && CODEX_ORCHESTRATION_HELPERS.has(call.callee.name); +function isKnownOrchestrationHelperCall(call: CallExpression, environment: StaticEnvironment): boolean { + return ( + call.callee.type === 'Identifier' && + !environment.has(call.callee.name) && + CODEX_ORCHESTRATION_HELPERS.has(call.callee.name) + ); } function isArrayMapCall(call: CallExpression): call is CallExpression & { callee: Extract; } { - return call.callee.type === 'MemberExpression' - && !call.callee.computed - && call.callee.object.type !== 'Super' - && call.callee.property.type === 'Identifier' - && call.callee.property.name === 'map'; + return ( + call.callee.type === 'MemberExpression' && + !call.callee.computed && + call.callee.object.type !== 'Super' && + call.callee.property.type === 'Identifier' && + call.callee.property.name === 'map' + ); } function isStaticObject(value: StaticEvaluation): value is StaticObject { @@ -1157,8 +1139,7 @@ function isStaticOpaqueLeaf(value: StaticEvaluation): value is StaticOpaqueLeaf } function isUnprovenPromiseInput(value: StaticValue): boolean { - return isStaticOpaqueLeaf(value) - || (isStaticObject(value) && value.properties.has('then')); + return isStaticOpaqueLeaf(value) || (isStaticObject(value) && value.properties.has('then')); } function extractDirectSkillReadPaths(command: string): string[] { @@ -1246,9 +1227,10 @@ function statusFromLegacyOutput( output: string | undefined, statusHint: CodexSkillReadStatus | undefined, ): CodexSkillReadStatus { - const exitCode = output?.match(/Process exited with code\s+(\d+)/)?.[1] - ?? output?.match(/Exit code:\s+(\d+)/)?.[1] - ?? output?.match(/Exit status\s+(\d+)/)?.[1]; + const exitCode = + output?.match(/Process exited with code\s+(\d+)/)?.[1] ?? + output?.match(/Exit code:\s+(\d+)/)?.[1] ?? + output?.match(/Exit status\s+(\d+)/)?.[1]; if (exitCode !== undefined) return exitCode === '0' ? 'used' : 'failed'; return statusHint ?? 'unknown'; } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a931f52..a3cf1ee 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -42,8 +42,14 @@ export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { autoCheckUpdates: false, projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], providers: { - codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills'), path.join(homeDir, '.codex', 'plugins', 'cache')] }, - claude: { enabled: true, paths: [path.join(homeDir, '.claude', 'plugins', 'cache'), path.join(homeDir, '.claude', 'skills')] }, + codex: { + enabled: true, + paths: [path.join(homeDir, '.codex', 'skills'), path.join(homeDir, '.codex', 'plugins', 'cache')], + }, + claude: { + enabled: true, + paths: [path.join(homeDir, '.claude', 'plugins', 'cache'), path.join(homeDir, '.claude', 'skills')], + }, global: { enabled: true, paths: [path.join(homeDir, '.agents', 'skills')] }, }, sources: { @@ -65,9 +71,8 @@ export class ConfigManager { private config: SkillpackConfig; constructor(configDirOrOptions?: string | ConfigManagerOptions) { - const options = typeof configDirOrOptions === 'string' - ? { configDir: configDirOrOptions } - : (configDirOrOptions ?? {}); + const options = + typeof configDirOrOptions === 'string' ? { configDir: configDirOrOptions } : (configDirOrOptions ?? {}); const homeDir = options.homeDir ?? os.homedir(); this.defaultConfig = createDefaultConfig(homeDir); this.config = cloneConfig(this.defaultConfig); @@ -109,7 +114,9 @@ export class ConfigManager { await access(p); found = true; break; - } catch { /* not found */ } + } catch { + /* not found */ + } } this.config.providers[id].enabled = found; } @@ -128,10 +135,7 @@ function cloneConfig(config: SkillpackConfig): SkillpackConfig { ]), ), sources: Object.fromEntries( - Object.entries(config.sources).map(([id, source]) => [ - id, - { enabled: source.enabled }, - ]), + Object.entries(config.sources).map(([id, source]) => [id, { enabled: source.enabled }]), ), usage: { importConsent: config.usage.importConsent, diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 2eeabb0..aedec54 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -45,11 +45,21 @@ export class SkillManager { }); } - registerProvider(provider: ISkillProvider): void { this.providers.set(provider.id, provider); } - registerSource(source: IInstallSource): void { this.sources.set(source.id, source); } - getProvider(id: string): ISkillProvider | undefined { return this.providers.get(id); } - getProviders(): ISkillProvider[] { return [...this.providers.values()]; } - getSources(): IInstallSource[] { return [...this.sources.values()]; } + registerProvider(provider: ISkillProvider): void { + this.providers.set(provider.id, provider); + } + registerSource(source: IInstallSource): void { + this.sources.set(source.id, source); + } + getProvider(id: string): ISkillProvider | undefined { + return this.providers.get(id); + } + getProviders(): ISkillProvider[] { + return [...this.providers.values()]; + } + getSources(): IInstallSource[] { + return [...this.sources.values()]; + } async init(_configDir?: string): Promise { await this.skillsLock.load(); @@ -94,7 +104,11 @@ export class SkillManager { const seen = new Set(); for (const dir of projectSkillsDirs) { const projectPath = resolveProjectSkillsPath(cwd, dir); - try { await access(projectPath); } catch { continue; } + try { + await access(projectPath); + } catch { + continue; + } const entries = await readdir(projectPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue; @@ -116,7 +130,11 @@ export class SkillManager { version: parsed.raw.version as string | undefined, enabled: true, scope: 'project', - metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, + metadata: { + license: parsed.metadata.license, + author: parsed.metadata.author, + tags: parsed.metadata.tags, + }, source: { type: 'local' }, }); } catch (err) { @@ -131,20 +149,28 @@ export class SkillManager { scope: 'project', metadata: {}, source: { type: 'local' }, - scanIssues: [{ - code: 'invalid-skill-md', - message: err instanceof Error ? err.message : 'Invalid SKILL.md', - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }, + ], }); } - } catch { /* skip missing SKILL.md */ } + } catch { + /* skip missing SKILL.md */ + } } } return skills; } - getAllSkills(): Skill[] { return this.skills; } - getScanPathDiagnostics(): ScanPathDiagnostic[] { return this.scanPathDiagnostics; } + 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), @@ -170,9 +196,15 @@ export class SkillManager { notices: [], })); } - 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); } + 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); + } getSkillUsageOverview(input: SkillUsageOverviewInput): Promise { return this.skillUsage.getOverview(input); } @@ -239,7 +271,7 @@ export class SkillManager { const skillshSource = this.sources.get('skillssh'); if (skillshSource?.checkUpdates) { - updates.push(...await skillshSource.checkUpdates(skillshSkills)); + updates.push(...(await skillshSource.checkUpdates(skillshSkills))); this.rememberUpdateResults(skillshSkills, updates); return updates; } @@ -252,7 +284,9 @@ export class SkillManager { return updates; } - async checkSkillUpdate(skill: Pick): Promise { + async checkSkillUpdate( + skill: Pick, + ): Promise { if (!isSkillShManagedGlobalSkill(skill)) return null; for (const source of this.sources.values()) { const update = await source.checkUpdate(skill); @@ -292,28 +326,33 @@ export class SkillManager { 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, - }))), + [...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', - }; - })) - : []; + 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]; } @@ -356,14 +395,20 @@ function defaultUsageProviders(): SkillUsageProviderConfig[] { provider: 'codex', displayName: 'Codex', supported: true, - artifactRoots: [path.join(os.homedir(), '.codex', 'sessions'), path.join(os.homedir(), '.codex', 'archived_sessions')], + artifactRoots: [ + path.join(os.homedir(), '.codex', 'sessions'), + path.join(os.homedir(), '.codex', 'archived_sessions'), + ], }, { provider: 'claude', displayName: 'Claude', supported: true, artifactRoots: [path.join(os.homedir(), '.claude', 'projects')], - skillRoots: [path.join(os.homedir(), '.claude', 'plugins', 'cache'), path.join(os.homedir(), '.claude', 'skills')], + skillRoots: [ + path.join(os.homedir(), '.claude', 'plugins', 'cache'), + path.join(os.homedir(), '.claude', 'skills'), + ], }, ]; } diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index 15c9e89..dd5dc8c 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -2,10 +2,7 @@ import type { Skill, SkillSource } from './skill.js'; export type SkillIdentityConfidence = 'confirmed' | 'inferred'; -export type InventoryIssueCode = - | 'invalid-skill-md' - | 'broken-symlink' - | 'plugin-identity-mismatch'; +export type InventoryIssueCode = 'invalid-skill-md' | 'broken-symlink' | 'plugin-identity-mismatch'; export type InventoryNoticeCode = | 'related-providers' @@ -63,7 +60,10 @@ export interface SkillGroup { } export function normalizeSkillName(name: string): string { - return name.trim().toLowerCase().replace(/[\s_]+/g, '-'); + return name + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); } function strongIdentityFor(skill: Skill): { key: string; reason: string; confirmsSingleInstance: boolean } | null { @@ -164,7 +164,10 @@ export function buildSkillInventory(skills: Skill[], options: BuildSkillInventor } const assigned = new Set(); - const groups = new Map(); + const groups = new Map< + string, + { identity: { confidence: SkillIdentityConfidence; reason: string }; skills: Skill[] } + >(); for (const [key, group] of strongGroups) { if (group.skills.length < 2 && !group.confirmsSingleInstance) continue; for (const skill of group.skills) assigned.add(skill); @@ -188,9 +191,10 @@ export function buildSkillInventory(skills: Skill[], options: BuildSkillInventor if (instances.length > 1) { notices.push({ code: group.identity.confidence === 'confirmed' ? 'related-providers' : 'name-only-relationship', - message: group.identity.confidence === 'confirmed' - ? 'Skill has confirmed related provider instances' - : 'Skill has same-name provider instances without confirming provenance', + message: + group.identity.confidence === 'confirmed' + ? 'Skill has confirmed related provider instances' + : 'Skill has same-name provider instances without confirming provenance', }); } diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index 8819061..e1b156e 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -8,19 +8,34 @@ import { parseSkillMd } from '../parser.js'; type SkillDirEntryStatus = 'directory' | 'broken-symlink' | 'other'; -async function getSkillDirEntryStatus(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { +async function getSkillDirEntryStatus( + entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, + parentPath: string, +): Promise { if (entry.isDirectory()) return 'directory'; if (entry.isSymbolicLink()) { - try { return (await stat(path.join(parentPath, entry.name))).isDirectory() ? 'directory' : 'other'; } catch { return 'broken-symlink'; } + try { + return (await stat(path.join(parentPath, entry.name))).isDirectory() ? 'directory' : 'other'; + } catch { + return 'broken-symlink'; + } } return 'other'; } -async function isDirEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { - return await getSkillDirEntryStatus(entry, parentPath) === 'directory'; +async function isDirEntry( + entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, + parentPath: string, +): Promise { + return (await getSkillDirEntryStatus(entry, parentPath)) === 'directory'; } -function pluginOrigin(publisher: string, pluginName: string, version: string, pluginEnabled: boolean): NonNullable { +function pluginOrigin( + publisher: string, + pluginName: string, + version: string, + pluginEnabled: boolean, +): NonNullable { return { type: 'plugin', pluginId: `${pluginName}@${publisher}`, @@ -114,7 +129,11 @@ export class ClaudeProvider extends BaseProvider { const skills: Skill[] = []; const skillOverrides = getStringRecord(settings.skillOverrides); for (const basePath of this.flatPaths) { - try { await access(basePath); } catch { continue; } + try { + await access(basePath); + } catch { + continue; + } const entries = await readdir(basePath, { withFileTypes: true }); for (const entry of entries) { const isDisabled = entry.name.startsWith('.disabled-'); @@ -132,10 +151,12 @@ export class ClaudeProvider extends BaseProvider { scope: 'global', metadata: {}, source: { type: 'local' }, - scanIssues: [{ - code: 'broken-symlink', - message: 'Broken skill symlink', - }], + scanIssues: [ + { + code: 'broken-symlink', + message: 'Broken skill symlink', + }, + ], }); continue; } @@ -178,10 +199,12 @@ export class ClaudeProvider extends BaseProvider { scope: 'global', metadata: {}, source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, - scanIssues: [{ - code: 'invalid-skill-md', - message: err instanceof Error ? err.message : 'Invalid SKILL.md', - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }, + ], }); } } @@ -193,7 +216,11 @@ export class ClaudeProvider extends BaseProvider { const skills: Skill[] = []; const enabledPlugins = getBooleanRecord(settings.enabledPlugins); for (const basePath of this.basePaths) { - try { await access(basePath); } catch { continue; } + try { + await access(basePath); + } catch { + continue; + } const publishers = await readdir(basePath, { withFileTypes: true }); for (const pub of publishers) { if (!(await isDirEntry(pub, basePath))) continue; @@ -207,7 +234,11 @@ export class ClaudeProvider extends BaseProvider { for (const ver of versions) { if (!(await isDirEntry(ver, pluginPath))) continue; const skillsDir = path.join(pluginPath, ver.name, 'skills'); - try { await access(skillsDir); } catch { continue; } + try { + await access(skillsDir); + } catch { + continue; + } const skillEntries = await readdir(skillsDir, { withFileTypes: true }); for (const entry of skillEntries) { const isDisabled = entry.name.startsWith('.disabled-'); @@ -228,10 +259,12 @@ export class ClaudeProvider extends BaseProvider { metadata: {}, origin, source: { type: 'local' }, - scanIssues: [{ - code: 'broken-symlink', - message: 'Broken skill symlink', - }], + scanIssues: [ + { + code: 'broken-symlink', + message: 'Broken skill symlink', + }, + ], }); continue; } @@ -248,12 +281,19 @@ export class ClaudeProvider extends BaseProvider { const resolved = await realpath(skillPath); const dirStat = await stat(resolved); skills.push({ - name: parsed.name || skillDirName, description: parsed.description, - provider: this.id, path: skillPath, + name: parsed.name || skillDirName, + description: parsed.description, + provider: this.id, + path: skillPath, resolvedPath: resolved !== skillPath ? resolved : undefined, version: ver.name !== 'unknown' ? ver.name : undefined, - enabled: !isDisabled && pluginEnabled, scope: 'global', - metadata: { license: parsed.metadata.license, author: parsed.metadata.author ?? pub.name, tags: parsed.metadata.tags }, + enabled: !isDisabled && pluginEnabled, + scope: 'global', + metadata: { + license: parsed.metadata.license, + author: parsed.metadata.author ?? pub.name, + tags: parsed.metadata.tags, + }, origin, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, }); @@ -272,10 +312,12 @@ export class ClaudeProvider extends BaseProvider { metadata: {}, origin, source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, - scanIssues: [{ - code: 'invalid-skill-md', - message: err instanceof Error ? err.message : 'Invalid SKILL.md', - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }, + ], }); } } @@ -309,7 +351,9 @@ export class ClaudeProvider extends BaseProvider { } override async disable(name: string): Promise { - const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + 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; @@ -322,8 +366,12 @@ export class ClaudeProvider extends BaseProvider { try { await access(src); await access(dest).then( - () => { throw new Error(`Target ${dest} already exists`); }, - () => { /* good */ }, + () => { + throw new Error(`Target ${dest} already exists`); + }, + () => { + /* good */ + }, ); await rename(src, dest); return; @@ -333,7 +381,11 @@ export class ClaudeProvider extends BaseProvider { } // Then deep cache paths for (const basePath of this.basePaths) { - try { await access(basePath); } catch { continue; } + try { + await access(basePath); + } catch { + continue; + } const publishers = await readdir(basePath, { withFileTypes: true }); for (const pub of publishers) { if (!(await isDirEntry(pub, basePath))) continue; @@ -346,14 +398,22 @@ export class ClaudeProvider extends BaseProvider { for (const ver of versions) { if (!(await isDirEntry(ver, pluginPath))) continue; const skillsDir = path.join(pluginPath, ver.name, 'skills'); - try { await access(skillsDir); } catch { continue; } + try { + await access(skillsDir); + } catch { + continue; + } const src = path.join(skillsDir, name); const dest = path.join(skillsDir, `.disabled-${name}`); try { await access(src); await access(dest).then( - () => { throw new Error(`Target ${dest} already exists`); }, - () => { /* dest doesn't exist, good */ }, + () => { + throw new Error(`Target ${dest} already exists`); + }, + () => { + /* dest doesn't exist, good */ + }, ); await rename(src, dest); return; @@ -368,7 +428,9 @@ 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); + 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; @@ -382,11 +444,17 @@ export class ClaudeProvider extends BaseProvider { await access(src); await rename(src, dest); return; - } catch { /* try next */ } + } catch { + /* try next */ + } } // Then deep cache paths for (const basePath of this.basePaths) { - try { await access(basePath); } catch { continue; } + try { + await access(basePath); + } catch { + continue; + } const publishers = await readdir(basePath, { withFileTypes: true }); for (const pub of publishers) { if (!(await isDirEntry(pub, basePath))) continue; @@ -399,14 +467,20 @@ export class ClaudeProvider extends BaseProvider { for (const ver of versions) { if (!(await isDirEntry(ver, pluginPath))) continue; const skillsDir = path.join(pluginPath, ver.name, 'skills'); - try { await access(skillsDir); } catch { continue; } + try { + await access(skillsDir); + } catch { + continue; + } const src = path.join(skillsDir, `.disabled-${name}`); const dest = path.join(skillsDir, name); try { await access(src); await rename(src, dest); return; - } catch { /* not found here, try next */ } + } catch { + /* not found here, try next */ + } } } } @@ -440,7 +514,11 @@ function getBooleanRecord(value: unknown): Record { ); } -async function setClaudeSkillOverride(settingsPath: string, skillName: string, state: ClaudeSkillOverride): Promise { +async function setClaudeSkillOverride( + settingsPath: string, + skillName: string, + state: ClaudeSkillOverride, +): Promise { const settings = await readJsonSettings(settingsPath); settings.skillOverrides = { ...getStringRecord(settings.skillOverrides), diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index 5246eeb..2a7f601 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -39,7 +39,10 @@ async function isDirectory(filePath: string): Promise { } } -async function getSkillDirEntryStatus(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { +async function getSkillDirEntryStatus( + entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, + parentPath: string, +): Promise { if (entry.isDirectory()) return 'directory'; if (entry.isSymbolicLink()) { try { @@ -102,13 +105,15 @@ export class CodexProvider extends BaseProvider { override async scan(): Promise { const flatSkills = await this.scanBasePaths(this.skillPaths); - const hydratedFlatSkills = await Promise.all(flatSkills.map(async (skill) => { - const configEnabled = await readCodexSkillConfigEnabled(this.configPath, path.join(skill.path, 'SKILL.md')); - return { - ...skill, - enabled: skill.enabled && configEnabled !== false, - }; - })); + const 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]; } @@ -128,7 +133,9 @@ export class CodexProvider extends BaseProvider { ]; } - override getDisableStrategy(skill: Pick): { type: 'provider-config'; description: string } | undefined { + override getDisableStrategy( + skill: Pick, + ): { type: 'provider-config'; description: string } | undefined { if (skill.origin?.type === 'plugin') { if (skill.origin.identityStatus === 'mismatched') return undefined; return { @@ -142,10 +149,15 @@ export class CodexProvider extends BaseProvider { }; } - 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`); + 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); @@ -156,13 +168,17 @@ export class CodexProvider extends BaseProvider { } override async disable(name: string): Promise { - const skill = (await this.scan()).find((item) => item.name === name || path.basename(item.path).replace(/^\.disabled-/, '') === name); + 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); + 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); } @@ -202,7 +218,11 @@ export class CodexProvider extends BaseProvider { private async findPluginRoots(): Promise { const roots: CodexPluginRoot[] = []; for (const cachePath of this.pluginCachePaths) { - try { await access(cachePath); } catch { continue; } + try { + await access(cachePath); + } catch { + continue; + } const marketplaces = await readdir(cachePath, { withFileTypes: true }); for (const marketplace of marketplaces) { if (!marketplace.isDirectory()) continue; @@ -228,7 +248,8 @@ export class CodexProvider extends BaseProvider { path: rootPath, mtimeMs: rootStat.mtimeMs, manifestName: typeof manifest?.name === 'string' ? manifest.name : undefined, - displayName: typeof manifest?.interface?.displayName === 'string' ? manifest.interface.displayName : undefined, + displayName: + typeof manifest?.interface?.displayName === 'string' ? manifest.interface.displayName : undefined, }); } } @@ -245,11 +266,15 @@ export class CodexProvider extends BaseProvider { } } - private async scanPluginRoot(pluginRoot: CodexPluginRoot, pluginConfig: Record): Promise { + 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 identityStatus = + pluginRoot.manifestName && pluginRoot.manifestName !== pluginRoot.pluginName ? 'mismatched' : 'confirmed'; const entries = await readdir(skillsPath, { withFileTypes: true }); for (const entry of entries) { @@ -268,10 +293,15 @@ export class CodexProvider extends BaseProvider { skillConfigEnabled, identityStatus, }; - const identityIssues = identityStatus === 'mismatched' ? [{ - code: 'plugin-identity-mismatch' as const, - message: `Codex plugin manifest name "${pluginRoot.manifestName}" does not match cache plugin "${pluginRoot.pluginName}"`, - }] : []; + const identityIssues = + identityStatus === 'mismatched' + ? [ + { + code: 'plugin-identity-mismatch' as const, + message: `Codex plugin manifest name "${pluginRoot.manifestName}" does not match cache plugin "${pluginRoot.pluginName}"`, + }, + ] + : []; const entryStatus = await getSkillDirEntryStatus(entry, skillsPath); if (entryStatus === 'broken-symlink') { skills.push({ @@ -285,10 +315,7 @@ export class CodexProvider extends BaseProvider { metadata: {}, origin, source: { type: 'local' }, - scanIssues: [ - ...identityIssues, - { code: 'broken-symlink', message: 'Broken skill symlink' }, - ], + scanIssues: [...identityIssues, { code: 'broken-symlink', message: 'Broken skill symlink' }], }); continue; } diff --git a/packages/core/src/providers/provider-settings.ts b/packages/core/src/providers/provider-settings.ts index 0131683..4495342 100644 --- a/packages/core/src/providers/provider-settings.ts +++ b/packages/core/src/providers/provider-settings.ts @@ -152,7 +152,10 @@ async function readTextIfExists(filePath: string): Promise { } } -export async function readCodexSkillConfigEnabled(configPath: string, skillMdPath: string): Promise { +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); @@ -169,7 +172,11 @@ export async function readCodexPluginEnabled(configPath: string, pluginId: strin return config[pluginId]; } -export async function writeCodexSkillConfigEnabled(configPath: string, skillMdPath: string, enabled: boolean): Promise { +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); diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index a9c65fd..c109979 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -32,7 +32,11 @@ export abstract class BaseProvider implements ISkillProvider { protected async scanBasePaths(basePaths: string[]): Promise { const skills: Skill[] = []; for (const basePath of basePaths) { - try { await access(basePath); } catch { continue; } + try { + await access(basePath); + } catch { + continue; + } const entries = await readdir(basePath, { withFileTypes: true }); for (const entry of entries) { const isDisabled = entry.name.startsWith('.disabled-'); @@ -53,10 +57,12 @@ export abstract class BaseProvider implements ISkillProvider { scope: 'global', metadata: {}, source: { type: 'local' }, - scanIssues: [{ - code: 'broken-symlink', - message: err instanceof Error ? err.message : 'Broken skill symlink', - }], + scanIssues: [ + { + code: 'broken-symlink', + message: err instanceof Error ? err.message : 'Broken skill symlink', + }, + ], }); continue; } @@ -99,10 +105,12 @@ export abstract class BaseProvider implements ISkillProvider { scope: 'global', metadata: {}, source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, - scanIssues: [{ - code: 'invalid-skill-md', - message: err instanceof Error ? err.message : 'Invalid SKILL.md', - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }, + ], }); } } @@ -142,8 +150,12 @@ export abstract class BaseProvider implements ISkillProvider { 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 */ }, + () => { + throw new Error(`Target ${dest} already exists`); + }, + () => { + /* dest doesn't exist, good */ + }, ); await rename(skill.path, dest); } @@ -156,7 +168,9 @@ export abstract class BaseProvider implements ISkillProvider { await access(src); await rename(src, dest); return; - } catch { /* not found in this basePath, try next */ } + } catch { + /* not found in this basePath, try next */ + } } throw new Error(`Disabled skill "${name}" not found in ${this.displayName}`); } @@ -168,8 +182,12 @@ export abstract class BaseProvider implements ISkillProvider { try { await access(src); await access(dest).then( - () => { throw new Error(`Target ${dest} already exists`); }, - () => { /* dest doesn't exist, good */ }, + () => { + throw new Error(`Target ${dest} already exists`); + }, + () => { + /* dest doesn't exist, good */ + }, ); await rename(src, dest); return; diff --git a/packages/core/src/sources/skillssh.ts b/packages/core/src/sources/skillssh.ts index 63199e1..e01ce69 100644 --- a/packages/core/src/sources/skillssh.ts +++ b/packages/core/src/sources/skillssh.ts @@ -6,15 +6,20 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +const ANSI_SGR_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, 'g'); type CommandResult = { stdout: string; stderr: string }; -type CommandRunner = (command: string, args: string[], options: { - timeout: number; - env: NodeJS.ProcessEnv; -}) => Promise; +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, ''); + return s.replace(ANSI_SGR_PATTERN, ''); } function parseSearchOutput(raw: string): RemoteSkill[] { @@ -26,7 +31,8 @@ function parseSearchOutput(raw: string): RemoteSkill[] { const match = line.match(/^(\S+\/\S+@\S+)\s+(\S+)\s+installs?/); if (!match) continue; const identifier = match[1]; - const installs = parseFloat(match[2].replace(/K/i, '')) * (match[2].includes('K') || match[2].includes('k') ? 1000 : 1); + const installs = + parseFloat(match[2].replace(/K/i, '')) * (match[2].includes('K') || match[2].includes('k') ? 1000 : 1); const parts = identifier.split('@'); const skillName = parts[parts.length - 1] ?? identifier; @@ -81,10 +87,14 @@ export class SkillsShSource implements IInstallSource { env: { ...process.env, NO_COLOR: '1' }, }); } catch (err: unknown) { - const stderr = err && typeof err === 'object' && 'stderr' in err - ? stripAnsi(String((err as { stderr: string }).stderr)).trim() : ''; - const stdout = err && typeof err === 'object' && 'stdout' in err - ? stripAnsi(String((err as { stdout: string }).stdout)).trim() : ''; + const stderr = + err && typeof err === 'object' && 'stderr' in err + ? stripAnsi(String((err as { stderr: string }).stderr)).trim() + : ''; + const stdout = + err && typeof err === 'object' && 'stdout' in err + ? stripAnsi(String((err as { stdout: string }).stdout)).trim() + : ''; const detail = stderr || stdout || (err instanceof Error ? err.message : String(err)); throw new Error(`Install failed: ${detail}`); } @@ -96,7 +106,9 @@ export class SkillsShSource implements IInstallSource { if (skill.source?.type !== 'skillssh') return null; try { return parseUpdateForSkill(await this.runCheckCommand(), skill); - } catch { /* skip */ } + } catch { + /* skip */ + } return null; } diff --git a/packages/core/src/usage-paths.ts b/packages/core/src/usage-paths.ts index d0e627d..4b69fb7 100644 --- a/packages/core/src/usage-paths.ts +++ b/packages/core/src/usage-paths.ts @@ -12,7 +12,5 @@ export function isSupportedClaudeArtifactPath( if (!relative || relative.startsWith(`..${pathOperations.sep}`) || pathOperations.isAbsolute(relative)) return false; const parts = relative.split(pathOperations.sep); if (parts.length === 2) return parts[1].endsWith('.jsonl'); - return parts.length === 4 - && parts[2] === 'subagents' - && /^agent-.*\.jsonl$/.test(parts[3]); + return parts.length === 4 && parts[2] === 'subagents' && /^agent-.*\.jsonl$/.test(parts[3]); } diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index e92b077..0dedc96 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -108,10 +108,13 @@ interface UsageImportCursor { provider: string; importerVersion?: number; stale?: boolean; - artifacts: Record; + artifacts: Record< + string, + { + size: number; + mtimeMs: number; + } + >; } interface ParsedInvocationResult { @@ -175,11 +178,11 @@ export class SkillUsageManager { const diagnostics: UsageImportDiagnostic[] = []; let skippedArtifacts = 0; const records: SkillInvocationRecord[] = []; - const claudeSkills = provider.provider === 'claude' - ? await readClaudeSkillCandidates(provider.skillRoots ?? []) - : []; + const claudeSkills = + provider.provider === 'claude' ? await readClaudeSkillCandidates(provider.skillRoots ?? []) : []; const cursor = await this.readCursor(provider.provider); - if (cursor.stale) await rm(path.join(this.dataDir, 'invocations', provider.provider), { recursive: true, force: true }); + if (cursor.stale) + await rm(path.join(this.dataDir, 'invocations', provider.provider), { recursive: true, force: true }); const nextCursor: UsageImportCursor = { provider: provider.provider, artifacts: { ...cursor.artifacts }, @@ -230,9 +233,10 @@ export class SkillUsageManager { if (!content) continue; const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); - const result = provider.provider === 'claude' - ? await parseClaudeInvocationLines(provider.provider, file, lines, claudeSkills, currentSkills) - : await parseCodexInvocationLines(provider.provider, file, lines, currentSkills); + const result = + provider.provider === 'claude' + ? await parseClaudeInvocationLines(provider.provider, file, lines, claudeSkills, currentSkills) + : await parseCodexInvocationLines(provider.provider, file, lines, currentSkills); records.push(...result.records); diagnostics.push(...(result.diagnostics ?? [])); for (const lineNumber of result.ambiguousLineNumbers) { @@ -278,35 +282,37 @@ export class SkillUsageManager { const range = buildRange(input.rangeDays, input.now ?? new Date()); return { range, - providers: await Promise.all(this.providers.map(async (provider) => { - const coverageReasons = coverageReasonsFor(provider); - if (!provider.supported) { + providers: await Promise.all( + this.providers.map(async (provider) => { + const coverageReasons = coverageReasonsFor(provider); + if (!provider.supported) { + return { + provider: provider.provider, + displayName: provider.displayName, + coverageState: coverageStateFor(provider, 0), + coverageReasons, + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], + }; + } + const records = await this.readProviderRecords(provider.provider, range); + const heatmap = buildHeatmap(records, range); + const dailySkillUsage = buildDailySkillUsage(records, range); + const ranking = buildRanking(records); return { provider: provider.provider, displayName: provider.displayName, - coverageState: coverageStateFor(provider, 0), + coverageState: coverageStateFor(provider, records.length), coverageReasons, - heatmap: [], - dailySkillUsage: [], - ranking: [], + heatmap, + dailySkillUsage, + ranking, diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], }; - } - const records = await this.readProviderRecords(provider.provider, range); - const heatmap = buildHeatmap(records, range); - const dailySkillUsage = buildDailySkillUsage(records, range); - const ranking = buildRanking(records); - return { - provider: provider.provider, - displayName: provider.displayName, - coverageState: coverageStateFor(provider, records.length), - coverageReasons, - heatmap, - dailySkillUsage, - ranking, - diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], - }; - })), + }), + ), }; } @@ -340,8 +346,9 @@ export class SkillUsageManager { private async readProviderRecords(provider: string, range: SkillUsageRange): Promise { const dir = path.join(this.dataDir, 'invocations', provider); - const files = [...new Set(datesInRange(range).map((date) => date.slice(0, 7)))] - .map((month) => path.join(dir, `${month}.jsonl`)); + const files = [...new Set(datesInRange(range).map((date) => date.slice(0, 7)))].map((month) => + path.join(dir, `${month}.jsonl`), + ); const latestRecords = new Map(); for (const file of files.sort()) { @@ -382,13 +389,20 @@ export class SkillUsageManager { private async writeCursor(provider: string, cursor: UsageImportCursor): Promise { const cursorPath = path.join(this.dataDir, 'cursors', `${provider}.json`); await mkdir(path.dirname(cursorPath), { recursive: true }); - await writeFile(cursorPath, JSON.stringify({ - ...cursor, - provider, - importerVersion: usageImporterVersion(provider), - }, null, 2) + '\n', 'utf-8'); + await writeFile( + cursorPath, + JSON.stringify( + { + ...cursor, + provider, + importerVersion: usageImporterVersion(provider), + }, + null, + 2, + ) + '\n', + 'utf-8', + ); } - } function usageImporterVersion(provider: string): number { @@ -450,11 +464,13 @@ async function listJsonlFiles(root: string): Promise { if (isNodeError(err) && err.code === 'ENOENT') return []; throw err; }); - const files = await Promise.all(entries.map(async (entry) => { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) return listJsonlFiles(entryPath); - return (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.jsonl') ? [entryPath] : []; - })); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return listJsonlFiles(entryPath); + return (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.jsonl') ? [entryPath] : []; + }), + ); return files.flat(); } @@ -485,7 +501,8 @@ async function parseCodexInvocationLines( if (existing) { existing.status = mergeInvocationStatus(existing.status, candidate.status); if (candidate.startedAt < existing.startedAt) existing.startedAt = candidate.startedAt; - if (candidate.endedAt && (!existing.endedAt || candidate.endedAt > existing.endedAt)) existing.endedAt = candidate.endedAt; + if (candidate.endedAt && (!existing.endedAt || candidate.endedAt > existing.endedAt)) + existing.endedAt = candidate.endedAt; if (skillIdentity.confirmed) { if (existing.identityConfidence === 'confirmed') { existing.skillPath = commonPathEvidence(existing.skillPath, candidate.skillPath); @@ -496,31 +513,31 @@ async function parseCodexInvocationLines( } existing.identityConfidence = 'confirmed'; } - existing.source ??= findCurrentSkillSource({ + existing.source ??= findCurrentSkillSource( + { + provider, + skillName, + skillPath: candidate.skillPath, + resolvedPath: skillIdentity.resolvedPath, + }, + currentSkills, + ); + continue; + } + + const source = findCurrentSkillSource( + { provider, skillName, skillPath: candidate.skillPath, resolvedPath: skillIdentity.resolvedPath, - }, currentSkills); - continue; - } - - const source = findCurrentSkillSource({ - provider, - skillName, - skillPath: candidate.skillPath, - resolvedPath: skillIdentity.resolvedPath, - }, currentSkills); + }, + currentSkills, + ); recordsByTurnAndSkill.set(recordKey, { schemaVersion: 1, - recordId: invocationRecordId( - provider, - candidate.sessionId, - candidate.turnId, - skillName, - sourceIdentity, - ), + recordId: invocationRecordId(provider, candidate.sessionId, candidate.turnId, skillName, sourceIdentity), provider, sessionId: candidate.sessionId, turnId: candidate.turnId, @@ -579,12 +596,9 @@ async function parseClaudeInvocationLines( missingTimestampCount += message.content.filter((block) => claudeSkillName(block) !== undefined).length; continue; } - const sessionId = stringValue(value.sessionId) - ?? stringValue(value.session_id) - ?? path.basename(sourceFile, '.jsonl'); - const messageId = stringValue(value.uuid) - ?? stringValue(message.id) - ?? `${sessionId}:${lineIndex + 1}`; + const sessionId = + stringValue(value.sessionId) ?? stringValue(value.session_id) ?? path.basename(sourceFile, '.jsonl'); + const messageId = stringValue(value.uuid) ?? stringValue(message.id) ?? `${sessionId}:${lineIndex + 1}`; for (const [blockIndex, block] of message.content.entries()) { const skillName = claudeSkillName(block); @@ -630,10 +644,12 @@ async function parseClaudeInvocationLines( message: `Skipped ${missingTimestampCount} Claude Skill invocation${missingTimestampCount === 1 ? '' : 's'} in ${path.basename(sourceFile)}: missing or invalid assistant timestamp`, }); } - diagnostics.push(...[...unmatchedCounts.entries()].map(([skillName, count]) => ({ - provider, - message: `Skipped ${count} Claude Skill invocation${count === 1 ? '' : 's'} for "${skillName}" in ${path.basename(sourceFile)}: no eligible user-level or plugin source`, - }))); + diagnostics.push( + ...[...unmatchedCounts.entries()].map(([skillName, count]) => ({ + provider, + message: `Skipped ${count} Claude Skill invocation${count === 1 ? '' : 's'} for "${skillName}" in ${path.basename(sourceFile)}: no eligible user-level or plugin source`, + })), + ); return { records, @@ -720,20 +736,22 @@ async function listSkillMdFiles(root: string, ancestorRealPaths = new Set { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) return listSkillMdFiles(entryPath, nextAncestors); - if (entry.isSymbolicLink()) { - const metadata = await stat(entryPath).catch(() => undefined); - if (metadata?.isDirectory()) { - const skillPath = path.join(entryPath, 'SKILL.md'); - const skillMetadata = await stat(skillPath).catch(() => undefined); - return skillMetadata?.isFile() ? [skillPath] : listSkillMdFiles(entryPath, nextAncestors); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return listSkillMdFiles(entryPath, nextAncestors); + if (entry.isSymbolicLink()) { + const metadata = await stat(entryPath).catch(() => undefined); + if (metadata?.isDirectory()) { + const skillPath = path.join(entryPath, 'SKILL.md'); + const skillMetadata = await stat(skillPath).catch(() => undefined); + return skillMetadata?.isFile() ? [skillPath] : listSkillMdFiles(entryPath, nextAncestors); + } + return metadata?.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; } - return metadata?.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; - } - return entry.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; - })); + return entry.isFile() && entry.name === 'SKILL.md' ? [entryPath] : []; + }), + ); return files.flat(); } @@ -757,16 +775,18 @@ function normalizeSkillNameFromPath(skillPath: string): string { return pluginName ? `${pluginName}:${skillName}` : skillName; } -function parseCodexPluginSkillPath(skillPath: string): { - marketplace: string; - plugin: string; - relativeSkillPath: string; -} | undefined { +function parseCodexPluginSkillPath(skillPath: string): + | { + marketplace: string; + plugin: string; + relativeSkillPath: string; + } + | undefined { const normalized = path.normalize(skillPath); const parts = normalized.split(path.sep).filter(Boolean); - const cacheIndex = parts.findIndex((part, index) => ( - part === 'cache' && parts[index - 1] === 'plugins' && parts[index - 2] === '.codex' - )); + const cacheIndex = parts.findIndex( + (part, index) => part === 'cache' && parts[index - 1] === 'plugins' && parts[index - 2] === '.codex', + ); if (cacheIndex < 0) return undefined; const marketplace = parts[cacheIndex + 1]; @@ -842,10 +862,7 @@ function buildRanking(records: SkillInvocationRecord[]): ProviderSkillRankingRow return [...rows.values()].map(finalizeUsageRow).sort(compareUsageRows).slice(0, 10); } -function buildDailySkillUsage( - records: SkillInvocationRecord[], - range: SkillUsageRange, -): ProviderSkillDailyUsage[] { +function buildDailySkillUsage(records: SkillInvocationRecord[], range: SkillUsageRange): ProviderSkillDailyUsage[] { const byDate = new Map>(); for (const date of datesInRange(range)) byDate.set(date, new Map()); @@ -859,22 +876,23 @@ function buildDailySkillUsage( byDate.set(date, rows); } - return [...byDate.entries()].map(([date, rows]) => ({ - date, - rows: [...rows.values()].map(finalizeUsageRow).sort(compareUsageRows), - })).sort((a, b) => a.date.localeCompare(b.date)); + return [...byDate.entries()] + .map(([date, rows]) => ({ + date, + rows: [...rows.values()].map(finalizeUsageRow).sort(compareUsageRows), + })) + .sort((a, b) => a.date.localeCompare(b.date)); } function usageRowKey(record: SkillInvocationRecord): string { return `${record.skillName}\0${skillSourceIdentity(record)}`; } -function skillSourceIdentity( - record: Pick, -): string { - const pluginSkill = record.provider === 'codex' - ? parseCodexPluginSkillPath(record.skillPath ?? '') ?? parseCodexPluginSkillPath(record.resolvedPath ?? '') - : undefined; +function skillSourceIdentity(record: Pick): string { + const pluginSkill = + record.provider === 'codex' + ? (parseCodexPluginSkillPath(record.skillPath ?? '') ?? parseCodexPluginSkillPath(record.resolvedPath ?? '')) + : undefined; if (pluginSkill) { return `codex-plugin\0${pluginSkill.marketplace}\0${pluginSkill.plugin}\0${pluginSkill.relativeSkillPath}`; } @@ -896,10 +914,7 @@ function createUsageAccumulator(record: SkillInvocationRecord): ProviderSkillUsa }; } -function addRecordToUsageAccumulator( - accumulator: ProviderSkillUsageAccumulator, - record: SkillInvocationRecord, -): void { +function addRecordToUsageAccumulator(accumulator: ProviderSkillUsageAccumulator, record: SkillInvocationRecord): void { accumulator.recordCount += 1; if (record.skillPath) { accumulator.providerPathCount += 1; @@ -920,14 +935,14 @@ function addRecordToUsageAccumulator( } function finalizeUsageRow(accumulator: ProviderSkillUsageAccumulator): ProviderSkillUsageRow { - const commonProviderPath = accumulator.providerPathCount === accumulator.recordCount - && accumulator.providerPaths.size === 1 - ? [...accumulator.providerPaths][0] - : undefined; - const commonResolvedPath = accumulator.resolvedPathCount === accumulator.recordCount - && accumulator.resolvedPaths.size === 1 - ? [...accumulator.resolvedPaths][0] - : undefined; + const commonProviderPath = + accumulator.providerPathCount === accumulator.recordCount && accumulator.providerPaths.size === 1 + ? [...accumulator.providerPaths][0] + : undefined; + const commonResolvedPath = + accumulator.resolvedPathCount === accumulator.recordCount && accumulator.resolvedPaths.size === 1 + ? [...accumulator.resolvedPaths][0] + : undefined; return { ...accumulator.row, sourcePath: commonProviderPath ?? commonResolvedPath, @@ -935,10 +950,12 @@ function finalizeUsageRow(accumulator: ProviderSkillUsageAccumulator): ProviderS } function compareUsageRows(a: ProviderSkillUsageRow, b: ProviderSkillUsageRow): number { - return b.countedInvocations - a.countedInvocations - || b.failedInvocations - a.failedInvocations - || compareOptionalTimestampDesc(a.lastInvokedAt, b.lastInvokedAt) - || a.skillName.localeCompare(b.skillName); + return ( + b.countedInvocations - a.countedInvocations || + b.failedInvocations - a.failedInvocations || + compareOptionalTimestampDesc(a.lastInvokedAt, b.lastInvokedAt) || + a.skillName.localeCompare(b.skillName) + ); } function compareOptionalTimestampDesc(left: string | undefined, right: string | undefined): number { @@ -960,16 +977,23 @@ function matchesCurrentSkill( record: Pick, skill: CurrentSkillReference, ): boolean { - if (record.skillPath !== undefined && skill.path !== undefined && matchesSkillMdPath(record.skillPath, skill.path)) return true; - if (record.resolvedPath !== undefined && skill.resolvedPath !== undefined && matchesSkillMdPath(record.resolvedPath, skill.resolvedPath)) return true; + if (record.skillPath !== undefined && skill.path !== undefined && matchesSkillMdPath(record.skillPath, skill.path)) + return true; + if ( + record.resolvedPath !== undefined && + skill.resolvedPath !== undefined && + matchesSkillMdPath(record.resolvedPath, skill.resolvedPath) + ) + return true; return skill.provider === record.provider && skill.name === record.skillName; } function matchesSkillMdPath(recordPath: string, skillReferencePath: string): boolean { const normalizedRecordPath = path.normalize(recordPath); const normalizedSkillPath = path.normalize(skillReferencePath); - return normalizedRecordPath === normalizedSkillPath - || normalizedRecordPath === path.join(normalizedSkillPath, 'SKILL.md'); + return ( + normalizedRecordPath === normalizedSkillPath || normalizedRecordPath === path.join(normalizedSkillPath, 'SKILL.md') + ); } function isRecord(value: unknown): value is Record { @@ -981,9 +1005,7 @@ function stringValue(value: unknown): string | undefined { } function isValidInvocationTimestamp(value: string | undefined): value is string { - return value !== undefined - && /^\d{4}-\d{2}-\d{2}T/.test(value) - && !Number.isNaN(Date.parse(value)); + return value !== undefined && /^\d{4}-\d{2}-\d{2}T/.test(value) && !Number.isNaN(Date.parse(value)); } function isNodeError(value: unknown): value is NodeJS.ErrnoException { diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 0d1a02f..3f83e09 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -20,11 +20,15 @@ describe('ConfigManager', () => { 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'); + await writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + providers: { + codex: { paths: [customCodexPath] }, + }, + }), + 'utf-8', + ); const config = await new ConfigManager(configDir).load(); @@ -52,10 +56,7 @@ describe('ConfigManager', () => { expect(config.providers.codex).toEqual({ enabled: true, - paths: [ - path.join(homeDir, '.codex', 'skills'), - path.join(homeDir, '.codex', 'plugins', 'cache'), - ], + paths: [path.join(homeDir, '.codex', 'skills'), path.join(homeDir, '.codex', 'plugins', 'cache')], }); expect(config.providers.global).toEqual({ enabled: true, @@ -72,13 +73,17 @@ describe('ConfigManager', () => { it('merges configured Usage Artifact Roots with defaults', async () => { const customClaudeUsageRoot = path.join(root, 'claude-history'); - await writeFile(path.join(configDir, 'config.json'), JSON.stringify({ - usage: { - artifactRoots: { - claude: [customClaudeUsageRoot], + await writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + usage: { + artifactRoots: { + claude: [customClaudeUsageRoot], + }, }, - }, - }), 'utf-8'); + }), + 'utf-8', + ); const config = await new ConfigManager({ configDir, homeDir: path.join(root, 'home') }).load(); diff --git a/packages/core/tests/duplicates.test.ts b/packages/core/tests/duplicates.test.ts index c0ab5c6..e03bde1 100644 --- a/packages/core/tests/duplicates.test.ts +++ b/packages/core/tests/duplicates.test.ts @@ -1,9 +1,17 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { DuplicateDetector } from '../src/duplicates.js'; import type { Skill } from '../src/models/index.js'; function makeSkill(name: string, provider: string): Skill { - return { name, description: '', provider, path: `/fake/${provider}/${name}`, enabled: true, scope: 'global', metadata: {} }; + return { + name, + description: '', + provider, + path: `/fake/${provider}/${name}`, + enabled: true, + scope: 'global', + metadata: {}, + }; } describe('DuplicateDetector', () => { @@ -14,14 +22,24 @@ describe('DuplicateDetector', () => { }); it('detects duplicate when same name across providers', () => { - const duplicates = detector.detect([makeSkill('figma', 'codex'), makeSkill('figma', 'claude'), 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', 'global'), 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 e520de3..7d17aa8 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -48,16 +48,18 @@ describe('Skill Inventory', () => { }); it('does not create findings for a single local skill with name-only identity', () => { - const inventory = buildSkillInventory([{ - name: 'local-codex', - description: '', - provider: 'codex', - path: path.join(root, 'codex', 'local-codex'), - enabled: true, - scope: 'global', - metadata: {}, - source: { type: 'local' }, - }]); + const inventory = buildSkillInventory([ + { + name: 'local-codex', + description: '', + provider: 'codex', + path: path.join(root, 'codex', 'local-codex'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'local' }, + }, + ]); expect(inventory).toHaveLength(1); expect(inventory[0].identity.confidence).toBe('inferred'); @@ -161,16 +163,18 @@ describe('Skill Inventory', () => { }); 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', repo: 'owner/repo' }, - }]); + const inventory = buildSkillInventory([ + { + name: 'managed-global', + description: '', + provider: 'global', + path: path.join(root, 'global', 'managed-global'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', repo: 'owner/repo' }, + }, + ]); expect(inventory).toHaveLength(1); expect(inventory[0].identity.confidence).toBe('confirmed'); @@ -207,43 +211,53 @@ describe('Skill Inventory', () => { }); it('does not expose toggle actions when the provider has no Disable Strategy', () => { - const inventory = buildSkillInventory([{ - name: 'plugin-skill', - description: '', - provider: 'codex', - path: path.join(root, 'codex', 'plugin-skill'), - enabled: true, - scope: 'global', - metadata: {}, - origin: { - type: 'plugin', - pluginId: 'github@openai-curated', - pluginName: 'github', - marketplace: 'openai-curated', - pluginEnabled: true, - identityStatus: 'mismatched', + const inventory = buildSkillInventory( + [ + { + name: 'plugin-skill', + description: '', + provider: 'codex', + path: path.join(root, 'codex', 'plugin-skill'), + enabled: true, + scope: 'global', + metadata: {}, + origin: { + type: 'plugin', + pluginId: 'github@openai-curated', + pluginName: 'github', + marketplace: 'openai-curated', + pluginEnabled: true, + identityStatus: 'mismatched', + }, + source: { type: 'local' }, + }, + ], + { + getDisableStrategy: () => undefined, }, - source: { type: 'local' }, - }], { - getDisableStrategy: () => undefined, - }); + ); expect(inventory[0].instances[0].actions).toEqual([]); }); it('surfaces remembered skills.sh update checks as notices', () => { - const inventory = buildSkillInventory([{ - name: 'managed-global', - description: '', - provider: 'global', - path: path.join(root, 'global', 'managed-global'), - enabled: true, - scope: 'global', - metadata: {}, - source: { type: 'skillssh', repo: 'owner/repo' }, - }], { - hasUpdate: () => true, - }); + const inventory = buildSkillInventory( + [ + { + name: 'managed-global', + description: '', + provider: 'global', + path: path.join(root, 'global', 'managed-global'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', repo: 'owner/repo' }, + }, + ], + { + hasUpdate: () => true, + }, + ); expect(inventory[0].instances[0].notices.map((notice) => notice.code)).toContain('update-available'); expect(inventory[0].issues).toEqual([]); diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index 13ca46c..3748b77 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { access, mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -19,7 +19,9 @@ describe('SkillManager', () => { manager.registerProvider(new CodexProvider([dir])); }); - afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); it('scans all providers', async () => { const skillDir = path.join(dir, 'test'); @@ -65,16 +67,20 @@ describe('SkillManager', () => { 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(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: '' }; - })); + manager.registerSource( + new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }), + ); const skill: Skill = { name: 'managed-skill', description: '', @@ -88,10 +94,12 @@ describe('SkillManager', () => { await manager.uninstallSkill(skill); - expect(calls).toEqual([{ - command: 'npx', - args: ['skills', 'remove', 'managed-skill', '-g', '-y'], - }]); + expect(calls).toEqual([ + { + command: 'npx', + args: ['skills', 'remove', 'managed-skill', '-g', '-y'], + }, + ]); }); it('does not toggle Global Skills by renaming shared content', async () => { @@ -168,25 +176,25 @@ describe('SkillManager', () => { await expect(manager.checkUpdates()).resolves.toEqual([]); expect(checkCount).toBe(0); - await expect(manager.checkSkillUpdate({ - name: 'managed-skill', - description: '', - provider: 'global', - path: path.join(dir, 'managed-skill'), - enabled: true, - scope: 'global', - metadata: {}, - source: { type: 'skillssh' }, - })).resolves.toEqual({ hasUpdate: true, latestVersion: 'latest' }); + await expect( + manager.checkSkillUpdate({ + name: 'managed-skill', + provider: 'global', + path: path.join(dir, 'managed-skill'), + source: { type: 'skillssh' }, + }), + ).resolves.toEqual({ hasUpdate: true, latestVersion: 'latest' }); expect(checkCount).toBe(1); }); it('updates only skills.sh-managed Global Skills', async () => { const calls: Array<{ command: string; args: string[] }> = []; - manager.registerSource(new SkillsShSource(async (command, args) => { - calls.push({ command, args }); - return { stdout: '', stderr: '' }; - })); + manager.registerSource( + new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }), + ); const linkedSkill: Skill = { name: 'linked-skill', @@ -198,7 +206,9 @@ describe('SkillManager', () => { metadata: {}, source: { type: 'skillssh' }, }; - await expect(manager.updateSkill(linkedSkill)).rejects.toThrow('Only skills.sh-managed Global Skills can be updated'); + await expect(manager.updateSkill(linkedSkill)).rejects.toThrow( + 'Only skills.sh-managed Global Skills can be updated', + ); expect(calls).toEqual([]); await manager.updateSkill({ @@ -207,10 +217,12 @@ describe('SkillManager', () => { path: path.join(dir, 'managed-skill'), }); - expect(calls).toEqual([{ - command: 'npx', - args: ['skills', 'update', 'linked-skill', '-g', '-y'], - }]); + expect(calls).toEqual([ + { + command: 'npx', + args: ['skills', 'update', 'linked-skill', '-g', '-y'], + }, + ]); }); it('reports provider and project scan paths while scanning custom paths and skipping missing paths', async () => { @@ -218,9 +230,15 @@ describe('SkillManager', () => { 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 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'); + 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])); @@ -229,11 +247,17 @@ describe('SkillManager', () => { 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 }), - ])); + expect(customManager.getScanPathDiagnostics()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ scope: 'provider', provider: 'codex', path: missingProviderPath, exists: false }), + expect.objectContaining({ scope: 'provider', provider: 'codex', path: customProviderPath, exists: true }), + expect.objectContaining({ + scope: 'project', + path: path.join(projectRoot, 'missing-project-skills'), + exists: false, + }), + expect.objectContaining({ scope: 'project', path: path.join(projectRoot, '.custom', 'skills'), exists: true }), + ]), + ); }); }); diff --git a/packages/core/tests/parser.test.ts b/packages/core/tests/parser.test.ts index f60f51f..e06adce 100644 --- a/packages/core/tests/parser.test.ts +++ b/packages/core/tests/parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { parseSkillMd, generateSkillMd } from '../src/parser.js'; describe('parseSkillMd', () => { diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts index 140e2e0..1447e65 100644 --- a/packages/core/tests/providers/claude.test.ts +++ b/packages/core/tests/providers/claude.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { mkdtemp, rm, mkdir, writeFile, readFile, access, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -31,12 +31,19 @@ describe('ClaudeProvider', () => { 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)); + await writeFile( + settingsPath, + JSON.stringify( + { + skillOverrides: { + deploy: 'off', + 'legacy-context': 'name-only', + }, + }, + null, + 2, + ), + ); const skills = await provider.scan(); @@ -47,11 +54,18 @@ describe('ClaudeProvider', () => { 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)); + await writeFile( + settingsPath, + JSON.stringify( + { + skillOverrides: { + deploy: 'off', + }, + }, + null, + 2, + ), + ); const [skill] = await provider.scan(); @@ -91,12 +105,22 @@ describe('ClaudeProvider', () => { }); 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)); + 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(); @@ -137,10 +161,12 @@ describe('ClaudeProvider', () => { expect(skills[0]).toMatchObject({ name: 'missing-skill', provider: 'claude', - scanIssues: [{ - code: 'broken-symlink', - message: expect.any(String), - }], + scanIssues: [ + { + code: 'broken-symlink', + message: expect.any(String), + }, + ], }); }); @@ -159,10 +185,12 @@ describe('ClaudeProvider', () => { type: 'plugin', pluginId: 'deploy-plugin@team-tools', }, - scanIssues: [{ - code: 'invalid-skill-md', - message: expect.any(String), - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: expect.any(String), + }, + ], }); }); }); diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index f700668..dfdb5b1 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { mkdtemp, rm, mkdir, writeFile, readFile, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -34,12 +34,16 @@ describe('CodexProvider', () => { ) { 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 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; } @@ -73,16 +77,19 @@ describe('CodexProvider', () => { it('does not let later TOML tables overwrite Codex skill config state', async () => { const skillDir = path.join(dir, 'my-skill'); await writeSkill(skillDir, 'my-skill', 'Toggle me'); - await writeFile(configPath, [ - '[[skills.config]]', - `path = "${path.join(skillDir, 'SKILL.md')}"`, - 'enabled = false', - '', - '[mcp_servers.example]', - 'command = "example"', - 'enabled = true', - '', - ].join('\n')); + 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(); @@ -99,7 +106,9 @@ describe('CodexProvider', () => { 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`); + expect(await readFile(configPath, 'utf-8')).toContain( + `path = "${path.join(skillDir, 'SKILL.md')}"\nenabled = false`, + ); const skills = await provider.scan(); expect(skills).toHaveLength(1); @@ -116,7 +125,9 @@ describe('CodexProvider', () => { await provider.setEnabled(skill, true); await expect(access(path.join(dir, 'my-skill'))).resolves.toBeUndefined(); - expect(await readFile(configPath, 'utf-8')).toContain(`path = "${path.join(skillDir, 'SKILL.md')}"\nenabled = true`); + expect(await readFile(configPath, 'utf-8')).toContain( + `path = "${path.join(skillDir, 'SKILL.md')}"\nenabled = true`, + ); const skills = await provider.scan(); expect(skills).toHaveLength(1); @@ -155,15 +166,18 @@ describe('CodexProvider', () => { 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')); + 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(); @@ -190,7 +204,9 @@ describe('CodexProvider', () => { await pluginProvider.setEnabled(skill, false); - expect(await readFile(configPath, 'utf-8')).toContain('[plugins."product-design@openai-curated-remote"]\nenabled = 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 () => { @@ -232,10 +248,12 @@ describe('CodexProvider', () => { 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(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(); }); @@ -245,11 +263,15 @@ describe('CodexProvider', () => { const pluginRoot = path.join(pluginCache, 'openai-curated', 'github', '1.2.3'); const skillDir = path.join(pluginRoot, 'skills', 'broken'); await mkdir(path.join(pluginRoot, '.codex-plugin'), { recursive: true }); - await writeFile(path.join(pluginRoot, '.codex-plugin', 'plugin.json'), JSON.stringify({ - name: 'github', - version: '1.2.3', - skills: './skills/', - }), 'utf-8'); + await writeFile( + path.join(pluginRoot, '.codex-plugin', 'plugin.json'), + JSON.stringify({ + name: 'github', + version: '1.2.3', + skills: './skills/', + }), + 'utf-8', + ); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: [\n---\n'); @@ -262,10 +284,12 @@ describe('CodexProvider', () => { type: 'plugin', pluginId: 'github@openai-curated', }, - scanIssues: [{ - code: 'invalid-skill-md', - message: expect.any(String), - }], + scanIssues: [ + { + code: 'invalid-skill-md', + message: expect.any(String), + }, + ], }); }); }); diff --git a/packages/core/tests/sources/skillssh.test.ts b/packages/core/tests/sources/skillssh.test.ts index e3e294e..55d1482 100644 --- a/packages/core/tests/sources/skillssh.test.ts +++ b/packages/core/tests/sources/skillssh.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { SkillsShSource } from '../../src/sources/skillssh.js'; import type { Skill } from '../../src/models/index.js'; @@ -13,10 +13,12 @@ describe('SkillsShSource', () => { 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'], - }]); + expect(calls).toEqual([ + { + command: 'npx', + args: ['skills', 'add', 'owner/repo', '-g', '-y', '--skill', 'skill-name'], + }, + ]); }); it('checks for updates through the skills CLI only when requested', async () => { @@ -78,9 +80,11 @@ describe('SkillsShSource', () => { await source.updateViaCli('demo-skill'); - expect(calls).toEqual([{ - command: 'npx', - args: ['skills', 'update', 'demo-skill', '-g', '-y'], - }]); + expect(calls).toEqual([ + { + command: 'npx', + args: ['skills', 'update', 'demo-skill', '-g', '-y'], + }, + ]); }); }); diff --git a/packages/core/tests/usage-paths.test.ts b/packages/core/tests/usage-paths.test.ts index 30c2585..be37e2c 100644 --- a/packages/core/tests/usage-paths.test.ts +++ b/packages/core/tests/usage-paths.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import path from 'node:path'; import { isSupportedClaudeArtifactPath } from '../src/usage-paths.js'; @@ -6,26 +6,28 @@ describe('Claude usage artifact paths', () => { it('accepts supported shapes only within the configured Windows artifact root', () => { const root = 'C:\\Users\\agent\\.claude\\projects'; - expect(isSupportedClaudeArtifactPath( - root, - 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a.jsonl', - path.win32, - )).toBe(true); - expect(isSupportedClaudeArtifactPath( - root, - 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a\\subagents\\agent-a.jsonl', - path.win32, - )).toBe(true); + expect( + isSupportedClaudeArtifactPath( + root, + 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a.jsonl', + path.win32, + ), + ).toBe(true); + expect( + isSupportedClaudeArtifactPath( + root, + 'C:\\Users\\agent\\.claude\\projects\\encoded-cwd\\session-a\\subagents\\agent-a.jsonl', + path.win32, + ), + ).toBe(true); expect(isSupportedClaudeArtifactPath(root, 'D:\\session-a.jsonl', path.win32)).toBe(false); - expect(isSupportedClaudeArtifactPath( - root, - 'D:\\session-a\\subagents\\agent-a.jsonl', - path.win32, - )).toBe(false); - expect(isSupportedClaudeArtifactPath( - '\\\\server-a\\claude\\projects', - '\\\\server-b\\claude\\session-a.jsonl', - path.win32, - )).toBe(false); + expect(isSupportedClaudeArtifactPath(root, 'D:\\session-a\\subagents\\agent-a.jsonl', path.win32)).toBe(false); + expect( + isSupportedClaudeArtifactPath( + '\\\\server-a\\claude\\projects', + '\\\\server-b\\claude\\session-a.jsonl', + path.win32, + ), + ).toBe(false); }); }); diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts index 47738f6..732f578 100644 --- a/packages/core/tests/usage.test.ts +++ b/packages/core/tests/usage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { appendFile, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -10,43 +10,49 @@ describe('SkillUsageManager', () => { try { const usageRoot = path.join(root, 'usage'); await mkdir(path.join(usageRoot, 'invocations', 'codex'), { recursive: true }); - await writeFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), JSON.stringify({ - schemaVersion: 1, - recordId: 'stale', - provider: 'codex', - sessionId: 'session-a', - turnId: 'turn-a', - skillName: 'frontend-testing', - identityConfidence: 'inferred', - status: 'used', - startedAt: '2026-07-07T08:00:00Z', - }) + '\n', 'utf-8'); + await writeFile( + path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), + JSON.stringify({ + schemaVersion: 1, + recordId: 'stale', + provider: 'codex', + sessionId: 'session-a', + turnId: 'turn-a', + skillName: 'frontend-testing', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-07T08:00:00Z', + }) + '\n', + 'utf-8', + ); const usage = new SkillUsageManager({ dataDir: usageRoot, - providers: [ - { provider: 'codex', displayName: 'Codex', supported: false }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: false }], }); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T00:00:00Z'), - })).resolves.toEqual({ + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T00:00:00Z'), + }), + ).resolves.toEqual({ range: { days: 7, from: '2026-07-02', to: '2026-07-08', }, - providers: [{ - provider: 'codex', - displayName: 'Codex', - coverageState: 'unsupported', - coverageReasons: [], - heatmap: [], - dailySkillUsage: [], - ranking: [], - diagnostics: [], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'unsupported', + coverageReasons: [], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -60,25 +66,31 @@ describe('SkillUsageManager', () => { await mkdir(artifactRoot, { recursive: true }); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [], + }, + ], }); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T12:00:00Z'), - })).resolves.toMatchObject({ - providers: [{ - provider: 'claude', - coverageState: 'not-configured', - coverageReasons: ['missing-attribution-roots'], - diagnostics: [], - }], + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }), + ).resolves.toMatchObject({ + providers: [ + { + provider: 'claude', + coverageState: 'not-configured', + coverageReasons: ['missing-attribution-roots'], + diagnostics: [], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -90,31 +102,33 @@ describe('SkillUsageManager', () => { try { const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - configured: false, - artifactRoots: [], - skillRoots: [], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + configured: false, + artifactRoots: [], + skillRoots: [], + }, + ], }); await expect(usage.importAllProviders()).resolves.toEqual([]); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T12:00:00Z'), - })).resolves.toMatchObject({ - providers: [{ - provider: 'claude', - coverageState: 'not-configured', - coverageReasons: [ - 'missing-artifact-roots', - 'missing-attribution-roots', - 'provider-not-configured', - ], - diagnostics: [], - }], + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }), + ).resolves.toMatchObject({ + providers: [ + { + provider: 'claude', + coverageState: 'not-configured', + coverageReasons: ['missing-artifact-roots', 'missing-attribution-roots', 'provider-not-configured'], + diagnostics: [], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -131,42 +145,75 @@ describe('SkillUsageManager', () => { await mkdir(path.join(sessionsRoot, '2026', '07', '07'), { recursive: true }); await mkdir(archivedRoot, { recursive: true }); await mkdir(path.join(difyRoot, '.agents', 'skills', 'frontend-testing'), { recursive: true }); - await writeFile(path.join(difyRoot, '.agents', 'skills', 'frontend-testing', 'SKILL.md'), '---\nname: ignored-frontmatter-name\n---\n'); - - await writeFile(path.join(sessionsRoot, '2026', '07', '07', 'rollout-session-a.jsonl'), jsonl([ - sessionMeta('session-a', path.join(root, 'other-project'), '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), - functionCall('call-a1', "sed -n '1,220p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), - functionCall('call-a2', "sed -n '221,520p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:02Z'), - functionOutput('call-a2', 0, '2026-07-07T08:01:03Z'), - functionCall('call-a-rg', "rg 'SKILL.md' /Users/twwu/dify", '2026-07-07T08:02:00Z'), - functionCall('call-a-python', "python3 -c 'print(\"/Users/twwu/dify/.agents/skills/not-invoked/SKILL.md\")'", '2026-07-07T08:02:01Z'), - customToolCall('call-a-custom', "sed -n '1,240p' /Users/twwu/dify/.agents/skills/not-invoked/SKILL.md", '2026-07-07T08:02:02Z'), - taskStarted('turn-b', '2026-07-07T09:00:00Z'), - turnContext('turn-b', difyRoot, '2026-07-07T09:00:01Z'), - functionCall('call-b1', "cat /Users/twwu/.agents/skills/vercel-react-best-practices/SKILL.md", '2026-07-07T09:01:00Z'), - functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), - taskStarted('turn-c', '2026-07-07T10:00:00Z'), - turnContext('turn-c', path.join(root, 'other-project'), '2026-07-07T10:00:01Z'), - functionCall('call-c1', "cat /Users/twwu/.agents/skills/other-project-only/SKILL.md", '2026-07-07T10:01:00Z'), - ])); - await writeFile(path.join(archivedRoot, 'rollout-session-b.jsonl'), jsonl([ - sessionMeta('session-b', path.join(root, 'other-project'), '2026-07-07T11:00:00Z'), - taskStarted('turn-d', '2026-07-07T11:00:01Z'), - turnContext('turn-d', difyRoot, '2026-07-07T11:00:02Z'), - functionCall('call-d1', "nl -ba /Users/twwu/.codex/skills/.system/openai-docs/SKILL.md", '2026-07-07T11:01:00Z'), - functionOutput('call-d1', 0, '2026-07-07T11:01:01Z'), - functionCall('call-d2', "head /Users/twwu/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/github/SKILL.md", '2026-07-07T11:02:00Z'), - functionOutput('call-d2', 0, '2026-07-07T11:02:01Z'), - functionCall('call-d3', "tail /Users/twwu/.codex/plugins/cache/openai-bundled/browser/26.623.141536/skills/control-in-app-browser/SKILL.md", '2026-07-07T11:03:00Z'), - functionOutput('call-d3', 0, '2026-07-07T11:03:01Z'), - taskStarted('turn-e', '2026-06-30T11:00:01Z'), - turnContext('turn-e', difyRoot, '2026-06-30T11:00:02Z'), - functionCall('call-e1', "bat /Users/twwu/.agents/skills/too-old/SKILL.md", '2026-06-30T11:01:00Z'), - functionOutput('call-e1', 0, '2026-06-30T11:01:01Z'), - ])); + await writeFile( + path.join(difyRoot, '.agents', 'skills', 'frontend-testing', 'SKILL.md'), + '---\nname: ignored-frontmatter-name\n---\n', + ); + + await writeFile( + path.join(sessionsRoot, '2026', '07', '07', 'rollout-session-a.jsonl'), + jsonl([ + sessionMeta('session-a', path.join(root, 'other-project'), '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall('call-a1', "sed -n '1,220p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + functionCall('call-a2', "sed -n '221,520p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:02Z'), + functionOutput('call-a2', 0, '2026-07-07T08:01:03Z'), + functionCall('call-a-rg', "rg 'SKILL.md' /Users/twwu/dify", '2026-07-07T08:02:00Z'), + functionCall( + 'call-a-python', + 'python3 -c \'print("/Users/twwu/dify/.agents/skills/not-invoked/SKILL.md")\'', + '2026-07-07T08:02:01Z', + ), + customToolCall( + 'call-a-custom', + "sed -n '1,240p' /Users/twwu/dify/.agents/skills/not-invoked/SKILL.md", + '2026-07-07T08:02:02Z', + ), + taskStarted('turn-b', '2026-07-07T09:00:00Z'), + turnContext('turn-b', difyRoot, '2026-07-07T09:00:01Z'), + functionCall( + 'call-b1', + 'cat /Users/twwu/.agents/skills/vercel-react-best-practices/SKILL.md', + '2026-07-07T09:01:00Z', + ), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + taskStarted('turn-c', '2026-07-07T10:00:00Z'), + turnContext('turn-c', path.join(root, 'other-project'), '2026-07-07T10:00:01Z'), + functionCall('call-c1', 'cat /Users/twwu/.agents/skills/other-project-only/SKILL.md', '2026-07-07T10:01:00Z'), + ]), + ); + await writeFile( + path.join(archivedRoot, 'rollout-session-b.jsonl'), + jsonl([ + sessionMeta('session-b', path.join(root, 'other-project'), '2026-07-07T11:00:00Z'), + taskStarted('turn-d', '2026-07-07T11:00:01Z'), + turnContext('turn-d', difyRoot, '2026-07-07T11:00:02Z'), + functionCall( + 'call-d1', + 'nl -ba /Users/twwu/.codex/skills/.system/openai-docs/SKILL.md', + '2026-07-07T11:01:00Z', + ), + functionOutput('call-d1', 0, '2026-07-07T11:01:01Z'), + functionCall( + 'call-d2', + 'head /Users/twwu/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/github/SKILL.md', + '2026-07-07T11:02:00Z', + ), + functionOutput('call-d2', 0, '2026-07-07T11:02:01Z'), + functionCall( + 'call-d3', + 'tail /Users/twwu/.codex/plugins/cache/openai-bundled/browser/26.623.141536/skills/control-in-app-browser/SKILL.md', + '2026-07-07T11:03:00Z', + ), + functionOutput('call-d3', 0, '2026-07-07T11:03:01Z'), + taskStarted('turn-e', '2026-06-30T11:00:01Z'), + turnContext('turn-e', difyRoot, '2026-06-30T11:00:02Z'), + functionCall('call-e1', 'bat /Users/twwu/.agents/skills/too-old/SKILL.md', '2026-06-30T11:01:00Z'), + functionOutput('call-e1', 0, '2026-06-30T11:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: usageRoot, now: new Date('2026-07-08T12:00:00Z'), @@ -211,20 +258,24 @@ describe('SkillUsageManager', () => { rangeDays: 30, now: new Date('2026-07-08T12:00:00Z'), }); - expect(thirtyDayOverview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'too-old', - countedInvocations: 1, - })); + expect(thirtyDayOverview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'too-old', + countedInvocations: 1, + }), + ); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 0 }); const retainedOverview = await usage.getOverview({ rangeDays: 30, now: new Date('2026-07-08T12:00:00Z'), }); - expect(retainedOverview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'too-old', - countedInvocations: 1, - })); + expect(retainedOverview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'too-old', + countedInvocations: 1, + }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -242,46 +293,51 @@ describe('SkillUsageManager', () => { await writeFile(skillPath, '---\nname: diagnosing-bugs\n---\n'); const command = "sed -n '1,240p' .agents/skills/diagnosing-bugs/SKILL.md"; - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - { - timestamp: '2026-07-13T08:01:00Z', - type: 'response_item', - payload: { - type: 'custom_tool_call', - name: 'exec', - call_id: 'call-a', - status: 'completed', - input: `const result = await tools.exec_command(${JSON.stringify({ - cmd: command, - workdir: projectRoot, - })}); text(result.output);`, + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: `const result = await tools.exec_command(${JSON.stringify({ + cmd: command, + workdir: projectRoot, + })}); text(result.output);`, + }, }, - }, - { - timestamp: '2026-07-13T08:01:01Z', - type: 'response_item', - payload: { - type: 'custom_tool_call_output', - call_id: 'call-a', - output: [ - { type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' }, - { type: 'input_text', text: '---\nname: diagnosing-bugs\n---\n' }, - ], + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: [ + { type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' }, + { type: 'input_text', text: '---\nname: diagnosing-bugs\n---\n' }, + ], + }, }, - }, - ])); + ]), + ); const usage = new SkillUsageManager({ dataDir: usageRoot, - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ @@ -293,18 +349,19 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking).toEqual([{ - skillName: 'diagnosing-bugs', - sourcePath: skillPath, - countedInvocations: 1, - failedInvocations: 0, - lastInvokedAt: '2026-07-13T08:01:00Z', - }]); + expect(overview.providers[0].ranking).toEqual([ + { + skillName: 'diagnosing-bugs', + sourcePath: skillPath, + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-13T08:01:00Z', + }, + ]); - const stored = JSON.parse(await readFile( - path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), - 'utf-8', - )) as { status: string; endedAt?: string | null }; + const stored = JSON.parse( + await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'), + ) as { status: string; endedAt?: string | null }; expect(stored).toMatchObject({ status: 'unknown', endedAt: '2026-07-13T08:01:01Z', @@ -325,21 +382,26 @@ describe('SkillUsageManager', () => { 'text(first.output);', 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 2 }); @@ -347,10 +409,7 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking.map((row) => row.skillName).sort()).toEqual([ - 'diagnosing-bugs', - 'tdd', - ]); + expect(overview.providers[0].ranking.map((row) => row.skillName).sort()).toEqual(['diagnosing-bugs', 'tdd']); } finally { await rm(root, { recursive: true, force: true }); } @@ -571,25 +630,10 @@ describe('SkillUsageManager', () => { }); it.each([ - [ - 'Promise.all', - [ - 'const Promise = { all: () => { throw new Error("stop"); } };', - 'Promise.all([]);', - ], - ], - [ - 'Object.assign', - [ - 'const Object = { assign: () => { throw new Error("stop"); } };', - 'Object.assign({}, {});', - ], - ], + ['Promise.all', ['const Promise = { all: () => { throw new Error("stop"); } };', 'Promise.all([]);']], + ['Object.assign', ['const Object = { assign: () => { throw new Error("stop"); } };', 'Object.assign({}, {});']], ])('does not treat shadowed %s as a known builtin', async (_name, prefix) => { - const program = [ - ...prefix, - 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - ].join('\n'); + const program = [...prefix, 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });'].join('\n'); await expect(importOrchestratedProgram(program)).resolves.toMatchObject({ importedRecords: 0, @@ -678,10 +722,12 @@ describe('SkillUsageManager', () => { provider: 'codex', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }, + ], }); }); @@ -696,28 +742,29 @@ describe('SkillUsageManager', () => { provider: 'codex', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }, + ], }); }); it('fails closed when one concrete command contains too many skill paths', async () => { - const paths = Array.from( - { length: 257 }, - (_, index) => `.agents/skills/skill-${index}/SKILL.md`, - ); + const paths = Array.from({ length: 257 }, (_, index) => `.agents/skills/skill-${index}/SKILL.md`); const program = `tools.exec_command({ cmd: ${JSON.stringify(`cat ${paths.join(' ')}`)} });`; await expect(importOrchestratedProgram(program)).resolves.toEqual({ provider: 'codex', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: analysis-budget-exceeded', + }, + ], }); }); @@ -732,10 +779,12 @@ describe('SkillUsageManager', () => { provider: 'codex', importedRecords: 1, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }, + ], }); }); @@ -759,40 +808,45 @@ describe('SkillUsageManager', () => { ].join('\n'); expect(program).not.toContain('SKILL.md'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - { - timestamp: '2026-07-13T08:01:00Z', - type: 'response_item', - payload: { - type: 'custom_tool_call', - name: 'exec', - call_id: 'call-a', - status: 'completed', - input: program, + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: program, + }, }, - }, - { - timestamp: '2026-07-13T08:01:01Z', - type: 'response_item', - payload: { - type: 'custom_tool_call_output', - call_id: 'call-a', - output: [{ type: 'input_text', text: 'Script completed\n' }], + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: [{ type: 'input_text', text: 'Script completed\n' }], + }, }, - }, - ])); + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ @@ -803,11 +857,13 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'tdd', - sourcePath: skillPath, - countedInvocations: 1, - })); + expect(overview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + countedInvocations: 1, + }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -820,11 +876,13 @@ describe('SkillUsageManager', () => { const projectRoot = path.join(root, 'project'); const skillNames = ['diagnosing-bugs', 'tdd']; await mkdir(sessionsRoot, { recursive: true }); - await Promise.all(skillNames.map(async (skillName) => { - const skillPath = path.join(projectRoot, '.agents', 'skills', skillName, 'SKILL.md'); - await mkdir(path.dirname(skillPath), { recursive: true }); - await writeFile(skillPath, `---\nname: ${skillName}\n---\n`); - })); + await Promise.all( + skillNames.map(async (skillName) => { + const skillPath = path.join(projectRoot, '.agents', 'skills', skillName, 'SKILL.md'); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, `---\nname: ${skillName}\n---\n`); + }), + ); const program = [ `const workdir = ${JSON.stringify(projectRoot)};`, @@ -838,40 +896,45 @@ describe('SkillUsageManager', () => { 'results.forEach((result) => text(result.output));', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - { - timestamp: '2026-07-13T08:01:00Z', - type: 'response_item', - payload: { - type: 'custom_tool_call', - name: 'exec', - call_id: 'call-a', - status: 'completed', - input: program, + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + { + timestamp: '2026-07-13T08:01:00Z', + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec', + call_id: 'call-a', + status: 'completed', + input: program, + }, }, - }, - { - timestamp: '2026-07-13T08:01:01Z', - type: 'response_item', - payload: { - type: 'custom_tool_call_output', - call_id: 'call-a', - output: 'Script completed\n', + { + timestamp: '2026-07-13T08:01:01Z', + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-a', + output: 'Script completed\n', + }, }, - }, - ])); + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ @@ -907,21 +970,26 @@ describe('SkillUsageManager', () => { ' await Promise.all(commands.map(run));', '}', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); @@ -929,10 +997,12 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'tdd', - sourcePath: skillPath, - })); + expect(overview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -951,21 +1021,26 @@ describe('SkillUsageManager', () => { 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', 'await Promise.all(commands.map((command, index) => tools.exec_command(command)));', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); @@ -973,10 +1048,12 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'tdd', - sourcePath: skillPath, - })); + expect(overview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'tdd', + sourcePath: skillPath, + }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -992,24 +1069,29 @@ describe('SkillUsageManager', () => { 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', 'commands.map(function* (command) { tools.exec_command(command); });', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], - }); - - await expect(usage.importProvider('codex')).resolves.toEqual({ + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ provider: 'codex', importedRecords: 0, skippedArtifacts: 0, @@ -1041,22 +1123,27 @@ describe('SkillUsageManager', () => { '}', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - orchestratedExecOutput('call-a', '2026-07-13T08:01:01Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + orchestratedExecOutput('call-a', '2026-07-13T08:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ @@ -1067,11 +1154,13 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-13T12:00:00Z'), }); - expect(overview.providers[0].ranking).toContainEqual(expect.objectContaining({ - skillName: 'grilling', - sourcePath: skillPath, - countedInvocations: 1, - })); + expect(overview.providers[0].ranking).toContainEqual( + expect.objectContaining({ + skillName: 'grilling', + sourcePath: skillPath, + countedInvocations: 1, + }), + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -1096,21 +1185,26 @@ describe('SkillUsageManager', () => { ' break;', '}', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); @@ -1130,50 +1224,55 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-unresolved-command-a', - 'tools.exec_command({ cmd: secretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', - '2026-07-13T08:01:00Z', - ), - orchestratedExecCall( - 'call-unresolved-command-b', - 'tools.exec_command({ cmd: anotherSecretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', - '2026-07-13T08:01:01Z', - ), - orchestratedExecCall( - 'call-unresolved-workdir', - 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md", workdir: resolveSecretWorkdir() });', - '2026-07-13T08:01:02Z', - ), - orchestratedExecCall( - 'call-unrelated-dynamic', - 'tools.exec_command({ cmd: ordinaryRuntimeCommand, workdir: ordinaryRuntimeWorkdir });', - '2026-07-13T08:01:03Z', - ), - orchestratedExecCall( - 'call-invalid-a', - 'const brokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', - '2026-07-13T08:01:04Z', - ), - orchestratedExecCall( - 'call-invalid-b', - 'const otherBrokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', - '2026-07-13T08:01:05Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-unresolved-command-a', + 'tools.exec_command({ cmd: secretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', + '2026-07-13T08:01:00Z', + ), + orchestratedExecCall( + 'call-unresolved-command-b', + 'tools.exec_command({ cmd: anotherSecretCommand /* /skills/tdd/SKILL.md */, workdir: "/tmp" });', + '2026-07-13T08:01:01Z', + ), + orchestratedExecCall( + 'call-unresolved-workdir', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md", workdir: resolveSecretWorkdir() });', + '2026-07-13T08:01:02Z', + ), + orchestratedExecCall( + 'call-unrelated-dynamic', + 'tools.exec_command({ cmd: ordinaryRuntimeCommand, workdir: ordinaryRuntimeWorkdir });', + '2026-07-13T08:01:03Z', + ), + orchestratedExecCall( + 'call-invalid-a', + 'const brokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', + '2026-07-13T08:01:04Z', + ), + orchestratedExecCall( + 'call-invalid-b', + 'const otherBrokenSkillSource = "/skills/tdd/SKILL.md"; tools.exec_command({', + '2026-07-13T08:01:05Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); const result = await usage.importProvider('codex'); @@ -1211,33 +1310,40 @@ describe('SkillUsageManager', () => { try { const sessionsRoot = path.join(root, 'codex-sessions'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - orchestratedExecCall( - 'call-a', - 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + orchestratedExecCall( + 'call-a', + 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ provider: 'codex', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -1249,33 +1355,36 @@ describe('SkillUsageManager', () => { try { const sessionsRoot = path.join(root, 'codex-sessions'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - functionCall( - 'call-a', - 'cat .agents/skills/tdd/SKILL.md', - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + functionCall('call-a', 'cat .agents/skills/tdd/SKILL.md', '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ provider: 'codex', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'codex', - message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', - }], + diagnostics: [ + { + provider: 'codex', + message: 'Skipped 1 potential Codex Skill invocation in session.jsonl: unresolved-workdir', + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -1288,28 +1397,33 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const tools = { exec_command: (options) => options };', - 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const tools = { exec_command: (options) => options };', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1329,28 +1443,32 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - '{ var tools = fakeTools; }', - 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + ['{ var tools = fakeTools; }', 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });'].join( + '\n', + ), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1370,28 +1488,33 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const { tools } = { tools: fakeTools };', - 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const { tools } = { tools: fakeTools };', + 'tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1420,21 +1543,26 @@ describe('SkillUsageManager', () => { 'const runtimeCommand = makeCommand(".agents/skills/runtime/SKILL.md");', 'tools.exec_command({ cmd: runtimeCommand });', ].join('\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall('call-a', program, '2026-07-13T08:01:00Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1454,31 +1582,36 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const options = {', - ' cmd: "cat .agents/skills/tdd/SKILL.md",', - ' workdir: resolveRuntimeWorkdir(),', - '};', - 'tools.exec_command(options);', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = {', + ' cmd: "cat .agents/skills/tdd/SKILL.md",', + ' workdir: resolveRuntimeWorkdir(),', + '};', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1498,25 +1631,30 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - 'tools.exec_command({ cmd: "rg SKILL.md .", workdir: resolveRuntimeWorkdir() });', - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'tools.exec_command({ cmd: "rg SKILL.md .", workdir: resolveRuntimeWorkdir() });', + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1536,29 +1674,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', - 'options.cmd = runtimeCommand;', - 'tools.exec_command(options);', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'options.cmd = runtimeCommand;', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1578,29 +1721,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', - 'mutateAtRuntime(options);', - 'tools.exec_command(options);', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'mutateAtRuntime(options);', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1620,25 +1768,30 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - 'missingFunction(tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" }));', - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'missingFunction(tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" }));', + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1658,29 +1811,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', - 'if (runtimeCondition) options.cmd = runtimeCommand;', - 'tools.exec_command(options);', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'if (runtimeCondition) options.cmd = runtimeCommand;', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1700,29 +1858,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', - 'class Mutator { static { options.cmd = runtimeCommand; } }', - 'tools.exec_command(options);', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const options = { cmd: "cat .agents/skills/tdd/SKILL.md" };', + 'class Mutator { static { options.cmd = runtimeCommand; } }', + 'tools.exec_command(options);', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1742,29 +1905,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const cmd = "cat .agents/skills/tdd/SKILL.md";', - '{ cmd = runtimeCommand; }', - 'tools.exec_command({ cmd });', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const cmd = "cat .agents/skills/tdd/SKILL.md";', + '{ cmd = runtimeCommand; }', + 'tools.exec_command({ cmd });', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1784,29 +1952,34 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - [ - 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', - 'commands.splice(0, 1);', - 'await Promise.all(commands.map((command) => tools.exec_command(command)));', - ].join('\n'), - '2026-07-13T08:01:00Z', - ), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', projectRoot, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + [ + 'const commands = [{ cmd: "cat .agents/skills/tdd/SKILL.md" }];', + 'commands.splice(0, 1);', + 'await Promise.all(commands.map((command) => tools.exec_command(command)));', + ].join('\n'), + '2026-07-13T08:01:00Z', + ), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toEqual({ @@ -1828,40 +2001,48 @@ describe('SkillUsageManager', () => { const historicalPartition = path.join(partitionRoot, '2025-12.jsonl'); const requestedPartition = path.join(partitionRoot, '2026-07.jsonl'); await mkdir(partitionRoot, { recursive: true }); - await writeFile(historicalPartition, JSON.stringify({ - schemaVersion: 1, - recordId: 'historical', - provider: 'codex', - sessionId: 'session-old', - turnId: 'turn-old', - skillName: 'historical-skill', - identityConfidence: 'inferred', - status: 'used', - startedAt: '2025-12-15T08:00:00Z', - }) + '\n'); - await writeFile(requestedPartition, JSON.stringify({ - schemaVersion: 1, - recordId: 'requested', - provider: 'codex', - sessionId: 'session-current', - turnId: 'turn-current', - skillName: 'requested-skill', - identityConfidence: 'inferred', - status: 'used', - startedAt: '2026-07-07T08:00:00Z', - }) + '\n'); + await writeFile( + historicalPartition, + JSON.stringify({ + schemaVersion: 1, + recordId: 'historical', + provider: 'codex', + sessionId: 'session-old', + turnId: 'turn-old', + skillName: 'historical-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2025-12-15T08:00:00Z', + }) + '\n', + ); + await writeFile( + requestedPartition, + JSON.stringify({ + schemaVersion: 1, + recordId: 'requested', + provider: 'codex', + sessionId: 'session-current', + turnId: 'turn-current', + skillName: 'requested-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-07T08:00:00Z', + }) + '\n', + ); const oldAccessTime = new Date('2000-01-01T00:00:00.000Z'); const oldModifiedTime = new Date('2020-01-01T00:00:00.000Z'); await utimes(historicalPartition, oldAccessTime, oldModifiedTime); await utimes(requestedPartition, oldAccessTime, oldModifiedTime); const usage = new SkillUsageManager({ dataDir: usageRoot, - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [path.join(root, 'sessions')], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [path.join(root, 'sessions')], + }, + ], }); const overview = await usage.getOverview({ @@ -1869,9 +2050,7 @@ describe('SkillUsageManager', () => { now: new Date('2026-07-08T12:00:00Z'), }); - expect(overview.providers[0].ranking).toMatchObject([ - { skillName: 'requested-skill', countedInvocations: 1 }, - ]); + expect(overview.providers[0].ranking).toMatchObject([{ skillName: 'requested-skill', countedInvocations: 1 }]); expect((await stat(requestedPartition)).atimeMs).toBeGreaterThan(oldAccessTime.getTime()); expect((await stat(historicalPartition)).atimeMs).toBe(oldAccessTime.getTime()); } finally { @@ -1885,36 +2064,44 @@ describe('SkillUsageManager', () => { const usageRoot = path.join(root, 'usage'); const partitionRoot = path.join(usageRoot, 'invocations', 'codex'); await mkdir(partitionRoot, { recursive: true }); - await writeFile(path.join(partitionRoot, '2026-07.jsonl'), JSON.stringify({ - schemaVersion: 1, - recordId: 'july', - provider: 'codex', - sessionId: 'session-july', - turnId: 'turn-july', - skillName: 'july-skill', - identityConfidence: 'inferred', - status: 'used', - startedAt: '2026-07-31T08:00:00Z', - }) + '\n'); - await writeFile(path.join(partitionRoot, '2026-08.jsonl'), JSON.stringify({ - schemaVersion: 1, - recordId: 'august', - provider: 'codex', - sessionId: 'session-august', - turnId: 'turn-august', - skillName: 'august-skill', - identityConfidence: 'inferred', - status: 'used', - startedAt: '2026-08-01T08:00:00Z', - }) + '\n'); + await writeFile( + path.join(partitionRoot, '2026-07.jsonl'), + JSON.stringify({ + schemaVersion: 1, + recordId: 'july', + provider: 'codex', + sessionId: 'session-july', + turnId: 'turn-july', + skillName: 'july-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-31T08:00:00Z', + }) + '\n', + ); + await writeFile( + path.join(partitionRoot, '2026-08.jsonl'), + JSON.stringify({ + schemaVersion: 1, + recordId: 'august', + provider: 'codex', + sessionId: 'session-august', + turnId: 'turn-august', + skillName: 'august-skill', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-08-01T08:00:00Z', + }) + '\n', + ); const usage = new SkillUsageManager({ dataDir: usageRoot, - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [path.join(root, 'sessions')], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [path.join(root, 'sessions')], + }, + ], }); const overview = await usage.getOverview({ @@ -1922,10 +2109,7 @@ describe('SkillUsageManager', () => { now: new Date('2026-08-03T12:00:00Z'), }); - expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual([ - 'august-skill', - 'july-skill', - ]); + expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual(['august-skill', 'july-skill']); } finally { await rm(root, { recursive: true, force: true }); } @@ -1937,23 +2121,28 @@ describe('SkillUsageManager', () => { const sessionsRoot = path.join(root, 'codex-sessions'); const difyRoot = path.join(root, 'dify'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), - functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 1, '2026-07-07T08:01:01Z'), - taskStarted('turn-b', '2026-07-07T09:00:01Z'), - turnContext('turn-b', difyRoot, '2026-07-07T09:00:02Z'), - functionCall('call-b1', "cat /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T09:01:00Z'), - functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall( + 'call-a1', + "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", + '2026-07-07T08:01:00Z', + ), + functionOutput('call-a1', 1, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', difyRoot, '2026-07-07T09:00:02Z'), + functionCall('call-b1', 'cat /Users/twwu/.agents/skills/frontend-testing/SKILL.md', '2026-07-07T09:01:00Z'), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await usage.importProvider('codex'); @@ -1967,11 +2156,11 @@ describe('SkillUsageManager', () => { failedInvocations: 1, }); expect(overview).toMatchObject({ - providers: [{ - ranking: [ - { skillName: 'frontend-testing', countedInvocations: 1, failedInvocations: 1 }, - ], - }], + providers: [ + { + ranking: [{ skillName: 'frontend-testing', countedInvocations: 1, failedInvocations: 1 }], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -1985,41 +2174,52 @@ describe('SkillUsageManager', () => { const usageRoot = path.join(root, 'usage'); const projectRoot = path.join(root, 'project'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', projectRoot, '2026-07-07T08:00:00Z'), - taskStarted('turn-beta-1', '2026-07-07T08:00:01Z'), - turnContext('turn-beta-1', projectRoot, '2026-07-07T08:00:02Z'), - functionCall('call-beta-1', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T08:01:00Z'), - functionOutput('call-beta-1', 0, '2026-07-07T08:01:01Z'), - taskStarted('turn-alpha-success', '2026-07-07T09:00:01Z'), - turnContext('turn-alpha-success', projectRoot, '2026-07-07T09:00:02Z'), - functionCall('call-alpha-success', 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', '2026-07-07T09:01:00Z'), - functionOutput('call-alpha-success', 0, '2026-07-07T09:01:01Z'), - taskStarted('turn-zeta', '2026-07-07T10:00:01Z'), - turnContext('turn-zeta', projectRoot, '2026-07-07T10:00:02Z'), - functionCall('call-zeta', 'cat /Users/twwu/.agents/skills/skill-zeta/SKILL.md', '2026-07-07T10:01:00Z'), - functionOutput('call-zeta', 0, '2026-07-07T10:01:01Z'), - taskStarted('turn-alpha-failed', '2026-07-07T11:00:01Z'), - turnContext('turn-alpha-failed', projectRoot, '2026-07-07T11:00:02Z'), - functionCall('call-alpha-failed', 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', '2026-07-07T11:01:00Z'), - functionOutput('call-alpha-failed', 1, '2026-07-07T11:01:01Z'), - taskStarted('turn-beta-2', '2026-07-07T12:00:01Z'), - turnContext('turn-beta-2', projectRoot, '2026-07-07T12:00:02Z'), - functionCall('call-beta-2', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T12:01:00Z'), - functionOutput('call-beta-2', 0, '2026-07-07T12:01:01Z'), - ])); - const currentSkills = [{ - provider: 'global', - name: 'skill-beta', - path: '/Users/twwu/.agents/skills/skill-beta', - resolvedPath: '/Users/twwu/.agents/skills/skill-beta', - }]; + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-beta-1', '2026-07-07T08:00:01Z'), + turnContext('turn-beta-1', projectRoot, '2026-07-07T08:00:02Z'), + functionCall('call-beta-1', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T08:01:00Z'), + functionOutput('call-beta-1', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-alpha-success', '2026-07-07T09:00:01Z'), + turnContext('turn-alpha-success', projectRoot, '2026-07-07T09:00:02Z'), + functionCall( + 'call-alpha-success', + 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', + '2026-07-07T09:01:00Z', + ), + functionOutput('call-alpha-success', 0, '2026-07-07T09:01:01Z'), + taskStarted('turn-zeta', '2026-07-07T10:00:01Z'), + turnContext('turn-zeta', projectRoot, '2026-07-07T10:00:02Z'), + functionCall('call-zeta', 'cat /Users/twwu/.agents/skills/skill-zeta/SKILL.md', '2026-07-07T10:01:00Z'), + functionOutput('call-zeta', 0, '2026-07-07T10:01:01Z'), + taskStarted('turn-alpha-failed', '2026-07-07T11:00:01Z'), + turnContext('turn-alpha-failed', projectRoot, '2026-07-07T11:00:02Z'), + functionCall( + 'call-alpha-failed', + 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', + '2026-07-07T11:01:00Z', + ), + functionOutput('call-alpha-failed', 1, '2026-07-07T11:01:01Z'), + taskStarted('turn-beta-2', '2026-07-07T12:00:01Z'), + turnContext('turn-beta-2', projectRoot, '2026-07-07T12:00:02Z'), + functionCall('call-beta-2', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T12:01:00Z'), + functionOutput('call-beta-2', 0, '2026-07-07T12:01:01Z'), + ]), + ); + const currentSkills = [ + { + provider: 'global', + name: 'skill-beta', + path: '/Users/twwu/.agents/skills/skill-beta', + resolvedPath: '/Users/twwu/.agents/skills/skill-beta', + }, + ]; const usage = new SkillUsageManager({ dataDir: usageRoot, now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await usage.importProvider('codex', currentSkills); @@ -2030,20 +2230,55 @@ describe('SkillUsageManager', () => { }); const provider = overview.providers[0]; - expect(provider.ranking.map((row) => ({ - skillName: row.skillName, - sourcePath: row.sourcePath, - countedInvocations: row.countedInvocations, - failedInvocations: row.failedInvocations, - }))).toEqual([ - { skillName: 'skill-beta', sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', countedInvocations: 2, failedInvocations: 0 }, - { skillName: 'skill-alpha', sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', countedInvocations: 1, failedInvocations: 1 }, - { skillName: 'skill-zeta', sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', countedInvocations: 1, failedInvocations: 0 }, + expect( + provider.ranking.map((row) => ({ + skillName: row.skillName, + sourcePath: row.sourcePath, + countedInvocations: row.countedInvocations, + failedInvocations: row.failedInvocations, + })), + ).toEqual([ + { + skillName: 'skill-beta', + sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', + countedInvocations: 2, + failedInvocations: 0, + }, + { + skillName: 'skill-alpha', + sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', + countedInvocations: 1, + failedInvocations: 1, + }, + { + skillName: 'skill-zeta', + sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + }, ]); expect(provider.dailySkillUsage.find((day) => day.date === '2026-07-07')?.rows).toEqual([ - { skillName: 'skill-beta', sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-07T12:01:00Z' }, - { skillName: 'skill-alpha', sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-07T11:01:00Z' }, - { skillName: 'skill-zeta', sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:01:00Z' }, + { + skillName: 'skill-beta', + sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T12:01:00Z', + }, + { + skillName: 'skill-alpha', + sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', + countedInvocations: 1, + failedInvocations: 1, + lastInvokedAt: '2026-07-07T11:01:00Z', + }, + { + skillName: 'skill-zeta', + sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T10:01:00Z', + }, ]); } finally { await rm(root, { recursive: true, force: true }); @@ -2060,26 +2295,29 @@ describe('SkillUsageManager', () => { await mkdir(sessionsRoot, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(skillMdPath, '---\nname: frontend-testing\n---\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', root, '2026-07-07T08:00:02Z'), - functionCall('call-a1', `cat ${skillMdPath}`, '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 0, '2026-07-07T08:01:15Z'), - ])); - const currentSkills = [{ - provider: 'global', - name: 'frontend-testing', - path: skillDir, - resolvedPath: skillDir, - source: { type: 'skillssh' as const, repo: 'owner/repo', skillFolderHash: 'abc123' }, - }]; + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-a1', `cat ${skillMdPath}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:15Z'), + ]), + ); + const currentSkills = [ + { + provider: 'global', + name: 'frontend-testing', + path: skillDir, + resolvedPath: skillDir, + source: { type: 'skillssh' as const, repo: 'owner/repo', skillFolderHash: 'abc123' }, + }, + ]; const usage = new SkillUsageManager({ dataDir: usageRoot, now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await expect(usage.importProvider('codex', currentSkills)).resolves.toMatchObject({ importedRecords: 1 }); @@ -2113,23 +2351,24 @@ describe('SkillUsageManager', () => { const skillA = path.join(projectA, '.agents', 'skills', 'shared-name', 'SKILL.md'); const skillB = path.join(projectB, '.agents', 'skills', 'shared-name', 'SKILL.md'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', projectA, '2026-07-07T08:00:02Z'), - functionCall('call-a1', `cat ${skillA}`, '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), - taskStarted('turn-b', '2026-07-07T09:00:01Z'), - turnContext('turn-b', projectB, '2026-07-07T09:00:02Z'), - functionCall('call-b1', `cat ${skillB}`, '2026-07-07T09:01:00Z'), - functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', projectA, '2026-07-07T08:00:02Z'), + functionCall('call-a1', `cat ${skillA}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', projectB, '2026-07-07T09:00:02Z'), + functionCall('call-b1', `cat ${skillB}`, '2026-07-07T09:01:00Z'), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await usage.importProvider('codex'); @@ -2138,11 +2377,13 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-08T12:00:00Z'), }); - expect(overview.providers[0].ranking.map((row) => ({ - skillName: row.skillName, - sourcePath: row.sourcePath, - countedInvocations: row.countedInvocations, - }))).toEqual([ + expect( + overview.providers[0].ranking.map((row) => ({ + skillName: row.skillName, + sourcePath: row.sourcePath, + countedInvocations: row.countedInvocations, + })), + ).toEqual([ { skillName: 'shared-name', sourcePath: skillB, countedInvocations: 1 }, { skillName: 'shared-name', sourcePath: skillA, countedInvocations: 1 }, ]); @@ -2165,33 +2406,43 @@ describe('SkillUsageManager', () => { await mkdir(sharedSkillDir, { recursive: true }); await writeFile(resolvedSkillPath, '---\nname: frontend-testing\n---\n'); await symlink(sharedSkillDir, aliasSkillDir); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', root, '2026-07-07T08:00:02Z'), - functionCall('call-alias', `cat ${aliasSkillPath}`, '2026-07-07T08:01:00Z'), - functionOutput('call-alias', 0, '2026-07-07T08:01:01Z'), - functionCall('call-resolved', `cat ${resolvedSkillPath}`, '2026-07-07T08:01:02Z'), - functionOutput('call-resolved', 0, '2026-07-07T08:01:03Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-alias', `cat ${aliasSkillPath}`, '2026-07-07T08:01:00Z'), + functionOutput('call-alias', 0, '2026-07-07T08:01:01Z'), + functionCall('call-resolved', `cat ${resolvedSkillPath}`, '2026-07-07T08:01:02Z'), + functionOutput('call-resolved', 0, '2026-07-07T08:01:03Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); - const [storedRecord] = (await readFile( - path.join(root, 'usage', 'invocations', 'codex', '2026-07.jsonl'), - 'utf-8', - )).trim().split('\n').map((line) => JSON.parse(line) as { - skillPath?: string; - resolvedPath?: string; - }); + const [storedRecord] = ( + await readFile(path.join(root, 'usage', 'invocations', 'codex', '2026-07.jsonl'), 'utf-8') + ) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + skillPath?: string; + resolvedPath?: string; + }, + ); expect(storedRecord.skillPath).toBeUndefined(); expect(storedRecord.resolvedPath).toBe(await realpath(resolvedSkillPath)); const overview = await usage.getOverview({ @@ -2217,42 +2468,47 @@ describe('SkillUsageManager', () => { const partitionRoot = path.join(usageRoot, 'invocations', 'claude'); const resolvedPath = path.join(root, 'shared-skills', 'frontend-testing', 'SKILL.md'); await mkdir(partitionRoot, { recursive: true }); - await writeFile(path.join(partitionRoot, '2026-07.jsonl'), jsonl([ - { - schemaVersion: 1, - recordId: 'alias-invocation', - provider: 'claude', - sessionId: 'session-a', - turnId: 'message-a', - skillName: 'frontend-testing', - skillPath: path.join(root, 'claude-skills', 'frontend-testing', 'SKILL.md'), - resolvedPath, - identityConfidence: 'confirmed', - status: 'used', - startedAt: '2026-07-07T09:00:00Z', - }, - { - schemaVersion: 1, - recordId: 'resolved-invocation', - provider: 'claude', - sessionId: 'session-b', - turnId: 'message-b', - skillName: 'frontend-testing', - resolvedPath, - identityConfidence: 'confirmed', - status: 'used', - startedAt: '2026-07-07T10:00:00Z', - }, - ])); + await writeFile( + path.join(partitionRoot, '2026-07.jsonl'), + jsonl([ + { + schemaVersion: 1, + recordId: 'alias-invocation', + provider: 'claude', + sessionId: 'session-a', + turnId: 'message-a', + skillName: 'frontend-testing', + skillPath: path.join(root, 'claude-skills', 'frontend-testing', 'SKILL.md'), + resolvedPath, + identityConfidence: 'confirmed', + status: 'used', + startedAt: '2026-07-07T09:00:00Z', + }, + { + schemaVersion: 1, + recordId: 'resolved-invocation', + provider: 'claude', + sessionId: 'session-b', + turnId: 'message-b', + skillName: 'frontend-testing', + resolvedPath, + identityConfidence: 'confirmed', + status: 'used', + startedAt: '2026-07-07T10:00:00Z', + }, + ]), + ); const usage = new SkillUsageManager({ dataDir: usageRoot, - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [path.join(root, 'claude-projects')], - skillRoots: [path.join(root, 'claude-skills')], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [path.join(root, 'claude-projects')], + skillRoots: [path.join(root, 'claude-skills')], + }, + ], }); const overview = await usage.getOverview({ @@ -2260,13 +2516,15 @@ describe('SkillUsageManager', () => { now: new Date('2026-07-08T12:00:00Z'), }); - expect(overview.providers[0].ranking).toEqual([{ - skillName: 'frontend-testing', - sourcePath: resolvedPath, - countedInvocations: 2, - failedInvocations: 0, - lastInvokedAt: '2026-07-07T10:00:00Z', - }]); + expect(overview.providers[0].ranking).toEqual([ + { + skillName: 'frontend-testing', + sourcePath: resolvedPath, + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T10:00:00Z', + }, + ]); } finally { await rm(root, { recursive: true, force: true }); } @@ -2276,30 +2534,53 @@ describe('SkillUsageManager', () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { const sessionsRoot = path.join(root, 'codex-sessions'); - const firstVersion = path.join(root, '.codex', 'plugins', 'cache', 'openai-curated', 'github', 'version-a', 'skills', 'github', 'SKILL.md'); - const secondVersion = path.join(root, '.codex', 'plugins', 'cache', 'openai-curated', 'github', 'version-b', 'skills', 'github', 'SKILL.md'); + const firstVersion = path.join( + root, + '.codex', + 'plugins', + 'cache', + 'openai-curated', + 'github', + 'version-a', + 'skills', + 'github', + 'SKILL.md', + ); + const secondVersion = path.join( + root, + '.codex', + 'plugins', + 'cache', + 'openai-curated', + 'github', + 'version-b', + 'skills', + 'github', + 'SKILL.md', + ); await mkdir(sessionsRoot, { recursive: true }); await mkdir(path.dirname(firstVersion), { recursive: true }); await mkdir(path.dirname(secondVersion), { recursive: true }); await writeFile(firstVersion, '---\nname: github\n---\n'); await writeFile(secondVersion, '---\nname: github\n---\n'); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', root, '2026-07-07T08:00:02Z'), - functionCall('call-a', `cat ${firstVersion}`, '2026-07-07T08:01:00Z'), - functionOutput('call-a', 0, '2026-07-07T08:01:01Z'), - taskStarted('turn-b', '2026-07-07T09:00:01Z'), - turnContext('turn-b', root, '2026-07-07T09:00:02Z'), - functionCall('call-b', `cat ${secondVersion}`, '2026-07-07T09:01:00Z'), - functionOutput('call-b', 0, '2026-07-07T09:01:01Z'), - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-a', `cat ${firstVersion}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', root, '2026-07-07T09:00:02Z'), + functionCall('call-b', `cat ${secondVersion}`, '2026-07-07T09:01:00Z'), + functionOutput('call-b', 0, '2026-07-07T09:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await usage.importProvider('codex'); @@ -2308,13 +2589,15 @@ describe('SkillUsageManager', () => { rangeDays: 7, now: new Date('2026-07-08T12:00:00Z'), }); - expect(overview.providers[0].ranking).toEqual([{ - skillName: 'github:github', - sourcePath: undefined, - countedInvocations: 2, - failedInvocations: 0, - lastInvokedAt: '2026-07-07T09:01:00Z', - }]); + expect(overview.providers[0].ranking).toEqual([ + { + skillName: 'github:github', + sourcePath: undefined, + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T09:01:00Z', + }, + ]); expect(overview.providers[0].dailySkillUsage.find((day) => day.date === '2026-07-07')?.rows).toEqual([ { skillName: 'github:github', @@ -2341,7 +2624,11 @@ describe('SkillUsageManager', () => { events.push( taskStarted(`turn-${index}`, `2026-07-07T08:${String(index).padStart(2, '0')}:00Z`), turnContext(`turn-${index}`, difyRoot, `2026-07-07T08:${String(index).padStart(2, '0')}:01Z`), - functionCall(`call-${index}`, `cat /Users/twwu/.agents/skills/${skillName}/SKILL.md`, `2026-07-07T08:${String(index).padStart(2, '0')}:02Z`), + functionCall( + `call-${index}`, + `cat /Users/twwu/.agents/skills/${skillName}/SKILL.md`, + `2026-07-07T08:${String(index).padStart(2, '0')}:02Z`, + ), functionOutput(`call-${index}`, 0, `2026-07-07T08:${String(index).padStart(2, '0')}:03Z`), ); } @@ -2349,9 +2636,7 @@ describe('SkillUsageManager', () => { const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await usage.importProvider('codex'); @@ -2385,19 +2670,24 @@ describe('SkillUsageManager', () => { const difyRoot = path.join(root, 'dify'); const artifact = path.join(sessionsRoot, 'session.jsonl'); await mkdir(sessionsRoot, { recursive: true }); - await writeFile(artifact, jsonl([ - sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), - functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), - ])); + await writeFile( + artifact, + jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall( + 'call-a1', + "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", + '2026-07-07T08:01:00Z', + ), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + ]), + ); const usage = new SkillUsageManager({ dataDir: usageRoot, now: new Date('2026-07-08T12:00:00Z'), - providers: [ - { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, - ], + providers: [{ provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }], }); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); @@ -2405,18 +2695,24 @@ describe('SkillUsageManager', () => { const metadata = await stat(artifact); await mkdir(path.join(usageRoot, 'cursors'), { recursive: true }); - await writeFile(path.join(usageRoot, 'cursors', 'codex.json'), JSON.stringify({ - provider: 'codex', - importerVersion: 2, - artifacts: { - [artifact]: { size: metadata.size, mtimeMs: metadata.mtimeMs }, - }, - }) + '\n', 'utf-8'); + await writeFile( + path.join(usageRoot, 'cursors', 'codex.json'), + JSON.stringify({ + provider: 'codex', + importerVersion: 2, + artifacts: { + [artifact]: { size: metadata.size, mtimeMs: metadata.mtimeMs }, + }, + }) + '\n', + 'utf-8', + ); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); const stored = await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'); expect(stored.trim().split('\n')).toHaveLength(1); - await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 9'); + await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain( + '"importerVersion": 9', + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -2441,25 +2737,31 @@ describe('SkillUsageManager', () => { await mkdir(path.dirname(claudeSkillPath), { recursive: true }); await writeFile(codexSkillPath, '---\nname: tdd\n---\n'); await writeFile(claudeSkillPath, '---\nname: preserved-claude\n---\n'); - await writeFile(codexArtifact, jsonl([ - sessionMeta('session-a', codexProject, '2026-07-13T08:00:00Z'), - taskStarted('turn-a', '2026-07-13T08:00:01Z'), - turnContext('turn-a', codexProject, '2026-07-13T08:00:02Z'), - orchestratedExecCall( - 'call-a', - 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', - '2026-07-13T08:01:00Z', - ), - ])); - await writeFile(claudeArtifact, jsonl([ - claudeAssistant({ - sessionId: 'session-claude', - messageId: 'message-claude', - toolUseId: 'toolu-claude', - skillName: 'preserved-claude', - timestamp: '2026-07-13T09:00:00Z', - }), - ])); + await writeFile( + codexArtifact, + jsonl([ + sessionMeta('session-a', codexProject, '2026-07-13T08:00:00Z'), + taskStarted('turn-a', '2026-07-13T08:00:01Z'), + turnContext('turn-a', codexProject, '2026-07-13T08:00:02Z'), + orchestratedExecCall( + 'call-a', + 'await tools.exec_command({ cmd: "cat .agents/skills/tdd/SKILL.md" });', + '2026-07-13T08:01:00Z', + ), + ]), + ); + await writeFile( + claudeArtifact, + jsonl([ + claudeAssistant({ + sessionId: 'session-claude', + messageId: 'message-claude', + toolUseId: 'toolu-claude', + skillName: 'preserved-claude', + timestamp: '2026-07-13T09:00:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: usageRoot, @@ -2480,20 +2782,26 @@ describe('SkillUsageManager', () => { const codexMetadata = await stat(codexArtifact); const claudeMetadata = await stat(claudeArtifact); - await writeFile(path.join(usageRoot, 'cursors', 'codex.json'), JSON.stringify({ - provider: 'codex', - importerVersion: 8, - artifacts: { - [codexArtifact]: { size: codexMetadata.size, mtimeMs: codexMetadata.mtimeMs }, - }, - }) + '\n'); - await writeFile(path.join(usageRoot, 'cursors', 'claude.json'), JSON.stringify({ - provider: 'claude', - importerVersion: 8, - artifacts: { - [claudeArtifact]: { size: claudeMetadata.size, mtimeMs: claudeMetadata.mtimeMs }, - }, - }) + '\n'); + await writeFile( + path.join(usageRoot, 'cursors', 'codex.json'), + JSON.stringify({ + provider: 'codex', + importerVersion: 8, + artifacts: { + [codexArtifact]: { size: codexMetadata.size, mtimeMs: codexMetadata.mtimeMs }, + }, + }) + '\n', + ); + await writeFile( + path.join(usageRoot, 'cursors', 'claude.json'), + JSON.stringify({ + provider: 'claude', + importerVersion: 8, + artifacts: { + [claudeArtifact]: { size: claudeMetadata.size, mtimeMs: claudeMetadata.mtimeMs }, + }, + }) + '\n', + ); await rm(claudeArtifact); await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); @@ -2526,26 +2834,31 @@ describe('SkillUsageManager', () => { await mkdir(projectArtifacts, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(skillMdPath, '---\nname: frontend-testing\ndescription: Test frontend behavior\n---\n'); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toEqual({ @@ -2555,20 +2868,26 @@ describe('SkillUsageManager', () => { diagnostics: [], }); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T12:00:00Z'), - })).resolves.toMatchObject({ - providers: [{ - provider: 'claude', - coverageState: 'active', - ranking: [{ - skillName: 'frontend-testing', - sourcePath: skillMdPath, - countedInvocations: 1, - failedInvocations: 0, - }], - }], + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }), + ).resolves.toMatchObject({ + providers: [ + { + provider: 'claude', + coverageState: 'active', + ranking: [ + { + skillName: 'frontend-testing', + sourcePath: skillMdPath, + countedInvocations: 1, + failedInvocations: 0, + }, + ], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -2602,13 +2921,15 @@ describe('SkillUsageManager', () => { await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([assistant])); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 2 }); @@ -2633,41 +2954,49 @@ describe('SkillUsageManager', () => { const skillRoot = path.join(root, 'claude-skills'); await mkdir(projectArtifacts, { recursive: true }); await mkdir(skillRoot, { recursive: true }); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'statusline-setup', - timestamp: '2026-07-07T09:00:00Z', - }), - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-b', - toolUseId: 'toolu-b', - skillName: 'statusline-setup', - timestamp: '2026-07-07T09:01:00Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'statusline-setup', + timestamp: '2026-07-07T09:00:00Z', + }), + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-b', + skillName: 'statusline-setup', + timestamp: '2026-07-07T09:01:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toEqual({ provider: 'claude', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'claude', - message: 'Skipped 2 Claude Skill invocations for "statusline-setup" in session-a.jsonl: no eligible user-level or plugin source', - }], + diagnostics: [ + { + provider: 'claude', + message: + 'Skipped 2 Claude Skill invocations for "statusline-setup" in session-a.jsonl: no eligible user-level or plugin source', + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -2684,51 +3013,64 @@ describe('SkillUsageManager', () => { await mkdir(projectArtifacts, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - claudeToolResult({ - sessionId: 'session-a', - messageId: 'message-b', - toolUseId: 'toolu-a', - isError: true, - timestamp: '2026-07-07T09:00:05Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + claudeToolResult({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-a', + isError: true, + timestamp: '2026-07-07T09:00:05Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await usage.importProvider('claude'); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T12:00:00Z'), - })).resolves.toMatchObject({ - providers: [{ - heatmap: expect.arrayContaining([{ - date: '2026-07-07', - countedInvocations: 0, - failedInvocations: 1, - }]), - ranking: [{ - skillName: 'frontend-testing', - countedInvocations: 0, - failedInvocations: 1, - }], - }], + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }), + ).resolves.toMatchObject({ + providers: [ + { + heatmap: expect.arrayContaining([ + { + date: '2026-07-07', + countedInvocations: 0, + failedInvocations: 1, + }, + ]), + ranking: [ + { + skillName: 'frontend-testing', + countedInvocations: 0, + failedInvocations: 1, + }, + ], + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -2746,43 +3088,53 @@ describe('SkillUsageManager', () => { await mkdir(unsupportedDir, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); - await writeFile(path.join(unsupportedDir, 'trace.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-b', - toolUseId: 'toolu-b', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:01:00Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); + await writeFile( + path.join(unsupportedDir, 'trace.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-b', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:01:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toEqual({ provider: 'claude', importedRecords: 1, skippedArtifacts: 1, - diagnostics: [{ - provider: 'claude', - message: `Skipped unsupported Claude artifact path: ${path.join('-tmp-project', 'logs', 'trace.jsonl')}`, - }], + diagnostics: [ + { + provider: 'claude', + message: `Skipped unsupported Claude artifact path: ${path.join('-tmp-project', 'logs', 'trace.jsonl')}`, + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -2801,33 +3153,39 @@ describe('SkillUsageManager', () => { await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); await writeFile( path.join(projectArtifacts, 'session-a.jsonl'), - `${JSON.stringify(claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }))}\n{not-json\n[also-not-json\n`, + `${JSON.stringify( + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + )}\n{not-json\n[also-not-json\n`, ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toEqual({ provider: 'claude', importedRecords: 1, skippedArtifacts: 0, - diagnostics: [{ - provider: 'claude', - message: 'Skipped 2 malformed JSONL lines in session-a.jsonl', - }], + diagnostics: [ + { + provider: 'claude', + message: 'Skipped 2 malformed JSONL lines in session-a.jsonl', + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -2847,26 +3205,31 @@ describe('SkillUsageManager', () => { await mkdir(secondSubagents, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); - await writeFile(sharedArtifact, jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); + await writeFile( + sharedArtifact, + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); await symlink(sharedArtifact, path.join(firstSubagents, 'agent-shared.jsonl')); await symlink(sharedArtifact, path.join(secondSubagents, 'agent-shared.jsonl')); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toMatchObject({ @@ -2902,24 +3265,29 @@ describe('SkillUsageManager', () => { await symlink(sharedSkillDir, path.join(skillRoot, 'frontend-testing-a')); await symlink(sharedSkillDir, path.join(skillRoot, 'frontend-testing-b')); const expectedResolvedSkillMdPath = await realpath(resolvedSkillMdPath); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); @@ -2950,24 +3318,29 @@ describe('SkillUsageManager', () => { await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); } - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [firstSkillRoot, secondSkillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [firstSkillRoot, secondSkillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); @@ -2996,46 +3369,57 @@ describe('SkillUsageManager', () => { await mkdir(projectArtifacts, { recursive: true }); await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: frontend-testing\n---\n'); - await writeFile(artifact, jsonl([ - claudeAssistant({ - sessionId: 'session-a', - messageId: 'message-a', - toolUseId: 'toolu-a', - skillName: 'frontend-testing', - timestamp: '2026-07-07T09:00:00Z', - }), - ])); + await writeFile( + artifact, + jsonl([ + claudeAssistant({ + sessionId: 'session-a', + messageId: 'message-a', + toolUseId: 'toolu-a', + skillName: 'frontend-testing', + timestamp: '2026-07-07T09:00:00Z', + }), + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); - await appendFile(artifact, jsonl([ - claudeToolResult({ - sessionId: 'session-a', - messageId: 'message-b', - toolUseId: 'toolu-a', - isError: true, - timestamp: '2026-07-07T09:00:05Z', - }), - ])); + await appendFile( + artifact, + jsonl([ + claudeToolResult({ + sessionId: 'session-a', + messageId: 'message-b', + toolUseId: 'toolu-a', + isError: true, + timestamp: '2026-07-07T09:00:05Z', + }), + ]), + ); await expect(usage.importProvider('claude')).resolves.toMatchObject({ importedRecords: 1 }); - const revisions = (await readFile( - path.join(root, 'usage', 'invocations', 'claude', '2026-07.jsonl'), - 'utf-8', - )).trim().split('\n').map((line) => JSON.parse(line) as { - recordId: string; - provider: string; - startedAt: string; - }); + const revisions = (await readFile(path.join(root, 'usage', 'invocations', 'claude', '2026-07.jsonl'), 'utf-8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + recordId: string; + provider: string; + startedAt: string; + }, + ); expect(revisions).toHaveLength(2); expect(new Set(revisions.map((record) => record.recordId))).toHaveLength(1); expect(new Set(revisions.map((record) => record.provider))).toEqual(new Set(['claude'])); @@ -3088,29 +3472,33 @@ describe('SkillUsageManager', () => { skillName: 'frontend-testing', timestamp: 'not-a-timestamp', }); - await writeFile(path.join(projectArtifacts, 'session-a.jsonl'), jsonl([ - untimedAssistant, - invalidTimestampAssistant, - ])); + await writeFile( + path.join(projectArtifacts, 'session-a.jsonl'), + jsonl([untimedAssistant, invalidTimestampAssistant]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: [artifactRoot], - skillRoots: [skillRoot], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: [artifactRoot], + skillRoots: [skillRoot], + }, + ], }); await expect(usage.importProvider('claude')).resolves.toEqual({ provider: 'claude', importedRecords: 0, skippedArtifacts: 0, - diagnostics: [{ - provider: 'claude', - message: 'Skipped 2 Claude Skill invocations in session-a.jsonl: missing or invalid assistant timestamp', - }], + diagnostics: [ + { + provider: 'claude', + message: 'Skipped 2 Claude Skill invocations in session-a.jsonl: missing or invalid assistant timestamp', + }, + ], }); } finally { await rm(root, { recursive: true, force: true }); @@ -3125,22 +3513,32 @@ describe('SkillUsageManager', () => { const difyRoot = path.join(root, 'dify'); await mkdir(sessionsRoot, { recursive: true }); await mkdir(claudeRoot, { recursive: true }); - await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ - sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), - taskStarted('turn-a', '2026-07-07T08:00:01Z'), - turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), - functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), - functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), - ])); - await writeFile(path.join(claudeRoot, 'session.jsonl'), jsonl([ - { - type: 'skill_invocation', - session_id: 'claude-session', - timestamp: '2026-07-07T09:00:00Z', - status: 'used', - skill: { name: 'frontend-testing' }, - }, - ])); + await writeFile( + path.join(sessionsRoot, 'session.jsonl'), + jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall( + 'call-a1', + "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", + '2026-07-07T08:01:00Z', + ), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + ]), + ); + await writeFile( + path.join(claudeRoot, 'session.jsonl'), + jsonl([ + { + type: 'skill_invocation', + session_id: 'claude-session', + timestamp: '2026-07-07T09:00:00Z', + status: 'used', + skill: { name: 'frontend-testing' }, + }, + ]), + ); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), now: new Date('2026-07-08T12:00:00Z'), @@ -3150,16 +3548,20 @@ describe('SkillUsageManager', () => { ], }); - await expect(usage.importAllProviders()).resolves.toEqual([{ - provider: 'codex', - importedRecords: 1, - skippedArtifacts: 0, - diagnostics: [], - }]); - await expect(usage.getOverview({ - rangeDays: 7, - now: new Date('2026-07-08T12:00:00Z'), - })).resolves.toMatchObject({ + await expect(usage.importAllProviders()).resolves.toEqual([ + { + provider: 'codex', + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [], + }, + ]); + await expect( + usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }), + ).resolves.toMatchObject({ providers: [ { provider: 'codex', ranking: [{ skillName: 'frontend-testing', countedInvocations: 1 }] }, { provider: 'claude', coverageState: 'unsupported', ranking: [] }, @@ -3270,12 +3672,14 @@ async function importOrchestratedProgram(program: string, includeCwd = true) { await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl(events)); const usage = new SkillUsageManager({ dataDir: path.join(root, 'usage'), - providers: [{ - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: [sessionsRoot], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot], + }, + ], }); return await usage.importProvider('codex'); } finally { @@ -3310,12 +3714,14 @@ function claudeAssistant(input: { timestamp: input.timestamp, message: { role: 'assistant', - content: [{ - type: 'tool_use', - id: input.toolUseId, - name: 'Skill', - input: { skill: input.skillName }, - }], + content: [ + { + type: 'tool_use', + id: input.toolUseId, + name: 'Skill', + input: { skill: input.skillName }, + }, + ], }, }; } @@ -3334,12 +3740,14 @@ function claudeToolResult(input: { timestamp: input.timestamp, message: { role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: input.toolUseId, - is_error: input.isError, - content: 'Launching skill: frontend-testing', - }], + content: [ + { + type: 'tool_result', + tool_use_id: input.toolUseId, + is_error: input.isError, + content: 'Launching skill: frontend-testing', + }, + ], }, }; } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts new file mode 100644 index 0000000..037cf26 --- /dev/null +++ b/packages/core/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + clean: true, + deps: { + skipNodeModulesBundle: true, + }, + dts: { + sourcemap: false, + }, + entry: ['src/index.ts'], + fixedExtension: false, + format: ['esm'], + minify: false, + outDir: 'dist', + platform: 'node', + sourcemap: false, + target: 'node18', + }, + test: { + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packages/tui/package.json b/packages/tui/package.json index a5f9ce0..abf5eb8 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -2,36 +2,34 @@ "name": "skillpack-tui", "version": "0.3.0", "description": "Unified TUI manager for agent skills across Codex, Claude, and Global", - "type": "module", - "bin": { - "skillpack": "./dist/skillpack.js" - }, - "files": [ - "dist" - ], - "engines": { - "node": ">=18" - }, "keywords": [ - "cli", - "tui", "agent", - "skills", "claude", + "cli", "codex", - "ink" + "ink", + "skills", + "tui" ], "license": "MIT", "repository": { "type": "git", "url": "https://github.com/WTW0313/skillpack.git" }, + "bin": { + "skillpack": "./dist/skillpack.js" + }, + "files": [ + "dist" + ], + "type": "module", "scripts": { - "build": "tsup", - "build:dev": "tsc", - "dev": "tsc --watch", - "test": "vitest run --passWithNoTests", - "prepublishOnly": "pnpm run build" + "build": "vp pack", + "build:ci": "vp pack && node ../../scripts/verify-package.mjs", + "dev": "vp pack --watch", + "test": "vp test", + "test:ci": "vp test", + "prepublishOnly": "pnpm --dir ../.. check:ci && pnpm --dir ../.. test:ci && pnpm --dir ../.. build:ci" }, "dependencies": { "@inkjs/ui": "2.0.0", @@ -45,8 +43,9 @@ "@types/node": "25.9.3", "@types/react": "19.2.17", "ink-testing-library": "^4.0.0", - "tsup": "^8.5.1", - "typescript": "6.0.3", - "vitest": "4.1.8" + "typescript": "6.0.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 91a78a4..e270392 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -20,13 +20,20 @@ function Router() { const { view } = useAppContext(); switch (view) { - case 'list': return ; - case 'detail': return ; - case 'install': return ; - case 'project': return ; - case 'settings': return ; - case 'updates': return ; - case 'usage': return ; + case 'list': + return ; + case 'detail': + return ; + case 'install': + return ; + case 'project': + return ; + case 'settings': + return ; + case 'updates': + return ; + case 'usage': + return ; } } @@ -72,7 +79,12 @@ export interface AppSurfaceProps { terminalSize?: TerminalSize; } -function AppSurfaceContent({ manager, config, onUsageImportConsentChange, error }: Omit) { +function AppSurfaceContent({ + manager, + config, + onUsageImportConsentChange, + error, +}: Omit) { const { columns, rows } = useTerminalSize(); const terminalMode = getTerminalMode({ columns, rows }); @@ -115,13 +127,13 @@ export function AppSurface({ manager, config, onUsageImportConsentChange, error, /> ); - return terminalSize - ? {content} - : content; + return terminalSize ? {content} : content; } export function App() { const { manager, config, error, setUsageImportConsent } = useSkillManager(); - return ; + return ( + + ); } diff --git a/packages/tui/src/cli.tsx b/packages/tui/src/cli.tsx index 3ddd12a..1a643d6 100644 --- a/packages/tui/src/cli.tsx +++ b/packages/tui/src/cli.tsx @@ -34,7 +34,11 @@ export async function runSkillpackCli(options: SkillpackCliOptions = {}): Promis const stdin = options.stdin ?? processLike.stdin; const stderr = options.stderr ?? processLike.stderr; const renderApp = options.renderApp ?? render; - const exit = options.exit ?? ((code: number) => { processLike.exit(code); }); + const exit = + options.exit ?? + ((code: number) => { + processLike.exit(code); + }); const app = options.app ?? React.createElement(App); let instance: Pick | null = null; let exitCode = 0; diff --git a/packages/tui/src/components/confirm-dialog.tsx b/packages/tui/src/components/confirm-dialog.tsx index f2a33b8..03de84b 100644 --- a/packages/tui/src/components/confirm-dialog.tsx +++ b/packages/tui/src/components/confirm-dialog.tsx @@ -18,14 +18,20 @@ export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogPro return ( - {glyphs.warning} + + {glyphs.warning}{' '} + {message} - Press - y + {' '}Press + + y + to confirm or - n + + n + to cancel diff --git a/packages/tui/src/components/help-overlay.tsx b/packages/tui/src/components/help-overlay.tsx index 50fbf76..de242df 100644 --- a/packages/tui/src/components/help-overlay.tsx +++ b/packages/tui/src/components/help-overlay.tsx @@ -20,8 +20,10 @@ export function HelpOverlay(_props: HelpOverlayProps) { return ( - ‹ esc/? - {formatViewTitle(view)} Help + ‹ esc/?{' '} + + {formatViewTitle(view)} Help + diff --git a/packages/tui/src/components/search-input.tsx b/packages/tui/src/components/search-input.tsx index a1e3f11..386275a 100644 --- a/packages/tui/src/components/search-input.tsx +++ b/packages/tui/src/components/search-input.tsx @@ -15,13 +15,10 @@ export function SearchInput({ defaultValue, onChange, onSubmit, onCancel }: Sear return ( - / - + + /{' '} + + ); } diff --git a/packages/tui/src/components/skill-row.tsx b/packages/tui/src/components/skill-row.tsx index 518acc5..e40fef4 100644 --- a/packages/tui/src/components/skill-row.tsx +++ b/packages/tui/src/components/skill-row.tsx @@ -1,11 +1,6 @@ import { Box, Text } from 'ink'; import type { InventoryListRow } from '../hooks/use-skills.js'; -import { - fitCell, - formatInventoryStatus, - getGlyphSet, - type InventoryLayout, -} from '../lib/responsive-layout.js'; +import { fitCell, formatInventoryStatus, getGlyphSet, type InventoryLayout } from '../lib/responsive-layout.js'; export const COL_NAME_WIDTH = 30; export const COL_AGENT_WIDTH = 10; @@ -23,23 +18,15 @@ export function SkillRow({ }: SkillRowProps) { const glyphs = getGlyphSet(); const stateLabel = formatInventoryStatus(skill.instance.enabled, columns.state <= 3 ? 'compact' : 'full'); - const relatedLabel = skill.relatedProviders.length > 0 - ? skill.relatedProviders.map((provider) => provider.provider).join(' ') - : '-'; - const issueLabel = skill.issues.length > 0 - ? skill.issues[0].code.replace(/-/g, ' ') - : '-'; + const relatedLabel = + skill.relatedProviders.length > 0 ? skill.relatedProviders.map((provider) => provider.provider).join(' ') : '-'; + const issueLabel = skill.issues.length > 0 ? skill.issues[0].code.replace(/-/g, ' ') : '-'; const hasIssues = skill.issues.length > 0; return ( - - {isSelected ? glyphs.selected : ' '} - - + {isSelected ? glyphs.selected : ' '} + {fitCell(skill.instance.name, columns.name)} {fitCell(skill.instance.provider, columns.provider)} diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index d6c1adb..709727f 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -20,8 +20,10 @@ export function StatusBar({ shortcuts: shortcutsOverride }: StatusBarProps = {}) {'─'.repeat(2)} {visibleShortcuts.map((s, i) => ( - {i > 0 && } - {s.key} + {i > 0 && {' '}} + + {s.key} + {s.displayLabel} ))} diff --git a/packages/tui/src/components/tab-bar.tsx b/packages/tui/src/components/tab-bar.tsx index 85e5a51..1a99c3d 100644 --- a/packages/tui/src/components/tab-bar.tsx +++ b/packages/tui/src/components/tab-bar.tsx @@ -16,17 +16,13 @@ export function TabBar({ tabs, activeTab, counts }: TabBarProps) { return ( {i > 0 && } - + {tab} {count !== undefined && ( - {' '}{count} + {' '} + {count} )} diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 01eff70..7794c5c 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -74,18 +74,21 @@ export function AppProvider({ manager, config, onUsageImportConsentChange, child const [usageImportError, setUsageImportError] = useState(null); const [usageImportRevision, setUsageImportRevision] = useState(0); - const setUsageImportConsent = useCallback(async (importConsent: boolean) => { - const nextConfig = onUsageImportConsentChange - ? await onUsageImportConsentChange(importConsent) - : { - ...currentConfig, - usage: { - ...currentConfig.usage, - importConsent, - }, - }; - setCurrentConfig(nextConfig); - }, [currentConfig, onUsageImportConsentChange]); + const setUsageImportConsent = useCallback( + async (importConsent: boolean) => { + const nextConfig = onUsageImportConsentChange + ? await onUsageImportConsentChange(importConsent) + : { + ...currentConfig, + usage: { + ...currentConfig.usage, + importConsent, + }, + }; + setCurrentConfig(nextConfig); + }, + [currentConfig, onUsageImportConsentChange], + ); const importSkillUsage = useCallback(async () => { setUsageImporting(true); @@ -125,24 +128,47 @@ export function AppProvider({ manager, config, onUsageImportConsentChange, child }); setSelectedSkill((prev) => { if (!prev) return null; - const nextGroup = newInventory.find((group) => group.instances.some((instance) => ( - instance.provider === prev.provider && instance.path === prev.path - ))); - const nextInstance = nextGroup?.instances.find((instance) => ( - instance.provider === prev.provider && instance.path === prev.path - )); + const nextGroup = newInventory.find((group) => + group.instances.some((instance) => instance.provider === prev.provider && instance.path === prev.path), + ); + const nextInstance = nextGroup?.instances.find( + (instance) => instance.provider === prev.provider && instance.path === prev.path, + ); return nextInstance ?? nextGroup?.instances[0] ?? null; }); setLoading(false); }, [manager, currentConfig]); return ( - + {children} ); diff --git a/packages/tui/src/hooks/use-search.ts b/packages/tui/src/hooks/use-search.ts index 0c019e1..653bd75 100644 --- a/packages/tui/src/hooks/use-search.ts +++ b/packages/tui/src/hooks/use-search.ts @@ -4,10 +4,7 @@ import Fuse from 'fuse.js'; const DEFAULT_SEARCH_KEYS = ['name', 'description']; export function useSearch(items: T[], query: string, keys = DEFAULT_SEARCH_KEYS): T[] { - const fuse = useMemo( - () => new Fuse(items, { keys, threshold: 0.4 }), - [items, keys], - ); + const fuse = useMemo(() => new Fuse(items, { keys, threshold: 0.4 }), [items, keys]); return useMemo(() => { if (!query) return items; diff --git a/packages/tui/src/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index 7b86d6e..cec98c4 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -1,7 +1,10 @@ import { useState, useEffect } from 'react'; import { - SkillManager, ConfigManager, - CodexProvider, ClaudeProvider, GlobalProvider, + SkillManager, + ConfigManager, + CodexProvider, + ClaudeProvider, + GlobalProvider, SkillsShSource, type SkillpackConfig, } from '@skillpack/core'; @@ -23,64 +26,67 @@ export function useSkillManager(): SkillManagerResult { let cancelled = false; async function init() { - try { - const configManager = new ConfigManager(); - const cfg = await configManager.load(); - const mgr = new SkillManager({ - usageProviders: [ - { - provider: 'codex', - displayName: 'Codex', - supported: true, - artifactRoots: cfg.usage.artifactRoots.codex ?? [], - }, - { - provider: 'claude', - displayName: 'Claude', - supported: true, - artifactRoots: cfg.usage.artifactRoots.claude ?? [], - skillRoots: cfg.providers.claude?.paths ?? [], - }, - ], - }); - - const providerFactories: Record InstanceType> = { - codex: () => new CodexProvider(cfg.providers.codex?.paths), - claude: () => { - const allPaths = cfg.providers.claude?.paths ?? []; - const cachePaths = allPaths.filter((p) => p.includes('plugins') || p.includes('cache')); - const flatPaths = allPaths.filter((p) => !p.includes('plugins') && !p.includes('cache')); - return new ClaudeProvider( - cachePaths.length > 0 ? cachePaths : undefined, - flatPaths.length > 0 ? flatPaths : undefined, - ); + const configManager = new ConfigManager(); + const cfg = await configManager.load(); + const mgr = new SkillManager({ + usageProviders: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: cfg.usage.artifactRoots.codex ?? [], + }, + { + provider: 'claude', + displayName: 'Claude', + supported: true, + artifactRoots: cfg.usage.artifactRoots.claude ?? [], + skillRoots: cfg.providers.claude?.paths ?? [], }, - global: () => new GlobalProvider(cfg.providers.global?.paths), - }; + ], + }); + + const providerFactories: Record< + string, + () => InstanceType + > = { + codex: () => new CodexProvider(cfg.providers.codex?.paths), + claude: () => { + const allPaths = cfg.providers.claude?.paths ?? []; + const cachePaths = allPaths.filter((p) => p.includes('plugins') || p.includes('cache')); + const flatPaths = allPaths.filter((p) => !p.includes('plugins') && !p.includes('cache')); + return new ClaudeProvider( + cachePaths.length > 0 ? cachePaths : undefined, + flatPaths.length > 0 ? flatPaths : undefined, + ); + }, + global: () => new GlobalProvider(cfg.providers.global?.paths), + }; - for (const [id, factory] of Object.entries(providerFactories)) { - if (cfg.providers[id]?.enabled) { - mgr.registerProvider(factory()); - } + for (const [id, factory] of Object.entries(providerFactories)) { + if (cfg.providers[id]?.enabled) { + mgr.registerProvider(factory()); } + } - if (cfg.sources.skillssh?.enabled) mgr.registerSource(new SkillsShSource()); + if (cfg.sources.skillssh?.enabled) mgr.registerSource(new SkillsShSource()); - await mgr.init(); - await mgr.scanAll(process.cwd(), cfg.projectSkillsDirs); + await mgr.init(); + await mgr.scanAll(process.cwd(), cfg.projectSkillsDirs); - if (!cancelled) { - setManager(mgr); - setConfig(cfg); - setConfigManager(configManager); - } - } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + if (!cancelled) { + setManager(mgr); + setConfig(cfg); + setConfigManager(configManager); } } - init(); - return () => { cancelled = true; }; + init().catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; }, []); async function setUsageImportConsent(importConsent: boolean): Promise { diff --git a/packages/tui/src/hooks/use-skills.ts b/packages/tui/src/hooks/use-skills.ts index f270fe9..25aa35a 100644 --- a/packages/tui/src/hooks/use-skills.ts +++ b/packages/tui/src/hooks/use-skills.ts @@ -55,9 +55,10 @@ function toRows(inventory: SkillGroup[], provider: string | null): InventoryList id: rowId(group, instance), group, instance, - relatedProviders: group.identity.confidence === 'confirmed' - ? group.providers.filter((related) => related.provider !== instance.provider) - : [], + relatedProviders: + group.identity.confidence === 'confirmed' + ? group.providers.filter((related) => related.provider !== instance.provider) + : [], issues: instance.issues, notices: instance.notices, })); diff --git a/packages/tui/src/hooks/use-terminal-size.ts b/packages/tui/src/hooks/use-terminal-size.ts index 28090e1..0e70c2d 100644 --- a/packages/tui/src/hooks/use-terminal-size.ts +++ b/packages/tui/src/hooks/use-terminal-size.ts @@ -22,7 +22,9 @@ export function useTerminalSize() { setSize({ columns: stdout.columns, rows: stdout.rows }); }; stdout.on('resize', onResize); - return () => { stdout.off('resize', onResize); }; + return () => { + stdout.off('resize', onResize); + }; }, [stdout]); return override ?? size; diff --git a/packages/tui/src/lib/format-path.ts b/packages/tui/src/lib/format-path.ts index af28a31..c98e01c 100644 --- a/packages/tui/src/lib/format-path.ts +++ b/packages/tui/src/lib/format-path.ts @@ -3,8 +3,6 @@ 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; + const homePath = homeDir && value.startsWith(`${homeDir}${path.sep}`) ? value.replace(homeDir, '~') : value; return relativePath && !relativePath.startsWith('..') ? relativePath : homePath; } diff --git a/packages/tui/src/lib/plugin-toggle.ts b/packages/tui/src/lib/plugin-toggle.ts index f804d75..e236340 100644 --- a/packages/tui/src/lib/plugin-toggle.ts +++ b/packages/tui/src/lib/plugin-toggle.ts @@ -11,11 +11,12 @@ export function isPluginOwnedSkill(skills: T[], skill: PluginToggleSkill): T[] { if (!isPluginOwnedSkill(skill)) return []; return skills - .filter((candidate) => ( - candidate.provider === skill.provider - && candidate.origin?.type === 'plugin' - && candidate.origin.pluginId === skill.origin.pluginId - )) + .filter( + (candidate) => + candidate.provider === skill.provider && + candidate.origin?.type === 'plugin' && + candidate.origin.pluginId === skill.origin.pluginId, + ) .sort((left, right) => left.name.localeCompare(right.name)); } diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index 8fffd52..17d27a8 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -233,9 +233,7 @@ export function getVisibleShortcuts( const result: Array = []; for (const shortcut of shortcuts) { - const displayLabel = mode === 'compact' - ? (shortcut.compactLabel ?? shortcut.label) - : shortcut.label; + const displayLabel = mode === 'compact' ? (shortcut.compactLabel ?? shortcut.label) : shortcut.label; const next = [...result, { ...shortcut, displayLabel }]; if (measureShortcutLine(next) > maxWidth) break; result.push({ ...shortcut, displayLabel }); @@ -280,9 +278,8 @@ export function getProjectSkillsColumns(size: TerminalSize): ProjectSkillsColumn const state = mode === 'compact' ? 14 : PROJECT_STATE_WIDTH; const fixedWidth = 5 + state; const name = Math.max(12, Math.min(24, Math.floor((contentWidth - fixedWidth) * 0.3))); - const description = mode === 'compact' - ? 0 - : Math.max(12, Math.min(24, Math.floor((contentWidth - fixedWidth - name) * 0.45))); + const description = + mode === 'compact' ? 0 : Math.max(12, Math.min(24, Math.floor((contentWidth - fixedWidth - name) * 0.45))); const path = Math.max(12, contentWidth - fixedWidth - name - description); return { name, diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 906fcaa..c0af512 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -65,9 +65,7 @@ const DETAIL_BASE: Shortcut[] = [ { key: '←→', label: 'provider instance', compactLabel: 'provider' }, ]; -const DETAIL_TAIL: Shortcut[] = [ - { key: 'o', label: 'open folder', compactLabel: 'open' }, -]; +const DETAIL_TAIL: Shortcut[] = [{ key: 'o', label: 'open folder', compactLabel: 'open' }]; interface ShortcutContext { view: ViewType; @@ -83,13 +81,13 @@ export function getShortcutsForView({ view, selectedSkill, canToggle }: Shortcut const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const detailBase = canToggle ? [ - ...DETAIL_BASE, - { - key: 'space', - label: selectedSkill?.origin?.type === 'plugin' ? 'toggle plugin' : 'toggle', - compactLabel: 'toggle', - }, - ] + ...DETAIL_BASE, + { + key: 'space', + label: selectedSkill?.origin?.type === 'plugin' ? 'toggle plugin' : 'toggle', + compactLabel: 'toggle', + }, + ] : DETAIL_BASE; const tail = isRemovable ? [...DETAIL_TAIL, { key: 'd', label: 'delete' }] : DETAIL_TAIL; diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index ce1c96c..d2a1127 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -47,11 +47,12 @@ export function DetailView() { const instanceIssues = selectedSkill?.issues ?? []; const instanceNotices = selectedSkill?.notices ?? []; const hasFindings = relationshipNotices.length > 0 || instanceIssues.length > 0 || instanceNotices.length > 0; - const selectedInstanceIndex = selectedGroup && selectedSkill - ? selectedGroup.instances.findIndex((instance) => ( - instance.provider === selectedSkill.provider && instance.path === selectedSkill.path - )) - : -1; + const selectedInstanceIndex = + selectedGroup && selectedSkill + ? selectedGroup.instances.findIndex( + (instance) => instance.provider === selectedSkill.provider && instance.path === selectedSkill.path, + ) + : -1; const addedAt = selectedSkill?.source?.installedAt ?? selectedSkill?.source?.createdAt; @@ -60,10 +61,7 @@ export function DetailView() { return selectedSkill.description.split('\n'); }, [selectedSkill]); const descriptionWidth = Math.max(1, columns - 4); - const wrappedDescLines = useMemo( - () => wrapTextLines(descLines, descriptionWidth), - [descLines, descriptionWidth], - ); + const wrappedDescLines = useMemo(() => wrapTextLines(descLines, descriptionWidth), [descLines, descriptionWidth]); const detailLayout = getDetailLayout({ size: { columns, rows }, @@ -74,9 +72,9 @@ export function DetailView() { const fullVisibleDescRows = useMemo(() => { if (!selectedSkill) return 0; let used = 2; // padding (top + bottom) - used += 1; // title - used += 1; // gap before metadata - used += 3; // agent, path, status + used += 1; // title + used += 1; // gap before metadata + used += 3; // agent, path, status if (selectedSkill.version) used += 1; if (selectedSkill.source) used += 1; if (selectedSkill.origin?.type === 'plugin') used += 2; @@ -84,94 +82,116 @@ export function DetailView() { if (isUpdatable) used += 1; // update row if (addedAt) used += 1; if (hasFindings) used += 1 + 2 + relationshipNotices.length + instanceIssues.length + instanceNotices.length; // gap + headings + findings - used += 1; // gap before description - used += 1; // separator - used += 1; // "description" label - used += 1; // status bar + used += 1; // gap before description + used += 1; // separator + used += 1; // "description" label + used += 1; // status bar if (error) used += 1; return Math.max(0, rows - used); - }, [selectedSkill, relationshipNotices.length, instanceIssues.length, instanceNotices.length, hasFindings, error, rows, isUpdatable, disableStrategy]); + }, [ + selectedSkill, + relationshipNotices.length, + instanceIssues.length, + instanceNotices.length, + hasFindings, + error, + rows, + isUpdatable, + disableStrategy, + ]); - const visibleDescRows = detailLayout.sectioned - ? Math.max(1, detailLayout.visibleRows - 1) - : fullVisibleDescRows; + const visibleDescRows = detailLayout.sectioned ? Math.max(1, detailLayout.visibleRows - 1) : fullVisibleDescRows; useEffect(() => { setDescScroll((s) => Math.min(s, Math.max(0, wrappedDescLines.length - visibleDescRows))); }, [wrappedDescLines.length, visibleDescRows]); - useInput((input, key) => { - if (key.escape) { setView('list'); return; } - if ((key.leftArrow || key.rightArrow) && selectedGroup && selectedSkill && selectedGroup.instances.length > 1) { - const currentIndex = selectedInstanceIndex >= 0 ? selectedInstanceIndex : 0; - const direction = key.rightArrow ? 1 : -1; - const nextIndex = (currentIndex + direction + selectedGroup.instances.length) % selectedGroup.instances.length; - setSelectedSkill(selectedGroup.instances[nextIndex]); - setUpdateInfo(null); - setError(null); - setNotice(null); - return; - } - if (key.tab && detailLayout.sectioned) { - const idx = detailLayout.sections.findIndex((section) => section.id === activeSection); - const next = key.shift - ? (idx - 1 + detailLayout.sections.length) % detailLayout.sections.length - : (idx + 1) % detailLayout.sections.length; - setActiveSection(detailLayout.sections[next].id); - return; - } - if ((input === 'o' || input === 'O') && selectedSkill) { - const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; - try { - execSync(`${opener} "${selectedSkill.path}"`, { stdio: 'ignore' }); - } catch { /* opener failed */ } - return; - } - if (input === ' ' && selectedSkill && canToggle && !busy) { - if (isPluginOwnedSkill(selectedSkill)) { - setConfirming('plugin-toggle'); + useInput( + (input, key) => { + if (key.escape) { + setView('list'); + return; + } + if ((key.leftArrow || key.rightArrow) && selectedGroup && selectedSkill && selectedGroup.instances.length > 1) { + const currentIndex = selectedInstanceIndex >= 0 ? selectedInstanceIndex : 0; + const direction = key.rightArrow ? 1 : -1; + const nextIndex = (currentIndex + direction + selectedGroup.instances.length) % selectedGroup.instances.length; + setSelectedSkill(selectedGroup.instances[nextIndex]); + setUpdateInfo(null); + setError(null); + setNotice(null); + return; + } + if (key.tab && detailLayout.sectioned) { + const idx = detailLayout.sections.findIndex((section) => section.id === activeSection); + const next = key.shift + ? (idx - 1 + detailLayout.sections.length) % detailLayout.sections.length + : (idx + 1) % detailLayout.sections.length; + setActiveSection(detailLayout.sections[next].id); + return; + } + if ((input === 'o' || input === 'O') && selectedSkill) { + const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; + try { + execSync(`${opener} "${selectedSkill.path}"`, { stdio: 'ignore' }); + } catch { + /* opener failed */ + } return; } - setError(null); - setNotice(null); - setBusy(true); - manager.toggleInventoryInstance(selectedSkill) - .then(() => refresh()) - .then(() => setNotice('Availability updated.')) - .catch((err: Error) => setError(err.message)) - .finally(() => setBusy(false)); - return; - } - if (input === 'd' && selectedSkill && isRemovable) { - setError(null); - setConfirming('remove'); - } - if (input === 'u' && selectedSkill && isUpdatable && !busy && !checkingUpdate && !updating) { - setError(null); - setNotice(null); - if (updateInfo?.hasUpdate) { - setConfirming('update'); - } else if (!updateInfo) { - setCheckingUpdate(true); - manager.checkSkillUpdate(selectedSkill) - .then(async (info) => { - setUpdateInfo(info ?? { hasUpdate: false }); - await refresh(); - }) + if (input === ' ' && selectedSkill && canToggle && !busy) { + if (isPluginOwnedSkill(selectedSkill)) { + setConfirming('plugin-toggle'); + return; + } + setError(null); + setNotice(null); + setBusy(true); + manager + .toggleInventoryInstance(selectedSkill) + .then(() => refresh()) + .then(() => setNotice('Availability updated.')) .catch((err: Error) => setError(err.message)) - .finally(() => setCheckingUpdate(false)); + .finally(() => setBusy(false)); + return; + } + if (input === 'd' && selectedSkill && isRemovable) { + setError(null); + setConfirming('remove'); + } + if (input === 'u' && selectedSkill && isUpdatable && !busy && !checkingUpdate && !updating) { + setError(null); + setNotice(null); + if (updateInfo?.hasUpdate) { + setConfirming('update'); + } else if (!updateInfo) { + setCheckingUpdate(true); + manager + .checkSkillUpdate(selectedSkill) + .then(async (info) => { + setUpdateInfo(info ?? { hasUpdate: false }); + await refresh(); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setCheckingUpdate(false)); + } } - } - if (key.downArrow && (!detailLayout.sectioned || activeSection === 'description')) { - setDescScroll((s) => Math.min(s + 1, Math.max(0, wrappedDescLines.length - visibleDescRows))); - } - if (key.upArrow && (!detailLayout.sectioned || activeSection === 'description')) { - setDescScroll((s) => Math.max(0, s - 1)); - } - }, { isActive: !confirming }); + if (key.downArrow && (!detailLayout.sectioned || activeSection === 'description')) { + setDescScroll((s) => Math.min(s + 1, Math.max(0, wrappedDescLines.length - visibleDescRows))); + } + if (key.upArrow && (!detailLayout.sectioned || activeSection === 'description')) { + setDescScroll((s) => Math.max(0, s - 1)); + } + }, + { isActive: !confirming }, + ); if (!selectedGroup || !selectedSkill) { - return No skill selected; + return ( + + No skill selected + + ); } if (confirming === 'remove') { @@ -203,7 +223,8 @@ export function DetailView() { onConfirm={() => { setError(null); setBusy(true); - manager.toggleInventoryInstance(selectedSkill) + manager + .toggleInventoryInstance(selectedSkill) .then(() => refresh()) .then(() => setNotice('Plugin availability updated.')) .catch((err: Error) => setError(err.message)) @@ -227,7 +248,8 @@ export function DetailView() { setError(null); setNotice(null); setUpdating(true); - manager.updateSkill(selectedSkill) + manager + .updateSkill(selectedSkill) .then(() => refresh()) .then(() => { setUpdateInfo(null); @@ -302,7 +324,9 @@ export function DetailView() { {'agent'.padEnd(10)} {selectedSkill.provider} {selectedGroup.instances.length > 1 && ( - {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + + {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + )} @@ -326,7 +350,10 @@ export function DetailView() { provider instances {selectedGroup.instances.map((instance) => ( - + {instance.provider} {instance.enabled ? 'enabled' : 'disabled'} ))} @@ -352,7 +379,10 @@ export function DetailView() { {selectedSkill.source && ( {'source'.padEnd(10)} - {selectedSkill.source.type}{selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} + + {selectedSkill.source.type} + {selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} + )} {selectedSkill.origin?.type === 'plugin' && ( @@ -381,7 +411,10 @@ export function DetailView() { ) : ( {descScrollable && ( - {descScroll + 1}-{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of {wrappedDescLines.length} + + {descScroll + 1}-{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of{' '} + {wrappedDescLines.length} + )} {visibleDesc.map((line, i) => ( {line} @@ -411,8 +444,10 @@ export function DetailView() { return ( - ‹ esc - {glyphs.brand} + ‹ esc{' '} + + {glyphs.brand} + {selectedSkill.name} @@ -433,7 +468,11 @@ export function DetailView() { {detailLayout.sections.map((section, index) => ( {index > 0 && } - + {section.label.toLowerCase()} @@ -464,8 +503,10 @@ export function DetailView() { {/* Navigation + title */} - ‹ esc - {glyphs.brand} + ‹ esc{' '} + + {glyphs.brand} + {selectedSkill.name} @@ -478,14 +519,18 @@ export function DetailView() { {'providers'.padEnd(10)} - {selectedGroup.instances.map((instance) => ( - `${instance.provider}:${instance.enabled ? 'enabled' : 'disabled'}` - )).join(' ')} + + {selectedGroup.instances + .map((instance) => `${instance.provider}:${instance.enabled ? 'enabled' : 'disabled'}`) + .join(' ')} + {selectedGroup.instances.length > 1 && ( {'selected'.padEnd(10)} - {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + + {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + use left/right to switch provider instance )} @@ -495,7 +540,10 @@ export function DetailView() { {'path'.padEnd(10)} - {selectedSkill.path}{selectedSkill.resolvedPath ? ` → ${selectedSkill.resolvedPath}` : ''} + + {selectedSkill.path} + {selectedSkill.resolvedPath ? ` → ${selectedSkill.resolvedPath}` : ''} + {selectedSkill.version && ( @@ -506,7 +554,10 @@ export function DetailView() { {selectedSkill.source && ( {'source'.padEnd(10)} - {selectedSkill.source.type}{selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} + + {selectedSkill.source.type} + {selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} + {selectedSkill.source.type === 'skillssh' && selectedSkill.source.skillFolderHash && ( #{selectedSkill.source.skillFolderHash.slice(0, 7)} )} @@ -521,7 +572,10 @@ export function DetailView() { {'plugin on'.padEnd(10)} - + {selectedSkill.origin.pluginEnabled ? '● enabled' : '○ disabled'} {selectedSkill.provider === 'codex' && ( @@ -552,15 +606,15 @@ export function DetailView() { <> {updateInfo.currentVersion ?? '?'} - {updateInfo.latestVersion ?? '?'} - press + + {updateInfo.latestVersion ?? '?'} + + {' '}press u to update )} - {!checkingUpdate && !updating && updateInfo && !updateInfo.hasUpdate && ( - up to date ✓ - )} + {!checkingUpdate && !updating && updateInfo && !updateInfo.hasUpdate && up to date ✓} {!checkingUpdate && !updating && !updateInfo && ( <> press @@ -602,7 +656,10 @@ export function DetailView() { description {descScroll > 0 && } - {descScroll + 1}–{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of {wrappedDescLines.length} + + {descScroll + 1}–{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of{' '} + {wrappedDescLines.length} + {descScroll + visibleDescRows < wrappedDescLines.length && } )} diff --git a/packages/tui/src/views/install-view.tsx b/packages/tui/src/views/install-view.tsx index aa51d8e..1006eb3 100644 --- a/packages/tui/src/views/install-view.tsx +++ b/packages/tui/src/views/install-view.tsx @@ -42,8 +42,14 @@ export function InstallView() { useInput((_input, key) => { if (key.escape) { - if (step === 'query') { setView('list'); return; } - if (step === 'results') { setStep('query'); return; } + if (step === 'query') { + setView('list'); + return; + } + if (step === 'results') { + setStep('query'); + return; + } return; } @@ -53,7 +59,10 @@ export function InstallView() { if (key.downArrow) setCursor((c) => Math.min(c + 1, Math.max(0, results.length - 1))); if (key.upArrow) setCursor((c) => Math.max(c - 1, 0)); if (key.return && results[cursor]) { - doInstall(results[cursor].identifier); + doInstall(results[cursor].identifier).catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + setStep('results'); + }); } } }); @@ -79,14 +88,9 @@ export function InstallView() { const doInstall = async (identifier: string) => { setStep('installing'); - try { - await manager.installFromSource('skillssh', identifier, 'global'); - await refresh(); - setView('list'); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStep('results'); - } + await manager.installFromSource('skillssh', identifier, 'global'); + await refresh(); + setView('list'); }; const stepLabels = ['query', 'results']; @@ -96,8 +100,10 @@ export function InstallView() { {/* Header */} - ‹ esc - Install Skill + ‹ esc{' '} + + Install Skill + {/* Progress breadcrumb */} @@ -126,14 +132,15 @@ export function InstallView() { Search skills.sh {searching ? ( - + + + ) : ( - {glyphs.selected} - + + {glyphs.selected}{' '} + + )} @@ -141,7 +148,9 @@ export function InstallView() { {step === 'results' && ( - {results.length} result{results.length !== 1 ? 's' : ''} + + {results.length} result{results.length !== 1 ? 's' : ''} + {results.length === 0 ? ( Nothing found. Press esc to try again. @@ -153,17 +162,28 @@ export function InstallView() { {absoluteIndex === cursor ? glyphs.selected : ' '} - + {fitCell(r.name, resultNameWidth)} - {r.description && {r.description}} + {r.description && ( + + {' '} + {r.description} + + )} ); }) )} {results.length > resultRows && ( - {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, results.length)} of {results.length} + + {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, results.length)} of {results.length} + )} )} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 8090e74..79bd8e7 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -13,8 +13,16 @@ import { fitCell, getGlyphSet, getInventoryLayout } from '../lib/responsive-layo export function ListView() { const { exit } = useApp(); const { - loading, activeTab, setActiveTab, setView, setSelectedGroup, setSelectedSkill, - searchQuery, setSearchQuery, refresh, inventory, + loading, + activeTab, + setActiveTab, + setView, + setSelectedGroup, + setSelectedSkill, + searchQuery, + setSearchQuery, + refresh, + inventory, } = useAppContext(); const { skills, tabs } = useFilteredSkills(); const { columns, rows } = useTerminalSize(); @@ -33,8 +41,13 @@ export function ListView() { }); const visibleRows = layout.visibleRows; - useEffect(() => { refresh(); }, [refresh]); - useEffect(() => { setCursor(0); setScrollOffset(0); }, [activeTab]); + useEffect(() => { + refresh().catch(() => undefined); + }, [refresh]); + useEffect(() => { + setCursor(0); + setScrollOffset(0); + }, [activeTab]); useEffect(() => { if (skills.length !== prevSkillsLenRef.current) { @@ -64,7 +77,9 @@ export function ListView() { counts[tab] = inventory.reduce((count, group) => count + group.instances.length, 0); } else { const providerMap: Record = { - Codex: 'codex', Claude: 'claude', Global: 'global', + Codex: 'codex', + Claude: 'claude', + Global: 'global', }; counts[tab] = inventory.reduce( (count, group) => count + group.instances.filter((instance) => instance.provider === providerMap[tab]).length, @@ -75,61 +90,94 @@ export function ListView() { return counts; }, [inventory]); - useInput((input, key) => { - if (input === 'q') { exit(); return; } - if (key.escape && searchQuery) { - setSearchQuery(''); - return; - } - if (key.downArrow) { - setCursor((c) => Math.min(c + 1, Math.max(0, skills.length - 1))); - return; - } - if (key.upArrow) { - setCursor((c) => Math.max(c - 1, 0)); - return; - } - if (input === '/') { setSearching(true); return; } - if (input === 'p') { setView('project'); return; } - if (input === 's') { setView('settings'); return; } - if (input === 'i') { setView('install'); return; } - if (input === 'u') { setView('updates'); return; } - if (input === 'g') { setView('usage'); return; } - if (key.return && skills[cursor]) { - setSelectedGroup(skills[cursor].group); - setSelectedSkill(skills[cursor].instance); - setView('detail'); - return; - } - if (key.tab) { - const idx = tabs.indexOf(activeTab); - const next = key.shift - ? (idx - 1 + tabs.length) % tabs.length - : (idx + 1) % tabs.length; - setActiveTab(tabs[next]); - } - }, { isActive: !searching }); + useInput( + (input, key) => { + if (input === 'q') { + exit(); + return; + } + if (key.escape && searchQuery) { + setSearchQuery(''); + return; + } + if (key.downArrow) { + setCursor((c) => Math.min(c + 1, Math.max(0, skills.length - 1))); + return; + } + if (key.upArrow) { + setCursor((c) => Math.max(c - 1, 0)); + return; + } + if (input === '/') { + setSearching(true); + return; + } + if (input === 'p') { + setView('project'); + return; + } + if (input === 's') { + setView('settings'); + return; + } + if (input === 'i') { + setView('install'); + return; + } + if (input === 'u') { + setView('updates'); + return; + } + if (input === 'g') { + setView('usage'); + return; + } + if (key.return && skills[cursor]) { + setSelectedGroup(skills[cursor].group); + setSelectedSkill(skills[cursor].instance); + setView('detail'); + return; + } + if (key.tab) { + const idx = tabs.indexOf(activeTab); + const next = key.shift ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length; + setActiveTab(tabs[next]); + } + }, + { isActive: !searching }, + ); if (loading) { - return ; + return ( + + + + ); } const showScroll = skills.length > visibleRows; - const scrollBarHeight = showScroll - ? Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))) - : 0; - const scrollBarOffset = skills.length <= visibleRows - ? 0 - : Math.round(scrollOffset / (skills.length - visibleRows) * (visibleRows - scrollBarHeight)); + const scrollBarHeight = showScroll ? Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))) : 0; + const scrollBarOffset = + skills.length <= visibleRows + ? 0 + : Math.round((scrollOffset / (skills.length - visibleRows)) * (visibleRows - scrollBarHeight)); return ( {/* Header */} - {glyphs.brand} Skillpack - {skills.length} instance{skills.length !== 1 ? 's' : ''} + + {glyphs.brand} Skillpack + + + {' '} + {skills.length} instance{skills.length !== 1 ? 's' : ''} + {showScroll && ( - {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, skills.length)} of {skills.length} + + {' '} + {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, skills.length)} of {skills.length} + )} @@ -145,7 +193,10 @@ export function ListView() { defaultValue={searchQuery} onChange={setSearchQuery} onSubmit={() => setSearching(false)} - onCancel={() => { setSearching(false); setSearchQuery(''); }} + onCancel={() => { + setSearching(false); + setSearchQuery(''); + }} /> )} @@ -154,15 +205,17 @@ export function ListView() { {!searching && searchQuery && ( filtered by - "{searchQuery}" - esc to clear + + "{searchQuery}" + + {' '}esc to clear )} {/* Column headers */} - {' '} + {fitCell('NAME', layout.columns.name)} {fitCell('PROVIDER', layout.columns.provider)} {fitCell('STATUS', layout.columns.state)} @@ -178,7 +231,7 @@ export function ListView() { {searchQuery ? ( <> - No matches for + {' '}No matches for "{searchQuery}" . Press esc @@ -186,7 +239,7 @@ export function ListView() { ) : ( <> - No skills found. Press + {' '}No skills found. Press i to install or p diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index d9d6bb9..e42e744 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -23,8 +23,14 @@ export function ProjectSkillsView() { const visibleRows = layout.visibleRows; useInput((input, key) => { - if (input === 'q') { exit(); return; } - if (key.escape) { setView('list'); return; } + if (input === 'q') { + exit(); + return; + } + if (key.escape) { + setView('list'); + return; + } if (key.downArrow) { setCursor((c) => Math.min(c + 1, Math.max(0, projectSkills.length - 1))); setScrollOffset((offset) => { @@ -54,17 +60,25 @@ export function ProjectSkillsView() { return ( - ‹ esc - Project Skills - {projectSkills.length} skill{projectSkills.length !== 1 ? 's' : ''} + ‹ esc{' '} + + Project Skills + + + {' '} + {projectSkills.length} skill{projectSkills.length !== 1 ? 's' : ''} + {showScroll && ( - {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, projectSkills.length)} of {projectSkills.length} + + {' '} + {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, projectSkills.length)} of {projectSkills.length} + )} - {' '} + {fitCell('NAME', tableColumns.name)} {tableColumns.description > 0 && {fitCell('DESCRIPTION', tableColumns.description)}} {fitCell('PROJECT PATH', tableColumns.path)} @@ -75,7 +89,7 @@ export function ProjectSkillsView() { {projectSkills.length === 0 ? ( - No Project Skills found for this repository. + {' '}No Project Skills found for this repository. ) : ( visibleSkills.map((skill, index) => { @@ -85,9 +99,7 @@ export function ProjectSkillsView() { const state = hasIssues ? 'needs attention' : overlapsInventory ? 'overlaps' : 'read-only'; return ( - - {selected ? glyphs.selected : ' '} - + {selected ? glyphs.selected : ' '} {fitCell(skill.name, tableColumns.name)} diff --git a/packages/tui/src/views/settings-view.tsx b/packages/tui/src/views/settings-view.tsx index 579d6f0..51f6055 100644 --- a/packages/tui/src/views/settings-view.tsx +++ b/packages/tui/src/views/settings-view.tsx @@ -29,8 +29,13 @@ export function SettingsView() { const tableColumns = getSettingsColumns({ columns, rows }); useInput((input, key) => { - if (input === 'q') { exit(); return; } - if (key.escape) { setView('list'); } + 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))); } @@ -39,20 +44,32 @@ export function SettingsView() { } }); - const providerRows = useMemo(() => Object.entries(config.providers).map(([id, provider]) => ({ - id, - enabled: provider.enabled, - rootCount: provider.paths.length, - })), [config.providers]); + 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 sourceRows = useMemo( + () => + Object.entries(config.sources).map(([id, source]) => ({ + id, + enabled: source.enabled, + })), + [config.sources], + ); - const usageRootRows = useMemo(() => Object.entries(config.usage.artifactRoots).flatMap(([provider, roots]) => ( - roots.map((root) => ({ provider, root })) - )), [config.usage.artifactRoots]); + const usageRootRows = useMemo( + () => + Object.entries(config.usage.artifactRoots).flatMap(([provider, roots]) => + roots.map((root) => ({ provider, root })), + ), + [config.usage.artifactRoots], + ); const contentRows: SettingsRow[] = useMemo(() => { const result: SettingsRow[] = [ @@ -71,7 +88,7 @@ export function SettingsView() { ]; if (scanPaths.length === 0) { - result.push({ key: 'scan-empty', element: No Scan Roots reported yet. }); + result.push({ key: 'scan-empty', element: {' '}No Scan Roots reported yet. }); } else { for (const scanPath of scanPaths) { const scope = scanPath.provider ?? scanPath.scope; @@ -94,7 +111,10 @@ export function SettingsView() { result.push({ key: 'usage-roots-gap', element: }); result.push({ key: 'usage-roots-heading', element: Usage Artifact Roots }); if (usageRootRows.length === 0) { - result.push({ key: 'usage-roots-empty', element: No Usage Artifact Roots configured. }); + result.push({ + key: 'usage-roots-empty', + element: {' '}No Usage Artifact Roots configured., + }); } else { for (const row of usageRootRows) { result.push({ @@ -120,7 +140,9 @@ export function SettingsView() { {fitCell(provider.id, tableColumns.scope)} {formatEnabled(provider.enabled)} - {provider.rootCount} root{provider.rootCount === 1 ? '' : 's'} + + {provider.rootCount} root{provider.rootCount === 1 ? '' : 's'} + ), }); @@ -146,12 +168,15 @@ export function SettingsView() { const visibleRows = layout.visibleRows; const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); const showScroll = contentRows.length > visibleRows; - const settingsShortcuts: Shortcut[] = useMemo(() => [ - { key: 'esc', label: 'back' }, - { key: '?', label: 'help' }, - ...(showScroll ? [{ key: '↑↓', label: 'scroll' }] : []), - { key: 'q', label: 'quit' }, - ], [showScroll]); + const settingsShortcuts: Shortcut[] = useMemo( + () => [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + ...(showScroll ? [{ key: '↑↓', label: 'scroll' }] : []), + { key: 'q', label: 'quit' }, + ], + [showScroll], + ); useEffect(() => { setScrollOffset((offset) => Math.min(offset, Math.max(0, contentRows.length - visibleRows))); @@ -160,11 +185,16 @@ export function SettingsView() { return ( - ‹ esc - Settings - read-only + ‹ esc{' '} + + Settings + + {' '}read-only {showScroll && ( - {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + + {' '} + {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + )} diff --git a/packages/tui/src/views/updates-view.tsx b/packages/tui/src/views/updates-view.tsx index 507268f..37decc2 100644 --- a/packages/tui/src/views/updates-view.tsx +++ b/packages/tui/src/views/updates-view.tsx @@ -79,8 +79,14 @@ export function UpdatesView() { }; useInput((input, key) => { - if (input === 'q') { exit(); return; } - if (key.escape) { setView('list'); return; } + if (input === 'q') { + exit(); + return; + } + if (key.escape) { + setView('list'); + return; + } if (confirmingUpdate) return; if (state === 'checking' || state === 'updating') return; if (input === 'r') { @@ -122,8 +128,10 @@ export function UpdatesView() { return ( - ‹ esc - Updates + ‹ esc{' '} + + Updates + {error !== '' && ( @@ -154,7 +162,9 @@ export function UpdatesView() { {state === 'checked' && ( - {updates.length} update{updates.length !== 1 ? 's' : ''} available + + {updates.length} update{updates.length !== 1 ? 's' : ''} available + {updates.length === 0 ? ( No skills.sh updates found. Press r to check again. @@ -165,7 +175,9 @@ export function UpdatesView() { return ( {selected ? glyphs.selected : ' '} - {fitCell(row.skill.name, resultNameWidth)} + + {fitCell(row.skill.name, resultNameWidth)} + {row.update.currentVersion ?? '?'} {row.update.latestVersion ?? 'latest'} @@ -175,7 +187,9 @@ export function UpdatesView() { )} {updates.length > resultRows && ( - {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, updates.length)} of {updates.length} + + {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, updates.length)} of {updates.length} + )} )} diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx index 64531d7..492e89d 100644 --- a/packages/tui/src/views/usage-view.tsx +++ b/packages/tui/src/views/usage-view.tsx @@ -69,7 +69,8 @@ export function UsageView() { } let cancelled = false; - manager.getSkillUsageOverview({ rangeDays }) + manager + .getSkillUsageOverview({ rangeDays }) .then((nextOverview) => { if (!cancelled) setOverview(nextOverview); }) @@ -77,7 +78,9 @@ export function UsageView() { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [hasConsent, manager, rangeDays, refreshNonce, usageImportRevision]); useEffect(() => { @@ -133,8 +136,14 @@ export function UsageView() { useInput((input, key) => { if (confirmingReset) return; - if (input === 'q') { exit(); return; } - if (key.escape) { setView('list'); return; } + if (input === 'q') { + exit(); + return; + } + if (key.escape) { + setView('list'); + return; + } if (key.tab && key.shift && hasConsent && overview && overview.providers.length > 1) { setSelectedProviderIndex((index) => (index + overview.providers.length - 1) % overview.providers.length); return; @@ -193,7 +202,9 @@ export function UsageView() { } if (input === 'r' && hasConsent) { setOverview(null); - importSkillUsage().catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); + importSkillUsage().catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); } if (input === 'x' && hasConsent) { setConfirmingReset(true); @@ -202,8 +213,12 @@ export function UsageView() { setSaving(true); setError(null); setUsageImportConsent(true) - .catch((err) => { setError(err instanceof Error ? err.message : String(err)); }) - .finally(() => { setSaving(false); }); + .catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + setSaving(false); + }); } }); @@ -212,7 +227,8 @@ export function UsageView() { { - manager.resetSkillUsage() + manager + .resetSkillUsage() .then(() => { setConfirmingReset(false); setOverview(null); @@ -223,7 +239,9 @@ export function UsageView() { setConfirmingReset(false); }); }} - onCancel={() => { setConfirmingReset(false); }} + onCancel={() => { + setConfirmingReset(false); + }} /> ); } @@ -231,17 +249,28 @@ export function UsageView() { return ( - Skill Usage - {rangeDays} days + + Skill Usage + + + {' '} + {rangeDays} days + {showScroll && ( - {boundedScrollOffset + 1}-{Math.min(boundedScrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + + {' '} + {boundedScrollOffset + 1}-{Math.min(boundedScrollOffset + visibleRows, contentRows.length)} of{' '} + {contentRows.length} + )} {hasConsent ? ( <> - {visibleContent.map((row) => {row.element})} + {visibleContent.map((row) => ( + {row.element} + ))} ) : ( <> @@ -263,10 +292,14 @@ export function UsageView() { function coverageBadge(state: UsageCoverageState): { label: string; color: string } { switch (state) { - case 'active': return { label: '[active]', color: 'green' }; - case 'zero': return { label: '[zero]', color: 'yellow' }; - case 'not-configured': return { label: '[not configured]', color: 'yellow' }; - case 'unsupported': return { label: '[unsupported]', color: 'red' }; + case 'active': + return { label: '[active]', color: 'green' }; + case 'zero': + return { label: '[zero]', color: 'yellow' }; + case 'not-configured': + return { label: '[not configured]', color: 'yellow' }; + case 'unsupported': + return { label: '[unsupported]', color: 'red' }; } } @@ -288,8 +321,10 @@ function buildUsageContentRows(input: { key: 'providers', element: ( - Providers - {input.overview.providers.map((provider, index) => renderProviderTab(provider, index === input.selectedProviderIndex))} + Providers{' '} + {input.overview.providers.map((provider, index) => + renderProviderTab(provider, index === input.selectedProviderIndex), + )} ), }); @@ -297,7 +332,7 @@ function buildUsageContentRows(input: { key: 'range', element: ( - Range + Range{' '} {USAGE_RANGES.map((range) => ( {range === input.rangeDays ? `[${range}d] ` : `${range}d `} ))} @@ -328,7 +363,12 @@ function buildUsageContentRows(input: { if (sideBySide) { const heatmapWidth = heatmapDisplayWidth(provider.heatmap); const detailColumns = Math.max(30, input.columns - heatmapWidth - 6); - const detailRows = renderSelectedDayDetail(provider, input.selectedHeatmapCell, input.selectedDayRows, detailColumns); + const detailRows = renderSelectedDayDetail( + provider, + input.selectedHeatmapCell, + input.selectedDayRows, + detailColumns, + ); rows.push(...combineSideBySideRows(heatmapRows, detailRows, heatmapWidth, detailColumns)); } else { rows.push(...heatmapRows); @@ -349,7 +389,10 @@ function buildUsageContentRows(input: { } function renderProviderTab(provider: ProviderSkillUsageOverview, selected: boolean): ReactNode { - const suffix = provider.coverageState === 'active' ? '' : ` ${coverageBadge(provider.coverageState).label.replace(/^\[|\]$/g, '')}`; + const suffix = + provider.coverageState === 'active' + ? '' + : ` ${coverageBadge(provider.coverageState).label.replace(/^\[|\]$/g, '')}`; const label = `${provider.displayName}${suffix}`; return ( @@ -441,7 +484,9 @@ function combineSideBySideRows( element: ( {left} - {right} + + {right} + ), }); @@ -487,8 +532,22 @@ function renderSelectedDayDetail( const failed = cell?.failedInvocations ?? 0; const result: UsageContentRow[] = [ { key: 'detail:heading', element: Selected day }, - { key: 'detail:summary', element: {date} · {provider.displayName} }, - { key: 'detail:counts', element: Counted {counted} · Failed {failed} }, + { + key: 'detail:summary', + element: ( + + {date} · {provider.displayName} + + ), + }, + { + key: 'detail:counts', + element: ( + + Counted {counted} · Failed {failed} + + ), + }, ]; if (dayRows.length === 0) { @@ -501,7 +560,8 @@ function renderSelectedDayDetail( key: 'detail:header', element: ( - {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Source Path', widths.sourcePath)} + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)}{' '} + {fitCell('Source Path', widths.sourcePath)} ), }); @@ -510,7 +570,9 @@ function renderSelectedDayDetail( key: `detail:${row.skillName}`, element: ( - {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)}{' '} + {fitCell(formatFailed(row.failedInvocations), widths.failed)}{' '} + {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} ), }); @@ -519,9 +581,7 @@ function renderSelectedDayDetail( } function renderRankingTable(provider: ProviderSkillUsageOverview, columns: number): UsageContentRow[] { - const result: UsageContentRow[] = [ - { key: 'ranking:heading', element: Provider Skill Ranking }, - ]; + const result: UsageContentRow[] = [{ key: 'ranking:heading', element: Provider Skill Ranking }]; if (provider.ranking.length === 0) { result.push({ key: 'ranking:empty', element: No usage records for this provider and range. }); return result; @@ -532,7 +592,8 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe key: 'ranking:header', element: ( - {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Last Used', widths.lastUsed)} {fitCell('Source Path', widths.sourcePath)} + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)}{' '} + {fitCell('Last Used', widths.lastUsed)} {fitCell('Source Path', widths.sourcePath)} ), }); @@ -541,7 +602,10 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe key: `ranking:${row.skillName}`, element: ( - {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatLastUsed(row.lastInvokedAt), widths.lastUsed)} {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)}{' '} + {fitCell(formatFailed(row.failedInvocations), widths.failed)}{' '} + {fitCell(formatLastUsed(row.lastInvokedAt), widths.lastUsed)}{' '} + {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} ), }); @@ -549,7 +613,10 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe return result; } -function usageTableWidths(columns: number, withLastUsed: boolean): { skill: number; counted: number; failed: number; lastUsed: number; sourcePath: number } { +function usageTableWidths( + columns: number, + withLastUsed: boolean, +): { skill: number; counted: number; failed: number; lastUsed: number; sourcePath: number } { const contentWidth = Math.max(40, columns - 2); const counted = 7; const failed = 6; @@ -562,7 +629,8 @@ function usageTableWidths(columns: number, withLastUsed: boolean): { skill: numb } function appendErrors(rows: UsageContentRow[], input: { usageImportError: string | null; error: string | null }) { - if (input.usageImportError) rows.push({ key: 'error:import', element: {input.usageImportError} }); + if (input.usageImportError) + rows.push({ key: 'error:import', element: {input.usageImportError} }); if (input.error) rows.push({ key: 'error:view', element: {input.error} }); } @@ -603,13 +671,16 @@ function moveDateWithinRange( function nearestDateInTargetWeek(target: Date, selectableDates: string[]): string | null { const targetWeekStart = startOfUtcWeek(target); - const candidates = selectableDates.filter((date) => startOfUtcWeek(parseDate(date)).getTime() === targetWeekStart.getTime()); + const candidates = selectableDates.filter( + (date) => startOfUtcWeek(parseDate(date)).getTime() === targetWeekStart.getTime(), + ); if (candidates.length === 0) return null; const targetTime = target.getTime(); - return candidates.sort((left, right) => ( - Math.abs(parseDate(left).getTime() - targetTime) - Math.abs(parseDate(right).getTime() - targetTime) - || left.localeCompare(right) - ))[0]; + return candidates.sort( + (left, right) => + Math.abs(parseDate(left).getTime() - targetTime) - Math.abs(parseDate(right).getTime() - targetTime) || + left.localeCompare(right), + )[0]; } function startOfUtcWeek(date: Date): Date { diff --git a/packages/tui/tests/cli/run-skillpack-cli.test.ts b/packages/tui/tests/cli/run-skillpack-cli.test.ts index bba62e6..96d6e19 100644 --- a/packages/tui/tests/cli/run-skillpack-cli.test.ts +++ b/packages/tui/tests/cli/run-skillpack-cli.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { ENTER_ALTERNATE_SCREEN, EXIT_ALTERNATE_SCREEN, @@ -74,11 +74,7 @@ describe.sequential('runSkillpackCli', () => { exit: processLike.exit, }); - expect(processLike.stdout.chunks).toEqual([ - ENTER_ALTERNATE_SCREEN, - RESET_CURSOR_POSITION, - HIDE_CURSOR, - ]); + expect(processLike.stdout.chunks).toEqual([ENTER_ALTERNATE_SCREEN, RESET_CURSOR_POSITION, HIDE_CURSOR]); wait.resolve(); await run; @@ -126,11 +122,13 @@ describe.sequential('runSkillpackCli', () => { }), })); - await expect(runSkillpackCli({ - process: processLike as unknown as NodeJS.Process, - renderApp, - exit: processLike.exit, - })).rejects.toThrow('render failed'); + await expect( + runSkillpackCli({ + process: processLike as unknown as NodeJS.Process, + renderApp, + exit: processLike.exit, + }), + ).rejects.toThrow('render failed'); expect(processLike.stdout.chunks).toContain(SHOW_CURSOR); expect(processLike.stdout.chunks).toContain(EXIT_ALTERNATE_SCREEN); diff --git a/packages/tui/tests/fixtures/inventory.ts b/packages/tui/tests/fixtures/inventory.ts index 45cc2bf..d935a0c 100644 --- a/packages/tui/tests/fixtures/inventory.ts +++ b/packages/tui/tests/fixtures/inventory.ts @@ -1,9 +1,4 @@ -import type { - Skill, - SkillGroup, - SkillInventoryInstance, - SkillpackConfig, -} from '@skillpack/core'; +import type { Skill, SkillGroup, SkillInventoryInstance, SkillpackConfig } from '@skillpack/core'; type TestConfigOverrides = Partial> & { usage?: Partial; @@ -129,25 +124,29 @@ export function basicInventory(): SkillGroup[] { }, }), ]), - inventoryGroup('charlie', [ - inventoryInstance({ - name: 'charlie', - provider: 'global', - description: 'Charlie is managed by skills.sh.', - version: '1.0.0', - source: { - type: 'skillssh', - repo: 'owner/charlie', - skillFolderHash: 'abc1234', + inventoryGroup( + 'charlie', + [ + inventoryInstance({ + name: 'charlie', + provider: 'global', + description: 'Charlie is managed by skills.sh.', + version: '1.0.0', + source: { + type: 'skillssh', + repo: 'owner/charlie', + skillFolderHash: 'abc1234', + }, + actions: ['update', 'remove'], + }), + ], + { + identity: { + confidence: 'confirmed', + reasons: ['skills.sh provenance'], }, - actions: ['update', 'remove'], - }), - ], { - identity: { - confidence: 'confirmed', - reasons: ['skills.sh provenance'], }, - }), + ), ]; } diff --git a/packages/tui/tests/helpers/mock-manager.ts b/packages/tui/tests/helpers/mock-manager.ts index ebc8fbf..5a53abf 100644 --- a/packages/tui/tests/helpers/mock-manager.ts +++ b/packages/tui/tests/helpers/mock-manager.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest'; +import { vi } from 'vite-plus/test'; import type { DuplicateInfo, RemoteSkill, diff --git a/packages/tui/tests/helpers/render-tui.tsx b/packages/tui/tests/helpers/render-tui.tsx index 8c829dc..bb75ed2 100644 --- a/packages/tui/tests/helpers/render-tui.tsx +++ b/packages/tui/tests/helpers/render-tui.tsx @@ -7,6 +7,7 @@ import { testConfig } from '../fixtures/inventory.js'; import { createMockManager, type MockSkillManager } from './mock-manager.js'; type InkTestInstance = ReturnType; +const ANSI_PATTERN = new RegExp(String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, 'g'); export interface RenderTuiOptions { manager?: MockSkillManager; @@ -40,7 +41,7 @@ export const keypress = { }; export function stripAnsi(value: string): string { - return value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); + return value.replace(ANSI_PATTERN, ''); } export function normalizeFrame(frame: string | undefined): string { diff --git a/packages/tui/tests/plugin-toggle.test.ts b/packages/tui/tests/plugin-toggle.test.ts index 0be90eb..fdddec2 100644 --- a/packages/tui/tests/plugin-toggle.test.ts +++ b/packages/tui/tests/plugin-toggle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { formatPluginToggleMessage } from '../src/lib/plugin-toggle.js'; import type { Skill } from '@skillpack/core'; @@ -19,10 +19,7 @@ function pluginSkill(provider: string, name: string): Pick { it('uses provider-agnostic wording for plugin-owned skills', () => { const skill = pluginSkill('claude', 'deploy'); - const message = formatPluginToggleMessage(skill, [ - skill, - pluginSkill('claude', 'audit'), - ]); + const message = formatPluginToggleMessage(skill, [skill, pluginSkill('claude', 'audit')]); expect(message).toBe('Disable plugin deploy-plugin@team-tools? This affects 2 skills: audit, deploy.'); expect(message).not.toContain('Codex'); diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index 0773462..819d8bc 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { fitCell, getBoundedContentLayout, @@ -34,18 +34,11 @@ describe('getGlyphSet', () => { describe('wrapTextLines', () => { it('wraps long description lines into visual rows', () => { - expect(wrapTextLines(['alpha beta gamma delta'], 12)).toEqual([ - 'alpha beta', - 'gamma delta', - ]); + expect(wrapTextLines(['alpha beta gamma delta'], 12)).toEqual(['alpha beta', 'gamma delta']); }); it('splits words longer than the available width', () => { - expect(wrapTextLines(['supercalifragilistic'], 8)).toEqual([ - 'supercal', - 'ifragili', - 'stic', - ]); + expect(wrapTextLines(['supercalifragilistic'], 8)).toEqual(['supercal', 'ifragili', 'stic']); }); it('preserves explicit blank description lines', () => { @@ -102,14 +95,28 @@ describe('getDetailLayout', () => { expect(full.mode).toBe('full'); expect(full.sectioned).toBe(false); - expect(full.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'findings', 'actions']); + expect(full.sections.map((section) => section.id)).toEqual([ + 'summary', + 'paths', + 'source', + 'description', + 'findings', + 'actions', + ]); const compact = getDetailLayout({ size: { columns: 60, rows: 18 }, hasDescription: true, hasFindings: true }); expect(compact.mode).toBe('compact'); expect(compact.sectioned).toBe(true); expect(compact.visibleRows).toBeGreaterThan(0); - expect(compact.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'findings', 'actions']); + expect(compact.sections.map((section) => section.id)).toEqual([ + 'summary', + 'paths', + 'source', + 'description', + 'findings', + 'actions', + ]); }); }); diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts index 84d0ee2..7622c6b 100644 --- a/packages/tui/tests/shortcuts.test.ts +++ b/packages/tui/tests/shortcuts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { getShortcutsForView } from '../src/lib/shortcuts.js'; import type { SkillInventoryInstance } from '@skillpack/core'; diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index add50c4..460b3c1 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; import { inventoryGroup, inventoryInstance, testConfig } from '../fixtures/inventory.js'; import { createMockManager } from '../helpers/mock-manager.js'; import { keypress, renderTui, waitForFrame, type RenderTuiResult } from '../helpers/render-tui.js'; @@ -23,9 +23,10 @@ describe.sequential('Terminal UI Tests', () => { it('renders a stable inventory frame', async () => { const app = renderApp(); - const frame = await waitForFrame(app, (output) => ( - output.includes('* Skillpack') && !output.includes('Scanning skills') - )); + const frame = await waitForFrame( + app, + (output) => output.includes('* Skillpack') && !output.includes('Scanning skills'), + ); expect(frame).toMatchSnapshot(); }); @@ -67,9 +68,7 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('charlie')); app.stdin.write(keypress.enter); - const frame = await waitForFrame(app, (output) => ( - output.includes('filtered by') && output.includes('charlie') - )); + const frame = await waitForFrame(app, (output) => output.includes('filtered by') && output.includes('charlie')); expect(frame).toContain('1 instance'); expect(frame).toContain('charlie'); expect(frame).not.toContain('alpha'); @@ -162,7 +161,10 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write(keypress.enter); - await waitForFrame(app, (output) => output.includes('charlie') && output.includes('press') && output.includes('to check')); + await waitForFrame( + app, + (output) => output.includes('charlie') && output.includes('press') && output.includes('to check'), + ); app.stdin.write('u'); await waitForFrame(app, (output) => output.includes('1.0.0') && output.includes('1.1.0')); @@ -193,11 +195,11 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('s'); - await waitForFrame(app, (output) => ( - output.includes('Usage Artifact Roots') - && output.includes('codex') - && output.includes('/tmp/codex-usage') - )); + await waitForFrame( + app, + (output) => + output.includes('Usage Artifact Roots') && output.includes('codex') && output.includes('/tmp/codex-usage'), + ); }); it('does not show Settings scroll shortcut when all rows fit', async () => { @@ -216,9 +218,7 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); - await waitForFrame(app, (output) => ( - output.includes('Skill Usage') && output.includes('Enable usage import?') - )); + await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('Enable usage import?')); app.stdin.write(keypress.escape); await waitForFrame(app, (output) => output.includes('* Skillpack') && output.includes('alpha')); @@ -289,16 +289,18 @@ describe.sequential('Terminal UI Tests', () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, - providers: [{ - provider: 'claude', - displayName: 'Claude', - coverageState: 'not-configured', - coverageReasons: ['missing-attribution-roots'], - heatmap: [], - dailySkillUsage: [], - ranking: [], - diagnostics: [], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['missing-attribution-roots'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], }, }); const app = renderApp({ @@ -317,16 +319,18 @@ describe.sequential('Terminal UI Tests', () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, - providers: [{ - provider: 'claude', - displayName: 'Claude', - coverageState: 'not-configured', - coverageReasons: ['missing-artifact-roots', 'missing-attribution-roots'], - heatmap: [], - dailySkillUsage: [], - ranking: [], - diagnostics: [], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['missing-artifact-roots', 'missing-attribution-roots'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], }, }); const app = renderApp({ @@ -338,25 +342,27 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); - await waitForFrame(app, (output) => ( - output.includes('Claude Usage Artifact Roots and Skill Attribution Roots are not configured.') - )); + await waitForFrame(app, (output) => + output.includes('Claude Usage Artifact Roots and Skill Attribution Roots are not configured.'), + ); }); it('shows generic Skill Usage configuration context without a missing root', async () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, - providers: [{ - provider: 'claude', - displayName: 'Claude', - coverageState: 'not-configured', - coverageReasons: ['provider-not-configured'], - heatmap: [], - dailySkillUsage: [], - ranking: [], - diagnostics: [], - }], + providers: [ + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'not-configured', + coverageReasons: ['provider-not-configured'], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], }, }); const app = renderApp({ @@ -394,20 +400,50 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-07', rows: [ - { skillName: 'openai-docs', sourcePath: '/Users/twwu/.codex/skills/.system/openai-docs/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, + { + skillName: 'openai-docs', + sourcePath: '/Users/twwu/.codex/skills/.system/openai-docs/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T08:00:00Z', + }, ], }, { date: '2026-07-08', rows: [ - { skillName: 'frontend-testing', sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', countedInvocations: 2, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z' }, - { skillName: 'github:github', sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z' }, + { + skillName: 'frontend-testing', + sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', + countedInvocations: 2, + failedInvocations: 1, + lastInvokedAt: '2026-07-08T11:00:00Z', + }, + { + skillName: 'github:github', + sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-08T10:00:00Z', + }, ], }, ], ranking: [ - { skillName: 'frontend-testing', sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', countedInvocations: 5, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z' }, - { skillName: 'github:github', sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z' }, + { + skillName: 'frontend-testing', + sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', + countedInvocations: 5, + failedInvocations: 1, + lastInvokedAt: '2026-07-08T11:00:00Z', + }, + { + skillName: 'github:github', + sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-08T10:00:00Z', + }, ], diagnostics: [], }, @@ -432,24 +468,26 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); - await waitForFrame(app, (output) => ( - output.includes('Providers') - && output.includes('[Codex]') - && output.includes('Claude unsupported') - && output.includes('Range') - && output.includes('[7d]') - && output.includes('Usage heatmap') - && output.includes('Jul') - && output.includes('[■]') - && !output.includes('[3]') - && output.includes('2026-07-08 · Codex') - && output.includes('Counted 3 · Failed 1') - && output.includes('Selected day') - && output.includes('frontend-testing') - && output.includes('github:github') - && output.includes('Provider Skill Ranking') - && output.includes('Last Used') - )); + await waitForFrame( + app, + (output) => + output.includes('Providers') && + output.includes('[Codex]') && + output.includes('Claude unsupported') && + output.includes('Range') && + output.includes('[7d]') && + output.includes('Usage heatmap') && + output.includes('Jul') && + output.includes('[■]') && + !output.includes('[3]') && + output.includes('2026-07-08 · Codex') && + output.includes('Counted 3 · Failed 1') && + output.includes('Selected day') && + output.includes('frontend-testing') && + output.includes('github:github') && + output.includes('Provider Skill Ranking') && + output.includes('Last Used'), + ); app.stdin.write(keypress.up); await waitForFrame(app, (output) => output.includes('2026-07-07 · Codex') && output.includes('openai-docs')); @@ -458,7 +496,10 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('2026-07-08 · Codex') && output.includes('frontend-testing')); app.stdin.write(keypress.left); - await waitForFrame(app, (output) => output.includes('2026-07-02 · Codex') && output.includes('No skill usage on this day.')); + await waitForFrame( + app, + (output) => output.includes('2026-07-02 · Codex') && output.includes('No skill usage on this day.'), + ); app.stdin.write(keypress.right); await waitForFrame(app, (output) => output.includes('2026-07-08 · Codex') && output.includes('frontend-testing')); @@ -468,11 +509,13 @@ describe.sequential('Terminal UI Tests', () => { expect(manager.getSkillUsageOverview).toHaveBeenLastCalledWith({ rangeDays: 30 }); app.stdin.write(keypress.tab); - await waitForFrame(app, (output) => ( - output.includes('[Claude unsupported]') - && output.includes('Usage import is not supported for Claude yet.') - && output.includes('No usage records for this provider and range.') - )); + await waitForFrame( + app, + (output) => + output.includes('[Claude unsupported]') && + output.includes('Usage import is not supported for Claude yet.') && + output.includes('No usage records for this provider and range.'), + ); }); it('clamps Skill Usage scrolling when the selected day has fewer rows', async () => { @@ -486,22 +529,24 @@ describe.sequential('Terminal UI Tests', () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-07-02', to: '2026-07-08' }, - providers: [{ - provider: 'codex', - displayName: 'Codex', - coverageState: 'active', - coverageReasons: [], - heatmap: [ - { date: '2026-07-07', countedInvocations: 0, failedInvocations: 0 }, - { date: '2026-07-08', countedInvocations: 30, failedInvocations: 0 }, - ], - dailySkillUsage: [ - { date: '2026-07-07', rows: [] }, - { date: '2026-07-08', rows: longDayRows }, - ], - ranking: longDayRows.slice(0, 10), - diagnostics: [], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + coverageReasons: [], + heatmap: [ + { date: '2026-07-07', countedInvocations: 0, failedInvocations: 0 }, + { date: '2026-07-08', countedInvocations: 30, failedInvocations: 0 }, + ], + dailySkillUsage: [ + { date: '2026-07-07', rows: [] }, + { date: '2026-07-08', rows: longDayRows }, + ], + ranking: longDayRows.slice(0, 10), + diagnostics: [], + }, + ], }, }); const app = renderApp({ @@ -518,10 +563,10 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('skill-09') && output.includes('36-50 of 50')); app.stdin.write(keypress.up); - const frame = await waitForFrame(app, (output) => ( - output.includes('No skill usage on this day.') - && output.includes('1-15 of 25') - )); + const frame = await waitForFrame( + app, + (output) => output.includes('No skill usage on this day.') && output.includes('1-15 of 25'), + ); expect(frame).not.toContain('36-25 of 25'); }); @@ -529,35 +574,61 @@ describe.sequential('Terminal UI Tests', () => { const manager = createMockManager({ usageOverview: { range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, - providers: [{ - provider: 'codex', - displayName: 'Codex', - coverageState: 'active', - coverageReasons: [], - heatmap: [ - { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, - { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, - ], - dailySkillUsage: [ - { - date: '2026-07-06', - rows: [ - { skillName: 'tdd', sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z' }, - ], - }, - { - date: '2026-07-07', - rows: [ - { skillName: 'grilling', sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, - ], - }, - ], - ranking: [ - { skillName: 'tdd', sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z' }, - { skillName: 'grilling', sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, - ], - diagnostics: [], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + coverageReasons: [], + heatmap: [ + { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, + { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, + ], + dailySkillUsage: [ + { + date: '2026-07-06', + rows: [ + { + skillName: 'tdd', + sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-06T10:02:00Z', + }, + ], + }, + { + date: '2026-07-07', + rows: [ + { + skillName: 'grilling', + sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T08:00:00Z', + }, + ], + }, + ], + ranking: [ + { + skillName: 'tdd', + sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', + countedInvocations: 2, + failedInvocations: 0, + lastInvokedAt: '2026-07-06T10:02:00Z', + }, + { + skillName: 'grilling', + sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', + countedInvocations: 1, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T08:00:00Z', + }, + ], + diagnostics: [], + }, + ], }, }); const app = renderApp({ @@ -569,47 +640,64 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); - await waitForFrame(app, (output) => ( - output.includes('Usage heatmap') - && output.includes('2026-07-07 · Codex') - && output.includes('Counted 1 · Failed 0') - && output.includes('Provider Skill Ranking') - && output.includes('Last Used') - && output.includes('tdd') - && output.includes('grilling') - )); + await waitForFrame( + app, + (output) => + output.includes('Usage heatmap') && + output.includes('2026-07-07 · Codex') && + output.includes('Counted 1 · Failed 0') && + output.includes('Provider Skill Ranking') && + output.includes('Last Used') && + output.includes('tdd') && + output.includes('grilling'), + ); app.stdin.write(keypress.up); - await waitForFrame(app, (output) => output.includes('2026-07-06 · Codex') && output.includes('Counted 2 · Failed 0')); + await waitForFrame( + app, + (output) => output.includes('2026-07-06 · Codex') && output.includes('Counted 2 · Failed 0'), + ); }); it('shows failure context, source paths, and Usage Import Diagnostics', async () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-06-30', to: '2026-07-06' }, - providers: [{ - provider: 'codex', - displayName: 'Codex', - coverageState: 'active', - coverageReasons: [], - heatmap: [ - { date: '2026-07-06', countedInvocations: 1, failedInvocations: 1 }, - ], - dailySkillUsage: [ - { - date: '2026-07-06', - rows: [ - { skillName: 'deleted-skill', sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z' }, - ], - }, - ], - ranking: [ - { skillName: 'deleted-skill', sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z' }, - ], - diagnostics: [ - { provider: 'codex', message: 'Skipped ambiguous skill invocation evidence in session.jsonl:1' }, - ], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + coverageReasons: [], + heatmap: [{ date: '2026-07-06', countedInvocations: 1, failedInvocations: 1 }], + dailySkillUsage: [ + { + date: '2026-07-06', + rows: [ + { + skillName: 'deleted-skill', + sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', + countedInvocations: 1, + failedInvocations: 1, + lastInvokedAt: '2026-07-06T10:02:00Z', + }, + ], + }, + ], + ranking: [ + { + skillName: 'deleted-skill', + sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', + countedInvocations: 1, + failedInvocations: 1, + lastInvokedAt: '2026-07-06T10:02:00Z', + }, + ], + diagnostics: [ + { provider: 'codex', message: 'Skipped ambiguous skill invocation evidence in session.jsonl:1' }, + ], + }, + ], }, }); const app = renderApp({ @@ -621,14 +709,16 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); - await waitForFrame(app, (output) => ( - output.includes('2026-07-06 · Codex') - && output.includes('Counted 1 · Failed 1') - && output.includes('deleted-skill') - && output.includes('Source Path') - && output.includes('old-project') - && output.includes('Skipped ambiguous skill invocation evidence') - )); + await waitForFrame( + app, + (output) => + output.includes('2026-07-06 · Codex') && + output.includes('Counted 1 · Failed 1') && + output.includes('deleted-skill') && + output.includes('Source Path') && + output.includes('old-project') && + output.includes('Skipped ambiguous skill invocation evidence'), + ); }); it('switches the selected Skill Usage provider', async () => { @@ -651,19 +741,29 @@ describe.sequential('Terminal UI Tests', () => { displayName: 'Claude', coverageState: 'active', coverageReasons: [], - heatmap: [ - { date: '2026-07-07', countedInvocations: 3, failedInvocations: 0 }, - ], + heatmap: [{ date: '2026-07-07', countedInvocations: 3, failedInvocations: 0 }], dailySkillUsage: [ { date: '2026-07-07', rows: [ - { skillName: 'claude-skill', sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z' }, + { + skillName: 'claude-skill', + sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', + countedInvocations: 3, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T10:00:00Z', + }, ], }, ], ranking: [ - { skillName: 'claude-skill', sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z' }, + { + skillName: 'claude-skill', + sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', + countedInvocations: 3, + failedInvocations: 0, + lastInvokedAt: '2026-07-07T10:00:00Z', + }, ], diagnostics: [], }, @@ -681,27 +781,28 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write(keypress.tab); - await waitForFrame(app, (output) => ( - output.includes('Codex zero') - && output.includes('[Claude]') - && output.includes('claude-skill') - )); + await waitForFrame( + app, + (output) => output.includes('Codex zero') && output.includes('[Claude]') && output.includes('claude-skill'), + ); }); it('cycles Skill Usage ranges from 7 to 30 days', async () => { const manager = createMockManager(); manager.getSkillUsageOverview.mockImplementation(async ({ rangeDays }) => ({ range: { days: rangeDays, from: '2026-01-01', to: '2026-01-30' }, - providers: [{ - provider: 'codex', - displayName: 'Codex', - coverageState: 'zero', - coverageReasons: [], - heatmap: [], - dailySkillUsage: [], - ranking: [], - diagnostics: [], - }], + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'zero', + coverageReasons: [], + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], })); const app = renderApp({ manager, @@ -714,7 +815,10 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('2'); - await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('30 days') && output.includes('[30d]')); + await waitForFrame( + app, + (output) => output.includes('Skill Usage') && output.includes('30 days') && output.includes('[30d]'), + ); expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 30 }); }); diff --git a/packages/tui/tsup.config.ts b/packages/tui/tsup.config.ts deleted file mode 100644 index 724c609..0000000 --- a/packages/tui/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['bin/skillpack.ts'], - format: ['esm'], - target: 'node18', - platform: 'node', - outDir: 'dist', - clean: true, - sourcemap: false, - noExternal: ['@skillpack/core'], - external: ['yaml'], -}); diff --git a/packages/tui/vite.config.ts b/packages/tui/vite.config.ts new file mode 100644 index 0000000..ca43024 --- /dev/null +++ b/packages/tui/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite-plus'; + +const coreEntry = fileURLToPath(new URL('../core/src/index.ts', import.meta.url)); + +export default defineConfig({ + pack: { + alias: { + '@skillpack/core': coreEntry, + }, + clean: true, + deps: { + alwaysBundle: ['acorn'], + neverBundle: ['yaml'], + onlyBundle: ['acorn'], + }, + dts: false, + entry: ['bin/skillpack.ts'], + fixedExtension: false, + format: ['esm'], + hash: false, + minify: false, + outDir: 'dist', + platform: 'node', + sourcemap: false, + target: 'node18', + }, + resolve: { + alias: { + '@skillpack/core': coreEntry, + }, + }, + test: { + include: ['tests/**/*.test.{ts,tsx}'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87ac1a2..92fcd9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,26 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + vite: npm:@voidzero-dev/vite-plus-core@0.2.4 + vitest: 4.1.10 + importers: - .: {} + .: + devDependencies: + '@types/node': + specifier: 25.9.3 + version: 25.9.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.2.4 + version: '@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)' + vite-plus: + specifier: 0.2.4 + version: 0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0) packages/core: dependencies: @@ -23,9 +40,6 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.3)(vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0)) packages/tui: dependencies: @@ -57,15 +71,9 @@ importers: ink-testing-library: specifier: ^4.0.0 version: 4.0.0(@types/react@19.2.17) - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.15)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: 6.0.3 version: 6.0.3 - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.3)(vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0)) packages: @@ -73,6 +81,21 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -235,160 +258,313 @@ packages: peerDependencies: ink: '>=5' - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxc-project/runtime@0.138.0': + resolution: {integrity: sha512-yHhoXsN8tYxgdJCdD91PbySNjEEaBX/tH2OQRDXJpsQv5b184oC4/qVbU7qlblvfil/JP15Lh2HW7+HN5DS90Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} - '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + '@oxfmt/binding-android-arm-eabi@0.57.0': + resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + '@oxfmt/binding-android-arm64@0.57.0': + resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + '@oxfmt/binding-darwin-arm64@0.57.0': + resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + '@oxfmt/binding-darwin-x64@0.57.0': + resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + '@oxfmt/binding-freebsd-x64@0.57.0': + resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + '@oxfmt/binding-linux-arm64-musl@0.57.0': + resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} - cpu: [loong64] + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} - cpu: [loong64] + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} - cpu: [ppc64] + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} - cpu: [ppc64] + '@oxfmt/binding-linux-x64-gnu@0.57.0': + resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.57.0': + resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.57.0': + resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.57.0': + resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.24.0': + resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.24.0': + resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.24.0': + resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.24.0': + resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.24.0': + resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.24.0': + resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} - cpu: [x64] - os: [win32] + '@oxlint/plugins@1.68.0': + resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -404,11 +580,21 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/browser-preview@4.1.10': + resolution: {integrity: sha512-14MJrL59ZFkqXLjwfSk6RzTDy5Czf9UG4+8q8L6Gxjs2aPjEce/cVNYV14bXAc2BvMjUNu904+ZEZA1Xc1wtvQ==} + peerDependencies: + vitest: 4.1.10 - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -418,25 +604,129 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true + '@voidzero-dev/vite-plus-core@0.2.4': + resolution: {integrity: sha512-AoAYGPwNO56o9TuCR+KaQGA5XpSnTpn2QYHK0DQ0f8j3wvaMmToeWOJF6STo+XMntMDfiaB7sOsSFNE+hPYyjg==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': + resolution: {integrity: sha512-UQwoLpnBW3qLqj5H3airPQeMCX3kfffVDM2EYGe889Fn5dOS9VhikuWloJlcjwtKwqLbFqkrsGjfb6XRvuLozg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.2.4': + resolution: {integrity: sha512-f4XtiA4cBc/Z260QvvXIlBqTb/dZMAioohQDoR5jLG9o9vIOaWE2Ujm/zcWDyNpyZ88RLRrcO8ZwiMrEsdzbJw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': + resolution: {integrity: sha512-TUamG9wEehZI4SFG7M6nUKb6z/7x3nNOBbnPdWIpv4C8uWKHwNtsnaaVLeygHIUIJEhbByR3oEFDylYLLr4KRw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': + resolution: {integrity: sha512-CS9uQ22QFr+lZZp95Huccg3cip3QYVU9GWrhvATqMBXWXb8IRHOulzuXrFxzvhMBITyPaRS9m/eIHmnM4L4h4Q==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': + resolution: {integrity: sha512-X5XfvftFERjer78SG+QKaJCa9LgIKygx4w13sD1/RS/kYOtaf1+yifrJpOFel+t9TRe/YqGTZa5C8uFrDz3OHw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': + resolution: {integrity: sha512-2iIWW6JR0UOK4QIFKgUQHjaF/7hZxELePny8R1OIn2jzShRfQhS1cmxksSg8ce/5nhYrfQ9dkg5ZmdLbxhpS/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': + resolution: {integrity: sha512-3yY4FnpvKBxjmHtEcao6Wcs/VBstzC0GhXAPWetbiFEl/sgL66V4Uhws02esfOSWw8abqLrXyPE9vK+newtsuw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': + resolution: {integrity: sha512-ERnFrh1O0XZhmGSqND04jtHM7tyKaGAOeT1w/HpVFmL/cQK2vuLl2GKjEwOqkBLd2csNGCcnk3e7C2qDmrNR/Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} @@ -447,16 +737,24 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -466,16 +764,6 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -484,10 +772,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -508,17 +792,6 @@ packages: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -529,19 +802,21 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -581,9 +856,6 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -636,20 +908,86 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -658,24 +996,15 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - obug@2.1.2: resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} engines: {node: '>=12.20.0'} @@ -684,6 +1013,36 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + oxfmt@0.57.0: + resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@0.24.0: + resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + hasBin: true + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -698,35 +1057,21 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -737,23 +1082,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -763,6 +1095,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slice-ansi@9.0.0: resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} engines: {node: '>=22'} @@ -771,10 +1107,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -793,11 +1125,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -806,19 +1133,9 @@ packages: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -827,35 +1144,17 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} @@ -866,66 +1165,36 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} - engines: {node: ^20.19.0 || >=22.12.0} + vite-plus@0.2.4: + resolution: {integrity: sha512-gaBBjOXIq9lLRU44oAYdIr99p+JBLX1kxs+l/6LqGgSXwcVKAdDa1boSrOTELqYCkQQ0fpppXUGWi9o6JDT5zw==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: + '@vitest/browser-playwright': optional: true - yaml: + '@vitest/browser-webdriverio': optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -993,6 +1262,18 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@blazediff/core@1.9.1': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1079,97 +1360,167 @@ snapshots: figures: 6.1.0 ink: 7.0.5(@types/react@19.2.17)(react@19.2.7) - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/resolve-uri@3.1.2': {} + '@oxc-project/runtime@0.138.0': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@oxc-project/types@0.138.0': {} - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@oxfmt/binding-android-arm-eabi@0.57.0': + optional: true + + '@oxfmt/binding-android-arm64@0.57.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.57.0': + optional: true - '@rollup/rollup-android-arm-eabi@4.61.1': + '@oxfmt/binding-darwin-x64@0.57.0': optional: true - '@rollup/rollup-android-arm64@4.61.1': + '@oxfmt/binding-freebsd-x64@0.57.0': optional: true - '@rollup/rollup-darwin-arm64@4.61.1': + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': optional: true - '@rollup/rollup-darwin-x64@4.61.1': + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': optional: true - '@rollup/rollup-freebsd-arm64@4.61.1': + '@oxfmt/binding-linux-arm64-gnu@0.57.0': optional: true - '@rollup/rollup-freebsd-x64@4.61.1': + '@oxfmt/binding-linux-arm64-musl@0.57.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.61.1': + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.61.1': + '@oxfmt/binding-linux-riscv64-musl@0.57.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.61.1': + '@oxfmt/binding-linux-s390x-gnu@0.57.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.61.1': + '@oxfmt/binding-linux-x64-gnu@0.57.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.61.1': + '@oxfmt/binding-linux-x64-musl@0.57.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.61.1': + '@oxfmt/binding-openharmony-arm64@0.57.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.61.1': + '@oxfmt/binding-win32-arm64-msvc@0.57.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.61.1': + '@oxfmt/binding-win32-ia32-msvc@0.57.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.61.1': + '@oxfmt/binding-win32-x64-msvc@0.57.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.61.1': + '@oxlint-tsgolint/darwin-arm64@0.24.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.61.1': + '@oxlint-tsgolint/darwin-x64@0.24.0': optional: true - '@rollup/rollup-linux-x64-musl@4.61.1': + '@oxlint-tsgolint/linux-arm64@0.24.0': optional: true - '@rollup/rollup-openbsd-x64@4.61.1': + '@oxlint-tsgolint/linux-x64@0.24.0': optional: true - '@rollup/rollup-openharmony-arm64@4.61.1': + '@oxlint-tsgolint/win32-arm64@0.24.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.61.1': + '@oxlint-tsgolint/win32-x64@0.24.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.61.1': + '@oxlint/binding-android-arm-eabi@1.72.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.61.1': + '@oxlint/binding-android-arm64@1.72.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.61.1': + '@oxlint/binding-darwin-arm64@1.72.0': optional: true + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@oxlint/plugins@1.68.0': {} + + '@polka/url@1.0.0-next.29': {} + '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1187,48 +1538,112 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitest/expect@4.1.8': + '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) + vitest: 4.1.10(@types/node@25.9.3)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.9.3)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.9.3)(yaml@2.9.0) + vite: '@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)' - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.8': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - acorn@8.16.0: {} + '@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.138.0 + '@oxc-project/types': 0.138.0 + lightningcss: 1.32.0 + postcss: 8.5.15 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.27.7 + fsevents: 2.3.3 + typescript: 6.0.3 + yaml: 2.9.0 + + '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': + optional: true acorn@8.17.0: {} @@ -1236,31 +1651,26 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} - any-promise@1.3.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 assertion-error@2.0.1: {} auto-bind@5.0.1: {} - bundle-require@5.1.0(esbuild@0.27.7): - dependencies: - esbuild: 0.27.7 - load-tsconfig: 0.2.5 - - cac@6.7.14: {} - chai@6.2.2: {} chalk@5.6.2: {} - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - cli-boxes@4.0.1: {} cli-cursor@4.0.0: @@ -1278,24 +1688,20 @@ snapshots: dependencies: convert-to-spaces: 2.0.1 - commander@4.1.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - convert-source-map@2.0.0: {} convert-to-spaces@2.0.1: {} csstype@3.2.3: {} - debug@4.4.3: - dependencies: - ms: 2.1.3 - deepmerge@4.3.1: {} + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + environment@1.1.0: {} es-module-lexer@2.1.0: {} @@ -1330,6 +1736,7 @@ snapshots: '@esbuild/win32-arm64': 0.27.7 '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + optional: true escape-string-regexp@2.0.0: {} @@ -1347,12 +1754,6 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.2 - rollup: 4.61.1 - fsevents@2.3.3: optional: true @@ -1408,13 +1809,58 @@ snapshots: is-unicode-supported@2.1.0: {} - joycon@3.1.1: {} + js-tokens@4.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true - lilconfig@3.1.3: {} + lightningcss-win32-arm64-msvc@1.32.0: + optional: true - lines-and-columns@1.2.4: {} + lightningcss-win32-x64-msvc@1.32.0: + optional: true - load-tsconfig@0.2.5: {} + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lz-string@1.5.0: {} magic-string@0.30.21: dependencies: @@ -1422,31 +1868,74 @@ snapshots: mimic-fn@2.1.0: {} - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.4 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 + mrmime@2.0.1: {} nanoid@3.3.12: {} - object-assign@4.1.1: {} - obug@2.1.2: {} onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + oxfmt@0.57.0(vite-plus@0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.57.0 + '@oxfmt/binding-android-arm64': 0.57.0 + '@oxfmt/binding-darwin-arm64': 0.57.0 + '@oxfmt/binding-darwin-x64': 0.57.0 + '@oxfmt/binding-freebsd-x64': 0.57.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 + '@oxfmt/binding-linux-arm64-gnu': 0.57.0 + '@oxfmt/binding-linux-arm64-musl': 0.57.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-musl': 0.57.0 + '@oxfmt/binding-linux-s390x-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-musl': 0.57.0 + '@oxfmt/binding-openharmony-arm64': 0.57.0 + '@oxfmt/binding-win32-arm64-msvc': 0.57.0 + '@oxfmt/binding-win32-ia32-msvc': 0.57.0 + '@oxfmt/binding-win32-x64-msvc': 0.57.0 + vite-plus: 0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0) + + oxlint-tsgolint@0.24.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.24.0 + '@oxlint-tsgolint/darwin-x64': 0.24.0 + '@oxlint-tsgolint/linux-arm64': 0.24.0 + '@oxlint-tsgolint/linux-x64': 0.24.0 + '@oxlint-tsgolint/win32-arm64': 0.24.0 + '@oxlint-tsgolint/win32-x64': 0.24.0 + + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + oxlint-tsgolint: 0.24.0 + vite-plus: 0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0) + patch-console@2.0.0: {} pathe@2.0.3: {} @@ -1455,20 +1944,7 @@ snapshots: picomatch@4.0.4: {} - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - postcss-load-config@6.0.1(postcss@8.5.15)(yaml@2.9.0): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - postcss: 8.5.15 - yaml: 2.9.0 + pngjs@7.0.0: {} postcss@8.5.15: dependencies: @@ -1476,6 +1952,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + react-is@17.0.2: {} + react-reconciler@0.33.0(react@19.2.7): dependencies: react: 19.2.7 @@ -1483,52 +1967,23 @@ snapshots: react@19.2.7: {} - readdirp@4.1.2: {} - - resolve-from@5.0.0: {} - restore-cursor@4.0.0: dependencies: onetime: 5.1.2 signal-exit: 3.0.7 - rollup@4.61.1: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.61.1 - '@rollup/rollup-android-arm64': 4.61.1 - '@rollup/rollup-darwin-arm64': 4.61.1 - '@rollup/rollup-darwin-x64': 4.61.1 - '@rollup/rollup-freebsd-arm64': 4.61.1 - '@rollup/rollup-freebsd-x64': 4.61.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 - '@rollup/rollup-linux-arm-musleabihf': 4.61.1 - '@rollup/rollup-linux-arm64-gnu': 4.61.1 - '@rollup/rollup-linux-arm64-musl': 4.61.1 - '@rollup/rollup-linux-loong64-gnu': 4.61.1 - '@rollup/rollup-linux-loong64-musl': 4.61.1 - '@rollup/rollup-linux-ppc64-gnu': 4.61.1 - '@rollup/rollup-linux-ppc64-musl': 4.61.1 - '@rollup/rollup-linux-riscv64-gnu': 4.61.1 - '@rollup/rollup-linux-riscv64-musl': 4.61.1 - '@rollup/rollup-linux-s390x-gnu': 4.61.1 - '@rollup/rollup-linux-x64-gnu': 4.61.1 - '@rollup/rollup-linux-x64-musl': 4.61.1 - '@rollup/rollup-openbsd-x64': 4.61.1 - '@rollup/rollup-openharmony-arm64': 4.61.1 - '@rollup/rollup-win32-arm64-msvc': 4.61.1 - '@rollup/rollup-win32-ia32-msvc': 4.61.1 - '@rollup/rollup-win32-x64-gnu': 4.61.1 - '@rollup/rollup-win32-x64-msvc': 4.61.1 - fsevents: 2.3.3 - scheduler@0.27.0: {} siginfo@2.0.0: {} signal-exit@3.0.7: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 @@ -1536,8 +1991,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.7.6: {} - stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -1555,32 +2008,12 @@ snapshots: dependencies: ansi-regex: 6.2.2 - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - tagged-tag@1.0.0: {} terminal-size@4.0.1: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - tinybench@2.9.0: {} - tinyexec@0.3.2: {} - tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -1588,39 +2021,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinyrainbow@3.1.0: {} - - tree-kill@1.2.2: {} + tinypool@2.1.0: {} - ts-interface-checker@0.1.13: {} + tinyrainbow@3.1.0: {} - tsup@8.5.1(postcss@8.5.15)(typescript@6.0.3)(yaml@2.9.0): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.7 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15)(yaml@2.9.0) - resolve-from: 5.0.0 - rollup: 4.61.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.17 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.15 - typescript: 6.0.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml + totalist@3.0.1: {} type-fest@5.7.0: dependencies: @@ -1628,32 +2033,75 @@ snapshots: typescript@6.0.3: {} - ufo@1.6.4: {} - undici-types@7.24.6: {} - vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0): + vite-plus@0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0): dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.61.1 - tinyglobby: 0.2.17 + '@oxc-project/types': 0.138.0 + '@oxlint/plugins': 1.68.0 + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + '@voidzero-dev/vite-plus-core': 0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0) + oxfmt: 0.57.0(vite-plus@0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@25.9.3)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + oxlint-tsgolint: 0.24.0 + vitest: 4.1.10(@types/node@25.9.3)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: - '@types/node': 25.9.3 - fsevents: 2.3.3 - yaml: 2.9.0 + '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 + '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.4 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.4 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.4 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.4 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.4 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.4 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml - vitest@4.1.8(@types/node@25.9.3)(vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0)): + vitest@4.1.10(@types/node@25.9.3)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@25.9.3)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -1665,10 +2113,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.9.3)(yaml@2.9.0) + vite: '@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.3)(esbuild@0.27.7)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e24990..38765ce 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,13 @@ packages: - - "packages/*" + - 'packages/*' + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@0.2.4 + vitest: 4.1.10 + +peerDependencyRules: + allowedVersions: + vite: 0.2.4 + allowBuilds: esbuild: true diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..340d3f7 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,66 @@ +import { constants } from 'node:fs'; +import { access, readFile, readdir, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const coreDirectory = join(repositoryRoot, 'packages/core'); +const tuiDirectory = join(repositoryRoot, 'packages/tui'); +const coreDist = join(coreDirectory, 'dist'); +const tuiDist = join(tuiDirectory, 'dist'); +const cliPath = join(tuiDist, 'skillpack.js'); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function assertExactFiles(directory, expectedFiles) { + const files = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); + assert( + JSON.stringify(files) === JSON.stringify([...expectedFiles].sort((left, right) => left.localeCompare(right))), + `${directory} contains ${files.join(', ') || 'no files'}; expected ${expectedFiles.join(', ')}`, + ); +} + +await assertExactFiles(coreDist, ['index.d.ts', 'index.js']); +await assertExactFiles(tuiDist, ['skillpack.js']); + +const [corePackage, tuiPackage, coreJavaScript, cliJavaScript, cliStats] = await Promise.all([ + readFile(join(coreDirectory, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(tuiDirectory, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(coreDist, 'index.js'), 'utf8'), + readFile(cliPath, 'utf8'), + stat(cliPath), +]); + +assert(corePackage.main === './dist/index.js', 'Core main must point to ./dist/index.js.'); +assert(corePackage.types === './dist/index.d.ts', 'Core types must point to ./dist/index.d.ts.'); +assert(tuiPackage.type === 'module', 'The published CLI package must remain ESM.'); +assert(tuiPackage.bin?.skillpack === './dist/skillpack.js', 'The skillpack bin must point to ./dist/skillpack.js.'); +assert(cliJavaScript.startsWith('#!/usr/bin/env node\n'), 'The CLI must start with the Node.js shebang.'); +assert(/^import\s/m.test(cliJavaScript), 'The CLI artifact must contain ESM imports.'); +assert(!cliJavaScript.includes('sourceMappingURL='), 'The CLI artifact must not reference a source map.'); +assert(!coreJavaScript.includes('sourceMappingURL='), 'The Core artifact must not reference a source map.'); +assert((cliStats.mode & 0o111) !== 0, 'The CLI artifact must be executable.'); +await access(cliPath, constants.X_OK); + +execFileSync(process.execPath, ['--check', cliPath], { stdio: 'pipe' }); +execFileSync(process.execPath, ['--check', join(coreDist, 'index.js')], { stdio: 'pipe' }); + +const packResult = JSON.parse( + execFileSync('pnpm', ['pack', '--dry-run', '--json'], { + cwd: tuiDirectory, + encoding: 'utf8', + }), +); +const packedFiles = packResult.files.map(({ path }) => path).sort((left, right) => left.localeCompare(right)); +const expectedPackedFiles = ['LICENSE', 'README.md', 'dist/skillpack.js', 'package.json'].sort((left, right) => + left.localeCompare(right), +); +assert( + JSON.stringify(packedFiles) === JSON.stringify(expectedPackedFiles), + `Package would contain ${packedFiles.join(', ')}; expected ${expectedPackedFiles.join(', ')}`, +); + +console.log('Verified Core, CLI, and npm package artifacts.'); diff --git a/tsconfig.json b/tsconfig.json index 5408190..35a8036 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "declaration": true, diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f798f2b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from 'vite-plus'; + +const generatedPatterns = ['**/coverage/**', '**/dist/**', '**/*.snap', '**/*.tsbuildinfo', 'pnpm-lock.yaml']; + +export default defineConfig({ + fmt: { + ignorePatterns: [...generatedPatterns, '**/*.md', '.agents/**'], + printWidth: 120, + semi: true, + singleQuote: true, + sortImports: false, + sortPackageJson: true, + trailingComma: 'all', + }, + lint: { + env: { + node: true, + }, + ignorePatterns: generatedPatterns, + options: { + typeAware: true, + typeCheck: true, + }, + plugins: ['typescript'], + overrides: [ + { + files: ['packages/tui/src/**/*.{ts,tsx}', 'packages/tui/bin/**/*.{ts,tsx}'], + plugins: ['typescript', 'react'], + }, + { + files: ['packages/core/tests/**/*.ts'], + plugins: ['typescript', 'vitest'], + rules: { + 'vitest/no-disabled-tests': 'error', + }, + }, + { + files: ['packages/tui/tests/**/*.{ts,tsx}'], + plugins: ['typescript', 'react', 'vitest'], + rules: { + 'vitest/no-disabled-tests': 'error', + }, + }, + ], + }, + run: { + cache: false, + }, +}); From 9dc39cc63f1174f8494b93a4f39ba772c3cdc129 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 14 Jul 2026 16:43:52 +0800 Subject: [PATCH 4/5] docs(tui): sync package readme --- packages/tui/README.md | 208 ++++++++++++++++++++++++++++++++--------- 1 file changed, 165 insertions(+), 43 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index 09d3db7..5c7859b 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -1,10 +1,24 @@ # Skillpack +![NPM Downloads](https://img.shields.io/npm/dm/skillpack-tui?style=flat-square&color=%23085afc&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fskillpack-tui) + Unified TUI manager for agent skills across Codex, Claude, and Global (`~/.agents/skills`). +## Features + +- **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 +- **Skill Usage** — derives aggregate Codex and Claude skill usage from local session artifacts after explicit consent +- **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 + ## Installation -Install globally: +### From npm (recommended) ```bash npm i -g skillpack-tui @@ -17,70 +31,178 @@ Or run without installing: npx skillpack-tui ``` -## Features +### From source + +```bash +git clone https://github.com/WTW0313/skillpack.git && cd skillpack +pnpm install +pnpm build +node packages/tui/dist/skillpack.js +``` + +## Development -- 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 -- Check and apply manual updates for skills.sh-managed Global Skills -- Fuzzy search by name and description +Contributing requires a Vite+-supported Node.js release (20.19+, 22.18+, or 24.11+) and pnpm 11. The published CLI still supports Node.js 18 and newer. + +```bash +pnpm check # format check, type-aware lint, and type check +pnpm format # format supported source and config files +pnpm lint # type-aware lint and type check +pnpm test # run all package tests once +pnpm build # package Core and the TUI +pnpm dev # build Core, then watch the TUI package +``` + +CI uses the explicit `pnpm check:ci`, `pnpm test:ci`, and `pnpm build:ci` entry points. The final command also verifies the Core exports, CLI shebang and executable bit, ESM syntax, source-map policy, and npm package contents. ## Quick Start -Launch `skillpack` to see discovered provider instances with availability state, confirmed related providers, and Inventory Issues. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider, and `/` to search. +Launch `skillpack` to see discovered skills grouped into Skill Groups with provider status and Health Signals. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider (All, Codex, Claude, Global), and `/` to search. + +Press `Enter` to inspect a Skill Group, then use left/right to choose a provider instance before taking instance-level actions. Press `g` to open Skill Usage, `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. -Press `Enter` to inspect a provider instance, then use left/right to switch related provider instances when relationship context exists. Press `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. +Skill Usage is opt-in. The first time you open it, press `y` to allow Skillpack to derive aggregate usage records from provider session artifacts. Skillpack stores only minimal invocation metadata: skill name, provider, source path, status, and timestamps. It does not store prompts, responses, tool arguments, file contents, or transcripts. ## Keyboard Shortcuts ### List View -| Key | Action | -| --- | --- | -| `↑` / `↓` | Navigate skills | -| `Enter` | Open detail view | -| `Tab` / `Shift+Tab` | Switch provider tab | -| `/` | Search | -| `Esc` | Clear search | -| `p` | Open Project Skills | -| `s` | Open Settings | -| `i` | Install through skills.sh | -| `u` | Open manual updates | -| `q` | Quit | +| Key | Action | Description | +|-----|--------|-------------| +| `↑` / `↓` | Navigate | Move selection up / down | +| `Enter` | Detail | Open Skill Group detail view | +| `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 | +| `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 | +| `g` | Usage | Open opt-in Skill Usage analytics | +| `q` | Quit | Exit skillpack | ### Detail View -| Key | Action | -| --- | --- | -| `Esc` | Return to list view | -| `←` / `→` | Switch provider instance | -| `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 | +| Key | Action | Description | +|-----|--------|-------------| +| `Esc` | Back | Return to list view | +| `←` / `→` | Provider instance | Switch the selected provider instance inside the Skill Group | +| `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 | + +### Usage View + +| Key | Action | Description | +|-----|--------|-------------| +| `y` | Enable import | Grant consent for local Skill Usage import when prompted | +| `Esc` | Back | Return to list view | +| `Tab` / `Shift+Tab` | Provider | Switch between usage providers | +| `1` / `2` / `3` | Range | Show 7 / 30 / 90 days | +| `↑` / `↓` | Day | Move the selected day by one day | +| `←` / `→` | Week | Move the selected day by one week | +| `PageUp` / `PageDown` | Scroll | Scroll long usage reports | +| `Home` / `End` | Jump | Jump to top / bottom of the report | +| `r` | Rescan | Re-import usage from configured artifact roots | +| `x` | Reset | Clear Skillpack's derived local usage records | +| `q` | Quit | Exit skillpack | ## Configuration -Skillpack stores configuration in: +Skillpack stores its configuration at `~/.config/skillpack/config.json`. On first run it auto-detects which provider directories exist and enables them. + +```json +{ + "editor": "vi", + "autoCheckUpdates": false, + "projectSkillsDirs": [ + ".codex/skills", + ".claude/skills", + ".agents/skills" + ], + "providers": { + "codex": { "enabled": true, "paths": ["~/.codex/skills", "~/.codex/plugins/cache"] }, + "claude": { "enabled": true, "paths": ["~/.claude/plugins/cache", "~/.claude/skills"] }, + "global": { "enabled": true, "paths": ["~/.agents/skills"] } + }, + "sources": { + "skillssh": { "enabled": true } + }, + "usage": { + "importConsent": false, + "artifactRoots": { + "codex": [ + "~/.codex/sessions", + "~/.codex/archived_sessions" + ], + "claude": [ + "~/.claude/projects" + ] + } + } +} +``` + +`usage.importConsent` defaults to `false`. When enabled from the TUI, Skillpack imports supported Codex and Claude artifacts into derived JSONL records under `~/.local/share/skillpack/usage/`. Claude usage is derived from structured `Skill` tool calls that match user-level or plugin-provided skills in configured Claude Scan Roots. Built-In Skills and Claude Project Skills are not counted in the current Claude adapter. + +The Settings view shows both Scan Roots and Usage Artifact Roots so you can verify which local directories Skillpack will inspect. + +## Adding a Provider -```text -~/.config/skillpack/config.json +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'; + +class MyProvider extends BaseProvider { + readonly id = 'my-platform'; + readonly displayName = 'My Platform'; + readonly basePaths = ['/path/to/skills']; + readonly capabilities: ProviderCapabilities = { + canToggle: true, + }; + + override async scan() { /* return provider-native Skill instances */ } + override async setEnabled(skill, enabled) { /* update provider-native availability */ } + override getDisableStrategy(skill) { /* describe the availability mechanism */ } +} ``` -It auto-detects provider directories on first run and can scan project-level skill directories such as: +Register it in the manager: -```text -.codex/skills -.claude/skills -.agents/skills +```typescript +manager.registerProvider(new MyProvider()); ``` -## Requirements +## Project Structure -- Node.js >= 18 +``` +skillpack/ +├── packages/ +│ ├── core/ # @skillpack/core — platform-agnostic library +│ │ ├── src/ +│ │ │ ├── models/ # Skill, DuplicateInfo, RemoteSkill, etc. +│ │ │ ├── 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 +│ │ │ ├── manager.ts # SkillManager — central orchestrator +│ │ │ └── parser.ts # SKILL.md frontmatter parser (YAML) +│ │ └── tests/ +│ └── tui/ # @skillpack/tui — Ink-based terminal UI +│ └── src/ +│ ├── views/ # ListView, DetailView, InstallView, ProjectSkillsView, SettingsView, UpdatesView, UsageView +│ ├── components/ # StatusBar, ConfirmDialog, SearchInput, SkillRow, TabBar +│ ├── context/ # React context for app state +│ ├── hooks/ # useSkillManager, useSkills, useSearch, useTerminalSize +│ └── app.tsx # App shell and router +├── docs/ # Design specs and implementation plans +├── package.json # Workspace root +└── pnpm-workspace.yaml # pnpm workspace config +``` -## Links +## License -- [GitHub](https://github.com/WTW0313/skillpack) -- [npm](https://www.npmjs.com/package/skillpack-tui) +MIT From eed2fc0dff13d07b42c4bafffa34edc0d5d95e72 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Tue, 14 Jul 2026 17:11:44 +0800 Subject: [PATCH 5/5] fix(ci): resolve core types before build Map @skillpack/core to its workspace source for type checking so pnpm check:ci does not depend on a pre-existing Core declaration build. --- tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index 35a8036..af327fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,9 @@ "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", + "paths": { + "@skillpack/core": ["./packages/core/src/index.ts"] + }, "jsx": "react-jsx", "strict": true, "esModuleInterop": true,