From 914a8a8b54c03b8452fdb696243b7bad7167492c Mon Sep 17 00:00:00 2001 From: DevOhMyCode Date: Tue, 10 Nov 2026 16:40:39 +0100 Subject: [PATCH] feat: project updates and suggestions, context cost, profiles with backups, personal packs Project skills are checked for updates like global ones. suggest finds skills for the technologies a project uses. Previews, show and installed --size estimate what a skill costs in context. profile use keeps only a pack's skills in a scope after an exact backup, and profile restore puts it back. Personal packs are managed from the CLI, the tree and the web page. The MCP server gains suggest_skills, update_skills, rollback_skill, doctor and sync_project. --- CHANGELOG.md | 13 +- README.md | 57 ++++- src/agent-api.ts | 81 +++++- src/catalog/installed.ts | 6 +- src/catalog/normalize.ts | 6 +- src/catalog/packs.ts | 109 ++++++-- src/cli.tsx | 465 ++++++++++++++++++++++++++++++++--- src/config.ts | 2 + src/context.ts | 104 ++++++++ src/i18n/de.ts | 111 ++++++++- src/i18n/en.ts | 111 ++++++++- src/i18n/es.ts | 111 ++++++++- src/i18n/fr.ts | 111 ++++++++- src/i18n/it.ts | 111 ++++++++- src/i18n/ja.ts | 111 ++++++++- src/i18n/ko.ts | 111 ++++++++- src/i18n/pt.ts | 111 ++++++++- src/i18n/zh.ts | 111 ++++++++- src/install/diff.ts | 2 +- src/install/plan.ts | 17 +- src/install/updates.ts | 159 +++++++++++- src/mcp.ts | 213 ++++++++++++++-- src/profiles.ts | 191 ++++++++++++++ src/suggest.ts | 297 ++++++++++++++++++++++ src/types.ts | 2 + src/ui/App.tsx | 94 +++++-- src/ui/Dialogs.tsx | 256 +++++++++++++++++++ src/ui/Layout.tsx | 14 +- src/ui/TreeSelect.tsx | 261 +++++++++++++++++++- src/web/page.ts | 149 ++++++++++- src/web/server.ts | 127 ++++++++-- test/catalog.test.ts | 8 +- test/context.test.ts | 51 ++++ test/health-cli.test.ts | 3 +- test/mcp.test.ts | 108 +++++++- test/packs.test.ts | 56 ++++- test/profile-cli.test.ts | 39 +++ test/profiles.test.ts | 148 +++++++++++ test/project-updates.test.ts | 94 +++++++ test/suggest.test.ts | 113 +++++++++ test/ui.test.tsx | 136 +++++++++- test/updates-cli.test.ts | 39 +++ test/web-ui.test.ts | 2 +- test/web.test.ts | 74 +++++- 44 files changed, 4283 insertions(+), 212 deletions(-) create mode 100644 src/context.ts create mode 100644 src/profiles.ts create mode 100644 src/suggest.ts create mode 100644 src/ui/Dialogs.tsx create mode 100644 test/context.test.ts create mode 100644 test/profile-cli.test.ts create mode 100644 test/profiles.test.ts create mode 100644 test/project-updates.test.ts create mode 100644 test/suggest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 221b7ee..91c39fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## 0.40.0 — 2026-11-10 -Validation: 286 tests and TypeScript checks. +Validation: 323 tests and TypeScript checks. - **`skills-atlas unused`** lists the installed skills Claude Code has not used in the last 30 days, read from its session transcripts on this machine, and `--remove` uninstalls them. Skills installed during the window and skills of other agents are listed apart, never as unused. Also as the MCP tool `unused_skills`. - **`skills-atlas overlaps`** lists the installed skills whose descriptions are close enough for an agent to take one for the other, with a score and the words they share. Also as the MCP tool `overlapping_skills`. @@ -13,6 +13,17 @@ Validation: 286 tests and TypeScript checks. - **Secret keys written in a skill are reported** (medium), cut to their first characters; placeholders such as `sk-xxxxxxxx` are not. - **Look-alike names.** The audit, `show` and the web preview name the far more installed skill a skill could be taken for: a name, or an owner's name, one character away. - **The update diff covers the whole folder.** The files an update adds, changes or removes are listed and shown next to the `SKILL.md`, and their new warning signs are reported with their file; `outdated_skills` returns them as `files_changed`. +- **Project skills get updates too.** `outdated`, `update` and `u` in the tree check the project's skills as well as global ones: the installed files are compared blob by blob with the repository's HEAD. A copy edited by hand (its files no longer match the lock's `computedHash`) is reported as modified and never overwritten; a pinned one is left to `lock`. `-p`/`-g` limit either command to one scope; `outdated --json` entries carry their `scope`. +- **`skills-atlas suggest`** finds what a project is built with (manifests, workspaces and telltale files, about 45 technologies) and suggests catalog skills for each, publisher's own first, without what is installed; `--json` and `-n` available. +- **💡 For this project** opens the tree and the web page when the current directory uses known technologies: the suggested skills, in rank order, each tagged with its technology. +- **MCP:** new `suggest_skills`, `update_skills`, `rollback_skill`, `doctor` and `sync_project` tools, audited like installs; `install_skills` takes `packs`; `outdated_skills` covers both scopes and returns `modified`. +- **Context cost.** The preview, the web page's Details, `show` and MCP `show_skill` estimate what a skill costs in context: its description at every session start, its `SKILL.md` when used. `installed --size` adds each skill's tokens and size on disk and each agent's startup load; an agent loading more than 5,000 tokens of descriptions is flagged there and above the tree. +- **Profiles.** `skills-atlas profile use ` (and **Use this pack as a profile…** on the web page) keeps only a pack's skills in a scope: the others, home-made ones included, are removed after the whole scope is saved byte for byte. `profile restore` (and **Backups** on the web page) puts a scope back exactly, offline, saving the current state first; `--initial` returns to the configuration before any profile. `profile create`, `list` and `backups` manage the rest. +- **Manage personal packs everywhere.** In the tree, `N` saves the ticked skills as a pack (name and description asked), `E` edits a personal pack by ticking its skills, `D` deletes it, `P` activates any pack as a profile and `B` restores a backup. `profile edit` and `profile delete` do the same on the command line; the web page gets **Edit this pack…** (name, description, skills) and **Delete this pack**, and its Save as pack fills the id from the name. Shipped packs stay read-only; backups are never touched by pack changes. +- **Shipped packs always show.** `packs.json` now holds only personal packs, added after the shipped ones; it no longer replaces them, and `[]` no longer hides them. Copies of shipped packs an earlier version wrote there are ignored. +- **Tree: warnings on their own page.** Heavy context loads and incomplete update checks no longer stack above the tree: the status line shows `⚠ N`, `!` lists them, Esc goes back. The logo now shows on every screen when the terminal is tall enough, and every list sizes itself under it. +- **Fix:** the questions the tree's `N`, `E`, `P` and `B` keys ask (pack name, backup number…) no longer end the program with *Detected unsettled top-level await*: stdin is held while a question waits. +- **Packs, profiles and backups without leaving the tree.** `N`, `E`, `D`, `P` and `B` open the tree's own windows (name and description, confirmation, backups list, profile scope and plan); the pack themes update in place, a restore reads what is installed again, and only a confirmed profile leaves the tree to install, like any change. Warnings are spaced, and agents with the same load share one line. ## 0.30.0 — 2026-10-23 diff --git a/README.md b/README.md index 01b0c31..d1b98c1 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Browse public **Agent Skills** (`SKILL.md` format) by theme in a collapsible ter 🤖 Visit [Website](https://skillsatlas.replit.app/). - **Find**: about 75,000 skills from skills.sh, claude-plugins.dev and Smithery, in 20 themes, plus [★ Recommended](#recommended-skills) repositories, [🔥 Trending](#trending) skills and [📦 Packs](#packs) to install as a block. -- **Check before installing**: read any skill's `SKILL.md` with `p`, with a [risk check](#risk-check) that points at what it runs, reads or sends. A skill that would [replace another of the same name](#installed-skills) is flagged before anything runs. +- **Check before installing**: read any skill's `SKILL.md` with `p`, with a [risk check](#risk-check) that points at what it runs, reads or sends, at content it fetches from a URL and at a name imitating a far more installed skill. A skill that would [replace another of the same name](#installed-skills) is flagged before anything runs. - **Keep up to date**: installed skills are detected and ticked; untick to uninstall, press `u` to [update](#installed-skills) the outdated ones, and see [which agent has what](#usage). +- **Keep it lean**: see what each skill [costs in context](#context-cost), which ones [were not used lately](#unused-skills), which ones [an agent may confuse](#skills-an-agent-may-confuse), and switch between sets of skills with [profiles](#profiles). - **Share**: `export` and `import` a selection, or commit a [`skills-atlas.json`](#a-projects-skills-skills-atlasjson) so that `skills-atlas sync` gives every teammate the same skills. - **From your agent**: a skill, an MCP server and a Claude Code plugin let [Claude and other agents](#from-claude-and-other-agents) search, read and install skills for you. - **Everywhere**: 79 agents, a terminal tree and a [web page](#web-interface), nine languages, [shell completion](#usage), and it works offline. @@ -60,14 +61,21 @@ skills-atlas ui # the same in your browser, with t skills-atlas export > my-skills.json # installed skills as a selection file (-g / -p for one scope) skills-atlas import my-skills.json -a claude-code -g # install the same skills on another machine or project skills-atlas show obra/superpowers@brainstorming # a skill's SKILL.md, read from GitHub (--json available) -skills-atlas outdated # global skills with a newer version on GitHub (--json available) -skills-atlas update # update them all (or name some; --dry-run available) +skills-atlas outdated # installed skills, project and global, with a newer version on GitHub (-p/-g, --json) +skills-atlas update # update them all, each in its scope (or name some; -p/-g, --dry-run) +skills-atlas suggest # skills for this project, from its package.json, pyproject.toml, Dockerfile… (--json) skills-atlas rollback # reinstall the version it had before its last update (--list, --to ) skills-atlas mcp # MCP server on stdio, for Claude and other agents skills-atlas installed -a claude-code codex # installed skills by agent: what each one sees (--json available) +skills-atlas installed --size # the same, with what each skill costs in context (tokens) and on disk +skills-atlas unused # installed skills Claude Code has not used in 30 days (--days, --remove, --json) +skills-atlas overlaps # installed skills an agent may confuse: close descriptions (--threshold, --json) skills-atlas packs # curated packs: frontend, security, quality, writing skills-atlas install --pack security -a claude-code -g # install a whole pack skills-atlas sync # install what this project's skills-atlas.json lists and is missing +skills-atlas profile create Design # a personal pack to use as a profile (--from-installed available) +skills-atlas profile use design # keep only that pack's skills in the global scope (-p: this project), after a backup +skills-atlas profile restore # put the scope back exactly as before (--initial: before any profile) skills-atlas completion bash # shell completion script (bash, zsh, fish, powershell) ``` @@ -116,7 +124,25 @@ A pack is a set of skills meant to be installed together: **📦 Frontend dev**, In the web interface, **Save as pack** saves the checked catalog skills, including installed ones that remain checked, as a personal pack. Enter a unique lowercase id (letters, digits and hyphens), a name and description; a pack contains 1–100 skills. Saving adds to your personal file, preserves bundled packs on the first save, and opens the new pack immediately without restarting. Duplicate ids and invalid existing files are rejected without overwriting them. The offline demo explicitly disables saving. -The packs ship in [`data/packs.json`](data/packs.json). As for Recommended, a personal `~/.config/skills-atlas/packs.json` replaces them, with the same shape: `[{ "id", "name", "description", "skills": ["owner/repo@skill", …] }]`; `[]` hides them. +The packs ship in [`data/packs.json`](data/packs.json). They always show. Personal packs are added to them from `~/.config/skills-atlas/packs.json`, with the same shape: `[{ "id", "name", "description", "skills": ["owner/repo@skill", …] }]`; an entry under a shipped pack's id is ignored. + +### Profiles + +A profile is a pack activated on its own, to switch between configurations such as *Design* or *Backend*. `skills-atlas profile use ` (or, on the web page, a pack's **Use this pack as a profile…**) shows what stays, what goes and what comes, then, once confirmed: saves the scope, removes every skill of the scope that is not in the pack (home-made ones included), and installs the pack's missing skills, audited like any install. A profile changes one scope: the global one, or the project with `-p`. + +**Nothing is lost.** Before each profile change, the whole scope is saved in `~/.local/state/skills-atlas/backups/` (or `$XDG_STATE_HOME`): every skills directory as it is on disk (folders copied, links kept as links) and the lock file, byte for byte. Home-made and edited skills are thus moved to the backup, not deleted. `skills-atlas profile restore [id]` (or **Backups** on the web page) puts the scope back exactly as it was, offline, and saves the current state first, so a restore can be undone too. The first backup of a scope is its initial configuration, kept for good: `profile restore --initial` returns to it. The ten latest others are kept. + +**Personal packs** are kept in `~/.config/skills-atlas/packs.json` (or `$XDG_CONFIG_HOME`), as many as you like, next to the shipped ones (which can be used but not changed). Changing or deleting a pack never touches the backups: the initial configuration stays restorable. + +| | Terminal (tree) | Command line | Web page | +|---|---|---|---| +| Create | `N`: the ticked skills, named and described in the tree's own window | `profile create ` (`-d`, `--from-installed`) | **Save as pack** (name, description) | +| Change | `E` on a personal pack: tick its skills, Enter, then name and description in a window | `profile edit ` (asks), or `--name`, `-d`, `--add`, `--remove` | **Edit this pack…** (name, description, skills) | +| Delete | `D` on a personal pack, confirmed in a window | `profile delete ` | **Delete this pack** | +| Activate as profile | `P` on any pack: scope (← →) and plan in a window, then the install | `profile use ` (`-p`) | **Use this pack as a profile…** | +| Restore a backup | `B`: the backups in a window, Enter then y | `profile backups`, `profile restore [id]` (`--initial`) | **Backups** | + +`profile list` shows every pack (✎ marks the personal ones) and the active profile. ### A project's skills: skills-atlas.json @@ -168,7 +194,7 @@ Copies do not count. A repository that shares at least three growing skill names Three ways to let an agent find and install skills for you, from the lightest: - **The skill.** `npx skills add devohmycode/skills-atlas -s skills-atlas` teaches any agent the commands above (`list --json`, `show`, `install … -y`, `outdated --json`). Ask "find me a skill to draw diagrams": the agent searches, reads the candidates' `SKILL.md`, then installs the one you pick. -- **The MCP server.** `skills-atlas mcp` serves the same catalog as tools: `search_skills`, `list_themes`, `show_skill` (with its risk check), `list_packs`, `list_installed`, `unused_skills` (what Claude Code has not used lately), `overlapping_skills` (descriptions an agent may confuse), `outdated_skills` (`{ skills, unchecked }`), `install_skills` (with `dry_run`; it refuses to replace a skill of the same name unless `replace: true`) and `remove_skills`. Installs and removals run one at a time per server, each re-checking what is installed; reads and dry runs do not wait. In Claude Code: +- **The MCP server.** `skills-atlas mcp` serves the same catalog as tools: `search_skills`, `list_themes`, `show_skill` (with its risk check), `list_packs`, `list_installed`, `unused_skills` (what Claude Code has not used lately), `overlapping_skills` (descriptions an agent may confuse), `suggest_skills` (skills for the current project), `outdated_skills` (`{ skills, unchecked, modified }`), `update_skills`, `rollback_skill`, `doctor`, `sync_project` (the project's `skills-atlas.json`), `install_skills` (ids or `packs`, with `dry_run`; it refuses to replace a skill of the same name unless `replace: true`) and `remove_skills`. Updates, rollbacks and syncs are audited like installs: a failing skill passes only with `ignore_audit: true`. Installs and removals run one at a time per server, each re-checking what is installed; reads and dry runs do not wait. In Claude Code: ```bash claude mcp add skills-atlas -- npx -y skills-atlas@latest mcp @@ -260,7 +286,7 @@ Only `repository` (a GitHub repository URL) and `reason` are required. Without ` - **Pre-ticked**: installed skills are ticked and marked with their scope, `● project`, `● global` or `● project + global`, under their theme and in an **Already installed** group at the top. That group also holds skills the catalog does not know, such as home-made ones. - **Uninstall**: unticking an installed skill marks it `✗ will be uninstalled`. On confirmation it is removed from every agent of every scope it was found in, with one `npx skills remove -s … -y [-g]` per scope, then checked on disk. - **Same name, one folder**: the skills CLI installs by name, so a skill from another repository replaces an installed one of the same name. The review screen lists such replacements before anything runs; `install` and `import` print them. -- **Updates**: globally installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version for the agents that already have them. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. A repository GitHub does not answer for (rate limit, timeout, private or deleted), a truncated tree or a skill folder it no longer has makes the check incomplete, never "up to date": the tree shows a warning, and `outdated` and `update` name each one on stderr and exit with 1 (`outdated --json` keeps its array on stdout; `update` still runs the confirmed updates). Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` and in the other text files of its folder, each under its own `=== ~ scripts/run.sh +3 −1` line (added lines green, removed red), and only the warning signs the new version brings, each with its file: a script can change while the `SKILL.md` stays the same. `skills-atlas update` prints each skill's `+added −removed` lines for its `SKILL.md`, one line per other file added (`+`), changed (`~`) or removed (`-`), and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns `{ skills, unchecked }`, each skill with `lines_added`, `lines_removed`, `files_changed` and `new_risks`, and `unchecked` listing what could not be checked (`source`, `reason`). Before each update, skills-atlas records the commit each skill was installed from, in `~/.config/skills-atlas/history.json`, five per skill, but only a commit it can prove: one a rollback installed while the lock still records the same folder hash (kept in `revisions.json` next to it), the installer's own ref, or the last commit touching the folder before the lock's install date whose folder hash on GitHub matches the lock's. A version that cannot be proven (offline, a folder hash that matches no such commit) is not recorded, and `update` says that skill has no way back. `skills-atlas rollback ` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to ` picks one. Skills updated before this version have no way back. +- **Updates**: installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version, in their own scope, for the agents that already have them. A project skill is compared file by file with its repository (its lock records a content hash, not GitHub's folder hash); one whose files no longer match its lock was edited by hand and is never updated, only named. Rollback covers global updates. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. A repository GitHub does not answer for (rate limit, timeout, private or deleted), a truncated tree or a skill folder it no longer has makes the check incomplete, never "up to date": the tree shows a warning, and `outdated` and `update` name each one on stderr and exit with 1 (`outdated --json` keeps its array on stdout; `update` still runs the confirmed updates). Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` and in the other text files of its folder, each under its own `=== ~ scripts/run.sh +3 −1` line (added lines green, removed red), and only the warning signs the new version brings, each with its file: a script can change while the `SKILL.md` stays the same. `skills-atlas update` prints each skill's `+added −removed` lines for its `SKILL.md`, one line per other file added (`+`), changed (`~`) or removed (`-`), and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns `{ skills, unchecked }`, each skill with `lines_added`, `lines_removed`, `files_changed` and `new_risks`, and `unchecked` listing what could not be checked (`source`, `reason`). Before each update, skills-atlas records the commit each skill was installed from, in `~/.config/skills-atlas/history.json`, five per skill, but only a commit it can prove: one a rollback installed while the lock still records the same folder hash (kept in `revisions.json` next to it), the installer's own ref, or the last commit touching the folder before the lock's install date whose folder hash on GitHub matches the lock's. A version that cannot be proven (offline, a folder hash that matches no such commit) is not recorded, and `update` says that skill has no way back. `skills-atlas rollback ` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to ` picks one. Skills updated before this version have no way back. - **Install**: ticking a skill that is not installed installs it. The scope is asked after the tree, pre-filled with your last choice, unless `-p`/`-g` sets it. - **Questions**: the status line shows the balance `+N −M`. Scope, agents and method are only asked when there is something to install. @@ -270,7 +296,7 @@ Only `repository` (a GitHub repository URL) and `reason` are required. Without ` ### Unused skills -Every installed skill costs its name and description in each session, used or not. `skills-atlas unused` reads Claude Code's session transcripts on this machine (`~/.claude/projects`, or `CLAUDE_CONFIG_DIR`) and lists the installed skills it has not used in the last 30 days (`--days `): a skill counts as used when the agent called it with the Skill tool, when you typed it as a slash command, or when the agent read its `SKILL.md`, subagents included. Nothing leaves the machine and nothing read is kept. +Every installed skill costs its name and description in each session, used or not ([how much](#context-cost)). `skills-atlas unused` reads Claude Code's session transcripts on this machine (`~/.claude/projects`, or `CLAUDE_CONFIG_DIR`) and lists the installed skills it has not used in the last 30 days (`--days `): a skill counts as used when the agent called it with the Skill tool, when you typed it as a slash command, or when the agent read its `SKILL.md`, subagents included. Nothing leaves the machine and nothing read is kept. - **Too early to tell**: a skill installed (or updated, which reinstalls it) during the window is listed apart, not as unused. - **Usage unknown**: skills not installed for Claude Code. Only its transcripts are read; the other agents' usage is not measured. @@ -290,6 +316,18 @@ An agent chooses a skill from its name and description alone: two installed skil Words are weighed by how rare they are among your installed skills, so that “use when the user asks” counts for little. `--threshold ` moves the bar (0.5 by default: lower lists more pairs), `-g` or `-p` keeps to one scope, `--json` adds both descriptions. It is a word comparison, in English and French stop words only: it points at pairs worth reading side by side, it does not prove they conflict, and skills of one family (`storage-get`, `storage-delete`) match by design. +### Suggestions for a project + +`skills-atlas suggest [dir]` reads the project's manifests and telltale files (`package.json` and its `apps/`, `packages/` workspaces, `pyproject.toml` and requirements files, `Cargo.toml`, `go.mod`, `Gemfile`, `composer.json`, `Dockerfile`, `.github/workflows`, `prisma/schema.prisma`, `wrangler.toml`…) to find what it is built with: about 45 technologies, from React and FastAPI to Supabase, Terraform and the Claude API. For each, it lists up to three catalog skills (`-n` to change it): the technology's own publisher first (`supabase/…`, `prisma/…`), then skills named after it, most installed first. Skills already installed, of archived or idle repositories, deprecated, installed fewer than 500 times, or copies of the same name are left out, and the packs that fit are named. It only reads files, runs nothing and sends nothing; the MCP server's `suggest_skills` gives agents the same answer. The tree and the web page show the same suggestions as their first theme, **💡 For this project**, each skill tagged with the technology it fits. + +### Context cost + +Agents load the name and description of every installed skill at each session start, so they know when to use one; the whole `SKILL.md` enters the context only when the skill is used. skills-atlas estimates both (about 4 characters per token, one per CJK character; an estimate, not a tokenizer count): + +- the `SKILL.md` preview (`p`), the web page's **Details**, `skills-atlas show` (on stderr, `cost` with `--json`) and the MCP server's `show_skill` give a skill's two costs before you install it; +- `skills-atlas installed --size` lists, under each agent, every skill's startup and on-use tokens and its size on disk, with the agent's total startup load (`startup_tokens` and `cost` with `--json`); +- when an agent loads more than 5,000 tokens of skill descriptions at each start, a warning names it under `installed --size`, and in the tree's warnings (`⚠ N` on the status line, listed with `!`): untick the skills you do not use. + ### Installation diagnostics `skills-atlas doctor` checks project and global installations without changing any files. Use `-p` or `-g` for one scope and `--json` for a machine-readable report. It reports broken links, missing or unreadable `SKILL.md` files, invalid lock files and lock entries without a readable installed skill. Missing ordinary agent directories and absent lock files are normal. It exits with 1 when it finds issues and 0 otherwise; it never repairs or removes anything automatically. @@ -309,6 +347,11 @@ Words are weighed by how rare they are among your installed skills, so that “u | `p` | preview the focused skill's `SKILL.md` before installing it, with its warning signs (↑↓ scroll, esc back) | | `u` | mark the focused outdated skill, or the outdated skills of a group, for update | | `U` | mark every outdated skill for update | +| `N` | new pack from the ticked skills (named in a window, without leaving the tree) | +| `E` / `D` | edit / delete the focused personal pack (in edit mode, ticks are its skills: Enter saves, Esc cancels) | +| `P` | use the focused pack as a profile | +| `B` | backups: restore one | +| `!` | warnings (heavy context loads, incomplete update checks): the status line shows only `⚠ N`, `!` lists them, Esc goes back | | enter | continue | | `q` | quit | diff --git a/src/agent-api.ts b/src/agent-api.ts index ccb5aa3..7458022 100644 --- a/src/agent-api.ts +++ b/src/agent-api.ts @@ -4,9 +4,13 @@ import { auditSkills, withLookalikes } from './audit.js'; import { fetchSkillFiles, fetchSkillMarkdown, githubUrl } from './github.js'; import { parseSkillId } from './install/plan.js'; import { listInstalled } from './install/run.js'; -import { findUpdates, type UpdateCheck } from './install/updates.js'; +import { checkUpdates, type UpdateCheck } from './install/updates.js'; +import { folderCost, markdownCost, type SkillCost } from './context.js'; +import { projectRevisionDrift } from './revisions.js'; import type { Risk } from './risks.js'; -import type { Catalog, Scope, Skill } from './types.js'; +import { missingSkills, missingSkillsByAgent } from './selection.js'; +import { detectStack, suggestSkills } from './suggest.js'; +import { SCOPES, type Catalog, type Scope, type Skill } from './types.js'; /** * What an agent (the CLI's non-interactive commands, the MCP server) can ask @@ -61,12 +65,12 @@ export function findSkill(catalog: Catalog, id: string): Skill { } /** A skill's SKILL.md, from its GitHub repository (escape sequences removed), and the warning signs of its folder. */ -export async function showSkill(catalog: Catalog, id: string): Promise<{ skill: SkillSummary; markdown: string; risks: Risk[] }> { +export async function showSkill(catalog: Catalog, id: string): Promise<{ skill: SkillSummary; markdown: string; risks: Risk[]; cost: SkillCost }> { const skill = findSkill(catalog, id); const markdown = await fetchSkillMarkdown(skill); // The audit's view: the other files of the folder too, the SKILL.md read once. const [audit] = withLookalikes(await auditSkills([skill], async () => markdown, fetchSkillFiles), catalog.skills); - return { skill: summarize(skill), markdown, risks: audit!.risks }; + return { skill: summarize(skill), markdown, risks: audit!.risks, cost: markdownCost(markdown, skill.name) }; } /** An installed skill, with the agent ids an install command needs. */ @@ -76,11 +80,16 @@ export interface InstalledSummary { source?: string; scope: Scope; agents: string[]; + /** Estimated context cost, when asked for: tokens at startup and when used, files and bytes on disk. */ + cost?: SkillCost; } -export async function installedSkills(scopes: Scope[]): Promise { +export async function installedSkills(scopes: Scope[], opts: { cost?: boolean } = {}): Promise { const found = (await Promise.all(scopes.map((sc) => listInstalled(sc)))).flat(); - return found.map((s) => ({ id: `${s.source ?? 'local'}@${s.name}`, name: s.name, source: s.source, scope: s.scope, agents: agentIds(s.agents) })); + return found.map((s) => { + const cost = opts.cost && s.path ? folderCost(s.path, s.name) : undefined; + return { id: `${s.source ?? 'local'}@${s.name}`, name: s.name, source: s.source, scope: s.scope, agents: agentIds(s.agents), ...(cost ? { cost } : {}) }; + }); } /** @@ -96,11 +105,59 @@ export function skillsByAgent(installed: InstalledSummary[]): { agent: string; n } /** - * Global skills with a newer version on GitHub. Updating one is installing it - * again: `skills-atlas install -g -a -y`. `unchecked` lists what - * could not be checked: the check is complete only when it is empty. + * Installed skills with a newer version on GitHub, in the project and global + * scopes (or the ones asked). Updating one is installing it again in its scope: + * `skills-atlas install -g|-p -a -y`. `unchecked` lists what + * could not be checked: the check is complete only when it is empty. `modified` + * names the project skills edited by hand, which are left out of the updates. */ -export async function outdatedSkills(): Promise<{ skills: InstalledSummary[]; unchecked: UpdateCheck['unchecked'] }> { - const [found, installed] = await Promise.all([findUpdates(), installedSkills(['global'])]); - return { skills: installed.filter((s) => s.source && found.outdated.has(`${s.source}@${s.name}`.toLowerCase())), unchecked: found.unchecked }; +export async function outdatedSkills(scopes: Scope[] = ['project', 'global']): Promise<{ skills: InstalledSummary[]; unchecked: UpdateCheck['unchecked']; modified: string[] }> { + const [found, installed] = await Promise.all([checkUpdates(scopes), installedSkills(scopes)]); + return { + skills: installed.filter((s) => s.source && found.outdated[s.scope].has(`${s.source}@${s.name}`.toLowerCase())), + unchecked: found.unchecked, + modified: found.modified, + }; +} + +/** A technology found in the project and the skills suggested for it, as an agent reads them. */ +export interface StackSummary { + id: string; + label: string; + evidence: string[]; + skills: SkillSummary[]; +} + +/** + * Skills that fit the project in `dir`: what its manifests and files say it uses, + * then catalog skills for each of those, without the ones installed there or globally. + * Reads files only; nothing is installed or sent anywhere. + */ +export async function suggestForProject(catalog: Catalog, dir = process.cwd(), opts: { perStack?: number } = {}): Promise<{ stack: StackSummary[]; packs: string[] }> { + const installed = (await Promise.all(SCOPES.map((sc) => listInstalled(sc, dir)))).flat(); + const found = suggestSkills(catalog, detectStack(dir), { installed: installed.map((s) => `${s.source ?? 'local'}@${s.name}`), perStack: opts.perStack }); + return { stack: found.stack.map((s) => ({ id: s.id, label: s.label, evidence: s.evidence, skills: s.skills.map(summarize) })), packs: found.packs }; +} + +/** What `sync` would install: groups of ids, each for its agents (all of the file's agents, or project-wide presence). */ +export interface SyncPlan { + ids: string[]; + agents?: string[]; + revisions?: Record; + groups: { agents: string[] | undefined; ids: string[] }[]; +} + +/** + * The skills of a project's `skills-atlas.json` that are missing in the project: per + * target agent when agents are given (or the file names some), else by presence. Pinned + * skills whose installed files drifted from their revision are reinstalled too. + */ +export async function projectSyncPlan(file: { ids: string[]; agents?: string[]; revisions?: Record }, targets: string[] | undefined, cwd = process.cwd()): Promise { + const installed = await listInstalled('project', cwd); + const groups: SyncPlan['groups'] = targets + ? missingSkillsByAgent(file.ids, installed, targets) + : [{ agents: undefined, ids: missingSkills(file.ids, installed.map((s) => `${s.source ?? 'local'}@${s.name}`)) }]; + const drift = (await projectRevisionDrift(file.ids, file.revisions ?? {}, installed, cwd)).filter((id) => !groups.some((g) => g.ids.includes(id))); + if (drift.length) groups.push({ agents: targets, ids: drift }); + return { ids: file.ids, agents: targets, revisions: file.revisions, groups: groups.filter((g) => g.ids.length) }; } diff --git a/src/catalog/installed.ts b/src/catalog/installed.ts index 1be08e4..4387536 100644 --- a/src/catalog/installed.ts +++ b/src/catalog/installed.ts @@ -9,6 +9,8 @@ export interface InstalledEntry { scopes: Scope[]; /** Display names of the agents it is installed for, all scopes together. */ agents: string[]; + /** The same, scope by scope: an update reinstalls in one scope for that scope's agents. */ + agentsByScope?: Partial>; } export interface TreeData { @@ -47,9 +49,11 @@ export function mergeInstalled(catalogSkills: Skill[], visible: Skill[], install if (entry) { if (!entry.scopes.includes(inst.scope)) entry.scopes.push(inst.scope); for (const a of inst.agents) if (!entry.agents.includes(a)) entry.agents.push(a); + const scoped = (entry.agentsByScope ??= {}); + scoped[inst.scope] = [...new Set([...(scoped[inst.scope] ?? []), ...inst.agents])]; continue; } - entries.set(base.id, { name: inst.name, scopes: [inst.scope], agents: [...inst.agents] }); + entries.set(base.id, { name: inst.name, scopes: [inst.scope], agents: [...inst.agents], agentsByScope: { [inst.scope]: [...inst.agents] } }); shown.set(base.id, { ...base, themes: [INSTALLED_THEME.id, ...base.themes] }); } for (const e of entries.values()) e.scopes.sort((a, b) => (a === 'project' ? -1 : b === 'project' ? 1 : 0)); diff --git a/src/catalog/normalize.ts b/src/catalog/normalize.ts index b602c42..0b960c7 100644 --- a/src/catalog/normalize.ts +++ b/src/catalog/normalize.ts @@ -70,7 +70,7 @@ export function cleanDescription(text: string | null | undefined, max = 400): st * Reads `description` from a SKILL.md frontmatter, including the folded (`>`) * and literal (`|`) block forms. Not a full YAML parser. */ -export function frontmatterDescription(markdown: string): string | undefined { +export function frontmatterDescription(markdown: string, max = 400): string | undefined { const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)?.[1]; if (!fm) return undefined; const lines = fm.split(/\r?\n/); @@ -79,12 +79,12 @@ export function frontmatterDescription(markdown: string): string | undefined { const inline = lines[i]!.replace(/^description\s*:\s*/, '').trim(); if (inline && !BROKEN.test(inline)) { const unquoted = /^(["'])([\s\S]*)\1$/.exec(inline); - return cleanDescription(unquoted ? unquoted[2]!.replace(/\\"/g, '"').replace(/''/g, "'") : inline); + return cleanDescription(unquoted ? unquoted[2]!.replace(/\\"/g, '"').replace(/''/g, "'") : inline, max); } const block: string[] = []; for (const line of lines.slice(i + 1)) { if (line.trim() && !/^\s/.test(line)) break; block.push(line.trim()); } - return cleanDescription(block.join(' ')); + return cleanDescription(block.join(' '), max); } diff --git a/src/catalog/packs.ts b/src/catalog/packs.ts index 87e6eba..519f9de 100644 --- a/src/catalog/packs.ts +++ b/src/catalog/packs.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import bundled from '../../data/packs.json' with { type: 'json' }; import { configPath } from '../config.js'; import { parseSkillId } from '../install/plan.js'; -import type { Catalog, Skill } from '../types.js'; +import type { Catalog, Skill, Theme } from '../types.js'; /** A curated set of skills for one kind of work, installed as a block. */ export interface Pack { @@ -23,6 +23,8 @@ const PackInput = z.object({ }); const Packs = z.array(PackInput); +const bundledIds = new Set((bundled as { id: string }[]).map((p) => p.id)); + /** Theme id of a pack in the tree and on the web page. */ export const packTheme = (id: string) => `pack:${id}`; @@ -42,19 +44,33 @@ function parsePacks(raw: unknown): Pack[] { return result.data; } -/** A personal list replaces the shipped packs; [] hides them. */ -export function readPacks(path = join(dirname(configPath()), 'packs.json')): Pack[] { +/** + * The personal packs of `packs.json`. Entries under a shipped pack's id (copies an older + * version wrote) are left out: the shipped pack is always the one shown. + */ +export function readPersonalPacks(path = join(dirname(configPath()), 'packs.json')): Pack[] { + let raw: unknown; try { - return parsePacks(JSON.parse(readFileSync(path, 'utf8'))); + raw = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return parsePacks(bundled); + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw new Error(`Could not read packs from ${path}`, { cause: error }); } + try { + return parsePacks(raw).filter((p) => !isBundledPack(p.id)); + } catch (error) { + throw new Error(`Could not read packs from ${path}`, { cause: error }); + } +} + +/** Every pack: the shipped ones, always, then the personal ones. */ +export function readPacks(path = join(dirname(configPath()), 'packs.json')): Pack[] { + return [...parsePacks(bundled), ...readPersonalPacks(path)]; } /** Invalid requests and duplicate ids are distinct from filesystem/configuration failures. */ export class PackSaveError extends Error { - constructor(readonly status: 400 | 409, message: string) { super(message); } + constructor(readonly status: 400 | 404 | 409, message: string) { super(message); } } /** Add a personal pack without losing defaults, existing packs, or an invalid file. */ @@ -67,14 +83,9 @@ export function savePack(raw: unknown, catalogSkills: Skill[], path = join(dirna const known = new Set(catalogSkills.map((s) => s.id)); if (pack.skills.some((id) => !known.has(id))) throw new PackSaveError(400, 'unknown pack skills'); if (new Set(pack.skills.map((id) => id.toLowerCase())).size !== pack.skills.length) throw new PackSaveError(400, 'duplicate pack skills'); - const existing = readPacks(path); - if (existing.some((p) => p.id === pack.id)) throw new PackSaveError(409, 'pack id already exists'); - mkdirSync(dirname(path), { recursive: true }); - const temporary = path + '.' + randomBytes(12).toString('hex') + '.tmp'; - try { - writeFileSync(temporary, JSON.stringify([...existing, pack], null, 2) + '\n', { flag: 'wx', mode: 0o600 }); - renameSync(temporary, path); - } finally { rmSync(temporary, { force: true }); } + const personal = readPersonalPacks(path); + if (isBundledPack(pack.id) || personal.some((p) => p.id === pack.id)) throw new PackSaveError(409, 'pack id already exists'); + writePacks([...personal, pack], path); return pack; } @@ -84,7 +95,7 @@ export function savePack(raw: unknown, catalogSkills: Skill[], path = join(dirna * (an alternative catalog) are left out of the theme, not invented. */ export function withPacks(catalog: Catalog, packs: Pack[] = readPacks()): Catalog { - if (!packs.length) return catalog; + if (!packs.length) return { ...catalog, themes: catalog.themes.filter((th) => !th.id.startsWith('pack:')) }; const members = new Map(); for (const p of packs) for (const id of p.skills) members.set(id.toLowerCase(), [...(members.get(id.toLowerCase()) ?? []), packTheme(p.id)]); const skills = catalog.skills.map((s) => { @@ -98,3 +109,71 @@ export function withPacks(catalog: Catalog, packs: Pack[] = readPacks()): Catalo const after = Math.max(others.findIndex((th) => th.id === 'trending'), others.findIndex((th) => th.id === 'recommended')) + 1; return { ...catalog, skills, themes: [...others.slice(0, after), ...packThemes, ...others.slice(after)], stats: { ...catalog.stats, byTheme } }; } + + +/** A pack shipped with skills-atlas: shown and usable, but not edited or deleted (a personal pack can copy it). */ +export const isBundledPack = (id: string): boolean => bundledIds.has(id); + +/** Writes the packs list in one step, so that an interrupted write never leaves half a file. */ +function writePacks(packs: Pack[], path: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = path + '.' + randomBytes(12).toString('hex') + '.tmp'; + try { + writeFileSync(temporary, JSON.stringify(packs, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + renameSync(temporary, path); + } finally { rmSync(temporary, { force: true }); } +} + +/** + * Changes a personal pack's name, description or skills. New skills must be in the + * catalog; members the catalog no longer lists are kept as they are. + */ +export function updatePack(id: string, patch: { name?: string; description?: string; skills?: string[] }, catalogSkills: Skill[], path = join(dirname(configPath()), 'packs.json')): Pack { + if (isBundledPack(id)) throw new PackSaveError(400, 'a shipped pack cannot be changed: save a copy under another id'); + const packs = readPersonalPacks(path); + const current = packs.find((p) => p.id === id); + if (!current) throw new PackSaveError(404, 'unknown pack'); + const skills = patch.skills ? [...new Map(patch.skills.map((s) => [s.toLowerCase(), s])).values()] : current.skills; + const known = new Set(catalogSkills.map((s) => s.id)); + const before = new Set(current.skills); + if (skills.some((s) => !known.has(s) && !before.has(s))) throw new PackSaveError(400, 'unknown pack skills'); + const parsed = PackInput.strict().safeParse({ ...current, ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.description !== undefined ? { description: patch.description } : {}), skills }); + if (!parsed.success) throw new PackSaveError(400, 'invalid pack'); + try { parsePacks([parsed.data]); } + catch { throw new PackSaveError(400, 'invalid pack skill ids'); } + writePacks(packs.map((p) => (p.id === id ? parsed.data : p)), path); + return parsed.data; +} + +/** Deletes a personal pack. Backups are not touched: a scope saved under it stays restorable. */ +export function deletePack(id: string, path = join(dirname(configPath()), 'packs.json')): void { + if (isBundledPack(id)) throw new PackSaveError(400, 'a shipped pack cannot be deleted'); + const packs = readPersonalPacks(path); + if (!packs.some((p) => p.id === id)) throw new PackSaveError(404, 'unknown pack'); + writePacks(packs.filter((p) => p.id !== id), path); +} + +/** The catalog with its pack themes rebuilt from the packs as they are now (after a change). */ +export function refreshPacks(catalog: Catalog, packs: Pack[] = readPacks()): Catalog { + const skills = catalog.skills.map((s) => (s.themes.some((th) => th.startsWith('pack:')) ? { ...s, themes: s.themes.filter((th) => !th.startsWith('pack:')) } : s)); + const byTheme = Object.fromEntries(Object.entries(catalog.stats.byTheme).filter(([th]) => !th.startsWith('pack:'))); + return withPacks({ ...catalog, skills, themes: catalog.themes.filter((th) => !th.id.startsWith('pack:')), stats: { ...catalog.stats, byTheme } }, packs); +} + +/** + * Skills and themes with their pack themes rebuilt from `packs`: what the tree shows + * after a pack is created, changed or deleted, without reloading anything. + */ +export function packThemes>(skills: S[], themes: Theme[], packs: Pack[]): { skills: S[]; themes: Theme[] } { + const members = new Map(); + for (const p of packs) for (const id of p.skills) members.set(id.toLowerCase(), [...(members.get(id.toLowerCase()) ?? []), packTheme(p.id)]); + const out = skills.map((s) => { + const own = s.themes.filter((th) => !th.startsWith('pack:')); + const extra = members.get(s.id.toLowerCase()) ?? []; + return own.length === s.themes.length && !extra.length ? s : { ...s, themes: [...new Set([...own, ...extra])] }; + }); + const others = themes.filter((th) => !th.id.startsWith('pack:')); + const after = Math.max(...others.map((th, i) => (['installed', 'suggested', 'recommended', 'trending'].includes(th.id) ? i : -1))) + 1; + const list = packs.map((p) => ({ id: packTheme(p.id), label: `📦 ${p.name}`, description: p.description, pack: packStatus(p, skills as unknown as Skill[]) })); + return { skills: out, themes: [...others.slice(0, after), ...list, ...others.slice(after)] }; +} diff --git a/src/cli.tsx b/src/cli.tsx index f6c70ae..eecddb0 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -6,21 +6,22 @@ import { mergeInstalled } from './catalog/installed.js'; import { loadCatalog } from './catalog/load.js'; import { loadLatestCatalog } from './catalog/remote.js'; import { curated } from './catalog/curated.js'; -import { packStatus, readPacks } from './catalog/packs.js'; +import { deletePack, isBundledPack, packStatus, readPacks, refreshPacks, savePack, updatePack } from './catalog/packs.js'; import pkg from '../package.json' with { type: 'json' }; import { groupByTheme, popularityLabel, querySkills, resolveTheme, sortSkills, SORT_ORDERS, type QueryOptions, type SortOrder } from './catalog/query.js'; import { readConfig, writeConfig } from './config.js'; import { formatNumber, getLang, langFlag, LANGUAGES, normalizeLang, setLang, systemLang, t, themeLabel, type Lang } from './i18n/index.js'; import { formatCommand, parseSkillId, planInstall, planRemovals, planUpdates, type InstallCommand, type Update } from './install/plan.js'; import { nameCollisions } from './install/collisions.js'; -import { findUpdates } from './install/updates.js'; +import { checkUpdates } from './install/updates.js'; import { dropRevision, readHistory, recordBeforeUpdate, rememberRevision } from './install/history.js'; import { onPath } from './prereqs.js'; import { diagnoseInstalled } from './install/doctor.js'; import { isSuccess, listInstalled, runAll, type CommandOutcome } from './install/run.js'; import { SCOPES, type Catalog, type InstallMethod, type InstallOptions, type Removal, type Scope } from './types.js'; import { readFileSync } from 'node:fs'; -import { agentIds, installedSkills, outdatedSkills, showSkill, skillsByAgent } from './agent-api.js'; +import { resolve as resolvePath } from 'node:path'; +import { agentIds, installedSkills, outdatedSkills, projectSyncPlan, showSkill, skillsByAgent, suggestForProject, type SyncPlan } from './agent-api.js'; import { serveMcp } from './mcp.js'; import { complete, completionScript, type CompletionContext } from './completion.js'; import { auditEnabled, auditSkills, auditTarget, failsAudit, withLookalikes } from './audit.js'; @@ -31,13 +32,18 @@ import { fileSummary, updateDiff } from './install/diff.js'; import { browseLoop } from './browse.js'; import { describeInstalled, findOverlaps, OVERLAP_THRESHOLD } from './overlaps.js'; import { scanUsage, transcriptsDir, usageReport } from './usage.js'; +import { agentLoads, formatBytes, heavyLoads, STARTUP_BUDGET } from './context.js'; +import { scanInstalled } from './install/scan.js'; +import { withSuggestions } from './suggest.js'; +import { backupScope, listBackups, profilePlan, restoreBackup, type Backup } from './profiles.js'; +import { createInterface } from 'node:readline/promises'; import { missingSkills, missingSkillsByAgent, parseSelection, parseSelectionFile, toSelection } from './selection.js'; /** The skills a project expects, at its root, versioned with its code. */ const PROJECT_FILE = 'skills-atlas.json'; import { newerVersion } from './version.js'; import { App, type WizardResult } from './ui/App.js'; -import type { TreeMemory } from './ui/TreeSelect.js'; +import type { PackOps, TreeAction, TreeMemory } from './ui/TreeSelect.js'; import { Progress } from './ui/Progress.js'; import { colorDepth, logo } from './ui/logo.js'; import { TERMINAL_PALETTE } from './ui/palette.js'; @@ -124,6 +130,30 @@ function resolveLang(argv: string[]): Lang { } +/** The catalog of the interactive interfaces: first, the skills suggested for the current directory. */ +async function projectCatalog(opts: CommonOptions): Promise { + const catalog = await openCatalog(opts); + try { + const installed = (await Promise.all(SCOPES.map((sc) => listInstalled(sc)))).flat().map((s) => `${s.source ?? 'local'}@${s.name}`); + return withSuggestions(catalog, process.cwd(), installed); + } catch { + // Suggestions are a convenience: a project that cannot be read leaves the catalog as it is. + return catalog; + } +} + +/** A warning for each agent whose skill descriptions weigh on every session start (listed with `!` in the tree). */ +function contextWarnings(): string[] { + try { + // Agents reading the same folders load the same descriptions: one line for each load. + const byLoad = new Map(); + for (const l of heavyLoads(agentLoads(SCOPES.flatMap((sc) => scanInstalled(sc))))) byLoad.set(l.startup, [...(byLoad.get(l.startup) ?? []), l.agent]); + return [...byLoad].map(([startup, agents]) => c.heavyLoad(agents.join(', '), formatNumber(startup), formatNumber(STARTUP_BUDGET))); + } catch { + return []; + } +} + async function openCatalog(opts: CommonOptions): Promise { const checked = (catalog: Catalog) => { const failed = Object.entries(catalog.stats.sources ?? {}).filter(([, s]) => s.status === 'error').map(([name]) => name); @@ -186,9 +216,10 @@ async function apply(ids: string[], remove: Removal[], options: InstallOptions, for (const c of commands) console.log(formatCommand(c)); return ''; } - // The way back: the version each updated skill has now, for `rollback`. - if (update.length) { - const missed = await recordBeforeUpdate(update.map((u) => u.id)); + // The way back: the version each updated global skill has now, for `rollback`. + const globalUpdates = update.filter((u) => (u.scope ?? 'global') === 'global'); + if (globalUpdates.length) { + const missed = await recordBeforeUpdate(globalUpdates.map((u) => u.id)); if (missed.length) console.error(paint('dim', t().cli.rollbackMissed(missed.join(', ')))); } const config = readConfig(); @@ -325,8 +356,8 @@ filterOptions(installOptions(program.command('browse', { isDefault: true }))) .description(c.browse) .action(async (opts: FilterOptions & InstallFlags) => { if (!process.stdin.isTTY) fail(m.errors.needsTty); - const catalog = await openCatalog(opts); - const skills = querySkills(catalog, toQuery(catalog, opts)); + let catalog = await projectCatalog(opts); + let skills = querySkills(catalog, toQuery(catalog, opts)); if (!skills.length) fail(m.errors.noMatch); const preset = presetFromFlags(opts); const loadInstalled = async (scopes: Scope[]) => @@ -337,6 +368,9 @@ filterOptions(installOptions(program.command('browse', { isDefault: true }))) // Until a run has its own summary to show, the line above the tree tells of a new version. const versionNotice = newer ? paint('dim', c.newVersion(newer, pkg.version)) : ''; const wizard = async (notice: string) => { + // Packs may have changed since the last tree (its own windows, another terminal). + catalog = refreshPacks(catalog); + skills = querySkills(catalog, toQuery(catalog, opts)); const { defaults, detected } = resolveDefaults(); let result: WizardResult | null = null; const restoreScreen = opts.mouse ? enableMouse() : () => {}; @@ -353,9 +387,12 @@ filterOptions(installOptions(program.command('browse', { isDefault: true }))) search={opts.search} official={opts.official} notice={notice || versionNotice} + warnings={contextWarnings()} treeMemory={treeMemory} - findUpdates={opts.offline ? undefined : () => findUpdates()} + findUpdates={opts.offline ? undefined : (scopes) => checkUpdates(scopes)} audit={auditEnabled(readConfig(), opts.audit)} + packOps={packOps} + personalPacks={new Set(readPacks().filter((p) => !isBundledPack(p.id)).map((p) => p.id))} onDone={(r) => (result = r)} />, ); @@ -364,7 +401,33 @@ filterOptions(installOptions(program.command('browse', { isDefault: true }))) releaseStdin(); return result as WizardResult | null; }; - const runs = await browseLoop(wizard, (r) => apply(r.ids, r.remove, r.options, opts, r.update), opts); + /** Pack and backup operations of the tree's own windows: nothing is asked outside of it. */ + const packOps: PackOps = { + list: () => readPacks(), + create: ({ name, description, skills: ids }) => savePack({ id: packId(name), name, description, skills: ids }, catalog.skills), + update: (id, patch) => updatePack(id, patch, catalog.skills), + remove: (id) => deletePack(id), + backups: () => listBackups().map(({ dirs: _dirs, ...b }) => b), + restore: async (id) => { + const backup = listBackups().find((b) => b.id === id); + if (!backup) throw new Error(c.profileUnknownBackup(id)); + const undo = restoreBackup(id, await listInstalled(backup.scope)); + const config = readConfig(); + const profiles = { ...(config.profiles ?? {}) }; + delete profiles[backup.scope === 'global' ? 'global' : backup.cwd!]; + writeConfig({ ...config, profiles }); + return undo.id; + }, + plan: async (id, scope) => { + const pack = readPacks().find((p) => p.id === id); + if (!pack) throw new Error(c.unknownPacks(id)); + return profilePlan(pack, await listInstalled(scope)); + }, + }; + /** A profile confirmed in the tree: saved, then installed like any change; back to the tree with the summary. */ + const act = async (action: TreeAction): Promise => + action.kind === 'profile' ? useProfile(action.pack, { ...opts, yes: true }, action.scope) : ''; + const runs = await browseLoop(wizard, (r) => (r.action ? act(r.action) : apply(r.ids, r.remove, r.options, opts, r.update)), opts); if (!runs) console.log(paint('dim', m.run.noChange)); }); @@ -402,7 +465,7 @@ program .addOption(offlineOption()) .addOption(refreshOption()) .action(async (opts: CommonOptions & { port?: number; open: boolean; skillsVersion: string }) => { - const catalog = await openCatalog(opts); + const catalog = await projectCatalog(opts); const server = await startServer({ skills: querySkills(catalog, {}), catalogSkills: catalog.skills, @@ -587,15 +650,11 @@ installOptions(program.command('sync')) } const targets = presetFromFlags({ ...opts, agent: opts.agent ?? file.agents }).agents; if (targets && !targets.length) fail(m.errors.agentRequired); - // Check the requested destinations, even if another agent already has the skill. - const installed = await listInstalled('project'); - const groups: { agents: string[] | undefined; ids: string[] }[] = targets - ? missingSkillsByAgent(file.ids, installed, targets) - : [{ agents: undefined, ids: missingSkills(file.ids, installed.map((s) => `${s.source ?? 'local'}@${s.name}`)) }]; - // Pinned skills whose installed files drifted from their revision: reinstalled for every target. + // Check the requested destinations, even if another agent already has the skill; + // pinned skills whose installed files drifted from their revision are reinstalled for every target. + let groups: SyncPlan['groups'] = []; try { - const drift = (await projectRevisionDrift(file.ids, file.revisions ?? {}, installed)).filter((id) => !groups.some((g) => g.ids.includes(id))); - if (drift.length) groups.push({ agents: targets, ids: drift }); + groups = (await projectSyncPlan(file, targets)).groups; } catch (err) { fail((err as Error).message); } const missing = new Set(groups.flatMap((g) => g.ids)); if (!missing.size) return console.log(paint('dim', c.syncUpToDate(file.ids.length))); @@ -617,6 +676,309 @@ installOptions(program.command('sync')) } }); +/** Asks a yes/no question on the terminal; no is the default. */ +async function ask(question: string): Promise { + return /^(y|yes|o|oui|j|ja|s|si|sí|sim)$/i.test(await prompt(`${question} [y/N] `)); +} + +/** Asks for a line of text on the terminal; an empty answer keeps `fallback`. */ +async function prompt(question: string, fallback = ''): Promise { + // After the tree, stdin is paused and unref'd so that the process can end: a question + // must hold it again, or Node exits while waiting for the answer. + process.stdin.ref(); + process.stdin.resume(); + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = (await rl.question(fallback ? `${question} [${fallback}] ` : question)).trim(); + return answer || fallback; + } finally { + rl.close(); + releaseStdin(); + } +} + +/** The key a profile is remembered under: `global`, or the project's path. */ +const profileKey = (scope: Scope) => (scope === 'global' ? 'global' : resolvePath(process.cwd())); + +/** A pack id from its name: `My Docs` → `my-docs`. */ +const packId = (name: string) => name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'pack'; + +/** Catalog ids for the given ids (any case); the unknown ones are named on stderr and left out. */ +function catalogIds(catalog: Catalog, ids: string[]): string[] { + const byId = new Map(catalog.skills.map((s) => [s.id.toLowerCase(), s.id])); + const unknown = ids.filter((id) => !byId.has(id.toLowerCase())); + if (unknown.length) console.error(paint('yellow', c.profileUnknown(unknown.join(', ')))); + return [...new Set(ids.map((id) => byId.get(id.toLowerCase())).filter((id): id is string => !!id))]; +} + +/** Creates a personal pack; the name and description are asked for when not given. Returns its id, or undefined when nothing was saved. */ +async function createPack(catalog: Catalog, ids: string[], given: { name?: string; description?: string } = {}): Promise { + const skills = catalogIds(catalog, ids); + if (!skills.length) { + console.error(paint('yellow', c.packEmpty)); + return undefined; + } + const name = given.name ?? (process.stdin.isTTY ? await prompt(`${c.packNamePrompt} `) : ''); + if (!name) return void console.log(paint('dim', m.run.noChange)); + const description = given.description ?? (process.stdin.isTTY ? await prompt(`${c.packDescriptionPrompt} `, c.profileDefaultDescription(String(skills.length))) : c.profileDefaultDescription(String(skills.length))); + try { + const pack = savePack({ id: packId(name), name, description, skills }, catalog.skills); + console.log(paint('pink', `✓ ${c.profileCreated(pack.id, String(pack.skills.length))}`)); + return pack.id; + } catch (err) { + console.error(paint('red', `✗ ${(err as Error).message}`)); + return undefined; + } +} + +/** Names what went wrong, in red, and answers false. */ +function refuse(message: string): false { + console.error(paint('red', `✗ ${message}`)); + return false; +} + +/** Changes a personal pack: name and description asked for (Enter keeps them), skills when given. */ +async function editPack(catalog: Catalog, id: string, change: { name?: string; description?: string; skills?: string[] } = {}): Promise { + const pack = readPacks().find((p) => p.id === id); + if (!pack) return refuse(c.unknownPacks(id)); + if (isBundledPack(id)) return refuse(c.packShipped(id)); + const interactive = process.stdin.isTTY && change.name === undefined && change.description === undefined; + const name = change.name ?? (interactive ? await prompt(c.packNamePrompt, pack.name) : undefined); + const description = change.description ?? (interactive ? await prompt(c.packDescriptionPrompt, pack.description) : undefined); + try { + const saved = updatePack(id, { name, description, skills: change.skills }, catalog.skills); + console.log(paint('pink', `✓ ${c.packUpdated(saved.id, String(saved.skills.length))}`)); + return true; + } catch (err) { + console.error(paint('red', `✗ ${(err as Error).message}`)); + return false; + } +} + +/** Deletes a personal pack after a yes (or -y). */ +async function removePack(id: string, yes = false): Promise { + if (!readPacks().some((p) => p.id === id)) return refuse(c.unknownPacks(id)); + if (isBundledPack(id)) return refuse(c.packShipped(id)); + if (!yes && !(process.stdin.isTTY && (await ask(c.packDeleteConfirm(id))))) { + console.log(paint('dim', m.run.noChange)); + return false; + } + deletePack(id); + console.log(paint('pink', `✓ ${c.packDeleted(id)}`)); + return true; +} + +/** + * Activates a pack as a profile in one scope (asked for in a terminal when no flag + * says it): shows the plan, asks for a yes, saves the scope, then applies. + */ +async function useProfile(id: string, opts: InstallFlags, chosenScope?: Scope): Promise { + const pack = readPacks().find((p) => p.id === id); + if (!pack) { + console.error(paint('red', `✗ ${c.unknownPacks(id)}`)); + return ''; + } + const preset = presetFromFlags(opts); + const scope: Scope = chosenScope ?? preset.scope ?? 'global'; + const installed = await listInstalled(scope); + const plan = profilePlan(pack, installed); + console.log(paint('bold', c.profileTitle(pack.name, t().tree.where[scope]))); + console.log(paint('dim', ` ${c.profileKeep(String(plan.keep.length))}`)); + for (const r of plan.remove) console.log(` ${paint('red', '−')} ${r.id}`); + for (const i of plan.install) console.log(` ${paint('pink', '+')} ${i}`); + if (!plan.remove.length && !plan.install.length) { + console.log(paint('dim', m.run.noChange)); + return ''; + } + if (plan.remove.length) console.error(paint('yellow', `⚠ ${c.profileWarning(String(plan.remove.length))}`)); + const { defaults } = resolveDefaults(); + const agents = preset.agents ?? defaults.agents; + if (plan.install.length && !agents.length) { + console.error(paint('red', m.errors.agentRequired)); + process.exitCode = 1; + return ''; + } + const options: InstallOptions = { agents, scope, method: preset.method ?? defaults.method, skillsVersion: opts.skillsVersion }; + const removals = plan.remove.map((r) => ({ name: r.name, scopes: [scope] })); + if (opts.dryRun) return apply(plan.install, removals, options, opts); + if (!opts.yes) { + if (!process.stdin.isTTY) fail(c.profileNeedsYes); + if (!(await ask(c.profileConfirm))) { + console.log(paint('dim', m.run.noChange)); + return ''; + } + } + const install = await auditGate(plan.install, opts); + const backup = backupScope(scope, `profile:${pack.id}`, installed); + console.log(paint('dim', c.profileBackedUp(backup.id))); + const summary = await apply(install, removals, options, opts); + writeConfig({ ...readConfig(), profiles: { ...(readConfig().profiles ?? {}), [profileKey(scope)]: pack.id } }); + console.log(paint('dim', c.profileUndo(backup.id))); + return summary; +} + +/** Prints the backups of the given scopes, numbered from 1, newest first. */ +function printBackups(backups: Backup[]): void { + backups.forEach((b, i) => { + const tag = b.initial ? paint('pink', ` ${c.profileInitial}`) : ''; + console.log(` ${paint('dim', String(i + 1).padStart(2))} ${padEnd(b.id, 40)} ${padEnd(t().tree.where[b.scope], 8)} ${padEnd(String(b.skills.length), 4)} ${paint('dim', b.reason)}${tag}`); + }); +} + +/** Puts a scope back as a backup saved it, after a yes (or -y); returns whether it did. */ +async function restoreScope(backup: Backup, yes = false): Promise { + console.log(paint('bold', c.profileRestoreTitle(backup.id, t().tree.where[backup.scope], String(backup.skills.length)))); + for (const s of backup.skills) console.log(` ${s.source ?? 'local'}@${s.name}`); + if (!yes) { + if (!process.stdin.isTTY) fail(c.profileNeedsYes); + if (!(await ask(c.profileRestoreConfirm))) { + console.log(paint('dim', m.run.noChange)); + return false; + } + } + const undo = restoreBackup(backup.id, await listInstalled(backup.scope)); + const config = readConfig(); + const profiles = { ...(config.profiles ?? {}) }; + delete profiles[backup.scope === 'global' ? 'global' : backup.cwd!]; + writeConfig({ ...config, profiles }); + console.log(paint('pink', `✓ ${c.profileRestored(backup.id)}`)); + console.log(paint('dim', c.profileUndo(undo.id))); + return true; +} + +const profile = program.command('profile').description(c.profileCmd); + +profile + .command('list') + .description(c.profileListCmd) + .option('--json', c.json) + .action((opts: { json?: boolean }) => { + const packs = readPacks(); + const active = readConfig().profiles ?? {}; + const rows = packs.map((p) => ({ id: p.id, name: p.name, description: p.description, skills: p.skills.length, personal: !isBundledPack(p.id), active: SCOPES.filter((sc) => active[profileKey(sc)] === p.id) })); + if (opts.json) return void process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + for (const r of rows) { + const mark = r.personal ? paint('pink', '✎ ') : ' '; + console.log(`${mark}${padEnd(r.id, 24)} ${padEnd(r.name, 30)} ${paint('dim', `${r.skills}`)}${r.active.length ? paint('pink', ` ● ${r.active.map((sc) => t().tree.where[sc]).join(' + ')}`) : ''}`); + } + console.log(paint('dim', `\n${c.profileHint}`)); + }); + +profile + .command('create') + .argument('', c.profileName) + .argument('[ids...]', c.ids) + .option('--from-installed', c.profileFromInstalled) + .option('-g, --global', c.global) + .option('-p, --project', c.project) + .option('-d, --description ', c.profileDescription) + .addOption(catalogOption()) + .addOption(offlineOption()) + .description(c.profileCreateCmd) + .action(async (name: string, ids: string[], opts: CommonOptions & { fromInstalled?: boolean; global?: boolean; project?: boolean; description?: string }) => { + const catalog = await openCatalog({ ...opts, quiet: true }); + const wanted = [...ids]; + if (opts.fromInstalled) wanted.push(...(await installedSkills(flagScopes(opts))).filter((s) => s.source).map((s) => s.id)); + if (!(await createPack(catalog, wanted, { name, description: opts.description ?? c.profileDefaultDescription(String(catalogIds(catalog, wanted).length)) }))) process.exitCode = 1; + }); + +profile + .command('edit') + .argument('', c.profilePack) + .option('--name ', c.profileName) + .option('-d, --description ', c.profileDescription) + .option('--add ', c.packAddOption) + .option('--remove ', c.packRemoveOption) + .addOption(catalogOption()) + .addOption(offlineOption()) + .description(c.packEditCmd) + .action(async (id: string, opts: CommonOptions & { name?: string; description?: string; add?: string[]; remove?: string[] }) => { + const catalog = await openCatalog({ ...opts, quiet: true }); + const pack = readPacks().find((p) => p.id === id); + let skills: string[] | undefined; + if (pack && (opts.add || opts.remove)) { + const drop = new Set((opts.remove ?? []).map((x) => x.toLowerCase())); + skills = [...pack.skills.filter((s) => !drop.has(s.toLowerCase())), ...catalogIds(catalog, opts.add ?? [])]; + } + const named = opts.name !== undefined || opts.description !== undefined || skills !== undefined; + if (!(await editPack(catalog, id, named ? { name: opts.name ?? pack?.name, description: opts.description ?? pack?.description, skills } : {}))) process.exitCode = 1; + }); + +profile + .command('delete') + .argument('', c.profilePack) + .option('-y, --yes', c.yes) + .description(c.packDeleteCmd) + .action(async (id: string, opts: { yes?: boolean }) => { + if (!(await removePack(id, opts.yes))) process.exitCode = 1; + }); + +installOptions(profile.command('use')) + .argument('', c.profilePack) + .description(c.profileUseCmd) + .action(async (id: string, opts: InstallFlags) => { + if (!readPacks().some((p) => p.id === id)) fail(c.unknownPacks(id)); + await useProfile(id, opts); + }); + +profile + .command('backups') + .description(c.profileBackupsCmd) + .option('-g, --global', c.global) + .option('-p, --project', c.project) + .option('--json', c.json) + .action((opts: { global?: boolean; project?: boolean; json?: boolean }) => { + const scopes = flagScopes(opts); + const backups = listBackups().filter((b) => scopes.includes(b.scope)); + if (opts.json) return void process.stdout.write(`${JSON.stringify(backups.map(({ dirs: _dirs, ...b }) => b), null, 2)}\n`); + if (!backups.length) return console.log(paint('dim', c.profileNoBackup)); + printBackups(backups); + console.log(paint('dim', `\n${c.profileRestoreHint}`)); + }); + +profile + .command('restore') + .argument('[backup]', c.profileBackupArg) + .option('--initial', c.profileInitialOption) + .option('-g, --global', c.global) + .option('-p, --project', c.project) + .option('-y, --yes', c.yes) + .description(c.profileRestoreCmd) + .action(async (id: string | undefined, opts: { initial?: boolean; global?: boolean; project?: boolean; yes?: boolean }) => { + const backups = listBackups().filter((b) => flagScopes(opts).includes(b.scope)); + const backup = id ? backups.find((b) => b.id === id) : opts.initial ? backups.find((b) => b.initial) : backups[0]; + if (!backup) fail(id ? c.profileUnknownBackup(id) : c.profileNoBackup); + await restoreScope(backup!, opts.yes); + }); + +program + .command('suggest') + .argument('[dir]', c.suggestDir, '.') + .description(c.suggestCmd) + .option('-n, --limit ', c.suggestLimit, (v) => Math.max(1, Math.min(10, Number.parseInt(v, 10) || 3)), 3) + .addOption(catalogOption()) + .addOption(offlineOption()) + .option('--json', c.json) + .action(async (dir: string, opts: CommonOptions & { json?: boolean; limit: number }) => { + const catalog = await openCatalog({ ...opts, quiet: true }); + const found = await suggestForProject(catalog, resolvePath(dir), { perStack: opts.limit }); + if (opts.json) return void process.stdout.write(`${JSON.stringify(found, null, 2)}\n`); + if (!found.stack.length) return console.log(paint('dim', c.suggestNothing)); + const ids: string[] = []; + for (const s of found.stack) { + console.log(`\n${paint('bold', s.label)} ${paint('dim', `· ${s.evidence.join(', ')}`)}`); + if (!s.skills.length) console.log(paint('dim', ` ${c.suggestNone}`)); + for (const k of s.skills) { + ids.push(k.id); + const tag = k.official ? paint('pink', ' ◆') : ''; + console.log(` ${padEnd(k.id, 58)}${tag} ${paint('dim', k.installs ? formatNumber(k.installs) : '')}`); + if (k.description) console.log(paint('dim', ` ${k.description.length > 100 ? `${k.description.slice(0, 99)}…` : k.description}`)); + } + } + if (found.packs.length) console.log(`\n${paint('dim', c.suggestPacks(found.packs.join(', ')))}`); + if (ids.length) console.log(paint('dim', `\n${c.suggestHint}\n skills-atlas install ${ids.join(' ')} -p`)); + }); + program .command('packs') .description(c.packsCmd) @@ -775,18 +1137,29 @@ program .option('-g, --global', c.global) .option('-p, --project', c.project) .option('-a, --agent ', c.agent) + .option('--size', c.sizeOption) .option('--json', c.json) - .action(async (opts: { global?: boolean; project?: boolean; agent?: string[]; json?: boolean }) => { + .action(async (opts: { global?: boolean; project?: boolean; agent?: string[]; json?: boolean; size?: boolean }) => { const scopes: Scope[] = opts.global && !opts.project ? ['global'] : opts.project && !opts.global ? ['project'] : SCOPES; const only = opts.agent?.flatMap((a) => a.split(',')).map((a) => a.trim()).filter(Boolean); const unknown = only ? unknownAgents(only) : []; if (unknown.length) fail(m.errors.unknownAgents(unknown.join(', '))); - const groups = skillsByAgent(await installedSkills(scopes)).filter((g) => !only || only.includes(g.agent)); - if (opts.json) return void process.stdout.write(`${JSON.stringify(groups, null, 2)}\n`); + const groups = skillsByAgent(await installedSkills(scopes, { cost: opts.size })).filter((g) => !only || only.includes(g.agent)); + // Each agent's startup load: the descriptions of every skill it sees. + const sized = groups.map((g) => ({ ...g, ...(opts.size ? { startup_tokens: g.skills.reduce((n, s) => n + (s.cost?.startup ?? 0), 0) } : {}) })); + if (opts.json) return void process.stdout.write(`${JSON.stringify(sized, null, 2)}\n`); if (!groups.length) return console.log(paint('dim', c.noneInstalled)); - for (const g of groups) { - console.log(`\n${paint('bold', g.name)} ${paint('dim', `${g.agent} · ${formatNumber(g.skills.length)}`)}`); - for (const s of g.skills) console.log(` ${padEnd(s.name, 34)} ${paint('dim', `${padEnd(s.source ?? 'local', 36)} ${t().tree.where[s.scope]}`)}`); + for (const g of sized) { + const load = g.startup_tokens === undefined ? '' : ` · ${c.startupLoad(formatNumber(g.startup_tokens))}`; + console.log(`\n${paint('bold', g.name)} ${paint(g.startup_tokens !== undefined && g.startup_tokens > STARTUP_BUDGET ? 'yellow' : 'dim', `${g.agent} · ${formatNumber(g.skills.length)}${load}`)}`); + for (const s of g.skills) { + const size = s.cost ? ` ${padEnd(`≈${formatNumber(s.cost.startup)} / ${formatNumber(s.cost.onUse)} tok`, 22)} ${formatBytes(s.cost.bytes ?? 0)}` : ''; + console.log(` ${padEnd(s.name, 34)} ${paint('dim', `${padEnd(s.source ?? 'local', 36)} ${padEnd(t().tree.where[s.scope], 8)}${size}`)}`); + } + } + if (opts.size) { + console.log(paint('dim', `\n${c.sizeLegend}`)); + for (const g of sized.filter((x) => (x.startup_tokens ?? 0) > STARTUP_BUDGET)) console.log(paint('yellow', `⚠ ${c.heavyLoad(g.name, formatNumber(g.startup_tokens!), formatNumber(STARTUP_BUDGET))}`)); } }); @@ -815,8 +1188,9 @@ program .action(async (id: string, opts: CommonOptions & { json?: boolean }) => { const catalog = await openCatalog({ ...opts, quiet: true }); try { - const { skill, markdown, risks } = await showSkill(catalog, id); - if (opts.json) return void process.stdout.write(`${JSON.stringify({ skill, risks, markdown }, null, 2)}\n`); + const { skill, markdown, risks, cost } = await showSkill(catalog, id); + if (opts.json) return void process.stdout.write(`${JSON.stringify({ skill, risks, cost, markdown }, null, 2)}\n`); + console.error(paint('dim', m.tree.cost(formatNumber(cost.startup), formatNumber(cost.onUse)))); // The warning signs go to stderr: stdout stays the SKILL.md alone, to pipe or save. const tree = m.tree; console.error(risks.length ? paint('yellow', `⚠ ${tree.risksFound(risks.length)}`) : paint('dim', `✓ ${tree.noRisk}`)); @@ -827,44 +1201,51 @@ program } }); -/** Names, on stderr, what an update check could not check, and sets exit code 1. */ -function reportUnchecked(unchecked: { source: string; reason: string }[]): void { +/** Names, on stderr, what an update check could not check (exit code 1), and the project skills edited by hand. */ +function reportUnchecked(unchecked: { source: string; reason: string }[], modified: string[] = []): void { + if (modified.length) console.error(paint('dim', c.updatesModified(modified.join(', ')))); if (!unchecked.length) return; process.exitCode = 1; console.error(paint('yellow', c.updatesUnchecked(unchecked.length))); for (const u of unchecked) console.error(paint('dim', ` ? ${u.source}: ${u.reason}`)); } +/** `-g` or `-p` alone limits a command to that scope; neither, or both, means both. */ +function flagScopes(opts: { global?: boolean; project?: boolean }): Scope[] { + return opts.global && !opts.project ? ['global'] : opts.project && !opts.global ? ['project'] : SCOPES; +} + installOptions(program.command('update')) .argument('[ids...]', c.ids) .description(c.updateCmd) .action(async (ids: string[], opts: InstallFlags) => { - const { skills: outdated, unchecked } = await outdatedSkills(); - reportUnchecked(unchecked); + const { skills: outdated, unchecked, modified } = await outdatedSkills(flagScopes(opts)); + reportUnchecked(unchecked, modified); const wanted = ids.map((id) => id.toLowerCase()); // Named skills are updated only if they are outdated: a reinstall of an up-to-date one would change nothing. const chosen = ids.length ? outdated.filter((s) => wanted.includes(s.id.toLowerCase())) : outdated; if (!chosen.length) return unchecked.length ? undefined : console.log(paint('dim', c.upToDate)); // What each update changes, and the warning signs it brings, before anything runs. - const local = await listInstalled('global'); + const local = (await Promise.all([...new Set(chosen.map((s) => s.scope))].map((sc) => listInstalled(sc)))).flat(); const catalog = await loadLatestCatalog({ offline: true }).catch(() => undefined); const known = new Map(catalog?.skills.map((s) => [s.id.toLowerCase(), s])); for (const s of chosen) { - const diff = await updateDiff(auditTarget(s.id, known), local).catch(() => undefined); - console.log(` ${paint('pink', '↑')} ${s.id}${diff ? paint('dim', ` +${diff.added} −${diff.removed}`) : ''}`); + const diff = await updateDiff(auditTarget(s.id, known), local.filter((l) => l.scope === s.scope)).catch(() => undefined); + const where = s.scope === 'project' ? paint('dim', ` (${m.scope.project})`) : ''; + console.log(` ${paint('pink', '↑')} ${s.id}${where}${diff ? paint('dim', ` +${diff.added} −${diff.removed}`) : ''}`); for (const f of diff?.files ?? []) console.log(paint('dim', ` ${fileSummary(f)}`)); for (const x of diff?.risks.filter((x) => x.level !== 'info') ?? []) { console.log(paint(x.level === 'high' ? 'red' : 'yellow', ` ${m.tree.riskLevels[x.level]} · ${m.tree.risks[x.rule]} ${riskWhere(x)} ${x.excerpt}`)); } } // A new version is new content: it is audited like an install. - const passed = new Set(await auditGate(chosen.map((s) => s.id), opts)); + const passed = new Set(await auditGate([...new Set(chosen.map((s) => s.id))], opts)); chosen.splice(0, chosen.length, ...chosen.filter((s) => passed.has(s.id))); if (!chosen.length) return console.log(paint('dim', m.run.noChange)); const { defaults } = resolveDefaults(); // Each skill keeps its own agents; these options only fill in what a skill's scan did not name. const options: InstallOptions = { agents: defaults.agents, scope: 'global', method: opts.copy ? 'copy' : opts.symlink ? 'symlink' : defaults.method, skillsVersion: opts.skillsVersion }; - await apply([], [], options, opts, chosen.map((s) => ({ id: s.id, agents: s.agents }))); + await apply([], [], options, opts, chosen.map((s) => ({ id: s.id, agents: s.agents, scope: s.scope }))); // The confirmed updates ran; the rest could not be checked, so this is not a full success. if (unchecked.length) console.error(paint('yellow', c.updatesUnchecked(unchecked.length))); }); @@ -904,14 +1285,16 @@ installOptions(program.command('rollback')) program .command('outdated') .description(c.outdatedCmd) + .option('-g, --global', c.global) + .option('-p, --project', c.project) .option('--json', c.json) - .action(async (opts: { json?: boolean }) => { - const { skills: outdated, unchecked } = await outdatedSkills(); + .action(async (opts: { json?: boolean; global?: boolean; project?: boolean }) => { + const { skills: outdated, unchecked, modified } = await outdatedSkills(flagScopes(opts)); // stdout keeps the array scripts read; what could not be checked goes to stderr, with exit code 1. - reportUnchecked(unchecked); + reportUnchecked(unchecked, modified); if (opts.json) return void process.stdout.write(`${JSON.stringify(outdated, null, 2)}\n`); if (!outdated.length) return unchecked.length ? undefined : console.log(paint('dim', c.upToDate)); - for (const s of outdated) console.log(` ${padEnd(s.id, 60)} ${paint('dim', s.agents.join(', '))}`); + for (const s of outdated) console.log(` ${padEnd(s.id, 60)} ${padEnd(s.scope === 'project' ? m.scope.project : m.scope.global, 8)} ${paint('dim', s.agents.join(', '))}`); console.log(paint('dim', `\n${c.outdatedHint}`)); console.log(paint('dim', c.updateAllHint)); }); diff --git a/src/config.ts b/src/config.ts index ba0e695..a28b3e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ export interface UserConfig { lang?: string; /** `false` (set with `skills-atlas audit off`): install without the security audit. */ audit?: boolean; + /** The profile last activated: under `global`, or under a project's path. */ + profiles?: Record; } export function configPath(): string { diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..0543a5e --- /dev/null +++ b/src/context.ts @@ -0,0 +1,104 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { frontmatterDescription } from './catalog/normalize.js'; +import { frontmatterName } from './install/scan.js'; +import type { InstalledSkill } from './install/run.js'; + +/** + * What a skill costs in an agent's context. Agents load the name and description + * of every installed skill when a session starts, to know when to use one; the whole + * SKILL.md enters the context only when the skill is used, and its other files only + * when the SKILL.md asks for them. Tokens are estimated, not counted with a tokenizer: + * about 4 characters per token for Latin text, about one per character for CJK. + */ + +/** Estimated tokens of skill descriptions per agent above which the startup load is called heavy. */ +export const STARTUP_BUDGET = 5_000; + +export function estimateTokens(text: string): number { + let wide = 0; + for (const ch of text) if (ch.codePointAt(0)! > 0x2e7f) wide++; + return Math.ceil((text.length - wide) / 4 + wide); +} + +export interface SkillCost { + /** Loaded at every session start: name and description. */ + startup: number; + /** Loaded when the skill is used: the whole SKILL.md. */ + onUse: number; + /** Files and bytes of the skill folder (SKILL.md included), when it is on disk. */ + files?: number; + bytes?: number; +} + +/** Startup and on-use cost of a SKILL.md. */ +export function markdownCost(markdown: string, fallbackName = ''): SkillCost { + const name = frontmatterName(markdown) ?? fallbackName; + // 1,024 characters: the longest description the Agent Skills spec allows. + const description = frontmatterDescription(markdown, 1024) ?? ''; + return { startup: estimateTokens(`${name}: ${description}`), onUse: estimateTokens(markdown) }; +} + +/** The cost of an installed skill folder: its SKILL.md, plus the files and bytes it holds. */ +export function folderCost(path: string, name = ''): SkillCost | undefined { + let markdown: string; + try { + markdown = readFileSync(join(path, 'SKILL.md'), 'utf8'); + } catch { + return undefined; + } + let files = 0; + let bytes = 0; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name !== '.git' && entry.name !== 'node_modules') walk(join(dir, entry.name)); + } else if (entry.isFile()) { + files++; + bytes += statSync(join(dir, entry.name)).size; + } + } + }; + try { + walk(path); + } catch { + // An unreadable subfolder: the counts stay partial, the SKILL.md cost is still right. + } + return { ...markdownCost(markdown, name), files, bytes }; +} + +/** One agent's startup load: the skills it sees and the description tokens they add to every session. */ +export interface AgentLoad { + /** Agent display name, as the scan reports it. */ + agent: string; + skills: number; + startup: number; +} + +/** + * The startup load of each agent: the descriptions of every skill it sees, project and + * global together (an agent started in this project loads both). Heaviest first. + */ +export function agentLoads(installed: InstalledSkill[], cost: (s: InstalledSkill) => SkillCost | undefined = (s) => (s.path ? folderCost(s.path, s.name) : undefined)): AgentLoad[] { + const loads = new Map(); + for (const s of installed) { + const c = cost(s); + for (const agent of s.agents) { + const load = loads.get(agent) ?? { agent, skills: 0, startup: 0 }; + load.skills++; + load.startup += c?.startup ?? 0; + loads.set(agent, load); + } + } + return [...loads.values()].sort((a, b) => b.startup - a.startup || a.agent.localeCompare(b.agent)); +} + +/** The agents whose startup load is above the budget. */ +export const heavyLoads = (loads: AgentLoad[], budget = STARTUP_BUDGET): AgentLoad[] => loads.filter((l) => l.startup > budget); + +/** `12.3 KB`, `840 B`. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 2c465cc..1dc1aa7 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -37,6 +37,21 @@ export const de: Messages = { clear: 'leeren', update: 'aktualisieren', updateAll: "alle aktualisieren", + nextField: "nächstes Feld", + save: "speichern", + yes: "ja", + no: "nein", + restore: "wiederherstellen", + scope: "Bereich", + profileApply: "sichern und installieren", + warnings: (n) => `Warnungen (${n})`, + newPack: "neues Pack", + editPack: "Pack bearbeiten", + deletePack: "Pack löschen", + useProfile: "als Profil nutzen", + backups: "Sicherungen", + savePack: "Pack speichern", + cancelEdit: "abbrechen", preview: 'Vorschau', }, @@ -86,6 +101,17 @@ export const de: Messages = { noNewRisk: 'Kein neues Warnsignal seit Ihrer Version', noRisk: "Kein Warnsignal gefunden (Musterprüfung, keine Garantie)", growth: (n) => `+${n} Installationen seit dem letzten Stand`, + andMore: (n) => `… und ${n} weitere`, + fieldRequired: (label) => `${label}: erforderlich`, + packNameField: "Name", + packDescriptionField: "Beschreibung", + packNewTitle: (n) => `Neues Pack aus den ${n} angekreuzten Skill(s)`, + packEditTitle: (label, n) => `${label}: ${n} Skill(s)`, + restoring: "Wiederherstellung…", + warningsTitle: (n) => `Warnungen (${n})`, + editingPack: (label, n) => `${label} bearbeiten: seine Skills ankreuzen (${n})`, + cost: (a, b) => `≈ ${a} Tokens bei jedem Sitzungsstart (Beschreibung) · ≈ ${b} bei Nutzung (SKILL.md)`, + suggestedFor: (x) => `für ${x} in diesem Projekt`, needs: 'braucht', updated: (date) => `geändert am ${date}`, growthSuspect: (n) => `nicht in Trends: dieselben Skills wachsen gleichzeitig in ${n} weiteren Repositories`, @@ -127,7 +153,7 @@ export const de: Messages = { auditUnread: "nicht geprüft: SKILL.md nicht lesbar", auditPassed: (n) => `${n} ${n === 1 ? 'Skill besteht' : 'Skills bestehen'} die Sicherheitsprüfung`, auditToggle: "trotzdem installieren", - update: (n) => `${n} ${n === 1 ? 'Skill' : 'Skills'} aktualisieren (global)`, + update: (n) => `${n} ${n === 1 ? 'Skill' : 'Skills'} aktualisieren`, }, progress: { @@ -177,6 +203,28 @@ export const de: Messages = { serving: 'Skills Atlas ist geöffnet unter', stop: 'Mit Strg+C beenden.', needAgent: 'Wähle mindestens einen Agenten.', + packEdit: "Dieses Pack bearbeiten…", + packDelete: "Dieses Pack löschen", + packEditTitle: "Pack {id} bearbeiten", + packMembers: "Skills ({n}): abwählen zum Entfernen", + packAddSelection: "Die {n} Skill(s) der aktuellen Auswahl hinzufügen", + packSaveChanges: "Änderungen speichern", + packDeleteConfirm: "Pack {id} löschen? Seine Sicherungen bleiben wiederherstellbar.", + profileUse: "Dieses Pack als Profil verwenden…", + profileTitle: "Profil: {name}", + profileKeep: "{n} bereits installierte(r) Skill(s) des Packs bleiben.", + profileWarning: "{n} andere(r) Skill(s) werden aus diesem Bereich entfernt, selbst erstellte eingeschlossen. Sie werden zuerst gesichert und lassen sich exakt wiederherstellen.", + profileActivate: "Sichern, dann aktivieren", + profileNothing: "Nichts zu ändern", + profileUndo: "Sicherung {id} vor der Änderung gespeichert: unter Sicherungen wiederherstellen.", + backupsOpen: "Sicherungen ({n})", + backupsTitle: "Sicherungen", + backupsHint: "Jeder Profilwechsel sichert zuerst den Bereich. Wiederherstellen setzt ihn exakt zurück und sichert vorher den aktuellen Zustand.", + backupSkills: "{n} Skills", + backupInitial: "ursprünglich", + backupRestore: "Wiederherstellen", + backupRestoreConfirm: "Die Skills dieses Bereichs durch diese Sicherung ersetzen? Der aktuelle Zustand wird zuerst gesichert.", + backupRestored: "{id} wiederhergestellt. Zum Rückgängigmachen {undo} wiederherstellen.", busy: 'Es läuft bereits eine Installation.', tagline: 'Alle öffentlichen Agent Skills, nach Thema: auswählen, installieren, deinstallieren.', searchPlaceholder: 'Nach Name, Repository oder Beschreibung suchen', @@ -301,12 +349,68 @@ export const de: Messages = { nothingToInstall: "Skill-IDs oder --pack angeben", completionShell: (shell) => `unbekannte Shell „${shell}“: bash, zsh, fish oder powershell`, noneInstalled: "Kein Skill installiert.", - outdatedCmd: "listet globale Skills mit neuerer Version auf GitHub", + outdatedCmd: "listet installierte Skills (Projekt und global) mit neuerer Version auf GitHub", showFailed: (id, msg) => `SKILL.md von ${id} nicht lesbar: ${msg}`, upToDate: "Alle globalen Skills sind aktuell.", updatesUnchecked: (n) => `Update-Prüfung unvollständig: ${n} Repository-Prüfung(en) fehlgeschlagen, diese Skills sind womöglich veraltet.`, + packEmpty: "Zuerst Katalog-Skills ankreuzen: ein Pack enthält von GitHub installierbare Skills.", + packNamePrompt: "Pack-Name:", + packDescriptionPrompt: "Beschreibung:", + packShipped: (id) => `${id} ist ein mitgeliefertes Pack: es kann weder geändert noch gelöscht werden (ein eigenes mit N im Baum oder skills-atlas profile create anlegen).`, + packUpdated: (id, n) => `Pack ${id} gespeichert (${n} Skills)`, + packDeleteConfirm: (id) => `Pack ${id} löschen? Seine Sicherungen bleiben wiederherstellbar.`, + packDeleted: (id) => `Pack ${id} gelöscht`, + profileScopePrompt: "Bereich: [g]lobal oder [p]rojekt?", + backupPickPrompt: "Nummer der wiederherzustellenden Sicherung (Enter bricht ab):", + packAddOption: "hinzuzufügende Skills (owner/repo@name)", + packRemoveOption: "zu entfernende Skills", + packEditCmd: "ändert ein persönliches Pack: Name, Beschreibung, Skills (ohne Option abgefragt)", + packDeleteCmd: "löscht ein persönliches Pack (seine Sicherungen bleiben wiederherstellbar)", + backToTree: "Enter: zurück zum Baum", + profileCmd: "Profile: ein Pack allein aktivieren (alle anderen Skills des Bereichs kommen in eine Sicherung) und Sicherungen wiederherstellen", + profileListCmd: "listet die als Profil nutzbaren Packs und das aktive", + profileHint: "skills-atlas profile use aktiviert eines (-p für dieses Projekt); skills-atlas profile restore stellt alles wieder her.", + profileCreateCmd: "erstellt ein persönliches Pack (ein Profil) aus Skill-IDs oder aus dem Installierten", + profileName: "Profilname, z. B. \"Design\"", + profileFromInstalled: "fügt die installierten Skills mit GitHub-Quelle hinzu (-g/-p für einen Bereich)", + profileDescription: "wofür das Profil gedacht ist", + profileUnknown: (ids) => `nicht im Katalog, ausgelassen: ${ids}`, + profileDefaultDescription: (n) => `${n} Skills`, + profileCreated: (id, n) => `Pack ${id} mit ${n} Skills erstellt: skills-atlas profile use ${id}`, + profileUseCmd: "aktiviert ein Pack allein: behält seine Skills, installiert fehlende, entfernt alle anderen Skills des Bereichs (nach einer Sicherung)", + profilePack: "Pack-ID (siehe skills-atlas profile list)", + profileTitle: (name, scope) => `Profil ${name} (${scope})`, + profileKeep: (n) => `${n} Skill(s) des Packs bereits installiert, behalten`, + profileWarning: (n) => `${n} andere(r) Skill(s) werden aus diesem Bereich entfernt, selbst erstellte eingeschlossen. Sie werden zuerst gesichert; skills-atlas profile restore stellt sie exakt wieder her.`, + profileNeedsYes: "dies ändert installierte Skills: zum Bestätigen in einem Terminal ausführen oder -y angeben", + profileConfirm: "Diesen Bereich sichern und dann das Profil anwenden?", + profileBackedUp: (id) => `Sicherung gespeichert: ${id}`, + profileUndo: (id) => `Zum Zurückkehren: skills-atlas profile restore ${id}`, + profileBackupsCmd: "listet die vor jedem Profilwechsel erstellten Sicherungen", + profileNoBackup: "Noch keine Sicherung: vor jedem Profilwechsel wird eine erstellt.", + profileInitial: "ursprünglich", + profileRestoreHint: "skills-atlas profile restore (oder --initial) stellt einen Bereich exakt wieder her.", + profileBackupArg: "Sicherungs-ID (Standard: die neueste)", + profileInitialOption: "stellt die erste Sicherung wieder her: die Konfiguration vor jedem Profil", + profileRestoreCmd: "stellt einen Bereich exakt so wieder her, wie eine Sicherung ihn gespeichert hat (der aktuelle Zustand wird zuerst gesichert)", + profileUnknownBackup: (id) => `keine Sicherung ${id} (siehe skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `${id} wiederherstellen (${scope}, ${n} Skills):`, + profileRestoreConfirm: "Die Skills dieses Bereichs durch diese Sicherung ersetzen?", + profileRestored: (id) => `${id} wiederhergestellt`, + sizeOption: "zeigt, was jeder Skill im Kontext des Agenten (geschätzte Tokens) und auf der Festplatte kostet", + startupLoad: (n) => `≈ ${n} Tokens beim Start`, + sizeLegend: "tok: geschätzte Tokens, geladen bei jedem Sitzungsstart (Name und Beschreibung) / bei Nutzung des Skills (SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent}: ≈ ${n} Tokens an Skill-Beschreibungen bei jedem Sitzungsstart (über ${budget}). Ungenutzte Skills abwählen.`, + updatesModified: (list) => `Von Hand geändert, nicht aktualisiert: ${list}`, + suggestCmd: "schlägt Skills für ein Projekt vor, je nachdem womit es gebaut ist (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "Projektverzeichnis (Standard: das aktuelle)", + suggestLimit: "Skills pro Technologie (1 bis 10, Standard 3)", + suggestNothing: "Keine bekannte Technologie in diesem Projekt gefunden.", + suggestNone: "nichts Neues im Katalog", + suggestPacks: (list) => `Passende Packs: ${list} (skills-atlas install --pack )`, + suggestHint: "Mit skills-atlas show lesen, dann die gewünschten installieren:", outdatedHint: "Zum Aktualisieren neu installieren: skills-atlas install -g -a -y (oder u im Baum).", - updateCmd: "aktualisiert veraltete globale Skills: alle oder die genannten", + updateCmd: "aktualisiert veraltete Skills, Projekt und global: alle oder die genannten", updateAllHint: "Alle aktualisieren: skills-atlas update", rollbackCmd: 'installiert die Version neu, die ein globaler Skill vor seinem letzten Update hatte', rollbackList: 'zeigt die gespeicherten Versionen', @@ -365,6 +469,7 @@ export const de: Messages = { themes: { recommended: 'Empfohlen', trending: 'Im Trend', + suggested: 'Für dieses Projekt', installed: 'Bereits installiert', frontend: 'Frontend & Web', backend: 'Backend & APIs', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2cb074f..e4c67c3 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -34,6 +34,21 @@ export const en = { clear: 'clear', update: 'update', updateAll: "update all", + nextField: "next field", + save: "save", + yes: "yes", + no: "no", + restore: "restore", + scope: "scope", + profileApply: "save and install", + warnings: (n: number) => `warnings (${n})`, + newPack: "new pack", + editPack: "edit pack", + deletePack: "delete pack", + useProfile: "use as profile", + backups: "backups", + savePack: "save pack", + cancelEdit: "cancel", preview: 'preview', }, @@ -83,6 +98,17 @@ export const en = { noNewRisk: 'No new warning sign since your version', noRisk: "No warning sign found (a pattern check, not a guarantee)", growth: (n: string) => `+${n} installs since the last snapshot`, + andMore: (n: string) => `… and ${n} more`, + fieldRequired: (label: string) => `${label}: required`, + packNameField: "Name", + packDescriptionField: "Description", + packNewTitle: (n: string) => `New pack from the ${n} ticked skill(s)`, + packEditTitle: (label: string, n: string) => `${label}: ${n} skill(s)`, + restoring: "Restoring…", + warningsTitle: (n: string) => `Warnings (${n})`, + editingPack: (label: string, n: string) => `Editing ${label}: tick its skills (${n})`, + cost: (a: string, b: string) => `≈ ${a} tokens at every session start (description) · ≈ ${b} when used (SKILL.md)`, + suggestedFor: (x: string) => `for ${x} in this project`, needs: 'needs', updated: (date: string) => `updated ${date}`, growthSuspect: (n: number) => `not in Trending: the same skills grow together in ${n} other repositories`, @@ -124,7 +150,7 @@ export const en = { auditUnread: "not audited: its SKILL.md could not be read", auditPassed: (n: number) => `${n} skill${n === 1 ? ' passes' : 's pass'} the security audit`, auditToggle: "install anyway", - update: (n: number) => `Update ${n} skill${n === 1 ? '' : 's'} to the latest version (global)`, + update: (n: number) => `Update ${n} skill${n === 1 ? '' : 's'} to the latest version`, }, progress: { @@ -175,6 +201,28 @@ export const en = { serving: 'Skills Atlas is open at', stop: 'Press Ctrl+C to stop.', needAgent: 'Pick at least one agent.', + packEdit: "Edit this pack…", + packDelete: "Delete this pack", + packEditTitle: "Edit pack {id}", + packMembers: "Skills ({n}): untick to take one out", + packAddSelection: "Add the {n} skill(s) of the current selection", + packSaveChanges: "Save changes", + packDeleteConfirm: "Delete the pack {id}? Its backups stay restorable.", + profileUse: "Use this pack as a profile…", + profileTitle: "Profile: {name}", + profileKeep: "{n} skill(s) of the pack already installed are kept.", + profileWarning: "{n} other skill(s) will be removed from this scope, home-made ones included. They are saved first and can be restored exactly.", + profileActivate: "Save, then activate", + profileNothing: "Nothing to change", + profileUndo: "Backup {id} saved before the change: restore it from Backups.", + backupsOpen: "Backups ({n})", + backupsTitle: "Backups", + backupsHint: "Each profile change saves the scope first. Restoring puts it back exactly, and saves the current state before.", + backupSkills: "{n} skills", + backupInitial: "initial", + backupRestore: "Restore", + backupRestoreConfirm: "Replace the skills of this scope with this backup? The current state is saved first.", + backupRestored: "Restored {id}. To undo, restore {undo}.", busy: 'An installation is already running.', tagline: 'Every public Agent Skill, by theme — pick, install, uninstall.', searchPlaceholder: 'Search by name, repository or description', @@ -299,12 +347,68 @@ export const en = { nothingToInstall: "give skill ids or --pack ", completionShell: (shell: string) => `unknown shell "${shell}": bash, zsh, fish or powershell`, noneInstalled: "No skill installed.", - outdatedCmd: "list global skills with a newer version on GitHub", + outdatedCmd: "list installed skills (project and global) with a newer version on GitHub", showFailed: (id: string, msg: string) => `Cannot read the SKILL.md of ${id}: ${msg}`, upToDate: "Every global skill is up to date.", updatesUnchecked: (n: number) => `Update check incomplete: ${n} repository check(s) failed, so those skills may be outdated.`, + packEmpty: "Tick catalog skills first: a pack holds skills that can be installed from GitHub.", + packNamePrompt: "Pack name:", + packDescriptionPrompt: "Description:", + packShipped: (id: string) => `${id} is a shipped pack: it cannot be changed or deleted (create your own with N in the tree, or skills-atlas profile create).`, + packUpdated: (id: string, n: string) => `Pack ${id} saved (${n} skills)`, + packDeleteConfirm: (id: string) => `Delete the pack ${id}? Its backups stay restorable.`, + packDeleted: (id: string) => `Pack ${id} deleted`, + profileScopePrompt: "Scope: [g]lobal or [p]roject?", + backupPickPrompt: "Number of the backup to restore (Enter cancels):", + packAddOption: "skills to add (owner/repo@name)", + packRemoveOption: "skills to take out", + packEditCmd: "change a personal pack: name, description, skills (asked for when no option is given)", + packDeleteCmd: "delete a personal pack (its backups stay restorable)", + backToTree: "Enter: back to the tree", + profileCmd: "profiles: activate a pack on its own (every other skill of the scope goes to a backup), and restore backups", + profileListCmd: "list the packs that can be used as profiles, and the active one", + profileHint: "skills-atlas profile use activates one (-p for this project); skills-atlas profile restore puts things back.", + profileCreateCmd: "create a personal pack (a profile) from skill ids or from what is installed", + profileName: "profile name, e.g. \"Design\"", + profileFromInstalled: "add the installed skills that have a GitHub source (-g/-p for one scope)", + profileDescription: "what the profile is for", + profileUnknown: (ids: string) => `not in the catalog, left out: ${ids}`, + profileDefaultDescription: (n: string) => `${n} skills`, + profileCreated: (id: string, n: string) => `Pack ${id} created with ${n} skills: skills-atlas profile use ${id}`, + profileUseCmd: "activate a pack on its own: keep its skills, install the missing ones, remove every other skill of the scope (after a backup)", + profilePack: "pack id (see skills-atlas profile list)", + profileTitle: (name: string, scope: string) => `Profile ${name} (${scope})`, + profileKeep: (n: string) => `${n} skill(s) of the pack already installed, kept`, + profileWarning: (n: string) => `${n} other skill(s) will be removed from this scope, home-made ones included. They are saved first; skills-atlas profile restore puts them back exactly.`, + profileNeedsYes: "this changes installed skills: run it in a terminal to confirm, or pass -y", + profileConfirm: "Save this scope, then apply the profile?", + profileBackedUp: (id: string) => `Backup saved: ${id}`, + profileUndo: (id: string) => `To go back: skills-atlas profile restore ${id}`, + profileBackupsCmd: "list the backups taken before each profile change", + profileNoBackup: "No backup yet: one is saved before each profile change.", + profileInitial: "initial", + profileRestoreHint: "skills-atlas profile restore (or --initial) puts a scope back exactly as it was.", + profileBackupArg: "backup id (default: the latest)", + profileInitialOption: "restore the first backup: the configuration before any profile", + profileRestoreCmd: "put a scope back exactly as a backup saved it (the current state is saved first)", + profileUnknownBackup: (id: string) => `no backup ${id} (see skills-atlas profile backups)`, + profileRestoreTitle: (id: string, scope: string, n: string) => `Restore ${id} (${scope}, ${n} skills):`, + profileRestoreConfirm: "Replace the skills of this scope with this backup?", + profileRestored: (id: string) => `Restored ${id}`, + sizeOption: "show what each skill costs in the agent's context (estimated tokens) and on disk", + startupLoad: (n: string) => `≈ ${n} tokens at startup`, + sizeLegend: "tok: estimated tokens loaded at every session start (name and description) / when the skill is used (SKILL.md).", + heavyLoad: (agent: string, n: string, budget: string) => `${agent}: ≈ ${n} tokens of skill descriptions loaded at every session start (over ${budget}). Untick the skills you do not use.`, + updatesModified: (list: string) => `Edited by hand, not updated: ${list}`, + suggestCmd: "suggest skills for a project from what it is built with (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "project directory (default: the current one)", + suggestLimit: "skills per technology (1 to 10, default 3)", + suggestNothing: "No known technology found in this project.", + suggestNone: "nothing new in the catalog", + suggestPacks: (list: string) => `Packs that fit: ${list} (skills-atlas install --pack )`, + suggestHint: "Read one with skills-atlas show , then install the ones you want:", outdatedHint: "Update one by installing it again: skills-atlas install -g -a -y (or press u in the tree).", - updateCmd: "update outdated global skills: all of them, or the ones named", + updateCmd: "update outdated skills, project and global: all of them, or the ones named", updateAllHint: "Update them all: skills-atlas update", rollbackCmd: 'reinstall the version a global skill had before its last update', rollbackList: 'list the recorded versions', @@ -363,6 +467,7 @@ export const en = { themes: { recommended: 'Recommended', trending: 'Trending', + suggested: 'For this project', installed: 'Already installed', frontend: 'Frontend & web', backend: 'Backend & APIs', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index fb7ca75..8aa3724 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -37,6 +37,21 @@ export const es: Messages = { clear: 'borrar', update: 'actualizar', updateAll: "actualizar todo", + nextField: "campo siguiente", + save: "guardar", + yes: "sí", + no: "no", + restore: "restaurar", + scope: "ámbito", + profileApply: "guardar e instalar", + warnings: (n) => `avisos (${n})`, + newPack: "nuevo pack", + editPack: "editar pack", + deletePack: "eliminar pack", + useProfile: "usar como perfil", + backups: "copias", + savePack: "guardar pack", + cancelEdit: "cancelar", preview: 'vista previa', }, @@ -86,6 +101,17 @@ export const es: Messages = { noNewRisk: 'Ninguna señal de alerta nueva desde su versión', noRisk: "Ninguna señal de alerta (comprobación por patrones, no una garantía)", growth: (n) => `+${n} instalaciones desde la última instantánea`, + andMore: (n) => `… y ${n} más`, + fieldRequired: (label) => `${label}: obligatorio`, + packNameField: "Nombre", + packDescriptionField: "Descripción", + packNewTitle: (n) => `Nuevo pack con las ${n} skill(s) marcadas`, + packEditTitle: (label, n) => `${label}: ${n} skill(s)`, + restoring: "Restaurando…", + warningsTitle: (n) => `Avisos (${n})`, + editingPack: (label, n) => `Editando ${label}: marca sus skills (${n})`, + cost: (a, b) => `≈ ${a} tokens en cada inicio de sesión (descripción) · ≈ ${b} al usarla (SKILL.md)`, + suggestedFor: (x) => `para ${x} en este proyecto`, needs: 'necesita', updated: (date) => `actualizado el ${date}`, growthSuspect: (n) => `fuera de Tendencias: los mismos skills crecen a la vez en ${n} repositorios más`, @@ -127,7 +153,7 @@ export const es: Messages = { auditUnread: "no auditada: no se pudo leer su SKILL.md", auditPassed: (n) => `${n} ${s(n, 'skill supera', 'skills superan')} la auditoría de seguridad`, auditToggle: "instalar igualmente", - update: (n) => `Actualizar ${n} ${s(n, 'skill', 'skills')} (global)`, + update: (n) => `Actualizar ${n} ${s(n, 'skill', 'skills')}`, }, progress: { @@ -177,6 +203,28 @@ export const es: Messages = { serving: 'Skills Atlas está abierto en', stop: 'Pulsa Ctrl+C para detenerlo.', needAgent: 'Elige al menos un agente.', + packEdit: "Editar este pack…", + packDelete: "Eliminar este pack", + packEditTitle: "Editar el pack {id}", + packMembers: "Skills ({n}): desmarca para quitar una", + packAddSelection: "Añadir las {n} skill(s) de la selección actual", + packSaveChanges: "Guardar cambios", + packDeleteConfirm: "¿Eliminar el pack {id}? Sus copias siguen siendo restaurables.", + profileUse: "Usar este pack como perfil…", + profileTitle: "Perfil: {name}", + profileKeep: "Se conservan {n} skill(s) del pack ya instalada(s).", + profileWarning: "Se quitarán {n} otra(s) skill(s) de este ámbito, incluidas las caseras. Antes se guardan y se pueden restaurar exactamente.", + profileActivate: "Guardar y activar", + profileNothing: "Nada que cambiar", + profileUndo: "Copia {id} guardada antes del cambio: restáurala desde Copias de seguridad.", + backupsOpen: "Copias de seguridad ({n})", + backupsTitle: "Copias de seguridad", + backupsHint: "Cada cambio de perfil guarda antes el ámbito. Restaurar lo deja exactamente igual y guarda antes el estado actual.", + backupSkills: "{n} skills", + backupInitial: "inicial", + backupRestore: "Restaurar", + backupRestoreConfirm: "¿Reemplazar las skills de este ámbito por esta copia? El estado actual se guarda antes.", + backupRestored: "{id} restaurada. Para deshacer, restaura {undo}.", busy: 'Ya hay una instalación en curso.', tagline: 'Todos los Agent Skills públicos, por tema: elegir, instalar, desinstalar.', searchPlaceholder: 'Buscar por nombre, repositorio o descripción', @@ -301,12 +349,68 @@ export const es: Messages = { nothingToInstall: "indica ids de skills o --pack ", completionShell: (shell) => `shell desconocido «${shell}»: bash, zsh, fish o powershell`, noneInstalled: "Ninguna skill instalada.", - outdatedCmd: "lista las skills globales con una versión más reciente en GitHub", + outdatedCmd: "lista las skills instaladas (proyecto y global) con una versión más reciente en GitHub", showFailed: (id, msg) => `No se puede leer el SKILL.md de ${id}: ${msg}`, upToDate: "Todas las skills globales están al día.", updatesUnchecked: (n) => `Comprobación de actualizaciones incompleta: ${n} comprobación(es) de repositorio fallaron, esas skills pueden estar desactualizadas.`, + packEmpty: "Marca primero skills del catálogo: un pack contiene skills instalables desde GitHub.", + packNamePrompt: "Nombre del pack:", + packDescriptionPrompt: "Descripción:", + packShipped: (id) => `${id} es un pack incluido: no se puede modificar ni eliminar (crea el tuyo con N en el árbol, o skills-atlas profile create).`, + packUpdated: (id, n) => `Pack ${id} guardado (${n} skills)`, + packDeleteConfirm: (id) => `¿Eliminar el pack ${id}? Sus copias de seguridad siguen siendo restaurables.`, + packDeleted: (id) => `Pack ${id} eliminado`, + profileScopePrompt: "Ámbito: [g]lobal o [p]royecto?", + backupPickPrompt: "Número de la copia a restaurar (Intro cancela):", + packAddOption: "skills que añadir (owner/repo@nombre)", + packRemoveOption: "skills que quitar", + packEditCmd: "modifica un pack personal: nombre, descripción, skills (se preguntan si no hay opciones)", + packDeleteCmd: "elimina un pack personal (sus copias siguen siendo restaurables)", + backToTree: "Intro: volver al árbol", + profileCmd: "perfiles: activar un pack en exclusiva (las demás skills del ámbito van a una copia de seguridad) y restaurar copias", + profileListCmd: "lista los packs utilizables como perfiles y el activo", + profileHint: "skills-atlas profile use activa uno (-p para este proyecto); skills-atlas profile restore lo deja todo como estaba.", + profileCreateCmd: "crea un pack personal (un perfil) a partir de ids de skills o de lo instalado", + profileName: "nombre del perfil, p. ej. \"Diseño\"", + profileFromInstalled: "añade las skills instaladas con fuente en GitHub (-g/-p para un ámbito)", + profileDescription: "para qué sirve el perfil", + profileUnknown: (ids) => `no están en el catálogo, se omiten: ${ids}`, + profileDefaultDescription: (n) => `${n} skills`, + profileCreated: (id, n) => `Pack ${id} creado con ${n} skills: skills-atlas profile use ${id}`, + profileUseCmd: "activa un pack en exclusiva: conserva sus skills, instala las que faltan y quita las demás del ámbito (tras una copia de seguridad)", + profilePack: "id del pack (ver skills-atlas profile list)", + profileTitle: (name, scope) => `Perfil ${name} (${scope})`, + profileKeep: (n) => `${n} skill(s) del pack ya instalada(s), se conservan`, + profileWarning: (n) => `Se quitarán ${n} otra(s) skill(s) de este ámbito, incluidas las caseras. Antes se guardan; skills-atlas profile restore las devuelve exactamente.`, + profileNeedsYes: "esto cambia las skills instaladas: ejecútalo en una terminal para confirmar, o pasa -y", + profileConfirm: "¿Guardar este ámbito y luego aplicar el perfil?", + profileBackedUp: (id) => `Copia de seguridad guardada: ${id}`, + profileUndo: (id) => `Para volver atrás: skills-atlas profile restore ${id}`, + profileBackupsCmd: "lista las copias de seguridad hechas antes de cada cambio de perfil", + profileNoBackup: "Aún no hay copias: se guarda una antes de cada cambio de perfil.", + profileInitial: "inicial", + profileRestoreHint: "skills-atlas profile restore (o --initial) devuelve un ámbito exactamente a como estaba.", + profileBackupArg: "id de la copia (por defecto: la más reciente)", + profileInitialOption: "restaura la primera copia: la configuración anterior a cualquier perfil", + profileRestoreCmd: "devuelve un ámbito exactamente a como lo guardó una copia (el estado actual se guarda primero)", + profileUnknownBackup: (id) => `no existe la copia ${id} (ver skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `Restaurar ${id} (${scope}, ${n} skills):`, + profileRestoreConfirm: "¿Reemplazar las skills de este ámbito por esta copia?", + profileRestored: (id) => `${id} restaurada`, + sizeOption: "muestra lo que cuesta cada skill en el contexto del agente (tokens estimados) y en disco", + startupLoad: (n) => `≈ ${n} tokens al inicio`, + sizeLegend: "tok: tokens estimados cargados en cada inicio de sesión (nombre y descripción) / al usar la skill (SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent}: ≈ ${n} tokens de descripciones de skills en cada inicio de sesión (más de ${budget}). Desmarca las que no uses.`, + updatesModified: (list) => `Editadas a mano, no actualizadas: ${list}`, + suggestCmd: "sugiere skills para un proyecto según con qué está hecho (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "directorio del proyecto (por defecto: el actual)", + suggestLimit: "skills por tecnología (1 a 10, 3 por defecto)", + suggestNothing: "No se encontró ninguna tecnología conocida en este proyecto.", + suggestNone: "nada nuevo en el catálogo", + suggestPacks: (list) => `Packs adecuados: ${list} (skills-atlas install --pack )`, + suggestHint: "Lee una con skills-atlas show y luego instala las que quieras:", outdatedHint: "Para actualizar una, reinstálala: skills-atlas install -g -a -y (o pulsa u en el árbol).", - updateCmd: "actualiza las skills globales obsoletas: todas, o las indicadas", + updateCmd: "actualiza las skills obsoletas, de proyecto y globales: todas, o las indicadas", updateAllHint: "Actualizar todas: skills-atlas update", rollbackCmd: 'reinstala la versión que tenía un skill global antes de su última actualización', rollbackList: 'lista las versiones registradas', @@ -365,6 +469,7 @@ export const es: Messages = { themes: { recommended: 'Recomendados', trending: 'Tendencias', + suggested: 'Para este proyecto', installed: 'Ya instalados', frontend: 'Frontend y web', backend: 'Backend y APIs', diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index bc56541..c6eedd6 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -37,6 +37,21 @@ export const fr: Messages = { clear: 'effacer', update: 'mettre à jour', updateAll: "tout mettre à jour", + nextField: "champ suivant", + save: "enregistrer", + yes: "oui", + no: "non", + restore: "restaurer", + scope: "portée", + profileApply: "sauvegarder et installer", + warnings: (n) => `avertissements (${n})`, + newPack: "nouveau pack", + editPack: "modifier le pack", + deletePack: "supprimer le pack", + useProfile: "utiliser comme profil", + backups: "sauvegardes", + savePack: "enregistrer le pack", + cancelEdit: "annuler", preview: 'aperçu', }, @@ -86,6 +101,17 @@ export const fr: Messages = { noNewRisk: 'Aucun nouveau signal d’alerte depuis votre version', noRisk: "Aucun signal d’alerte (contrôle par motifs, pas une garantie)", growth: (n) => `+${n} installations depuis le dernier instantané`, + andMore: (n) => `… et ${n} de plus`, + fieldRequired: (label) => `${label} : obligatoire`, + packNameField: "Nom", + packDescriptionField: "Description", + packNewTitle: (n) => `Nouveau pack avec les ${n} skill(s) coché(s)`, + packEditTitle: (label, n) => `${label} : ${n} skill(s)`, + restoring: "Restauration…", + warningsTitle: (n) => `Avertissements (${n})`, + editingPack: (label, n) => `Modification de ${label} : cochez ses skills (${n})`, + cost: (a, b) => `≈ ${a} tokens à chaque démarrage (description) · ≈ ${b} à l'utilisation (SKILL.md)`, + suggestedFor: (x) => `pour ${x} dans ce projet`, needs: 'nécessite', updated: (date) => `modifié le ${date}`, growthSuspect: (n) => `hors Tendances : les mêmes skills progressent ensemble dans ${n} autres dépôts`, @@ -127,7 +153,7 @@ export const fr: Messages = { auditUnread: "non audité : son SKILL.md n’a pas pu être lu", auditPassed: (n) => `${n} ${s(n, 'skill passe', 'skills passent')} l’audit de sécurité`, auditToggle: "installer quand même", - update: (n) => `Mettre à jour ${n} ${s(n, 'skill', 'skills')} (global)`, + update: (n) => `Mettre à jour ${n} ${s(n, 'skill', 'skills')}`, }, progress: { @@ -177,6 +203,28 @@ export const fr: Messages = { serving: 'Skills Atlas est ouvert sur', stop: 'Ctrl+C pour arrêter.', needAgent: 'Choisissez au moins un agent.', + packEdit: "Modifier ce pack…", + packDelete: "Supprimer ce pack", + packEditTitle: "Modifier le pack {id}", + packMembers: "Skills ({n}) : décochez pour en retirer un", + packAddSelection: "Ajouter les {n} skill(s) de la sélection en cours", + packSaveChanges: "Enregistrer les modifications", + packDeleteConfirm: "Supprimer le pack {id} ? Ses sauvegardes restent restaurables.", + profileUse: "Utiliser ce pack comme profil…", + profileTitle: "Profil : {name}", + profileKeep: "{n} skill(s) du pack déjà installé(s) sont conservé(s).", + profileWarning: "{n} autre(s) skill(s) seront retirés de cette portée, y compris les skills faits maison. Ils sont sauvegardés avant et peuvent être restaurés à l'identique.", + profileActivate: "Sauvegarder, puis activer", + profileNothing: "Rien à changer", + profileUndo: "Sauvegarde {id} faite avant le changement : restaurez-la depuis Sauvegardes.", + backupsOpen: "Sauvegardes ({n})", + backupsTitle: "Sauvegardes", + backupsHint: "Chaque changement de profil sauvegarde d'abord la portée. Restaurer la remet à l'identique, après avoir sauvegardé l'état actuel.", + backupSkills: "{n} skills", + backupInitial: "initiale", + backupRestore: "Restaurer", + backupRestoreConfirm: "Remplacer les skills de cette portée par cette sauvegarde ? L'état actuel est sauvegardé d'abord.", + backupRestored: "{id} restaurée. Pour annuler, restaurez {undo}.", busy: 'Une installation est déjà en cours.', tagline: 'Tous les Agent Skills publics, par thème — choisir, installer, désinstaller.', searchPlaceholder: 'Chercher par nom, dépôt ou description', @@ -301,12 +349,68 @@ export const fr: Messages = { nothingToInstall: "indiquez des identifiants de skills ou --pack ", completionShell: (shell) => `shell inconnu « ${shell} » : bash, zsh, fish ou powershell`, noneInstalled: "Aucun skill installé.", - outdatedCmd: "liste les skills globaux qui ont une version plus récente sur GitHub", + outdatedCmd: "liste les skills installés (projet et global) qui ont une version plus récente sur GitHub", showFailed: (id, msg) => `Impossible de lire le SKILL.md de ${id} : ${msg}`, upToDate: "Tous les skills globaux sont à jour.", updatesUnchecked: (n) => `Vérification des mises à jour incomplète : ${n} contrôle(s) de dépôt impossible(s), ces skills sont peut-être obsolètes.`, + packEmpty: "Cochez d'abord des skills du catalogue : un pack contient des skills installables depuis GitHub.", + packNamePrompt: "Nom du pack :", + packDescriptionPrompt: "Description :", + packShipped: (id) => `${id} est un pack fourni : il ne peut être ni modifié ni supprimé (créez le vôtre avec N dans l'arbre, ou skills-atlas profile create).`, + packUpdated: (id, n) => `Pack ${id} enregistré (${n} skills)`, + packDeleteConfirm: (id) => `Supprimer le pack ${id} ? Ses sauvegardes restent restaurables.`, + packDeleted: (id) => `Pack ${id} supprimé`, + profileScopePrompt: "Portée : [g]lobale ou [p]rojet ?", + backupPickPrompt: "Numéro de la sauvegarde à restaurer (Entrée annule) :", + packAddOption: "skills à ajouter (owner/repo@nom)", + packRemoveOption: "skills à retirer", + packEditCmd: "modifie un pack perso : nom, description, skills (demandés si aucune option n'est donnée)", + packDeleteCmd: "supprime un pack perso (ses sauvegardes restent restaurables)", + backToTree: "Entrée : retour à l'arbre", + profileCmd: "profils : activer un pack seul (les autres skills de la portée partent dans une sauvegarde), et restaurer les sauvegardes", + profileListCmd: "liste les packs utilisables comme profils, et le profil actif", + profileHint: "skills-atlas profile use en active un (-p pour ce projet) ; skills-atlas profile restore remet tout en place.", + profileCreateCmd: "crée un pack personnel (un profil) à partir d'ids de skills ou de ce qui est installé", + profileName: "nom du profil, par exemple « Design »", + profileFromInstalled: "ajoute les skills installés qui ont une source GitHub (-g/-p pour une portée)", + profileDescription: "à quoi sert le profil", + profileUnknown: (ids) => `absents du catalogue, ignorés : ${ids}`, + profileDefaultDescription: (n) => `${n} skills`, + profileCreated: (id, n) => `Pack ${id} créé avec ${n} skills : skills-atlas profile use ${id}`, + profileUseCmd: "active un pack seul : garde ses skills, installe ceux qui manquent, retire tous les autres skills de la portée (après sauvegarde)", + profilePack: "id du pack (voir skills-atlas profile list)", + profileTitle: (name, scope) => `Profil ${name} (${scope})`, + profileKeep: (n) => `${n} skill(s) du pack déjà installé(s), conservé(s)`, + profileWarning: (n) => `${n} autre(s) skill(s) seront retirés de cette portée, y compris les skills faits maison. Ils sont sauvegardés avant ; skills-atlas profile restore les remet exactement.`, + profileNeedsYes: "cela modifie les skills installés : lancez-le dans un terminal pour confirmer, ou passez -y", + profileConfirm: "Sauvegarder cette portée, puis appliquer le profil ?", + profileBackedUp: (id) => `Sauvegarde enregistrée : ${id}`, + profileUndo: (id) => `Pour revenir en arrière : skills-atlas profile restore ${id}`, + profileBackupsCmd: "liste les sauvegardes faites avant chaque changement de profil", + profileNoBackup: "Aucune sauvegarde pour l'instant : une est faite avant chaque changement de profil.", + profileInitial: "initiale", + profileRestoreHint: "skills-atlas profile restore (ou --initial) remet une portée exactement comme elle était.", + profileBackupArg: "id de la sauvegarde (par défaut : la plus récente)", + profileInitialOption: "restaure la première sauvegarde : la configuration d'avant tout profil", + profileRestoreCmd: "remet une portée exactement comme une sauvegarde l'a enregistrée (l'état actuel est sauvegardé d'abord)", + profileUnknownBackup: (id) => `pas de sauvegarde ${id} (voir skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `Restaurer ${id} (${scope}, ${n} skills) :`, + profileRestoreConfirm: "Remplacer les skills de cette portée par cette sauvegarde ?", + profileRestored: (id) => `${id} restaurée`, + sizeOption: "affiche le coût de chaque skill dans le contexte de l'agent (tokens estimés) et sur le disque", + startupLoad: (n) => `≈ ${n} tokens au démarrage`, + sizeLegend: "tok : tokens estimés chargés à chaque démarrage de session (nom et description) / quand le skill sert (SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent} : ≈ ${n} tokens de descriptions de skills chargés à chaque démarrage (plus de ${budget}). Décochez les skills inutilisés.`, + updatesModified: (list) => `Modifiés à la main, non mis à jour : ${list}`, + suggestCmd: "suggère des skills pour un projet d'après ce qu'il utilise (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "dossier du projet (par défaut : le dossier courant)", + suggestLimit: "skills par technologie (1 à 10, 3 par défaut)", + suggestNothing: "Aucune technologie connue trouvée dans ce projet.", + suggestNone: "rien de nouveau dans le catalogue", + suggestPacks: (list) => `Packs adaptés : ${list} (skills-atlas install --pack )`, + suggestHint: "Lisez-en un avec skills-atlas show , puis installez ceux qui vous intéressent :", outdatedHint: "Pour en mettre un à jour, réinstallez-le : skills-atlas install -g -a -y (ou touche u dans l'arbre).", - updateCmd: "met à jour les skills globaux obsolètes : tous, ou ceux indiqués", + updateCmd: "met à jour les skills obsolètes, projet et global : tous, ou ceux indiqués", updateAllHint: "Tout mettre à jour : skills-atlas update", rollbackCmd: 'réinstalle la version qu’avait un skill global avant sa dernière mise à jour', rollbackList: 'liste les versions enregistrées', @@ -366,6 +470,7 @@ export const fr: Messages = { themes: { recommended: 'Recommandés', trending: 'Tendances', + suggested: 'Pour ce projet', installed: 'Déjà installés', frontend: 'Frontend & web', backend: 'Backend & API', diff --git a/src/i18n/it.ts b/src/i18n/it.ts index c1143e7..4badd3e 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -37,6 +37,21 @@ export const it: Messages = { clear: 'cancella', update: 'aggiorna', updateAll: "aggiorna tutto", + nextField: "campo successivo", + save: "salva", + yes: "sì", + no: "no", + restore: "ripristina", + scope: "ambito", + profileApply: "salva e installa", + warnings: (n) => `avvisi (${n})`, + newPack: "nuovo pack", + editPack: "modifica pack", + deletePack: "elimina pack", + useProfile: "usa come profilo", + backups: "backup", + savePack: "salva pack", + cancelEdit: "annulla", preview: 'anteprima', }, @@ -86,6 +101,17 @@ export const it: Messages = { noNewRisk: 'Nessun nuovo segnale di allarme dalla tua versione', noRisk: "Nessun segnale di allarme (controllo per schemi, non una garanzia)", growth: (n) => `+${n} installazioni dall'ultima istantanea`, + andMore: (n) => `… e altri ${n}`, + fieldRequired: (label) => `${label}: obbligatorio`, + packNameField: "Nome", + packDescriptionField: "Descrizione", + packNewTitle: (n) => `Nuovo pack con le ${n} skill selezionate`, + packEditTitle: (label, n) => `${label}: ${n} skill`, + restoring: "Ripristino…", + warningsTitle: (n) => `Avvisi (${n})`, + editingPack: (label, n) => `Modifica di ${label}: seleziona le sue skill (${n})`, + cost: (a, b) => `≈ ${a} token a ogni avvio di sessione (descrizione) · ≈ ${b} quando usata (SKILL.md)`, + suggestedFor: (x) => `per ${x} in questo progetto`, needs: 'richiede', updated: (date) => `aggiornato il ${date}`, growthSuspect: (n) => `fuori da Tendenze: gli stessi skill crescono insieme in altri ${n} repository`, @@ -127,7 +153,7 @@ export const it: Messages = { auditUnread: "non verificata: impossibile leggere il suo SKILL.md", auditPassed: (n) => `${n} skill ${s(n, 'supera', 'superano')} la verifica di sicurezza`, auditToggle: "installa comunque", - update: (n) => `Aggiorna ${n} skill (globale)`, + update: (n) => `Aggiorna ${n} skill`, }, progress: { @@ -177,6 +203,28 @@ export const it: Messages = { serving: 'Skills Atlas è aperto su', stop: 'Premi Ctrl+C per arrestare.', needAgent: 'Scegli almeno un agente.', + packEdit: "Modifica questo pack…", + packDelete: "Elimina questo pack", + packEditTitle: "Modifica il pack {id}", + packMembers: "Skill ({n}): deseleziona per toglierne una", + packAddSelection: "Aggiungi le {n} skill della selezione attuale", + packSaveChanges: "Salva le modifiche", + packDeleteConfirm: "Eliminare il pack {id}? I suoi backup restano ripristinabili.", + profileUse: "Usa questo pack come profilo…", + profileTitle: "Profilo: {name}", + profileKeep: "{n} skill del pack già installate vengono mantenute.", + profileWarning: "{n} altre skill verranno rimosse da questo ambito, comprese quelle fatte a mano. Prima vengono salvate e si possono ripristinare esattamente.", + profileActivate: "Salva, poi attiva", + profileNothing: "Niente da cambiare", + profileUndo: "Backup {id} salvato prima della modifica: ripristinalo da Backup.", + backupsOpen: "Backup ({n})", + backupsTitle: "Backup", + backupsHint: "Ogni cambio di profilo salva prima l'ambito. Il ripristino lo rimette esattamente com'era, salvando prima lo stato attuale.", + backupSkills: "{n} skill", + backupInitial: "iniziale", + backupRestore: "Ripristina", + backupRestoreConfirm: "Sostituire le skill di questo ambito con questo backup? Lo stato attuale viene salvato prima.", + backupRestored: "{id} ripristinato. Per annullare, ripristina {undo}.", busy: 'È già in corso un’installazione.', tagline: 'Tutte le Agent Skills pubbliche, per tema: scegli, installa, disinstalla.', searchPlaceholder: 'Cerca per nome, repository o descrizione', @@ -301,12 +349,68 @@ export const it: Messages = { nothingToInstall: "indica id di skill o --pack ", completionShell: (shell) => `shell sconosciuta «${shell}»: bash, zsh, fish o powershell`, noneInstalled: "Nessuna skill installata.", - outdatedCmd: "elenca le skill globali con una versione più recente su GitHub", + outdatedCmd: "elenca le skill installate (progetto e globali) con una versione più recente su GitHub", showFailed: (id, msg) => `Impossibile leggere lo SKILL.md di ${id}: ${msg}`, upToDate: "Tutte le skill globali sono aggiornate.", updatesUnchecked: (n) => `Controllo aggiornamenti incompleto: ${n} controllo/i di repository non riuscito/i, quelle skill potrebbero essere obsolete.`, + packEmpty: "Seleziona prima skill del catalogo: un pack contiene skill installabili da GitHub.", + packNamePrompt: "Nome del pack:", + packDescriptionPrompt: "Descrizione:", + packShipped: (id) => `${id} è un pack incluso: non può essere modificato né eliminato (creane uno tuo con N nell'albero, o skills-atlas profile create).`, + packUpdated: (id, n) => `Pack ${id} salvato (${n} skill)`, + packDeleteConfirm: (id) => `Eliminare il pack ${id}? I suoi backup restano ripristinabili.`, + packDeleted: (id) => `Pack ${id} eliminato`, + profileScopePrompt: "Ambito: [g]lobale o [p]rogetto?", + backupPickPrompt: "Numero del backup da ripristinare (Invio annulla):", + packAddOption: "skill da aggiungere (owner/repo@nome)", + packRemoveOption: "skill da togliere", + packEditCmd: "modifica un pack personale: nome, descrizione, skill (chiesti se non c'è alcuna opzione)", + packDeleteCmd: "elimina un pack personale (i suoi backup restano ripristinabili)", + backToTree: "Invio: torna all'albero", + profileCmd: "profili: attiva un pack da solo (le altre skill dell'ambito vanno in un backup) e ripristina i backup", + profileListCmd: "elenca i pack utilizzabili come profili e quello attivo", + profileHint: "skills-atlas profile use ne attiva uno (-p per questo progetto); skills-atlas profile restore rimette tutto a posto.", + profileCreateCmd: "crea un pack personale (un profilo) da id di skill o da ciò che è installato", + profileName: "nome del profilo, es. \"Design\"", + profileFromInstalled: "aggiunge le skill installate con sorgente GitHub (-g/-p per un ambito)", + profileDescription: "a cosa serve il profilo", + profileUnknown: (ids) => `non nel catalogo, esclusi: ${ids}`, + profileDefaultDescription: (n) => `${n} skill`, + profileCreated: (id, n) => `Pack ${id} creato con ${n} skill: skills-atlas profile use ${id}`, + profileUseCmd: "attiva un pack da solo: tiene le sue skill, installa quelle mancanti, rimuove tutte le altre dell'ambito (dopo un backup)", + profilePack: "id del pack (vedi skills-atlas profile list)", + profileTitle: (name, scope) => `Profilo ${name} (${scope})`, + profileKeep: (n) => `${n} skill del pack già installate, mantenute`, + profileWarning: (n) => `${n} altre skill verranno rimosse da questo ambito, comprese quelle fatte a mano. Prima vengono salvate; skills-atlas profile restore le rimette esattamente.`, + profileNeedsYes: "modifica le skill installate: eseguilo in un terminale per confermare, o passa -y", + profileConfirm: "Salvare questo ambito e poi applicare il profilo?", + profileBackedUp: (id) => `Backup salvato: ${id}`, + profileUndo: (id) => `Per tornare indietro: skills-atlas profile restore ${id}`, + profileBackupsCmd: "elenca i backup fatti prima di ogni cambio di profilo", + profileNoBackup: "Ancora nessun backup: se ne fa uno prima di ogni cambio di profilo.", + profileInitial: "iniziale", + profileRestoreHint: "skills-atlas profile restore (o --initial) rimette un ambito esattamente com'era.", + profileBackupArg: "id del backup (predefinito: il più recente)", + profileInitialOption: "ripristina il primo backup: la configurazione prima di qualsiasi profilo", + profileRestoreCmd: "rimette un ambito esattamente come un backup l'ha salvato (lo stato attuale viene salvato prima)", + profileUnknownBackup: (id) => `nessun backup ${id} (vedi skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `Ripristinare ${id} (${scope}, ${n} skill):`, + profileRestoreConfirm: "Sostituire le skill di questo ambito con questo backup?", + profileRestored: (id) => `${id} ripristinato`, + sizeOption: "mostra quanto costa ogni skill nel contesto dell'agente (token stimati) e su disco", + startupLoad: (n) => `≈ ${n} token all'avvio`, + sizeLegend: "tok: token stimati caricati a ogni avvio di sessione (nome e descrizione) / quando la skill viene usata (SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent}: ≈ ${n} token di descrizioni di skill a ogni avvio di sessione (oltre ${budget}). Deseleziona le skill che non usi.`, + updatesModified: (list) => `Modificate a mano, non aggiornate: ${list}`, + suggestCmd: "suggerisce skill per un progetto in base a ciò con cui è costruito (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "cartella del progetto (predefinita: quella corrente)", + suggestLimit: "skill per tecnologia (da 1 a 10, predefinito 3)", + suggestNothing: "Nessuna tecnologia nota trovata in questo progetto.", + suggestNone: "niente di nuovo nel catalogo", + suggestPacks: (list) => `Pack adatti: ${list} (skills-atlas install --pack )`, + suggestHint: "Leggine una con skills-atlas show , poi installa quelle che vuoi:", outdatedHint: "Per aggiornarne una, reinstallala: skills-atlas install -g -a -y (o premi u nell'albero).", - updateCmd: "aggiorna le skill globali obsolete: tutte, o quelle indicate", + updateCmd: "aggiorna le skill obsolete, di progetto e globali: tutte, o quelle indicate", updateAllHint: "Aggiornarle tutte: skills-atlas update", rollbackCmd: 'reinstalla la versione che uno skill globale aveva prima del suo ultimo aggiornamento', rollbackList: 'elenca le versioni registrate', @@ -365,6 +469,7 @@ export const it: Messages = { themes: { recommended: 'Consigliate', trending: 'Di tendenza', + suggested: 'Per questo progetto', installed: 'Già installate', frontend: 'Frontend e web', backend: 'Backend e API', diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index fe2dc52..ef31ad9 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -35,6 +35,21 @@ export const ja: Messages = { clear: 'クリア', update: '更新', updateAll: "すべて更新", + nextField: "次の欄", + save: "保存", + yes: "はい", + no: "いいえ", + restore: "復元", + scope: "範囲", + profileApply: "保存してインストール", + warnings: (n) => `警告(${n})`, + newPack: "新しいパック", + editPack: "パックを編集", + deletePack: "パックを削除", + useProfile: "プロファイルとして使う", + backups: "バックアップ", + savePack: "パックを保存", + cancelEdit: "取り消し", preview: 'プレビュー', }, @@ -84,6 +99,17 @@ export const ja: Messages = { noNewRisk: 'お使いの版から新しい警告サインはありません', noRisk: "警告サインなし(パターン検査であり保証ではありません)", growth: (n) => `前回のスナップショットから +${n} インストール`, + andMore: (n) => `… ほか ${n} 件`, + fieldRequired: (label) => `${label}: 必須`, + packNameField: "名前", + packDescriptionField: "説明", + packNewTitle: (n) => `チェックした ${n} 件のスキルで新しいパック`, + packEditTitle: (label, n) => `${label}: ${n} 件のスキル`, + restoring: "復元中…", + warningsTitle: (n) => `警告(${n})`, + editingPack: (label, n) => `${label} を編集中: スキルにチェック(${n})`, + cost: (a, b) => `セッション開始ごとに約 ${a} トークン(説明)· 使用時に約 ${b}(SKILL.md)`, + suggestedFor: (x) => `このプロジェクトの ${x} 向け`, needs: '必要', updated: (date) => `${date} 更新`, growthSuspect: (n) => `トレンド対象外: 同じ skill がほかの ${n} 個のリポジトリで同時に増加`, @@ -125,7 +151,7 @@ export const ja: Messages = { auditUnread: "未監査: SKILL.md を読めませんでした", auditPassed: (n) => `${n} 件のスキルがセキュリティ監査に合格`, auditToggle: "それでもインストール", - update: (n) => `${n} 件のスキルを更新 (グローバル)`, + update: (n) => `${n} 件のスキルを更新`, }, progress: { @@ -175,6 +201,28 @@ export const ja: Messages = { serving: 'Skills Atlas を開きました:', stop: 'Ctrl+C で停止します。', needAgent: 'エージェントを 1 つ以上選んでください。', + packEdit: "このパックを編集…", + packDelete: "このパックを削除", + packEditTitle: "パック {id} を編集", + packMembers: "スキル({n}): チェックを外すと除外", + packAddSelection: "現在の選択から {n} 件のスキルを追加", + packSaveChanges: "変更を保存", + packDeleteConfirm: "パック {id} を削除しますか?バックアップは引き続き復元できます。", + profileUse: "このパックをプロファイルとして使う…", + profileTitle: "プロファイル: {name}", + profileKeep: "インストール済みのパックのスキル {n} 件は保持されます。", + profileWarning: "この範囲から他のスキル {n} 件(自作のものを含む)が削除されます。先に保存され、正確に復元できます。", + profileActivate: "保存してから有効化", + profileNothing: "変更はありません", + profileUndo: "変更前にバックアップ {id} を保存しました。バックアップから復元できます。", + backupsOpen: "バックアップ({n})", + backupsTitle: "バックアップ", + backupsHint: "プロファイルを変更するたびに範囲を先に保存します。復元すると正確に元に戻り、その前に現在の状態を保存します。", + backupSkills: "{n} 件のスキル", + backupInitial: "初期", + backupRestore: "復元", + backupRestoreConfirm: "この範囲のスキルをこのバックアップで置き換えますか?現在の状態は先に保存されます。", + backupRestored: "{id} を復元しました。取り消すには {undo} を復元してください。", busy: 'すでにインストールを実行中です。', tagline: '公開されている Agent Skills をテーマ別に — 選んで、インストール、アンインストール。', searchPlaceholder: '名前・リポジトリ・説明で検索', @@ -299,12 +347,68 @@ export const ja: Messages = { nothingToInstall: "スキル ID か --pack を指定してください", completionShell: (shell) => `不明なシェル「${shell}」: bash, zsh, fish, powershell のいずれか`, noneInstalled: "インストール済みのスキルはありません。", - outdatedCmd: "GitHub に新しいバージョンがあるグローバルスキルを一覧表示", + outdatedCmd: "GitHub に新しいバージョンがあるインストール済みスキル(プロジェクトとグローバル)を一覧表示", showFailed: (id, msg) => `${id} の SKILL.md を読めません: ${msg}`, upToDate: "すべてのグローバルスキルは最新です。", updatesUnchecked: (n) => `更新の確認が不完全です: ${n} 件のリポジトリを確認できなかったため、その skill は古い可能性があります。`, + packEmpty: "まずカタログのスキルにチェックを入れてください: パックには GitHub からインストールできるスキルが入ります。", + packNamePrompt: "パック名:", + packDescriptionPrompt: "説明:", + packShipped: (id) => `${id} は同梱パックのため変更・削除できません(ツリーで N、または skills-atlas profile create で独自のパックを作成)。`, + packUpdated: (id, n) => `パック ${id} を保存しました(${n} 件のスキル)`, + packDeleteConfirm: (id) => `パック ${id} を削除しますか?バックアップは引き続き復元できます。`, + packDeleted: (id) => `パック ${id} を削除しました`, + profileScopePrompt: "範囲: [g]lobal(グローバル)か [p]roject(プロジェクト)か?", + backupPickPrompt: "復元するバックアップの番号(Enter で取り消し):", + packAddOption: "追加するスキル(owner/repo@name)", + packRemoveOption: "外すスキル", + packEditCmd: "個人用パックを変更: 名前、説明、スキル(オプションがなければ質問)", + packDeleteCmd: "個人用パックを削除(バックアップは復元可能なまま)", + backToTree: "Enter: ツリーに戻る", + profileCmd: "プロファイル: パックだけを有効化(範囲内の他のスキルはバックアップへ)、バックアップの復元", + profileListCmd: "プロファイルとして使えるパックと有効なものを一覧表示", + profileHint: "skills-atlas profile use で有効化(-p でこのプロジェクト)、skills-atlas profile restore で元に戻します。", + profileCreateCmd: "スキル ID またはインストール済みのものから個人用パック(プロファイル)を作成", + profileName: "プロファイル名(例: \"Design\")", + profileFromInstalled: "GitHub ソースを持つインストール済みスキルを追加(-g/-p で範囲を指定)", + profileDescription: "プロファイルの用途", + profileUnknown: (ids) => `カタログにないため除外: ${ids}`, + profileDefaultDescription: (n) => `${n} 件のスキル`, + profileCreated: (id, n) => `パック ${id} を ${n} 件のスキルで作成しました: skills-atlas profile use ${id}`, + profileUseCmd: "パックだけを有効化: そのスキルを残し、不足分をインストールし、範囲内の他のスキルをすべて削除(バックアップ後)", + profilePack: "パック ID(skills-atlas profile list を参照)", + profileTitle: (name, scope) => `プロファイル ${name}(${scope})`, + profileKeep: (n) => `パックのスキル ${n} 件はインストール済みのため保持`, + profileWarning: (n) => `この範囲から他のスキル ${n} 件(自作のものを含む)が削除されます。先に保存され、skills-atlas profile restore で正確に戻せます。`, + profileNeedsYes: "インストール済みスキルを変更します: 確認のためターミナルで実行するか -y を指定してください", + profileConfirm: "この範囲を保存してからプロファイルを適用しますか?", + profileBackedUp: (id) => `バックアップを保存しました: ${id}`, + profileUndo: (id) => `元に戻すには: skills-atlas profile restore ${id}`, + profileBackupsCmd: "プロファイル変更前に取ったバックアップを一覧表示", + profileNoBackup: "まだバックアップはありません。プロファイル変更のたびに保存されます。", + profileInitial: "初期", + profileRestoreHint: "skills-atlas profile restore (または --initial)で範囲を元の状態に正確に戻せます。", + profileBackupArg: "バックアップ ID(既定: 最新)", + profileInitialOption: "最初のバックアップ(プロファイル適用前の構成)を復元", + profileRestoreCmd: "バックアップ時の状態に範囲を正確に戻す(現在の状態を先に保存)", + profileUnknownBackup: (id) => `バックアップ ${id} はありません(skills-atlas profile backups を参照)`, + profileRestoreTitle: (id, scope, n) => `${id} を復元(${scope}、${n} 件のスキル):`, + profileRestoreConfirm: "この範囲のスキルをこのバックアップで置き換えますか?", + profileRestored: (id) => `${id} を復元しました`, + sizeOption: "各スキルがエージェントのコンテキスト(推定トークン)とディスクで占める量を表示", + startupLoad: (n) => `起動時 約 ${n} トークン`, + sizeLegend: "tok: セッション開始ごとに読み込まれる推定トークン(名前と説明)/ スキル使用時(SKILL.md)。", + heavyLoad: (agent, n, budget) => `${agent}: セッション開始ごとにスキルの説明で約 ${n} トークンを読み込みます(${budget} 超)。使わないスキルのチェックを外してください。`, + updatesModified: (list) => `手動で編集されているため更新しません: ${list}`, + suggestCmd: "プロジェクトの構成(package.json、pyproject.toml、Dockerfile…)からスキルを提案", + suggestDir: "プロジェクトのディレクトリ(既定: 現在のディレクトリ)", + suggestLimit: "技術ごとのスキル数(1〜10、既定 3)", + suggestNothing: "このプロジェクトで既知の技術は見つかりませんでした。", + suggestNone: "カタログに新しいものはありません", + suggestPacks: (list) => `合うパック: ${list}(skills-atlas install --pack )`, + suggestHint: "skills-atlas show で内容を確認し、必要なものをインストール:", outdatedHint: "更新するには再インストール: skills-atlas install -g -a -y(ツリーでは u キー)", - updateCmd: "古いグローバルスキルを更新(すべて、または指定したもの)", + updateCmd: "古いスキル(プロジェクトとグローバル)を更新(すべて、または指定したもの)", updateAllHint: "すべて更新: skills-atlas update", rollbackCmd: 'グローバル skill を前回の更新前のバージョンに戻して再インストール', rollbackList: '記録されたバージョンを一覧表示', @@ -363,6 +467,7 @@ export const ja: Messages = { themes: { recommended: 'おすすめ', trending: 'トレンド', + suggested: 'このプロジェクト向け', installed: 'インストール済み', frontend: 'フロントエンド・Web', backend: 'バックエンド・API', diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index ace9982..e4e5ad4 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -35,6 +35,21 @@ export const ko: Messages = { clear: '지우기', update: '업데이트', updateAll: "모두 업데이트", + nextField: "다음 칸", + save: "저장", + yes: "예", + no: "아니요", + restore: "복원", + scope: "범위", + profileApply: "저장 후 설치", + warnings: (n) => `경고(${n})`, + newPack: "새 팩", + editPack: "팩 편집", + deletePack: "팩 삭제", + useProfile: "프로필로 사용", + backups: "백업", + savePack: "팩 저장", + cancelEdit: "취소", preview: '미리보기', }, @@ -84,6 +99,17 @@ export const ko: Messages = { noNewRisk: '설치된 버전 이후 새 경고 신호 없음', noRisk: "경고 신호 없음 (패턴 검사이며 보장은 아님)", growth: (n) => `지난 스냅샷 이후 설치 +${n}`, + andMore: (n) => `… 외 ${n}개`, + fieldRequired: (label) => `${label}: 필수`, + packNameField: "이름", + packDescriptionField: "설명", + packNewTitle: (n) => `선택한 스킬 ${n}개로 새 팩`, + packEditTitle: (label, n) => `${label}: 스킬 ${n}개`, + restoring: "복원 중…", + warningsTitle: (n) => `경고(${n})`, + editingPack: (label, n) => `${label} 편집 중: 스킬 선택(${n})`, + cost: (a, b) => `세션 시작마다 약 ${a} 토큰(설명) · 사용 시 약 ${b}(SKILL.md)`, + suggestedFor: (x) => `이 프로젝트의 ${x}용`, needs: '필요', updated: (date) => `${date} 업데이트`, growthSuspect: (n) => `트렌딩 제외: 같은 skill이 다른 저장소 ${n}곳에서 함께 증가`, @@ -125,7 +151,7 @@ export const ko: Messages = { auditUnread: "감사되지 않음: SKILL.md를 읽을 수 없음", auditPassed: (n) => `스킬 ${n}개가 보안 감사를 통과함`, auditToggle: "그래도 설치", - update: (n) => `스킬 ${n}개 업데이트 (전역)`, + update: (n) => `스킬 ${n}개 업데이트`, }, progress: { @@ -175,6 +201,28 @@ export const ko: Messages = { serving: 'Skills Atlas가 열렸습니다:', stop: 'Ctrl+C를 눌러 중지하세요.', needAgent: '에이전트를 하나 이상 선택하세요.', + packEdit: "이 팩 편집…", + packDelete: "이 팩 삭제", + packEditTitle: "팩 {id} 편집", + packMembers: "스킬({n}): 선택 해제하면 제외", + packAddSelection: "현재 선택의 스킬 {n}개 추가", + packSaveChanges: "변경 사항 저장", + packDeleteConfirm: "팩 {id}을(를) 삭제할까요? 백업은 계속 복원할 수 있습니다.", + profileUse: "이 팩을 프로필로 사용…", + profileTitle: "프로필: {name}", + profileKeep: "이미 설치된 팩 스킬 {n}개는 유지됩니다.", + profileWarning: "이 범위에서 다른 스킬 {n}개(직접 만든 것 포함)가 제거됩니다. 먼저 저장되며 그대로 복원할 수 있습니다.", + profileActivate: "저장 후 활성화", + profileNothing: "바꿀 것이 없습니다", + profileUndo: "변경 전에 백업 {id}을(를) 저장했습니다. 백업에서 복원할 수 있습니다.", + backupsOpen: "백업({n})", + backupsTitle: "백업", + backupsHint: "프로필을 바꿀 때마다 범위를 먼저 저장합니다. 복원하면 그대로 돌아가며, 그 전에 현재 상태를 저장합니다.", + backupSkills: "스킬 {n}개", + backupInitial: "초기", + backupRestore: "복원", + backupRestoreConfirm: "이 범위의 스킬을 이 백업으로 바꿀까요? 현재 상태는 먼저 저장됩니다.", + backupRestored: "{id} 복원됨. 취소하려면 {undo}을(를) 복원하세요.", busy: '이미 설치가 진행 중입니다.', tagline: '공개된 모든 Agent Skills를 테마별로 — 고르고, 설치하고, 제거하세요.', searchPlaceholder: '이름, 저장소 또는 설명으로 검색', @@ -299,12 +347,68 @@ export const ko: Messages = { nothingToInstall: "스킬 ID 또는 --pack 를 지정하세요", completionShell: (shell) => `알 수 없는 셸 "${shell}": bash, zsh, fish, powershell 중 하나`, noneInstalled: "설치된 스킬이 없습니다.", - outdatedCmd: "GitHub에 새 버전이 있는 전역 스킬 목록", + outdatedCmd: "GitHub에 새 버전이 있는 설치된 스킬(프로젝트 및 전역) 목록", showFailed: (id, msg) => `${id}의 SKILL.md를 읽을 수 없습니다: ${msg}`, upToDate: "모든 전역 스킬이 최신입니다.", updatesUnchecked: (n) => `업데이트 확인이 불완전합니다: 저장소 ${n}곳을 확인하지 못해 해당 skill이 오래되었을 수 있습니다.`, + packEmpty: "먼저 카탈로그 스킬을 선택하세요: 팩에는 GitHub에서 설치할 수 있는 스킬이 들어갑니다.", + packNamePrompt: "팩 이름:", + packDescriptionPrompt: "설명:", + packShipped: (id) => `${id}은(는) 기본 제공 팩이라 변경하거나 삭제할 수 없습니다(트리에서 N 또는 skills-atlas profile create로 직접 만드세요).`, + packUpdated: (id, n) => `팩 ${id} 저장됨(스킬 ${n}개)`, + packDeleteConfirm: (id) => `팩 ${id}을(를) 삭제할까요? 백업은 계속 복원할 수 있습니다.`, + packDeleted: (id) => `팩 ${id} 삭제됨`, + profileScopePrompt: "범위: [g]전역 또는 [p]프로젝트?", + backupPickPrompt: "복원할 백업 번호(Enter는 취소):", + packAddOption: "추가할 스킬(owner/repo@name)", + packRemoveOption: "뺄 스킬", + packEditCmd: "개인 팩 변경: 이름, 설명, 스킬(옵션이 없으면 질문)", + packDeleteCmd: "개인 팩 삭제(백업은 계속 복원 가능)", + backToTree: "Enter: 트리로 돌아가기", + profileCmd: "프로필: 팩 하나만 활성화(범위의 다른 스킬은 백업으로 이동)하고 백업을 복원", + profileListCmd: "프로필로 쓸 수 있는 팩과 활성 프로필 목록", + profileHint: "skills-atlas profile use 로 활성화(-p는 이 프로젝트), skills-atlas profile restore로 되돌립니다.", + profileCreateCmd: "스킬 ID 또는 설치된 항목으로 개인 팩(프로필) 만들기", + profileName: "프로필 이름(예: \"Design\")", + profileFromInstalled: "GitHub 소스가 있는 설치된 스킬 추가(-g/-p로 범위 지정)", + profileDescription: "프로필 용도", + profileUnknown: (ids) => `카탈로그에 없어 제외됨: ${ids}`, + profileDefaultDescription: (n) => `스킬 ${n}개`, + profileCreated: (id, n) => `스킬 ${n}개로 팩 ${id} 생성: skills-atlas profile use ${id}`, + profileUseCmd: "팩 하나만 활성화: 팩의 스킬은 유지, 없는 것은 설치, 범위의 다른 스킬은 모두 제거(백업 후)", + profilePack: "팩 ID(skills-atlas profile list 참고)", + profileTitle: (name, scope) => `프로필 ${name}(${scope})`, + profileKeep: (n) => `이미 설치된 팩 스킬 ${n}개 유지`, + profileWarning: (n) => `이 범위에서 다른 스킬 ${n}개(직접 만든 것 포함)가 제거됩니다. 먼저 저장되며 skills-atlas profile restore로 그대로 되돌릴 수 있습니다.`, + profileNeedsYes: "설치된 스킬이 바뀝니다: 터미널에서 실행해 확인하거나 -y를 지정하세요", + profileConfirm: "이 범위를 저장한 뒤 프로필을 적용할까요?", + profileBackedUp: (id) => `백업 저장됨: ${id}`, + profileUndo: (id) => `되돌리려면: skills-atlas profile restore ${id}`, + profileBackupsCmd: "프로필 변경 전마다 만든 백업 목록", + profileNoBackup: "아직 백업이 없습니다. 프로필을 바꿀 때마다 저장됩니다.", + profileInitial: "초기", + profileRestoreHint: "skills-atlas profile restore (또는 --initial)로 범위를 그대로 되돌립니다.", + profileBackupArg: "백업 ID(기본값: 최신)", + profileInitialOption: "첫 백업 복원: 프로필 적용 전 구성", + profileRestoreCmd: "백업이 저장한 상태로 범위를 그대로 되돌림(현재 상태를 먼저 저장)", + profileUnknownBackup: (id) => `백업 ${id} 없음(skills-atlas profile backups 참고)`, + profileRestoreTitle: (id, scope, n) => `${id} 복원(${scope}, 스킬 ${n}개):`, + profileRestoreConfirm: "이 범위의 스킬을 이 백업으로 바꿀까요?", + profileRestored: (id) => `${id} 복원됨`, + sizeOption: "각 스킬이 에이전트 컨텍스트(추정 토큰)와 디스크에서 차지하는 양 표시", + startupLoad: (n) => `시작 시 약 ${n} 토큰`, + sizeLegend: "tok: 세션 시작마다 로드되는 추정 토큰(이름과 설명) / 스킬 사용 시(SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent}: 세션 시작마다 스킬 설명으로 약 ${n} 토큰을 로드합니다(${budget} 초과). 사용하지 않는 스킬을 해제하세요.`, + updatesModified: (list) => `직접 수정되어 업데이트하지 않음: ${list}`, + suggestCmd: "프로젝트 구성(package.json, pyproject.toml, Dockerfile…)에 맞는 스킬 추천", + suggestDir: "프로젝트 디렉터리 (기본값: 현재 디렉터리)", + suggestLimit: "기술별 스킬 수 (1~10, 기본값 3)", + suggestNothing: "이 프로젝트에서 알려진 기술을 찾지 못했습니다.", + suggestNone: "카탈로그에 새로운 항목 없음", + suggestPacks: (list) => `어울리는 팩: ${list} (skills-atlas install --pack )`, + suggestHint: "skills-atlas show 로 읽어 본 뒤 원하는 것을 설치하세요:", outdatedHint: "업데이트하려면 다시 설치: skills-atlas install -g -a -y (트리에서는 u)", - updateCmd: "오래된 전역 스킬 업데이트: 전부 또는 지정한 것", + updateCmd: "오래된 스킬(프로젝트 및 전역) 업데이트: 전부 또는 지정한 것", updateAllHint: "모두 업데이트: skills-atlas update", rollbackCmd: '전역 skill을 마지막 업데이트 이전 버전으로 다시 설치', rollbackList: '기록된 버전 목록', @@ -363,6 +467,7 @@ export const ko: Messages = { themes: { recommended: '추천', trending: '인기 급상승', + suggested: '이 프로젝트용', installed: '설치됨', frontend: '프론트엔드 & 웹', backend: '백엔드 & API', diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index aa13cc7..765f970 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -37,6 +37,21 @@ export const pt: Messages = { clear: 'limpar', update: 'atualizar', updateAll: "atualizar tudo", + nextField: "próximo campo", + save: "salvar", + yes: "sim", + no: "não", + restore: "restaurar", + scope: "escopo", + profileApply: "salvar e instalar", + warnings: (n) => `avisos (${n})`, + newPack: "novo pack", + editPack: "editar pack", + deletePack: "excluir pack", + useProfile: "usar como perfil", + backups: "backups", + savePack: "salvar pack", + cancelEdit: "cancelar", preview: 'prévia', }, @@ -86,6 +101,17 @@ export const pt: Messages = { noNewRisk: 'Nenhum sinal de alerta novo desde a sua versão', noRisk: "Nenhum sinal de alerta (verificação por padrões, não uma garantia)", growth: (n) => `+${n} instalações desde o último instantâneo`, + andMore: (n) => `… e mais ${n}`, + fieldRequired: (label) => `${label}: obrigatório`, + packNameField: "Nome", + packDescriptionField: "Descrição", + packNewTitle: (n) => `Novo pack com as ${n} skill(s) marcadas`, + packEditTitle: (label, n) => `${label}: ${n} skill(s)`, + restoring: "Restaurando…", + warningsTitle: (n) => `Avisos (${n})`, + editingPack: (label, n) => `Editando ${label}: marque suas skills (${n})`, + cost: (a, b) => `≈ ${a} tokens a cada início de sessão (descrição) · ≈ ${b} ao usar (SKILL.md)`, + suggestedFor: (x) => `para ${x} neste projeto`, needs: 'precisa de', updated: (date) => `atualizado em ${date}`, growthSuspect: (n) => `fora de Tendências: os mesmos skills crescem juntos em ${n} outros repositórios`, @@ -127,7 +153,7 @@ export const pt: Messages = { auditUnread: "não auditada: o SKILL.md não pôde ser lido", auditPassed: (n) => `${n} ${s(n, 'skill passa', 'skills passam')} na auditoria de segurança`, auditToggle: "instalar mesmo assim", - update: (n) => `Atualizar ${n} ${s(n, 'skill', 'skills')} (global)`, + update: (n) => `Atualizar ${n} ${s(n, 'skill', 'skills')}`, }, progress: { @@ -177,6 +203,28 @@ export const pt: Messages = { serving: 'Skills Atlas está aberto em', stop: 'Pressione Ctrl+C para parar.', needAgent: 'Escolha pelo menos um agente.', + packEdit: "Editar este pack…", + packDelete: "Excluir este pack", + packEditTitle: "Editar o pack {id}", + packMembers: "Skills ({n}): desmarque para remover uma", + packAddSelection: "Adicionar as {n} skill(s) da seleção atual", + packSaveChanges: "Salvar alterações", + packDeleteConfirm: "Excluir o pack {id}? Seus backups continuam restauráveis.", + profileUse: "Usar este pack como perfil…", + profileTitle: "Perfil: {name}", + profileKeep: "{n} skill(s) do pack já instalada(s) são mantida(s).", + profileWarning: "{n} outra(s) skill(s) serão removidas deste escopo, incluindo as caseiras. Antes são salvas e podem ser restauradas exatamente.", + profileActivate: "Salvar e ativar", + profileNothing: "Nada a mudar", + profileUndo: "Backup {id} salvo antes da mudança: restaure-o em Backups.", + backupsOpen: "Backups ({n})", + backupsTitle: "Backups", + backupsHint: "Cada troca de perfil salva antes o escopo. Restaurar o devolve exatamente, salvando antes o estado atual.", + backupSkills: "{n} skills", + backupInitial: "inicial", + backupRestore: "Restaurar", + backupRestoreConfirm: "Substituir as skills deste escopo por este backup? O estado atual é salvo antes.", + backupRestored: "{id} restaurado. Para desfazer, restaure {undo}.", busy: 'Já há uma instalação em andamento.', tagline: 'Todos os Agent Skills públicos, por tema: escolher, instalar, desinstalar.', searchPlaceholder: 'Buscar por nome, repositório ou descrição', @@ -301,12 +349,68 @@ export const pt: Messages = { nothingToInstall: "informe ids de skills ou --pack ", completionShell: (shell) => `shell desconhecido «${shell}»: bash, zsh, fish ou powershell`, noneInstalled: "Nenhuma skill instalada.", - outdatedCmd: "lista as skills globais com versão mais recente no GitHub", + outdatedCmd: "lista as skills instaladas (projeto e globais) com versão mais recente no GitHub", showFailed: (id, msg) => `Não foi possível ler o SKILL.md de ${id}: ${msg}`, upToDate: "Todas as skills globais estão atualizadas.", updatesUnchecked: (n) => `Verificação de atualizações incompleta: ${n} verificação(ões) de repositório falharam, essas skills podem estar desatualizadas.`, + packEmpty: "Marque primeiro skills do catálogo: um pack contém skills instaláveis a partir do GitHub.", + packNamePrompt: "Nome do pack:", + packDescriptionPrompt: "Descrição:", + packShipped: (id) => `${id} é um pack incluído: não pode ser alterado nem excluído (crie o seu com N na árvore, ou skills-atlas profile create).`, + packUpdated: (id, n) => `Pack ${id} salvo (${n} skills)`, + packDeleteConfirm: (id) => `Excluir o pack ${id}? Seus backups continuam restauráveis.`, + packDeleted: (id) => `Pack ${id} excluído`, + profileScopePrompt: "Escopo: [g]lobal ou [p]rojeto?", + backupPickPrompt: "Número do backup a restaurar (Enter cancela):", + packAddOption: "skills a adicionar (owner/repo@nome)", + packRemoveOption: "skills a remover", + packEditCmd: "altera um pack pessoal: nome, descrição, skills (perguntados se não houver opção)", + packDeleteCmd: "exclui um pack pessoal (seus backups continuam restauráveis)", + backToTree: "Enter: voltar à árvore", + profileCmd: "perfis: ativar um pack sozinho (as outras skills do escopo vão para um backup) e restaurar backups", + profileListCmd: "lista os packs utilizáveis como perfis e o ativo", + profileHint: "skills-atlas profile use ativa um (-p para este projeto); skills-atlas profile restore volta tudo ao lugar.", + profileCreateCmd: "cria um pack pessoal (um perfil) a partir de ids de skills ou do que está instalado", + profileName: "nome do perfil, ex. \"Design\"", + profileFromInstalled: "adiciona as skills instaladas com fonte no GitHub (-g/-p para um escopo)", + profileDescription: "para que serve o perfil", + profileUnknown: (ids) => `fora do catálogo, ignorados: ${ids}`, + profileDefaultDescription: (n) => `${n} skills`, + profileCreated: (id, n) => `Pack ${id} criado com ${n} skills: skills-atlas profile use ${id}`, + profileUseCmd: "ativa um pack sozinho: mantém suas skills, instala as que faltam, remove todas as outras do escopo (após um backup)", + profilePack: "id do pack (ver skills-atlas profile list)", + profileTitle: (name, scope) => `Perfil ${name} (${scope})`, + profileKeep: (n) => `${n} skill(s) do pack já instalada(s), mantida(s)`, + profileWarning: (n) => `${n} outra(s) skill(s) serão removidas deste escopo, incluindo as caseiras. Antes são salvas; skills-atlas profile restore as devolve exatamente.`, + profileNeedsYes: "isto altera as skills instaladas: execute num terminal para confirmar, ou passe -y", + profileConfirm: "Salvar este escopo e depois aplicar o perfil?", + profileBackedUp: (id) => `Backup salvo: ${id}`, + profileUndo: (id) => `Para voltar atrás: skills-atlas profile restore ${id}`, + profileBackupsCmd: "lista os backups feitos antes de cada troca de perfil", + profileNoBackup: "Ainda sem backup: um é feito antes de cada troca de perfil.", + profileInitial: "inicial", + profileRestoreHint: "skills-atlas profile restore (ou --initial) devolve um escopo exatamente como estava.", + profileBackupArg: "id do backup (padrão: o mais recente)", + profileInitialOption: "restaura o primeiro backup: a configuração antes de qualquer perfil", + profileRestoreCmd: "devolve um escopo exatamente como um backup o salvou (o estado atual é salvo antes)", + profileUnknownBackup: (id) => `não há backup ${id} (ver skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `Restaurar ${id} (${scope}, ${n} skills):`, + profileRestoreConfirm: "Substituir as skills deste escopo por este backup?", + profileRestored: (id) => `${id} restaurado`, + sizeOption: "mostra quanto cada skill custa no contexto do agente (tokens estimados) e em disco", + startupLoad: (n) => `≈ ${n} tokens no início`, + sizeLegend: "tok: tokens estimados carregados a cada início de sessão (nome e descrição) / ao usar a skill (SKILL.md).", + heavyLoad: (agent, n, budget) => `${agent}: ≈ ${n} tokens de descrições de skills a cada início de sessão (acima de ${budget}). Desmarque as que não usa.`, + updatesModified: (list) => `Editadas à mão, não atualizadas: ${list}`, + suggestCmd: "sugere skills para um projeto conforme aquilo com que é feito (package.json, pyproject.toml, Dockerfile…)", + suggestDir: "diretório do projeto (padrão: o atual)", + suggestLimit: "skills por tecnologia (1 a 10, padrão 3)", + suggestNothing: "Nenhuma tecnologia conhecida encontrada neste projeto.", + suggestNone: "nada de novo no catálogo", + suggestPacks: (list) => `Packs adequados: ${list} (skills-atlas install --pack )`, + suggestHint: "Leia uma com skills-atlas show e instale as que quiser:", outdatedHint: "Para atualizar uma, reinstale-a: skills-atlas install -g -a -y (ou tecle u na árvore).", - updateCmd: "atualiza as skills globais desatualizadas: todas, ou as indicadas", + updateCmd: "atualiza as skills desatualizadas, de projeto e globais: todas, ou as indicadas", updateAllHint: "Atualizar todas: skills-atlas update", rollbackCmd: 'reinstala a versão que um skill global tinha antes da última atualização', rollbackList: 'lista as versões registadas', @@ -365,6 +469,7 @@ export const pt: Messages = { themes: { recommended: 'Recomendados', trending: 'Em alta', + suggested: 'Para este projeto', installed: 'Já instalados', frontend: 'Frontend e web', backend: 'Backend e APIs', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 187ae9c..de6d5e1 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -35,6 +35,21 @@ export const zh: Messages = { clear: '清除', update: '更新', updateAll: "全部更新", + nextField: "下一栏", + save: "保存", + yes: "是", + no: "否", + restore: "恢复", + scope: "范围", + profileApply: "保存并安装", + warnings: (n) => `警告(${n})`, + newPack: "新建技能包", + editPack: "编辑技能包", + deletePack: "删除技能包", + useProfile: "用作配置档", + backups: "备份", + savePack: "保存技能包", + cancelEdit: "取消", preview: '预览', }, @@ -84,6 +99,17 @@ export const zh: Messages = { noNewRisk: '相对你的版本没有新的警示信号', noRisk: "未发现警示信号(模式检查,并非保证)", growth: (n) => `自上次快照以来新增 ${n} 次安装`, + andMore: (n) => `… 另有 ${n} 个`, + fieldRequired: (label) => `${label}:必填`, + packNameField: "名称", + packDescriptionField: "描述", + packNewTitle: (n) => `用勾选的 ${n} 个技能新建技能包`, + packEditTitle: (label, n) => `${label}:${n} 个技能`, + restoring: "正在恢复…", + warningsTitle: (n) => `警告(${n})`, + editingPack: (label, n) => `正在编辑 ${label}:勾选其技能(${n})`, + cost: (a, b) => `每次会话启动约 ${a} 个 token(描述)· 使用时约 ${b} 个(SKILL.md)`, + suggestedFor: (x) => `适用于此项目中的 ${x}`, needs: '需要', updated: (date) => `更新于 ${date}`, growthSuspect: (n) => `未计入热门:相同的 skill 在另外 ${n} 个仓库中同步增长`, @@ -125,7 +151,7 @@ export const zh: Messages = { auditUnread: "未审计:无法读取其 SKILL.md", auditPassed: (n) => `${n} 个技能通过安全审计`, auditToggle: "仍然安装", - update: (n) => `更新 ${n} 个技能(全局)`, + update: (n) => `更新 ${n} 个技能`, }, progress: { @@ -175,6 +201,28 @@ export const zh: Messages = { serving: 'Skills Atlas 已打开:', stop: '按 Ctrl+C 停止。', needAgent: '请至少选择一个智能体。', + packEdit: "编辑此技能包…", + packDelete: "删除此技能包", + packEditTitle: "编辑技能包 {id}", + packMembers: "技能({n}):取消勾选即可移除", + packAddSelection: "添加当前选择中的 {n} 个技能", + packSaveChanges: "保存更改", + packDeleteConfirm: "删除技能包 {id}?其备份仍可恢复。", + profileUse: "将此技能包用作配置档…", + profileTitle: "配置档:{name}", + profileKeep: "技能包中已安装的 {n} 个技能将保留。", + profileWarning: "将从该范围移除其他 {n} 个技能,包括自制技能。它们会先被保存,可原样恢复。", + profileActivate: "保存后启用", + profileNothing: "无需更改", + profileUndo: "更改前已保存备份 {id}:可在“备份”中恢复。", + backupsOpen: "备份({n})", + backupsTitle: "备份", + backupsHint: "每次切换配置档都会先保存该范围。恢复会原样还原,并先保存当前状态。", + backupSkills: "{n} 个技能", + backupInitial: "初始", + backupRestore: "恢复", + backupRestoreConfirm: "用此备份替换该范围的技能?当前状态会先被保存。", + backupRestored: "已恢复 {id}。如需撤销,请恢复 {undo}。", busy: '已有安装正在进行。', tagline: '按主题浏览所有公开的 Agent Skills —— 选择、安装、卸载。', searchPlaceholder: '按名称、仓库或描述搜索', @@ -299,12 +347,68 @@ export const zh: Messages = { nothingToInstall: "请提供技能 ID 或 --pack ", completionShell: (shell) => `未知的 shell「${shell}」:bash、zsh、fish 或 powershell`, noneInstalled: "未安装任何技能。", - outdatedCmd: "列出 GitHub 上有新版本的全局技能", + outdatedCmd: "列出 GitHub 上有新版本的已安装技能(项目和全局)", showFailed: (id, msg) => `无法读取 ${id} 的 SKILL.md:${msg}`, upToDate: "所有全局技能均为最新。", updatesUnchecked: (n) => `更新检查不完整:${n} 个仓库无法检查,这些 skill 可能已过时。`, + packEmpty: "请先勾选目录中的技能:技能包只包含可从 GitHub 安装的技能。", + packNamePrompt: "技能包名称:", + packDescriptionPrompt: "描述:", + packShipped: (id) => `${id} 是内置技能包,不能修改或删除(在树中按 N 或用 skills-atlas profile create 创建自己的)。`, + packUpdated: (id, n) => `已保存技能包 ${id}(${n} 个技能)`, + packDeleteConfirm: (id) => `删除技能包 ${id}?其备份仍可恢复。`, + packDeleted: (id) => `已删除技能包 ${id}`, + profileScopePrompt: "范围:[g] 全局 还是 [p] 项目?", + backupPickPrompt: "要恢复的备份编号(回车取消):", + packAddOption: "要添加的技能(owner/repo@name)", + packRemoveOption: "要移除的技能", + packEditCmd: "修改个人技能包:名称、描述、技能(未给出选项时会询问)", + packDeleteCmd: "删除个人技能包(其备份仍可恢复)", + backToTree: "回车:返回树", + profileCmd: "配置档:单独启用一个技能包(该范围的其他技能移入备份),以及恢复备份", + profileListCmd: "列出可用作配置档的技能包及当前启用的配置档", + profileHint: "skills-atlas profile use 启用一个(-p 表示本项目);skills-atlas profile restore 恢复原状。", + profileCreateCmd: "根据技能 ID 或已安装的技能创建个人技能包(配置档)", + profileName: "配置档名称,例如 \"Design\"", + profileFromInstalled: "加入有 GitHub 来源的已安装技能(-g/-p 指定范围)", + profileDescription: "配置档的用途", + profileUnknown: (ids) => `不在目录中,已忽略:${ids}`, + profileDefaultDescription: (n) => `${n} 个技能`, + profileCreated: (id, n) => `已创建包含 ${n} 个技能的技能包 ${id}:skills-atlas profile use ${id}`, + profileUseCmd: "单独启用一个技能包:保留其技能,安装缺少的,移除该范围的所有其他技能(先备份)", + profilePack: "技能包 ID(见 skills-atlas profile list)", + profileTitle: (name, scope) => `配置档 ${name}(${scope})`, + profileKeep: (n) => `技能包中已安装的 ${n} 个技能将保留`, + profileWarning: (n) => `将从该范围移除其他 ${n} 个技能,包括自制技能。它们会先被保存;skills-atlas profile restore 可原样恢复。`, + profileNeedsYes: "这会更改已安装的技能:请在终端中运行以确认,或使用 -y", + profileConfirm: "先保存该范围,再应用配置档?", + profileBackedUp: (id) => `备份已保存:${id}`, + profileUndo: (id) => `如需撤销:skills-atlas profile restore ${id}`, + profileBackupsCmd: "列出每次切换配置档前的备份", + profileNoBackup: "还没有备份:每次切换配置档前都会保存一个。", + profileInitial: "初始", + profileRestoreHint: "skills-atlas profile restore (或 --initial)可将范围原样恢复。", + profileBackupArg: "备份 ID(默认:最新)", + profileInitialOption: "恢复第一个备份:启用任何配置档之前的配置", + profileRestoreCmd: "将范围原样恢复为备份时的状态(先保存当前状态)", + profileUnknownBackup: (id) => `没有备份 ${id}(见 skills-atlas profile backups)`, + profileRestoreTitle: (id, scope, n) => `恢复 ${id}(${scope},${n} 个技能):`, + profileRestoreConfirm: "用此备份替换该范围的技能?", + profileRestored: (id) => `已恢复 ${id}`, + sizeOption: "显示每个技能在智能体上下文中的开销(估算 token)和磁盘占用", + startupLoad: (n) => `启动时约 ${n} 个 token`, + sizeLegend: "tok:每次会话启动时加载的估算 token(名称和描述)/ 使用技能时(SKILL.md)。", + heavyLoad: (agent, n, budget) => `${agent}:每次会话启动加载约 ${n} 个 token 的技能描述(超过 ${budget})。取消勾选不用的技能。`, + updatesModified: (list) => `已手动修改,未更新:${list}`, + suggestCmd: "根据项目所用技术(package.json、pyproject.toml、Dockerfile…)推荐技能", + suggestDir: "项目目录(默认:当前目录)", + suggestLimit: "每种技术的技能数(1 到 10,默认 3)", + suggestNothing: "在此项目中未发现已知技术。", + suggestNone: "目录中没有新内容", + suggestPacks: (list) => `适合的技能包:${list}(skills-atlas install --pack )`, + suggestHint: "用 skills-atlas show 查看,然后安装需要的技能:", outdatedHint: "更新方法是重新安装:skills-atlas install -g -a -y(或在树中按 u)", - updateCmd: "更新过时的全局技能:全部,或指定的技能", + updateCmd: "更新过时的技能(项目和全局):全部,或指定的技能", updateAllHint: "全部更新:skills-atlas update", rollbackCmd: '重新安装全局 skill 在上次更新前的版本', rollbackList: '列出已记录的版本', @@ -363,6 +467,7 @@ export const zh: Messages = { themes: { recommended: '推荐', trending: '趋势', + suggested: '适合此项目', installed: '已安装', frontend: '前端与 Web', backend: '后端与 API', diff --git a/src/install/diff.ts b/src/install/diff.ts index fdbdf32..d61b08d 100644 --- a/src/install/diff.ts +++ b/src/install/diff.ts @@ -131,7 +131,7 @@ export async function updateDiff( ): Promise { const all = installed ?? (await listInstalled('global')); const local = all.find((s) => s.source?.toLowerCase() === skill.source.toLowerCase() && s.name.toLowerCase() === skill.name.toLowerCase() && s.path); - if (!local) throw new Error(`${skill.source}@${skill.name} is not installed globally`); + if (!local) throw new Error(`${skill.source}@${skill.name} is not installed${installed ? '' : ' globally'}`); const before = stripControls(readFileSync(join(local.path!, 'SKILL.md'), 'utf8')); const after = await read(skill); const lines = lineDiff(before, after); diff --git a/src/install/plan.ts b/src/install/plan.ts index ed2fa95..db881ac 100644 --- a/src/install/plan.ts +++ b/src/install/plan.ts @@ -6,6 +6,8 @@ export interface Update { id: string; /** Agent display names, as the installed-skill scan reports them, or agent ids. */ agents: string[]; + /** Where it is installed and outdated; global when absent. */ + scope?: Scope; } export interface InstallCommand { @@ -76,20 +78,23 @@ export function planInstall(ids: string[], opts: InstallOptions, revisions: Reco } /** - * Updates are reinstalls: `skills add` again, in the global scope (the only - * one whose lock lets updates be detected), for the agents that have each - * skill. `skills update` is not used: it can exit 0 having skipped the skill. - * Agents unknown to the list fall back to the options' agents. + * Updates are reinstalls: `skills add` again, in the skill's own scope (global + * when it names none), for the agents that have each skill. `skills update` is + * not used: it can exit 0 having skipped the skill. Agents unknown to the list + * fall back to the options' agents. */ export function planUpdates(updates: Update[], opts: InstallOptions): InstallCommand[] { const byAgents = new Map(); for (const u of updates) { // Display names from the tree's scan, or agent ids from `outdated`. const ids = u.agents.map((name) => AGENTS.find((a) => a.displayName === name || a.id === name)?.id).filter((id): id is string => !!id); - const key = [...new Set(ids.length ? ids : opts.agents)].sort().join(' '); + const key = `${u.scope ?? 'global'} ${[...new Set(ids.length ? ids : opts.agents)].sort().join(' ')}`; byAgents.set(key, [...(byAgents.get(key) ?? []), u.id]); } - return [...byAgents].flatMap(([agents, ids]) => planInstall(ids, { ...opts, agents: agents.split(' '), scope: 'global' })); + return [...byAgents].flatMap(([key, ids]) => { + const [scope, ...agents] = key.split(' '); + return planInstall(ids, { ...opts, agents, scope: scope as Scope }); + }); } /** diff --git a/src/install/updates.ts b/src/install/updates.ts index 569678b..a7d3fd0 100644 --- a/src/install/updates.ts +++ b/src/install/updates.ts @@ -1,9 +1,12 @@ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import pLimit from 'p-limit'; import { z } from 'zod'; import { fetchJson, HttpError } from '../catalog/http.js'; -import { lockPath } from './scan.js'; +import type { Scope } from '../types.js'; +import { installedSkillPaths, lockPath } from './scan.js'; /** A skill of the global lock file, with the git tree SHA of its folder when it was installed. */ export interface LockedSkill { @@ -31,11 +34,21 @@ const Lock = z.object({ })), }); const Tree = z.object({ sha: z.string(), truncated: z.boolean().optional(), tree: z.array(z.object({ path: z.string(), type: z.string(), sha: z.string() })) }); +type Tree = z.infer; /** What an update check found: confirmed updates, and the repositories (or skills) it could not check. */ export interface UpdateCheck { outdated: Set; unchecked: { source: string; reason: string }[]; + /** Project skills whose files no longer match their lock: edited by hand, so never overwritten by an update. */ + modified?: string[]; +} + +/** Both scopes checked: the confirmed updates of each, and everything that could not be checked. */ +export interface ScopedUpdateCheck { + outdated: Record>; + unchecked: UpdateCheck['unchecked']; + modified: string[]; } /** @@ -97,33 +110,157 @@ function reason(err: unknown): string { */ export async function findUpdates(path = lockPath('global', process.cwd()), token = githubToken()): Promise { const skills = readLockedSkills(path); + const { fetched, unchecked } = await headTrees(skills.map((s) => s.source), token); const trees = new Map>(); const truncated = new Set(); + for (const [source, tree] of fetched) { + // '' is the repository root, for a skill whose SKILL.md sits there. + trees.set(source, new Map([['', tree.sha], ...tree.tree.filter((e) => e.type === 'tree').map((e) => [e.path, e.sha] as [string, string])])); + if (tree.truncated) truncated.add(source); + } + for (const s of skills) { + const tree = trees.get(s.source); + if (tree && !tree.has(s.dir)) unchecked.push({ source: s.source, reason: `${s.name}: ${truncated.has(s.source) ? 'tree truncated' : `${s.dir} not found`}` }); + } + return { outdated: outdated(skills, trees), unchecked }; +} + +/** The HEAD tree of each repository, one Trees API call each, and the repositories that could not be read. */ +async function headTrees(sources: string[], token?: string): Promise<{ fetched: Map; unchecked: UpdateCheck['unchecked'] }> { + const fetched = new Map(); const unchecked: UpdateCheck['unchecked'] = []; const limit = pLimit(8); await Promise.all( - [...new Set(skills.map((s) => s.source))].map((source) => + [...new Set(sources)].map((source) => limit(async () => { try { - const tree = Tree.parse( + fetched.set(source, Tree.parse( await fetchJson(`https://api.github.com/repos/${source}/git/trees/HEAD?recursive=1`, { retries: 0, timeoutMs: 10_000, headers: { accept: 'application/vnd.github+json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, }), - ); - // '' is the repository root, for a skill whose SKILL.md sits there. - trees.set(source, new Map([['', tree.sha], ...tree.tree.filter((e) => e.type === 'tree').map((e) => [e.path, e.sha] as [string, string])])); - if (tree.truncated) truncated.add(source); + )); } catch (err) { unchecked.push({ source, reason: reason(err) }); } }), ), ); + return { fetched, unchecked }; +} + +/** A GitHub skill of the project lock (`skills-lock.json`), with the content hash the installer computed. */ +export interface ProjectLockedSkill { + name: string; + source: string; + dir: string; + computedHash: string; +} + +const ProjectLock = z.object({ + skills: z.record(z.string(), z.object({ + source: z.string().optional(), + sourceType: z.string().optional(), + skillPath: z.string().optional(), + computedHash: z.string().optional(), + ref: z.string().optional(), + })), +}); + +/** + * GitHub skills of the project lock that can be checked: a source, a skill path and a content hash. + * A skill installed at a ref is left out: it is pinned (`skills-atlas lock`), its HEAD is not its version. + */ +export function readProjectLockedSkills(path: string): ProjectLockedSkill[] { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, 'utf8')); + } catch { + return []; + } + const lock = ProjectLock.safeParse(raw); + if (!lock.success) return []; + return Object.entries(lock.data.skills).flatMap(([name, e]) => { + if (e.sourceType !== 'github' || !e.source || !/^[\w.-]+\/[\w.-]+$/.test(e.source)) return []; + if (!e.skillPath || !e.computedHash || e.ref) return []; + return [{ name, source: e.source, dir: e.skillPath.replace(/\/?SKILL\.md$/i, ''), computedHash: e.computedHash }]; + }); +} + +/** Files the skills CLI never copies, so never compared. */ +const notCopied = (path: string) => { + const parts = path.split('/'); + return parts.at(-1) === 'metadata.json' || parts.slice(0, -1).some((p) => ['.git', '__pycache__', '__pypackages__'].includes(p)); +}; + +/** Every file of a folder, by path relative to it (`/` separated). */ +function readFolder(dir: string, prefix = '', out = new Map()): Map { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relative = prefix + entry.name; + if (entry.isDirectory()) readFolder(join(dir, entry.name), relative + '/', out); + else if (entry.isFile()) out.set(relative, readFileSync(join(dir, entry.name))); + } + return out; +} + +/** The content hash the skills CLI writes as `computedHash`: SHA-256 of each path then its bytes, in path order. */ +export function computedFolderHash(files: Map): string { + const hash = createHash('sha256'); + const kept = [...files].filter(([path]) => !path.split('/').slice(0, -1).some((p) => p === '.git' || p === 'node_modules')); + for (const [path, bytes] of kept.sort(([a], [b]) => a.localeCompare(b))) hash.update(path).update(bytes); + return hash.digest('hex'); +} + +const blobSha = (bytes: Buffer) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); + +/** + * Project skills with a newer version on GitHub. The project lock keeps a content hash, not + * GitHub's folder hash, so the installed files are compared blob by blob with the repository's + * HEAD. A copy that differs and no longer matches its lock hash was edited here: it is + * reported as modified, never as outdated, so an update cannot overwrite the edits. + */ +export async function findProjectUpdates(cwd = process.cwd(), token = githubToken()): Promise { + const skills = readProjectLockedSkills(lockPath('project', cwd)) + .map((s) => ({ ...s, path: installedSkillPaths('project', cwd, s.name)[0] })) + .filter((s): s is ProjectLockedSkill & { path: string } => !!s.path); + const { fetched, unchecked } = await headTrees(skills.map((s) => s.source), token); + const out = new Set(); + const modified: string[] = []; for (const s of skills) { - const tree = trees.get(s.source); - if (tree && !tree.has(s.dir)) unchecked.push({ source: s.source, reason: `${s.name}: ${truncated.has(s.source) ? 'tree truncated' : `${s.dir} not found`}` }); + const tree = fetched.get(s.source); + if (!tree) continue; + const prefix = s.dir ? `${s.dir}/` : ''; + const upstream = new Map(tree.tree + .filter((e) => e.type === 'blob' && e.path.startsWith(prefix) && !notCopied(e.path.slice(prefix.length))) + .map((e) => [e.path.slice(prefix.length), e.sha.toLowerCase()])); + if (!upstream.has('SKILL.md')) { + unchecked.push({ source: s.source, reason: `${s.name}: ${tree.truncated ? 'tree truncated' : `${s.dir || '/'} not found`}` }); + continue; + } + let files: Map; + try { + files = readFolder(s.path); + } catch { + unchecked.push({ source: s.source, reason: `${s.name}: installed folder unreadable` }); + continue; + } + const local = [...files].filter(([path]) => !notCopied(path)); + const same = local.length === upstream.size && local.every(([path, bytes]) => upstream.get(path) === blobSha(bytes)); + if (same) continue; + const id = `${s.source}@${s.name}`.toLowerCase(); + if (computedFolderHash(files) === s.computedHash) out.add(id); + else modified.push(id); } - return { outdated: outdated(skills, trees), unchecked }; + return { outdated: out, unchecked, modified }; +} + +/** Updates of both scopes (or the ones asked), checked together. */ +export async function checkUpdates(scopes: Scope[] = ['project', 'global'], cwd = process.cwd(), token = githubToken()): Promise { + const none: UpdateCheck = { outdated: new Set(), unchecked: [] }; + const [project, global] = await Promise.all([ + scopes.includes('project') ? findProjectUpdates(cwd, token) : none, + scopes.includes('global') ? findUpdates(lockPath('global', cwd), token) : none, + ]); + return { outdated: { project: project.outdated, global: global.outdated }, unchecked: [...project.unchecked, ...global.unchecked], modified: project.modified ?? [] }; } diff --git a/src/mcp.ts b/src/mcp.ts index d3d3882..bb70011 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,16 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { z } from 'zod'; -import { agentIds, installedSkills, outdatedSkills, searchSkills, showSkill } from './agent-api.js'; +import { agentIds, installedSkills, outdatedSkills, projectSyncPlan, searchSkills, showSkill, suggestForProject } from './agent-api.js'; import { unknownAgents } from './agents.js'; -import { auditSkills, auditTarget, failsAudit } from './audit.js'; +import { auditSkills, auditTarget, failsAudit, type AuditResult } from './audit.js'; +import { fetchSkillFiles, fetchSkillMarkdown } from './github.js'; import { updateDiff } from './install/diff.js'; import { readPacks } from './catalog/packs.js'; import { nameCollisions } from './install/collisions.js'; -import { formatCommand, parseSkillId, planInstall, planRemove } from './install/plan.js'; -import { isSuccess, listInstalled, runAll } from './install/run.js'; +import { diagnoseInstalled } from './install/doctor.js'; +import { dropRevision, readHistory, recordBeforeUpdate, rememberRevision } from './install/history.js'; +import { formatCommand, parseSkillId, planInstall, planRemove, planUpdates } from './install/plan.js'; +import { isSuccess, listInstalled, runAll, type CommandOutcome } from './install/run.js'; +import { projectRevisionDrift } from './revisions.js'; import { riskReport, riskWhere } from './risks.js'; import { describeInstalled, findOverlaps, OVERLAP_THRESHOLD } from './overlaps.js'; import { scanUsage, transcriptsDir, usageReport } from './usage.js'; +import { missingSkills, parseSelectionFile } from './selection.js'; import { SCOPES, type Catalog, type Scope, type Skill } from './types.js'; /** @@ -32,6 +39,8 @@ export interface McpOptions { readSkill?: (s: Pick) => Promise; /** Whether installs are audited (the user's `skills-atlas audit` setting). */ audit?: boolean; + /** The project directory (sync, doctor, suggestions); the current directory by default. */ + cwd?: string; } const Scope = z.enum(['project', 'global']); @@ -91,18 +100,75 @@ const TOOLS = { annotations: { readOnlyHint: true }, }, outdated_skills: { - description: 'List global skills with a newer version on GitHub, as { skills, unchecked }: each skill with the lines the update changes in SKILL.md and the warning signs it adds (new_risks); unchecked lists the repositories that could not be checked (source, reason), so the check is complete only when it is empty. Tell the user about new_risks and unchecked before updating. Update one with install_skills, scope global, with its own agents.', - schema: z.object({}), - inputSchema: { type: 'object', properties: {} }, + description: 'List installed skills (project and global) with a newer version on GitHub, as { skills, unchecked, modified }: each skill with its scope, the lines the update changes in SKILL.md and the warning signs it adds (new_risks); unchecked lists the repositories that could not be checked (source, reason), so the check is complete only when it is empty; modified names project skills edited by hand, which are never updated. Tell the user about new_risks and unchecked before updating with update_skills.', + schema: z.object({ scope: Scope.optional() }), + inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'global'], description: 'Default: both.' } } }, annotations: { readOnlyHint: true, openWorldHint: true }, }, + update_skills: { + description: "Update outdated skills to their latest version, each in its own scope and for its own agents: all of them, or the ids given. Changes the machine: show the user what outdated_skills reported (new_risks, unchecked) and get their yes first. dry_run returns the commands without running them. A new version that fails the security audit is refused unless ignore_audit is true, which needs the user's explicit yes. Global updates can be undone with rollback_skill.", + schema: z.object({ ids: Ids.optional(), scope: Scope.optional(), dry_run: z.boolean().optional(), ignore_audit: z.boolean().optional() }), + inputSchema: { + type: 'object', + properties: { + ids: { type: 'array', items: { type: 'string' }, description: 'owner/repo@name ids to update; default: every outdated skill.' }, + scope: { type: 'string', enum: ['project', 'global'], description: 'Default: both.' }, + dry_run: { type: 'boolean' }, + ignore_audit: { type: 'boolean', description: "Update skills whose new version fails the security audit. Only with the user's explicit yes." }, + }, + }, + annotations: { destructiveHint: false, openWorldHint: true }, + }, + rollback_skill: { + description: "Reinstall the version a global skill had before its last update (or the revision given in to), for its own agents. list: true returns the recorded versions without changing anything. Changes the machine: confirm with the user first. The old version is audited at that revision; one that fails is refused unless ignore_audit is true.", + schema: z.object({ id: z.string(), to: z.string().regex(/^[a-f0-9]{40}$/i).optional(), list: z.boolean().optional(), dry_run: z.boolean().optional(), ignore_audit: z.boolean().optional() }), + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'owner/repo@name of a globally installed skill.' }, + to: { type: 'string', description: 'A full commit SHA among the recorded versions; default: the last one.' }, + list: { type: 'boolean', description: 'Only list the recorded versions.' }, + dry_run: { type: 'boolean' }, + ignore_audit: { type: 'boolean' }, + }, + required: ['id'], + }, + annotations: { destructiveHint: false, openWorldHint: true }, + }, + doctor: { + description: 'Diagnose installed skills without changing anything: broken links, unreadable folders or SKILL.md, a malformed lock file, lock entries whose skill is gone. Returns { ok, issues }.', + schema: z.object({ scope: Scope.optional() }), + inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'global'], description: 'Default: both.' } } }, + annotations: { readOnlyHint: true }, + }, + sync_project: { + description: "Install in the project what its skills-atlas.json lists and is missing (for each of its agents, or the agents given), at the pinned revision when it has one. check: true only returns what is missing. Changes the project: show the user the missing skills and get their yes first. A skill that fails the security audit is refused unless ignore_audit is true.", + schema: z.object({ agents: Agents.optional(), check: z.boolean().optional(), ignore_audit: z.boolean().optional() }), + inputSchema: { + type: 'object', + properties: { + agents: { type: 'array', items: { type: 'string' }, description: "Agent ids; default: the file's agents. Required when the file names none." }, + check: { type: 'boolean', description: 'Only list what is missing.' }, + ignore_audit: { type: 'boolean' }, + }, + }, + annotations: { destructiveHint: false, openWorldHint: true }, + }, + suggest_skills: { + description: "Suggest skills for the current project: reads its manifests and files (package.json, pyproject.toml, Cargo.toml, Dockerfile, workflows…) to find what it is built with, then returns { stack, packs }: each technology with the files that show it and the catalog skills that fit, without the ones already installed, and the curated packs that fit. Reads files only. Read the candidates with show_skill before proposing to install them.", + schema: z.object({ per_stack: z.number().int().min(1).max(10).optional() }), + inputSchema: { type: 'object', properties: { per_stack: { type: 'integer', minimum: 1, maximum: 10, description: 'Skills per technology, default 3.' } } }, + annotations: { readOnlyHint: true }, + }, install_skills: { - description: 'Install (or update) skills through npx skills add, for the given agents and scope. Changes the machine: confirm the skills, agents and scope with the user first. dry_run returns the commands, and any skill that would replace another of the same name, without running anything. Such a replacement is refused unless replace is true, which needs the user\'s say-so.', - schema: z.object({ ids: Ids, agents: Agents, scope: Scope, method: z.enum(['symlink', 'copy']).optional(), dry_run: z.boolean().optional(), replace: z.boolean().optional(), ignore_audit: z.boolean().optional() }), + description: 'Install skills, or whole packs (see list_packs), through npx skills add, for the given agents and scope. Changes the machine: confirm the skills, agents and scope with the user first. dry_run returns the commands, and any skill that would replace another of the same name, without running anything. Such a replacement is refused unless replace is true, which needs the user\'s say-so.', + schema: z.object({ ids: Ids.optional(), packs: z.array(z.string()).min(1).max(20).optional(), agents: Agents, scope: Scope, method: z.enum(['symlink', 'copy']).optional(), dry_run: z.boolean().optional(), replace: z.boolean().optional(), ignore_audit: z.boolean().optional() }) + .refine((v) => v.ids?.length || v.packs?.length, { message: 'give ids, packs or both' }), inputSchema: { type: 'object', properties: { ids: { type: 'array', items: { type: 'string' }, description: 'owner/repo@name ids.' }, + packs: { type: 'array', items: { type: 'string' }, description: 'Pack ids from list_packs: every skill of each pack is installed.' }, agents: { type: 'array', items: { type: 'string' }, description: 'Agent ids, e.g. claude-code, codex, cursor.' }, scope: { type: 'string', enum: ['project', 'global'], description: 'project: the current directory; global: every project.' }, method: { type: 'string', enum: ['symlink', 'copy'], description: 'Default symlink.' }, @@ -110,7 +176,7 @@ const TOOLS = { replace: { type: 'boolean', description: 'Allow a skill to replace an installed or selected one of the same name from another source.' }, ignore_audit: { type: 'boolean', description: "Install skills that fail the security audit (a high warning sign in their SKILL.md). Only with the user's explicit yes, after telling them the warning signs." }, }, - required: ['ids', 'agents', 'scope'], + required: ['agents', 'scope'], }, annotations: { destructiveHint: false, openWorldHint: true }, }, @@ -139,8 +205,9 @@ async function callTool(name: ToolName, args: unknown, opts: McpOptions, catalog return json(c.themes.map((th) => ({ id: th.id, label: th.label, skills: c.stats.byTheme[th.id] ?? 0 }))); } case 'show_skill': { - const { markdown, risks } = await showSkill(await catalog(), input.id); - return `${riskReport(risks)}\n\n---\n\n${markdown}`; + const { markdown, risks, cost } = await showSkill(await catalog(), input.id); + const load = `Context cost (estimated): about ${cost.startup} tokens at every session start (name and description), about ${cost.onUse} tokens when the skill is used (SKILL.md).`; + return `${riskReport(risks)}\n${load}\n\n---\n\n${markdown}`; } case 'list_packs': return json(readPacks()); @@ -164,16 +231,23 @@ async function callTool(name: ToolName, args: unknown, opts: McpOptions, catalog } case 'outdated_skills': { // Each update with what it changes: line counts and the warning signs the new version adds. - const { skills: outdated, unchecked } = await outdatedSkills(); - const local = outdated.length ? await listInstalled('global') : []; + const { skills: outdated, unchecked, modified = [] } = await outdatedSkills(input.scope ? [input.scope as Scope] : SCOPES); + const local = (await Promise.all([...new Set(outdated.map((s) => s.scope))].map((sc) => listInstalled(sc)))).flat(); const c = await catalog(); const known = new Map(c.skills.map((s) => [s.id.toLowerCase(), s])); - return json({ unchecked, skills: await Promise.all(outdated.map(async (s) => { - const diff = await updateDiff(auditTarget(s.id, known), local).catch(() => undefined); + return json({ unchecked, modified, skills: await Promise.all(outdated.map(async (s) => { + const diff = await updateDiff(auditTarget(s.id, known), local.filter((l) => l.scope === s.scope)).catch(() => undefined); return diff ? { ...s, lines_added: diff.added, lines_removed: diff.removed, files_changed: diff.files.map(({ path, status, added, removed }) => ({ path, status, lines_added: added, lines_removed: removed })), new_risks: diff.risks.map(({ rule, level, label, line, excerpt, file }) => ({ rule, level, label, file: file ?? 'SKILL.md', line, excerpt })) } : s; })) }); } case 'install_skills': { + if (input.packs) { + const packs = readPacks(); + const unknown = (input.packs as string[]).filter((id) => !packs.some((p) => p.id === id)); + if (unknown.length) throw new Error(`unknown packs: ${unknown.join(', ')} (see list_packs)`); + const fromPacks = packs.filter((p) => input.packs.includes(p.id)).flatMap((p) => p.skills); + input.ids = [...new Map([...(input.ids ?? []), ...fromPacks].map((id: string) => [id.toLowerCase(), id])).values()]; + } for (const id of input.ids as string[]) parseSkillId(id); const unknown = unknownAgents(input.agents); if (unknown.length) throw new Error(`unknown agents: ${unknown.join(', ')}`); @@ -198,6 +272,79 @@ async function callTool(name: ToolName, args: unknown, opts: McpOptions, catalog const outcomes = await (opts.run ?? runAll)(commands); return json({ ok: outcomes.every(isSuccess), results: outcomes.flatMap((o) => o.results), errors: outcomes.filter((o) => !isSuccess(o)).map((o) => o.stderr.slice(-500)) }); } + case 'update_skills': { + const { skills: outdated, unchecked, modified = [] } = await outdatedSkills(input.scope ? [input.scope as Scope] : SCOPES); + const wanted = new Set((input.ids as string[] | undefined)?.map((id) => id.toLowerCase())); + const chosen = outdated.filter((s) => !wanted.size || wanted.has(s.id.toLowerCase())); + // A skill whose agents are all unknown to the list cannot be reinstalled for the same agents. + const noAgents = chosen.filter((s) => !s.agents.length).map((s) => s.id); + const updates = chosen.filter((s) => s.agents.length).map((s) => ({ id: s.id, agents: s.agents, scope: s.scope })); + const notOutdated = [...wanted].filter((id) => !outdated.some((s) => s.id.toLowerCase() === id)); + const report = { unchecked, modified, not_outdated: notOutdated, no_agents: noAgents }; + const commands = planUpdates(updates, { agents: [], scope: 'global', method: 'symlink', skillsVersion: opts.skillsVersion }); + if (input.dry_run || !commands.length) return json({ ...report, updates, commands: commands.map(formatCommand) }); + await refuseFailing([...new Set(updates.map((u) => u.id))], opts, input.ignore_audit, catalog); + const globalIds = updates.filter((u) => u.scope === 'global').map((u) => u.id); + const missed = globalIds.length ? await recordBeforeUpdate(globalIds) : []; + const outcomes = await (opts.run ?? runAll)(commands); + return json({ ...outcome(outcomes), ...report, no_rollback: missed }); + } + case 'rollback_skill': { + parseSkillId(input.id); + const past = readHistory()[input.id.toLowerCase()] ?? []; + if (input.list) return json({ id: input.id, versions: past }); + const skill = (await (opts.installed ?? installedSkills)(['global'])).find((s) => s.id.toLowerCase() === input.id.toLowerCase()); + if (!skill) throw new Error(`${input.id} is not installed globally`); + const target = input.to ? past.find((r) => r.revision.toLowerCase() === input.to.toLowerCase()) : past.at(-1); + if (!target) throw new Error(input.to ? `${input.to} is not a recorded version of ${input.id} (list: true shows them)` : `no recorded version of ${input.id} to roll back to`); + if (!skill.agents.length) throw new Error(`${input.id}: its agents are unknown, it cannot be reinstalled for them`); + const revisions = { [skill.id.toLowerCase()]: target.revision }; + const commands = planInstall([skill.id], { agents: skill.agents, scope: 'global', method: 'symlink', skillsVersion: opts.skillsVersion }, revisions); + if (input.dry_run) return json({ revision: target.revision, commands: commands.map(formatCommand) }); + await refuseFailing([skill.id], opts, input.ignore_audit, catalog, revisions); + const outcomes = await (opts.run ?? runAll)(commands); + const result = outcome(outcomes); + if (result.ok) { + dropRevision(skill.id, target.revision); + rememberRevision(skill.id, target.revision); + } + return json({ ...result, revision: target.revision }); + } + case 'doctor': { + const issues = (input.scope ? [input.scope as Scope] : SCOPES).flatMap((sc) => diagnoseInstalled(sc, opts.cwd ?? process.cwd())); + return json({ ok: !issues.length, issues }); + } + case 'sync_project': { + const cwd = opts.cwd ?? process.cwd(); + const path = join(cwd, 'skills-atlas.json'); + let file: ReturnType; + try { + file = parseSelectionFile(readFileSync(path, 'utf8')); + } catch (err) { + throw new Error((err as NodeJS.ErrnoException).code === 'ENOENT' ? `no skills-atlas.json in ${cwd}` : `cannot read ${path}: ${(err as Error).message}`); + } + const targets = (input.agents as string[] | undefined) ?? file.agents; + const unknown = unknownAgents(targets ?? []); + if (unknown.length) throw new Error(`unknown agents: ${unknown.join(', ')}`); + const plan = await projectSyncPlan(file, targets, cwd); + const missing = plan.groups.map((g) => ({ ids: g.ids, agents: g.agents })); + if (input.check || !plan.groups.length) return json({ listed: file.ids.length, missing, pinned: Object.keys(file.revisions ?? {}).length }); + if (plan.groups.some((g) => !g.agents)) throw new Error('skills-atlas.json names no agents: pass agents (ids such as claude-code)'); + const commands = plan.groups.flatMap((g) => planInstall(g.ids, { agents: g.agents!, scope: 'project', method: 'symlink', skillsVersion: opts.skillsVersion }, file.revisions)); + await refuseFailing([...new Set(plan.groups.flatMap((g) => g.ids))], opts, input.ignore_audit, catalog, file.revisions); + const outcomes = await (opts.run ?? runAll)(commands, { cwd }); + const result = outcome(outcomes); + // Pinned skills are checked byte for byte once installed, as `skills-atlas sync` does. + let verified: boolean | undefined; + if (file.revisions && result.ok) { + const installed = await listInstalled('project', cwd); + verified = !missingSkills(file.ids, installed.map((s) => `${s.source ?? 'local'}@${s.name}`)).length + && !(await projectRevisionDrift(file.ids, file.revisions, installed, cwd)).length; + } + return json({ ...result, missing, ...(verified === undefined ? {} : { verified }) }); + } + case 'suggest_skills': + return json(await suggestForProject(await catalog(), resolve(opts.cwd ?? process.cwd()), { perStack: input.per_stack })); case 'remove_skills': { const outcomes = await (opts.run ?? runAll)(planRemove(input.names, { scope: input.scope, skillsVersion: opts.skillsVersion })); return json({ ok: outcomes.every(isSuccess), results: outcomes.flatMap((o) => o.results) }); @@ -205,6 +352,34 @@ async function callTool(name: ToolName, args: unknown, opts: McpOptions, catalog } } +/** What a run did, as the install tools report it. */ +function outcome(outcomes: CommandOutcome[]) { + return { ok: outcomes.every(isSuccess), results: outcomes.flatMap((o) => o.results), errors: outcomes.filter((o) => !isSuccess(o)).map((o) => o.stderr.slice(-500)) }; +} + +/** + * The security audit before an update, a rollback or a sync: throws, naming the warning + * signs, when a skill fails it and the user has not said yes (ignore_audit). With + * revisions, each skill is read at its revision; one that cannot be read there is refused. + */ +async function refuseFailing(ids: string[], opts: McpOptions, ignore: boolean | undefined, catalog: () => Promise, revisions?: Record): Promise { + if (opts.audit === false || ignore || !ids.length) return; + const c = await catalog(); + const known = new Map(c.skills.map((s) => [s.id.toLowerCase(), s])); + const at = (s: { id: string }) => revisions?.[s.id.toLowerCase()]; + const results: AuditResult[] = await auditSkills( + ids.map((id) => auditTarget(id, known)), + opts.readSkill ? (s) => opts.readSkill!(s) : (s) => fetchSkillMarkdown(s, at(s)), + opts.readSkill ? undefined : (s) => fetchSkillFiles(s, at(s)), + ); + const unread = results.filter((r) => r.error && at(r)); + if (unread.length) throw new Error(`Nothing installed: cannot audit ${unread.map((r) => r.id).join(', ')} at the pinned revision.`); + const failing = results.filter(failsAudit); + if (!failing.length) return; + const list = failing.map((r) => `${r.id}: ${r.risks.filter((x) => x.level === 'high').map((x) => `${x.label} (${riskWhere(x)}: ${x.excerpt})`).join('; ')}`); + throw new Error(`Nothing installed: ${failing.length} skill(s) fail the security audit. ${list.join(' | ')}. Tell the user, and call again with ignore_audit: true only if they say yes.`); +} + interface Request { jsonrpc: '2.0'; id?: string | number | null; @@ -238,7 +413,11 @@ export function createHandler(opts: McpOptions) { if (!(name in TOOLS)) return error(-32602, `unknown tool: ${name}`); const call = () => callTool(name as ToolName, msg.params?.arguments, opts, catalog); // One install or removal at a time: the next re-reads what the previous changed. Reads and dry runs do not wait. - const mutates = name === 'remove_skills' || (name === 'install_skills' && !msg.params?.arguments?.dry_run); + const args = msg.params?.arguments ?? {}; + const mutates = name === 'remove_skills' + || (['install_skills', 'update_skills'].includes(name) && !args.dry_run) + || (name === 'rollback_skill' && !args.dry_run && !args.list) + || (name === 'sync_project' && !args.check); const result = mutates ? mutations.then(call) : call(); if (mutates) mutations = result.catch(() => undefined); try { diff --git a/src/profiles.ts b/src/profiles.ts new file mode 100644 index 0000000..7f8cfa7 --- /dev/null +++ b/src/profiles.ts @@ -0,0 +1,191 @@ +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { z } from 'zod'; +import type { Pack } from './catalog/packs.js'; +import type { InstalledSkill } from './install/run.js'; +import { lockPath, skillDirectories } from './install/scan.js'; +import type { Scope } from './types.js'; + +/** + * Profiles: a personal pack activated on its own in one scope. Activating one keeps the + * pack's skills, installs those that are missing and removes every other skill of the + * scope, home-made ones included. Each activation first saves the whole scope as it is + * on disk: every skills directory (folders copied, links kept as links) and the lock + * file, byte for byte. Restoring a backup puts that state back exactly, offline, edited + * and home-made skills included; it saves the current state first, so it can be undone too. + * The first backup of a scope is its initial configuration: it is never pruned. + */ + +/** Backups kept per scope (and project), besides the initial one. */ +const KEEP = 10; + +export function backupsRoot(): string { + const state = process.env.XDG_STATE_HOME?.trim() || join(process.env.HOME || process.env.USERPROFILE || '.', '.local', 'state'); + return join(state, 'skills-atlas', 'backups'); +} + +const Entry = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('dir'), name: z.string() }), + z.object({ kind: z.literal('file'), name: z.string() }), + z.object({ kind: z.literal('link'), name: z.string(), target: z.string() }), +]); +const Manifest = z.object({ + version: z.literal(1), + id: z.string(), + createdAt: z.string(), + scope: z.enum(['project', 'global']), + /** The project, for a project backup. */ + cwd: z.string().optional(), + /** Why it was taken: `profile:`, `restore`, `manual`. */ + reason: z.string(), + initial: z.boolean(), + /** The skills it holds, for listings: name and source. */ + skills: z.array(z.object({ name: z.string(), source: z.string().optional() })), + dirs: z.array(z.object({ path: z.string(), exists: z.boolean(), entries: z.array(Entry) })), + lock: z.object({ path: z.string(), saved: z.boolean() }), +}); +export type Backup = z.infer; + +const safeName = (name: string) => name !== '' && name !== '.' && name !== '..' && !/[\\/\0]/.test(name); + +function readManifest(dir: string): Backup | undefined { + try { + const parsed = Manifest.safeParse(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))); + if (!parsed.success) return undefined; + // A manifest names entries only: one that would step out of its directory is not a backup we restore. + if (parsed.data.dirs.some((d) => d.entries.some((e) => !safeName(e.name)))) return undefined; + return parsed.data; + } catch { + return undefined; + } +} + +/** Whether two paths name the same folder; Windows paths ignore case (`C:\\` and `c:\\`). */ +const samePath = (a: string, b: string) => (process.platform === 'win32' ? resolve(a).toLowerCase() === resolve(b).toLowerCase() : resolve(a) === resolve(b)); + +/** The backups of a scope (of this project, for the project scope), newest first. */ +export function listBackups(scope?: Scope, cwd = process.cwd(), root = backupsRoot()): Backup[] { + let ids: string[]; + try { + ids = readdirSync(root); + } catch { + return []; + } + return ids + .map((id) => readManifest(join(root, id))) + .filter((b): b is Backup => !!b && (!scope || b.scope === scope) && (b.scope === 'global' || samePath(b.cwd ?? '', cwd))) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)); +} + +/** + * Saves every skills directory of a scope and its lock file. Returns the backup; prunes + * the oldest ones beyond KEEP, never the initial one. + */ +export function backupScope(scope: Scope, reason: string, installed: Pick[], cwd = process.cwd(), root = backupsRoot(), now = new Date()): Backup { + const previous = listBackups(scope, cwd, root); + const stamp = now.toISOString().replace(/[:.]/g, '-'); + let id = `${stamp}-${scope}`; + for (let i = 2; existsSync(join(root, id)); i++) id = `${stamp}-${scope}-${i}`; + const dir = join(root, id); + const tmp = join(root, `.${id}.tmp`); + mkdirSync(tmp, { recursive: true }); + try { + const dirs: Backup['dirs'] = []; + [...skillDirectories(scope, cwd).keys()].forEach((path, i) => { + const exists = existsSync(path); + const entries: Backup['dirs'][number]['entries'] = []; + if (exists) { + for (const name of readdirSync(path).sort()) { + const full = join(path, name); + const stat = lstatSync(full); + if (stat.isSymbolicLink()) entries.push({ kind: 'link', name, target: readlinkSync(full) }); + else if (stat.isDirectory() || stat.isFile()) { + // Folders are copied as they are, links inside them kept as links. + cpSync(full, join(tmp, 'dirs', String(i), name), { recursive: true, verbatimSymlinks: true }); + entries.push({ kind: stat.isDirectory() ? 'dir' : 'file', name }); + } + } + } + dirs.push({ path, exists, entries }); + }); + const lock = lockPath(scope, cwd); + const saved = existsSync(lock); + if (saved) cpSync(lock, join(tmp, 'lock')); + const backup: Backup = { + version: 1, id, createdAt: now.toISOString(), scope, ...(scope === 'project' ? { cwd: resolve(cwd) } : {}), reason, + initial: previous.length === 0, + skills: installed.map((s) => ({ name: s.name, ...(s.source ? { source: s.source } : {}) })), + dirs, lock: { path: lock, saved }, + }; + writeFileSync(join(tmp, 'manifest.json'), `${JSON.stringify(backup, null, 2)}\n`); + renameSync(tmp, dir); + for (const old of previous.filter((b) => !b.initial).slice(KEEP - 1)) rmSync(join(root, old.id), { recursive: true, force: true }); + return backup; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +/** + * Puts a scope back as a backup saved it: every skills directory holds exactly the + * entries it held then, and the lock file is the same (or absent, if it was). The + * current state is saved first; that backup is returned, to undo the restore. + */ +export function restoreBackup(id: string, installed: Pick[], cwd = process.cwd(), root = backupsRoot()): Backup { + const dir = join(root, id); + const backup = readManifest(dir); + if (!backup || backup.id !== id) throw new Error(`no backup ${id}`); + const where = backup.scope === 'project' ? backup.cwd! : cwd; + const undo = backupScope(backup.scope, `restore:${id}`, installed, where, root); + // Directories the scope has now but the backup did not know (an agent added since) are emptied too. + const known = new Set(backup.dirs.map((d) => d.path)); + const extra = [...skillDirectories(backup.scope, where).keys()].filter((p) => !known.has(p)).map((path) => ({ path, exists: false, entries: [] })); + for (const [i, d] of [...backup.dirs.entries(), ...extra.map((d) => [-1, d] as const)]) { + if (existsSync(d.path)) { + for (const name of readdirSync(d.path)) rmSync(join(d.path, name), { recursive: true, force: true }); + } + if (!d.entries.length) continue; + mkdirSync(d.path, { recursive: true }); + for (const e of d.entries) { + const to = join(d.path, e.name); + if (e.kind === 'link') symlinkSync(...linkArgs(e.target, to)); + else cpSync(join(dir, 'dirs', String(i), e.name), to, { recursive: true, verbatimSymlinks: true }); + } + } + const lock = backup.lock.path; + if (backup.lock.saved) { + mkdirSync(dirname(lock), { recursive: true }); + cpSync(join(dir, 'lock'), lock); + } else rmSync(lock, { force: true }); + return undo; +} + +/** + * How to recreate a saved link. On Windows, as the skills CLI does, a junction: it needs + * no administrator rights, and its target must be absolute (a relative one is resolved + * from the link's folder, not the current directory). + */ +export function linkArgs(target: string, to: string, platform: NodeJS.Platform = process.platform): [string, string, 'junction' | 'dir'] { + return platform === 'win32' ? [resolve(dirname(to), target), to, 'junction'] : [target, to, 'dir']; +} + +/** What activating a pack as a profile does in a scope: what stays, what goes, what comes. */ +export interface ProfilePlan { + keep: string[]; + /** Installed skills of the scope outside the pack, by name (home-made ones too). */ + remove: { name: string; id: string }[]; + /** Pack skills the scope does not have yet. */ + install: string[]; +} + +export function profilePlan(pack: Pick, installed: Pick[]): ProfilePlan { + const wanted = new Map(pack.skills.map((id) => [id.toLowerCase(), id])); + const id = (s: Pick) => `${s.source ?? 'local'}@${s.name}`; + const keep = installed.filter((s) => wanted.has(id(s).toLowerCase())).map(id); + const have = new Set(keep.map((k) => k.toLowerCase())); + return { + keep, + remove: installed.filter((s) => !wanted.has(id(s).toLowerCase())).map((s) => ({ name: s.name, id: id(s) })), + install: [...wanted.values()].filter((w) => !have.has(w.toLowerCase())), + }; +} diff --git a/src/suggest.ts b/src/suggest.ts new file mode 100644 index 0000000..b6a4aa3 --- /dev/null +++ b/src/suggest.ts @@ -0,0 +1,297 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { isStale } from './catalog/query.js'; +import type { Catalog, Skill, Theme } from './types.js'; + +/** + * Skills for the project at hand: what its manifests and files say it uses + * (frameworks, databases, tools), then the catalog skills named after each of + * them. Nothing leaves the machine: the project is read, the catalog is local. + */ + +/** A technology found in the project, with what gave it away (`package.json: react`). */ +export interface StackSignal { + id: string; + label: string; + evidence: string[]; +} + +interface StackRule { + id: string; + label: string; + /** npm packages, exact or `@scope/*`. */ + npm?: string[]; + /** Python distributions, as requirements and pyproject list them. */ + python?: string[]; + /** Rust crates in Cargo.toml. */ + crates?: string[]; + /** Files or folders whose presence is enough, relative to the project root. */ + files?: string[]; + /** File names, at the root, matching this. */ + rootFile?: RegExp; + /** Other text manifests and a word they must hold (`Gemfile` + `rails`). */ + manifest?: [file: string, word: RegExp][]; + /** Words of a skill's name that make it a match (long ones also inside a joined name: `nextjs-app`). */ + names: string[]; + /** Words that make a name about something else (`react-native` is not React). */ + exclude?: string[]; + /** GitHub owners who publish this technology: their skills come first and match by owner alone. */ + owners?: string[]; + /** Skills to suggest first when the catalog has them. */ + ids?: string[]; + /** Curated packs that fit. */ + packs?: string[]; +} + +const RULES: StackRule[] = [ + { id: 'react', label: 'React', npm: ['react'], names: ['react'], exclude: ['native'], ids: ['vercel-labs/agent-skills@vercel-react-best-practices'], packs: ['frontend'] }, + { id: 'nextjs', label: 'Next.js', npm: ['next'], files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], names: ['nextjs'], packs: ['frontend'] }, + { id: 'vue', label: 'Vue', npm: ['vue'], names: ['vue', 'pinia'], owners: ['vuejs-ai'], packs: ['frontend'] }, + { id: 'nuxt', label: 'Nuxt', npm: ['nuxt'], files: ['nuxt.config.ts', 'nuxt.config.js'], names: ['nuxt'], owners: ['nuxt'], packs: ['frontend'] }, + { id: 'svelte', label: 'Svelte', npm: ['svelte', '@sveltejs/kit'], files: ['svelte.config.js'], names: ['svelte', 'sveltekit', 'svelte5'], owners: ['sveltejs'], packs: ['frontend'] }, + { id: 'angular', label: 'Angular', npm: ['@angular/core'], files: ['angular.json'], names: ['angular'], owners: ['angular'], packs: ['frontend'] }, + { id: 'astro', label: 'Astro', npm: ['astro'], files: ['astro.config.mjs', 'astro.config.ts'], names: ['astro'], packs: ['frontend'] }, + { id: 'tailwind', label: 'Tailwind CSS', npm: ['tailwindcss', '@tailwindcss/*'], files: ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'], names: ['tailwind', 'tailwindcss'], packs: ['frontend'] }, + { id: 'shadcn', label: 'shadcn/ui', files: ['components.json'], npm: ['shadcn'], names: ['shadcn'], owners: ['shadcn-ui'], packs: ['frontend'] }, + { id: 'react-native', label: 'React Native', npm: ['react-native'], names: ['reactnative'], packs: ['frontend'] }, + { id: 'expo', label: 'Expo', npm: ['expo'], names: ['expo', 'eas'], owners: ['expo'] }, + { id: 'electron', label: 'Electron', npm: ['electron'], names: ['electron'] }, + { id: 'tauri', label: 'Tauri', npm: ['@tauri-apps/api'], crates: ['tauri'], files: ['src-tauri'], names: ['tauri'] }, + { id: 'remotion', label: 'Remotion', npm: ['remotion', '@remotion/*'], names: ['remotion'], owners: ['remotion-dev'] }, + { id: 'typescript', label: 'TypeScript', npm: ['typescript'], files: ['tsconfig.json'], names: ['typescript'] }, + { id: 'supabase', label: 'Supabase', npm: ['@supabase/*'], python: ['supabase'], files: ['supabase/config.toml'], names: ['supabase'], owners: ['supabase'] }, + { id: 'prisma', label: 'Prisma', npm: ['prisma', '@prisma/client'], files: ['prisma/schema.prisma'], names: ['prisma'], owners: ['prisma'] }, + { id: 'drizzle', label: 'Drizzle ORM', npm: ['drizzle-orm', 'drizzle-kit'], names: ['drizzle'] }, + { id: 'postgres', label: 'PostgreSQL', npm: ['pg', 'postgres', '@neondatabase/serverless'], python: ['psycopg', 'psycopg2', 'psycopg2-binary', 'asyncpg'], names: ['postgres', 'postgresql'] }, + { id: 'mongodb', label: 'MongoDB', npm: ['mongodb', 'mongoose'], python: ['pymongo', 'motor'], names: ['mongodb', 'mongo'], owners: ['mongodb'] }, + { id: 'firebase', label: 'Firebase', npm: ['firebase', 'firebase-admin'], files: ['firebase.json'], names: ['firebase'], owners: ['firebase'] }, + { id: 'convex', label: 'Convex', npm: ['convex'], files: ['convex'], names: ['convex'], owners: ['convex'] }, + { id: 'clerk', label: 'Clerk', npm: ['@clerk/*'], names: ['clerk'], owners: ['clerk'] }, + { id: 'stripe', label: 'Stripe', npm: ['stripe', '@stripe/*'], python: ['stripe'], names: ['stripe'], owners: ['stripe'] }, + { id: 'cloudflare', label: 'Cloudflare Workers', npm: ['wrangler', '@cloudflare/*'], files: ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc'], names: ['cloudflare', 'wrangler'], owners: ['cloudflare'] }, + { id: 'vercel', label: 'Vercel', npm: ['vercel', '@vercel/*'], files: ['vercel.json'], names: ['vercel'], owners: ['vercel-labs'] }, + { id: 'playwright', label: 'Playwright', npm: ['@playwright/test', 'playwright'], python: ['playwright', 'pytest-playwright'], files: ['playwright.config.ts', 'playwright.config.js'], names: ['playwright'], packs: ['quality'] }, + { id: 'vitest', label: 'Vitest', npm: ['vitest'], files: ['vitest.config.ts', 'vitest.config.js'], names: ['vitest'], packs: ['quality'] }, + { id: 'jest', label: 'Jest', npm: ['jest'], files: ['jest.config.js', 'jest.config.ts'], names: ['jest'], packs: ['quality'] }, + { id: 'pytest', label: 'pytest', python: ['pytest'], files: ['pytest.ini', 'conftest.py'], names: ['pytest'], packs: ['quality'] }, + { id: 'fastapi', label: 'FastAPI', python: ['fastapi'], names: ['fastapi'], owners: ['fastapi'] }, + { id: 'django', label: 'Django', python: ['django'], files: ['manage.py'], names: ['django'] }, + { id: 'anthropic', label: 'Claude API', npm: ['@anthropic-ai/sdk'], python: ['anthropic'], names: [], ids: ['anthropics/skills@claude-api'] }, + { id: 'mcp', label: 'MCP server', npm: ['@modelcontextprotocol/sdk'], python: ['mcp', 'fastmcp'], names: [], ids: ['anthropics/skills@mcp-builder'] }, + { id: 'go', label: 'Go', files: ['go.mod'], names: ['golang'] }, + { id: 'rust', label: 'Rust', files: ['Cargo.toml'], names: ['rust', 'cargo'] }, + { id: 'swift', label: 'Swift', files: ['Package.swift'], rootFile: /\.xcodeproj$/, names: ['swift', 'swiftui'] }, + { id: 'flutter', label: 'Flutter', files: ['pubspec.yaml'], names: ['flutter', 'dart'], owners: ['flutter'] }, + { id: 'dotnet', label: '.NET', rootFile: /\.(csproj|fsproj|sln)$/, names: ['dotnet', 'csharp'] }, + { id: 'rails', label: 'Ruby on Rails', manifest: [['Gemfile', /^\s*gem\s+["']rails["']/m]], names: ['rails'] }, + { id: 'laravel', label: 'Laravel', manifest: [['composer.json', /"laravel\/framework"/]], names: ['laravel'] }, + { id: 'docker', label: 'Docker', files: ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'], names: ['docker', 'dockerfile'] }, + { id: 'kubernetes', label: 'Kubernetes', files: ['Chart.yaml', 'kustomization.yaml', 'k8s', 'helm'], names: ['kubernetes', 'k8s', 'helm'] }, + { id: 'terraform', label: 'Terraform', rootFile: /\.tf$/, files: ['terraform'], names: ['terraform'], owners: ['hashicorp'] }, + { id: 'github-actions', label: 'GitHub Actions', files: ['.github/workflows'], names: ['githubactions', 'cicd'] }, +]; + +/** Owners publishing one of the technologies above. */ +const VENDORS = new Set(RULES.flatMap((r) => r.owners ?? [])); + +/** Workspace folders of a monorepo, one level down, read like the root. */ +const WORKSPACES = ['apps', 'packages', 'services']; + +function readText(path: string): string | undefined { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } +} + +function listDir(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** Every package.json dependency name, root and workspaces, with the file that lists it. */ +function npmDependencies(root: string): Map { + const out = new Map(); + const dirs = [root, ...WORKSPACES.flatMap((w) => listDir(join(root, w)).map((d) => join(root, w, d)))]; + for (const dir of dirs) { + const text = readText(join(dir, 'package.json')); + if (!text) continue; + let pkg: Record; + try { + pkg = JSON.parse(text) as Record; + } catch { + continue; + } + const where = dir === root ? 'package.json' : `${dir.slice(root.length + 1).replace(/\\/g, '/')}/package.json`; + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const deps = pkg[field]; + if (deps && typeof deps === 'object') for (const name of Object.keys(deps)) if (!out.has(name)) out.set(name, where); + } + } + return out; +} + +/** Python distribution names (lower case, `_` read as `-`) of requirements files, pyproject.toml, Pipfile and setup.py. */ +function pythonDependencies(root: string): Map { + const out = new Map(); + const files = [...listDir(root).filter((f) => /^requirements.*\.(txt|in)$/i.test(f)), 'pyproject.toml', 'Pipfile', 'setup.py', 'setup.cfg']; + for (const file of files) { + const text = readText(join(root, file)); + if (!text) continue; + // A name starts a line or a quoted string, and ends at a version, an extra, a marker or the quote. + for (const m of text.matchAll(/(?:^|["'\s,[])([A-Za-z][A-Za-z0-9._-]*)\s*(?=[<>=~!;[\]"',]|\s*$|\s+[<>=~!(])/gm)) { + const name = m[1]!.toLowerCase().replace(/_/g, '-'); + if (!out.has(name)) out.set(name, file); + } + } + return out; +} + +/** Crate names under the `[dependencies]` tables of Cargo.toml (root and `src-tauri`). */ +function rustCrates(root: string): Map { + const out = new Map(); + for (const file of ['Cargo.toml', 'src-tauri/Cargo.toml']) { + const text = readText(join(root, file)); + if (!text) continue; + let inDeps = false; + for (const line of text.split(/\r?\n/)) { + const table = /^\s*\[([^\]]+)\]/.exec(line)?.[1]; + if (table) inDeps = /(^|\.)(dev-|build-)?dependencies$/.test(table.trim()); + else if (inDeps) { + const name = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line)?.[1]; + if (name && !out.has(name)) out.set(name, file); + } + } + } + return out; +} + +const npmMatch = (pattern: string, name: string) => (pattern.endsWith('/*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern); + +/** What the project at `root` is built with, in the order of the rules. Reads files only, never runs anything. */ +export function detectStack(root: string): StackSignal[] { + const npm = npmDependencies(root); + const python = pythonDependencies(root); + const crates = rustCrates(root); + const rootFiles = listDir(root); + const signals: StackSignal[] = []; + for (const rule of RULES) { + const evidence: string[] = []; + for (const p of rule.npm ?? []) for (const [name, file] of npm) if (npmMatch(p, name)) evidence.push(`${file}: ${name}`); + for (const p of rule.python ?? []) if (python.has(p)) evidence.push(`${python.get(p)}: ${p}`); + for (const c of rule.crates ?? []) if (crates.has(c)) evidence.push(`${crates.get(c)}: ${c}`); + for (const f of rule.files ?? []) if (existsSync(join(root, f))) evidence.push(statSync(join(root, f)).isDirectory() ? `${f}/` : f); + if (rule.rootFile) for (const f of rootFiles.filter((f) => rule.rootFile!.test(f)).slice(0, 2)) evidence.push(f); + for (const [file, word] of rule.manifest ?? []) if (word.test(readText(join(root, file)) ?? '')) evidence.push(file); + if (evidence.length) signals.push({ id: rule.id, label: rule.label, evidence: [...new Set(evidence)].slice(0, 3) }); + } + return signals; +} + +/** A technology of the project and the skills suggested for it. */ +export interface StackSuggestion extends StackSignal { + skills: Skill[]; +} + +export interface Suggestions { + stack: StackSuggestion[]; + /** Curated packs that fit what was found. */ + packs: string[]; +} + +export interface SuggestOptions { + /** `owner/repo@name` (any case) of what is installed: never suggested again. */ + installed?: Iterable; + /** Skills per technology, 3 by default. */ + perStack?: number; + /** Skills installed fewer times are left out, 500 by default. A preferred skill always passes. */ + minInstalls?: number; +} + +const words = (name: string) => name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +function matches(rule: StackRule, s: Skill): boolean { + const owner = s.source.split('/')[0]!.toLowerCase(); + if (rule.owners?.includes(owner)) return true; + const w = words(s.name); + if (rule.exclude?.some((x) => w.includes(x))) return false; + const joined = w.join(''); + return rule.names.some((n) => w.includes(n) || (n.length > 5 && joined.includes(n))); +} + +/** + * Catalog skills for each technology found: the rule's own picks first, then + * its publishers' skills, then skills named after it, then the most installed. Skills + * already installed, of archived or idle repositories, deprecated, or below + * the install threshold are left out; a skill shows under the first technology + * that suggests it, and copies of one name in several repositories count once. + */ +export function suggestSkills(catalog: Catalog, signals: StackSignal[], opts: SuggestOptions = {}): Suggestions { + const perStack = opts.perStack ?? 3; + const minInstalls = opts.minInstalls ?? 500; + const installed = new Set([...(opts.installed ?? [])].map((id) => id.toLowerCase())); + const byId = new Map(catalog.skills.map((s) => [s.id.toLowerCase(), s])); + const taken = new Set(); + const stack: StackSuggestion[] = []; + const packs = new Set(); + for (const signal of signals) { + const rule = RULES.find((r) => r.id === signal.id); + if (!rule) continue; + for (const p of rule.packs ?? []) packs.add(p); + const usable = (s: Skill) => !installed.has(s.id.toLowerCase()) && !taken.has(s.id.toLowerCase()) && !isStale(s) && !/\bdeprecated\b/i.test(s.description ?? ''); + const preferred = (rule.ids ?? []).map((id) => byId.get(id.toLowerCase())).filter((s): s is Skill => !!s && usable(s)); + // Publisher's own skills first, then names that start with the technology, then the rest; + // another technology's publisher (an integration such as `auth0-nextjs`) comes last. + const rank = (s: Skill) => { + const owner = s.source.split('/')[0]!.toLowerCase(); + if (rule.owners?.includes(owner)) return 0; + if (VENDORS.has(owner)) return 3; + const first = words(s.name)[0] ?? ''; + return rule.names.includes(first) || rule.names.some((n) => n.length > 5 && words(s.name).join('').startsWith(n)) ? 1 : 2; + }; + const found = catalog.skills + .filter((s) => (s.installs ?? 0) >= minInstalls && usable(s) && matches(rule, s)) + .sort((a, b) => rank(a) - rank(b) || (b.installs ?? 0) - (a.installs ?? 0)); + const names = new Set(); + const skills: Skill[] = []; + for (const s of [...preferred, ...found]) { + if (skills.length >= perStack) break; + const name = s.name.toLowerCase(); + if (names.has(name) || skills.includes(s)) continue; + names.add(name); + skills.push(s); + taken.add(s.id.toLowerCase()); + } + stack.push({ ...signal, skills }); + } + return { stack, packs: [...packs] }; +} + +export const SUGGESTED_THEME: Theme = { id: 'suggested', label: 'For this project' }; + +/** + * The catalog with a first theme holding the skills suggested for the project in `dir` + * (the tree and the web page show it), each marked with the technology it fits. + * Unchanged when nothing is found or nothing new fits. + */ +export function withSuggestions(catalog: Catalog, dir: string, installed: Iterable = []): Catalog { + const found = suggestSkills(catalog, detectStack(dir), { installed }); + const picks = new Map(); + for (const st of found.stack) for (const s of st.skills) picks.set(s.id, { for: st.label, order: picks.size }); + if (!picks.size) return catalog; + const skills = catalog.skills.map((s) => { + const pick = picks.get(s.id); + return pick ? { ...s, suggestion: pick, themes: [...s.themes, SUGGESTED_THEME.id] } : s; + }); + const used = found.stack.filter((st) => st.skills.length); + const theme: Theme = { ...SUGGESTED_THEME, description: used.map((st) => `${st.label} (${st.evidence[0]})`).join(' · ') }; + const themes = [theme, ...catalog.themes.filter((th) => th.id !== SUGGESTED_THEME.id)]; + return { ...catalog, skills, themes, stats: { ...catalog.stats, byTheme: { ...catalog.stats.byTheme, [SUGGESTED_THEME.id]: picks.size } } }; +} + +/** The technologies a rule can detect, for help texts and tests. */ +export const STACK_IDS = RULES.map((r) => r.id); diff --git a/src/types.ts b/src/types.ts index 429f5e4..cc65794 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,8 @@ export interface Skill { /** Personal editorial selection, independent of popularity or official status. */ /** `order` is the pick's position in the curated list, which orders its repository group. */ recommendation?: { repository: string; reason: string; order: number }; + /** Set for the current project (`withSuggestions`): the technology it fits and its rank among the suggestions. */ + suggestion?: { for: string; order: number }; labels: string[]; origins: Origin[]; /** Theme ids, 1 or 2, the first being the primary theme. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 823a7c7..2984454 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -9,13 +9,14 @@ import { riskNote, riskWhere } from '../risks.js'; import { auditSkills, auditTarget, failsAudit, withLookalikes, type AuditResult } from '../audit.js'; import { formatCommand, planInstall, planRemovals, planUpdates, type Update } from '../install/plan.js'; import { SCOPES, type InstallMethod, type InstallOptions, type Removal, type Scope, type Skill, type Theme } from '../types.js'; -import { ACCENT, Banner, Header, Hints, Panel, useTerminalSize, type Hint, type StepId } from './Layout.js'; +import { ACCENT, Banner, bannerLines, Header, Hints, Panel, ReservedRows, useTerminalSize, type Hint, type StepId } from './Layout.js'; import { ListSelect } from './ListSelect.js'; import { isMouseInput } from './mouse.js'; import { padEnd } from './width.js'; -import { scopesLabel, TreeSelect, type TreeMemory } from './TreeSelect.js'; +import { scopesLabel, TreeSelect, type PackOps, type TreeAction, type TreeMemory } from './TreeSelect.js'; +import { isBundledPack, packThemes } from '../catalog/packs.js'; import { TERMINAL_PALETTE as PALETTE } from './palette.js'; -import type { UpdateCheck } from '../install/updates.js'; +import type { ScopedUpdateCheck } from '../install/updates.js'; export interface WizardResult { /** Skills to install (`owner/repo@name`). */ @@ -25,6 +26,8 @@ export interface WizardResult { /** Installed skills to reinstall at their latest version. */ update: Update[]; options: InstallOptions; + /** A pack, profile or backup key pressed in the tree: the caller does it, then shows the tree again. */ + action?: TreeAction; } export interface AppProps { @@ -47,15 +50,21 @@ export interface AppProps { loadInstalled?: (scopes: Scope[]) => Promise; /** Summary of the previous run, shown above the tree. */ notice?: string; + /** Warnings about this machine (heavy context loads…): one ⚠ count in the tree, listed with `!`. */ + warnings?: string[]; /** Shared by the successive trees of one session: open groups and focused row. */ treeMemory?: TreeMemory; - /** `owner/repo@name` (lower case) of the installed skills with a newer version; checked in the background. */ - findUpdates?: () => Promise; + /** `owner/repo@name` (lower case) of the installed skills with a newer version, per scope; checked in the background. */ + findUpdates?: (scopes: Scope[]) => Promise; /** Run the security audit on the review screen (on unless turned off). */ audit?: boolean; /** Reads a skill's SKILL.md for the audit; replaced in tests. */ readSkill?: (s: Pick) => Promise; onDone: (result: WizardResult | null) => void; + /** Turns on the tree's pack, profile and backup keys; the ids of the packs that can be edited. */ + personalPacks?: Set; + /** Pack and backup operations for the tree's N, E, D, P and B keys. */ + packOps?: PackOps; } type Step = 'loading' | 'tree' | 'scope' | 'agents' | 'method' | 'confirm'; @@ -70,6 +79,8 @@ const STEP_OF: Record = { }; /** The big logo needs about this many rows on top of the step content. */ const BANNER_MIN_ROWS = 26; +/** Rows the tree keeps for itself under the logo; below that, the logo is left out on the tree. */ +const TREE_MIN_ROWS = 30; /** The skills CLI only asks for a method when agents use distinct directories. */ function needsMethod(agents: string[]): boolean { @@ -96,15 +107,22 @@ function nextStep(from: Step | undefined, preset: AppProps['preset'], a: Answers } export function App(props: AppProps) { - const { themes, skills, preset, defaults, detectedAgents = [], skillsVersion, initialIds, loadInstalled, sort, search, official, notice, treeMemory, findUpdates, audit = true, readSkill, onDone } = props; + const { themes, skills, preset, defaults, detectedAgents = [], skillsVersion, initialIds, loadInstalled, sort, search, official, notice, treeMemory, findUpdates, audit = true, readSkill, onDone, personalPacks: initialPersonal, packOps, warnings = [] } = props; + // Packs change while the tree is open (N, E, D): their themes are rebuilt here, in place. + const [themeList, setThemeList] = useState(themes); + const [personalPacks, setPersonalPacks] = useState(initialPersonal); + // After a restore: what it did, above the tree read again. + const [restoredNotice, setRestoredNotice] = useState(); const m = t(); const { exit } = useApp(); - const { rows } = useTerminalSize(); + const { rows, columns } = useTerminalSize(); const hasTree = !initialIds; const [ids, setIds] = useState(initialIds ?? []); const [removals, setRemovals] = useState([]); const [updates, setUpdates] = useState([]); const [outdated, setOutdated] = useState>(new Set()); + // The scopes in which each outdated skill has a newer version. + const [outdatedScopes, setOutdatedScopes] = useState>(new Map()); const [unchecked, setUnchecked] = useState(0); const [agents, setAgents] = useState(preset.agents); const [scope, setScope] = useState(preset.scope); @@ -144,7 +162,8 @@ export function App(props: AppProps) { loadInstalled(readScopes) .then((data) => { if (!live) return; - setTree(data); + // Packs changed in this session (N, E, D) keep their themes when what is installed is read again. + setTree(packOps ? { ...data, skills: packThemes(data.skills, themeList, packOps.list()).skills } : data); next('loading'); }) .catch((err: unknown) => { @@ -161,11 +180,17 @@ export function App(props: AppProps) { useEffect(() => { if (step !== 'tree' || !findUpdates || !tree.installed.size) return; let live = true; - findUpdates() + const scopes = [...new Set([...tree.installed.values()].flatMap((e) => e.scopes))]; + findUpdates(scopes) .then((found) => { if (!live) return; - const global = [...tree.installed].filter(([, e]) => e.scopes.includes('global')); - setOutdated(new Set(global.map(([id]) => id).filter((id) => found.outdated.has(id.toLowerCase())))); + const byId = new Map(); + for (const [id, e] of tree.installed) { + const inScopes = e.scopes.filter((sc) => found.outdated[sc].has(id.toLowerCase())); + if (inScopes.length) byId.set(id, inScopes); + } + setOutdatedScopes(byId); + setOutdated(new Set(byId.keys())); setUnchecked(found.unchecked.length); }) .catch(() => {}); @@ -174,12 +199,17 @@ export function App(props: AppProps) { }; }, [step === 'tree', tree]); - /** Every screen: header with the steps, the logo on the first ones, then the content. */ - const screen = (content: ReactNode) => ( + /** + * Every screen: the logo when the terminal is tall enough (the tree needs more room under it), + * the header with the steps, then the content, which sizes itself without the rows above it. + */ + const logoRows = bannerLines(columns); + const showBanner = rows >= (step === 'tree' ? logoRows + TREE_MIN_ROWS : BANNER_MIN_ROWS); + const screen = (content: ReactNode, above = 0) => ( - {step !== 'tree' && history.length === 0 && rows >= BANNER_MIN_ROWS && } + {showBanner && }
- {content} + {content} ); @@ -193,10 +223,10 @@ export function App(props: AppProps) { return screen( {error && {error}} - {notice && {notice}} - {unchecked > 0 && {t().cli.updatesUnchecked(unchecked)}} + {(restoredNotice ?? notice) && {restoredNotice ?? notice}} 0 ? [t().cli.updatesUnchecked(unchecked)] : [])]} + themes={tree.installed.size ? [INSTALLED_THEME, ...themeList] : themeList} skills={tree.skills} installed={tree.installed} scopes={loadInstalled ? readScopes : undefined} @@ -212,14 +242,36 @@ export function App(props: AppProps) { const toRemove = installedIds.filter((id) => !chosen.has(id)).map((id) => tree.installed.get(id)!); if (!toInstall.length && !toRemove.length && !update.length) return finish(null); setIds(toInstall); - setUpdates(update.map((id) => ({ id, agents: tree.installed.get(id)?.agents ?? [] }))); + setUpdates(update.flatMap((id) => { + const e = tree.installed.get(id); + // One reinstall per scope where it is outdated, for the agents of that scope. + return (outdatedScopes.get(id) ?? ['global' as Scope]).map((scope) => ({ id, scope, agents: e?.agentsByScope?.[scope] ?? e?.agents ?? [] })); + })); // An unticked skill is uninstalled from every scope it was found in. setRemovals(toRemove.map((e) => ({ name: e.name, scopes: [...e.scopes] }))); next('tree', { installs: toInstall.length }); }} onCancel={() => finish(null)} + personalPacks={personalPacks} + packOps={packOps} + onAction={(action) => { + if (action.kind === 'packsChanged' && packOps) { + const packs = packOps.list(); + const rebuilt = packThemes(tree.skills, themeList, packs); + setTree({ ...tree, skills: rebuilt.skills }); + setThemeList(rebuilt.themes); + setPersonalPacks(new Set(packs.filter((p) => !isBundledPack(p.id)).map((p) => p.id))); + } else if (action.kind === 'restored') { + // What is installed changed: read it again, then back to the tree. + setRestoredNotice(action.message); + setStep('loading'); + } else if (action.kind === 'profile') { + finish({ ids: [], remove: [], update: [], options: { ...defaults, skillsVersion }, action }); + } + }} /> , + (error ? 1 : 0) + ((restoredNotice ?? notice) ? (restoredNotice ?? notice)!.split('\n').length : 0), ); } @@ -338,7 +390,7 @@ interface ConfirmProps { function Confirm({ ids, remove, update, collisions, options, audit, targets, readSkill, onConfirm, onBack }: ConfirmProps) { const m = t(); - const audited = [...ids, ...update.map((u) => u.id)]; + const audited = [...new Set([...ids, ...update.map((u) => u.id)])]; const [results, setResults] = useState(audit && audited.length ? undefined : []); // Skills that fail the audit are left out, unless the user ticks them here. const [keep, setKeep] = useState>(new Set()); @@ -448,7 +500,7 @@ function Confirm({ ids, remove, update, collisions, options, audit, targets, rea )} {keptUpdates.length > 0 && ( - {keptUpdates.map((u) => u.id).join(' · ')} + {keptUpdates.map((u) => `${u.id} (${u.scope === 'project' ? m.scope.project : m.scope.global})`).join(' · ')} )} diff --git a/src/ui/Dialogs.tsx b/src/ui/Dialogs.tsx new file mode 100644 index 0000000..680843a --- /dev/null +++ b/src/ui/Dialogs.tsx @@ -0,0 +1,256 @@ +import { Box, Text, useInput } from 'ink'; +import { useEffect, useState } from 'react'; +import { formatNumber, t } from '../i18n/index.js'; +import type { Scope } from '../types.js'; +import { ACCENT, Hints, Panel } from './Layout.js'; +import { isMouseInput } from './mouse.js'; +import { TERMINAL_PALETTE as PALETTE } from './palette.js'; + +/** + * The tree's own windows, for what needs no install: naming a pack, confirming a + * deletion, picking a backup, choosing where a profile goes. Each one takes the keys + * while it is open; the tree under it keeps its state. + */ + +/** A saved scope, as the backups window lists it. */ +export interface BackupSummary { + id: string; + createdAt: string; + scope: Scope; + reason: string; + initial: boolean; + skills: { name: string; source?: string }[]; +} + +/** What activating a pack as a profile would do in a scope. */ +export interface ProfilePreview { + keep: string[]; + remove: { id: string }[]; + install: string[]; +} + +/** Text fields typed in turn: Tab or ↓ goes to the next one, Enter on the last one saves. */ +export function FormDialog({ title, fields, onSubmit, onCancel }: { + title: string; + fields: { label: string; value: string }[]; + /** Returns an error to show, or nothing when it is done. */ + onSubmit: (values: string[]) => string | undefined; + onCancel: () => void; +}) { + const m = t(); + const [values, setValues] = useState(fields.map((f) => f.value)); + const [index, setIndex] = useState(0); + const [error, setError] = useState(); + const submit = () => { + if (!values[0]!.trim()) return setError(m.tree.fieldRequired(fields[0]!.label)); + const err = onSubmit(values.map((v) => v.trim())); + setError(err); + // Most refusals are about the name (an id already taken): back to it. + if (err) setIndex(0); + }; + useInput((input, key) => { + if (isMouseInput(input)) return; + if (key.escape) return onCancel(); + if (key.return) return index < fields.length - 1 ? setIndex(index + 1) : submit(); + if (key.tab || key.downArrow) return setIndex((index + 1) % fields.length); + if (key.upArrow) return setIndex((index + fields.length - 1) % fields.length); + setError(undefined); + if (key.backspace || key.delete) setValues(values.map((v, i) => (i === index ? v.slice(0, -1) : v))); + else if (input && !key.ctrl && !key.meta) setValues(values.map((v, i) => (i === index ? v + input : v))); + }); + return ( + + {title} + + + {fields.map((f, i) => ( + + {f.label} + + {i === index ? '› ' : ' '} + {values[i]} + {i === index && ▌} + + + ))} + + + {error && {`✗ ${error}`}} + setIndex((index + 1) % fields.length)], + [m.keyNames.enter, index < fields.length - 1 ? m.keys.nextField : m.keys.save, index < fields.length - 1 ? () => setIndex(index + 1) : submit], + [m.keyNames.esc, m.keys.cancelEdit, onCancel], + ]} /> + + ); +} + +/** A yes/no question; only y (or o, j, s) says yes. */ +export function ConfirmDialog({ title, lines = [], tone = 'warn', onYes, onNo }: { + title: string; + lines?: string[]; + tone?: 'warn' | 'danger'; + onYes: () => void; + onNo: () => void; +}) { + const m = t(); + useInput((input, key) => { + if (isMouseInput(input)) return; + if (/^[yojs]$/i.test(input)) onYes(); + else if (key.escape || /^n$/i.test(input) || key.return) onNo(); + }); + return ( + + {`⚠ ${title}`} + {lines.length > 0 && ( + + {lines.map((l, i) => {l})} + + )} + + + ); +} + +/** The backups, newest first: Enter on one asks before restoring it. */ +export function BackupsDialog({ backups, height, onRestore, onClose }: { + backups: BackupSummary[]; + height: number; + /** Restores one; returns an error to show, or nothing when it is done. */ + onRestore: (id: string) => Promise; + onClose: () => void; +}) { + const m = t(); + const [cursor, setCursor] = useState(0); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const shown = Math.max(3, Math.floor(height / 3)); + const top = Math.max(0, Math.min(cursor - Math.floor(shown / 2), backups.length - shown)); + const restore = () => { + const b = backups[cursor]; + if (!b) return; + setBusy(true); + void onRestore(b.id).then((err) => { + setBusy(false); + setConfirming(false); + setError(err); + }); + }; + useInput((input, key) => { + if (isMouseInput(input) || busy) return; + if (confirming) { + if (/^[yojs]$/i.test(input)) restore(); + else setConfirming(false); + return; + } + if (key.escape || input === 'q' || input === 'B') onClose(); + else if (key.upArrow || input === 'k') setCursor(Math.max(0, cursor - 1)); + else if (key.downArrow || input === 'j') setCursor(Math.min(backups.length - 1, cursor + 1)); + else if (key.return && backups.length) setConfirming(true); + }); + const current = backups[cursor]; + return ( + + {m.web.backupsTitle} + {m.web.backupsHint} + + + {!backups.length && {m.cli.profileNoBackup}} + {backups.slice(top, top + shown).map((b, i) => { + const on = top + i === cursor; + return ( + + + {on ? '› ' : ' '} + {`${b.createdAt.slice(0, 16).replace('T', ' ')} · ${m.tree.where[b.scope]} · ${m.web.backupSkills.replace('{n}', formatNumber(b.skills.length))}`} + {b.initial && {` · ${m.web.backupInitial}`}} + + {` ${b.reason} — ${b.skills.map((s) => s.name).join(', ') || '∅'}`} + + ); + })} + + + {error && {`✗ ${error}`}} + {busy ? ( + {m.tree.restoring} + ) : confirming && current ? ( + <> + {`⚠ ${m.web.backupRestoreConfirm}`} + setConfirming(false)]]} /> + + ) : ( + setConfirming(true)] as [string, string, () => void]] : []), [m.keyNames.esc, m.keys.back, onClose]]} /> + )} + + ); +} + +/** A pack as a profile: the scope (← →), what stays, goes and comes; Enter goes on to the install. */ +export function ProfileDialog({ label, plan, height, initialScope = 'global', onConfirm, onCancel }: { + label: string; + plan: (scope: Scope) => Promise; + height: number; + initialScope?: Scope; + onConfirm: (scope: Scope) => void; + onCancel: () => void; +}) { + const m = t(); + const [scope, setScope] = useState(initialScope); + const [preview, setPreview] = useState(); + const [error, setError] = useState(); + useEffect(() => { + let live = true; + setPreview(undefined); + setError(undefined); + plan(scope).then((p) => live && setPreview(p), (err: unknown) => live && setError(err instanceof Error ? err.message : String(err))); + return () => { + live = false; + }; + }, [scope]); + const changes = preview ? preview.remove.length + preview.install.length : 0; + const toggle = () => setScope(scope === 'global' ? 'project' : 'global'); + useInput((input, key) => { + if (isMouseInput(input)) return; + if (key.escape) onCancel(); + else if (key.leftArrow || key.rightArrow || key.tab || input === 'g' || input === 'p') { + if (input === 'g') setScope('global'); + else if (input === 'p') setScope('project'); + else toggle(); + } else if (key.return && changes) onConfirm(scope); + }); + const room = Math.max(4, height - 8); + const lines = preview ? [...preview.remove.map((r) => ({ sign: '−', id: r.id })), ...preview.install.map((id) => ({ sign: '+', id }))] : []; + return ( + + {m.cli.profileTitle(label, m.tree.where[scope])} + + {`${m.confirm.scope}: `} + {(['global', 'project'] as Scope[]).map((sc) => ( + {`${sc === scope ? '◉' : '○'} ${m.tree.where[sc]} `} + ))} + + + + {error ? {error} : !preview ? {m.tree.previewLoading} : ( + <> + {m.cli.profileKeep(String(preview.keep.length))} + {lines.slice(0, room).map((l) => ( + {`${l.sign} ${l.id}`} + ))} + {lines.length > room && {m.tree.andMore(formatNumber(lines.length - room))}} + {!changes && {m.run.noChange}} + + )} + + + {preview && preview.remove.length > 0 && {`⚠ ${m.cli.profileWarning(String(preview.remove.length))}`}} + onConfirm(scope) : undefined], + [m.keyNames.esc, m.keys.cancelEdit, onCancel], + ]} /> + + ); +} diff --git a/src/ui/Layout.tsx b/src/ui/Layout.tsx index 68ff410..28ef8c5 100644 --- a/src/ui/Layout.tsx +++ b/src/ui/Layout.tsx @@ -1,5 +1,5 @@ import { Box, Text, useStdout } from 'ink'; -import type { ReactNode } from 'react'; +import { createContext, useContext, type ReactNode } from 'react'; import { t } from '../i18n/index.js'; import { logo } from './logo.js'; import { useClick } from './mouse.js'; @@ -10,12 +10,20 @@ export const ACCENT = PALETTE.accent; export type StepId = 'scope' | 'select' | 'agents' | 'method' | 'confirm'; export const STEPS: StepId[] = ['select', 'scope', 'agents', 'method', 'confirm']; +/** Rows already taken above a screen's content (the logo, notices): lists size themselves without them. */ +export const ReservedRows = createContext(0); + +/** The terminal's size, less the rows reserved above the current content. */ export function useTerminalSize(): { columns: number; rows: number } { const { stdout } = useStdout(); - return { columns: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 }; + const reserved = useContext(ReservedRows); + return { columns: stdout?.columns ?? 80, rows: Math.max(8, (stdout?.rows ?? 24) - reserved) }; } -/** Full ASCII logo and tagline, for the first screens when the terminal is tall enough. */ +/** Lines the banner takes: the logo, the tagline and the blank line under them. */ +export const bannerLines = (columns: number): number => logo(columns).split('\n').length + 2; + +/** Full ASCII logo and tagline, on every screen when the terminal is tall enough. */ export function Banner() { const { columns } = useTerminalSize(); return ( diff --git a/src/ui/TreeSelect.tsx b/src/ui/TreeSelect.tsx index a3695a3..8d65742 100644 --- a/src/ui/TreeSelect.tsx +++ b/src/ui/TreeSelect.tsx @@ -1,3 +1,4 @@ +import { markdownCost } from '../context.js'; import { Box, Text, useInput } from 'ink'; import { useMemo, useRef, useState } from 'react'; import { groupByRepository } from '../catalog/groups.js'; @@ -14,6 +15,8 @@ import { ACCENT, BOX, Hints, Panel, useTerminalSize, type Hint } from './Layout. import { isMouseInput, useListMouse } from './mouse.js'; import { TERMINAL_PALETTE as PALETTE } from './palette.js'; import { fit, padStart, width, wrapLines } from './width.js'; +import { BackupsDialog, ConfirmDialog, FormDialog, ProfileDialog, type BackupSummary, type ProfilePreview } from './Dialogs.js'; +import type { Pack } from '../catalog/packs.js'; type Row = | { kind: 'theme'; theme: Theme; skills: Skill[]; expanded: boolean } @@ -47,6 +50,35 @@ function rowKey(r: Row): string { return `s:${r.themeId}:${r.skill.id}`; } +/** + * What the tree tells its caller after a pack, profile or backup key: a profile to install + * (it leaves the tree, like any install), packs changed (the themes to rebuild), or a + * backup restored (what is installed to read again). + */ +export type TreeAction = + | { kind: 'profile'; pack: string; scope: Scope } + | { kind: 'packsChanged' } + | { kind: 'restored'; message: string }; + +/** Pack and backup operations, done without leaving the tree. Errors are thrown with a message to show. */ +export interface PackOps { + list: () => Pack[]; + create: (input: { name: string; description: string; skills: string[] }) => Pack; + update: (id: string, patch: { name?: string; description?: string; skills?: string[] }) => Pack; + remove: (id: string) => void; + backups: () => BackupSummary[]; + /** Returns the id of the backup taken just before, to undo the restore. */ + restore: (id: string) => Promise; + plan: (pack: string, scope: Scope) => Promise; +} + +type Dialog = + | { kind: 'create'; ids: string[] } + | { kind: 'edit'; id: string; label: string; skills: string[] } + | { kind: 'delete'; id: string; label: string } + | { kind: 'backups' } + | { kind: 'profile'; id: string; label: string }; + export interface TreeSelectProps { themes: Theme[]; skills: Skill[]; @@ -75,6 +107,13 @@ export interface TreeSelectProps { loadPreview?: (skill: Skill) => Promise; /** Whether a program a skill needs is on this machine; the PATH by default. */ hasBin?: (bin: string) => boolean; + /** Pack, profile and backup keys (N, E, D, P, B), done in the tree's own windows; without it they are not offered. */ + packOps?: PackOps; + onAction?: (action: TreeAction) => void; + /** Ids of the packs that can be edited and deleted (the personal ones). */ + personalPacks?: Set; + /** Warnings about this machine: a ⚠ count on the status line, the list on its own page with `!`. */ + warnings?: string[]; } /** Lines used around the list: header, status, search, details panel, hints (may wrap). */ @@ -87,6 +126,8 @@ const THEME_BOX_COLUMN = 7; const OFFICIAL_MARK = '◆'; const RECOMMENDED_MARK = '★'; const RECOMMENDED = 'recommended'; +const NO_PACKS = new Set(); +const NO_WARNINGS: string[] = []; const EMPTY_INSTALLED = new Map(); const NO_UPDATES = new Set(); const UPDATE_MARK = '↑'; @@ -104,7 +145,12 @@ function buildRows(groups: { theme: Theme; skills: Skill[] }[], expanded: Set (a.suggestion?.order ?? 0) - (b.suggestion?.order ?? 0)) + : g.skills; + for (const skill of ordered) out.push({ kind: 'skill', skill, themeId: g.theme.id }); continue; } for (const repo of byRepository(g.skills)) { @@ -140,7 +186,7 @@ export function TreeSelect({ themes, skills, initialSelected = [], - installed = EMPTY_INSTALLED, + installed: installedProp = EMPTY_INSTALLED, scopes, initialSort, initialSearch = '', @@ -153,8 +199,26 @@ export function TreeSelect({ loadPreview = fetchSkillMarkdown, loadDiff = (s) => updateDiff(s), hasBin = onPath, + onAction, + packOps, + personalPacks = NO_PACKS, + warnings = NO_WARNINGS, }: TreeSelectProps) { const m = t(); + // Editing a pack's skills: ticks mean membership, not installs; the selection before it comes back on Esc. + const [showWarnings, setShowWarnings] = useState(false); + const [dialog, setDialog] = useState(null); + // What the last pack or backup action did, on the status line until the next key. + const [flash, setFlash] = useState(); + const [packEdit, setPackEdit] = useState<{ id: string; label: string; saved: Set } | null>(null); + const installed = packEdit ? EMPTY_INSTALLED : installedProp; + // Catalog skills (not home-made ones), the only ones a pack can hold. + const known = useMemo(() => new Set(skills.filter((sk) => sk.origins.length).map((sk) => sk.id)), [skills]); + /** The pack of the focused row: its theme row, or a skill listed under it. */ + const focusedPack = (row: Row | undefined): string | undefined => { + const id = row?.kind === 'theme' ? row.theme.id : row?.kind === 'skill' ? row.themeId : undefined; + return id?.startsWith('pack:') ? id.slice(5) : undefined; + }; const { columns, rows: termRows } = useTerminalSize(); const [expanded, setExpandedState] = useState>(() => new Set(memory?.expanded)); const [selected, setSelectedState] = useState>(new Set(initialSelected)); @@ -380,7 +444,9 @@ export function TreeSelect({ // At most three warnings show above the text; the rest are in `skills-atlas show`. // An update shows what it changes, and only the warning signs it brings. const risks: Risk[] = useMemo(() => preview?.diff?.risks ?? (preview?.text !== undefined ? scanRisks(preview.text) : []), [preview?.text, preview?.diff]); - const riskTitle = preview?.diff ? 1 : 0; + // What the skill would cost in an agent's context: its description at every start, the SKILL.md when used. + const previewCost = useMemo(() => (preview?.text !== undefined && !preview.diff ? markdownCost(preview.text, preview.skill.name) : undefined), [preview?.text, preview?.diff]); + const riskTitle = preview?.diff ? 1 : previewCost ? 1 : 0; const riskLines = preview?.text === undefined ? 0 : 1 + riskTitle + Math.min(3, risks.length); const previewHeight = Math.max(5, termRows - 8 - riskLines); const previewLines = preview?.text !== undefined ? wrapLines(preview.text, Math.max(20, columns - 6)) : []; @@ -409,6 +475,13 @@ export function TreeSelect({ useInput((input, key) => { if (isMouseInput(input)) return; + // An open window takes the keys. + if (dialog) return; + if (flash) setFlash(undefined); + if (showWarnings) { + if (key.escape || key.return || input === '!' || input === 'q') setShowWarnings(false); + return; + } if (preview) { if (key.upArrow || input === 'k') scrollPreview(-1); else if (key.downArrow || input === 'j') scrollPreview(1); @@ -444,12 +517,28 @@ export function TreeSelect({ setCursor(liveRows().findIndex((r) => r.kind === 'theme' && r.theme.id === themeId)); } else if (input === ' ' && row) { toggle(row.kind === 'skill' ? [row.skill.id] : row.skills.map((s) => s.id)); - } else if (input === '/') startSearch(); + } else if (packEdit && key.return) { + openEditSave(); + } else if (packEdit && key.escape && !live.current.filter) { + cancelPackEdit(); + } else if (!packEdit && packOps && input === 'N') { + openCreate(); + } else if (!packEdit && packOps && input === 'E' && focusedPack(row) && personalPacks.has(focusedPack(row)!)) { + startPackEdit(focusedPack(row)!); + } else if (!packEdit && packOps && input === 'D' && focusedPack(row) && personalPacks.has(focusedPack(row)!)) { + setDialog({ kind: 'delete', id: focusedPack(row)!, label: packLabel(focusedPack(row)!) }); + } else if (!packEdit && packOps && input === 'P' && focusedPack(row)) { + setDialog({ kind: 'profile', id: focusedPack(row)!, label: packLabel(focusedPack(row)!) }); + } else if (!packEdit && packOps && input === 'B') { + setDialog({ kind: 'backups' }); + } else if (input === '!' && warnings.length) setShowWarnings(true); + else if (input === '/') startSearch(); else if (input === 's') cycleSort(); else if (input === 'o') toggleOfficial(); else if (input === 'a') toggleAll(); else if (input === 'g') openFocusedGitHub(); else if (input === 'p') openPreview(); + else if (packEdit && (input === 'u' || input === 'U' || input === 'q')) return; else if (input === 'u' && row) toggleUpdate(row.kind === 'skill' ? [row.skill.id] : row.skills.map((s) => s.id)); else if (input === 'U') toggleUpdate([...updates]); else if (key.escape && live.current.filter) setFilter(''); @@ -459,12 +548,68 @@ export function TreeSelect({ else if (input === 'q' || key.escape) onCancel(); }); + // Pack windows: name and describe, confirm, pick a backup, choose a profile's scope. + const packLabel = (id: string) => { + const theme = themes.find((th) => th.id === `pack:${id}`); + return theme ? themeLabel(theme) : id; + }; + const tickedForPack = () => [...live.current.selected].filter((id) => known.has(id)); + const openCreate = () => { + const ids = tickedForPack(); + if (ids.length) setDialog({ kind: 'create', ids }); + }; + const startPackEdit = (id: string) => { + setPackEdit({ id, label: packLabel(id), saved: new Set(live.current.selected) }); + setSelected(new Set(skills.filter((sk) => sk.themes.includes(`pack:${id}`)).map((sk) => sk.id))); + }; + const cancelPackEdit = () => { + if (!packEdit) return; + setSelected(packEdit.saved); + setPackEdit(null); + }; + const openEditSave = () => { + if (!packEdit) return; + // Members the tree does not show (filtered out, gone from the catalog) stay in the pack. + const shown = new Set(skills.map((sk) => sk.id)); + const hidden = (packOps?.list().find((p) => p.id === packEdit.id)?.skills ?? []).filter((id) => !shown.has(id)); + setDialog({ kind: 'edit', id: packEdit.id, label: packEdit.label, skills: [...[...live.current.selected].filter((id) => known.has(id)), ...hidden] }); + }; + const done = (message: string, change?: TreeAction) => { + setDialog(null); + setFlash(message); + if (change) onAction?.(change); + }; + const attempt = (run: () => string): string | undefined => { + try { + done(run(), { kind: 'packsChanged' }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }; + // Buttons do what their key does; continue is only clickable once something changes. + const pack = focusedPack(rows[current]); + const packHints: Hint[] = packOps + ? [ + ...(known.size && [...selected].some((id) => known.has(id)) ? [['N', m.keys.newPack, openCreate] as Hint] : []), + ...(pack && personalPacks.has(pack) ? [['E', m.keys.editPack, () => startPackEdit(pack)] as Hint, ['D', m.keys.deletePack, () => setDialog({ kind: 'delete', id: pack, label: packLabel(pack) })] as Hint] : []), + ...(pack ? [['P', m.keys.useProfile, () => setDialog({ kind: 'profile', id: pack, label: packLabel(pack) })] as Hint] : []), + ['B', m.keys.backups, () => setDialog({ kind: 'backups' })] as Hint, + ] + : []; const hints: Hint[] = editingFilter ? [ [m.keyNames.enter, m.keys.done, endSearch], [m.keyNames.esc, m.keys.clear, clearSearch], ] + : packEdit + ? [ + [m.keyNames.space, m.keys.toggle], + ['/', m.keys.search, startSearch], + [m.keyNames.enter, m.keys.savePack, openEditSave], + [m.keyNames.esc, m.keys.cancelEdit, cancelPackEdit], + ] : [ [m.keyNames.space, m.keys.toggle], ['/', m.keys.search, startSearch], @@ -474,10 +619,111 @@ export function TreeSelect({ ['o', m.keys.official, toggleOfficial], ...(canOpenGitHub ? [['g', m.keys.github, openFocusedGitHub] as Hint, ['p', m.keys.preview, openPreview] as Hint] : []), ...(updates.size ? [['u', m.keys.update] as Hint, ['U', m.keys.updateAll, () => toggleUpdate([...updates])] as Hint] : []), + ...(warnings.length ? [['!', m.keys.warnings(warnings.length), () => setShowWarnings(true)] as Hint] : []), + ...packHints, [m.keyNames.enter, m.keys.continue, canSubmit ? submit : undefined], ['q', m.keys.quit, onCancel], ]; + if (dialog && packOps) { + const close = () => setDialog(null); + if (dialog.kind === 'create') { + return ( + attempt(() => { + const p = packOps.create({ name: name!, description: description || m.cli.profileDefaultDescription(String(dialog.ids.length)), skills: dialog.ids }); + return m.cli.profileCreated(p.id, String(p.skills.length)); + })} + onCancel={close} + /> + ); + } + if (dialog.kind === 'edit') { + const current = packOps.list().find((p) => p.id === dialog.id); + return ( + attempt(() => { + const p = packOps.update(dialog.id, { name, description: description || current?.description, skills: dialog.skills }); + // Back to the selection the edit began with. + if (packEdit) setSelected(packEdit.saved); + setPackEdit(null); + return m.cli.packUpdated(p.id, String(p.skills.length)); + })} + onCancel={close} + /> + ); + } + if (dialog.kind === 'delete') { + return ( + { + const err = attempt(() => { + packOps.remove(dialog.id); + return m.cli.packDeleted(dialog.label); + }); + if (err) done(`✗ ${err}`); + }} + onNo={close} + /> + ); + } + if (dialog.kind === 'backups') { + return ( + { + try { + const undo = await packOps.restore(id); + setDialog(null); + onAction?.({ kind: 'restored', message: `✓ ${m.cli.profileRestored(id)} — ${m.cli.profileUndo(undo)}` }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }} + onClose={close} + /> + ); + } + return ( + packOps.plan(dialog.id, sc)} + onConfirm={(sc) => { + setDialog(null); + onAction?.({ kind: 'profile', pack: dialog.id, scope: sc }); + }} + onCancel={close} + /> + ); + } + + if (showWarnings) { + return ( + + {`⚠ ${m.tree.warningsTitle(formatNumber(warnings.length))}`} + + + {warnings.map((w, i) => ( + + {`• ${w}`} + + ))} + + + setShowWarnings(false)]]} /> + + ); + } + if (preview) { const s = preview.skill; const shown = previewLines.slice(preview.top, preview.top + previewHeight); @@ -496,6 +742,7 @@ export function TreeSelect({ {preview.text !== undefined && ( {preview.diff && {m.tree.diffSummary(preview.diff.added, preview.diff.removed)}} + {!preview.diff && previewCost && {m.tree.cost(formatNumber(previewCost.startup), formatNumber(previewCost.onUse))}} {risks.length ? ( {`⚠ ${preview.diff ? m.tree.newRisksFound(risks.length) : m.tree.risksFound(risks.length)}`} ) : ( @@ -544,7 +791,10 @@ export function TreeSelect({ {BOX.on} {m.tree.selected(selected.size)} - {(toInstall > 0 || toRemove > 0) && ( + {flash && {` ${flash.startsWith('✗') ? '' : '✓ '}${flash}`}} + {warnings.length > 0 && {` ⚠ ${formatNumber(warnings.length)} (!)`}} + {packEdit && {` ✎ ${m.tree.editingPack(packEdit.label, formatNumber(selected.size))}`}} + {!packEdit && (toInstall > 0 || toRemove > 0) && ( {' '} +{toInstall} −{toRemove} @@ -676,6 +926,7 @@ export function TreeSelect({ )} + {row.skill.suggestion ? {`💡 ${m.tree.suggestedFor(row.skill.suggestion.for)} `} : null} {row.skill.growth ? {`↑ ${m.tree.growth(formatNumber(row.skill.growth))}${row.skill.growthSuspect ? ` (${m.tree.growthSuspect(row.skill.growthSuspect)})` : ''} `} : null} {row.skill.updatedAt ? {`${m.tree.updated(row.skill.updatedAt)} · `} : null} {row.skill.repo?.license ? {`${row.skill.repo.license} · `} : null} diff --git a/src/web/page.ts b/src/web/page.ts index 148c5b9..829f052 100644 --- a/src/web/page.ts +++ b/src/web/page.ts @@ -129,6 +129,14 @@ nav button.all::before { background: conic-gradient(from 0deg, oklch(var(--regio nav button.mine::before { background: var(--accent); border-radius: 50%; } nav button.recommended::before { content: '★'; background: none; color: var(--gold); line-height: 12px; font-size: 17px; } nav button[data-theme="trending"]::before { content: '🔥'; background: none; line-height: 12px; font-size: 13px; } +nav button[data-theme="suggested"]::before { content: '💡'; background: none; line-height: 12px; font-size: 13px; } +.tag.suggested { color: var(--accent, inherit); } +.profile-list { list-style: none; padding: 0; margin: 4px 0 10px; max-height: 220px; overflow: auto; font-family: var(--mono, monospace); font-size: 12px; } +.profile-list .minus { color: var(--danger, #b3261e); } .profile-list .plus { color: var(--success, #2e7d32); } +.backups { list-style: none; padding: 0; margin: 0; max-height: 360px; overflow: auto; } +.backups li { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--rule); } +.ok-msg { color: var(--success, #2e7d32); } +.profile-agents { display: flex; flex-wrap: wrap; gap: 4px 14px; max-height: 160px; overflow: auto; } nav .sep { height: 1px; background: var(--rule); margin: 8px 10px; } /* ---- skill list -------------------------------------------------------------------------- */ @@ -361,6 +369,8 @@ dialog::backdrop { background: color-mix(in oklab, var(--paper) 40%, rgba(5,12,2
+
+