From 7f62f80dba89208e370138c04034e83efde0fe44 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:03:59 +0200 Subject: [PATCH 1/3] feat(cli)!: default commands to global scope --- CHANGELOG.md | 22 +- README.md | 40 +++- RELEASING.md | 2 + docs/public/llms.txt | 92 +++++---- .../dotagents/src/cli/commands/add.test.ts | 67 +++--- packages/dotagents/src/cli/commands/add.ts | 32 +-- .../dotagents/src/cli/commands/doctor.test.ts | 37 +++- packages/dotagents/src/cli/commands/doctor.ts | 64 +++--- .../dotagents/src/cli/commands/init.test.ts | 40 +++- packages/dotagents/src/cli/commands/init.ts | 119 +++++------ .../src/cli/commands/install.test.ts | 2 +- .../dotagents/src/cli/commands/install.ts | 14 +- .../src/cli/commands/install/gitignore.ts | 3 +- .../dotagents/src/cli/commands/list.test.ts | 2 +- packages/dotagents/src/cli/commands/list.ts | 21 +- .../dotagents/src/cli/commands/mcp.test.ts | 14 +- packages/dotagents/src/cli/commands/mcp.ts | 37 ++-- packages/dotagents/src/cli/commands/remove.ts | 20 +- .../dotagents/src/cli/commands/sync.test.ts | 2 +- packages/dotagents/src/cli/commands/sync.ts | 37 ++-- .../dotagents/src/cli/commands/trust.test.ts | 13 +- packages/dotagents/src/cli/commands/trust.ts | 34 ++-- packages/dotagents/src/cli/context.ts | 12 ++ packages/dotagents/src/cli/errors.test.ts | 15 +- packages/dotagents/src/cli/errors.ts | 16 +- packages/dotagents/src/cli/help.test.ts | 10 + packages/dotagents/src/cli/help.ts | 41 ++-- packages/dotagents/src/cli/index.test.ts | 191 +++++++++++++++++- packages/dotagents/src/cli/main.ts | 60 +++++- .../dotagents/src/cli/post-merge-hook.test.ts | 66 ++++++ packages/dotagents/src/cli/post-merge-hook.ts | 81 ++++++++ packages/dotagents/src/plugins/store.test.ts | 4 +- packages/dotagents/src/plugins/store.ts | 3 +- packages/dotagents/src/scope.test.ts | 35 ++-- packages/dotagents/src/scope.ts | 31 +-- .../dotagents/src/subagents/store.test.ts | 6 +- packages/dotagents/src/subagents/store.ts | 3 +- skills/dotagents-qa/SKILL.md | 24 ++- skills/dotagents-qa/SOURCES.md | 4 +- .../exercise-complete-cli-lifecycles.yaml | 4 +- .../verify-project-scope-migration-edges.yaml | 5 + .../verify-scope-flag-compatibility.yaml | 5 + .../cases/verify-scope-reversal-release.yaml | 5 + .../references/core-agentic-qa.md | 6 +- skills/dotagents-qa/references/opencode.md | 2 +- .../dotagents-qa/references/plugin-runtime.md | 2 +- .../references/release-plugin-matrix.md | 20 +- skills/dotagents-qa/scripts/qa-example.mjs | 12 +- skills/dotagents-qa/spec.md | 13 +- skills/dotagents/SKILL.md | 46 +++-- .../evals/cases/choose-management-scope.yaml | 2 +- .../initialize-user-scope-management.yaml | 2 +- .../evals/cases/remove-skills-safely.yaml | 2 +- skills/dotagents/references/cli-reference.md | 43 ++-- skills/dotagents/references/config-schema.md | 4 +- skills/dotagents/references/configuration.md | 52 ++--- skills/dotagents/spec.md | 30 ++- specs/SPEC.md | 112 ++++++---- 58 files changed, 1159 insertions(+), 524 deletions(-) create mode 100644 packages/dotagents/src/cli/context.ts create mode 100644 packages/dotagents/src/cli/post-merge-hook.test.ts create mode 100644 packages/dotagents/src/cli/post-merge-hook.ts create mode 100644 skills/dotagents-qa/evals/cases/verify-project-scope-migration-edges.yaml create mode 100644 skills/dotagents-qa/evals/cases/verify-scope-flag-compatibility.yaml create mode 100644 skills/dotagents-qa/evals/cases/verify-scope-reversal-release.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e093eb..20d91124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,25 @@ # Changelog + +## 3.0.0 + +### Breaking Changes ⚠️ + +- **Scope-aware commands now target global state by default.** Unqualified `init`, `install`, `add`, `remove`, `sync`, `list`, `mcp`, `trust`, and `doctor` operate under `~/.agents/` (or `DOTAGENTS_HOME`) even when run inside a configured repository. Add `--project` to every repository-local invocation. +- Existing project and global configuration, lockfiles, and managed directories are not copied, merged, renamed, or deleted automatically. The command's scope flag alone selects which state is used. +- `--global` remains an optional explicit global spelling and `--user` remains a compatibility alias. Combining `--project` with either global alias is an error. + +| v2 intent and command | v3 command | +| --- | --- | +| Project init: `dotagents init` | `dotagents --project init` | +| Project install/refresh: `dotagents install` | `dotagents --project install` | +| Project add/remove: `dotagents add …` / `dotagents remove …` | `dotagents --project add …` / `dotagents --project remove …` | +| Project repair/inspection: `dotagents sync`, `list`, or `doctor` | Add `--project` to the command | +| Global operation: `dotagents --user …` or `dotagents --global …` | `dotagents …` (`--global` and `--user` still work) | + +Legacy dotagents-managed project post-merge hooks contain a bare install command whose meaning changes in v3. Run `dotagents --project doctor --fix` (or `dotagents --project init`) once in each affected repository. The repair replaces only the marker-delimited dotagents block and preserves unrelated hook content and permissions. + +If a release-blocking regression requires rollback, restore the last v2 release as npm's `latest` tag while preparing a v3 patch. No data conversion is required because v3 does not migrate files. + ## 2.2.0 ### New Features ✨ @@ -174,4 +195,3 @@ - (deps) Bump smol-toml from 1.6.0 to 1.6.1 by @dependabot in [#82](https://github.com/getsentry/dotagents/pull/82) - Pin GitHub Actions to full-length commit SHAs by @joshuarli in [#81](https://github.com/getsentry/dotagents/pull/81) - diff --git a/README.md b/README.md index 0fe0a352..d8224f68 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,19 @@ Shared tooling for coding agents. Declare skills, MCP servers, hooks, subagents, **One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/` or wherever your tools expect them. Cursor shares Claude-compatible skills. No copy-pasting between directories. -**One command to install.** `agents.toml` is committed, managed skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. +**One command to install.** Global dependencies live under `~/.agents/`. Repository-local dependencies can be declared in a committed `agents.toml`; collaborators run `dotagents --project install` to fetch or refresh that project's managed state. **Shareable.** Skills are directories with a `SKILL.md`. Host them in any git repo, discover them automatically, install with one command. **Multi-agent.** Configure Claude, Cursor, Codex, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. -## Quick Start +## Quick Start: Global by Default ```bash npx @sentry/dotagents init ``` -The interactive setup walks you through selecting agents and trust policy. Then add skills or plugins: +Without a scope flag, every command operates on global state under `~/.agents/`, even when run inside a repository. The interactive setup walks you through selecting agents and trust policy. Then add skills or plugins: ```bash # Add a skill from a GitHub repo @@ -34,14 +34,34 @@ npx @sentry/dotagents add getsentry/skills --all npx @sentry/dotagents add getsentry/agent-plugins review-tools ``` -This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills, subagents, and plugins. +This creates `~/.agents/agents.toml` and `~/.agents/agents.lock`, making the dependencies available across projects. -After cloning a project that already has `agents.toml`, run `install` to fetch skills, subagents, and plugins. Run it again to refresh managed local state: +Run `install` again whenever you want to refresh global managed state: ```bash npx @sentry/dotagents install ``` +## Repository-Local Workflow + +Use `--project` for repository-local state. Inside Git, dotagents uses the repository root; outside Git, `--project init` uses the current directory. + +```bash +# Initialize this repository +npx @sentry/dotagents --project init + +# Add a dependency only for this repository +npx @sentry/dotagents --project add getsentry/skills find-bugs + +# After cloning or pulling a repository with agents.toml +npx @sentry/dotagents --project install + +# Check and repair repository-local state +npx @sentry/dotagents --project doctor --fix +``` + +Project commands other than `init` require `agents.toml` and never fall back to global state. Existing project and global files are not copied, merged, or removed when switching scopes. + ## Commands | Command | Description | @@ -54,9 +74,9 @@ npx @sentry/dotagents install | `sync` | Reconcile state offline: adopt local skills, prune stale managed ones, repair configs | | `mcp` | Manage MCP server declarations | | `trust` | Manage trusted sources | -| `doctor` | Check project health, including plugin runtime projections, and fix supported issues | +| `doctor` | Check active-scope health, including plugin runtime projections, and fix supported issues | -All commands accept `--user` or its `--global` alias to operate on user scope (`~/.agents/`) instead of the current project. +All commands default to global scope (`~/.agents/`). `--global` selects it explicitly, and legacy `--user` remains a compatibility alias. `--project` selects repository-local state. Combining `--project` with either global alias is an error. ## Source Formats @@ -133,7 +153,7 @@ dotagents can also import native runtime subagent files from `.claude/agents/`, OpenCode reuses an existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`. -Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, `.opencode/skills//`, OpenCode MCP entries, and Pi skill links under `.agents/skills//` where supported. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: +Plugins are declared with `[[plugins]]` entries. In project scope, dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, `.opencode/skills//`, OpenCode MCP entries, and Pi skill links under `.agents/skills//` where supported. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: ```toml [[plugins]] @@ -145,7 +165,7 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] The canonical portable format is an [Agent Plugins](https://agent-plugins.org/) v1 bundle: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. dotagents preserves those portable source files under `.agents/plugins//` and generates isolated target harnesses. OpenCode receives portable MCP servers under managed keys such as `plugin..`; `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` are expanded into the installed bundle and persistent `.agents/plugin-data/` paths. Generated JSON uses adjacent ownership sidecars, while component symlinks use markers in reserved `.dotagents-managed/` directories, so client-owned JSON remains unchanged. Legacy generalized and native Claude/Cursor/Codex manifests remain discoverable during migration; native imports preserve their owning manifest and expose only core metadata and Agent Skills to other clients. Standard bundles reject legacy root components so client-specific behavior cannot leak across harnesses. -User-scope plugins install canonical bundles under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated under `~/.agents/`, the Codex marketplace is generated at `~/.agents/plugins/marketplace.json`, OpenCode skills are linked into `~/.config/opencode/skills/`, portable MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skills are linked into `~/.agents/skills/`. `--global` is an alias for `--user`. +Global plugins install canonical bundles under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated under `~/.agents/`, the Codex marketplace is generated at `~/.agents/plugins/marketplace.json`, OpenCode skills are linked into `~/.config/opencode/skills/`, portable MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skills are linked into `~/.agents/skills/`. `--user` remains a compatibility alias for `--global`. Pi plugin targets are global skill projections rather than isolated plugin installs: a Pi-targeted plugin skill is added to `.agents/skills/` and is therefore visible to other clients that consume that shared directory. @@ -153,7 +173,7 @@ Pi plugin targets are global skill projections rather than isolated plugin insta ## Documentation -For the full guide -- including MCP servers, hooks, subagents, plugins, trust policies, wildcard skills, user scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). +For the full guide -- including MCP servers, hooks, subagents, plugins, trust policies, wildcard skills, global and project scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). ## Contributing diff --git a/RELEASING.md b/RELEASING.md index 8a4de950..af8e7b98 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,6 +20,8 @@ Releases are driven by [getsentry/craft](https://github.com/getsentry/craft) via 4. Craft has two `npm` targets in `.craft.yml`, each filtered to one tarball via `includeNames`. Targets run in declared order: **`@sentry/dotagents-lib` first, then `@sentry/dotagents`**. By the time the host's publish starts, the lib is already on the registry, so an end-user `npm install @sentry/dotagents` mid-release will always resolve. 5. Craft tags the commit and creates the GitHub release. +For a breaking release such as v3, leave all package manifest versions unchanged in the feature PR. Trigger Craft with `major`; do not hand-edit versions. `scripts/bump-version.mjs` will update the root, host, and library manifests in lock-step, and the normal ordered targets publish the library before the host. Before triggering the release, ensure the changelog includes the final PR reference and the migration/rollback notes intended for the GitHub release. + ## Why lock-step + ordered publish? A consumer running `npm install @sentry/dotagents` must install a published version of the lib. If the lib were unpublished or out-of-sync, the install would either fail or pull a mismatched pair. Three guardrails: diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 5fb395ba..d74d016a 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -7,10 +7,10 @@ dotagents manages agent skills, MCP servers, hooks, subagents, and plugins decla Install: `npm install -g @sentry/dotagents` Run without installing: `npx @sentry/dotagents ` -## Quick Start +## Quick Start (Global by Default) ```bash -# Initialize a project (interactive TUI) +# Initialize global state under ~/.agents/ (interactive TUI) npx @sentry/dotagents init # Add a single skill from GitHub @@ -26,7 +26,16 @@ npx @sentry/dotagents add getsentry/skills --all npx @sentry/dotagents install ``` -This creates an `agents.toml`: +These unqualified commands create and use `~/.agents/agents.toml`, even when run inside a repository. To manage repository-local state, make project intent explicit: + +```bash +npx @sentry/dotagents --project init +npx @sentry/dotagents --project add getsentry/skills find-bugs +npx @sentry/dotagents --project install +npx @sentry/dotagents --project doctor --fix +``` + +The selected config contains entries such as: ```toml version = 1 @@ -37,14 +46,14 @@ name = "find-bugs" source = "getsentry/skills" ``` -And a lockfile (`agents.lock`) tracking which skills, subagents, and plugins are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. +And a lockfile (`agents.lock`) tracking which skills, subagents, and plugins are managed. In project scope, `agents.lock` and `.agents/.gitignore` are automatically added to the root `.gitignore`. ## How It Works -1. Declare skill dependencies in `agents.toml` at the project root (or `~/.agents/agents.toml` for user scope) -2. `install` clones or refreshes sources, discovers skills by convention, and copies them into `.agents/skills/` -3. `agents.lock` tracks which skills, subagents, and plugins are managed (gitignored automatically) -4. Managed skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are tracked by git normally when they are not installed dependencies. +1. Declare global dependencies in `~/.agents/agents.toml`, or project dependencies in `/agents.toml` +2. `install` clones or refreshes sources, discovers skills by convention, and copies them into the selected scope's managed directories +3. `agents.lock` tracks which skills, subagents, and plugins are managed (automatically gitignored in project scope) +4. Managed project skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents --project install` after cloning. Custom skills in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are tracked by git normally when they are not installed dependencies. 5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) 6. MCP, hook, subagent, and plugin configs are generated for each declared agent where supported @@ -171,7 +180,7 @@ Source formats: - `https://gitlab.com/group/repo` -- explicit GitLab URL - `https://` -- Well-known HTTP skill source (discovers skills via `.well-known/skills/index.json`) - `git:` -- Non-GitHub git (requires https://, git://, ssh://, git@, file://, or absolute path) -- `path:` -- Local filesystem path (relative to project root) +- `path:` -- Local filesystem path (relative to the selected scope root) ### Wildcard Skills @@ -268,10 +277,10 @@ Review the current diff and return findings with file references. | `targets` | string[] | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported configured agents produce warnings. | Generated subagent files: -- Claude: `.claude/agents/.md`, or `~/.claude/agents/.md` for user scope -- Cursor: `.cursor/agents/.md`, or `~/.cursor/agents/.md` for user scope -- Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope -- OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope +- Claude: `.claude/agents/.md`, or `~/.claude/agents/.md` for global scope +- Cursor: `.cursor/agents/.md`, or `~/.cursor/agents/.md` for global scope +- Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for global scope +- OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for global scope Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. The deprecated `--frozen` flag is a warned compatibility no-op. @@ -299,7 +308,7 @@ Generated project-scope plugin outputs: Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated marketplaces and Claude/Cursor/Codex manifests use adjacent `.dotagents-managed` sidecars so client-owned JSON remains schema-native; legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. -User-scope plugins install under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated below `~/.agents/`, Codex uses `~/.agents/plugins/marketplace.json` with paths rooted at the user's home, OpenCode skills use `~/.config/opencode/skills/`, portable plugin MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skill projections use `~/.agents/skills/`. +Global plugins install under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated below `~/.agents/`, Codex uses `~/.agents/plugins/marketplace.json` with paths rooted at the user's home, OpenCode skills use `~/.config/opencode/skills/`, portable plugin MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skill projections use `~/.agents/skills/`. ### Trust @@ -321,18 +330,23 @@ Rules: ## CLI Commands -Global flags (before command name): -- `--user`, `--global` -- Operate on user scope (`~/.agents/`) instead of the current project +Global flags (accepted before or after the command name): +- no scope flag -- Operate on global scope (`DOTAGENTS_HOME` or `~/.agents/`); this is the default in every directory +- `--project` -- Operate on the containing Git repository, or the current directory outside Git +- `--global` -- Explicitly select global scope +- `--user` -- Compatibility alias for `--global` - `--help`, `-h` -- Show help - `--version`, `-V` -- Show version +`--global` and `--user` may be combined because they are equivalent. Combining `--project` with either global alias is an error before any command executes or scope is bootstrapped. Project commands other than `init` require `agents.toml`; they never fall back to or mutate global state. dotagents does not copy, merge, rename, or delete files between scopes. + ### init ``` npx @sentry/dotagents init [--force] [--agents claude,cursor] ``` -Create `agents.toml` and `.agents/skills/` directory. Automatically includes the `dotagents` skill from `getsentry/dotagents` for CLI guidance. Interactive mode (when TTY is available) prompts for agent targets, trust policy, and optionally sets up a git `post-merge` hook to auto-run `npx @sentry/dotagents install` on pull (defaults to no). Sets up gitignore entries automatically. +Create the selected scope's `agents.toml` and managed directories. Automatically includes the `dotagents` skill from `getsentry/dotagents` for CLI guidance. Interactive project init inside Git prompts for agent targets, trust policy, and optionally sets up a git `post-merge` hook to auto-run `npx @sentry/dotagents --project install` on pull (defaults to no). Both direct and npx fallback hook commands include `--project`. Project init outside Git uses the current directory and skips Git-only hook setup. Project init upgrades legacy marker-delimited hooks while preserving unrelated content and executable permissions. | Flag | Description | |------|-------------| @@ -363,7 +377,7 @@ Add and install plugins or skills. Git and local sources are classified once: if | `--ref ` | Pin to a specific tag, branch, or commit | | `--all` | Add every current plugin explicitly, or all skills as a wildcard (`name = "*"`) | -When a source has one dependency of the selected kind, it is added automatically. Multiple dependencies use a picker in a TTY or are listed with selection guidance in non-interactive mode. Plugin adds support project and user scope. +When a source has one dependency of the selected kind, it is added automatically. Multiple dependencies use a picker in a TTY or are listed with selection guidance in non-interactive mode. Plugin adds support project and global scope. When adding multiple dependencies, names already declared for that kind are skipped with a warning. The command fails if all specified names already exist. Positional names and `--name`/`--skill` flags cannot be mixed. Plugin `--all` is a snapshot of current candidates; skill `--all` remains a wildcard that can include future upstream skills. @@ -375,7 +389,7 @@ Plugin declarations persist the exact discovered source path (`.` for a source-r npx @sentry/dotagents remove [-y] ``` -Remove a skill or plugin from `agents.toml`, delete managed installed files, update the lockfile, prune generated plugin outputs when needed, and regenerate `.agents/.gitignore`. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. +Remove a skill or plugin from `agents.toml`, delete files from the selected scope's managed directories, update the lockfile, and prune generated plugin outputs when needed. In project scope, also regenerate `.agents/.gitignore`. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. If a skill and plugin share the same name, name-based removal is rejected. When their sources differ, pass the dependency's source to disambiguate. @@ -387,7 +401,7 @@ When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a npx @sentry/dotagents sync ``` -Reconcile project state without network access: adopt truly local orphaned skills, prune stale managed skills/subagents/plugins removed from config, regenerate `.agents/.gitignore`, check for missing skills and plugins, repair symlinks, and verify/repair MCP, hook, subagent, and plugin configs. Reports issues as warnings or errors. +Reconcile the selected scope without network access: adopt truly local orphaned skills, prune stale managed skills/subagents/plugins removed from config, regenerate managed ignore state, check for missing skills and plugins, repair symlinks, and verify/repair MCP, hook, subagent, and plugin configs. Reports issues as warnings or errors. ### mcp add @@ -469,7 +483,7 @@ Skills from wildcard entries are marked with a wildcard indicator. npx @sentry/dotagents doctor [--fix] ``` -Check project health: gitignore setup, installed skills and plugins, plugin runtime projections, symlinks, and legacy config fields. Use `--fix` to auto-repair issues; use `sync` to repair generated runtime config drift. +Check selected-scope health: gitignore setup where applicable, installed skills and plugins, plugin runtime projections, symlinks, legacy config fields, and legacy managed project hooks. Use `--fix` to auto-repair issues; project hook repair is `npx @sentry/dotagents --project doctor --fix`. Use `sync` in the same scope to repair generated runtime config drift. | Flag | Description | |------|-------------| @@ -491,27 +505,27 @@ Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skil ## Scopes -### Project Scope (default) - -Operates on the current project directory. Requires `agents.toml` at the project root. - -- Config: `/agents.toml` -- Skills: `/.agents/skills/` -- Lockfile: `/agents.lock` - -### User Scope (`--user` or `--global`) +### Global Scope (default) -Operates on the user's home directory. For skills shared across all projects. +Operates on `DOTAGENTS_HOME` when set and otherwise `~/.agents/`, regardless of the current directory. - Config: `~/.agents/agents.toml` - Skills: `~/.agents/skills/` - Lockfile: `~/.agents/agents.lock` - Plugins: `~/.agents/plugins/` - Override location: `DOTAGENTS_HOME` environment variable +- Explicit spellings: `--global`, or compatibility alias `--user` + +Global-scope symlinks include `~/.claude/skills/` for Claude and Cursor. -User-scope symlinks: `~/.claude/skills/` for Claude and Cursor. +### Project Scope (`--project`) -When no `agents.toml` exists and you are not inside a git repo, dotagents falls back to user scope automatically. +Operates on the containing Git repository root, or the current directory outside Git. Commands other than `init` require `agents.toml`. + +- Config: `/agents.toml` +- Skills: `/.agents/skills/` +- Lockfile: `/agents.lock` +- Plugins: `/.agents/plugins/` ## Skill Discovery @@ -543,7 +557,7 @@ Required frontmatter fields: `name` (string), `description` (string). ## Lockfile (agents.lock) -Auto-generated TOML file. Do not edit manually. Gitignored automatically (`npx @sentry/dotagents init` adds it to `.gitignore`). +Auto-generated TOML file. Do not edit manually. In project scope it is gitignored automatically (`npx @sentry/dotagents --project init` adds it to `.gitignore`). ```toml # Auto-generated by dotagents. Do not edit. @@ -591,23 +605,23 @@ Location: `~/.local/dotagents/` (override: `DOTAGENTS_STATE_DIR`) | Variable | Description | |----------|-------------| | `DOTAGENTS_STATE_DIR` | Override cache location (default: `~/.local/dotagents`) | -| `DOTAGENTS_HOME` | Override user-scope location (default: `~/.agents`) | +| `DOTAGENTS_HOME` | Override global-scope location (default: `~/.agents`) | ## Gitignore -dotagents always manages gitignore. Two files are gitignored automatically: +In project scope, dotagents manages Git ignore state. Global scope does not modify repository Git files. Two project files are gitignored automatically: - `agents.lock` -- tracks managed skills, subagents, and plugins - `.agents/.gitignore` -- excludes managed skill directories, canonical installed subagent files, and managed plugin bundles from git -`npx @sentry/dotagents init` adds both to the root `.gitignore`. If they're missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. +`npx @sentry/dotagents --project init` adds both to the root `.gitignore`. If they're missing, project `install` and `sync` warn. Run `npx @sentry/dotagents --project doctor --fix` to add them. Custom skills created directly in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are not gitignored unless they are managed installed dependencies. They're tracked by git normally. -`.agents/.gitignore` is regenerated on every `install`, `add`, `remove`, and `sync`. +`.agents/.gitignore` is regenerated by project `install`, `add`, `remove`, and `sync` commands. ## Refresh Strategy -Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills, subagents, and plugins unless a ref is pinned. There is no separate update command. +Run `npx @sentry/dotagents --project install` after cloning or pulling project changes. Use unqualified `npx @sentry/dotagents install` to refresh global dependencies. Install fetches or refreshes managed skills, subagents, and plugins unless a ref is pinned. There is no separate update command. ## Links diff --git a/packages/dotagents/src/cli/commands/add.test.ts b/packages/dotagents/src/cli/commands/add.test.ts index 7effbe9d..aa705d13 100644 --- a/packages/dotagents/src/cli/commands/add.test.ts +++ b/packages/dotagents/src/cli/commands/add.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, rm, readFile, symlink } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import * as clack from "@clack/prompts"; @@ -45,6 +46,17 @@ function mockRunInstall() { }); } +function countGitFetches(tracePath: string): number { + if (!existsSync(tracePath)) {return 0;} + return readFileSync(tracePath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { event: string; argv?: string[] }) + .filter((event) => event.event === "start" && event.argv?.[1] === "fetch") + .length; +} + describe("runAdd", () => { let tmpDir: string; let stateDir: string; @@ -107,21 +119,26 @@ describe("runAdd", () => { const tracePath = join(tmpDir, "add-git-trace.json"); process.env["GIT_TRACE2_EVENT"] = tracePath; const scope = resolveScope("project", projectRoot); + let fetchesBeforeInstall = -1; await runAdd({ scope, specifier: `git:${repoDir}`, names: ["pdf"], + progress: { + start(message) { + if (message === "Installing components") { + fetchesBeforeInstall = countGitFetches(tracePath); + } + }, + message() {}, + stop() {}, + error() {}, + }, }); - const events = (await readFile(tracePath, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { event: string; argv?: string[] }); - const fetches = events.filter( - (event) => event.event === "start" && event.argv?.[1] === "fetch", - ); - expect(fetches).toHaveLength(0); + expect(fetchesBeforeInstall).toBeGreaterThanOrEqual(0); + expect(countGitFetches(tracePath)).toBe(fetchesBeforeInstall); const installTracePath = join(tmpDir, "install-git-trace.json"); process.env["GIT_TRACE2_EVENT"] = installTracePath; @@ -139,21 +156,26 @@ describe("runAdd", () => { it("does not fetch a wildcard git source again during the nested install", async () => { const tracePath = join(tmpDir, "add-wildcard-git-trace.json"); process.env["GIT_TRACE2_EVENT"] = tracePath; + let fetchesBeforeInstall = -1; await runAdd({ scope: resolveScope("project", projectRoot), specifier: `git:${repoDir}`, all: true, + progress: { + start(message) { + if (message === "Installing components") { + fetchesBeforeInstall = countGitFetches(tracePath); + } + }, + message() {}, + stop() {}, + error() {}, + }, }); - const events = (await readFile(tracePath, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { event: string; argv?: string[] }); - const fetches = events.filter( - (event) => event.event === "start" && event.argv?.[1] === "fetch", - ); - expect(fetches).toHaveLength(0); + expect(fetchesBeforeInstall).toBeGreaterThanOrEqual(0); + expect(countGitFetches(tracePath)).toBe(fetchesBeforeInstall); }); it("restores the acquired commit after another dependency checks out a different ref", async () => { @@ -987,12 +1009,11 @@ describe("add() CLI parsing", () => { }); it("passes positional skill names to runAdd", async () => { - // We test the full CLI add() by running it against a real project dir - // The project root must be the cwd for resolveDefaultScope + // We test the full CLI add() against a resolved project scope. const origCwd = process.cwd(); process.chdir(projectRoot); try { - await add([`git:${repoDir}`, "pdf", "review"]); + await add([`git:${repoDir}`, "pdf", "review"], { scope: resolveScope("project", projectRoot) }); expect(process.exitCode).toBeUndefined(); const toml = await readFile(join(projectRoot, "agents.toml"), "utf-8"); @@ -1007,7 +1028,7 @@ describe("add() CLI parsing", () => { const origCwd = process.cwd(); process.chdir(projectRoot); try { - await add([`git:${repoDir}`, "--skill", "pdf", "--skill", "review"]); + await add([`git:${repoDir}`, "--skill", "pdf", "--skill", "review"], { scope: resolveScope("project", projectRoot) }); expect(process.exitCode).toBeUndefined(); const toml = await readFile(join(projectRoot, "agents.toml"), "utf-8"); @@ -1022,7 +1043,7 @@ describe("add() CLI parsing", () => { const origCwd = process.cwd(); process.chdir(projectRoot); try { - await add([`git:${repoDir}`, "pdf", "--skill", "review"]); + await add([`git:${repoDir}`, "pdf", "--skill", "review"], { scope: resolveScope("project", projectRoot) }); expect(process.exitCode).toBe(1); } finally { process.chdir(origCwd); @@ -1036,7 +1057,7 @@ describe("add() CLI parsing", () => { const origCwd = process.cwd(); process.chdir(projectRoot); try { - await add(["path:plugin-source", "--skill", "cli-plugin"]); + await add(["path:plugin-source", "--skill", "cli-plugin"], { scope: resolveScope("project", projectRoot) }); expect(process.exitCode).toBeUndefined(); expect(await readFile(join(projectRoot, "agents.toml"), "utf-8")).toContain( @@ -1067,7 +1088,7 @@ describe("add() CLI parsing", () => { const origCwd = process.cwd(); process.chdir(projectRoot); try { - await add(["path:plugin-source", "--skill", "cli-plugin"]); + await add(["path:plugin-source", "--skill", "cli-plugin"], { scope: resolveScope("project", projectRoot) }); expect(clack.spinner).toHaveBeenCalledWith({ indicator: "timer" }); expect(spinner.start.mock.calls).toEqual([ diff --git a/packages/dotagents/src/cli/commands/add.ts b/packages/dotagents/src/cli/commands/add.ts index 94ccf08c..78c22ac0 100644 --- a/packages/dotagents/src/cli/commands/add.ts +++ b/packages/dotagents/src/cli/commands/add.ts @@ -39,8 +39,9 @@ import { import { getCacheStateDir, HOST_SCAN_DIRS } from "../cache.js"; import { formatGitError, formatTrustError } from "../errors.js"; import { runInstall } from "./install.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; import { discoverPlugins, type PluginCandidate, @@ -178,6 +179,7 @@ async function verifyRequestedNames( rootDir: string, names: string[], source: string, + command: string, ): Promise { for (const name of names) { const found = await discoverSkill(rootDir, name, { @@ -186,7 +188,7 @@ async function verifyRequestedNames( if (!found) { throw new AddError( `Skill "${name}" not found in ${source}. ` + - `Use 'npx @sentry/dotagents add ${source}' without --name to see available skills.`, + `Use '${command} add ${source}' without --name to see available skills.`, ); } } @@ -197,6 +199,7 @@ async function selectSkills( names: string[] | undefined, source: string, interactive: boolean | undefined, + command: string, ): Promise { if (acquired.local && !names?.length) { const meta = await loadSkillMd(join(acquired.rootDir, "SKILL.md")); @@ -208,7 +211,7 @@ async function selectSkills( } if (names?.length) { - await verifyRequestedNames(acquired.rootDir, names, source); + await verifyRequestedNames(acquired.rootDir, names, source, command); return { type: "skills", names, @@ -287,12 +290,13 @@ function pluginCandidateForName( candidates: PluginCandidate[], name: string, source: string, + command: string, ): PluginCandidate { const matches = candidates.filter((candidate) => candidate.name === name); if (matches.length === 0) { throw new AddError( `Plugin "${name}" not found in ${source}. ` + - `Use 'npx @sentry/dotagents add ${source}' without --name to see available plugins.`, + `Use '${command} add ${source}' without --name to see available plugins.`, ); } if (matches.length > 1) { @@ -328,6 +332,7 @@ async function selectPlugins( source: string, interactive: boolean | undefined, all: boolean | undefined, + command: string, ): Promise { if (all) { assertUniquePluginNames(candidates, source); @@ -335,7 +340,7 @@ async function selectPlugins( } if (names?.length) { return { - candidates: names.map((name) => pluginCandidateForName(candidates, name, source)), + candidates: names.map((name) => pluginCandidateForName(candidates, name, source, command)), duplicatePolicy: names.length === 1 ? "single" : "specified", }; } @@ -407,6 +412,7 @@ async function executeAdd(opts: AddOptions): Promise { interactive, progress, } = opts; + const command = commandPrefix(scope); const specifier = stripLeadingAt(rawSpecifier); const namesOverride = rawNames ? [...new Set(rawNames)] : rawNames; const { configPath } = scope; @@ -630,6 +636,7 @@ async function executeAdd(opts: AddOptions): Promise { sourceForStorage, interactive, all, + command, ); if (acquired.local) { const managedPluginsDir = await physicalPath(scope.pluginsDir); @@ -669,6 +676,7 @@ async function executeAdd(opts: AddOptions): Promise { namesOverride, sourceForStorage, interactive, + command, ); return persistSkills(selection); } @@ -679,7 +687,7 @@ export async function runAdd(opts: AddOptions): Promise { export default async function add( args: string[], - flags?: { user?: boolean }, + context: CommandContext, ): Promise { const { positionals, values } = parseArgs({ args, @@ -697,7 +705,7 @@ export default async function add( if (!specifier) { console.error( chalk.red( - "Usage: npx @sentry/dotagents add [...] [--name ...] [--ref ] [--all]", + `Usage: ${commandPrefix(context.scope)} add [...] [--name ...] [--ref ] [--all]`, ), ); process.exitCode = 1; @@ -721,9 +729,7 @@ export default async function add( const names = rawNames.length > 0 ? [...new Set(rawNames)] : undefined; try { - const scope = flags?.user - ? resolveScope("user") - : resolveDefaultScope(resolve(".")); + const { scope } = context; const interactive = process.stdout.isTTY === true && !names && !values["all"]; const progress = process.stdout.isTTY === true @@ -748,16 +754,16 @@ export default async function add( } catch (err) { if (err instanceof AddCancelledError) {return;} if (err instanceof TrustError) { - console.error(chalk.red(formatTrustError(err))); + console.error(chalk.red(formatTrustError(err, context.scope))); process.exitCode = 1; return; } if (err instanceof GitError) { - console.error(chalk.red(formatGitError(err))); + console.error(chalk.red(formatGitError(err, context.scope))); process.exitCode = 1; return; } - if (err instanceof ScopeError || err instanceof AddError) { + if (err instanceof AddError) { console.error(chalk.red(err.message)); process.exitCode = 1; return; diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 2df591fc..4525acd5 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -172,7 +172,7 @@ source = "path:external-review-tools" expect(check?.message).toContain("local-tools"); expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); expect(check?.message).toContain("review-tools"); - expect(check?.message).toContain("Run 'npx @sentry/dotagents install'"); + expect(check?.message).toContain("Run 'npx @sentry/dotagents --project install'"); }); it("detects a missing agent skill symlink", async () => { @@ -291,7 +291,42 @@ source = "getsentry/plugins" expect(check).toBeUndefined(); }); + it("diagnoses a legacy managed post-merge hook", async () => { + await mkdir(join(projectRoot, ".git", "hooks"), { recursive: true }); + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); + await writeFile( + join(projectRoot, ".git", "hooks", "post-merge"), + "#!/bin/sh\n# dotagents:post-merge\n dotagents install\n# dotagents:end\n", + ); + + const result = await runDoctor({ scope: resolveScope("project", projectRoot) }); + const check = result.checks.find((candidate) => candidate.name === "post-merge hook scope"); + expect(check?.status).toBe("warn"); + expect(check?.message).toContain("--project doctor --fix"); + }); + describe("--fix", () => { + it("repairs only the legacy managed post-merge block", async () => { + const hookPath = join(projectRoot, ".git", "hooks", "post-merge"); + await mkdir(join(projectRoot, ".git", "hooks"), { recursive: true }); + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); + await writeFile( + hookPath, + "#!/bin/sh\necho before\n# dotagents:post-merge\n dotagents install\n npx --yes @sentry/dotagents install\n# dotagents:end\necho after\n", + ); + + const result = await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + const hook = await readFile(hookPath, "utf-8"); + expect(result.fixed).toBeGreaterThan(0); + expect(hook).toMatch(/^#!\/bin\/sh\necho before\n/); + expect(hook).toContain("dotagents --project install"); + expect(hook).toMatch(/# dotagents:end\necho after\n$/); + }); + it("fixes missing root .gitignore entries", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 3f915ecd..eb5e2a0f 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; @@ -12,7 +11,9 @@ import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { verifySymlinks } from "../../symlinks/manager.js"; import { skillSymlinkTargets } from "../../targets/skill-symlinks.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import { findGitDir, type ScopeRoot } from "../../scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; +import { inspectPostMergeHook, updateManagedPostMergeHook } from "../post-merge-hook.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins } from "../../plugins/store.js"; @@ -38,6 +39,7 @@ export interface DoctorResult { export async function runDoctor(opts: DoctorOptions): Promise { const { scope, fix } = opts; + const cmd = commandPrefix(scope); const checks: DoctorCheck[] = []; let fixed = 0; @@ -46,7 +48,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: "agents.toml", status: "error", - message: "agents.toml not found. Run 'npx @sentry/dotagents init' to create one.", + message: `agents.toml not found. Run '${cmd} init' to create one.`, }); return { checks, fixed }; } @@ -148,7 +150,22 @@ export async function runDoctor(opts: DoctorOptions): Promise { } } - // 6. .agents/.gitignore exists (project scope only) + // 6. Legacy generated post-merge hook commands (project scope only) + if (scope.scope === "project") { + const gitDir = findGitDir(scope.root); + if (gitDir && await inspectPostMergeHook(gitDir) === "legacy") { + checks.push({ + name: "post-merge hook scope", + status: "warn", + message: `The managed post-merge hook uses a bare install command that targets global scope in v3. Run '${cmd} doctor --fix' to update it.`, + fix: async () => { + await updateManagedPostMergeHook(gitDir); + }, + }); + } + } + + // 7. .agents/.gitignore exists (project scope only) if (scope.scope === "project") { if (existsSync(`${scope.agentsDir}/.gitignore`)) { checks.push({ name: ".agents/.gitignore", status: "ok", message: ".agents/.gitignore exists." }); @@ -159,11 +176,12 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: ".agents/.gitignore", status: "warn", - message: ".agents/.gitignore is missing. Run 'npx @sentry/dotagents install' or 'npx @sentry/dotagents sync' to regenerate.", + message: `.agents/.gitignore is missing. Run '${cmd} install' or '${cmd} sync' to regenerate.`, fix: async () => { const installedPlugins = await loadInstalledPlugins( scope.pluginsDir, config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), + `${cmd} install`, ); await writeAgentsGitignore( scope.agentsDir, @@ -184,25 +202,25 @@ export async function runDoctor(opts: DoctorOptions): Promise { } } - // 7. Skills directory exists + // 8. Skills directory exists if (existsSync(scope.skillsDir)) { checks.push({ name: "skills directory", status: "ok", message: "Skills directory exists." }); } else { checks.push({ name: "skills directory", status: "warn", - message: ".agents/skills/ directory is missing. Run 'npx @sentry/dotagents install' to create it.", + message: `The managed skills directory is missing. Run '${cmd} install' to create it.`, }); } - // 8. Declared skills are installed + // 9. Declared skills are installed const declaredNames = getDeclaredSkillNames(config, lockfile); const missingSkills = declaredNames.filter((name) => !existsSync(`${scope.skillsDir}/${name}`)); if (missingSkills.length > 0) { checks.push({ name: "installed skills", status: "error", - message: `${missingSkills.length} skill(s) not installed: ${missingSkills.join(", ")}. Run 'npx @sentry/dotagents install'.`, + message: `${missingSkills.length} skill(s) not installed: ${missingSkills.join(", ")}. Run '${cmd} install'.`, }); } else if (declaredNames.length > 0) { checks.push({ name: "installed skills", status: "ok", message: `All ${declaredNames.length} declared skill(s) installed.` }); @@ -210,7 +228,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: "installed skills", status: "ok", message: "No skills declared." }); } - // 9. Declared plugins are installed + // 10. Declared plugins are installed const sameProjectPlugins = scope.scope === "project" ? config.plugins .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) @@ -228,7 +246,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { } if (missingPlugins.length > 0) { pluginErrors.push( - `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run '${cmd} install'.`, ); } if (pluginErrors.length > 0) { @@ -244,7 +262,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { } if (config.plugins.length > 0 && pluginErrors.length === 0) { - const installed = await loadInstalledPlugins(scope.pluginsDir, config.plugins); + const installed = await loadInstalledPlugins(scope.pluginsDir, config.plugins, `${cmd} install`); const runtimeIssues = installed.issues.length === 0 ? await verifyPluginOutputs(config.agents, installed.plugins, pluginRuntimeLayout(scope), { reservedMcpNames: config.mcp.map((server) => server.name), @@ -254,14 +272,14 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: "plugin runtime", status: "warn", - message: `${runtimeIssues.length} plugin runtime artifact(s) broken or missing. Run 'npx @sentry/dotagents sync' to repair. ${runtimeIssues.map(({ issue }) => issue).join(" ")}`, + message: `${runtimeIssues.length} plugin runtime artifact(s) broken or missing. Run '${cmd} sync' to repair. ${runtimeIssues.map(({ issue }) => issue).join(" ")}`, }); } else { checks.push({ name: "plugin runtime", status: "ok", message: "All plugin runtime artifacts intact." }); } } - // 10. Symlinks (project scope only) + // 11. Symlinks (project scope only) if (scope.scope === "project" && existsSync(scope.agentsDir)) { const targets = skillSymlinkTargets( scope, @@ -275,7 +293,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: "symlinks", status: "warn", - message: `${issues.length} symlink(s) broken or missing. Run 'npx @sentry/dotagents sync' to repair.`, + message: `${issues.length} symlink(s) broken or missing. Run '${cmd} sync' to repair.`, }); } else { checks.push({ name: "symlinks", status: "ok", message: "All symlinks intact." }); @@ -382,7 +400,7 @@ function getManagedPluginNames( return [...names]; } -export default async function doctor(args: string[], flags?: { user?: boolean }): Promise { +export default async function doctor(args: string[], context: CommandContext): Promise { const { values } = parseArgs({ args, options: { @@ -391,17 +409,7 @@ export default async function doctor(args: string[], flags?: { user?: boolean }) strict: true, }); - let scope: ScopeRoot; - try { - scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); - } catch (err) { - if (err instanceof ScopeError) { - console.error(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } + const { scope } = context; const result = await runDoctor({ scope, fix: values["fix"] }); @@ -423,7 +431,7 @@ export default async function doctor(args: string[], flags?: { user?: boolean }) } else if (hasIssues && !values["fix"]) { const fixable = result.checks.filter((c) => c.status !== "ok" && c.fix).length; if (fixable > 0) { - console.log(chalk.yellow(`\nRun 'npx @sentry/dotagents doctor --fix' to auto-fix ${fixable} issue(s).`)); + console.log(chalk.yellow(`\nRun '${commandPrefix(scope)} doctor --fix' to auto-fix ${fixable} issue(s).`)); } } else if (!hasIssues) { console.log(chalk.green("\nAll checks passed.")); diff --git a/packages/dotagents/src/cli/commands/init.test.ts b/packages/dotagents/src/cli/commands/init.test.ts index 45332f70..9818259d 100644 --- a/packages/dotagents/src/cli/commands/init.test.ts +++ b/packages/dotagents/src/cli/commands/init.test.ts @@ -53,7 +53,7 @@ describe("runInit", () => { try { process.chdir(child); - await init(["--agents", "claude"]); + await init(["--agents", "claude"], { scope: resolveScope("project", dir) }); } finally { process.chdir(cwd); } @@ -261,7 +261,7 @@ describe("installPostMergeHook", () => { expect(result).toBe("created"); const content = await readFile(join(gitDir, "hooks", "post-merge"), "utf-8"); expect(content).toMatch(/^#!\/bin\/sh\n/); - expect(content).toContain("dotagents install"); + expect(content).toContain("dotagents --project install"); expect(content).toContain("dotagents:post-merge"); }); @@ -283,7 +283,7 @@ describe("installPostMergeHook", () => { expect(result).toBe("created"); const content = await readFile(join(hooksDir, "post-merge"), "utf-8"); expect(content).toContain("echo 'existing'"); - expect(content).toContain("dotagents install"); + expect(content).toContain("dotagents --project install"); // Only one shebang expect(content.match(/^#!\/bin\/sh/gm)).toHaveLength(1); }); @@ -307,6 +307,38 @@ describe("installPostMergeHook", () => { await installPostMergeHook(gitDir); const content = await readFile(join(gitDir, "hooks", "post-merge"), "utf-8"); - expect(content).toContain("npx --yes @sentry/dotagents install"); + expect(content).toContain("npx --yes @sentry/dotagents --project install"); + }); +}); + +describe("init hook migration", () => { + let dir: string; + + afterEach(async () => { + process.exitCode = undefined; + await rm(dir, { recursive: true, force: true }); + }); + + it("repairs a legacy managed hook even when config already exists", async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-init-migration-")); + const hookPath = join(dir, ".git", "hooks", "post-merge"); + await mkdir(join(dir, ".git", "hooks"), { recursive: true }); + await writeFile(join(dir, "agents.toml"), "version = 1\n"); + await writeFile( + hookPath, + "#!/bin/sh\necho before\n# dotagents:post-merge\n dotagents install\n npx --yes @sentry/dotagents install\n# dotagents:end\necho after\n", + ); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await init([], { scope: resolveScope("project", dir) }); + + const hook = await readFile(hookPath, "utf-8"); + expect(hook).toContain("dotagents --project install"); + expect(hook).toMatch(/^#!\/bin\/sh\necho before\n/); + expect(hook).toMatch(/# dotagents:end\necho after\n$/); + expect(process.exitCode).toBe(1); + error.mockRestore(); + log.mockRestore(); }); }); diff --git a/packages/dotagents/src/cli/commands/init.ts b/packages/dotagents/src/cli/commands/init.ts index a46bbf0a..9d9afaab 100644 --- a/packages/dotagents/src/cli/commands/init.ts +++ b/packages/dotagents/src/cli/commands/init.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, relative, resolve } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { relative } from "node:path"; import chalk from "chalk"; import { generateDefaultConfig } from "../../config/writer.js"; import { writeAgentsGitignore, ensureRootGitignoreEntries } from "../../gitignore/writer.js"; @@ -10,12 +10,19 @@ import { allAgentIds, allAgents } from "../../targets/registry.js"; import { skillSymlinkTargets } from "../../targets/skill-symlinks.js"; import { parseArgs } from "node:util"; import * as clack from "@clack/prompts"; -import { resolveScope, findGitDir, findGitRoot, type ScopeRoot } from "../../scope.js"; +import { findGitDir, type ScopeRoot } from "../../scope.js"; import type { TrustConfig } from "../../config/schema.js"; import { GitError, TrustError } from "@sentry/dotagents-lib"; import { formatGitError, formatTrustError } from "../errors.js"; import { runInstall } from "./install.js"; import { allPluginOnlyAgentIds } from "../../plugins/targets.js"; +import { commandPrefix, type CommandContext } from "../context.js"; +import { + installPostMergeHook, + updateManagedPostMergeHook, +} from "../post-merge-hook.js"; + +export { installPostMergeHook } from "../post-merge-hook.js"; const BOOTSTRAP_SKILL = { name: "dotagents", source: "getsentry/dotagents" } as const; @@ -95,7 +102,7 @@ export async function runInit(opts: InitOptions): Promise { // carries the auth-required SSH hint. Both deserve a hard fail with the // formatted message rather than the generic "could not install" copy. if (err instanceof TrustError || err instanceof GitError) {throw err;} - console.log(chalk.yellow("Could not install skills. Run `npx @sentry/dotagents install` to install them later.")); + console.log(chalk.yellow(`Could not install skills. Run \`${commandPrefix(scope)} install\` to install them later.`)); } } @@ -128,7 +135,7 @@ function printSummary( } } - const cmd = scope.scope === "user" ? "npx @sentry/dotagents --user" : "npx @sentry/dotagents"; + const cmd = commandPrefix(scope); console.log( `\n${chalk.bold("Next steps:")}\n 1. Add skills: ${cmd} add getsentry/skills find-bugs\n 2. Install: ${cmd} install`, ); @@ -143,39 +150,6 @@ export class InitError extends Error { class CancelledError extends Error {} -const POST_MERGE_MARKER = "# dotagents:post-merge"; - -const POST_MERGE_SNIPPET = ` -${POST_MERGE_MARKER} -if command -v dotagents >/dev/null 2>&1; then - dotagents install -elif command -v npx >/dev/null 2>&1; then - npx --yes @sentry/dotagents install -fi -# dotagents:end -`; - -export async function installPostMergeHook(gitDir: string): Promise<"created" | "exists"> { - const hooksDir = join(gitDir, "hooks"); - await mkdir(hooksDir, { recursive: true }); - - const hookPath = join(hooksDir, "post-merge"); - - if (existsSync(hookPath)) { - const existing = await readFile(hookPath, "utf-8"); - if (existing.includes(POST_MERGE_MARKER)) { - return "exists"; - } - // Append to existing hook - await writeFile(hookPath, `${existing.trimEnd()}\n${POST_MERGE_SNIPPET}`, "utf-8"); - } else { - await writeFile(hookPath, `#!/bin/sh\n${POST_MERGE_SNIPPET}`, "utf-8"); - } - - await chmod(hookPath, 0o755); - return "created"; -} - const BANNER = ` _ _ _ __| | ___ | |_ __ _ __ _ ___ _ __| |_ ___ @@ -247,36 +221,37 @@ async function runInteractiveInit(scope: ScopeRoot, force?: boolean): Promise { +export default async function init(args: string[], context: CommandContext): Promise { const { values } = parseArgs({ args, options: { @@ -286,18 +261,20 @@ export default async function init(args: string[], flags?: { user?: boolean }): strict: true, }); - const gitRoot = findGitRoot(resolve(".")); - let scope: ScopeRoot; - if (flags?.user) { - scope = resolveScope("user"); - } else if (gitRoot) { - scope = resolveScope("project", gitRoot); - } else { - console.error("No project found, using user scope (~/.agents/)"); - scope = resolveScope("user"); - } + const { scope } = context; try { + if (scope.scope === "project") { + const gitDir = findGitDir(scope.root); + if (gitDir && await updateManagedPostMergeHook(gitDir)) { + if (process.stdout.isTTY) { + clack.log.success("Updated managed post-merge hook for project scope."); + } else { + console.log(chalk.green("Updated managed post-merge hook for project scope.")); + } + } + } + // Interactive mode: TTY with no --agents flag if (process.stdout.isTTY && values["agents"] === undefined) { await runInteractiveInit(scope, values["force"]); @@ -312,12 +289,12 @@ export default async function init(args: string[], flags?: { user?: boolean }): } catch (err) { if (err instanceof CancelledError) {return;} if (err instanceof TrustError) { - console.error(chalk.red(formatTrustError(err))); + console.error(chalk.red(formatTrustError(err, context.scope))); process.exitCode = 1; return; } if (err instanceof GitError) { - console.error(chalk.red(formatGitError(err))); + console.error(chalk.red(formatGitError(err, context.scope))); process.exitCode = 1; return; } diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 370f225b..e80efa31 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1537,7 +1537,7 @@ source = "path:plugin-source" let output = ""; try { process.chdir(projectRoot); - await install(["--frozen"]); + await install(["--frozen"], { scope: resolveScope("project", projectRoot) }); } finally { output = log.mock.calls.flat().join("\n"); process.chdir(previousCwd); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 478e1759..a5f4745f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { GitError, TrustError, type CacheReuse } from "@sentry/dotagents-lib"; @@ -6,8 +5,9 @@ import { loadConfig } from "../../config/loader.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import type { Lockfile } from "../../lockfile/schema.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import type { CommandContext } from "../context.js"; import { formatGitError, formatTrustError } from "../errors.js"; import { InstallError } from "./install/errors.js"; import { installSkills } from "./install/skills.js"; @@ -96,7 +96,7 @@ export async function runInstall(opts: InstallOptions): Promise { }; } -export default async function install(args: string[], flags?: { user?: boolean }): Promise { +export default async function install(args: string[], context: CommandContext): Promise { const { values } = parseArgs({ args, options: { @@ -106,7 +106,7 @@ export default async function install(args: string[], flags?: { user?: boolean } }); try { - const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); + const { scope } = context; await ensureUserScopeBootstrapped(scope); if (values["frozen"]) { console.log(chalk.yellow("Warning: --frozen is ignored and will be removed in the next major release. Install now follows normal agents.toml resolution; use explicit refs to pin sources.")); @@ -148,16 +148,16 @@ export default async function install(args: string[], flags?: { user?: boolean } } } catch (err) { if (err instanceof TrustError) { - console.error(chalk.red(formatTrustError(err))); + console.error(chalk.red(formatTrustError(err, context.scope))); process.exitCode = 1; return; } if (err instanceof GitError) { - console.error(chalk.red(formatGitError(err))); + console.error(chalk.red(formatGitError(err, context.scope))); process.exitCode = 1; return; } - if (err instanceof ScopeError || err instanceof InstallError) { + if (err instanceof InstallError) { console.error(chalk.red(err.message)); process.exitCode = 1; return; diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index b2df9f3a..5f31d98e 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -8,6 +8,7 @@ import { isInPlacePluginSource } from "../../../plugins/store.js"; import type { PluginDeclaration } from "../../../plugins/types.js"; import { projectedPiSkillNames } from "../../../plugins/runtime/writer.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; +import { commandPrefix } from "../../context.js"; export interface InstallGitignoreArtifacts { installedSkillNames: string[]; @@ -61,6 +62,6 @@ export async function writeInstallGitignore( const missing = await checkRootGitignoreEntries(scope.root); if (missing.length > 0) { - console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); + console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run '${commandPrefix(scope)} doctor --fix' to fix.`)); } } diff --git a/packages/dotagents/src/cli/commands/list.test.ts b/packages/dotagents/src/cli/commands/list.test.ts index 79483454..30c6c04b 100644 --- a/packages/dotagents/src/cli/commands/list.test.ts +++ b/packages/dotagents/src/cli/commands/list.test.ts @@ -225,7 +225,7 @@ source = "org/plugins" const log = vi.spyOn(console, "log").mockImplementation(() => {}); process.chdir(projectRoot); - await list(["--json"]); + await list(["--json"], { scope: resolveScope("project", projectRoot) }); const printed = log.mock.calls[0]?.[0]; expect(JSON.parse(String(printed))).toEqual({ diff --git a/packages/dotagents/src/cli/commands/list.ts b/packages/dotagents/src/cli/commands/list.ts index 5944eb88..fbad3eb6 100644 --- a/packages/dotagents/src/cli/commands/list.ts +++ b/packages/dotagents/src/cli/commands/list.ts @@ -1,4 +1,4 @@ -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; @@ -6,8 +6,9 @@ import { isWildcardDep } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { wildcardContainsLockedSkill } from "../../lockfile/wildcard.js"; import { existsSync } from "node:fs"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import type { CommandContext } from "../context.js"; export interface SkillStatus { name: string; @@ -142,7 +143,7 @@ function formatPluginStatus(s: PluginStatus): string { } } -export default async function list(args: string[], flags?: { user?: boolean }): Promise { +export default async function list(args: string[], context: CommandContext): Promise { const { values } = parseArgs({ args, options: { @@ -151,18 +152,8 @@ export default async function list(args: string[], flags?: { user?: boolean }): strict: true, }); - let scope: ScopeRoot; - try { - scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); - await ensureUserScopeBootstrapped(scope); - } catch (err) { - if (err instanceof ScopeError) { - console.error(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } + const { scope } = context; + await ensureUserScopeBootstrapped(scope); const results = await runList({ scope, json: values["json"], diff --git a/packages/dotagents/src/cli/commands/mcp.test.ts b/packages/dotagents/src/cli/commands/mcp.test.ts index 74fcc301..5ceaf6c6 100644 --- a/packages/dotagents/src/cli/commands/mcp.test.ts +++ b/packages/dotagents/src/cli/commands/mcp.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runMcpAdd, runMcpRemove, getMcpList, McpError, validateMcpName, parseHeader } from "./mcp.js"; +import mcp, { runMcpAdd, runMcpRemove, getMcpList, McpError, validateMcpName, parseHeader } from "./mcp.js"; import { loadConfig } from "../../config/loader.js"; import type { ScopeRoot } from "../../scope.js"; @@ -34,6 +34,7 @@ describe("mcp", () => { }); afterEach(async () => { + process.exitCode = undefined; delete process.env["DOTAGENTS_STATE_DIR"]; await rm(tmpDir, { recursive: true }); }); @@ -172,4 +173,13 @@ describe("mcp", () => { }); }); }); + + it("includes explicit project scope in nested usage errors", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await mcp(["add"], { scope }); + + expect(error).toHaveBeenCalledWith(expect.stringContaining("npx @sentry/dotagents --project mcp add")); + error.mockRestore(); + }); }); diff --git a/packages/dotagents/src/cli/commands/mcp.ts b/packages/dotagents/src/cli/commands/mcp.ts index f7bcec4d..43acc119 100644 --- a/packages/dotagents/src/cli/commands/mcp.ts +++ b/packages/dotagents/src/cli/commands/mcp.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { parseArgs } from "node:util"; import * as clack from "@clack/prompts"; import chalk from "chalk"; @@ -6,8 +5,9 @@ import { loadConfig } from "../../config/loader.js"; import type { AgentsConfig, McpConfig } from "../../config/schema.js"; import { addMcpToConfig, removeMcpFromConfig } from "../../config/writer.js"; import { runInstall } from "./install.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; export class McpError extends Error { constructor(message: string) { @@ -239,11 +239,12 @@ async function mcpAdd(args: string[], scope: ScopeRoot): Promise { const name = positionals[0]; if (!name) { + const cmd = commandPrefix(scope); console.error( - chalk.red("Usage: npx @sentry/dotagents mcp add --command [--env ...]"), + chalk.red(`Usage: ${cmd} mcp add --command [--env ...]`), ); console.error( - chalk.red(" npx @sentry/dotagents mcp add --url [--header ...] [--env ...]"), + chalk.red(` ${cmd} mcp add --url [--header ...] [--env ...]`), ); process.exitCode = 1; return; @@ -278,7 +279,7 @@ async function mcpRemove(args: string[], scope: ScopeRoot): Promise { const name = positionals[0]; if (!name) { - console.error(chalk.red("Usage: npx @sentry/dotagents mcp remove ")); + console.error(chalk.red(`Usage: ${commandPrefix(scope)} mcp remove `)); process.exitCode = 1; return; } @@ -316,8 +317,8 @@ async function mcpList(args: string[], scope: ScopeRoot): Promise { } } -function printMcpUsage(): void { - console.error(`Usage: npx @sentry/dotagents mcp +function printMcpUsage(scope: ScopeRoot): void { + console.error(`Usage: ${commandPrefix(scope)} mcp Subcommands: add Add an MCP server declaration @@ -325,26 +326,16 @@ Subcommands: list Show declared MCP servers`); } -export default async function mcp(args: string[], flags?: { user?: boolean }): Promise { +export default async function mcp(args: string[], context: CommandContext): Promise { const sub = args[0]; if (!sub || sub === "--help" || sub === "-h") { - printMcpUsage(); + printMcpUsage(context.scope); return; } - let scope: ScopeRoot; - try { - scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); - await ensureUserScopeBootstrapped(scope); - } catch (err) { - if (err instanceof ScopeError) { - console.error(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } + const { scope } = context; + await ensureUserScopeBootstrapped(scope); const subArgs = args.slice(1); @@ -361,12 +352,12 @@ export default async function mcp(args: string[], flags?: { user?: boolean }): P break; default: console.error(chalk.red(`Unknown mcp subcommand: ${sub}`)); - printMcpUsage(); + printMcpUsage(scope); process.exitCode = 1; } } catch (err) { if (err instanceof McpCancelledError) {return;} - if (err instanceof ScopeError || err instanceof McpError) { + if (err instanceof McpError) { console.error(chalk.red(err.message)); process.exitCode = 1; return; diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index a3799a4b..1646b91b 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { rm } from "node:fs/promises"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; @@ -23,8 +23,9 @@ import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { wildcardContainsLockedSkill } from "../../lockfile/wildcard.js"; import { writeAgentsGitignore } from "../../gitignore/writer.js"; import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from "@sentry/dotagents-lib"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; import { isInPlaceSkill } from "../../utils/fs.js"; import { isInPlacePluginSource, @@ -271,7 +272,11 @@ async function removePluginArtifacts( const remainingPluginConfigs = config.plugins .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) .filter((plugin) => existsSync(join(scope.pluginsDir, plugin.name))); - const installedPlugins = await loadInstalledPlugins(scope.pluginsDir, remainingPluginConfigs); + const installedPlugins = await loadInstalledPlugins( + scope.pluginsDir, + remainingPluginConfigs, + `${commandPrefix(scope)} install`, + ); if (installedPlugins.issues.length === 0) { const { result } = await reconcilePluginOutputs( config.agents, @@ -328,6 +333,7 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { const installedPlugins = await loadInstalledPlugins( scope.pluginsDir, config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), + `${commandPrefix(scope)} install`, ); await writeAgentsGitignore( scope.agentsDir, @@ -355,7 +361,7 @@ async function promptYesNo(question: string): Promise { }); } -export default async function remove(args: string[], flags?: { user?: boolean }): Promise { +export default async function remove(args: string[], context: CommandContext): Promise { const { positionals, values } = parseArgs({ args, allowPositionals: true, @@ -367,7 +373,7 @@ export default async function remove(args: string[], flags?: { user?: boolean }) const arg = positionals[0]; if (!arg) { - console.error(chalk.red("Usage: npx @sentry/dotagents remove [-y]")); + console.error(chalk.red(`Usage: ${commandPrefix(context.scope)} remove [-y]`)); process.exitCode = 1; return; } @@ -375,7 +381,7 @@ export default async function remove(args: string[], flags?: { user?: boolean }) const skipConfirm = values.yes as boolean; try { - const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); + const { scope } = context; await ensureUserScopeBootstrapped(scope); try { @@ -460,7 +466,7 @@ export default async function remove(args: string[], flags?: { user?: boolean }) console.log(chalk.green(`Removed from "${arg}": ${summary}`)); } } catch (err) { - if (err instanceof ScopeError || err instanceof RemoveError) { + if (err instanceof RemoveError) { console.error(chalk.red(err.message)); process.exitCode = 1; return; diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 3ffc27c1..f8df199d 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -397,7 +397,7 @@ source = "path:plugin-source/review-tools" { type: "missing", name: "review-tools", - message: `Plugin "review-tools" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + message: `Plugin "review-tools" is in agents.toml but not installed. Run 'npx @sentry/dotagents --project install'.`, }, ]); }); diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 75e77112..044f922b 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -1,4 +1,4 @@ -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { existsSync } from "node:fs"; import { readdir, rm } from "node:fs/promises"; import chalk from "chalk"; @@ -20,8 +20,9 @@ import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, import { projectedPiSkillNames, reconcilePluginOutputs, verifyPluginOutputs } from "../../plugins/runtime/writer.js"; import { pluginRuntimeLayout } from "../../plugins/runtime/layout.js"; import { userMcpResolver } from "../../targets/paths.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; export interface SyncIssue { @@ -48,6 +49,7 @@ export interface SyncResult { export async function runSync(opts: SyncOptions): Promise { const { scope } = opts; + const cmd = commandPrefix(scope); const { configPath, lockPath, agentsDir, skillsDir, pluginsDir } = scope; const subagentsDir = join(agentsDir, "agents"); @@ -189,6 +191,7 @@ export async function runSync(opts: SyncOptions): Promise { const installedPluginsForGitignore = await loadInstalledPlugins( pluginsDir, runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))), + `${cmd} install`, ); await writeAgentsGitignore( agentsDir, @@ -209,7 +212,7 @@ export async function runSync(opts: SyncOptions): Promise { // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore const missing = await checkRootGitignoreEntries(scope.root); if (missing.length > 0) { - console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); + console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run '${cmd} doctor --fix' to fix.`)); } } @@ -219,7 +222,7 @@ export async function runSync(opts: SyncOptions): Promise { issues.push({ type: "missing", name, - message: `"${name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + message: `"${name}" is in agents.toml but not installed. Run '${cmd} install'.`, }); } } @@ -228,7 +231,7 @@ export async function runSync(opts: SyncOptions): Promise { issues.push({ type: "missing", name: plugin.name, - message: `Plugin "${plugin.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + message: `Plugin "${plugin.name}" is in agents.toml but not installed. Run '${cmd} install'.`, }); } } @@ -280,7 +283,11 @@ export async function runSync(opts: SyncOptions): Promise { // 7. Verify and repair custom subagent files let subagentsRepaired = 0; - const installedSubagentResult = await loadInstalledSubagents(subagentsDir, config.subagents); + const installedSubagentResult = await loadInstalledSubagents( + subagentsDir, + config.subagents, + `${cmd} install`, + ); const prunedInstalledSubagents = await pruneInstalledSubagents(subagentsDir, config.subagents); const subagentDecls = installedSubagentResult.subagents; const subagentResolver = scope.scope === "user" @@ -327,7 +334,7 @@ export async function runSync(opts: SyncOptions): Promise { // 8. Verify and repair plugin runtime projections let pluginsRepaired = 0; const installedPluginConfigs = runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))); - const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs); + const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs, `${cmd} install`); const pluginDecls = installedPluginResult.plugins; const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); let pluginIssues: Awaited> = []; @@ -390,19 +397,9 @@ async function removeStaleManagedSkill(skillsDir: string, name: string): Promise return true; } -export default async function sync(_args: string[], flags?: { user?: boolean }): Promise { - let scope: ScopeRoot; - try { - scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); - await ensureUserScopeBootstrapped(scope); - } catch (err) { - if (err instanceof ScopeError) { - console.error(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } +export default async function sync(_args: string[], context: CommandContext): Promise { + const { scope } = context; + await ensureUserScopeBootstrapped(scope); const result = await runSync({ scope }); if (result.adopted.length > 0) { diff --git a/packages/dotagents/src/cli/commands/trust.test.ts b/packages/dotagents/src/cli/commands/trust.test.ts index bd20dd76..d8e49a9c 100644 --- a/packages/dotagents/src/cli/commands/trust.test.ts +++ b/packages/dotagents/src/cli/commands/trust.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { + default as trust, classifyTrustSource, runTrustAdd, runTrustRemove, @@ -40,6 +41,7 @@ describe("trust", () => { }); afterEach(async () => { + process.exitCode = undefined; delete process.env["DOTAGENTS_STATE_DIR"]; await rm(tmpDir, { recursive: true }); }); @@ -300,4 +302,13 @@ describe("trust", () => { ]); }); }); + + it("includes explicit project scope in nested usage errors", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await trust(["add"], { scope }); + + expect(error).toHaveBeenCalledWith(expect.stringContaining("npx @sentry/dotagents --project trust add")); + error.mockRestore(); + }); }); diff --git a/packages/dotagents/src/cli/commands/trust.ts b/packages/dotagents/src/cli/commands/trust.ts index 9fd93cdf..57ce08f5 100644 --- a/packages/dotagents/src/cli/commands/trust.ts +++ b/packages/dotagents/src/cli/commands/trust.ts @@ -1,11 +1,11 @@ -import { resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; import type { AgentsConfig, RepositorySource } from "../../config/schema.js"; import { addTrustSource, removeTrustSource } from "../../config/writer.js"; -import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; +import { commandPrefix, type CommandContext } from "../context.js"; export class TrustCommandError extends Error { constructor(message: string) { @@ -110,7 +110,7 @@ async function trustAdd(args: string[], scope: ScopeRoot): Promise { const source = positionals[0]; if (!source) { - console.error(chalk.red("Usage: npx @sentry/dotagents trust add ")); + console.error(chalk.red(`Usage: ${commandPrefix(scope)} trust add `)); console.error(chalk.red(" can be: org, owner/repo, or domain.name")); process.exitCode = 1; return; @@ -131,7 +131,7 @@ async function trustRemove(args: string[], scope: ScopeRoot): Promise { const source = positionals[0]; if (!source) { - console.error(chalk.red("Usage: npx @sentry/dotagents trust remove ")); + console.error(chalk.red(`Usage: ${commandPrefix(scope)} trust remove `)); process.exitCode = 1; return; } @@ -176,8 +176,8 @@ async function trustList(args: string[], scope: ScopeRoot): Promise { } } -function printTrustUsage(): void { - console.error(`Usage: npx @sentry/dotagents trust +function printTrustUsage(scope: ScopeRoot): void { + console.error(`Usage: ${commandPrefix(scope)} trust Subcommands: add Add a trusted source (org, owner/repo, or domain) @@ -185,26 +185,16 @@ Subcommands: list Show trusted sources`); } -export default async function trust(args: string[], flags?: { user?: boolean }): Promise { +export default async function trust(args: string[], context: CommandContext): Promise { const sub = args[0]; if (!sub || sub === "--help" || sub === "-h") { - printTrustUsage(); + printTrustUsage(context.scope); return; } - let scope: ScopeRoot; - try { - scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); - await ensureUserScopeBootstrapped(scope); - } catch (err) { - if (err instanceof ScopeError) { - console.error(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } + const { scope } = context; + await ensureUserScopeBootstrapped(scope); const subArgs = args.slice(1); @@ -221,11 +211,11 @@ export default async function trust(args: string[], flags?: { user?: boolean }): break; default: console.error(chalk.red(`Unknown trust subcommand: ${sub}`)); - printTrustUsage(); + printTrustUsage(scope); process.exitCode = 1; } } catch (err) { - if (err instanceof ScopeError || err instanceof TrustCommandError) { + if (err instanceof TrustCommandError) { console.error(chalk.red(err.message)); process.exitCode = 1; return; diff --git a/packages/dotagents/src/cli/context.ts b/packages/dotagents/src/cli/context.ts new file mode 100644 index 00000000..9aad0f8b --- /dev/null +++ b/packages/dotagents/src/cli/context.ts @@ -0,0 +1,12 @@ +import type { ScopeRoot } from "../scope.js"; + +export interface CommandContext { + scope: ScopeRoot; +} + +export function commandPrefix(scope: ScopeRoot): string { + return scope.scope === "project" + ? "npx @sentry/dotagents --project" + : "npx @sentry/dotagents"; +} + diff --git a/packages/dotagents/src/cli/errors.test.ts b/packages/dotagents/src/cli/errors.test.ts index 8fadbc68..9bae80e5 100644 --- a/packages/dotagents/src/cli/errors.test.ts +++ b/packages/dotagents/src/cli/errors.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; import { GitError, TrustError, type TrustPolicy } from "@sentry/dotagents-lib"; +import { resolveScope } from "../scope.js"; import { formatGitError, formatTrustError } from "./errors.js"; +const globalScope = resolveScope("user"); + const policy: TrustPolicy = { allow_all: false, github_orgs: ["getsentry"], @@ -19,7 +22,7 @@ describe("formatTrustError", () => { allowed: policy, }); - const out = formatTrustError(err); + const out = formatTrustError(err, globalScope); expect(out).toContain("npx @sentry/dotagents trust add evil"); expect(out).toContain("npx @sentry/dotagents trust add evil/repo"); }); @@ -32,7 +35,7 @@ describe("formatTrustError", () => { allowed: policy, }); - const out = formatTrustError(err); + const out = formatTrustError(err, globalScope); expect(out).toContain("npx @sentry/dotagents trust add git.evil.com"); }); @@ -42,7 +45,7 @@ describe("formatTrustError", () => { kind: "git", allowed: policy, }); - expect(formatTrustError(err)).toBe("Some unusual case."); + expect(formatTrustError(err, globalScope)).toBe("Some unusual case."); }); }); @@ -54,7 +57,7 @@ describe("formatGitError", () => { sshUrl: "git@github.com:private/repo.git", }); - const out = formatGitError(err); + const out = formatGitError(err, globalScope); expect(out).toContain("npx @sentry/dotagents add git@github.com:private/repo.git"); }); @@ -63,11 +66,11 @@ describe("formatGitError", () => { kind: "other", url: "https://github.com/x/y.git", }); - expect(formatGitError(err)).toBe("Failed to clone: some other error"); + expect(formatGitError(err, globalScope)).toBe("Failed to clone: some other error"); }); it("returns the bare message when details are absent", () => { const err = new GitError("Plain message"); - expect(formatGitError(err)).toBe("Plain message"); + expect(formatGitError(err, globalScope)).toBe("Plain message"); }); }); diff --git a/packages/dotagents/src/cli/errors.ts b/packages/dotagents/src/cli/errors.ts index 09a327d6..1541449b 100644 --- a/packages/dotagents/src/cli/errors.ts +++ b/packages/dotagents/src/cli/errors.ts @@ -1,4 +1,6 @@ import { GitError, TrustError } from "@sentry/dotagents-lib"; +import type { ScopeRoot } from "../scope.js"; +import { commandPrefix } from "./context.js"; /** * Format a TrustError into a user-facing message that includes the @@ -6,17 +8,18 @@ import { GitError, TrustError } from "@sentry/dotagents-lib"; * offending source. The lib throws plain TrustError; this wrapper adds the * host-specific recovery copy. */ -export function formatTrustError(err: TrustError): string { +export function formatTrustError(err: TrustError, scope: ScopeRoot): string { + const cmd = commandPrefix(scope); const { details } = err; if (details.kind === "github" && details.owner && details.repo) { return ( `${err.message}\n` + - `Run: npx @sentry/dotagents trust add ${details.owner} ` + - `(or \`npx @sentry/dotagents trust add ${details.owner}/${details.repo}\` for just this repo)` + `Run: ${cmd} trust add ${details.owner} ` + + `(or \`${cmd} trust add ${details.owner}/${details.repo}\` for just this repo)` ); } if ((details.kind === "git" || details.kind === "well-known") && details.domain) { - return `${err.message}\nRun: npx @sentry/dotagents trust add ${details.domain}`; + return `${err.message}\nRun: ${cmd} trust add ${details.domain}`; } return err.message; } @@ -26,12 +29,13 @@ export function formatTrustError(err: TrustError): string { * `npx @sentry/dotagents add ` hint when the failure looks like * missing auth on a public hosting provider. */ -export function formatGitError(err: GitError): string { +export function formatGitError(err: GitError, scope: ScopeRoot): string { + const cmd = commandPrefix(scope); if (err.details?.kind === "auth-required" && err.details.sshUrl) { return ( `${err.message}\n` + `Hint: for private repos, use the SSH URL instead:\n` + - ` npx @sentry/dotagents add ${err.details.sshUrl}` + ` ${cmd} add ${err.details.sshUrl}` ); } return err.message; diff --git a/packages/dotagents/src/cli/help.test.ts b/packages/dotagents/src/cli/help.test.ts index 1b170427..f7095b22 100644 --- a/packages/dotagents/src/cli/help.test.ts +++ b/packages/dotagents/src/cli/help.test.ts @@ -44,4 +44,14 @@ describe("getCommandHelp", () => { expect(help).toContain("Add plugins explicitly, or all skills as a wildcard"); expect(help).not.toContain("--plugin"); }); + + it("gives exact scope guidance on command help", () => { + const help = getCommandHelp("install", ["--help"]); + + expect(help).toContain(`Scope: + (no flag) Global scope (~/.agents/); this is the default + --project Current project (Git root, or current directory outside Git) + --global Explicit global scope + --user Compatibility alias for --global`); + }); }); diff --git a/packages/dotagents/src/cli/help.ts b/packages/dotagents/src/cli/help.ts index 7c27de9f..af09a175 100644 --- a/packages/dotagents/src/cli/help.ts +++ b/packages/dotagents/src/cli/help.ts @@ -1,20 +1,20 @@ const COMMAND_HELP: Record = { - init: `Usage: npx @sentry/dotagents [--user|--global] init [options] + init: `Usage: npx @sentry/dotagents [--project|--global|--user] init [options] -Initialize agents.toml and the managed .agents directory. +Initialize agents.toml and the selected scope's managed directories. Options: --agents Comma-separated agent IDs for non-interactive setup --force Replace an existing configuration --help, -h Show this help message`, - install: `Usage: npx @sentry/dotagents [--user|--global] install [options] + install: `Usage: npx @sentry/dotagents [--project|--global|--user] install [options] Install or refresh dependencies declared in agents.toml. Options: --frozen Deprecated compatibility flag; normal install still runs --help, -h Show this help message`, - add: `Usage: npx @sentry/dotagents [--user|--global] add [name...] [options] + add: `Usage: npx @sentry/dotagents [--project|--global|--user] add [name...] [options] Discover plugins first, otherwise skills, then add and install the selected dependencies. @@ -24,34 +24,34 @@ Options: --ref Git tag, branch, or commit --all Add plugins explicitly, or all skills as a wildcard dependency --help, -h Show this help message`, - remove: `Usage: npx @sentry/dotagents [--user|--global] remove [options] + remove: `Usage: npx @sentry/dotagents [--project|--global|--user] remove [options] Remove a skill or plugin, or remove every dependency from a source. Options: -y, --yes Skip confirmation when excluding or removing by source --help, -h Show this help message`, - sync: `Usage: npx @sentry/dotagents [--user|--global] sync [options] + sync: `Usage: npx @sentry/dotagents [--project|--global|--user] sync [options] Reconcile local state without fetching dependency updates. Options: --help, -h Show this help message`, - list: `Usage: npx @sentry/dotagents [--user|--global] list [options] + list: `Usage: npx @sentry/dotagents [--project|--global|--user] list [options] Show declared skills and plugins with installation status. Options: --json Print structured output --help, -h Show this help message`, - doctor: `Usage: npx @sentry/dotagents [--user|--global] doctor [options] + doctor: `Usage: npx @sentry/dotagents [--project|--global|--user] doctor [options] Check configuration, managed state, symlinks, and generated files. Options: --fix Apply supported repairs --help, -h Show this help message`, - mcp: `Usage: npx @sentry/dotagents [--user|--global] mcp + mcp: `Usage: npx @sentry/dotagents [--project|--global|--user] mcp Manage MCP server declarations. @@ -61,7 +61,7 @@ Subcommands: list Show declared MCP servers Run 'npx @sentry/dotagents mcp --help' for details.`, - "mcp add": `Usage: npx @sentry/dotagents [--user|--global] mcp add (--command | --url ) [options] + "mcp add": `Usage: npx @sentry/dotagents [--project|--global|--user] mcp add (--command | --url ) [options] Add an MCP server declaration to agents.toml. @@ -71,20 +71,20 @@ Options: --header HTTP header; repeatable and URL transport only --env Environment variable name; repeatable --help, -h Show this help message`, - "mcp remove": `Usage: npx @sentry/dotagents [--user|--global] mcp remove [options] + "mcp remove": `Usage: npx @sentry/dotagents [--project|--global|--user] mcp remove [options] Remove an MCP server declaration from agents.toml. Options: --help, -h Show this help message`, - "mcp list": `Usage: npx @sentry/dotagents [--user|--global] mcp list [options] + "mcp list": `Usage: npx @sentry/dotagents [--project|--global|--user] mcp list [options] Show MCP server declarations from agents.toml. Options: --json Print structured output --help, -h Show this help message`, - trust: `Usage: npx @sentry/dotagents [--user|--global] trust + trust: `Usage: npx @sentry/dotagents [--project|--global|--user] trust Manage trusted skill sources. @@ -94,19 +94,19 @@ Subcommands: list Show trusted sources Run 'npx @sentry/dotagents trust --help' for details.`, - "trust add": `Usage: npx @sentry/dotagents [--user|--global] trust add [options] + "trust add": `Usage: npx @sentry/dotagents [--project|--global|--user] trust add [options] Add a trusted GitHub org, owner/repo, or Git domain. Options: --help, -h Show this help message`, - "trust remove": `Usage: npx @sentry/dotagents [--user|--global] trust remove [options] + "trust remove": `Usage: npx @sentry/dotagents [--project|--global|--user] trust remove [options] Remove a trusted source from agents.toml. Options: --help, -h Show this help message`, - "trust list": `Usage: npx @sentry/dotagents [--user|--global] trust list [options] + "trust list": `Usage: npx @sentry/dotagents [--project|--global|--user] trust list [options] Show trusted sources from agents.toml. @@ -115,8 +115,15 @@ Options: --help, -h Show this help message`, }; +const SCOPE_HELP = `Scope: + (no flag) Global scope (~/.agents/); this is the default + --project Current project (Git root, or current directory outside Git) + --global Explicit global scope + --user Compatibility alias for --global`; + export function getCommandHelp(command: string, args: string[]): string | undefined { if (!args.some((arg) => arg === "--help" || arg === "-h")) {return undefined;} const subcommand = args[0]; - return COMMAND_HELP[subcommand ? `${command} ${subcommand}` : command] ?? COMMAND_HELP[command]; + const help = COMMAND_HELP[subcommand ? `${command} ${subcommand}` : command] ?? COMMAND_HELP[command]; + return help ? `${help}\n\n${SCOPE_HELP}` : undefined; } diff --git a/packages/dotagents/src/cli/index.test.ts b/packages/dotagents/src/cli/index.test.ts index 666072af..6aa1e059 100644 --- a/packages/dotagents/src/cli/index.test.ts +++ b/packages/dotagents/src/cli/index.test.ts @@ -1,15 +1,53 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const init = vi.fn(); +const install = vi.fn(); +const add = vi.fn(); +const remove = vi.fn(); const sync = vi.fn(); +const list = vi.fn(); +const mcp = vi.fn(); +const trust = vi.fn(); +const doctor = vi.fn(); const checkForUpdate = vi.fn(() => Promise.resolve(null)); +vi.mock("./commands/init.js", () => ({ default: init })); +vi.mock("./commands/install.js", () => ({ default: install })); +vi.mock("./commands/add.js", () => ({ default: add })); +vi.mock("./commands/remove.js", () => ({ default: remove })); vi.mock("./commands/sync.js", () => ({ default: sync })); +vi.mock("./commands/list.js", () => ({ default: list })); +vi.mock("./commands/mcp.js", () => ({ default: mcp })); +vi.mock("./commands/trust.js", () => ({ default: trust })); +vi.mock("./commands/doctor.js", () => ({ default: doctor })); vi.mock("./update-notifier.js", () => ({ checkForUpdate })); +const COMMAND_CASES = [ + ["init", init], + ["install", install], + ["add", add], + ["remove", remove], + ["sync", sync], + ["list", list], + ["mcp", mcp], + ["trust", trust], + ["doctor", doctor], +] as const; + describe("CLI help dispatch", () => { + const originalExitCode = process.exitCode; + beforeEach(() => { - sync.mockReset(); + for (const [, handler] of COMMAND_CASES) {handler.mockReset();} checkForUpdate.mockClear(); + process.exitCode = originalExitCode; + }); + + afterEach(() => { + process.exitCode = originalExitCode; }); it("prints command help without running the command", async () => { @@ -21,15 +59,28 @@ describe("CLI help dispatch", () => { expect(sync).not.toHaveBeenCalled(); expect(checkForUpdate).not.toHaveBeenCalled(); expect(log).toHaveBeenCalledWith(expect.stringContaining("Reconcile local state")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("(no flag) Global scope (~/.agents/); this is the default")); log.mockRestore(); }); - it.each(["--user", "--global"])("passes %s as user scope", async (scopeFlag) => { + it("defaults to global scope", async () => { + const { main } = await import("./main.js"); + + await main(["sync"]); + + expect(sync).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "user" }), + }); + }); + + it.each(["--user", "--global"])("passes %s as explicit global scope", async (scopeFlag) => { const { main } = await import("./main.js"); - await main([scopeFlag, "sync"]); + await main(["sync", scopeFlag]); - expect(sync).toHaveBeenCalledWith([], { user: true }); + expect(sync).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "user" }), + }); }); it("accepts both user scope aliases together", async () => { @@ -37,6 +88,134 @@ describe("CLI help dispatch", () => { await main(["--user", "--global", "sync"]); - expect(sync).toHaveBeenCalledWith([], { user: true }); + expect(sync).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "user" }), + }); + }); + + it.each([ + ["before", ["--project", "sync"]], + ["after", ["sync", "--project"]], + ])("passes project scope with the flag %s the command", async (_placement, argv) => { + const { main } = await import("./main.js"); + + await main(argv); + + expect(sync).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "project" }), + }); + }); + + it("rejects contradictory scope flags without running the command", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const { main } = await import("./main.js"); + + await main(["--project", "sync", "--global"]); + + expect(sync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Cannot combine --project")); + error.mockRestore(); + }); + + it("rejects a project conflict with the legacy alias", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const { main } = await import("./main.js"); + + await main(["sync", "--project", "--user"]); + + expect(sync).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith("Cannot combine --project with --global or --user."); + error.mockRestore(); + }); + + it("documents the default and compatibility flags in top-level help", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const { main } = await import("./main.js"); + + await main([]); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("--project Operate on the current project instead of global scope")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("--global Explicitly operate on global scope (~/.agents/, the default)")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("--user Compatibility alias for --global")); + log.mockRestore(); + }); +}); + +describe("scope isolation for all commands", () => { + let root: string; + let projectRoot: string; + let canonicalProjectRoot: string; + let globalRoot: string; + let originalCwd: string; + + beforeEach(async () => { + for (const [, handler] of COMMAND_CASES) {handler.mockReset();} + checkForUpdate.mockClear(); + process.exitCode = undefined; + originalCwd = process.cwd(); + root = await mkdtemp(join(tmpdir(), "dotagents-cli-scope-")); + projectRoot = join(root, "project"); + globalRoot = join(root, "global"); + await mkdir(join(projectRoot, ".git"), { recursive: true }); + await mkdir(globalRoot, { recursive: true }); + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + await writeFile(join(globalRoot, "agents.toml"), "version = 1\n"); + canonicalProjectRoot = await realpath(projectRoot); + process.env["DOTAGENTS_HOME"] = globalRoot; + process.chdir(projectRoot); + }); + + afterEach(async () => { + process.chdir(originalCwd); + delete process.env["DOTAGENTS_HOME"]; + await rm(root, { recursive: true, force: true }); + }); + + it.each(COMMAND_CASES)("uses only global state for unqualified %s", async (command, handler) => { + const { main } = await import("./main.js"); + + await main([command]); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "user", root: globalRoot }), + }); + }); + + it.each(COMMAND_CASES)("uses only project state for explicit-project %s", async (command, handler) => { + const { main } = await import("./main.js"); + + await main(["--project", command]); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "project", root: canonicalProjectRoot }), + }); + }); + + it("fails a project command with no config instead of falling back globally", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await rm(join(projectRoot, "agents.toml")); + const { main } = await import("./main.js"); + + await main(["--project", "sync"]); + + expect(sync).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("--project init")); + error.mockRestore(); + }); + + it("allows project init in the current directory outside Git", async () => { + await rm(join(projectRoot, ".git"), { recursive: true }); + await rm(join(projectRoot, "agents.toml")); + const { main } = await import("./main.js"); + + await main(["--project", "init"]); + + expect(init).toHaveBeenCalledWith([], { + scope: expect.objectContaining({ scope: "project", root: canonicalProjectRoot }), + }); }); }); diff --git a/packages/dotagents/src/cli/main.ts b/packages/dotagents/src/cli/main.ts index 52abfd41..a5089974 100644 --- a/packages/dotagents/src/cli/main.ts +++ b/packages/dotagents/src/cli/main.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import { resolve } from "node:path"; import { checkForUpdate } from "./update-notifier.js"; import init from "./commands/init.js"; import install from "./commands/install.js"; @@ -10,6 +11,7 @@ import mcp from "./commands/mcp.js"; import trust from "./commands/trust.js"; import doctor from "./commands/doctor.js"; import { getCommandHelp } from "./help.js"; +import { resolveProjectScope, resolveScope, ScopeError, type Scope } from "../scope.js"; const require = createRequire(import.meta.url); const { version } = require("../../package.json") as { version: string }; @@ -20,13 +22,30 @@ const COMMANDS = { } as const; type Command = keyof typeof COMMANDS; +export interface ParsedScopeArgs { + args: string[]; + scope: Scope; +} + +export function parseScopeArgs(argv: string[]): ParsedScopeArgs { + const project = argv.includes("--project"); + const global = argv.some((arg) => arg === "--global" || arg === "--user"); + if (project && global) { + throw new ScopeError("Cannot combine --project with --global or --user."); + } + return { + args: argv.filter((arg) => !["--project", "--global", "--user"].includes(arg)), + scope: project ? "project" : "user", + }; +} + function printUsage(): void { console.log(`dotagents - shared tooling for coding agents -Usage: npx @sentry/dotagents [--user|--global] [options] +Usage: npx @sentry/dotagents [--project|--global|--user] [options] Commands: - init Initialize agents.toml and .agents/skills/ + init Initialize configuration and managed directories install Install dependencies from agents.toml add Add a skill dependency remove Remove a skill, plugin, or source @@ -34,18 +53,29 @@ Commands: list Show declared skills and plugins mcp Manage MCP server declarations trust Manage trusted sources - doctor Check project health and fix issues + doctor Check active-scope health and fix issues Options: - --user Operate on user-scope (~/.agents/) instead of project - --global Alias for --user + --project Operate on the current project instead of global scope + --global Explicitly operate on global scope (~/.agents/, the default) + --user Compatibility alias for --global --help, -h Show this help message --version Show version`); } export async function main(argv = process.argv.slice(2)): Promise { - const isUser = argv.some((arg) => arg === "--user" || arg === "--global"); - const args = argv.filter((arg) => arg !== "--user" && arg !== "--global"); + let parsed: ParsedScopeArgs; + try { + parsed = parseScopeArgs(argv); + } catch (err) { + if (err instanceof ScopeError) { + console.error(err.message); + process.exitCode = 1; + return; + } + throw err; + } + const { args } = parsed; const first = args[0]; if (!first || first === "--help" || first === "-h") { @@ -72,8 +102,22 @@ export async function main(argv = process.argv.slice(2)): Promise { return; } + let scope; + try { + scope = parsed.scope === "project" + ? resolveProjectScope(resolve("."), { requireConfig: first !== "init" }) + : resolveScope("user"); + } catch (err) { + if (err instanceof ScopeError) { + console.error(err.message); + process.exitCode = 1; + return; + } + throw err; + } + const updateMessage = checkForUpdate(version); - await command(commandArgs, { user: isUser }); + await command(commandArgs, { scope }); const message = await updateMessage; if (message) { diff --git a/packages/dotagents/src/cli/post-merge-hook.test.ts b/packages/dotagents/src/cli/post-merge-hook.test.ts new file mode 100644 index 00000000..4e7cf3a6 --- /dev/null +++ b/packages/dotagents/src/cli/post-merge-hook.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectPostMergeHook, + installPostMergeHook, + updateManagedPostMergeHook, +} from "./post-merge-hook.js"; + +const LEGACY_BLOCK = `# dotagents:post-merge +if command -v dotagents >/dev/null 2>&1; then + dotagents install +elif command -v npx >/dev/null 2>&1; then + npx --yes @sentry/dotagents install +fi +# dotagents:end`; + +describe("managed post-merge hooks", () => { + let root: string; + let gitDir: string; + let hookPath: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "dotagents-post-merge-")); + gitDir = join(root, ".git"); + hookPath = join(gitDir, "hooks", "post-merge"); + await mkdir(join(gitDir, "hooks"), { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("detects and replaces a legacy block without touching surrounding content", async () => { + const before = `#!/usr/bin/env bash\necho before\n${LEGACY_BLOCK}\necho after\n`; + await writeFile(hookPath, before); + await chmod(hookPath, 0o744); + + expect(await inspectPostMergeHook(gitDir)).toBe("legacy"); + expect(await updateManagedPostMergeHook(gitDir)).toBe(true); + + const after = await readFile(hookPath, "utf-8"); + expect(after).toMatch(/^#!\/usr\/bin\/env bash\necho before\n/); + expect(after).toContain("dotagents --project install"); + expect(after).toContain("npx --yes @sentry/dotagents --project install"); + expect(after).toMatch(/# dotagents:end\necho after\n$/); + expect((await lstat(hookPath)).mode & 0o777).toBe(0o744); + expect(await inspectPostMergeHook(gitDir)).toBe("current"); + }); + + it("is idempotent after repairing a legacy block", async () => { + await writeFile(hookPath, `#!/bin/sh\n${LEGACY_BLOCK}\n`); + + expect(await installPostMergeHook(gitDir)).toBe("updated"); + const repaired = await readFile(hookPath, "utf-8"); + expect(await installPostMergeHook(gitDir)).toBe("exists"); + expect(await readFile(hookPath, "utf-8")).toBe(repaired); + }); + + it("treats an unmatched marker as unmanaged content", async () => { + await writeFile(hookPath, "#!/bin/sh\n# dotagents:post-merge\necho custom\n"); + + expect(await inspectPostMergeHook(gitDir)).toBe("unmanaged"); + }); +}); diff --git a/packages/dotagents/src/cli/post-merge-hook.ts b/packages/dotagents/src/cli/post-merge-hook.ts new file mode 100644 index 00000000..3ce10f56 --- /dev/null +++ b/packages/dotagents/src/cli/post-merge-hook.ts @@ -0,0 +1,81 @@ +import { existsSync } from "node:fs"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export const POST_MERGE_MARKER = "# dotagents:post-merge"; +export const POST_MERGE_END_MARKER = "# dotagents:end"; + +const POST_MERGE_BLOCK = `${POST_MERGE_MARKER} +if command -v dotagents >/dev/null 2>&1; then + dotagents --project install +elif command -v npx >/dev/null 2>&1; then + npx --yes @sentry/dotagents --project install +fi +${POST_MERGE_END_MARKER}`; + +function managedBlockRange(content: string): { start: number; end: number } | undefined { + const start = content.indexOf(POST_MERGE_MARKER); + if (start === -1) {return undefined;} + const endMarker = content.indexOf(POST_MERGE_END_MARKER, start); + if (endMarker === -1) {return undefined;} + return { start, end: endMarker + POST_MERGE_END_MARKER.length }; +} + +export function hasLegacyPostMergeBlock(content: string): boolean { + const range = managedBlockRange(content); + if (!range) {return false;} + const block = content.slice(range.start, range.end); + return /^\s*dotagents install\s*$/m.test(block) || + /^\s*npx --yes @sentry\/dotagents install\s*$/m.test(block); +} + +function replaceManagedBlock(content: string): string | undefined { + const range = managedBlockRange(content); + if (!range) {return undefined;} + return `${content.slice(0, range.start)}${POST_MERGE_BLOCK}${content.slice(range.end)}`; +} + +export async function inspectPostMergeHook( + gitDir: string, +): Promise<"missing" | "unmanaged" | "current" | "legacy"> { + const hookPath = join(gitDir, "hooks", "post-merge"); + if (!existsSync(hookPath)) {return "missing";} + const content = await readFile(hookPath, "utf-8"); + if (!managedBlockRange(content)) {return "unmanaged";} + return hasLegacyPostMergeBlock(content) ? "legacy" : "current"; +} + +/** Updates an existing managed block without creating a new hook. */ +export async function updateManagedPostMergeHook(gitDir: string): Promise { + const hookPath = join(gitDir, "hooks", "post-merge"); + if (!existsSync(hookPath)) {return false;} + const content = await readFile(hookPath, "utf-8"); + const updated = replaceManagedBlock(content); + if (updated === undefined || updated === content) {return false;} + await writeFile(hookPath, updated, "utf-8"); + return true; +} + +export async function installPostMergeHook( + gitDir: string, +): Promise<"created" | "updated" | "exists"> { + const hooksDir = join(gitDir, "hooks"); + await mkdir(hooksDir, { recursive: true }); + const hookPath = join(hooksDir, "post-merge"); + + if (existsSync(hookPath)) { + const existing = await readFile(hookPath, "utf-8"); + const updated = replaceManagedBlock(existing); + if (updated !== undefined) { + if (updated === existing) {return "exists";} + await writeFile(hookPath, updated, "utf-8"); + return "updated"; + } + await writeFile(hookPath, `${existing.trimEnd()}\n${POST_MERGE_BLOCK}\n`, "utf-8"); + } else { + await writeFile(hookPath, `#!/bin/sh\n${POST_MERGE_BLOCK}\n`, "utf-8"); + } + + await chmod(hookPath, 0o755); + return "created"; +} diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 3fca8cba..93e2562a 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -118,7 +118,7 @@ describe("plugin store", () => { const result = await loadInstalledPlugins(pluginsDir, [{ name: "review-tools", source: "path:source/review-tools", - }]); + }], "npx @sentry/dotagents install"); expect(result.plugins).toEqual([]); expect(result.issues[0]?.issue).toContain("Installed plugin resolves outside source"); } finally { @@ -135,7 +135,7 @@ describe("plugin store", () => { const result = await loadInstalledPlugins(pluginsDir, [{ name: "review-tools", source: "path:source/review-tools", - }]); + }], "npx @sentry/dotagents install"); expect(result.plugins).toEqual([]); expect(result.issues[0]?.issue).toContain("has no plugin.json or supported native manifest"); } finally { diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index 9759d4a9..fd291f37 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -224,6 +224,7 @@ export async function installPluginBundle( export async function loadInstalledPlugins( pluginsDir: string, configs: PluginConfig[], + installCommand: string, ): Promise<{ plugins: PluginDeclaration[]; issues: Array<{ name: string; issue: string }> }> { const plugins: PluginDeclaration[] = []; const issues: Array<{ name: string; issue: string }> = []; @@ -233,7 +234,7 @@ export async function loadInstalledPlugins( if (!existsSync(pluginDir)) { issues.push({ name: config.name, - issue: `Plugin "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + issue: `Plugin "${config.name}" is in agents.toml but not installed. Run '${installCommand}'.`, }); continue; } diff --git a/packages/dotagents/src/scope.test.ts b/packages/dotagents/src/scope.test.ts index 235299c1..73f3dc3b 100644 --- a/packages/dotagents/src/scope.test.ts +++ b/packages/dotagents/src/scope.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { dirname, join, resolve } from "node:path"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; @@ -7,7 +7,7 @@ import { isInsideGitRepo, findGitDir, findGitRoot, - resolveDefaultScope, + resolveProjectScope, ScopeError, } from "./scope.js"; @@ -161,7 +161,7 @@ describe("findGitRoot", () => { }); }); -describe("resolveDefaultScope", () => { +describe("resolveProjectScope", () => { let tempDir: string; afterEach(() => { @@ -172,7 +172,7 @@ describe("resolveDefaultScope", () => { it("returns project scope when agents.toml exists", () => { tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); writeFileSync(join(tempDir, "agents.toml"), ""); - const s = resolveDefaultScope(tempDir); + const s = resolveProjectScope(tempDir); expect(s.scope).toBe("project"); expect(s.root).toBe(tempDir); }); @@ -184,27 +184,32 @@ describe("resolveDefaultScope", () => { const child = join(tempDir, "packages", "app"); mkdirSync(child, { recursive: true }); - const s = resolveDefaultScope(child); + const s = resolveProjectScope(child); expect(s.scope).toBe("project"); expect(s.root).toBe(tempDir); }); - it("falls back to user scope when not in a git repo", () => { + it("uses the current directory for a non-Git project", () => { tempDir = mkNonGitTempDir(); - process.env["DOTAGENTS_HOME"] = join(tempDir, "user-home"); - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const s = resolveDefaultScope(tempDir); - expect(s.scope).toBe("user"); - expect(spy).toHaveBeenCalledWith(expect.stringContaining("user scope")); - spy.mockRestore(); + writeFileSync(join(tempDir, "agents.toml"), ""); + const s = resolveProjectScope(tempDir); + expect(s.scope).toBe("project"); + expect(s.root).toBe(tempDir); + }); + + it("allows missing config when initializing a non-Git project", () => { + tempDir = mkNonGitTempDir(); + const s = resolveProjectScope(tempDir, { requireConfig: false }); + expect(s.scope).toBe("project"); + expect(s.root).toBe(tempDir); }); - it("throws ScopeError when in a git repo but no agents.toml", () => { + it("throws ScopeError when agents.toml is required but missing", () => { tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); mkdirSync(join(tempDir, ".git")); - expect(() => resolveDefaultScope(tempDir)).toThrow(ScopeError); - expect(() => resolveDefaultScope(tempDir)).toThrow(/dotagents init/); + expect(() => resolveProjectScope(tempDir)).toThrow(ScopeError); + expect(() => resolveProjectScope(tempDir)).toThrow(/--project init/); }); }); diff --git a/packages/dotagents/src/scope.ts b/packages/dotagents/src/scope.ts index 6b268dd2..d6354ce0 100644 --- a/packages/dotagents/src/scope.ts +++ b/packages/dotagents/src/scope.ts @@ -123,29 +123,16 @@ export class ScopeError extends Error { } } -/** - * Resolve scope when the user did NOT pass `--user`. - * - * - If inside a Git repository → use its root when `agents.toml` exists there. - * - If `agents.toml` exists at a non-Git `projectRoot` → project scope. - * - If we're not inside a git repo → user scope (with a notice). - * - If the repository root has no agents.toml → throw with a helpful message. - */ -export function resolveDefaultScope(projectRoot: string): ScopeRoot { - const gitRoot = findGitRoot(projectRoot); - if (gitRoot) { - if (existsSync(join(gitRoot, "agents.toml"))) { - return resolveScope("project", gitRoot); - } +/** Resolve explicit project scope without falling back to global state. */ +export function resolveProjectScope( + projectRoot: string, + options: { requireConfig?: boolean } = {}, +): ScopeRoot { + const root = findGitRoot(projectRoot) ?? resolve(projectRoot); + if (options.requireConfig !== false && !existsSync(join(root, "agents.toml"))) { throw new ScopeError( - "No agents.toml found. Run 'npx @sentry/dotagents init' to set up this project, or use --user for user scope.", + "No agents.toml found. Run 'npx @sentry/dotagents --project init' to set up this project.", ); } - - if (existsSync(join(projectRoot, "agents.toml"))) { - return resolveScope("project", projectRoot); - } - - console.error("No project found, using user scope (~/.agents/)"); - return resolveScope("user"); + return resolveScope("project", root); } diff --git a/packages/dotagents/src/subagents/store.test.ts b/packages/dotagents/src/subagents/store.test.ts index af56687f..dbf08d1e 100644 --- a/packages/dotagents/src/subagents/store.test.ts +++ b/packages/dotagents/src/subagents/store.test.ts @@ -367,7 +367,7 @@ Review the current diff for Claude. markManagedMarkdownSubagent(SUBAGENT_MD("other-reviewer")), ); - const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + const result = await loadInstalledSubagents(installedDir, [subagentConfig()], "npx @sentry/dotagents install"); expect(result.subagents).toEqual([]); expect(result.issues).toHaveLength(1); @@ -381,7 +381,7 @@ Review the current diff for Claude. await mkdir(installedDir, { recursive: true }); await writeFile(join(installedDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); - const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + const result = await loadInstalledSubagents(installedDir, [subagentConfig()], "npx @sentry/dotagents install"); expect(result.subagents).toEqual([]); expect(result.issues).toHaveLength(1); @@ -580,7 +580,7 @@ Review the current diff. }, }]); - const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + const result = await loadInstalledSubagents(installedDir, [subagentConfig()], "npx @sentry/dotagents install"); expect(result.issues).toEqual([]); expect(result.subagents[0]!.native?.codex).toContain('sandbox_mode = "read-only"'); diff --git a/packages/dotagents/src/subagents/store.ts b/packages/dotagents/src/subagents/store.ts index 7307f989..bd7105b9 100644 --- a/packages/dotagents/src/subagents/store.ts +++ b/packages/dotagents/src/subagents/store.ts @@ -194,6 +194,7 @@ export async function writeInstalledSubagents( export async function loadInstalledSubagents( subagentsDir: string, configs: SubagentConfig[], + installCommand: string, ): Promise<{ subagents: SubagentDeclaration[]; issues: InstalledSubagentLoadIssue[] }> { const subagents: SubagentDeclaration[] = []; const issues: InstalledSubagentLoadIssue[] = []; @@ -203,7 +204,7 @@ export async function loadInstalledSubagents( if (!existsSync(filePath)) { issues.push({ name: config.name, - issue: `Subagent "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + issue: `Subagent "${config.name}" is in agents.toml but not installed. Run '${installCommand}'.`, }); continue; } diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 4e6696e7..13e2b0ef 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -1,7 +1,7 @@ --- name: dotagents-qa description: QA dotagents changes and published releases in Docker, including CLI lifecycles, user/global scope, real plugins, and Claude, Codex, OpenCode, or Pi projections. Use when behavior, packaging, scopes, or harness integration needs runtime proof. -spec_hash: 5ea75cc3362b +spec_hash: eda48b96deb3 --- # dotagents QA @@ -14,7 +14,7 @@ Before commands, state: - the exact subject: local checkout, packed local build, or published version; - the commands and semantics at risk; -- project scope, user scope, or both; +- default-global scope, explicit-project scope, or both; - the harnesses involved; - the fixture and evidence that constitute a pass. @@ -66,20 +66,24 @@ Add focused regression tests for every confirmed logic bug. A failing baseline i Use the CLI from a fresh fixture and inspect output plus files: ```bash -dotagents init --agents claude,codex,opencode,pi -dotagents add [name] -dotagents list --json -dotagents doctor -dotagents install +dotagents --project init --agents claude,codex,opencode,pi +dotagents --project add [name] +dotagents --project list --json +dotagents --project doctor +dotagents --project install ``` -Inspect `agents.toml`, `agents.lock`, canonical skills/plugins, ownership markers, marketplaces, manifests, harness projections, and warnings. Delete representative managed artifacts, run `dotagents sync`, and prove exact repair. Run `dotagents remove ` and prove canonical and generated cleanup. +Inspect `agents.toml`, `agents.lock`, canonical skills/plugins, ownership markers, marketplaces, manifests, harness projections, and warnings. Delete representative managed artifacts, run `dotagents --project sync`, and prove exact repair. Run `dotagents --project remove ` and prove canonical and generated cleanup. -Invoke every lifecycle command named by the contract. In particular, run `dotagents install` explicitly even though `add` also installs, and assert the config and lockfile paths directly rather than inferring them from `list` output. +Invoke every lifecycle command named by the contract with the intended scope. In particular, run `install` explicitly even though `add` also installs, and assert the config and lockfile paths directly rather than inferring them from `list` output. + +For scope-selection changes, create both a project config and isolated global config, then prove unqualified commands mutate only global state from inside that configured repository. Prove `--project` mutates only project state, both `--global` and `--user` select the global paths, `--global --user` executes once, and either alias combined with `--project` fails before creating or changing files. Also cover `--project init` outside Git. + +Test hook migration explicitly: create an executable, marker-delimited legacy `post-merge` hook containing unrelated lines around the bare install command; prove `--project doctor` diagnoses it and `--project doctor --fix` changes only the managed command to explicit project scope while preserving the unrelated content and executable mode. Cover an unambiguous skill source as well as plugins so plugin-first discovery still falls back correctly. Use `--all` only with a controlled small catalog. -For user plugins, test both `--user` and `--global`. Isolate `HOME` and `DOTAGENTS_HOME`; inspect global Claude, Codex, OpenCode, and Pi paths; repair with one flag spelling and remove with the other. +For global plugins, test the unqualified default plus both `--global` and legacy `--user`. Isolate `HOME` and `DOTAGENTS_HOME`; inspect global Claude, Codex, OpenCode, and Pi paths; repair with one spelling and remove with another. ## 5. Test representative plugins diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index ae760d68..a36030da 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -9,7 +9,7 @@ | `specs/SPEC.md` | canonical skills, scopes, lifecycle commands, and generated harness behavior | | `specs/plugins.md` | plugin discovery, storage, targeting, projection, and CLI semantics | | `packages/dotagents/src/scope.ts` | project and user root/path resolution, including `DOTAGENTS_HOME` | -| `packages/dotagents/src/cli/main.ts` | global `--user` and `--global` flag parsing | +| `packages/dotagents/src/cli/main.ts` | default-global, explicit-project, alias, and conflict parsing | | `packages/dotagents/src/cli/commands/{add,install,sync,remove,doctor}.ts` | complete plugin lifecycle behavior and repair surfaces | | `packages/dotagents/src/plugins/store.ts` | plugin discovery, source resolution, canonical install, and installed-bundle loading | | `packages/dotagents/src/plugins/runtime/layout.ts` | project versus user/global runtime destinations | @@ -26,7 +26,7 @@ - Exclude `*.tsbuildinfo` with `dist` so TypeScript project references rebuild cleanly instead of reusing stale host incremental state. - Document `-i` for non-TTY Docker runs because stdin must stay attached when an agent feeds commands through a here-doc. - Distinguish exact published-package evidence from a later packed-local-build fix. -- Require project and user/global lifecycle coverage when plugin scope behavior changes, including both flag spellings. +- Require default-global and explicit-project lifecycle coverage when scope behavior changes, including aliases, conflicts, and cross-scope isolation. - Test Sentry, Vercel, and one selected Anthropic marketplace plugin as the high-signal compatibility set. - Keep OpenCode and Pi proofs isolated because both can observe `.agents/skills` and contaminate one another. - Use native no-auth Claude/Codex management commands, OpenCode skill and MCP resource discovery, and Pi link inspection without claiming model invocation. diff --git a/skills/dotagents-qa/evals/cases/exercise-complete-cli-lifecycles.yaml b/skills/dotagents-qa/evals/cases/exercise-complete-cli-lifecycles.yaml index 2a11c222..cd7844c4 100644 --- a/skills/dotagents-qa/evals/cases/exercise-complete-cli-lifecycles.yaml +++ b/skills/dotagents-qa/evals/cases/exercise-complete-cli-lifecycles.yaml @@ -1,5 +1,5 @@ behavior: exercise-complete-cli-lifecycles prompt: | - Design the exact CLI lifecycle test for --user and --global plugins, including recovery and uninstall. I will provide the checkout afterward; for now, give me the executable test sequence and assertions without running it. + Design the exact CLI lifecycle test for a global plugin, including recovery and uninstall. I will provide the checkout afterward; for now, give me the executable test sequence and assertions without running it. checks: - - judge: The sequence covers init, add, install, list, doctor, deliberate managed-output damage, sync repair, and remove cleanup; uses both flag spellings interchangeably; and asserts config, lockfile, canonical bundle, marketplace, and global harness projection paths. + - judge: The sequence covers init, add, install, list, doctor, deliberate managed-output damage, sync repair, and remove cleanup; uses the unqualified default plus both global alias spellings; and asserts config, lockfile, canonical bundle, marketplace, and global harness projection paths. diff --git a/skills/dotagents-qa/evals/cases/verify-project-scope-migration-edges.yaml b/skills/dotagents-qa/evals/cases/verify-project-scope-migration-edges.yaml new file mode 100644 index 00000000..0b1619b0 --- /dev/null +++ b/skills/dotagents-qa/evals/cases/verify-project-scope-migration-edges.yaml @@ -0,0 +1,5 @@ +behavior: exercise-complete-cli-lifecycles +prompt: | + Design the exact CLI test for v3 project-scope migration edge cases: initializing outside Git and repairing an old managed post-merge hook. I will provide the checkout afterward; for now, give me the executable test sequence and assertions without running it. +checks: + - judge: The sequence proves `--project init` outside Git uses the current directory and skips Git-only setup, then separately proves a legacy managed hook is diagnosed and repaired to explicit project scope while preserving unrelated hook content and executable mode. diff --git a/skills/dotagents-qa/evals/cases/verify-scope-flag-compatibility.yaml b/skills/dotagents-qa/evals/cases/verify-scope-flag-compatibility.yaml new file mode 100644 index 00000000..de5de905 --- /dev/null +++ b/skills/dotagents-qa/evals/cases/verify-scope-flag-compatibility.yaml @@ -0,0 +1,5 @@ +behavior: exercise-complete-cli-lifecycles +prompt: | + Design the exact CLI test for v3 scope-flag compatibility and conflicts. I will provide the checkout afterward; for now, give me the executable test sequence and assertions without running it. +checks: + - judge: The sequence proves `--global` and `--user` select the same global state as the unqualified default, `--global --user` executes successfully exactly once, and either alias combined with `--project` fails before changing global or project files. diff --git a/skills/dotagents-qa/evals/cases/verify-scope-reversal-release.yaml b/skills/dotagents-qa/evals/cases/verify-scope-reversal-release.yaml new file mode 100644 index 00000000..95769121 --- /dev/null +++ b/skills/dotagents-qa/evals/cases/verify-scope-reversal-release.yaml @@ -0,0 +1,5 @@ +behavior: exercise-complete-cli-lifecycles +prompt: | + Design the exact CLI test proving v3 unqualified commands use global state while explicit project commands remain isolated inside a configured Git repository. I will provide the checkout afterward; for now, give me the executable test sequence and assertions without running it. +checks: + - judge: The sequence creates distinct global and project state, proves unqualified commands from inside the configured repository mutate only global state, and proves `--project` commands mutate only project state. diff --git a/skills/dotagents-qa/references/core-agentic-qa.md b/skills/dotagents-qa/references/core-agentic-qa.md index e58b32c4..2585adc4 100644 --- a/skills/dotagents-qa/references/core-agentic-qa.md +++ b/skills/dotagents-qa/references/core-agentic-qa.md @@ -12,14 +12,14 @@ pnpm qa:example This builds the local CLI, copies `examples/full/` to a temp project, and verifies: -- `install`, `list`, `doctor --fix`, and `doctor` complete successfully +- `--project install`, `--project list`, `--project doctor --fix`, and `--project doctor` complete successfully - managed skills under `.agents/skills/` - Claude/Cursor skill symlink behavior - MCP files for Claude, Cursor, Codex, and OpenCode - hook files for Claude and Cursor - canonical installed subagent under `.agents/agents/` - generated subagent runtime files for Claude, Cursor, Codex, and OpenCode -- `sync` repair after deleting representative generated files +- `--project sync` repair after deleting representative generated files Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` to keep the temp project for inspection. The script prints the project path. @@ -30,4 +30,4 @@ This proves dotagents local CLI behavior and generated file placement. It does n ## When To Customize -Start from `examples/full/` when a branch changes broad behavior. Create a custom temp fixture only when the example cannot express the changed surface, such as unusual source resolution, user scope, conflict handling, or packaging behavior. +Start from `examples/full/` when a branch changes broad behavior. Create a custom temp fixture only when the example cannot express the changed surface, such as unusual source resolution, global/project isolation, scope conflicts, legacy hook repair, or packaging behavior. diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 1e142aaf..374ad5c7 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -62,7 +62,7 @@ Manual Docker probes can prove more when the branch affects OpenCode output: stdio command, args, cwd, and environment For lifecycle proof, preserve an unrelated MCP entry, damage a managed plugin -MCP entry or its ownership state, run `dotagents sync`, and verify repair. +MCP entry or its ownership state, run `dotagents --project sync`, and verify repair. Then remove the plugin and verify only its managed MCP entries, ownership state, and managed data directory are pruned. diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index 8c3d6ee1..2d8e9005 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -17,7 +17,7 @@ pnpm qa:plugins ``` `pnpm qa:example` proves the dotagents install/sync filesystem contract. It runs -`install`, `list`, `doctor --fix`, `doctor`, and `sync`, then checks generated +`--project install`, `--project list`, `--project doctor --fix`, `--project doctor`, and `--project sync`, then checks generated files and repair behavior. `pnpm qa:plugins` runs installed no-auth client proofs: diff --git a/skills/dotagents-qa/references/release-plugin-matrix.md b/skills/dotagents-qa/references/release-plugin-matrix.md index f97e9e33..d156614c 100644 --- a/skills/dotagents-qa/references/release-plugin-matrix.md +++ b/skills/dotagents-qa/references/release-plugin-matrix.md @@ -20,13 +20,13 @@ Use a fresh git project per source and initialize every relevant full agent plus mkdir -p /sandbox/cases/sentry cd /sandbox/cases/sentry git init -q -dotagents init --agents claude,codex,opencode,pi -dotagents add getsentry/agent-plugin -dotagents list --json -dotagents doctor +dotagents --project init --agents claude,codex,opencode,pi +dotagents --project add getsentry/agent-plugin +dotagents --project list --json +dotagents --project doctor ``` -Inspect `agents.toml`, `agents.lock`, `.agents/.gitignore`, the canonical plugin bundle, generated marketplaces/manifests, and component links. Delete representative managed outputs, run `dotagents sync`, and verify repair. Run `dotagents remove ` and verify canonical files, marketplaces, markers, and links are pruned. +Inspect `agents.toml`, `agents.lock`, `.agents/.gitignore`, the canonical plugin bundle, generated marketplaces/manifests, and component links. Delete representative managed outputs, run `dotagents --project sync`, and verify repair. Run `dotagents --project remove ` and verify canonical files, marketplaces, markers, and links are pruned. Also cover a skill-only source or fixture so plugin-first detection still falls back to skill behavior. Test `--all` with a small controlled catalog, not a hundreds-entry public marketplace. @@ -84,15 +84,15 @@ data directory. Use a Pi-only target and verify each managed link in `.agents/skills/` resolves to the canonical plugin skill and has its ownership marker. Pi has no equivalent no-auth skill inventory command, so do not claim model-backed loading from link proof alone. -## User and global scope +## Default global scope and aliases -Test both flag spellings with isolated homes: +Test the default plus both flag spellings with isolated homes. Run the unqualified lifecycle from inside a separate configured Git repository and prove the repository remains unchanged: ```bash export HOME=/sandbox/home export DOTAGENTS_HOME="$HOME/.agents" -dotagents --global init --agents claude,codex,opencode,pi -dotagents --user add getsentry/agent-plugin +dotagents init --agents claude,codex,opencode,pi +dotagents add getsentry/agent-plugin dotagents --global list --json dotagents --user doctor ``` @@ -126,7 +126,7 @@ opencode debug config When `DOTAGENTS_HOME` is not `$HOME/.agents`, add the Codex marketplace from `$DOTAGENTS_HOME`; dotagents emits its adapter catalog below `$DOTAGENTS_HOME/.agents/plugins/` so Codex can discover it. Assert user-scope plugin MCP from a neutral project so project config cannot supply a false pass. -Delete one marketplace, one component link, and one managed OpenCode MCP entry or ownership record; run `dotagents --user sync` and prove repair. Remove the plugin using the other flag spelling and prove all canonical and generated state is gone while unrelated OpenCode config remains. +Delete one marketplace, one component link, and one managed OpenCode MCP entry or ownership record; run `dotagents --user sync` and prove repair. Remove the plugin using the other flag spelling and prove all canonical and generated state is gone while unrelated OpenCode config remains. Also prove `--global --user` executes once and `--project` combined with either alias fails without mutating either scope. ## Reporting diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 73076710..b8339f8f 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -146,7 +146,7 @@ async function runSyncRepair() { rmSync(join(projectDir, ".grok", "plugins", "qa-tools"), { force: true, recursive: true }); rmSync(join(projectDir, ".opencode", "skills", "plugin-qa"), { force: true, recursive: true }); rmSync(join(projectDir, ".agents", "skills", "plugin-qa"), { force: true, recursive: true }); - runCli(["sync"]); + runCli(["--project", "sync"]); assertFile(".mcp.json"); assertSymlink(".claude/skills"); assertFile(".claude/agents/code-reviewer.md"); @@ -313,7 +313,7 @@ function prepareClientHarness(agent) { const configPath = join(projectDir, "agents.toml"); const config = readFileSync(configPath, "utf-8").replace(/^agents = .*$/m, `agents = ["${agent}"]`); writeFileSync(configPath, config); - runCli(["install"]); + runCli(["--project", "install"]); assertFile(".agents/plugins/qa-tools/plugin.json"); } @@ -323,14 +323,14 @@ async function runCodexRuntimeProof() { } async function installAndAssert() { - runCli(["install"]); - const list = runCli(["list"]); + runCli(["--project", "install"]); + const list = runCli(["--project", "list"]); writeFileSync(join(tmp, "list.out"), list); const listStatuses = await listSkills(); assertSkillStatus(listStatuses, "review"); assertSkillStatus(listStatuses, "commit"); - runCli(["doctor", "--fix"]); - runCli(["doctor"]); + runCli(["--project", "doctor", "--fix"]); + runCli(["--project", "doctor"]); assertFile(".agents/skills/review/SKILL.md"); assertFile(".agents/skills/commit/SKILL.md"); diff --git a/skills/dotagents-qa/spec.md b/skills/dotagents-qa/spec.md index b072b9d2..292c3ca3 100644 --- a/skills/dotagents-qa/spec.md +++ b/skills/dotagents-qa/spec.md @@ -25,9 +25,9 @@ The agent SHALL identify the exact build or published version under test, the be The agent SHALL run package and runtime QA as a non-root user with `HOME`, `DOTAGENTS_STATE_DIR`, `DOTAGENTS_HOME`, and harness-specific config homes contained in Docker or disposable directories. -#### Scenario: User scope plugin QA +#### Scenario: Global scope plugin QA -- **WHEN** testing `dotagents --user` or `dotagents --global` +- **WHEN** testing unqualified global commands, `dotagents --user`, or `dotagents --global` - **THEN** the agent proves the resolved paths are inside the sandbox and does not copy host authentication unless a separately authorized model-backed proof requires it ### Behavior: Run proportionate baseline validation @@ -45,8 +45,13 @@ The agent SHALL use CLI commands to cover initialization, add or declaration, in #### Scenario: Global plugin lifecycle -- **WHEN** validating user-scope plugin support -- **THEN** the agent tests both `--user` and `--global`, verifies global harness paths, deletes representative managed outputs, proves `sync` repairs them, and proves `remove` cleans them up +- **WHEN** validating global plugin support +- **THEN** the agent tests the unqualified default plus `--user` and `--global`, verifies global harness paths and project isolation, deletes representative managed outputs, proves `sync` repairs them, and proves `remove` cleans them up + +#### Scenario: Scope reversal release + +- **WHEN** validating the default-global major release +- **THEN** the agent proves unqualified commands remain global inside a configured repository, `--project` remains isolated, aliases and conflicts behave exactly, non-Git project init works, and legacy managed hooks are diagnosed and repaired ### Behavior: Test representative real plugins diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 5fd5536f..2b1ea832 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -1,13 +1,18 @@ --- name: dotagents -description: Manage dotagents dependencies and runtime config. Use when asked to "add a skill", "install skills", "remove a skill", "configure plugins", "configure subagents", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "user scope", "dotagents doctor", or any dotagents-related task. +description: Manage dotagents dependencies and runtime config. Use when asked to "add a skill", "install skills", "remove a skill", "configure plugins", "configure subagents", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "global scope", "project scope", "dotagents doctor", or any dotagents-related task. +spec_hash: 618e5a1625a1 --- -Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared project config. +Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared global or project config. ## Running dotagents -Always use `npx @sentry/dotagents` to run commands. For example: `npx @sentry/dotagents sync`. +Always use `npx @sentry/dotagents` to run commands. Unqualified commands are global by default. Add `--project` whenever the user asks to manage the current repository. Do not infer project intent merely because the current directory contains `agents.toml`. + +Apply this literally: “add this skill” with no repository-local wording means `npx @sentry/dotagents add ...`, even inside a repository that has `agents.toml`. “Add this skill to this repository/project” means `npx @sentry/dotagents --project add ...`. + +Global `add` bootstraps `~/.agents/agents.toml` when it is missing. Do not run a separate global `init` or `install` before or after `add` unless the user independently requested it. ## References @@ -22,7 +27,7 @@ Read the relevant reference when the task requires deeper detail: ## Quick Start ```bash -# Initialize a new project (interactive TUI) +# Initialize global state (interactive TUI) npx @sentry/dotagents init # Add a skill from GitHub @@ -44,11 +49,21 @@ npx @sentry/dotagents install npx @sentry/dotagents list ``` +For repository-local management, keep `--project` on every command: + +```bash +npx @sentry/dotagents --project init +npx @sentry/dotagents --project add getsentry/skills find-bugs +npx @sentry/dotagents --project install +npx @sentry/dotagents --project list +npx @sentry/dotagents --project doctor --fix +``` + ## Commands | Command | Description | |---------|-------------| -| `npx @sentry/dotagents init` | Initialize `agents.toml` and `.agents/` directory | +| `npx @sentry/dotagents init` | Initialize global config and managed directories | | `npx @sentry/dotagents install` | Install all dependencies from `agents.toml` | | `npx @sentry/dotagents add ` | Add a skill dependency | | `npx @sentry/dotagents remove ` | Remove a skill or plugin | @@ -56,12 +71,18 @@ npx @sentry/dotagents list | `npx @sentry/dotagents list` | Show declared skills, plugins, and status | | `npx @sentry/dotagents mcp` | Add, remove, or list MCP server declarations | | `npx @sentry/dotagents trust` | Add, remove, or list trusted sources | -| `npx @sentry/dotagents doctor` | Check project health and fix issues | +| `npx @sentry/dotagents doctor` | Check global health and fix issues | -All commands accept `--user` to operate on user scope (`~/.agents/`) instead of the current project. +All commands default to global scope (`~/.agents/`). `--project` selects the current repository (or current directory outside Git). `--global` is an explicit global spelling, and `--user` is its compatibility alias. Never combine `--project` with a global alias. For full options and flags, read [references/cli-reference.md](references/cli-reference.md). +## Safe Removal and Trust + +Use `remove` instead of manually deleting managed files or editing `agents.lock`. For a project dependency, keep explicit scope: `npx @sentry/dotagents --project remove `. When a wildcard provides the skill, let `remove` add the name to the wildcard's `exclude` list so the next install does not restore it. + +When trust blocks a source, inspect syntax without mutation using `npx @sentry/dotagents trust add --help`. Then show the exact scoped command, such as `npx @sentry/dotagents trust add git.corp.example.com`, and explicitly ask approval before running it. Never enable `allow_all` or add a trusted source without explicit user intent. + ## Source Formats | Format | Example | Description | @@ -72,18 +93,19 @@ For full options and flags, read [references/cli-reference.md](references/cli-re | GitHub HTTPS | `https://github.com/owner/repo` | Full HTTPS URL | | Git URL | `git:https://git.corp.dev/team/skills` | Any non-GitHub git remote | | Well-known HTTPS | `https://cli.sentry.dev` | HTTP source using `.well-known/skills/` | -| Local path | `path:./my-skills/custom` | Relative to project root | +| Local path | `path:./my-skills/custom` | Relative to the selected scope root | ## Key Concepts -- **`.agents/skills/`** is the canonical home for skills; **`.agents/plugins/`** is the canonical home for plugins +- **Managed paths** live under `~/.agents/` globally or `.agents/` in project scope - **`agents.toml`** declares dependencies; **`agents.lock`** tracks managed skills, subagents, and plugins -- **Symlinks**: `.claude/skills/`, `.cursor/skills/` point to `.agents/skills/` +- **Symlinks**: agent skill directories point to the selected scope's managed skills directory - **Wildcards**: `name = "*"` installs all skills from a source, with optional `exclude` list - **Trust**: Optional `[trust]` section restricts which sources are allowed - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config - **Subagents**: `[[subagents]]` declarations install portable or native subagent files - **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs -- **Gitignore**: Managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked -- **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects; plugins are project-scope only +- **Gitignore**: In project scope, managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked +- **Global scope**: the default; manages dependencies in `~/.agents/` shared across projects, including plugins +- **Project scope**: `--project` manages repository-local `agents.toml`, `agents.lock`, and `.agents/` - **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills, subagents, and plugins; there is no `update` command diff --git a/skills/dotagents/evals/cases/choose-management-scope.yaml b/skills/dotagents/evals/cases/choose-management-scope.yaml index 2f2ef3df..596210b2 100644 --- a/skills/dotagents/evals/cases/choose-management-scope.yaml +++ b/skills/dotagents/evals/cases/choose-management-scope.yaml @@ -2,4 +2,4 @@ behavior: choose-management-scope prompt: | Do not execute commands or modify files. This repository has agents.toml. Tell me the exact command you would use to install the find-bugs coding-agent skill from getsentry/skills. checks: - - judge: The response uses project-scoped dotagents add without `--user` because the repository has agents.toml. + - judge: The response uses an unqualified dotagents add command, because merely being in a repository with agents.toml does not express project-local intent. diff --git a/skills/dotagents/evals/cases/initialize-user-scope-management.yaml b/skills/dotagents/evals/cases/initialize-user-scope-management.yaml index f8ce4e8f..a6815d12 100644 --- a/skills/dotagents/evals/cases/initialize-user-scope-management.yaml +++ b/skills/dotagents/evals/cases/initialize-user-scope-management.yaml @@ -2,4 +2,4 @@ behavior: initialize-dotagents-management prompt: | Do not execute commands or modify files. I have never used dotagents user scope, and I want to add find-bugs from getsentry/skills for every project. Tell me the command sequence. checks: - - judge: The response uses one dotagents command with `--user add getsentry/skills find-bugs` and does not require a separate user-scope init or install command. + - judge: The response uses one unqualified dotagents add command for the default global scope and does not require a separate global init or install command. diff --git a/skills/dotagents/evals/cases/remove-skills-safely.yaml b/skills/dotagents/evals/cases/remove-skills-safely.yaml index e091e786..033a1072 100644 --- a/skills/dotagents/evals/cases/remove-skills-safely.yaml +++ b/skills/dotagents/evals/cases/remove-skills-safely.yaml @@ -2,4 +2,4 @@ behavior: remove-skills-safely prompt: | Do not execute commands or modify files. The code-review skill was installed through a wildcard dependency, but I do not want it in this project anymore. Tell me the safe removal workflow. checks: - - judge: The response uses dotagents remove and the wildcard exclusion flow so future installs do not restore the skill, and does not manually delete the installed directory or edit agents.lock. + - judge: The response uses `dotagents --project remove` and the wildcard exclusion flow so future installs do not restore the project skill, and does not manually delete the installed directory or edit agents.lock. diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index 0107c75b..0ea3f065 100644 --- a/skills/dotagents/references/cli-reference.md +++ b/skills/dotagents/references/cli-reference.md @@ -3,28 +3,42 @@ ## Usage ``` -npx @sentry/dotagents [--user] [options] +npx @sentry/dotagents [--project|--global|--user] [options] ``` ### Global Flags | Flag | Description | |------|-------------| -| `--user` | Operate on user scope (`~/.agents/`) instead of current project | +| no scope flag | Operate on global scope (`~/.agents/`); the default in every directory | +| `--project` | Operate on the containing Git repository, or current directory outside Git | +| `--global` | Explicitly operate on global scope | +| `--user` | Compatibility alias for `--global` | | `--help`, `-h` | Show help | | `--version`, `-V` | Show version | +Scope flags may appear before or after the command. `--global --user` is allowed; combining `--project` with either global alias is an error before execution. Project commands other than `init` require `agents.toml` and never fall back globally. No files are migrated between scopes. + +All unqualified examples below are intentionally global. For repository-local work, use: + +```bash +npx @sentry/dotagents --project init +npx @sentry/dotagents --project add getsentry/skills find-bugs +npx @sentry/dotagents --project install +npx @sentry/dotagents --project doctor --fix +``` + ## Commands ### `init` -Initialize a new project with `agents.toml` and `.agents/` directory. Automatically includes the `dotagents` skill from `getsentry/dotagents` for CLI guidance, and attempts to install it. +Initialize the selected scope. Automatically includes the `dotagents` skill from `getsentry/dotagents` for CLI guidance, and attempts to install it. ```bash npx @sentry/dotagents init npx @sentry/dotagents init --agents claude,cursor npx @sentry/dotagents init --force -npx @sentry/dotagents --user init +npx @sentry/dotagents --project init ``` | Flag | Description | @@ -36,6 +50,9 @@ npx @sentry/dotagents --user init 1. Select agents (multiselect) 2. Trust policy: allow all sources or restrict to trusted 3. If restricted: enter trusted GitHub orgs/repos (comma-separated) +4. In a Git project, optionally install a post-merge hook whose direct and npx fallback commands both run `--project install` + +Outside Git, `--project init` uses the current directory and skips Git-only hook setup. It also upgrades legacy marker-delimited project hooks while preserving unrelated content and executable permissions. ### `install` @@ -50,13 +67,13 @@ npx @sentry/dotagents install 2. Expand wildcard skill entries 3. Validate trust for each skill, subagent, and plugin source 4. Resolve skills, subagents, and plugins -5. Copy canonical artifacts into `.agents/skills/`, `.agents/agents/`, and `.agents/plugins/` +5. Copy canonical artifacts into the selected scope's managed skills, agents, and plugins directories 6. Write/update lockfile -7. Generate `.agents/.gitignore` +7. In project scope, generate `.agents/.gitignore` 8. Create/verify agent symlinks 9. Write MCP, hook, subagent, and plugin runtime configs -`dotagents --user install` rejects `[[plugins]]` because plugin runtime projections are project-scoped. +Global and project installs both support plugins, with outputs rooted in their selected scope. ### `add [skill...]` @@ -106,7 +123,7 @@ Remove a skill or plugin dependency. npx @sentry/dotagents remove find-bugs ``` -Removes from `agents.toml`, deletes managed installed files, updates the lockfile, prunes generated plugin outputs when needed, and regenerates `.agents/.gitignore`. Passing a source removes all matching skills and plugins from that source. +Removes from `agents.toml`, deletes files from the selected scope's managed directories, updates the lockfile, and prunes generated plugin outputs when needed. In project scope, it also regenerates `.agents/.gitignore`. Passing a source removes all matching skills and plugins from that source. If a skill and plugin share the same name, name-based removal is rejected. When their sources differ, pass the dependency's source to disambiguate. @@ -114,7 +131,7 @@ For skills sourced from a wildcard entry (`name = "*"`), interactively prompts w ### `sync` -Reconcile project state without network access: adopt local orphans, prune stale managed skills/subagents/plugins, and repair symlinks and generated configs. +Reconcile selected-scope state without network access: adopt local orphans, prune stale managed skills/subagents/plugins, and repair symlinks and generated configs. ```bash npx @sentry/dotagents sync @@ -122,7 +139,7 @@ npx @sentry/dotagents sync **Actions performed:** 1. Adopt orphaned skills (installed but not declared in config) -2. Regenerate `.agents/.gitignore` +2. In project scope, regenerate `.agents/.gitignore` 3. Prune stale managed skills, subagents, and plugins removed from config 4. Check for missing skills and plugins 5. Repair agent symlinks @@ -130,11 +147,11 @@ npx @sentry/dotagents sync 7. Verify/repair hook configs 8. Verify/repair subagent and plugin runtime configs -Reports issues as warnings or errors, including user-scope plugin declarations and same-project plugin declarations. +Reports issues as warnings or errors, including invalid same-project plugin declarations. ### `doctor` -Check project health and fix issues. +Check selected-scope health and fix issues. ```bash npx @sentry/dotagents doctor @@ -145,7 +162,7 @@ npx @sentry/dotagents doctor --fix |------|-------------| | `--fix` | Auto-fix issues where possible | -**Checks:** gitignore setup, legacy config fields, installed skills/plugins, symlinks, and `.agents/.gitignore`. Use `dotagents sync` to repair generated runtime configs. +**Checks:** applicable gitignore setup, legacy config fields, installed skills/plugins, symlinks, `.agents/.gitignore`, and legacy managed project hooks. Use the same scope on `sync` to repair generated runtime configs. Run `npx @sentry/dotagents --project doctor --fix` to migrate a legacy project hook. Useful when migrating to a new version of dotagents. diff --git a/skills/dotagents/references/config-schema.md b/skills/dotagents/references/config-schema.md index f1e2dcc4..72a2eb0a 100644 --- a/skills/dotagents/references/config-schema.md +++ b/skills/dotagents/references/config-schema.md @@ -188,7 +188,7 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ## Lockfile (agents.lock) -Auto-generated. Do not edit manually. Gitignored automatically. +Auto-generated. Do not edit manually. In project scope, it is gitignored automatically; global scope does not modify repository Git files. ```toml version = 1 @@ -228,4 +228,4 @@ Local path skills, subagents, and plugins have `source` only. | Variable | Purpose | |----------|---------| | `DOTAGENTS_STATE_DIR` | Override cache location (default: `~/.local/dotagents`) | -| `DOTAGENTS_HOME` | Override user-scope location (default: `~/.agents`) | +| `DOTAGENTS_HOME` | Override global-scope location (default: `~/.agents`) | diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index 60b007e7..2e902e89 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -36,7 +36,7 @@ path = "plugins/sentry-skills/skills/find-bugs" | GitLab HTTPS | `https://gitlab.com/group/repo` | URL used directly | | Git URL | `git:https://git.corp.dev/team/skills` | Any non-GitHub git remote | | Well-known HTTPS | `https://cli.sentry.dev` | HTTP source using `.well-known/skills/` | -| Local | `path:./my-skills/custom` | Relative to project root | +| Local | `path:./my-skills/custom` | Relative to the selected scope root | **Skill name rules:** Must start with alphanumeric, contain only `[a-zA-Z0-9._-]`. @@ -52,7 +52,7 @@ path = "skills/engineering" exclude = ["deprecated-skill"] ``` -During `install`, dotagents recursively discovers skills under `path` and installs each one except those in `exclude`. Use `path = "."` for the complete source root. The path cannot escape the source root, and well-known HTTPS sources do not support wildcard path scoping. Each skill gets its own lockfile entry. Use `npx @sentry/dotagents add --all` to create a wildcard entry from the CLI. +During `install`, dotagents recursively discovers skills under `path` and installs each one except those in `exclude`. Use `path = "."` for the complete source root. The path cannot escape the source root, and well-known HTTPS sources do not support wildcard path scoping. Each skill gets its own lockfile entry. Use `npx @sentry/dotagents add --all` globally or add `--project` for a repository-local wildcard. ## Trust @@ -132,7 +132,7 @@ Hook configs are written per-agent: ## Subagents -Declare portable or native subagent artifacts with `[[subagents]]`. dotagents installs canonical managed files under `.agents/agents/` and writes runtime-specific files for supported agents. +Declare portable or native subagent artifacts with `[[subagents]]`. dotagents installs canonical files in the selected scope's managed agents directory and writes runtime-specific files for supported agents. ```toml [[subagents]] @@ -144,7 +144,7 @@ targets = ["claude", "codex", "opencode"] ## Plugins -Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles under `.agents/plugins//` and generates runtime-specific plugin outputs for configured targets. +Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles in the selected scope's managed plugins directory and generates runtime-specific plugin outputs for configured targets. ```toml [[plugins]] @@ -154,7 +154,7 @@ path = "plugins/review-tools" targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` -Plugin declarations are project-scope only. User-scope plugin declarations are rejected. +Plugin declarations work in global and project scope. Canonical bundles and runtime projections use the selected scope's paths. ## Agents @@ -165,30 +165,36 @@ agents = ["claude", "cursor", "codex", "vscode", "grok", "opencode", "pi"] ``` Each agent gets: -- A `/skills/` symlink pointing to `.agents/skills/` (Claude, Cursor) -- Or native discovery from `.agents/skills/` (Codex, VS Code, OpenCode) +- A `/skills/` symlink pointing to the selected scope's managed skills directory (Claude, Cursor) +- Or native discovery from the selected scope's managed skills directory (Codex, VS Code, OpenCode) - MCP server configs in the agent's config file - Hook configs (where supported) - Subagent and plugin runtime outputs (where supported) ## Scopes -### Project Scope (default) +### Global Scope (default) -Operates on the current project. Requires `agents.toml` at the project root. +Operates on `DOTAGENTS_HOME` or `~/.agents/` in every directory. Unqualified commands select this scope. `--global` is the explicit spelling and `--user` is a compatibility alias. -### User Scope (`--user`) +```bash +npx @sentry/dotagents add getsentry/skills --all +npx @sentry/dotagents install +``` + +Global symlinks include `~/.claude/skills/` for Claude and Cursor. -Operates on `~/.agents/` for skills shared across all projects. Override with `DOTAGENTS_HOME`. +### Project Scope (`--project`) + +Operates on the containing Git repository root, or current directory outside Git. `--project init` may create `agents.toml`; other commands require it and never fall back globally. ```bash -npx @sentry/dotagents --user init -npx @sentry/dotagents --user add getsentry/skills --all +npx @sentry/dotagents --project init +npx @sentry/dotagents --project add getsentry/skills --all +npx @sentry/dotagents --project install ``` -User-scope symlinks go to `~/.claude/skills/` and `~/.cursor/skills/`. - -When no `agents.toml` exists and you're not inside a git repo, dotagents falls back to user scope automatically. +Do not combine `--project` with `--global` or `--user`. dotagents never copies, merges, or removes config when switching scopes. ## Minimum Release Age @@ -201,15 +207,15 @@ minimum_release_age_exclude = ["getsentry/*"] Use `minimum_release_age_exclude` for trusted sources that can bypass the age gate. -## Gitignore +## Project Gitignore -dotagents always manages gitignore. It generates `.agents/.gitignore` listing managed skills, subagents, and plugins. In-place skills (`path:.agents/skills/...`) and in-place plugin sources are not gitignored since they must be tracked in git. +In project scope, dotagents generates `.agents/.gitignore` listing managed skills, subagents, and plugins. In-place skills (`path:.agents/skills/...`) and in-place plugin sources are not gitignored since they must be tracked in git. Global scope does not modify repository gitignore files. Two files are added to the root `.gitignore` during `init`: - `agents.lock` — tracks managed skills, subagents, and plugins - `.agents/.gitignore` — excludes managed skill directories, subagent files, and plugin bundles -If these entries are missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. +If these entries are missing, project `install` and `sync` warn. Run `npx @sentry/dotagents --project doctor --fix` to add them. ## Caching @@ -220,13 +226,13 @@ If these entries are missing, `install` and `sync` warn. Run `npx @sentry/dotage ## Troubleshooting **Skills not installing:** -- Check `agents.toml` syntax with `npx @sentry/dotagents list` +- Check project `agents.toml` syntax with `npx @sentry/dotagents --project list` - Verify source is accessible (`git clone` the URL manually) - Check trust config if using restricted mode -- Run `npx @sentry/dotagents doctor` to check project health +- Run `npx @sentry/dotagents --project doctor` to check project health **Symlinks broken:** -- Run `npx @sentry/dotagents sync` to repair +- Run `npx @sentry/dotagents --project sync` to repair project state **Configuration issues:** -- Run `npx @sentry/dotagents doctor --fix` to auto-repair gitignore and legacy config fields +- Run `npx @sentry/dotagents --project doctor --fix` to auto-repair project gitignore, legacy config fields, and managed hooks diff --git a/skills/dotagents/spec.md b/skills/dotagents/spec.md index 307ef20d..64ae2535 100644 --- a/skills/dotagents/spec.md +++ b/skills/dotagents/spec.md @@ -4,13 +4,13 @@ This skill makes an agent use dotagents as the dependency manager for coding-agent skills and related shared configuration. The agent should add, install, update, remove, and inspect skills through `agents.toml` and the dotagents CLI instead of copying skill directories into individual agent runtimes. -The skill teaches the difference between project and user scope, the lifecycle of `add`, `install`, and `sync`, and the safe handling of trust, wildcard dependencies, and advanced configuration. +The skill teaches the difference between default-global and explicit-project scope, the lifecycle of `add`, `install`, and `sync`, and the safe handling of trust, wildcard dependencies, and advanced configuration. ## Triggers - **SHOULD** apply when the user asks to add, install, update, remove, list, share, or manage coding-agent skills with dotagents or `agents.toml`. - **SHOULD** apply when the repository contains `agents.toml` and the task concerns skills, MCP declarations, hooks, subagents, trust, or generated agent configuration. -- **SHOULD** apply when the user asks to set up project-wide or personal user-scope skill management. +- **SHOULD** apply when the user asks to set up project-wide or personal/global skill management. - **SHOULD NOT** apply to normal application package dependencies such as npm, Python, Rust, or system packages. - **SHOULD NOT** apply when the user explicitly asks to install a standalone Codex skill through another skill installer rather than manage it with dotagents. @@ -18,34 +18,40 @@ The skill teaches the difference between project and user scope, the lifecycle o ### Behavior: Choose management scope -The agent SHALL use project scope for skill management in a repository with `agents.toml`, and use user scope only when the user requests personal, global, or cross-project management. +The agent SHALL use unqualified default-global commands unless the user requests repository-local or project-wide management. The presence of `agents.toml` alone SHALL NOT change scope intent. For repository-local work, the agent SHALL include `--project` on every scope-aware command. #### Scenario: Repository skill installation - **GIVEN** the current repository contains `agents.toml` -- **WHEN** the user asks to install or add a coding-agent skill -- **THEN** the agent uses project scope without `--user` +- **WHEN** the user asks to install or add a coding-agent skill for that repository +- **THEN** the agent uses project scope with `--project` + +#### Scenario: Repository present without project wording + +- **GIVEN** the current repository contains `agents.toml` +- **WHEN** the user asks to install a skill without expressing repository-local intent +- **THEN** the agent uses the unqualified global command #### Scenario: Personal or global wording - **WHEN** the user asks to install a skill globally, personally, or for every project -- **THEN** the agent maps that request to dotagents user scope +- **THEN** the agent maps that request to unqualified dotagents global scope ### Behavior: Initialize dotagents management -The agent SHALL initialize a project before adding dependencies when its repository root has no `agents.toml`, while allowing user-scope commands to bootstrap their configuration automatically. +The agent SHALL initialize project scope before adding repository-local dependencies when its project root has no `agents.toml`, while allowing supported global commands to bootstrap their configuration automatically. #### Scenario: New project setup - **GIVEN** the current project has no `agents.toml` - **WHEN** the user asks to start managing project skills with dotagents -- **THEN** the agent runs `npx --yes @sentry/dotagents@latest init` or presents that command when interactive choices require the user +- **THEN** the agent runs `npx --yes @sentry/dotagents@latest --project init` or presents that command when interactive choices require the user -#### Scenario: First user-scope command +#### Scenario: First global command - **GIVEN** `~/.agents/agents.toml` does not exist -- **WHEN** the user asks to add a personal skill -- **THEN** the agent runs the requested command with `--user` without a separate `init` +- **WHEN** the user asks to add a personal or global skill +- **THEN** the agent runs the unqualified requested command without a separate `init` ### Behavior: Add skills through dotagents @@ -86,6 +92,8 @@ The agent SHALL edit `agents.toml` directly only for dependency options not repr The agent SHALL choose `add`, `install`, or `sync` according to the requested lifecycle operation. +The agent SHALL preserve the user's selected global or project scope across each lifecycle command and remediation command. + #### Scenario: Refresh dependency contents - **WHEN** the user asks to update or refresh installed skills diff --git a/specs/SPEC.md b/specs/SPEC.md index 9263ee08..a85408a9 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -4,7 +4,7 @@ dotagents is shared tooling for coding agents. It manages agent skill dependencies using the [agentskills.io](https://agentskills.io) standard, and handles MCP servers, hooks, subagents, plugins, and symlinks so that multiple agent tools (Claude Code, Cursor, Codex, etc.) can be configured from a single `agents.toml`. -Declare what you need, run `dotagents install`, and skills appear in `.agents/skills/` with symlinks into each tool's expected directory. Plugins install into `.agents/plugins/`. MCP, hook, subagent, and plugin configs are generated per agent. +Declare what you need, run `dotagents install` for global state or `dotagents --project install` for repository-local state, and skills appear in the selected scope with integrations for each configured tool. MCP, hook, subagent, and plugin configs are generated per agent. > **Implementation note.** The skill-loading, source-fetching, and trust-validation primitives that drive the CLI are factored into a separate npm package, [`@sentry/dotagents-lib`](../packages/dotagents-lib/), versioned in lock-step with `@sentry/dotagents`. The `agents.toml` grammar and the `.agents/` convention described below remain entirely the host's responsibility — the lib only knows about source strings, SKILL.md, and the cache. @@ -25,7 +25,7 @@ Agent skills, MCP servers, hooks, and subagents are configured differently for e ## agents.toml -The manifest file. Lives at the project root. +The manifest file. Lives at the selected scope root: `~/.agents/agents.toml` by default, or `agents.toml` at the project root with `--project`. ### Schema @@ -141,7 +141,7 @@ allow_all = true - `[trust]` present without `allow_all` → only matching sources allowed (allow-list) - A source passes if it matches ANY rule (org OR repo OR domain/path prefix) - `git_domains` entries match by prefix: `gitlab.com` matches all repos on GitLab, `gitlab.com/myorg` matches repos under that org, `gitlab.com/myorg/repo` matches only that repo -- Local `path:` sources are always allowed (already sandboxed to project root) +- Local `path:` sources are always allowed (already sandboxed to the selected scope root) Trust is checked before any network work in `dotagents add` for dependencies and `dotagents install` for configured skills, subagents, and plugins. @@ -232,7 +232,7 @@ Installed and generated files are marked as dotagents-managed with a generated h Generated paths: -| Agent | Project Scope | User Scope | Format | +| Agent | Project Scope | Global Scope | Format | |-------|---------------|------------|--------| | Claude Code | `.claude/agents/.md` | `~/.claude/agents/.md` | Markdown with YAML frontmatter | | Cursor | `.cursor/agents/.md` | `~/.cursor/agents/.md` | Markdown with YAML frontmatter | @@ -269,7 +269,7 @@ compatibility implementation (see the remaining gaps in `specs/plugins.md`): Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated marketplaces and Claude/Cursor/Codex manifests use adjacent `.dotagents-managed` sidecars; OpenCode/Pi component symlinks use marker files in reserved sibling `.dotagents-managed/` directories. This keeps ownership explicit without changing client-owned JSON or consuming a valid component name. Legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. -User scope installs canonical plugins into `~/.agents/plugins//`. It generates Claude and Cursor marketplaces below `~/.agents/`, a Codex marketplace at `~/.agents/plugins/marketplace.json` whose local paths are rooted at the user's home, OpenCode skill and legacy-agent projections below `~/.config/opencode/`, portable plugin MCP entries in `~/.config/opencode/opencode.json`, and Pi skill projections below `~/.agents/skills/`. +Global scope installs canonical plugins into `~/.agents/plugins//`. It generates Claude and Cursor marketplaces below `~/.agents/`, a Codex marketplace at `~/.agents/plugins/marketplace.json` whose local paths are rooted at the user's home, OpenCode skill and legacy-agent projections below `~/.config/opencode/`, portable plugin MCP entries in `~/.config/opencode/opencode.json`, and Pi skill projections below `~/.agents/skills/`. #### Supported Agents @@ -369,7 +369,7 @@ ref = "main" #### `path:` -- local filesystem -Relative to the project root. Copied (not symlinked) into `.agents/skills/` during install. +Relative to the selected scope root. Copied (not symlinked) into that scope's `skills/` directory during install. ```toml [[skills]] @@ -383,9 +383,9 @@ Local path skills are re-copied on each install. ## agents.lock -The lockfile. Lives at the project root alongside `agents.toml`. TOML format. +The lockfile. Lives at the selected scope root alongside `agents.toml`: `~/.agents/agents.lock` by default, or `agents.lock` at the project root with `--project`. TOML format. -**This file is auto-generated.** Do not edit manually. Gitignored automatically (`dotagents init` adds it to `.gitignore`). +**This file is auto-generated.** Do not edit manually. In project scope it is gitignored automatically (`dotagents --project init` adds it to `.gitignore`). ### Format @@ -471,9 +471,31 @@ Plugin lock entries use the same source-resolution fields under `[plugins. The CLI binary is `dotagents`. During development, run it with `pnpm dev -- ` or `tsx`. Published packages are built with `tsc` and run on Node.js 20+. +### Scope selection + +The scope-aware commands `init`, `install`, `add`, `remove`, `sync`, `list`, `mcp`, `trust`, and `doctor` use global scope by default. Global state is rooted at `DOTAGENTS_HOME` when set and otherwise at `~/.agents/`; this selection does not depend on the current directory, Git repository, or a nearby `agents.toml`. + +Use `--project` to select repository-local state. Inside Git, the project root is the containing repository root. Outside Git, the current directory is the project root. `--project init` may create a new `agents.toml`; other project commands require one and fail without changing either scope when it is missing. There is no automatic copying, merging, or deletion between scopes. + +`--global` explicitly selects global scope. `--user` remains a compatibility alias for `--global`. Both global aliases may be supplied together. Combining `--project` with either global alias is an error before command execution or scope bootstrap. Scope flags are accepted before or after the command. + +| Scope | Config | Lockfile | Managed dependencies | +| --- | --- | --- | --- | +| Global (default) | `~/.agents/agents.toml` | `~/.agents/agents.lock` | `~/.agents/skills/`, `~/.agents/agents/`, `~/.agents/plugins/` | +| Project (`--project`) | `/agents.toml` | `/agents.lock` | `/.agents/skills/`, `/.agents/agents/`, `/.agents/plugins/` | + +Examples below are global unless they include `--project`. A complete project lifecycle is: + +```bash +dotagents --project init +dotagents --project add getsentry/skills find-bugs +dotagents --project install +dotagents --project doctor --fix +``` + ### `dotagents init` -Initialize a new project. +Initialize global state by default, or a repository-local configuration with `--project`. ``` dotagents init [--force] [--agents claude,cursor] @@ -481,14 +503,16 @@ dotagents init [--force] [--agents claude,cursor] **Behavior:** 1. Create `agents.toml` with `version = 1` and a bootstrap `dotagents` skill from `getsentry/dotagents` -2. Create `.agents/skills/` directory -3. Generate `.agents/.gitignore` -4. Add `agents.lock` and `.agents/.gitignore` to the root `.gitignore` +2. Create the selected scope's managed skills directory (`~/.agents/skills/` globally or `.agents/skills/` in project scope) +3. In project scope, generate `.agents/.gitignore` +4. In project scope, add `agents.lock` and `.agents/.gitignore` to the root `.gitignore` 5. If symlink targets or agents are configured, set up symlinks 6. Attempt to install the bootstrap skill (best-effort — warns on failure) -7. (Interactive, project scope) Offer to install a git `post-merge` hook that runs `dotagents install` on pull (defaults to no). The hook tries `dotagents` first, falling back to `npx --yes @sentry/dotagents`. +7. (Interactive, project scope inside Git) Offer to install a git `post-merge` hook that runs `dotagents --project install` on pull (defaults to no). The hook tries `dotagents` first, falling back to `npx --yes @sentry/dotagents`; both commands include `--project`. 8. Print next steps +Outside Git, `dotagents --project init` initializes the current directory and skips Git-only post-merge hook setup. Project init upgrades an existing marker-delimited legacy dotagents post-merge block to the explicit-project command while preserving unrelated hook content and file permissions. + **Flags:** - `--force`: Overwrite existing `agents.toml` - `--agents `: Comma-separated list of agent IDs to include in config (e.g. `claude,cursor`) @@ -506,13 +530,13 @@ dotagents install 2. For each skill: a. Resolve source (check cache with TTL-based refresh, clone/fetch if needed) b. Discover skill within the repo - c. Copy skill directory into `.agents/skills//` + c. Copy the skill directory into the selected scope's managed skills directory 3. Resolve configured subagents -4. Resolve and install configured plugins into `.agents/plugins//` for project scope or `~/.agents/plugins//` for user scope +4. Resolve and install configured plugins into `.agents/plugins//` for project scope or `~/.agents/plugins//` for global scope 5. Write `agents.lock` with the current configured skills, subagents, and plugins -6. Install configured subagents into `.agents/agents/` -7. Regenerate `.agents/.gitignore` -8. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` +6. Install configured subagents into the selected scope's managed agents directory +7. In project scope, regenerate `.agents/.gitignore` +8. In project scope, warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 9. Create/verify symlinks (legacy `[symlinks]` and agent-specific) 10. Write MCP config files for each declared agent 11. Write hook config files for each declared agent that supports hooks @@ -562,8 +586,8 @@ dotagents add myorg/single-skill-repo # auto-detects if repo has one skill 8. Run install exactly once and update `agents.lock` - If config mutation or installation fails, restore the pre-add `agents.toml` content so a failed add does not leave a declaration behind -User scope uses the same discovery and lifecycle as project scope, with canonical -bundles and runtime projections rooted in the user paths described above. Mixed +Global scope uses the same discovery and lifecycle as project scope, with canonical +bundles and runtime projections rooted in the global paths described above. Mixed plugin-and-skill selection from one source is intentionally unsupported; plugin presence wins for the whole source. @@ -587,10 +611,10 @@ dotagents remove [-y] If an explicit skill and plugin share the requested name, fail without changing either dependency. When their sources differ, source-based removal can disambiguate them. 1. Remove matching `[[skills]]` or `[[plugins]]` entry from `agents.toml` -2. Delete the managed installed artifact (`.agents/skills//` for skills or `.agents/plugins//` for managed plugins) +2. Delete the artifact from the selected scope's managed skills or plugins directory 3. Remove entry from the relevant `agents.lock` section 4. Prune generated plugin runtime outputs when removing a plugin -5. Regenerate `.agents/.gitignore` +5. In project scope, regenerate `.agents/.gitignore` **Behavior (source removal):** When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather than a dependency name, removes all skills and plugins from that source: @@ -599,7 +623,7 @@ When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather t 3. Remove all matching `[[skills]]` and `[[plugins]]` entries from `agents.toml` 4. Delete managed installed artifacts and lockfile entries 5. Prune generated plugin runtime outputs for removed managed plugins -6. Regenerate `.agents/.gitignore` +6. In project scope, regenerate `.agents/.gitignore` **Flags:** - `-y`, `--yes`: Skip confirmation prompt @@ -615,8 +639,8 @@ dotagents sync **Behavior:** 1. Adopt orphaned local skills (installed but not in `agents.toml`, and not previously managed) into config 2. Prune stale managed skills that were removed from config but still exist on disk locally -3. Regenerate `.agents/.gitignore` -4. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` +3. In project scope, regenerate `.agents/.gitignore` +4. In project scope, warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 5. Check for missing skills (in `agents.toml` but not installed) 6. Create/verify/repair symlinks 7. Verify and repair MCP config files for declared agents @@ -664,7 +688,7 @@ When `defaultRepositorySource = "gitlab"`, shorthand sources (without dots) are ### `dotagents doctor` -Check project health and fix issues. +Check the selected scope and fix supported issues. ``` dotagents doctor [--fix] @@ -674,16 +698,18 @@ dotagents doctor [--fix] 1. `agents.toml` exists 2. No legacy fields (`pin`, `gitignore`) in `agents.toml` 3. No legacy fields (`commit`, `integrity`) in `agents.lock` -4. Root `.gitignore` has required entries (`agents.lock`, `.agents/.gitignore`) -5. `.agents/.gitignore` exists -6. `.agents/skills/` directory exists -7. All declared skills are installed -8. All declared plugins are installed -9. Generated plugin runtime artifacts are intact -10. Symlinks are intact +4. In project scope, the root `.gitignore` has required entries (`agents.lock`, `.agents/.gitignore`) +5. In project scope, generated files are not tracked by Git +6. In project scope, the managed post-merge hook does not contain legacy bare install commands +7. In project scope, `.agents/.gitignore` exists +8. The selected scope's managed skills directory exists +9. All declared skills are installed +10. All declared plugins are installed +11. Generated plugin runtime artifacts are intact +12. In project scope, symlinks are intact **Flags:** -- `--fix`: Auto-fix issues where possible (add gitignore entries, remove legacy fields, create missing `.agents/.gitignore`) +- `--fix`: Auto-fix issues where possible (add gitignore entries, remove legacy fields, create missing `.agents/.gitignore`, and repair legacy managed project hooks) ### `dotagents list` @@ -711,7 +737,7 @@ How dotagents resolves a specifier to a concrete skill directory. ``` Source string | - ├─ starts with "path:" -> Resolve relative to project root + ├─ starts with "path:" -> Resolve relative to selected scope root ├─ starts with "git:" -> Parse URL, clone, discover skill by name └─ otherwise -> Parse as owner/repo[@ref], clone from GitHub, discover skill by name | @@ -753,7 +779,7 @@ The YAML frontmatter is parsed with the `yaml` package. `allowed-tools` can be a ## Gitignore Strategy -dotagents always manages gitignore. Two files are added to the root `.gitignore` during `init`: +In project scope, dotagents manages Git ignore state. Global commands do not modify repository Git files. Two files are added to the root `.gitignore` during `dotagents --project init`: - `agents.lock` — tracks managed skills, subagents, and plugins - `.agents/.gitignore` — excludes managed skill directories, canonical installed subagent files, and managed plugin bundles from git @@ -776,19 +802,19 @@ Custom skills in `.agents/skills/my-local-skill/` and canonical local plugins in ### Regeneration `.agents/.gitignore` is regenerated on every: -- `dotagents install` -- `dotagents add` -- `dotagents remove` -- `dotagents sync` +- `dotagents --project install` +- `dotagents --project add` +- `dotagents --project remove` +- `dotagents --project sync` ### Health Checks -`install` and `sync` warn if gitignore entries are missing but do not modify the root `.gitignore`. Run `dotagents doctor --fix` to add them. +Project `install` and `sync` warn if gitignore entries are missing but do not modify the root `.gitignore`. Run `dotagents --project doctor --fix` to add them. ### Edge Cases -- **Custom skill name collides with managed skill**: `dotagents add` refuses to install if `.agents/skills//` already exists and is tracked by git -- **Someone commits a managed skill**: `dotagents sync` detects this and warns +- **Custom skill name collides with managed skill**: `dotagents --project add` refuses to install if `.agents/skills//` already exists and is tracked by git +- **Someone commits a managed skill**: `dotagents --project sync` detects this and warns --- From e028f453786174dd67deb0bcef4e3a205111d1e4 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:04:53 +0200 Subject: [PATCH 2/3] docs(changelog): reference default-global PR --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d91124..5adf8441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes ⚠️ +- (cli) Default commands to global scope by @gricha in [#156](https://github.com/getsentry/dotagents/pull/156) - **Scope-aware commands now target global state by default.** Unqualified `init`, `install`, `add`, `remove`, `sync`, `list`, `mcp`, `trust`, and `doctor` operate under `~/.agents/` (or `DOTAGENTS_HOME`) even when run inside a configured repository. Add `--project` to every repository-local invocation. - Existing project and global configuration, lockfiles, and managed directories are not copied, merged, renamed, or deleted automatically. The command's scope flag alone selects which state is used. - `--global` remains an optional explicit global spelling and `--user` remains a compatibility alias. Combining `--project` with either global alias is an error. From 74648b8cd2d248e6394aa2490d336fbd9d2306d8 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:57:17 +0200 Subject: [PATCH 3/3] fix(cli): make hook migration best effort --- .../dotagents/src/cli/commands/init.test.ts | 16 +++++++++- packages/dotagents/src/cli/commands/init.ts | 30 +++++++++++++++---- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/dotagents/src/cli/commands/init.test.ts b/packages/dotagents/src/cli/commands/init.test.ts index 9818259d..d5817fe9 100644 --- a/packages/dotagents/src/cli/commands/init.test.ts +++ b/packages/dotagents/src/cli/commands/init.test.ts @@ -337,8 +337,22 @@ describe("init hook migration", () => { expect(hook).toContain("dotagents --project install"); expect(hook).toMatch(/^#!\/bin\/sh\necho before\n/); expect(hook).toMatch(/# dotagents:end\necho after\n$/); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBeUndefined(); + expect(error).not.toHaveBeenCalled(); error.mockRestore(); log.mockRestore(); }); + + it("continues initialization when legacy hook repair fails", async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-init-migration-error-")); + await mkdir(join(dir, ".git", "hooks", "post-merge"), { recursive: true }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await init(["--agents", "claude"], { scope: resolveScope("project", dir) }); + + expect(existsSync(join(dir, "agents.toml"))).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("--project doctor --fix")); + expect(process.exitCode).toBeUndefined(); + warn.mockRestore(); + }); }); diff --git a/packages/dotagents/src/cli/commands/init.ts b/packages/dotagents/src/cli/commands/init.ts index 9d9afaab..6a7ca53b 100644 --- a/packages/dotagents/src/cli/commands/init.ts +++ b/packages/dotagents/src/cli/commands/init.ts @@ -264,16 +264,36 @@ export default async function init(args: string[], context: CommandContext): Pro const { scope } = context; try { + let hookUpdated = false; if (scope.scope === "project") { const gitDir = findGitDir(scope.root); - if (gitDir && await updateManagedPostMergeHook(gitDir)) { - if (process.stdout.isTTY) { - clack.log.success("Updated managed post-merge hook for project scope."); - } else { - console.log(chalk.green("Updated managed post-merge hook for project scope.")); + if (gitDir) { + try { + hookUpdated = await updateManagedPostMergeHook(gitDir); + } catch { + const message = `Could not update the managed post-merge hook. Run \`${commandPrefix(scope)} doctor --fix\` later.`; + if (process.stdout.isTTY) { + clack.log.warn(message); + } else { + console.warn(chalk.yellow(message)); + } } } } + if (hookUpdated) { + if (process.stdout.isTTY) { + clack.log.success("Updated managed post-merge hook for project scope."); + } else { + console.log(chalk.green("Updated managed post-merge hook for project scope.")); + } + if ( + existsSync(scope.configPath) && + values["force"] !== true && + values["agents"] === undefined + ) { + return; + } + } // Interactive mode: TTY with no --agents flag if (process.stdout.isTTY && values["agents"] === undefined) {