diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6df1ff7f..0190e5fb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- IBM Bob is now a stable target for Agent Skills, lifecycle hooks, MCP servers, and `AGENTS.md` compilation, with documented project and user-scope paths. (#2909) - gh-aw's shared APM import now supports `token-source: github-token`; after consumers re-vendor the workflow, its read-only current-repository identity can fetch same-repository private packages, while `cascade` remains the default and cross-repository packages still require a dedicated token or GitHub App. (#2706) - OpenAPM v0.1 adds `req-pl-018` for dependency-policy identity casing and amends `req-rs-016` clause (3), the Section 6.4 merge rules, and the Section 6.5 pattern grammar so repository identity and policy matching cannot diverge; Section 11.2 item 6 now requires the per-host case rule in `CONFORMANCE.md`. (#2706) diff --git a/README.md b/README.md index 808fddc5e3..a79d2ca3df 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent configuration. -GitHub Copilot | Claude Code | Grok Build | Cursor | OpenCode | Codex | Gemini | Windsurf | Kiro +GitHub Copilot | Claude Code | Grok Build | Cursor | OpenCode | Codex | Gemini | Windsurf | Kiro | IBM Bob **[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** · **[v0.29 Roadmap](https://github.com/microsoft/apm/milestone/17)** @@ -67,7 +67,7 @@ One command, no configuration -- VS Code and GitHub Copilot read the file automa One `apm.yml` describes every primitive your agents need — instructions, skills, prompts, agents, hooks, plugins, MCP servers — and `apm install` reproduces the exact same setup across every client on every machine. `apm.lock.yaml` pins the resolved tree the way `package-lock.json` does for npm. -- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** -- declared once, deployed across Copilot, Claude, Grok Build, Cursor, OpenCode, Codex, Gemini, Windsurf, Kiro +- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** -- declared once, deployed across Copilot, Claude, Grok Build, Cursor, OpenCode, Codex, Gemini, Windsurf, Kiro, and IBM Bob - **[Install from anywhere](https://microsoft.github.io/apm/guides/dependencies/)** — GitHub, GitLab, Bitbucket, Azure DevOps, GitHub Enterprise, Gitea, Gogs, any git host - **[Transitive dependencies](https://microsoft.github.io/apm/guides/dependencies/)** — packages can depend on packages; APM resolves the full tree - **[Author plugins](https://microsoft.github.io/apm/guides/plugins/)** — build Copilot, Claude, and Cursor plugins with dependency management, then export standard `plugin.json` @@ -161,7 +161,7 @@ apm marketplace add github/awesome-copilot apm install azure-cloud-development@awesome-copilot ``` -Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, OpenCode, Gemini, Windsurf, and Kiro): +Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, OpenCode, Gemini, Windsurf, Kiro, and IBM Bob): ```bash apm install --mcp io.github.github/github-mcp-server --transport http # connects over HTTPS diff --git a/docs/src/content/docs/concepts/primitives-and-targets.md b/docs/src/content/docs/concepts/primitives-and-targets.md index 80b6db7906..34c4f97343 100644 --- a/docs/src/content/docs/concepts/primitives-and-targets.md +++ b/docs/src/content/docs/concepts/primitives-and-targets.md @@ -5,7 +5,7 @@ sidebar: order: 3 --- -A **primitive** is a unit of agent context APM can manage: instructions, prompts, agents, skills, hooks, commands, plugins, and MCP servers. A **target** is a harness APM compiles primitives for: Copilot, Claude, Grok Build, Cursor, Codex, Gemini, OpenCode, Windsurf, and Kiro, with Antigravity and Hermes available as explicit-only targets. The matrix below is the full reach map. For any primitive X and harness Y, it tells you whether Y receives X natively, receives it after APM transforms it, or does not receive it at all. +A **primitive** is a unit of agent context APM can manage: instructions, prompts, agents, skills, hooks, commands, plugins, and MCP servers. A **target** is a harness APM compiles primitives for: Copilot, Claude, Grok Build, Cursor, Codex, Gemini, OpenCode, Windsurf, Kiro, and IBM Bob, with Antigravity and Hermes available as explicit-only targets. The matrix below is the full reach map. For any primitive X and harness Y, it tells you whether Y receives X natively, receives it after APM transforms it, or does not receive it at all. This page is the canonical reference. Tutorials and how-tos link here; do not duplicate. @@ -78,7 +78,7 @@ GitHub Copilot CLI canvas extensions: a directory bundle whose entry file is `ex ## Target catalogue -Each target is identified by a slug used in `apm.yml`'s `targets:` field or on the `--target` flag. `apm.yml` accepts the canonical targets (`copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `agent-skills`, `hermes`). `agent-skills`, `antigravity`, and `hermes` can be selected explicitly or listed in `apm.yml`, but they are not auto-detected or included in `all`. The output directory is where APM writes deployed primitives. +Each target is identified by a slug used in `apm.yml`'s `targets:` field or on the `--target` flag. `apm.yml` accepts the canonical targets (`copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `bob`, `agent-skills`, `hermes`). `agent-skills`, `antigravity`, and `hermes` can be selected explicitly or listed in `apm.yml`, but they are not auto-detected or included in `all`. The output directory is where APM writes deployed primitives. | Slug | Output directory | Compile family | |---|---|---| @@ -92,6 +92,7 @@ Each target is identified by a slug used in `apm.yml`'s `targets:` field or on t | `opencode` | `.opencode/` (project), `~/.config/opencode/` (user) | agents | | `windsurf` | `.windsurf/` (project), `~/.codeium/windsurf/` (user) | agents | | `kiro` | `.kiro/` (project and user) | agents | +| `bob` | `.bob/` (project and user) | agents | | `hermes` | `.agents/` (project), `~/.hermes/` (user) | agents | Notes per target: @@ -106,6 +107,7 @@ Notes per target: - **opencode** -- OpenCode. No hooks support. - **windsurf** -- Windsurf / Cascade. No native agents primitive -- Cascade auto-invokes any `SKILL.md` by its `description:` frontmatter, so personas ship as skills. Workflows are the harness's name for commands. - **kiro** -- Kiro IDE/CLI v3. Instructions become steering files, skills stay as `SKILL.md` folders, hooks are individual JSON files, MCP lands in `.kiro/settings/mcp.json`, and agents deploy to `.kiro/agents/.md` with frontmatter filtered to `description`, `model`, and `tools` only. +- **bob** -- IBM Bob. Skills deploy as `SKILL.md` folders; hooks merge into `.bob/settings.json` or `~/.bob/settings/settings.json`; MCP uses `.bob/mcp.json` or `~/.bob/mcp.json`. Compiled instructions use the project-root `AGENTS.md` that Bob reads automatically. - **hermes** -- Hermes Agent. Stable explicit-only target; skills use `.agents/skills/` at project scope and `~/.hermes/skills/` at user scope. Compiled instructions use `AGENTS.md`; MCP servers use `~/.hermes/config.yaml`. ## The compatibility matrix @@ -117,17 +119,17 @@ Rows are primitives, columns are harnesses. Cell legend: - **unsupported** -- APM does not deliver this primitive to this harness. - **gated** -- delivered behind an explicit declaration or trust flag. -| Primitive | Copilot | Claude | Grok Build | Cursor | Codex | Gemini | Antigravity | OpenCode | Windsurf | Kiro | Hermes | -|---|---|---|---|---|---|---|---|---|---|---|---| -| instructions | native | native | native | native | compiled | compiled | native | compiled | native | native | compiled | -| prompts | native | compiled | compiled | compiled | unsupported | compiled | compiled | compiled | compiled | unsupported | unsupported | -| agents | native | native | native | compiled | compiled | unsupported | unsupported | native | unsupported | compiled | unsupported | -| skills | native | native | native | native | native | native | native | native | native | native | native | -| hooks | native | native | unsupported | native | native | native | native | unsupported | native | native | unsupported | -| commands | unsupported | native | compiled | compiled | unsupported | compiled | unsupported | compiled | compiled | unsupported | unsupported | -| plugins | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | -| MCP servers | native | native | unsupported | native | native | native | native | native | native | native | native | -| canvas (experimental) | gated | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | +| Primitive | Copilot | Claude | Grok Build | Cursor | Codex | Gemini | Antigravity | OpenCode | Windsurf | Kiro | Bob | Hermes | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| instructions | native | native | native | native | compiled | compiled | native | compiled | native | native | compiled | compiled | +| prompts | native | compiled | compiled | compiled | unsupported | compiled | compiled | compiled | compiled | unsupported | unsupported | unsupported | +| agents | native | native | native | compiled | compiled | unsupported | unsupported | native | unsupported | compiled | unsupported | unsupported | +| skills | native | native | native | native | native | native | native | native | native | native | native | native | +| hooks | native | native | unsupported | native | native | native | native | unsupported | native | native | native | unsupported | +| commands | unsupported | native | compiled | compiled | unsupported | compiled | unsupported | compiled | compiled | unsupported | unsupported | unsupported | +| plugins | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | +| MCP servers | native | native | unsupported | native | native | native | native | native | native | native | native | native | +| canvas (experimental) | gated | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | How to read a cell: diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index 932e4bdd9c..508885d234 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -153,6 +153,7 @@ unresolved required entries fail closed. | OpenCode | `opencode.json` | project (only if `.opencode/` exists) | JSON `mcp` | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` | | Kiro IDE | `.kiro/settings/mcp.json` (project, only if `.kiro/` exists) or `~/.kiro/settings/mcp.json` (`-g`) | both | JSON `mcpServers` | +| IBM Bob | `.bob/mcp.json` (project) or `~/.bob/mcp.json` (`-g`) | both | JSON `mcpServers` | | JetBrains Copilot | `%LOCALAPPDATA%\github-copilot\intellij\mcp.json` (Windows) or `$XDG_CONFIG_HOME/github-copilot/intellij/mcp.json` (macOS/Linux; defaults to `~/.config/github-copilot/intellij/mcp.json`) | global | JSON `servers` | ## How `targets:` gates which configs get written @@ -231,6 +232,7 @@ Antigravity CLI to `~/.gemini/config/mcp_config.json`, Hermes to `$HERMES_HOME/config.yaml` whenever selected explicitly (or `~/.hermes/config.yaml` when unset or blank), Windsurf to `~/.codeium/windsurf/mcp_config.json`, Kiro to `~/.kiro/settings/mcp.json`, +IBM Bob to `~/.bob/mcp.json`, and JetBrains Copilot to its OS-specific user config). When the user-scope manifest declares a `targets:` field (or the CLI passes `--target`), only the matching runtimes receive the config write. When no CLI diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index a5d090f10d..9b658f2126 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -119,6 +119,7 @@ Dependency packages contribute only `dependencies.mcp`; their - `.gemini/settings.json` (Gemini) - `~/.codeium/windsurf/mcp_config.json` (Windsurf) - `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` (Kiro IDE) +- `.bob/mcp.json` and `~/.bob/mcp.json` (IBM Bob) - OS-specific `github-copilot/intellij/mcp.json` (JetBrains Copilot -- uses `"servers"` key, user-scope global path): - `%LOCALAPPDATA%\github-copilot\intellij\mcp.json` (Windows) @@ -148,6 +149,22 @@ This target covers the documented Kiro IDE/CLI v3 layout [kiro.dev/docs/cli/v3/](https://kiro.dev/docs/cli/v3/), accessed 2026-08-03). See [the targets matrix](../../reference/targets-matrix/#kiro) for a full primitives list. +### IBM Bob + +[IBM Bob](https://bob.ibm.com/) reads Agent Skills from `.bob/skills/`, +lifecycle hooks from `.bob/settings.json`, and MCP servers from +`.bob/mcp.json`. APM supports the corresponding global locations under +`~/.bob/`; global hooks use Bob's distinct +`~/.bob/settings/settings.json` path. Bob also reads the project-root +`AGENTS.md`, so `apm compile --target bob` supplies compiled instructions. + +The integration follows IBM's documented +[skills](https://bob.ibm.com/docs/ide/features/skills), +[lifecycle hooks](https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks), +and [MCP](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob) schemas. +See [the targets matrix](../../reference/targets-matrix/#bob) for the full +path and capability table. + ### JetBrains (IntelliJ IDEA, PyCharm, GoLand, and others) GitHub Copilot for JetBrains reads MCP servers from a single user-scope diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index c4aad57108..3c4259ca3d 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -47,7 +47,7 @@ for what those files are) even though compile does not itself *deploy* hooks. `.github/instructions/*.instructions.md` (with their `applyTo:` frontmatter) that `apm install` already deploys. Compile is **recommended for every other context-producing target** (`claude`, `cursor`, `codex`, -`gemini`, `grok-build`, `opencode`, `antigravity`, `windsurf`, `kiro`, `hermes`, `intellij`), which load instructions through a +`gemini`, `grok-build`, `opencode`, `antigravity`, `windsurf`, `kiro`, `bob`, `hermes`, `intellij`), which load instructions through a root context file or harness-specific rules folder that compile generates. @@ -320,6 +320,7 @@ one-shot `apm compile`; `--output` only applies in single-file mode. | `antigravity` | `AGENTS.md` | | `windsurf` | `AGENTS.md` | | `kiro` | `AGENTS.md` | +| `bob` | `AGENTS.md` | | `hermes` | `AGENTS.md` | | `intellij` | `AGENTS.md` | | `agent-skills` | none | diff --git a/docs/src/content/docs/reference/cli/deps.md b/docs/src/content/docs/reference/cli/deps.md index 3e9725b6f4..995909e322 100644 --- a/docs/src/content/docs/reference/cli/deps.md +++ b/docs/src/content/docs/reference/cli/deps.md @@ -124,7 +124,7 @@ apm deps update [PACKAGES...] [OPTIONS] |---|---| | `-v, --verbose` | Show detailed update information. | | `--force` | Overwrite locally-authored files on collision. | -| `-t, --target` | Force deployment to specific targets. Comma-separated. Values: `agent-skills`, `agents`, `agy`, `all`, `antigravity`, `claude`, `codex`, `copilot`, `cursor`, `gemini`, `grok-build`, `hermes`, `intellij`, `kiro`, `opencode`, `vscode`, `windsurf`. Experimental targets (`copilot-app`, `copilot-cowork`, `grok-cloud`, `openclaw`) are also accepted when their feature flags are enabled. `all` excludes `agent-skills`, `antigravity`, `hermes`, experimental targets, and `intellij`. | +| `-t, --target` | Force deployment to specific targets. Comma-separated. Values: `agent-skills`, `agents`, `agy`, `all`, `antigravity`, `bob`, `claude`, `codex`, `copilot`, `cursor`, `gemini`, `grok-build`, `hermes`, `intellij`, `kiro`, `opencode`, `vscode`, `windsurf`. Experimental targets (`copilot-app`, `copilot-cowork`, `grok-cloud`, `openclaw`) are also accepted when their feature flags are enabled. `all` excludes `agent-skills`, `antigravity`, `hermes`, experimental targets, and `intellij`. | | `--parallel-downloads N` | Max concurrent downloads. Default `4`. `0` disables parallelism. | | `-g, --global` | Update user-scope dependencies in `~/.apm/`. | | `--legacy-skill-paths` | Deploy skill files to per-client paths (`.cursor/skills/`, etc.) instead of the shared `.agents/skills/` directory. | diff --git a/docs/src/content/docs/reference/cli/init.md b/docs/src/content/docs/reference/cli/init.md index 66627d4352..4c1892f543 100644 --- a/docs/src/content/docs/reference/cli/init.md +++ b/docs/src/content/docs/reference/cli/init.md @@ -35,7 +35,7 @@ and [`apm marketplace init`](../marketplace/) instead. | `-y`, `--yes` | off | Skip interactive prompts; use auto-detected defaults. Overwrites an existing `apm.yml` without confirmation. | | `--plugin` | off | **Deprecated.** Use [`apm plugin init`](../plugin/) instead. Scaffold a plugin authoring project: also writes `plugin.json` and adds a `devDependencies` block to `apm.yml`. Plugin name must be kebab-case, max 64 chars. | | `--marketplace` | off | **Deprecated.** Use [`apm marketplace init`](../marketplace/) instead. Append a `marketplace:` authoring block to `apm.yml`. See [Publish to a marketplace](../../../producer/publish-to-a-marketplace/). | -| `--target` | (prompt) | Comma-separated target list. Skips the interactive target prompt. Stable manifest targets include `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, and `agent-skills`; `all` expands the default stable set. | +| `--target` | (prompt) | Comma-separated target list. Skips the interactive target prompt. Stable manifest targets include `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `bob`, and `agent-skills`; `all` expands the default stable set. | | `-v`, `--verbose` | off | Show detailed output. | Target precedence: `--target` flag > interactive prompt > auto-detect at diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index f691690a6e..fd4a0b31ef 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -46,7 +46,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA | Flag | Default | Description | |---|---|---| -| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `intellij`, `vscode`, `agent-skills`, `hermes`, `all`; experimental `copilot-cowork`, `copilot-app`, and `grok-cloud` (skills only) are also accepted when enabled. Hermes is stable but explicit-only. IntelliJ-specific integration is MCP-only and writes JetBrains Copilot's user-scope MCP config; package file primitives use the Copilot profile. `all` excludes `agent-skills`, `antigravity`, `hermes`, `intellij`, and all experimental targets; combine them explicitly to add them, for example `all,hermes`. Explicit MCP target lists are exact: `intellij,claude` writes only those two MCP configs. See the precedence note below. With nothing to detect, install exits `2` with a teaching message. | +| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `bob`, `intellij`, `vscode`, `agent-skills`, `hermes`, `all`; experimental `copilot-cowork`, `copilot-app`, and `grok-cloud` (skills only) are also accepted when enabled. Hermes is stable but explicit-only. IntelliJ-specific integration is MCP-only and writes JetBrains Copilot's user-scope MCP config; package file primitives use the Copilot profile. `all` excludes `agent-skills`, `antigravity`, `hermes`, `intellij`, and all experimental targets; combine them explicitly to add them, for example `all,hermes`. Explicit MCP target lists are exact: `intellij,claude` writes only those two MCP configs. See the precedence note below. With nothing to detect, install exits `2` with a teaching message. | | `--runtime VALUE` | unset | Legacy alias for `--target` (single value only). Still accepted; prefer `--target`. | | `--exclude VALUE` | unset | Skip one runtime from the resolved MCP/LSP target set (explicit selection, manifest, saved config, or auto-detection). | | `--only apm\|mcp` | both | Install only APM packages or MCP/LSP service dependencies. Use `--only=apm` to skip service configuration and `--only=mcp` to select MCP and LSP services only. | @@ -275,7 +275,7 @@ apm install owner/skill-bundle --skill '*' # reset to all skills - **`--force` is dual-purpose.** It overwrites locally-authored files on collision **and** disables the critical-finding block from the built-in security scan. It does **not** suppress general install errors -- any error reported in the diagnostic summary still exits `1` (matches `npm` / `pip` / `cargo`). It does **not** refresh remote refs -- for routine ref updates, run [`apm update`](../update/). To remediate a blocked package, fix the reported source files and reinstall; `apm audit --strip` only remediates files that are already deployed. See [Drift and secure by default](../../../consumer/drift-and-secure-by-default/). - **Target contraction is reconciled.** A narrowed `targets:` in `apm.yml` is reconciled on the next non-dry-run install: deployed files, lockfile ownership, and merge-hook config/sidecar entries for the dropped target are cleaned up, even when no dependency itself changed. A package's own `target:` / `targets:` declaration applies an additional restriction within that effective set. See [Hooks and commands](../../../producer/author-primitives/hooks-and-commands/#hooks) for the full intersection and merge-hook config/sidecar details. `apm lock` may refresh the lockfile rows, but it never deletes deployed files from disk. - **Claude target prompt rewrite.** When deploying to `.claude/commands/`, prompt files with an `input:` front-matter key are rewritten to Claude's `arguments:` shape and `${input:name}` placeholders become `$name`. Argument names must match `^[A-Za-z][\w-]{0,63}$`; rejected names are dropped with a warning. -- **MCP env-var passthrough.** Copilot CLI and Kiro translate `${env:VAR}` and `` to `${VAR}` in their MCP configs. Kiro writes `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` with `0o600` permissions. JetBrains Copilot preserves env references as `${env:VAR}` in `github-copilot/intellij/mcp.json`. Plaintext secrets are never written to disk for these runtime-resolved targets; legacy targets resolve placeholders at install time. +- **MCP env-var handling.** Copilot CLI and Kiro translate `${env:VAR}` and `` to `${VAR}` in their MCP configs. Kiro writes `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` with `0o600` permissions. JetBrains Copilot preserves env references as `${env:VAR}` in `github-copilot/intellij/mcp.json`. IBM Bob does not document runtime placeholder interpolation, so APM resolves its values at install time and writes `.bob/mcp.json` with `0o600` permissions. ### Install from a private registry (experimental) diff --git a/docs/src/content/docs/reference/cli/targets.md b/docs/src/content/docs/reference/cli/targets.md index 8ed75de2d1..453226e168 100644 --- a/docs/src/content/docs/reference/cli/targets.md +++ b/docs/src/content/docs/reference/cli/targets.md @@ -77,6 +77,7 @@ Sample output in a project with `CLAUDE.md` and `.cursor/`: opencode inactive needs .opencode/ .opencode/ windsurf inactive needs .windsurf/ .windsurf/ kiro inactive needs .kiro/ .kiro/ + bob inactive needs .bob/ .bob/ ``` Machine-readable form: @@ -102,6 +103,7 @@ match per target is enough to activate it. | `opencode` | `.opencode/` directory | `.opencode/` | | `windsurf` | `.windsurf/` directory | `.windsurf/` | | `kiro` | `.kiro/` directory | `.kiro/` | +| `bob` | `.bob/` directory | `.bob/` | | `agent-skills` | Meta-target; never auto-detected. Opt in via `targets:` in `apm.yml` or `--target agent-skills` on `apm install` / `apm deps update` (compile is a no-op for this target). | `.agents/` | Notes: diff --git a/docs/src/content/docs/reference/cli/uninstall.md b/docs/src/content/docs/reference/cli/uninstall.md index 5ad6cd8422..6864ab7a2c 100644 --- a/docs/src/content/docs/reference/cli/uninstall.md +++ b/docs/src/content/docs/reference/cli/uninstall.md @@ -106,7 +106,7 @@ What gets removed, in order: and pruned orphans, across target-owned folders such as `.github/`, `.claude/`, `.grok/`, and `.agents/`. 5. Hook entries inside `.claude/settings.json`, `.cursor/hooks.json`, - `.gemini/settings.json`, and `.kiro/hooks/` that the removed packages + `.gemini/settings.json`, `.kiro/hooks/`, and `.bob/settings.json` that the removed packages contributed. Remaining packages -- including transitive dependencies still required by another package -- have their hook entries rebuilt from the post-removal lockfile. diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 856f5c5f95..31a5d20d9b 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -145,7 +145,7 @@ actionable nudge (the authoring path only). | **Type** | `target`: `string` or `list`; `targets`: `list` (a scalar is accepted as one-item compatibility input) | | **Required** | OPTIONAL | | **Default** | Auto-detect from filesystem signals (see below). [`apm compile -g`](../cli/compile/#global-compilation) instead writes every supported user-scope target when both fields are omitted. | -| **Allowed values** | `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `agent-skills` | +| **Allowed values** | `copilot`, `claude`, `grok-build`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `bob`, `agent-skills`, `hermes` | Controls which output targets are generated during compilation, installation, and packing. Accepts a single string or a YAML list. Unknown values MUST raise a parse error at load time, naming the offending token. @@ -188,6 +188,7 @@ form and normalized to `copilot`; `agents` remains a CLI-only alias. | `antigravity` | Emits `AGENTS.md` and deploys rules, skills, hooks, and MCP config under `.agents/`. | | `windsurf` | Emits `AGENTS.md` and deploys to `.windsurf/rules/`, `.agents/skills/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. | | `kiro` | Emits `AGENTS.md` and deploys to `.kiro/steering/`, `.kiro/skills/`, `.kiro/hooks/`, `.kiro/settings/mcp.json`. | +| `bob` | Emits `AGENTS.md` and deploys skills to `.bob/skills/`, hooks to `.bob/settings.json`, and MCP servers to `.bob/mcp.json`. | | `agent-skills` | Deploys Agent Skills under `.agents/skills/`. | :::tip[Deterministic committed output] @@ -438,7 +439,7 @@ REQUIRED when the shorthand is ambiguous (e.g. direct nested-group repos with vi | `type` | `string` | OPTIONAL (remote Git only) | `gitlab` | Treat a bespoke hostname as self-managed GitLab. | | `allow_insecure` | `boolean` | OPTIONAL (remote Git only) | `true` or `false` | Manifest-side approval for an `http://` dependency; the install command still requires its separate insecure-host opt-in. | | `skills` | `list` | OPTIONAL | Non-empty skill names or `["*"]` | Installs only the selected skills from a dependency that exposes selectable skills. | -| `targets` | `list` | OPTIONAL | Target slugs. Stable: `copilot`, `claude`, `grok-build`, `cursor`, `kiro`, `opencode`, `gemini`, `antigravity`, `codex`, `windsurf`, `agent-skills`, `hermes`. Experimental: `grok-cloud`, `openclaw`, `copilot-cowork`, `copilot-app`. | Restricts which install targets receive this dependency's target-scoped primitives. Omitted = all active install targets. Effective reach = install targets INTERSECT this list. | +| `targets` | `list` | OPTIONAL | Target slugs. Stable: `copilot`, `claude`, `grok-build`, `cursor`, `kiro`, `bob`, `opencode`, `gemini`, `antigravity`, `codex`, `windsurf`, `agent-skills`, `hermes`. Experimental: `grok-cloud`, `openclaw`, `copilot-cowork`, `copilot-app`. | Restricts which install targets receive this dependency's target-scoped primitives. Omitted = all active install targets. Effective reach = install targets INTERSECT this list. | Unknown object-form fields are rejected. On a Git object, `version` reports an actionable error to use `ref` for a branch, tag, or commit; `version` belongs diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index ccc077370b..007346ee24 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -29,14 +29,15 @@ see [Primitive types](../primitive-types/). | opencode | `.opencode/` | [ ] | [ ] | [x] | [x] | [x] | [ ] | [x] | | windsurf | `.windsurf/` + `.agents/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] | | kiro | `.kiro/` | [x] | [ ] | [x] | [x] | [ ] | [x] | [x] | +| bob | `.bob/` | [ ] | [ ] | [ ] | [x] | [ ] | [x] | [x] | | intellij | user MCP config; files via Copilot | [x] (*) | [x] (*) | [x] (*) | [x] (*) | [ ] | [x] (*) | [x] | | agent-skills | `.agents/` | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [ ] | | hermes | `.agents/` (`~/.hermes/` user scope) | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [x] | Skills deploy to `.agents/skills/` for Copilot, Cursor, OpenCode, Gemini, Antigravity, Codex, Hermes project scope, and Windsurf by default (see -[Skills convergence](#skills-convergence) below). Claude, Grok Build, Kiro, and -Hermes user scope keep target-native skill directories. +[Skills convergence](#skills-convergence) below). Claude, Grok Build, Kiro, +Bob, and Hermes user scope keep target-native skill directories. (*) For `intellij`, file primitives route through the Copilot profile: instructions, prompts, agents, and hooks use `.github/`, while skills use @@ -50,7 +51,7 @@ stable but explicit-only. See [Experimental](../experimental/). ## Post-install instruction compilation After a project install stages dependency instructions, the APM CLI requires a -separate root-context compile for `codex`, `gemini`, `opencode`, and `hermes`. +separate root-context compile for `bob`, `codex`, `gemini`, `opencode`, and `hermes`. It emits the [`req-tg-007`](../../specs/openapm-v01/#req-tg-007) reminder for those targets. All other targets in this matrix either deploy instructions as native per-file @@ -90,6 +91,7 @@ runtime-specific configuration while compile only generates project output. Use | opencode | `.opencode/` directory | | windsurf | `.windsurf/` directory | | kiro | `.kiro/` directory | +| bob | `.bob/` directory | | intellij | Global `github-copilot/intellij/` config directory (MCP runtime discovery only) | IntelliJ-specific integration is MCP-only and writes JetBrains Copilot's @@ -265,6 +267,32 @@ Kiro IDE/CLI v3 unified agent harness. - **MCP shape.** JSON `mcpServers` entries use `command`/`args`/`env` for stdio and `url`/`headers` for remote servers. Kiro resolves `${VAR}` placeholders at runtime, so APM preserves them rather than writing secrets to disk. - **Scope.** Covers the documented Kiro IDE and CLI v3 layout (unified harness). Ref: [kiro.dev/docs/cli/v3/](https://kiro.dev/docs/cli/v3/) (accessed 2026-08-03). +## bob + +IBM Bob IDE. + +- **Detection.** `.bob/` directory. +- **Deploy directory.** `.bob/` at project and user scope. +- **Supported primitives.** skills, hooks, mcp. +- **File conventions.** + - skills: `.bob/skills//SKILL.md` (project) or + `~/.bob/skills//SKILL.md` (user) + - hooks: merged into `.bob/settings.json` (project) or + `~/.bob/settings/settings.json` (user); APM preserves existing settings + and user-authored hooks + - mcp: `.bob/mcp.json` (project) or `~/.bob/mcp.json` (user), under the + top-level `mcpServers` key +- **Compile output.** `AGENTS.md`; Bob automatically reads the project-root + file. +- **MCP shape.** STDIO servers use `command`, `args`, `cwd`, and `env`. + Streamable HTTP servers use `type: streamable-http`, `url`, and optional + `headers`; legacy SSE entries omit the type discriminator. + +The paths and schemas follow IBM's current documentation for +[skills](https://bob.ibm.com/docs/ide/features/skills), +[lifecycle hooks](https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks), +and [MCP](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob). + ## intellij GitHub Copilot for JetBrains IDEs. diff --git a/src/apm_cli/adapters/client/bob.py b/src/apm_cli/adapters/client/bob.py new file mode 100644 index 0000000000..2e78270ae5 --- /dev/null +++ b/src/apm_cli/adapters/client/bob.py @@ -0,0 +1,205 @@ +"""IBM Bob MCP client adapter. + +Bob reads project MCP configuration from ``.bob/mcp.json`` and global +configuration from ``~/.bob/mcp.json``. Both files use a top-level +``mcpServers`` object. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from ...utils.atomic_io import atomic_write_text +from ...utils.console import _rich_error, _rich_success +from .copilot import CopilotClientAdapter + +logger = logging.getLogger(__name__) + + +class BobClientAdapter(CopilotClientAdapter): + """MCP configuration adapter for IBM Bob.""" + + supports_user_scope: bool = True + _client_label: str = "IBM Bob" + target_name: str = "bob" + mcp_servers_key: str = "mcpServers" + + # Bob's documented schema does not promise runtime environment-variable + # interpolation. Resolve placeholders before writing until that contract + # is documented by IBM. + _supports_runtime_env_substitution: bool = False + + def get_config_path(self) -> str: + """Return Bob's project- or user-scope MCP config path.""" + root = Path.home() if self.user_scope else self.project_root + return str(root / ".bob" / "mcp.json") + + def get_current_config(self) -> dict[str, Any]: + """Read the current Bob MCP config, tolerating missing/bad JSON.""" + config_path = Path(self.get_config_path()) + if not config_path.exists(): + return {} + try: + with open(config_path, encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Could not read %s: %s", config_path, exc) + return {} + + def update_config(self, config_updates: dict[str, dict[str, Any]]) -> bool: + """Merge server entries into Bob's ``mcpServers`` object.""" + config_path = Path(self.get_config_path()) + current_config = self.get_current_config() + if not isinstance(current_config.get(self.mcp_servers_key), dict): + current_config[self.mcp_servers_key] = {} + current_config[self.mcp_servers_key].update(config_updates) + + config_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text( + config_path, + json.dumps(current_config, indent=2) + "\n", + new_file_mode=0o600, + ) + os.chmod(config_path, 0o600) + return True + + @staticmethod + def _header_mapping(remote: dict[str, Any]) -> dict[str, str]: + """Normalize registry header representations to a string mapping.""" + headers = remote.get("headers", {}) + if isinstance(headers, list): + return { + str(header["name"]): str(header["value"]) + for header in headers + if isinstance(header, dict) and "name" in header and "value" in header + } + if isinstance(headers, dict): + return {str(name): str(value) for name, value in headers.items()} + return {} + + @staticmethod + def _copy_bob_extensions(config: dict[str, Any], server_info: dict[str, Any]) -> None: + """Carry fields that are part of Bob's documented server schema.""" + for key in ("alwaysAllow", "disabled"): + if key in server_info and server_info[key] is not None: + config[key] = server_info[key] + + def _format_server_config( + self, + server_info: dict[str, Any], + env_overrides: dict[str, str] | None = None, + runtime_vars: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Format registry or self-defined server data for IBM Bob.""" + if runtime_vars is None: + runtime_vars = {} + + raw = server_info.get("_raw_stdio") + if raw: + config: dict[str, Any] = {"command": raw["command"]} + if raw.get("cwd") is not None: + config["cwd"] = raw["cwd"] + resolved_env: dict[str, Any] = {} + if raw.get("env"): + resolved_env = self._resolve_environment_variables( + raw["env"], env_overrides=env_overrides + ) + config["env"] = resolved_env + self._warn_input_variables(raw["env"], server_info.get("name", ""), "IBM Bob") + config["args"] = [ + self._resolve_variable_placeholders(arg, resolved_env, runtime_vars) + if isinstance(arg, str) + else arg + for arg in raw.get("args") or [] + ] + self._copy_bob_extensions(config, server_info) + self._merge_extra(config, server_info) + return config + + remotes = server_info.get("remotes", []) + if remotes: + remote = self._select_remote_with_url(remotes) or remotes[0] + transport = (remote.get("transport_type") or "http").strip() + if transport not in ("sse", "http", "streamable-http"): + raise ValueError( + f"Unsupported remote transport '{transport}' for IBM Bob. " + f"Server: {server_info.get('name', 'unknown')}. " + "Supported transports: http, sse, streamable-http." + ) + + config = {"url": (remote.get("url") or "").strip()} + if transport in ("http", "streamable-http"): + config["type"] = "streamable-http" + headers = { + name: self._resolve_env_variable(name, value, env_overrides) + for name, value in self._header_mapping(remote).items() + if name + } + if headers: + config["headers"] = headers + self._warn_input_variables(headers, server_info.get("name", ""), "IBM Bob") + self._copy_bob_extensions(config, server_info) + self._merge_extra(config, server_info) + return config + + packages = server_info.get("packages", []) + if not packages: + raise ValueError( + "MCP server has incomplete configuration in registry - " + "no package information or remote endpoints available. " + f"Server: {server_info.get('name', 'unknown')}" + ) + + config = {} + package = self._select_and_dispatch_best_package( + config, packages, env_overrides, runtime_vars + ) + if not package: + raise ValueError( + f"No supported package type found for IBM Bob. " + f"Server: {server_info.get('name', 'unknown')}." + ) + self._copy_bob_extensions(config, server_info) + self._merge_extra(config, server_info) + return config + + def configure_mcp_server( + self, + server_url: str, + server_name: str | None = None, + enabled: bool = True, + env_overrides: dict[str, str] | None = None, + server_info_cache: dict[str, Any] | None = None, + runtime_vars: dict[str, str] | None = None, + ) -> bool: + """Configure one MCP server in Bob's native JSON file.""" + if not server_url: + _rich_error("server_url cannot be empty", symbol="error") + return False + + config_key = self._determine_config_key(server_url, server_name) + try: + server_info = self._fetch_server_info(server_url, server_info_cache) + if server_info is None: + return False + server_config = self._format_server_config(server_info, env_overrides, runtime_vars) + if not enabled: + server_config["disabled"] = True + self.update_config({config_key: server_config}) + _rich_success( + f"Configured MCP server '{config_key}' for IBM Bob", + symbol="success", + ) + return True + except Exception as exc: + logger.debug("IBM Bob MCP configuration failed: %s", exc) + _rich_error( + f"Failed to configure MCP server '{config_key}' for IBM Bob", + symbol="error", + ) + return False diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index 4349dace8d..c0ffad3914 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -1096,9 +1096,10 @@ def _native_hook_state_exists(project_root: Path, targets: list[object]) -> bool if config is None: continue target_dir = project_root / target.root_dir - if (target_dir / config.config_filename).exists() or ( - target_dir / _APM_HOOKS_SIDECAR - ).exists(): + config_path = target_dir / config.filename_for_scope( + bool(getattr(target, "is_user_scope", False)) + ) + if config_path.exists() or (config_path.parent / _APM_HOOKS_SIDECAR).exists(): return True return False diff --git a/src/apm_cli/compilation/context_optimizer.py b/src/apm_cli/compilation/context_optimizer.py index 3a08b0a70e..c0611312f1 100644 --- a/src/apm_cli/compilation/context_optimizer.py +++ b/src/apm_cli/compilation/context_optimizer.py @@ -62,6 +62,7 @@ ".gemini", ".github", ".kiro", + ".bob", ".opencode", ".windsurf", } diff --git a/src/apm_cli/core/deployment_ledger.py b/src/apm_cli/core/deployment_ledger.py index 9f6d00d139..00e612396c 100644 --- a/src/apm_cli/core/deployment_ledger.py +++ b/src/apm_cli/core/deployment_ledger.py @@ -32,6 +32,7 @@ ".cursor/": "cursor", ".windsurf/": "windsurf", ".kiro/": "kiro", + ".bob/": "bob", ".gemini/": "gemini", ".codex/": "codex", ".opencode/": "opencode", diff --git a/src/apm_cli/core/errors.py b/src/apm_cli/core/errors.py index d2b2ebf7cc..01bedd2543 100644 --- a/src/apm_cli/core/errors.py +++ b/src/apm_cli/core/errors.py @@ -64,7 +64,7 @@ class EmptyTargetsListError(TargetResolutionError): ".github/copilot-instructions.md, .github/instructions/, " ".github/agents/, .github/prompts/, .github/hooks/, " ".codex/, .gemini/, GEMINI.md, " - ".opencode/, .windsurf/, .kiro/" + ".opencode/, .windsurf/, .kiro/, .bob/" ) diff --git a/src/apm_cli/core/target_catalog.py b/src/apm_cli/core/target_catalog.py index be19710284..2154a8a92b 100644 --- a/src/apm_cli/core/target_catalog.py +++ b/src/apm_cli/core/target_catalog.py @@ -133,6 +133,13 @@ def _build_target_catalog( primitive_profile="kiro", compile_family="agents", ), + _capability( + "bob", + "IBM Bob native .bob configuration", + in_all=True, + primitive_profile="bob", + compile_family="agents", + ), _capability( "opencode", "OpenCode native .opencode configuration", diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index f33ae3451d..90ca68a5bd 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -1,7 +1,8 @@ """Target detection for auto-selecting compilation and integration targets. This module implements the auto-detection pattern for determining which agent -targets (Copilot, Claude, Cursor, OpenCode, Codex, Gemini, Antigravity, Kiro) should be used +targets (Copilot, Claude, Cursor, OpenCode, Codex, Gemini, Antigravity, Kiro, +IBM Bob) should be used based on existing project structure and configuration. Detection priority (highest to lowest): @@ -73,6 +74,7 @@ def agents_alias_was_detected() -> bool: "grok-build", "windsurf", "kiro", + "bob", "agent-skills", "hermes", "all", @@ -116,6 +118,7 @@ def agents_alias_was_detected() -> bool: "grok-build", "windsurf", "kiro", + "bob", "agent-skills", "hermes", "all", @@ -123,7 +126,7 @@ def agents_alias_was_detected() -> bool: ] -def detect_target( # noqa: PLR0911 +def detect_target( # noqa: C901, PLR0911 -- legacy branch table; catalog migration pending project_root: Path, explicit_target: str | None = None, config_target: str | None = None, @@ -163,6 +166,8 @@ def detect_target( # noqa: PLR0911 return "windsurf", "explicit --target flag" elif explicit_target == "kiro": return "kiro", "explicit --target flag" + elif explicit_target == "bob": + return "bob", "explicit --target flag" elif explicit_target == "grok-build": return "grok-build", "explicit --target flag" elif explicit_target == "agent-skills": @@ -192,6 +197,8 @@ def detect_target( # noqa: PLR0911 return "windsurf", "apm.yml target" elif config_target == "kiro": return "kiro", "apm.yml target" + elif config_target == "bob": + return "bob", "apm.yml target" elif config_target == "grok-build": return "grok-build", "apm.yml target" elif config_target == "agent-skills": @@ -210,6 +217,7 @@ def detect_target( # noqa: PLR0911 gemini_exists = (project_root / ".gemini").is_dir() windsurf_exists = (project_root / ".windsurf").is_dir() kiro_exists = (project_root / ".kiro").is_dir() + bob_exists = (project_root / ".bob").is_dir() grok_exists = (project_root / ".grok").is_dir() detected = [] if github_exists: @@ -228,6 +236,8 @@ def detect_target( # noqa: PLR0911 detected.append(".windsurf/") if kiro_exists: detected.append(".kiro/") + if bob_exists: + detected.append(".bob/") if grok_exists: detected.append(".grok/") @@ -249,6 +259,8 @@ def detect_target( # noqa: PLR0911 return "windsurf", "detected .windsurf/ folder" elif kiro_exists: return "kiro", "detected .kiro/ folder" + elif bob_exists: + return "bob", "detected .bob/ folder" elif grok_exists: return "grok-build", "detected .grok/ folder" else: @@ -259,7 +271,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: """Check if AGENTS.md should be compiled. AGENTS.md is generated for vscode, cursor, opencode, codex, gemini, - windsurf, kiro, antigravity, grok-build, hermes, all, and minimal targets. + windsurf, kiro, bob, antigravity, grok-build, hermes, all, and minimal targets. Gemini needs it because GEMINI.md imports AGENTS.md. Args: @@ -281,6 +293,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: "grok-build", "windsurf", "kiro", + "bob", "hermes", "all", "minimal", @@ -419,10 +432,11 @@ def get_target_description(target: UserTargetType) -> str: "grok-build": "AGENTS.md + .grok/rules/ + .grok/agents/ + .grok/commands/ + .grok/skills/", "windsurf": "AGENTS.md + .windsurf/rules/ + .agents/skills/ + .windsurf/workflows/ + .windsurf/hooks.json", "kiro": "AGENTS.md + .kiro/steering/ + .kiro/skills/ + .kiro/hooks/ + .kiro/settings/mcp.json", + "bob": "AGENTS.md + .bob/skills/ + .bob/settings.json hooks + .bob/mcp.json", "agent-skills": ".agents/skills/ only (cross-client shared skills -- no agents, hooks, or commands)", "openclaw": ".agents/skills/ (project) or ~/.openclaw/skills/ (--global) -- experimental", "hermes": "AGENTS.md + .agents/skills/ (project) or $HERMES_HOME/skills/ + $HERMES_HOME/config.yaml MCP (explicit --target only)", - "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .agents/", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .bob/ + .agents/", "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } return descriptions.get(normalized, "unknown target") @@ -508,7 +522,7 @@ def normalize_target_list( - ``None`` -> ``None`` (auto-detect) - ``"claude"`` -> ``["claude"]`` - ``"copilot"`` -> ``["vscode"]`` (alias resolution) - - ``"all"`` -> ``["claude", "codex", "cursor", "gemini", "kiro", "opencode", "vscode", "windsurf"]`` + - ``"all"`` -> every target marked ``in_all`` in the target catalog - ``["claude", "copilot"]`` -> ``["claude", "vscode"]`` - Deduplicates while preserving first-seen order. @@ -1024,6 +1038,7 @@ def resolve_manifest_target_decision( ("opencode", "dir", ".opencode"), ("windsurf", "dir", ".windsurf"), ("kiro", "dir", ".kiro"), + ("bob", "dir", ".bob"), ] # Ordered list of targets for display (excludes agent-skills meta-target). @@ -1037,6 +1052,7 @@ def resolve_manifest_target_decision( "opencode", "windsurf", "kiro", + "bob", ] # Canonical deploy directories for each target. @@ -1050,6 +1066,7 @@ def resolve_manifest_target_decision( "opencode": ".opencode/", "windsurf": ".windsurf/", "kiro": ".kiro/", + "bob": ".bob/", } # The primary (lowest-friction) signal for each target, used in @@ -1064,6 +1081,7 @@ def resolve_manifest_target_decision( "opencode": ".opencode/", "windsurf": ".windsurf/", "kiro": ".kiro/", + "bob": ".bob/", } diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index e11e2a6d4e..73e79b7d0d 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -3,6 +3,7 @@ from pathlib import Path from .adapters.client.antigravity import AntigravityClientAdapter +from .adapters.client.bob import BobClientAdapter from .adapters.client.claude import ClaudeClientAdapter from .adapters.client.codex import CodexClientAdapter from .adapters.client.copilot import CopilotClientAdapter @@ -24,6 +25,7 @@ # consumers). _MCP_CLIENT_REGISTRY: dict[str, type] = { "antigravity": AntigravityClientAdapter, + "bob": BobClientAdapter, "copilot": CopilotClientAdapter, "vscode": VSCodeClientAdapter, "codex": CodexClientAdapter, diff --git a/src/apm_cli/install/manifest_reconcile.py b/src/apm_cli/install/manifest_reconcile.py index 4cefc0cbfe..615d9ae6a7 100644 --- a/src/apm_cli/install/manifest_reconcile.py +++ b/src/apm_cli/install/manifest_reconcile.py @@ -228,8 +228,11 @@ def merge_hook_config_projection_specs( root = str(getattr(target, "root_dir", "") or "").rstrip("/") if config is None or not root: continue - specs[f"{root}/{config.config_filename}"] = ( - f"{root}/{_hi._APM_HOOKS_SIDECAR}", + config_filename = config.filename_for_scope(target.is_user_scope) + config_path = f"{root}/{config_filename}" + config_parent = config_path.rsplit("/", 1)[0] + specs[config_path] = ( + f"{config_parent}/{_hi._APM_HOOKS_SIDECAR}", config.event_container_key, ) return specs diff --git a/src/apm_cli/integration/_hook_dropped_targets.py b/src/apm_cli/integration/_hook_dropped_targets.py index bf63b6e854..275b77f00d 100644 --- a/src/apm_cli/integration/_hook_dropped_targets.py +++ b/src/apm_cli/integration/_hook_dropped_targets.py @@ -66,8 +66,8 @@ def reconcile_dropped_targets( if scoped is None: continue target_dir = scoped.deploy_path(project_root) - json_path = target_dir / config.config_filename - sidecar_path = target_dir / _hi._APM_HOOKS_SIDECAR + json_path = target_dir / config.filename_for_scope(scoped.is_user_scope) + sidecar_path = json_path.parent / _hi._APM_HOOKS_SIDECAR if not json_path.exists(): if sidecar_path.exists(): diff --git a/src/apm_cli/integration/hook_file_routing.py b/src/apm_cli/integration/hook_file_routing.py index 6a54a41915..5fad69c8b9 100644 --- a/src/apm_cli/integration/hook_file_routing.py +++ b/src/apm_cli/integration/hook_file_routing.py @@ -16,6 +16,7 @@ "antigravity": {"antigravity"}, "windsurf": {"windsurf"}, "kiro": {"kiro"}, + "bob": {"bob"}, } diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 4916188d8d..705ecafce8 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -166,6 +166,14 @@ class _MergeHookConfig: # overwritten -- the guard in _integrate_merged_hooks() preserves any # value the user has set manually. top_level_defaults: dict[str, Any] = field(default_factory=dict) + user_config_filename: str | None = None + allowed_events: frozenset[str] | None = None + + def filename_for_scope(self, user_scope: bool) -> str: + """Return the config path relative to the target root.""" + if user_scope and self.user_config_filename: + return self.user_config_filename + return self.config_filename # Per-target hook event name mapping. Packages are authored with @@ -194,6 +202,17 @@ class _MergeHookConfig: **dict.fromkeys(("SessionStart", "sessionStart"), "SessionStart"), **dict.fromkeys(("Stop", "AgentStop", "agentStop"), "Stop"), }, + "bob": { + "PreToolUse": "PreToolUse", + "preToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "postToolUse": "PostToolUse", + "UserPromptSubmit": "UserPromptSubmit", + "userPromptSubmit": "UserPromptSubmit", + "promptSubmit": "UserPromptSubmit", + **dict.fromkeys(("SessionStart", "sessionStart"), "SessionStart"), + **dict.fromkeys(("Stop", "AgentStop", "agentStop"), "Stop"), + }, "gemini": { # Copilot / Claude -> Gemini "PreToolUse": "BeforeTool", @@ -242,6 +261,7 @@ class _MergeHookConfig: "antigravity": "PascalCase", "windsurf": "PascalCase", "kiro": "PascalCase", + "bob": "PascalCase", } @@ -337,6 +357,16 @@ def _validate_copilot_payload(payload: dict) -> list[str]: require_dir=False, schema_strict=True, ), + "bob": _MergeHookConfig( + config_filename="settings.json", + user_config_filename="settings/settings.json", + target_key="bob", + require_dir=False, + schema_strict=True, + allowed_events=frozenset( + {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"} + ), + ), "cursor": _MergeHookConfig( config_filename="hooks.json", target_key="cursor", @@ -1232,6 +1262,7 @@ def _integrate_merged_hooks( root_dir = target.root_dir if target else f".{config.target_key}" target_dir = project_root / root_dir container = config.event_container_key + config_filename = config.filename_for_scope(user_scope) # Opt-in check: some targets only deploy when their dir exists if config.require_dir and not target_dir.exists(): @@ -1276,7 +1307,7 @@ def _integrate_merged_hooks( cleared_events: set = set() # Read existing JSON config - json_path = target_dir / config.config_filename + json_path = target_dir / config_filename json_config: dict = {} if json_path.exists(): try: @@ -1286,7 +1317,7 @@ def _integrate_merged_hooks( json_config = {} # Load external ownership metadata before reconciling native entries. - sidecar_path = target_dir / _APM_HOOKS_SIDECAR + sidecar_path = json_path.parent / _APM_HOOKS_SIDECAR sidecar_data: dict = {} if config.schema_strict and sidecar_path.exists(): try: @@ -1315,7 +1346,7 @@ def _integrate_merged_hooks( # stray empty "hooks" object in their native file. if container not in json_config: json_config[container] = {} - _log.debug("Seeded hook container '%s' in %s", container, config.config_filename) + _log.debug("Seeded hook container '%s' in %s", container, config_filename) # Inject any target-specific top-level defaults (e.g. "version": 1 for # Cursor) that are absent from the existing file. Existing values are @@ -1328,7 +1359,7 @@ def _integrate_merged_hooks( if injected_keys: _log.debug( "Injected top_level_defaults into %s: %s", - config.config_filename, + config_filename, injected_keys, ) @@ -1365,12 +1396,18 @@ def _integrate_merged_hooks( if not isinstance(entries, list) or not entries: continue event_name = event_map.get(raw_event_name, raw_event_name) + if config.allowed_events is not None and event_name not in config.allowed_events: + _rich_warning( + f"Hook event '{raw_event_name}' is not supported by " + f"{config.target_key}; skipped." + ) + continue if event_name not in json_config[container]: json_config[container][event_name] = [] # Transform flat Copilot entries to the target's nested / # native hook shape. - if config.target_key == "claude": + if config.target_key in ("claude", "bob"): entries = _to_claude_hook_entries(entries) elif config.target_key == "gemini": entries = _to_gemini_hook_entries(entries) @@ -1489,8 +1526,8 @@ def _integrate_merged_hooks( hooks_integrated += 1 pending_display.append( ( - config.config_filename, - config.config_filename, + config_filename, + config_filename, hook_file, file_event_entries, ) @@ -1543,7 +1580,6 @@ def _integrate_merged_hooks( sidecar_out = _extract_apm_source_sidecar(json_config.get(container, {})) # Write sidecar - sidecar_path = target_dir / _APM_HOOKS_SIDECAR if sidecar_out: atomic_write_text( sidecar_path, @@ -1755,7 +1791,7 @@ def reconcile_package_target_restriction( if config is None: continue target_dir = project_root / target.root_dir - json_path = target_dir / config.config_filename + json_path = target_dir / config.filename_for_scope(target.is_user_scope) errors_before = stats["errors"] self._clean_apm_source_from_json( json_path, @@ -1937,7 +1973,7 @@ def sync_integration( for t in merge_source: config = _MERGE_HOOK_TARGETS.get(t.name) if config is not None: - json_path = project_root / t.root_dir / config.config_filename + json_path = project_root / t.root_dir / config.filename_for_scope(t.is_user_scope) self._clean_apm_entries_from_json( json_path, stats, diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index f4c94a0c24..4a513fa1fd 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -854,6 +854,25 @@ def remove_stale( fail_on_write_error=fail_on_write_error, ) + if "bob" in target_runtimes: + from apm_cli.factory import ClientFactory + + bob_cfg = Path( + ClientFactory.create_client( + "bob", + project_root=project_root_path, + user_scope=user_scope or scope is InstallScope.USER, + ).get_config_path() + ) + _clean_json_mcp_config( + bob_cfg, + expanded_stale, + logger, + "IBM Bob MCP config", + use_rich=True, + fail_on_write_error=fail_on_write_error, + ) + # Clean JetBrains Copilot user-scope mcp.json if "intellij" in target_runtimes: from apm_cli.factory import ClientFactory diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 37a0ddf829..77c3cb6b65 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -435,6 +435,7 @@ def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list "gemini": ".gemini", "windsurf": ".windsurf", "kiro": ".kiro", + "bob": ".bob", } try: from apm_cli.factory import ClientFactory @@ -452,6 +453,7 @@ def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list "gemini", "windsurf", "kiro", + "bob", "claude", "intellij", ]: @@ -485,6 +487,8 @@ def _runtime_is_present( return _is_vscode_available(project_root=project_root_path) if runtime_name == "kiro" and user_scope: return True + if runtime_name == "bob" and user_scope: + return (Path.home() / ".bob").is_dir() if runtime_name in dir_signal: return (project_root_path / dir_signal[runtime_name]).is_dir() if runtime_name == "claude": @@ -513,8 +517,14 @@ def _discover_installed_runtimes_fallback( ("gemini", ".gemini"), ("windsurf", ".windsurf"), ("kiro", ".kiro"), + ("bob", ".bob"), ): - if (name == "kiro" and user_scope) or (project_root_path / signal).is_dir(): + if name == "kiro" and user_scope: + installed_runtimes.append(name) + elif name == "bob" and user_scope: + if (Path.home() / signal).is_dir(): + installed_runtimes.append(name) + elif (project_root_path / signal).is_dir(): installed_runtimes.append(name) # Claude Code: directory-presence OR binary-on-PATH if (project_root_path / ".claude").is_dir() or find_runtime_binary("claude") is not None: diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index a327df2164..757a73bb0d 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -273,6 +273,9 @@ class TargetProfile: warn_unsupported_primitives: bool = False """Warn when a package contains primitives omitted by this profile.""" + is_user_scope: bool = False + """Whether this immutable profile copy has been resolved for user scope.""" + @property def name(self) -> str: """Return the canonical native target name.""" @@ -424,6 +427,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: self, primitives=filtered, resolved_deploy_root=resolved_root, + is_user_scope=True, ) if not self.user_supported: @@ -470,7 +474,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: merged.update(self.user_primitive_overrides) filtered = merged - return replace(self, root_dir=new_root, primitives=filtered) + return replace(self, root_dir=new_root, primitives=filtered, is_user_scope=True) def _encode_cowork_locator(path: Path, deploy_root: Path) -> str: @@ -650,6 +654,26 @@ def _encode_copilot_app_locator(path: Path) -> str: user_supported=True, user_root_dir=".kiro", ), + # IBM Bob -- skills use the Agent Skills SKILL.md layout. Lifecycle + # hooks merge into .bob/settings.json (project) or + # ~/.bob/settings/settings.json (user); MCP is handled by + # BobClientAdapter at .bob/mcp.json in either scope. + # Ref: https://bob.ibm.com/docs/ide/features/skills + # Ref: https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks + # Ref: https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob + "bob": TargetProfile( + capability=TARGET_CAPABILITIES["bob"], + root_dir=".bob", + primitives={ + "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), + "hooks": PrimitiveMapping("hooks", ".json", "bob_hooks"), + }, + auto_create=False, + detect_by_dir=True, + user_supported=True, + user_root_dir=".bob", + hooks_config_display=".bob/settings.json", + ), # OpenCode -- at user scope, ~/.config/opencode/ supports skills, agents, # and commands. OpenCode has no hooks concept, so "hooks" is excluded. "opencode": TargetProfile( diff --git a/src/apm_cli/models/dependency/host_virtual.py b/src/apm_cli/models/dependency/host_virtual.py index 1577488859..f3d171d85e 100644 --- a/src/apm_cli/models/dependency/host_virtual.py +++ b/src/apm_cli/models/dependency/host_virtual.py @@ -29,6 +29,7 @@ ".gemini", ".github", ".kiro", + ".bob", ".windsurf", } ) diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index b303a6cf2f..56d25b5727 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -778,6 +778,7 @@ def _check_scripts_policy( ".claude", ".opencode", ".kiro", + ".bob", ] diff --git a/tests/unit/adapters/test_bob_client_adapter.py b/tests/unit/adapters/test_bob_client_adapter.py new file mode 100644 index 0000000000..ad5da73762 --- /dev/null +++ b/tests/unit/adapters/test_bob_client_adapter.py @@ -0,0 +1,157 @@ +"""IBM Bob MCP adapter tests.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.adapters.client.bob import BobClientAdapter +from apm_cli.factory import ClientFactory + + +def test_factory_creates_bob_adapter() -> None: + assert isinstance(ClientFactory.create_client("BoB"), BobClientAdapter) + + +def test_bob_config_paths_follow_documented_scopes(tmp_path: Path) -> None: + assert BobClientAdapter(project_root=tmp_path).get_config_path() == str( + tmp_path / ".bob" / "mcp.json" + ) + with patch("pathlib.Path.home", return_value=tmp_path): + assert BobClientAdapter(user_scope=True).get_config_path() == str( + tmp_path / ".bob" / "mcp.json" + ) + + +def test_update_config_preserves_user_keys_and_makes_private_file(tmp_path: Path) -> None: + bob_dir = tmp_path / ".bob" + bob_dir.mkdir() + config_path = bob_dir / "mcp.json" + config_path.write_text( + json.dumps({"userSetting": True, "mcpServers": {"keep": {"command": "node"}}}), + encoding="utf-8", + ) + + BobClientAdapter(project_root=tmp_path).update_config( + {"new": {"command": "python", "args": ["server.py"]}} + ) + + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["userSetting"] is True + assert set(data["mcpServers"]) == {"keep", "new"} + if sys.platform != "win32": + assert config_path.stat().st_mode & 0o777 == 0o600 + + +def test_stdio_config_uses_bob_schema_and_resolves_env(tmp_path: Path) -> None: + adapter = BobClientAdapter(project_root=tmp_path) + server_info = { + "id": "not-written", + "name": "local", + "_raw_stdio": { + "command": "node", + "args": ["server.js", "--token", "${BOB_TOKEN}"], + "cwd": "/workspace", + "env": {"BOB_TOKEN": "${BOB_TOKEN}"}, + }, + "alwaysAllow": ["search"], + } + + with patch.dict("os.environ", {"BOB_TOKEN": "resolved-secret"}, clear=False): + config = adapter._format_server_config(server_info) + + assert config == { + "command": "node", + # New-style placeholders in argv are preserved by APM's legacy-mode + # compatibility contract; the corresponding env block is resolved. + "args": ["server.js", "--token", "${BOB_TOKEN}"], + "cwd": "/workspace", + "env": {"BOB_TOKEN": "resolved-secret"}, + "alwaysAllow": ["search"], + } + assert "type" not in config + assert "tools" not in config + assert "id" not in config + + +@pytest.mark.parametrize( + ("transport", "expected_type"), + [("http", "streamable-http"), ("streamable-http", "streamable-http"), ("sse", None)], +) +def test_remote_transport_uses_bob_schema(transport: str, expected_type: str | None) -> None: + adapter = BobClientAdapter() + server_info = { + "name": "remote", + "remotes": [ + { + "transport_type": transport, + "url": "https://example.test/mcp", + "headers": [{"name": "Authorization", "value": "Bearer ${BOB_TOKEN}"}], + } + ], + } + with patch.dict("os.environ", {"BOB_TOKEN": "secret"}, clear=False): + config = adapter._format_server_config(server_info) + + assert config["url"] == "https://example.test/mcp" + assert config["headers"] == {"Authorization": "Bearer secret"} + assert config.get("type") == expected_type + assert "tools" not in config + assert "id" not in config + + +def test_configure_server_writes_project_file(tmp_path: Path) -> None: + adapter = BobClientAdapter(project_root=tmp_path) + adapter.registry_client = MagicMock() + adapter.registry_client.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + + assert adapter.configure_mcp_server("scope/server", server_name="srv") is True + data = json.loads((tmp_path / ".bob" / "mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["srv"]["command"] == "npx" + + +def test_project_runtime_discovery_uses_bob_directory(tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes + + (tmp_path / ".bob").mkdir() + assert "bob" in _discover_installed_runtimes(tmp_path, user_scope=False) + + +def test_user_runtime_discovery_uses_global_bob_directory(tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes + + (tmp_path / ".bob").mkdir() + project = tmp_path / "project" + project.mkdir() + with patch("pathlib.Path.home", return_value=tmp_path): + assert "bob" in _discover_installed_runtimes(project, user_scope=True) + + +def test_remove_stale_bob_project_config(tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator import MCPIntegrator + + bob_dir = tmp_path / ".bob" + bob_dir.mkdir() + config_path = bob_dir / "mcp.json" + config_path.write_text( + json.dumps( + { + "mcpServers": { + "keep": {"command": "node"}, + "stale": {"command": "python"}, + } + } + ), + encoding="utf-8", + ) + + MCPIntegrator.remove_stale({"stale"}, runtime="bob", project_root=tmp_path) + + data = json.loads(config_path.read_text(encoding="utf-8")) + assert set(data["mcpServers"]) == {"keep"} diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py index fa3ef3cf83..0bf5ac17fc 100644 --- a/tests/unit/core/test_scope.py +++ b/tests/unit/core/test_scope.py @@ -174,6 +174,7 @@ def test_all_known_targets_present(self): "grok-build", "grok-cloud", "antigravity", + "bob", "windsurf", "kiro", "copilot-cowork", diff --git a/tests/unit/core/test_target_catalog.py b/tests/unit/core/test_target_catalog.py index 5ed9846998..553e36a077 100644 --- a/tests/unit/core/test_target_catalog.py +++ b/tests/unit/core/test_target_catalog.py @@ -39,6 +39,7 @@ def test_current_target_sets_and_aliases_are_characterized() -> None: assert ( frozenset( { + "bob", "claude", "codex", "cursor", @@ -74,6 +75,7 @@ def test_current_target_sets_and_aliases_are_characterized() -> None: "agy", "all", "antigravity", + "bob", "claude", "codex", "copilot", @@ -118,6 +120,15 @@ def test_current_native_profiles_are_characterized() -> None: for name, profile in KNOWN_TARGETS.items() } assert actual == { + "bob": ( + ".bob", + { + "skills": ("skills", "/SKILL.md", "skill_standard", None, False), + "hooks": ("hooks", ".json", "bob_hooks", None, False), + }, + "agents", + None, + ), "copilot": ( ".github", { diff --git a/tests/unit/core/test_target_catalog_owner_invariant.py b/tests/unit/core/test_target_catalog_owner_invariant.py index 3dc9479d44..b93a715ba9 100644 --- a/tests/unit/core/test_target_catalog_owner_invariant.py +++ b/tests/unit/core/test_target_catalog_owner_invariant.py @@ -9,6 +9,7 @@ { "agent-skills", "antigravity", + "bob", "claude", "codex", "copilot", diff --git a/tests/unit/integration/test_bob_target.py b/tests/unit/integration/test_bob_target.py new file mode 100644 index 0000000000..b99e336513 --- /dev/null +++ b/tests/unit/integration/test_bob_target.py @@ -0,0 +1,213 @@ +"""Acceptance tests for the IBM Bob target (#2736).""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from apm_cli.core.target_detection import detect_target, should_compile_agents_md +from apm_cli.integration.hook_integrator import HookIntegrator +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.integration.targets import KNOWN_TARGETS, active_targets +from apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + PackageType, + ResolvedReference, +) + + +def _package_info( + package_dir: Path, + name: str = "bob-package", + package_type: PackageType | None = None, +) -> PackageInfo: + package = APMPackage( + name=name, + version="1.0.0", + package_path=package_dir, + source=f"github.com/test/{name}", + ) + resolved = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +def _hook_package(tmp_path: Path) -> PackageInfo: + package_dir = tmp_path / "package" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "check.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (hooks_dir / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "preToolUse": [ + { + "matcher": "^write_file$", + "hooks": [ + { + "type": "command", + "command": "sh ${PLUGIN_ROOT}/hooks/check.sh", + "timeout": 5, + } + ], + } + ], + "PreTaskExecution": [{"command": "echo unsupported"}], + } + } + ), + encoding="utf-8", + ) + return _package_info(package_dir) + + +def test_bob_profile_and_detection_match_native_contract(tmp_path: Path) -> None: + target = KNOWN_TARGETS["bob"] + assert target.root_dir == ".bob" + assert target.auto_create is False + assert target.detect_by_dir is True + assert target.user_supported is True + assert target.compile_family == "agents" + assert set(target.primitives) == {"skills", "hooks"} + assert target.primitives["skills"].extension == "/SKILL.md" + assert target.hooks_config_display == ".bob/settings.json" + + (tmp_path / ".bob").mkdir() + assert detect_target(tmp_path) == ("bob", "detected .bob/ folder") + assert [profile.name for profile in active_targets(tmp_path)] == ["bob"] + assert should_compile_agents_md("bob") is True + + +def test_bob_skill_deploys_to_project_skill_directory(tmp_path: Path) -> None: + (tmp_path / ".bob").mkdir() + package_dir = tmp_path / "bob-skill" + package_dir.mkdir() + (package_dir / "SKILL.md").write_text( + "---\nname: bob-skill\ndescription: Bob skill\n---\n\n# Bob skill\n", + encoding="utf-8", + ) + + result = SkillIntegrator().integrate_package_skill( + _package_info(package_dir, "bob-skill", PackageType.CLAUDE_SKILL), + tmp_path, + targets=[KNOWN_TARGETS["bob"]], + ) + + deployed = tmp_path / ".bob" / "skills" / "bob-skill" / "SKILL.md" + assert result.skill_created is True + assert deployed.is_file() + + user_home = tmp_path / "home" + (user_home / ".bob").mkdir(parents=True) + user_target = KNOWN_TARGETS["bob"].for_scope(user_scope=True) + assert user_target is not None + user_result = SkillIntegrator().integrate_package_skill( + _package_info(package_dir, "bob-skill", PackageType.CLAUDE_SKILL), + user_home, + targets=[user_target], + ) + assert user_result.skill_created is True + assert (user_home / ".bob" / "skills" / "bob-skill" / "SKILL.md").is_file() + + +def test_bob_hooks_merge_into_project_settings_and_preserve_user_entries( + tmp_path: Path, +) -> None: + bob_dir = tmp_path / ".bob" + bob_dir.mkdir() + settings = bob_dir / "settings.json" + settings.write_text( + json.dumps( + { + "userSetting": True, + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "echo user"}]}]}, + } + ), + encoding="utf-8", + ) + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["bob"], + _hook_package(tmp_path), + tmp_path, + ) + + data = json.loads(settings.read_text(encoding="utf-8")) + assert result.files_integrated == 1 + assert data["userSetting"] is True + assert data["hooks"]["Stop"][0]["hooks"][0]["command"] == "echo user" + entry = data["hooks"]["PreToolUse"][0] + assert entry["matcher"] == "^write_file$" + assert entry["hooks"][0]["command"] == "sh .bob/hooks/package/hooks/check.sh" + assert "_apm_source" not in entry + assert "PreTaskExecution" not in data["hooks"] + assert (bob_dir / "hooks" / "package" / "hooks" / "check.sh").is_file() + sidecar = json.loads((bob_dir / "apm-hooks.json").read_text(encoding="utf-8")) + assert sidecar["PreToolUse"][0]["_apm_source"] == "package" + + +def test_bob_hooks_use_documented_user_settings_path(tmp_path: Path) -> None: + target = KNOWN_TARGETS["bob"].for_scope(user_scope=True) + assert target is not None + + result = HookIntegrator().integrate_hooks_for_target( + target, + _hook_package(tmp_path), + tmp_path, + user_scope=True, + ) + + settings = tmp_path / ".bob" / "settings" / "settings.json" + assert result.files_integrated == 1 + assert settings.is_file() + assert (tmp_path / ".bob" / "settings" / "apm-hooks.json").is_file() + assert (tmp_path / ".bob" / "hooks" / "package" / "hooks" / "check.sh").is_file() + + cleanup = HookIntegrator().sync_integration( + None, + tmp_path, + managed_files=set(), + targets=[target], + ) + assert cleanup["errors"] == 0 + assert "hooks" not in json.loads(settings.read_text(encoding="utf-8")) + assert not (tmp_path / ".bob" / "settings" / "apm-hooks.json").exists() + + +def test_bob_dropped_user_target_cleans_nested_settings_path(tmp_path: Path) -> None: + target = KNOWN_TARGETS["bob"].for_scope(user_scope=True) + assert target is not None + + HookIntegrator().integrate_hooks_for_target( + target, + _hook_package(tmp_path), + tmp_path, + user_scope=True, + ) + + settings = tmp_path / ".bob" / "settings" / "settings.json" + assert settings.is_file() + + cleanup = HookIntegrator().reconcile_dropped_targets( + tmp_path, + {"bob"}, + user_scope=True, + ) + + assert cleanup["errors"] == 0 + assert "hooks" not in json.loads(settings.read_text(encoding="utf-8")) + assert not (tmp_path / ".bob" / "settings" / "apm-hooks.json").exists() diff --git a/tests/unit/integration/test_targets_registry_completeness.py b/tests/unit/integration/test_targets_registry_completeness.py index 47d5a5269d..3f36c3f674 100644 --- a/tests/unit/integration/test_targets_registry_completeness.py +++ b/tests/unit/integration/test_targets_registry_completeness.py @@ -17,6 +17,7 @@ from apm_cli.adapters.client.antigravity import AntigravityClientAdapter from apm_cli.adapters.client.base import MCPClientAdapter +from apm_cli.adapters.client.bob import BobClientAdapter from apm_cli.adapters.client.claude import ClaudeClientAdapter from apm_cli.adapters.client.codex import CodexClientAdapter from apm_cli.adapters.client.copilot import CopilotClientAdapter @@ -52,6 +53,7 @@ # can resolve config metadata without sniffing class names. _ADAPTER_CLASSES = ( AntigravityClientAdapter, + BobClientAdapter, CopilotClientAdapter, ClaudeClientAdapter, CursorClientAdapter,