diff --git a/CHANGELOG.md b/CHANGELOG.md index 787365fa7..969829368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm install --target goose` and `apm compile -t goose` add the Goose + agent (Block) as a new **experimental** target, gated behind the `goose` + flag (`apm experimental enable goose`). APM agents compile to Goose + *recipes* at `.goose/recipes/.yaml` (the native `goose run --recipe` + unit; `title`/`description`/`instructions`/`prompt`, plus + `settings.goose_model` when the agent pins a model -- the `prompt` makes + every recipe headless-runnable and is taken from an optional `prompt:` + frontmatter key when present; an optional `parameters:` frontmatter block + passes through verbatim so authors can template `{{ key }}` variables in + the body/prompt), skills deploy to the cross-tool + `.agents/skills//SKILL.md` standard Goose reads natively, MCP servers + write to the YAML `extensions:` block of `~/.config/goose/config.yaml` + (honouring `$XDG_CONFIG_HOME`), and `compile` emits `AGENTS.md` plus a thin + `.goosehints` stub that imports it via Goose's `@./AGENTS.md` preprocessor. + MCP `extensions` are not embedded per-recipe (an APM agent declares no MCP + servers). Like the other frontier targets, `goose` is never auto-detected + and is excluded from `--target all`. (#1833) - `apm audit` now surfaces unmanaged files in governance directories as a single enriched report: each finding states a factual reason (`not tracked in apm.lock.yaml`), a lazy primitive-type tag (`[type: skill|agent|instruction|mcp]`), and a deny-conflict note (`matches deny rule ()`) when the path matches the policy's own `dependencies.deny` / `mcp.deny`. A new `unmanaged_files.exclude` policy key suppresses known harness-managed paths, and a symlink guard prevents following links out of the workspace. This is drift / divergence visibility, not supply-chain-attack prevention. (closes #1775) (#1793) - Azure DevOps is now documented as a first-class marketplace authoring host: a `marketplace.sourceBase` of `https://dev.azure.com/{org}/{project}/_git` composes relative package sources and preserves the `dev.azure.com` host through to the consumer (authenticated with `ADO_APM_PAT`). The end-to-end authoring -> consume path is pinned by a hermetic test. (closes #1010) (#1810) - `apm install --target antigravity` and `apm compile -t antigravity` add diff --git a/docs/src/content/docs/integrations/goose.md b/docs/src/content/docs/integrations/goose.md new file mode 100644 index 000000000..395325b86 --- /dev/null +++ b/docs/src/content/docs/integrations/goose.md @@ -0,0 +1,142 @@ +--- +title: "Goose (Experimental)" +description: "Configure MCP servers and AGENTS.md-backed instructions for the Goose agent by Block." +sidebar: + order: 10 +--- + +:::caution[Frontier preview] +This integration is experimental and off by default. You must enable the `goose` flag before using it. + +```bash +apm experimental enable goose +``` + +Until the flag is enabled, the `goose` target stays inert: it is hidden from active target detection, excluded from `apm compile --all`, and explicit `--target goose` installs exit cleanly with an enable hint instead of deploying anything. +::: + +## What it does + +[Goose](https://goose-docs.ai) (by Block) is an on-machine AI agent with a CLI and desktop app. It has no project-level config directory: instruction context comes from a `.goosehints` file read from the project tree, and MCP servers (which Goose calls **extensions**) live only in a single home config at `~/.config/goose/config.yaml`. + +The `goose` target maps APM onto Goose's native surfaces: + +| APM primitive | Goose surface | Location | +|---------------|---------------|----------| +| agents | Recipe (`goose run --recipe`) | `.goose/recipes/.yaml` (project scope) | +| skills | Skills (agentskills.io `SKILL.md`) | `.agents/skills//` (project) or `~/.agents/skills//` (`--global`) | +| instructions | `.goosehints` (imports `AGENTS.md`) | `.goosehints` + `AGENTS.md` at the project root | +| MCP servers | `extensions:` block | `~/.config/goose/config.yaml` (user scope) | + +Goose's hint files support an `@path` import preprocessor, so APM emits a thin `.goosehints` stub containing `@./AGENTS.md` rather than a second copy of the instruction roll-up -- exactly the pattern used for `GEMINI.md`. Prompts, hooks, and commands are not part of the Goose surface and are skipped for this target. + +## Enable the flag + +```bash +apm experimental enable goose +apm experimental list +apm experimental disable goose +``` + +Use `apm experimental list` to confirm whether `goose` is enabled on the current machine. + +## Install + +```bash +# Project scope: recipes -> .goose/recipes/, skills -> .agents/skills/, +# plus AGENTS.md + .goosehints on compile +apm install --target goose +apm compile -t goose + +# User scope: skills -> ~/.agents/skills/, MCP servers -> ~/.config/goose/config.yaml +apm install --target goose --global +``` + +`apm compile -t goose` emits `AGENTS.md` at the project root (the `goose` target shares the `agents` compile family) plus a `.goosehints` stub that imports it. + +## Recipes (from APM agents) + +Each APM agent (`.apm/agents/.md`) compiles to a Goose **recipe** at `.goose/recipes/.yaml` -- the native packaged-agent unit you run with `goose run --recipe `. The agent's frontmatter and body map directly: + +```yaml +version: 1.0.0 +title: security-review +description: Reviews diffs for OWASP issues. +instructions: |- + You are a security reviewer. Inspect the working diff for... +prompt: Begin and follow the instructions above to complete your task. +settings: + goose_model: gpt-5 # only when the agent pins `model:` +``` + +Every recipe carries a `prompt`, so it runs headless (`goose run --recipe `). By default `prompt` is a generic kickoff; declare a `prompt:` key in the agent's frontmatter to set a task-specific entry point (e.g. `prompt: Analyze {{url}} end-to-end.`), and it is copied verbatim. + +To parameterize a recipe, add a `parameters:` block to the agent's frontmatter (Goose's schema: `key` / `input_type` / `requirement` / `description` / optional `default`) and reference the values with `{{ key }}` in the body or `prompt`. APM passes the block through verbatim and preserves the `{{ }}` placeholders, so `goose run --recipe ` prompts for (or accepts) the parameters: + +```yaml +--- +name: web-summarizer +description: Crawls a URL and summarizes it. +prompt: Crawl {{ url }} and summarize it in 3 bullets. +parameters: + - key: url + input_type: string + requirement: required + description: The URL to crawl +--- +``` + +Recipes load from the current directory or `$GOOSE_RECIPE_PATH`, so point Goose at the generated folder when running outside the project root: + +```bash +export GOOSE_RECIPE_PATH=.goose/recipes +goose run --recipe security-review +``` + +MCP `extensions:` are intentionally **not** embedded in recipes: an APM agent declares no MCP servers (those live at package scope and are written to `config.yaml`, which Goose reads globally at run time). Recipes are project-scope only -- Goose has no canonical user-scope recipe home. + +## Skills + +Skills deploy to the cross-tool `.agents/skills//SKILL.md` standard that Goose reads natively (`.agents/skills/` at project scope, `~/.agents/skills/` with `--global`). No transformation is applied -- the `SKILL.md` is the format APM already produces. + +## $XDG_CONFIG_HOME override + +By default the MCP config is written to `~/.config/goose/config.yaml`. When `XDG_CONFIG_HOME` is set, APM writes to `$XDG_CONFIG_HOME/goose/config.yaml` instead, matching Goose's own resolution: + +```bash +export XDG_CONFIG_HOME="$HOME/.config" +apm install --target goose --global +``` + +## MCP servers + +When the flag is enabled, APM writes MCP servers into the `extensions:` block of `~/.config/goose/config.yaml` using Goose's native per-server schema: + +```yaml +extensions: + my-server: + name: my-server + type: stdio + cmd: npx + args: ["-y", "my-mcp-package"] + envs: + MY_TOKEN: "..." + enabled: true + timeout: 300 +``` + +Remote servers are written with `type: streamable_http` and a `uri` (plus optional `headers`) instead of `cmd`/`args`. APM merges into the existing `extensions:` block and preserves every other top-level key in `config.yaml` (model provider, UI settings, other extensions, and so on). The file is written atomically with `0o600` permissions because it carries literal credentials; a malformed existing `config.yaml` is left untouched rather than overwritten. + +## Instructions + +- Instructions compile to `AGENTS.md`, and a `.goosehints` stub at the project root pulls it in via Goose's `@./AGENTS.md` import. +- Place project-specific context directly in your own `.goosehints` only if you want content outside the APM-managed roll-up; APM regenerates the stub on each compile. + +## Troubleshooting + +- `The 'goose' target requires an experimental flag`: run `apm experimental enable goose`. +- MCP servers not written: confirm the flag is enabled and that you passed `--global` (Goose MCP config is user-scope only). +- `config.yaml is malformed YAML; refusing to overwrite`: fix or remove the file manually, then retry -- APM never discards a config it cannot parse. +- Hints not picked up: ensure `.goosehints` and `AGENTS.md` are at the directory where you launch Goose (the project root). + +See also [IDE and Tool Integration](../ide-tool-integration/) and [apm experimental](../../reference/experimental/). diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index ca9fa9e6b..95ef9fcd5 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -96,11 +96,13 @@ accepted in target lists for symmetry only. Unknown slugs are rejected before any work runs. Experimental targets (`hermes`, `openclaw`, `copilot-cowork`, -`copilot-app`) are also accepted once their flag is enabled via +`copilot-app`, `goose`) are also accepted once their flag is enabled via `apm experimental enable `, but are excluded from `--all`. `apm compile -t hermes` emits `AGENTS.md` (the `hermes` target shares the `agents` compile family). See -[Hermes Agent](../integrations/hermes/). +[Hermes Agent](../integrations/hermes/). `apm compile -t goose` +additionally writes a `.goosehints` stub that imports `AGENTS.md`; see +[Goose](../integrations/goose/). ## Detection cascade diff --git a/docs/src/content/docs/reference/experimental.md b/docs/src/content/docs/reference/experimental.md index a9c303a1f..5a3baedae 100644 --- a/docs/src/content/docs/reference/experimental.md +++ b/docs/src/content/docs/reference/experimental.md @@ -176,6 +176,7 @@ apm experimental reset verbose-version | `registries` | Enable REST-based APM package registries in `apm.yml`. | | `external-scanners` | Ingest third-party SARIF scanners into `apm audit` (`--external`, including SkillSpector LLM mode and allowlisted `--external-args`), the `external..{llm,args}` config keys, and the `security.audit.scanners` policy block. See [External scanners](../integrations/external-scanners/). | | `canvas` | Ship Copilot CLI canvas extensions (`.apm/extensions//extension.mjs`) through APM packages. Dependency-provided canvases additionally require `--trust-canvas-extensions`. See [Canvas extensions](../integrations/canvas/). | +| `goose` | Deploy to the Goose agent (Block): APM agents -> recipes (`.goose/recipes/`), skills -> `.agents/skills/`, MCP servers -> `~/.config/goose/config.yaml`, and a `.goosehints` stub importing `AGENTS.md`. See [Goose integration](../integrations/goose/). | New flags are proposed via [CONTRIBUTING.md](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#how-to-add-an-experimental-feature-flag) and graduate to default when stable. See the contributor recipe for the full lifecycle. See also: [Cowork integration](../integrations/copilot-cowork/). diff --git a/src/apm_cli/adapters/client/_yaml_config.py b/src/apm_cli/adapters/client/_yaml_config.py new file mode 100644 index 000000000..8e994c961 --- /dev/null +++ b/src/apm_cli/adapters/client/_yaml_config.py @@ -0,0 +1,185 @@ +"""Shared base for MCP client adapters backed by a YAML config document. + +Some agent runtimes (Hermes, Goose) store their MCP servers in a single +YAML file with one top-level mapping of server-name -> config, rather than +the JSON ``mcpServers`` schema used by Claude/Copilot. This base captures +the boilerplate common to those adapters -- safe round-trip load, sibling +preservation, atomic ``0o600`` write, malformed-file refusal, and the +``configure_mcp_server`` registry-fetch flow -- so each concrete adapter +only declares: + + * :attr:`mcp_servers_key` -- top-level key holding the servers mapping + * :attr:`target_name` -- canonical target id + * :attr:`_display_name` -- human-facing name used in messages + * :meth:`_config_path` -- the YAML file location + * :meth:`_to_native_format` -- per-server schema transform + +Registry formatting (package/remote resolution, env-var handling) is +inherited from :class:`CopilotClientAdapter`; the per-runtime +:meth:`_to_native_format` hook converts each Copilot-format entry to the +runtime's on-disk shape. +""" + +from __future__ import annotations + +import contextlib +import os +from pathlib import Path + +import yaml + +from ...utils.atomic_io import atomic_write_text +from ...utils.console import _rich_error, _rich_success +from ...utils.yaml_io import load_yaml, yaml_to_str +from .copilot import CopilotClientAdapter + +# Credential-bearing config file mode: owner read/write only. These config +# files hold literal MCP env values plus native model-provider keys, so they +# must never be group/world-readable (parity with claude/codex/gemini/cursor). +_CONFIG_FILE_MODE = 0o600 + + +class _MalformedYamlConfig(Exception): + """Raised when the YAML config exists but is not a mapping. + + Signals write paths to refuse the overwrite so a user's native runtime + config (model-provider keys, unrelated servers) is never discarded. + """ + + +class YamlMcpClientAdapter(CopilotClientAdapter): + """Base for MCP adapters whose on-disk config is a YAML servers mapping.""" + + supports_user_scope: bool = True + + # These YAML configs do NOT support runtime env-var substitution; the + # value in the env block must be a literal string, so install-time + # resolution is kept (see #1152 supply-chain analysis). + _supports_runtime_env_substitution: bool = False + + # Human-facing name used in console messages; subclasses may override to + # preserve a specific casing (e.g. "Hermes") distinct from ``target_name``. + _display_name: str = "" + + def _config_path(self) -> Path: + """Return the YAML config file path. Must be overridden.""" + raise NotImplementedError + + def _to_native_format(self, name: str, copilot_entry: dict, *, enabled: bool = True) -> dict: + """Convert a Copilot-format entry to the runtime's shape. Override.""" + raise NotImplementedError + + @property + def _label(self) -> str: + """Human-facing runtime name for messages.""" + return self._display_name or self.target_name + + def get_config_path(self): + """Path to the runtime's YAML config file.""" + return str(self._config_path()) + + def _load_document(self) -> dict: + """Load the full config document (preserving siblings). + + Returns ``{}`` when the file is absent or empty. Raises + :class:`_MalformedYamlConfig` when the file exists but is not a YAML + mapping (parse error or non-dict root) so write paths can refuse to + overwrite and silently discard the user's native config. + """ + path = self._config_path() + if not path.is_file(): + return {} + try: + data = load_yaml(path) + except (OSError, yaml.YAMLError) as exc: + raise _MalformedYamlConfig(str(path)) from exc + if data is None: + return {} + if not isinstance(data, dict): + raise _MalformedYamlConfig(str(path)) + return data + + def get_current_config(self): + """Return ``{: {...}}`` for the on-disk config.""" + try: + data = self._load_document() + except _MalformedYamlConfig: + return {self.mcp_servers_key: {}} + servers = data.get(self.mcp_servers_key) + return {self.mcp_servers_key: dict(servers) if isinstance(servers, dict) else {}} + + def update_config(self, config_updates, enabled=True): + """Merge *config_updates* into the servers mapping. + + Entries are normalized via :meth:`_to_native_format`. Per-server + entries are replaced on key conflict; unrelated servers and all other + top-level config keys are preserved. The file is written atomically + with ``0o600`` permissions so the credential-bearing config is never + left group/world-readable. A malformed existing file is left + untouched (returns ``False``) rather than overwritten. + """ + path = self._config_path() + try: + data = self._load_document() + except _MalformedYamlConfig: + _rich_error( + f"{path} is malformed YAML; refusing to overwrite. " + "Fix or remove the file manually, then retry." + ) + return False + try: + servers = data.get(self.mcp_servers_key) + if not isinstance(servers, dict): + servers = {} + for name, cfg in config_updates.items(): + servers[name] = self._to_native_format(name, cfg, enabled=enabled) + data[self.mcp_servers_key] = servers + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text(path, yaml_to_str(data), new_file_mode=_CONFIG_FILE_MODE) + # Tighten perms even when the file pre-existed with a looser mode + # (atomic_write_text only applies new_file_mode on first create). + with contextlib.suppress(OSError, NotImplementedError): + os.chmod(path, _CONFIG_FILE_MODE) + return True + except OSError: + return False + except (TypeError, ValueError): + # A per-server transform (_to_native_format) rejected malformed + # registry/config data. Fail closed like any other write failure + # rather than crashing the install. Do not interpolate the + # exception -- inputs may carry embedded credentials. + _rich_error(f"Could not serialize MCP config for {self._label}; skipping write.") + return False + + def configure_mcp_server( + self, + server_url, + server_name=None, + enabled=True, + env_overrides=None, + server_info_cache=None, + runtime_vars=None, + ): + if not server_url: + _rich_error("server_url cannot be empty") + return False + + try: + server_info = self._fetch_server_info(server_url, server_info_cache) + if server_info is None: + return False + + config_key = self._determine_config_key(server_url, server_name) + server_config = self._format_server_config(server_info, env_overrides, runtime_vars) + ok = self.update_config({config_key: server_config}, enabled=enabled) + if not ok: + _rich_error(f"Failed to write MCP config for '{config_key}' to {self._label}") + return False + + _rich_success(f"Successfully configured MCP server '{config_key}' for {self._label}") + return True + except Exception: + # Do not interpolate the exception message: registry URLs and + # other inputs may carry embedded credentials. + _rich_error("Error configuring MCP server") + return False diff --git a/src/apm_cli/adapters/client/goose.py b/src/apm_cli/adapters/client/goose.py new file mode 100644 index 000000000..72585c339 --- /dev/null +++ b/src/apm_cli/adapters/client/goose.py @@ -0,0 +1,103 @@ +"""Goose (Block) MCP client adapter. + +Goose reads its MCP servers from a YAML ``extensions:`` block in +``~/.config/goose/config.yaml`` (honouring ``$XDG_CONFIG_HOME``). Goose +calls MCP servers "extensions" and uses a schema distinct from the JSON +``mcpServers`` used by Claude/Copilot: + +.. code-block:: yaml + + extensions: + server-name: + name: server-name + type: stdio + cmd: npx + args: ["-y", "@modelcontextprotocol/server-foo"] + envs: { KEY: value } + enabled: true + timeout: 300 + +Per-server shape: + * stdio -> ``type: stdio`` / ``cmd`` / ``args`` / ``envs`` + * remote -> ``type: streamable_http`` / ``uri`` / ``headers`` + +Scope: Goose has a single home-directory config, so MCP writes are always +user-scope -- ``config.yaml`` is the same file regardless of whether the +install was triggered at project or user scope (Goose reads only +``.goosehints`` from the project tree, never a project ``config.yaml``). + +Shared YAML round-trip / atomic-write / malformed-file handling lives in +:class:`YamlMcpClientAdapter`; this adapter only declares the config path +and the Goose-specific per-server schema transform. + +Ref: https://goose-docs.ai/docs/getting-started/using-extensions/ +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from ._yaml_config import YamlMcpClientAdapter + +# Goose's default per-extension tool-response timeout (seconds). +_DEFAULT_TIMEOUT = 300 + + +class GooseClientAdapter(YamlMcpClientAdapter): + """MCP configuration for the Goose agent (YAML ``extensions`` schema).""" + + target_name: str = "goose" + _display_name: str = "Goose" + mcp_servers_key: str = "extensions" + + def _config_path(self) -> Path: + """Resolve ``/goose/config.yaml`` honouring ``$XDG_CONFIG_HOME``.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "goose" / "config.yaml" + + def _to_native_format(self, name: str, copilot_entry: dict, *, enabled: bool = True) -> dict: + """Convert a Copilot-format server entry to Goose's on-disk shape. + + Drops Copilot-CLI-only fields (``type: "local"``, default + ``tools: ["*"]``, empty ``id``), renames ``command``/``env`` to Goose's + ``cmd``/``envs``, stamps an explicit ``name``/``enabled``/``timeout``, + and maps remote endpoints to Goose's ``streamable_http`` transport. + Each field is emitted only when it has the expected type (string for + ``uri``/``cmd``, dict for ``headers``/``envs``, list for ``args``), so + a malformed entry never serializes a bad value into Goose's config. + A non-mapping entry fails closed (raises ``ValueError``) -- the base + adapter turns that into a skipped write rather than a crash. + """ + if not isinstance(copilot_entry, dict): + raise ValueError(f"MCP server config for {name!r} is not a mapping") + + url = copilot_entry.get("url") + t = copilot_entry.get("type") + is_remote = bool(url) or t in ("http", "sse", "streamable-http") + + out: dict = {"name": name} + if is_remote: + # Copilot collapses sse/streamable-http to "http"; Goose has no + # bare "http" transport, so the modern streamable_http is used. + out["type"] = "streamable_http" + if isinstance(url, str) and url: + out["uri"] = url + headers = copilot_entry.get("headers") + if isinstance(headers, dict) and headers: + out["headers"] = dict(headers) + else: + out["type"] = "stdio" + command = copilot_entry.get("command") + if isinstance(command, str) and command: + out["cmd"] = command + args = copilot_entry.get("args") + if isinstance(args, list) and args: + out["args"] = list(args) + envs = copilot_entry.get("env") + if isinstance(envs, dict) and envs: + out["envs"] = dict(envs) + out["enabled"] = enabled + out["timeout"] = _DEFAULT_TIMEOUT + return out diff --git a/src/apm_cli/adapters/client/hermes.py b/src/apm_cli/adapters/client/hermes.py index 464249b42..03b1406bf 100644 --- a/src/apm_cli/adapters/client/hermes.py +++ b/src/apm_cli/adapters/client/hermes.py @@ -15,60 +15,39 @@ * stdio -> ``command`` / ``args`` / ``env`` (+ ``enabled``) * http -> ``url`` / ``headers`` (+ ``enabled``) -YAML serialization goes through ``utils.yaml_io`` (lint forbids raw -``yaml.dump``); the document is written atomically with ``0o600`` perms via -``utils.atomic_io`` because ``config.yaml`` carries literal credentials. +Shared YAML round-trip / atomic-write / malformed-file handling lives in +:class:`YamlMcpClientAdapter`; this adapter only declares the config path +and the Hermes-specific per-server schema transform. """ from __future__ import annotations -import contextlib -import os from pathlib import Path -import yaml +from ._yaml_config import YamlMcpClientAdapter -from ...utils.atomic_io import atomic_write_text -from ...utils.console import _rich_error, _rich_success -from ...utils.yaml_io import load_yaml, yaml_to_str -from .copilot import CopilotClientAdapter -# Credential-bearing config file mode: owner read/write only. Hermes' config.yaml -# holds literal MCP env values plus native model-provider keys / messaging tokens, -# so it must never be group/world-readable (parity with claude/codex/gemini/cursor). -_CONFIG_FILE_MODE = 0o600 - - -class _MalformedHermesConfig(Exception): - """Raised when ``config.yaml`` exists but is not a YAML mapping. - - Signals write paths to refuse the overwrite so a user's native Hermes - credentials (model-provider keys, Telegram tokens) are never discarded. - """ - - -class HermesClientAdapter(CopilotClientAdapter): +class HermesClientAdapter(YamlMcpClientAdapter): """MCP configuration for the Hermes agent (YAML ``mcp_servers`` schema). Registry formatting reuses :class:`CopilotClientAdapter`, then entries are converted to Hermes' on-disk shape via :meth:`_to_hermes_format`. """ - supports_user_scope: bool = True target_name: str = "hermes" + _display_name: str = "Hermes" mcp_servers_key: str = "mcp_servers" - # Hermes' config.yaml does NOT support runtime env-var substitution; the - # value in ``env`` must be a literal string, so install-time resolution - # is kept (mirrors Claude -- see #1152 supply-chain analysis). - _supports_runtime_env_substitution: bool = False - def _config_path(self) -> Path: """Resolve ``/config.yaml`` honouring ``$HERMES_HOME``.""" from ...integration.targets import resolve_hermes_root return resolve_hermes_root() / "config.yaml" + def _to_native_format(self, name: str, copilot_entry: dict, *, enabled: bool = True) -> dict: + """Adapt the shared per-server hook to Hermes' static transform.""" + return self._to_hermes_format(copilot_entry, enabled=enabled) + @staticmethod def _to_hermes_format(copilot_entry: dict, *, enabled: bool = True) -> dict: """Convert a Copilot-format server entry to Hermes' on-disk shape. @@ -105,106 +84,3 @@ def _to_hermes_format(copilot_entry: dict, *, enabled: bool = True) -> dict: out["env"] = dict(env) out["enabled"] = enabled return out - - def get_config_path(self): - """Path to the Hermes config file (``/config.yaml``).""" - return str(self._config_path()) - - def _load_document(self) -> dict: - """Load the full ``config.yaml`` document (preserving siblings). - - Returns ``{}`` when the file is absent or empty. Raises - :class:`_MalformedHermesConfig` when the file exists but is not a YAML - mapping (parse error or non-dict root) so write paths can refuse to - overwrite and silently discard the user's native Hermes credentials. - """ - path = self._config_path() - if not path.is_file(): - return {} - try: - data = load_yaml(path) - except (OSError, yaml.YAMLError) as exc: - raise _MalformedHermesConfig(str(path)) from exc - if data is None: - return {} - if not isinstance(data, dict): - raise _MalformedHermesConfig(str(path)) - return data - - def get_current_config(self): - """Return ``{"mcp_servers": {...}}`` for the on-disk config.""" - try: - data = self._load_document() - except _MalformedHermesConfig: - return {self.mcp_servers_key: {}} - servers = data.get(self.mcp_servers_key) - return {self.mcp_servers_key: dict(servers) if isinstance(servers, dict) else {}} - - def update_config(self, config_updates, enabled=True): - """Merge *config_updates* into the ``mcp_servers:`` block. - - Entries are normalized to Hermes' shape. Per-server entries are - replaced on key conflict; unrelated servers and all other top-level - config keys are preserved. The file is written atomically with - ``0o600`` permissions so the credential-bearing config is never left - group/world-readable. A malformed existing ``config.yaml`` is left - untouched (returns ``False``) rather than overwritten. - """ - path = self._config_path() - try: - data = self._load_document() - except _MalformedHermesConfig: - _rich_error( - f"{path} is malformed YAML; refusing to overwrite. " - "Fix or remove the file manually, then retry." - ) - return False - try: - servers = data.get(self.mcp_servers_key) - if not isinstance(servers, dict): - servers = {} - for name, cfg in config_updates.items(): - servers[name] = self._to_hermes_format(cfg, enabled=enabled) - data[self.mcp_servers_key] = servers - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_text(path, yaml_to_str(data), new_file_mode=_CONFIG_FILE_MODE) - # Tighten perms even when the file pre-existed with a looser mode - # (atomic_write_text only applies new_file_mode on first create). - with contextlib.suppress(OSError, NotImplementedError): - os.chmod(path, _CONFIG_FILE_MODE) - return True - except OSError: - return False - - def configure_mcp_server( - self, - server_url, - server_name=None, - enabled=True, - env_overrides=None, - server_info_cache=None, - runtime_vars=None, - ): - if not server_url: - _rich_error("server_url cannot be empty") - return False - - try: - server_info = self._fetch_server_info(server_url, server_info_cache) - if server_info is None: - return False - - config_key = self._determine_config_key(server_url, server_name) - server_config = self._format_server_config(server_info, env_overrides, runtime_vars) - ok = self.update_config({config_key: server_config}, enabled=enabled) - if not ok: - _rich_error(f"Failed to write MCP config for '{config_key}' to Hermes") - return False - - _rich_success(f"Successfully configured MCP server '{config_key}' for Hermes") - return True - except Exception: - # Do not interpolate the exception message: registry URLs and - # other inputs may carry embedded credentials. - _rich_error("Error configuring MCP server") - return False diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 3e5aa8f9c..a57087dc1 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -944,7 +944,7 @@ def _handle_mcp_install( "target", type=TargetParamType(), default=None, - help="Target harness(es) to deploy to. Comma-separated for multiple: --target claude,cursor. Repeating the flag (e.g. '-t a -t b') is NOT supported -- only the last value wins; use commas. Highest-priority entry in the resolution chain (--target > apm.yml targets: > auto-detect). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'antigravity' (alias 'agy') deploys to .agents/ (AGENTS.md + rules + skills + hooks.json + mcp_config.json) and is explicit-only -- not part of 'all' or auto-detection. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills and antigravity); combine with 'agent-skills' or 'antigravity' to add them. 'copilot-cowork' is also accepted when the copilot-cowork experimental flag is enabled (run 'apm experimental enable copilot-cowork'). 'copilot-app' is also accepted when the copilot-app experimental flag is enabled (run 'apm experimental enable copilot-app'). Note: '--target all' on 'apm compile' is deprecated; use 'apm compile --all' instead.", + help="Target harness(es) to deploy to. Comma-separated for multiple: --target claude,cursor. Repeating the flag (e.g. '-t a -t b') is NOT supported -- only the last value wins; use commas. Highest-priority entry in the resolution chain (--target > apm.yml targets: > auto-detect). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'antigravity' (alias 'agy') deploys to .agents/ (AGENTS.md + rules + skills + hooks.json + mcp_config.json) and is explicit-only -- not part of 'all' or auto-detection. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills and antigravity); combine with 'agent-skills' or 'antigravity' to add them. 'copilot-cowork' is also accepted when the copilot-cowork experimental flag is enabled (run 'apm experimental enable copilot-cowork'). 'copilot-app' is also accepted when the copilot-app experimental flag is enabled (run 'apm experimental enable copilot-app'). 'goose' is also accepted when the goose experimental flag is enabled (run 'apm experimental enable goose'); use '--target goose --global' to write MCP servers as Goose extensions in ~/.config/goose/config.yaml (a .goosehints stub importing AGENTS.md is generated at the project root). Note: '--target all' on 'apm compile' is deprecated; use 'apm compile --all' instead.", ) @click.option( "--allow-insecure", diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 50f914d70..4fa8eff9e 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -18,6 +18,7 @@ should_compile_claude_md, should_compile_copilot_instructions_md, should_compile_gemini_md, + should_compile_goose_hints, ) from ..primitives.discovery import discover_primitives from ..primitives.models import PrimitiveCollection @@ -51,6 +52,7 @@ "antigravity", "windsurf", "kiro", + "goose", "all", "minimal", ) + _VSCODE_TARGET_ALIASES @@ -399,6 +401,9 @@ def compile( if should_compile_gemini_md(routing_target): results.append(self._compile_gemini_md(config, primitives)) + if should_compile_goose_hints(routing_target): + results.append(self._compile_goose_hints(config, primitives)) + # Some targets (e.g. cursor, agent-skills) use the data-driven # integration layer and don't need compilation. if not results: @@ -1041,66 +1046,100 @@ def _compile_claude_md( has_critical_security=critical_security_found, ) - def _compile_gemini_md( - self, config: CompilationConfig, primitives: PrimitiveCollection + def _compile_import_stub( + self, + config: CompilationConfig, + primitives: PrimitiveCollection, + formatter, + label: str, + stat_key: str, ) -> CompilationResult: - """Compile GEMINI.md stub that imports AGENTS.md. + """Compile a thin "@import AGENTS.md" stub via *formatter*. - Gemini CLI supports ``@./path`` import syntax, so GEMINI.md is a - thin wrapper that pulls in AGENTS.md at load time. The actual - instruction roll-up is handled by the AGENTS.md pipeline (which - is always compiled alongside via ``should_compile_agents_md``). + Shared by the GEMINI.md and ``.goosehints`` paths: both emit a + single wrapper file at the project root that pulls in AGENTS.md + through the target CLI's ``@path`` preprocessor. The actual + instruction roll-up is handled by the AGENTS.md pipeline (always + compiled alongside via ``should_compile_agents_md``). Args: config: Compilation configuration. - primitives: Primitives to compile. + formatter: A formatter exposing ``format_distributed`` and a + ``content_map`` of path -> rendered stub content. + label: Human-readable output name (e.g. ``"GEMINI.md"``). + stat_key: Stats key under which the written-file count is stored. Returns: - CompilationResult for the GEMINI.md compilation. + CompilationResult for the stub compilation. """ - from .gemini_formatter import GeminiFormatter - - gemini_formatter = GeminiFormatter(str(self.base_dir)) - gemini_result = gemini_formatter.format_distributed(primitives) + result = formatter.format_distributed(primitives) - all_warnings = self.warnings + gemini_result.warnings - all_errors = self.errors + gemini_result.errors + all_warnings = self.warnings + result.warnings + all_errors = self.errors + result.errors if config.dry_run: return CompilationResult( success=len(all_errors) == 0, - output_path="Preview mode - GEMINI.md", - content="GEMINI.md Preview: Would generate stub importing AGENTS.md", + output_path=f"Preview mode - {label}", + content=f"{label} Preview: Would generate stub importing AGENTS.md", warnings=all_warnings, errors=all_errors, - stats=gemini_result.stats, + stats=result.stats, ) files_written = 0 from .output_writer import CompiledOutputWriter writer = CompiledOutputWriter() - for gemini_path, content in gemini_result.content_map.items(): + for path, content in result.content_map.items(): try: - writer.write(gemini_path, content) + writer.write(path, content) files_written += 1 except OSError as e: - all_errors.append(f"Failed to write {gemini_path}: {e!s}") + all_errors.append(f"Failed to write {path}: {e!s}") - stats = gemini_result.stats.copy() - stats["gemini_files_written"] = files_written + stats = result.stats.copy() + stats[stat_key] = files_written - self._log("progress", "Generated GEMINI.md (imports AGENTS.md)") + self._log("progress", f"Generated {label} (imports AGENTS.md)") return CompilationResult( success=len(all_errors) == 0, - output_path=f"GEMINI.md: {files_written} files", - content=f"Generated {files_written} GEMINI.md stub importing AGENTS.md", + output_path=f"{label}: {files_written} files", + content=f"Generated {files_written} {label} stub importing AGENTS.md", warnings=all_warnings, errors=all_errors, stats=stats, ) + def _compile_gemini_md( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: + """Compile the GEMINI.md stub that imports AGENTS.md (Gemini CLI).""" + from .gemini_formatter import GeminiFormatter + + return self._compile_import_stub( + config, + primitives, + GeminiFormatter(str(self.base_dir)), + "GEMINI.md", + "gemini_files_written", + ) + + def _compile_goose_hints( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: + """Compile the ``.goosehints`` stub that imports AGENTS.md (Goose).""" + from .goose_formatter import GooseFormatter + + return self._compile_import_stub( + config, + primitives, + GooseFormatter(str(self.base_dir)), + ".goosehints", + "goose_files_written", + ) + def _merge_results(self, results: list[CompilationResult]) -> CompilationResult: """Merge multiple compilation results into a single result. diff --git a/src/apm_cli/compilation/gemini_formatter.py b/src/apm_cli/compilation/gemini_formatter.py index 76197b0a3..37e023793 100644 --- a/src/apm_cli/compilation/gemini_formatter.py +++ b/src/apm_cli/compilation/gemini_formatter.py @@ -49,6 +49,14 @@ class GeminiFormatter: this formatter only creates the thin import wrapper. """ + # Stub shape -- overridable by subclasses that emit the same + # "@import AGENTS.md" wrapper under a different filename (e.g. Goose's + # ``.goosehints``). Keeping these as class attributes lets such + # subclasses reuse ``format_distributed`` / ``_generate_stub`` verbatim. + _stub_filename: str = "GEMINI.md" + _stub_title: str = "# GEMINI.md" + _import_line: str = "@./AGENTS.md" + def __init__(self, base_dir: str = ".") -> None: try: self.base_dir = Path(base_dir).resolve() @@ -74,7 +82,7 @@ def format_distributed( self.errors.clear() try: - gemini_path = self.base_dir / "GEMINI.md" + gemini_path = self.base_dir / self._stub_filename content = self._generate_stub() placement = GeminiPlacement( @@ -107,14 +115,14 @@ def format_distributed( ) def _generate_stub(self) -> str: - """Generate the GEMINI.md stub content.""" + """Generate the import-stub content (title + ``@import`` of AGENTS.md).""" lines = [ - "# GEMINI.md", + self._stub_title, "", BUILD_ID_PLACEHOLDER, f"", "", - "@./AGENTS.md", + self._import_line, "", ] return "\n".join(lines) diff --git a/src/apm_cli/compilation/goose_formatter.py b/src/apm_cli/compilation/goose_formatter.py new file mode 100644 index 000000000..9eeaf78ca --- /dev/null +++ b/src/apm_cli/compilation/goose_formatter.py @@ -0,0 +1,24 @@ +""".goosehints formatter for Goose (Block) integration. + +Generates a lightweight ``.goosehints`` stub at the project root that imports +AGENTS.md via Goose's ``@path`` preprocessor (Goose resolves ``@./AGENTS.md`` +at load time, up to an import depth of 3). The instruction roll-up itself is +produced by the AGENTS.md pipeline, so the stub is just the thin import +wrapper -- identical mechanics to the GEMINI.md stub, hence the reuse of +:class:`GeminiFormatter`'s logic via overridable class attributes. + +Ref: https://goose-docs.ai/docs/guides/context-engineering/using-goosehints/ +""" + +from .gemini_formatter import GeminiFormatter + + +class GooseFormatter(GeminiFormatter): + """Formatter for the ``.goosehints`` import stub. + + Reuses :class:`GeminiFormatter` wholesale and only swaps the output + filename and title; the ``@./AGENTS.md`` import line is inherited. + """ + + _stub_filename = ".goosehints" + _stub_title = "# Goose hints" diff --git a/src/apm_cli/core/experimental.py b/src/apm_cli/core/experimental.py index d1463c3cc..e30d2a07b 100644 --- a/src/apm_cli/core/experimental.py +++ b/src/apm_cli/core/experimental.py @@ -139,6 +139,17 @@ class ExperimentalFlag: "home at ~/.hermes/ (skills + MCP servers in config.yaml)." ), ), + "goose": ExperimentalFlag( + name="goose", + description="Configure MCP servers for the Goose agent (Block).", + default=False, + hint=( + "Use '--target goose --global' to write MCP servers as Goose " + "extensions in ~/.config/goose/config.yaml. Goose has no " + "project-level config; instruction context is provided via " + ".goosehints at the project root." + ), + ), } diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 25f14029c..19524fcae 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -61,6 +61,7 @@ def agents_alias_was_detected() -> bool: "windsurf", "kiro", "agent-skills", + "goose", "all", "minimal", ] @@ -146,6 +147,8 @@ def detect_target( # noqa: PLR0911 return "kiro", "explicit --target flag" elif explicit_target == "agent-skills": return "agent-skills", "explicit --target flag" + elif explicit_target == "goose": + return "goose", "explicit --target flag" elif explicit_target == "all": return "all", "explicit --target flag" @@ -171,6 +174,8 @@ def detect_target( # noqa: PLR0911 return "kiro", "apm.yml target" elif config_target == "agent-skills": return "agent-skills", "apm.yml target" + elif config_target == "goose": + return "goose", "apm.yml target" elif config_target == "all": return "all", "apm.yml target" @@ -247,6 +252,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: "windsurf", "kiro", "hermes", + "goose", "all", "minimal", ) @@ -282,6 +288,26 @@ def should_compile_gemini_md(target: CompileTargetType) -> bool: return target in ("gemini", "all") +def should_compile_goose_hints(target: CompileTargetType) -> bool: + """Check if a ``.goosehints`` stub should be compiled. + + Goose belongs to the ``agents`` compile family (it consumes AGENTS.md), + so a multi-target frozenset only carries the ``"agents"`` token and the + Goose-specific stub is intentionally NOT emitted for generic agents-family + compiles. The stub is generated only for an explicit single ``goose`` + target -- Goose is experimental and explicit-only, so it never appears in + the ``"all"`` expansion. + + Args: + target: The detected or configured target. May be a string or a + frozenset of compiler families for multi-target lists. + + Returns: + bool: True if ``.goosehints`` should be generated + """ + return target == "goose" + + def should_compile_copilot_instructions_md(target: CompileTargetType) -> bool: """Check if .github/copilot-instructions.md should be compiled. @@ -361,6 +387,7 @@ def get_target_description(target: UserTargetType) -> str: "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/skills/ + config.yaml MCP (--global) -- experimental", + "goose": "MCP servers as extensions in ~/.config/goose/config.yaml (--global); .goosehints at project root -- experimental", "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .agents/", "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } @@ -382,7 +409,7 @@ def get_target_description(target: UserTargetType) -> str: #: ``integration/targets.py``. They are NOT included in the #: ``parse_target_arg("all")`` expansion -- explicit opt-in only. EXPERIMENTAL_TARGETS: frozenset[str] = frozenset( - {"copilot-cowork", "copilot-app", "openclaw", "hermes"} + {"copilot-cowork", "copilot-app", "openclaw", "hermes", "goose"} ) #: Stable targets excluded from "all" expansion (cross-client deploy @@ -752,12 +779,23 @@ def detect_signals(project_root: Path) -> list[Signal]: def _validate_canonical_v2(tokens: list[str]) -> None: - """Validate every token is a known canonical target.""" + """Validate every ``--target`` token is a known target. + + Accepts the same set the ``--target`` flag does: GA targets plus + experimental and explicit-only targets (antigravity, hermes, goose, ...). + Restricting this to the GA-only ``CANONICAL_TARGETS`` previously rejected + ``--target `` during the MCP-install phase + (``resolve_targets``), so e.g. ``apm install --target goose`` crashed + whenever the package declared an ``mcp:`` dependency. The error still + suggests only GA targets (experimental names are intentionally not + advertised). + """ from apm_cli.core.apm_yml import CANONICAL_TARGETS from apm_cli.core.errors import UnknownTargetError, render_unknown_target_error + valid = CANONICAL_TARGETS | EXPERIMENTAL_TARGETS | EXPLICIT_ONLY_TARGETS for token in tokens: - if token not in CANONICAL_TARGETS: + if token not in valid: raise UnknownTargetError(render_unknown_target_error(token, sorted(CANONICAL_TARGETS))) diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index e11e2a6d4..a4b05c494 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -8,6 +8,7 @@ from .adapters.client.copilot import CopilotClientAdapter from .adapters.client.cursor import CursorClientAdapter from .adapters.client.gemini import GeminiClientAdapter +from .adapters.client.goose import GooseClientAdapter from .adapters.client.hermes import HermesClientAdapter from .adapters.client.intellij import IntelliJClientAdapter from .adapters.client.kiro import KiroClientAdapter @@ -29,6 +30,7 @@ "codex": CodexClientAdapter, "cursor": CursorClientAdapter, "gemini": GeminiClientAdapter, + "goose": GooseClientAdapter, "intellij": IntelliJClientAdapter, "kiro": KiroClientAdapter, "opencode": OpenCodeClientAdapter, diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index bd41fabe7..da7911823 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -168,6 +168,9 @@ def integrate_agents_for_target( if mapping.format_id == "codex_agent": self._write_codex_agent(source_file, target_path) links_resolved = 0 + elif mapping.format_id == "goose_recipe": + self._write_goose_recipe(source_file, target_path) + links_resolved = 0 else: if mapping.format_id == "opencode_agent": self._warn_opencode_frontmatter( @@ -299,15 +302,17 @@ def _warn_opencode_frontmatter( ) @staticmethod - def _write_codex_agent(source: Path, target: Path) -> None: - """Transform an ``.agent.md`` file to Codex ``.toml`` format. - - Parses YAML frontmatter for ``name`` and ``description``, uses - the markdown body as ``developer_instructions``. + def _parse_agent_frontmatter(source: Path) -> tuple[str, str, str, dict]: + """Return ``(name, description, body, frontmatter)`` for an agent file. + + Shared by the per-target agent transformers (Codex TOML, Goose + recipe) so the frontmatter-parsing preamble lives in one place. + ``name`` falls back to the file stem (``.agent`` suffix stripped); + ``description`` defaults to empty; ``body`` is the markdown after the + frontmatter; ``frontmatter`` is the parsed mapping (``{}`` on error). """ if source.is_symlink(): raise ValueError(f"Refusing to read symlink source: {source}") - import toml as _toml content = source.read_text(encoding="utf-8") @@ -316,16 +321,39 @@ def _write_codex_agent(source: Path, target: Path) -> None: name = name[: -len(".agent")] description = "" body = content + fm: dict = {} fm_match = AgentIntegrator._FRONTMATTER_RE.match(content) if fm_match: body = content[fm_match.end() :] try: fm = yaml.safe_load(fm_match.group(1)) or {} - name = fm.get("name", name) - description = fm.get("description", description) + if not isinstance(fm, dict): + fm = {} + # ``name``/``description`` flow into generated files (recipe + # title, Codex name). Only honour string values -- a stray + # ``name: [a, b]`` must not propagate a list into the output. + fm_name = fm.get("name") + if isinstance(fm_name, str) and fm_name.strip(): + name = fm_name + fm_description = fm.get("description") + if isinstance(fm_description, str): + description = fm_description except Exception: - pass + fm = {} + + return name, description, body, fm + + @staticmethod + def _write_codex_agent(source: Path, target: Path) -> None: + """Transform an ``.agent.md`` file to Codex ``.toml`` format. + + Parses YAML frontmatter for ``name`` and ``description``, uses + the markdown body as ``developer_instructions``. + """ + import toml as _toml + + name, description, body, _fm = AgentIntegrator._parse_agent_frontmatter(source) doc = { "name": name, @@ -334,6 +362,61 @@ def _write_codex_agent(source: Path, target: Path) -> None: } target.write_text(_toml.dumps(doc), encoding="utf-8") + # Default ``prompt`` for recipes whose source agent declares none. Goose + # requires a ``prompt`` to run a recipe headless (``goose run --recipe``); + # this generic kickoff makes every generated recipe headless-ready, and + # authors override it with a ``prompt:`` frontmatter key when they want a + # task-specific entry point. + _DEFAULT_RECIPE_PROMPT = "Begin and follow the instructions above to complete your task." + + @staticmethod + def _write_goose_recipe(source: Path, target: Path) -> None: + """Transform an ``.agent.md`` file to a Goose recipe ``.yaml``. + + Emits a valid, headless-runnable recipe (``goose run --recipe``) with + ``version``/``title``/``description``/``instructions``/``prompt``; a + pinned ``model`` becomes ``settings.goose_model``. ``prompt`` is taken + from a ``prompt:`` frontmatter key when present, else defaults to + :data:`_DEFAULT_RECIPE_PROMPT` so the recipe always runs headless. + + A ``parameters:`` frontmatter list is passed through verbatim to the + recipe's ``parameters`` block, so authors can template ``{{ key }}`` + variables into the body/prompt (Goose's Jinja substitution). ``{{ }}`` + placeholders in the markdown are preserved untouched. + + MCP ``extensions`` are intentionally not embedded -- an APM agent + declares no MCP servers, which live at package scope in + ``~/.config/goose/config.yaml``. + """ + from ..utils.yaml_io import yaml_to_str + + name, description, body, fm = AgentIntegrator._parse_agent_frontmatter(source) + + prompt = fm.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + prompt = AgentIntegrator._DEFAULT_RECIPE_PROMPT + + recipe: dict = { + "version": "1.0.0", + "title": name, + "description": description, + "instructions": body.strip(), + "prompt": prompt.strip(), + } + # Author-declared recipe parameters (key/input_type/requirement/ + # description/default/options) pass through verbatim; Goose validates + # the shape at load time. + parameters = fm.get("parameters") + if isinstance(parameters, list) and parameters: + recipe["parameters"] = parameters + model = fm.get("model") + if isinstance(model, str) and model.strip(): + recipe["settings"] = {"goose_model": model.strip()} + + # multiline_block renders ``instructions``/``prompt`` as readable + # literal blocks (``key: |``) rather than quoted flow scalars. + target.write_text(yaml_to_str(recipe, multiline_block=True), encoding="utf-8") + # DEPRECATED: use integrate_agents_for_target(KNOWN_TARGETS["copilot"], ...) instead. def integrate_package_agents( self, diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 4ddb4cfe8..aba9f4886 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -203,6 +203,28 @@ def _hermes_runtime_opted_in() -> bool: return False +def _goose_runtime_opted_in() -> bool: + """Return ``True`` when Goose MCP writes are opted into. + + Gate: the ``goose`` experimental flag is enabled AND Goose is actually + present on the host (its config dir ``~/.config/goose`` exists, honouring + ``$XDG_CONFIG_HOME``, or the ``goose`` binary is on PATH). Goose stores + MCP servers ("extensions") only in that user-scope config, so it is + discovered regardless of install scope. Any import/path error is treated + as "not opted in". + """ + try: + from apm_cli.adapters.client.goose import GooseClientAdapter + from apm_cli.core.experimental import is_enabled + + if not is_enabled("goose"): + return False + config_dir = Path(GooseClientAdapter().get_config_path()).parent + return config_dir.is_dir() or find_runtime_binary("goose") is not None + except (ImportError, ValueError): + return False + + def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list[str]: """Detect which MCP-capable runtimes are installed on the host. @@ -240,6 +262,7 @@ def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list "claude", "intellij", "hermes", + "goose", ]: try: if not _runtime_is_present( @@ -285,6 +308,8 @@ def _runtime_is_present( return _intellij_config_dir().is_dir() if runtime_name == "hermes": return _hermes_runtime_opted_in() + if runtime_name == "goose": + return _goose_runtime_opted_in() return manager.is_runtime_available(runtime_name) @@ -320,6 +345,9 @@ def _discover_installed_runtimes_fallback( # Hermes: experimental flag enabled AND home-dir/binary present. if _hermes_runtime_opted_in(): installed_runtimes.append("hermes") + # Goose: experimental flag enabled AND config-dir/binary present. + if _goose_runtime_opted_in(): + installed_runtimes.append("goose") return installed_runtimes diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index c89ab7b28..29204eca6 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -816,6 +816,51 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: compile_family="agents", requires_flag="hermes", ), + # Goose agent (Block) -- experimental. Goose has no project-level config + # directory of its own; each APM primitive maps onto a different native + # Goose surface: + # - agents -> RECIPES at .goose/recipes/.yaml, the "packaged + # agent" unit Goose runs via `goose run --recipe`. Each recipe carries + # title/description/instructions + a ``prompt`` (headless-ready: from a + # ``prompt:`` frontmatter key, else a generic default), an optional + # ``parameters:`` block (passed through verbatim so authors can + # template ``{{ key }}`` variables), and settings.goose_model when the + # agent pins a model. MCP servers are + # NOT embedded per-recipe -- an APM agent declares no MCP servers; + # those live at package scope and are written to + # ~/.config/goose/config.yaml by GooseClientAdapter (which Goose reads + # globally at run time, honouring $XDG_CONFIG_HOME). + # - skills -> the cross-tool .agents/skills/ standard Goose reads + # natively (project .agents/skills/, user ~/.agents/skills/). + # - instructions -> a .goosehints stub at the project root (compile + # stub -- see compile_family below). + # compile_family="agents" emits AGENTS.md; a thin .goosehints stub at the + # project root imports it via Goose's ``@./AGENTS.md`` preprocessor. + # Recipes have no canonical user-scope home (Goose loads them from the cwd + # or $GOOSE_RECIPE_PATH), so the agents primitive is project-scope only. + # Ref: https://goose-docs.ai/docs/guides/recipes/recipe-reference/ + # Ref: https://goose-docs.ai/docs/guides/context-engineering/using-skills/ + # Ref: https://goose-docs.ai/docs/guides/context-engineering/using-goosehints/ + "goose": TargetProfile( + name="goose", + root_dir=".goose", + primitives={ + "agents": PrimitiveMapping("recipes", ".yaml", "goose_recipe"), + "skills": PrimitiveMapping( + "skills", + "/SKILL.md", + "skill_standard", + deploy_root=".agents", + ), + }, + auto_create=True, + detect_by_dir=False, + user_supported="partial", + unsupported_user_primitives=("agents",), + compile_family="agents", + requires_flag="goose", + pack_prefixes=(".goose/", ".agents/"), + ), # Microsoft 365 Copilot (Cowork) -- experimental, user-scope only. # Skills are deployed to /Documents/Cowork/skills/. # The deploy root is resolved dynamically at runtime via diff --git a/src/apm_cli/utils/yaml_io.py b/src/apm_cli/utils/yaml_io.py index edff6cb64..6cec8358e 100644 --- a/src/apm_cli/utils/yaml_io.py +++ b/src/apm_cli/utils/yaml_io.py @@ -28,6 +28,24 @@ ) +class _BlockStringDumper(yaml.SafeDumper): + """SafeDumper that renders multi-line strings as literal block scalars. + + Opt-in via ``yaml_to_str(..., multiline_block=True)``. Single-line + strings are unaffected. The emitter falls back to a quoted style on + its own when ``|`` cannot faithfully represent the value (e.g. trailing + whitespace), so output stays valid and round-trips. + """ + + +def _represent_str_block(dumper: yaml.Dumper, data: str) -> yaml.nodes.ScalarNode: + style = "|" if "\n" in data else None + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style) + + +_BlockStringDumper.add_representer(str, _represent_str_block) + + def load_yaml(path: str | Path) -> dict[str, Any] | None: """Load a YAML file with explicit UTF-8 encoding. @@ -49,12 +67,26 @@ def dump_yaml( yaml.safe_dump(data, fh, **{**_DUMP_DEFAULTS, "sort_keys": sort_keys}) -def yaml_to_str(data: Any, *, sort_keys: bool = False) -> str: +def yaml_to_str(data: Any, *, sort_keys: bool = False, multiline_block: bool = False) -> str: """Serialize data to a YAML string with unicode support. Use instead of bare ``yaml.dump()`` when building YAML content for later file writes or string returns. + + When *multiline_block* is True, multi-line strings render as literal + block scalars (``key: |``) instead of quoted flow scalars -- the + human-readable form for embedded prose (e.g. Goose recipe + ``instructions``). Single-line strings are unaffected. A wide line + width is used so a long single-line value (e.g. a recipe ``prompt``) is + not wrapped mid-sentence. """ + if multiline_block: + return yaml.dump( + data, + Dumper=_BlockStringDumper, + width=4096, + **{**_DUMP_DEFAULTS, "sort_keys": sort_keys}, + ) return yaml.safe_dump(data, **{**_DUMP_DEFAULTS, "sort_keys": sort_keys}) diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py index 7fe144268..cdacd3a07 100644 --- a/tests/unit/core/test_scope.py +++ b/tests/unit/core/test_scope.py @@ -172,6 +172,7 @@ def test_all_known_targets_present(self): "agent-skills", "openclaw", "hermes", + "goose", } assert set(KNOWN_TARGETS.keys()) == expected diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 69d8894da..5988ee96d 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -941,7 +941,7 @@ def test_experimental_targets_exact_membership(self): requires an intentional test update. """ assert ( - frozenset({"copilot-cowork", "copilot-app", "openclaw", "hermes"}) + frozenset({"copilot-cowork", "copilot-app", "openclaw", "hermes", "goose"}) == EXPERIMENTAL_TARGETS ) diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index 7d72cc6cb..937166a73 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -309,6 +309,7 @@ def test_partition_parity_with_old_buckets(self): "agents_cursor", "agents_opencode", "agents_codex", + "agents_goose", # goose agents -> .goose/recipes/.yaml # NOTE: windsurf no longer exposes an 'agents' primitive # (its content deploys as skills under .windsurf/skills/). "commands", # was commands_claude, aliased diff --git a/tests/unit/integration/test_goose_target.py b/tests/unit/integration/test_goose_target.py new file mode 100644 index 000000000..07399528c --- /dev/null +++ b/tests/unit/integration/test_goose_target.py @@ -0,0 +1,633 @@ +"""Path-fidelity acceptance tests for the experimental Goose (Block) target. + +These tests lock the resolved deploy surface for Goose against its official +config docs (https://goose-docs.ai). Goose is unlike every other target on +two structural points, both asserted here: + + - MCP servers live in a single YAML home config + (~/.config/goose/config.yaml, honouring $XDG_CONFIG_HOME) under an + ``extensions:`` key, with a Goose-native per-server schema + (type: stdio / cmd / args / envs / enabled / timeout) -- NOT the JSON + ``mcpServers`` schema. Written by GooseClientAdapter at user scope. + - Instructions are a single ``.goosehints`` stub at the project root that + imports the AGENTS.md roll-up via Goose's ``@./AGENTS.md`` preprocessor + (compile_family="agents"), NOT a per-file rules directory. + +Activation is EXPERIMENTAL (flag "goose"): never part of ``--target all``. +""" + +from __future__ import annotations + +import os +import stat +from datetime import datetime +from pathlib import Path + +import pytest + +from apm_cli.adapters.client.goose import GooseClientAdapter +from apm_cli.compilation.agents_compiler import AgentsCompiler, CompilationConfig +from apm_cli.compilation.goose_formatter import GooseFormatter +from apm_cli.core.target_detection import ( + ALL_CANONICAL_TARGETS, + EXPERIMENTAL_TARGETS, + should_compile_agents_md, + should_compile_goose_hints, +) +from apm_cli.factory import ClientFactory +from apm_cli.integration.agent_integrator import AgentIntegrator +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 _make_package_info( + package_dir: Path, name: str = "test-pkg", 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_ref = 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_ref, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +# --------------------------------------------------------------------------- +# Target profile shape +# --------------------------------------------------------------------------- + + +def test_goose_profile_matches_official_surface() -> None: + target = KNOWN_TARGETS["goose"] + + assert target.name == "goose" + assert target.root_dir == ".goose" # display-grouping placeholder only + assert target.compile_family == "agents" # emits AGENTS.md (imported by .goosehints) + assert target.requires_flag == "goose" # experimental + assert target.user_supported == "partial" # skills at user scope; recipes project-only + assert target.auto_create is True # explicit --target goose creates .goose/recipes/ + assert target.detect_by_dir is False + + # agents -> Goose recipes (.goose/recipes/*.yaml); skills -> .agents/skills/. + # MCP is handled by the adapter; instructions by the compile stub. + assert set(target.primitives) == {"agents", "skills"} + + agents = target.primitives["agents"] + assert agents.subdir == "recipes" + assert agents.extension == ".yaml" + assert agents.format_id == "goose_recipe" + + skills = target.primitives["skills"] + assert skills.format_id == "skill_standard" + assert skills.deploy_root == ".agents" # cross-tool standard Goose reads natively + + # Recipes have no canonical user-scope home -> project-scope only. + assert target.unsupported_user_primitives == ("agents",) + + +# --------------------------------------------------------------------------- +# Experimental activation: flag-gated, never in "all" +# --------------------------------------------------------------------------- + + +def test_goose_is_experimental_not_in_all() -> None: + assert "goose" in EXPERIMENTAL_TARGETS + assert "goose" not in ALL_CANONICAL_TARGETS + + +def test_goose_excluded_from_target_all(tmp_path: Path) -> None: + names = {p.name for p in active_targets(tmp_path, "all")} + + assert "goose" not in names + # Sanity: canonical single-tool targets are still present in "all". + assert {"claude", "gemini", "kiro"} <= names + + +def test_goose_resolves_only_when_named_with_flag_enabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("apm_cli.core.experimental.is_enabled", lambda name: name == "goose") + + profiles = active_targets(tmp_path, "goose") + + assert [p.name for p in profiles] == ["goose"] + + +def test_goose_accepted_by_resolve_targets(tmp_path: Path) -> None: + # Regression: the MCP-install phase resolves `--target` via resolve_targets, + # which used to validate against the GA-only CANONICAL_TARGETS and crashed + # `apm install --target goose` whenever the package declared an mcp: dep. + from apm_cli.core.target_detection import resolve_targets + + resolved = resolve_targets(tmp_path, flag="goose") + assert resolved.targets == ["goose"] + + +def test_goose_runtime_discovered_when_opted_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The MCP runtime-discovery list must include goose (flag on + config dir + # present), else `apm install --target goose` deploys recipe/skill but + # silently skips writing the MCP server to ~/.config/goose/config.yaml. + from apm_cli.integration import mcp_integrator_install as mii + + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + (tmp_path / "goose").mkdir() + + monkeypatch.setattr("apm_cli.core.experimental.is_enabled", lambda name: name == "goose") + assert mii._goose_runtime_opted_in() is True + + monkeypatch.setattr("apm_cli.core.experimental.is_enabled", lambda name: False) + assert mii._goose_runtime_opted_in() is False + + +# --------------------------------------------------------------------------- +# Factory wiring +# --------------------------------------------------------------------------- + + +def test_goose_factory_returns_goose_adapter() -> None: + adapter = ClientFactory.create_client("goose", user_scope=True) + + assert isinstance(adapter, GooseClientAdapter) + assert "goose" in ClientFactory.supported_clients() + assert adapter.target_name == "goose" + assert adapter.mcp_servers_key == "extensions" + assert adapter.supports_user_scope is True + + +# --------------------------------------------------------------------------- +# MCP adapter: config path honours XDG_CONFIG_HOME +# --------------------------------------------------------------------------- + + +def test_goose_config_path_honours_xdg(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + adapter = GooseClientAdapter(user_scope=True) + assert adapter.get_config_path() == str(tmp_path / "xdg" / "goose" / "config.yaml") + + +def test_goose_config_path_defaults_to_dot_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + adapter = GooseClientAdapter(user_scope=True) + assert adapter.get_config_path() == str(tmp_path / ".config" / "goose" / "config.yaml") + + +# --------------------------------------------------------------------------- +# MCP adapter: per-server schema transform (Copilot-format -> Goose extensions) +# --------------------------------------------------------------------------- + + +def test_goose_stdio_transform() -> None: + out = GooseClientAdapter()._to_native_format( + "github", + { + "type": "local", + "command": "npx", + "args": ["-y", "srv"], + "env": {"K": "v"}, + "tools": ["*"], + }, + ) + assert out == { + "name": "github", + "type": "stdio", + "cmd": "npx", + "args": ["-y", "srv"], + "envs": {"K": "v"}, + "enabled": True, + "timeout": 300, + } + + +def test_goose_remote_transform_maps_to_streamable_http() -> None: + out = GooseClientAdapter()._to_native_format( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer x"}, + }, + ) + assert out["type"] == "streamable_http" + assert out["uri"] == "https://example.com/mcp" + assert out["headers"] == {"Authorization": "Bearer x"} + assert "cmd" not in out and "args" not in out + + +def test_goose_transform_fails_closed_on_non_mapping() -> None: + with pytest.raises(ValueError): + GooseClientAdapter()._to_native_format("bad", "not-a-dict") # type: ignore[arg-type] + + +def test_goose_transform_drops_malformed_typed_fields() -> None: + # Non-dict headers/envs and non-list args must be dropped, never crash. + remote = GooseClientAdapter()._to_native_format( + "r", {"type": "http", "url": "https://x", "headers": "oops"} + ) + assert "headers" not in remote + stdio = GooseClientAdapter()._to_native_format( + "s", {"command": "npx", "args": "oops", "env": "oops"} + ) + assert "args" not in stdio and "envs" not in stdio + + +def test_goose_update_config_fails_closed_on_bad_entry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # A scalar server config makes the transform raise; update_config must + # return False (not crash) and write nothing. + adapter = _adapter_with_home(monkeypatch, tmp_path) + assert adapter.update_config({"bad": "not-a-dict"}) is False + assert not Path(adapter.get_config_path()).exists() + + +# --------------------------------------------------------------------------- +# MCP adapter: YAML write -- 0o600, sibling preservation, malformed refusal +# --------------------------------------------------------------------------- + + +def _adapter_with_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> GooseClientAdapter: + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + return GooseClientAdapter(user_scope=True) + + +def test_goose_update_config_writes_extensions_block_0600( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + adapter = _adapter_with_home(monkeypatch, tmp_path) + + assert adapter.update_config({"srv": {"command": "uvx", "args": ["pkg"], "env": {}}}) is True + + path = Path(adapter.get_config_path()) + from apm_cli.utils.yaml_io import load_yaml + + data = load_yaml(path) + assert data["extensions"]["srv"]["type"] == "stdio" + assert data["extensions"]["srv"]["cmd"] == "uvx" + # Credential-bearing config must be owner-only. + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + +def test_goose_update_config_preserves_unrelated_keys( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + adapter = _adapter_with_home(monkeypatch, tmp_path) + path = Path(adapter.get_config_path()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "GOOSE_PROVIDER: openai\n" + "extensions:\n" + " preexisting:\n" + " name: preexisting\n" + " type: stdio\n" + " cmd: echo\n" + " enabled: true\n", + encoding="utf-8", + ) + + adapter.update_config({"github": {"command": "npx", "args": ["-y", "srv"], "env": {}}}) + + from apm_cli.utils.yaml_io import load_yaml + + data = load_yaml(path) + assert data["GOOSE_PROVIDER"] == "openai" # native key preserved + assert set(data["extensions"]) == {"preexisting", "github"} + + +def test_goose_update_config_refuses_malformed_yaml( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + adapter = _adapter_with_home(monkeypatch, tmp_path) + path = Path(adapter.get_config_path()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("just a string, not a mapping\n", encoding="utf-8") + + assert adapter.update_config({"github": {"command": "npx", "args": []}}) is False + # File left untouched. + assert path.read_text(encoding="utf-8") == "just a string, not a mapping\n" + + +# --------------------------------------------------------------------------- +# Compile: .goosehints stub imports AGENTS.md +# --------------------------------------------------------------------------- + + +def test_goose_hints_compiles_only_for_explicit_goose() -> None: + assert should_compile_goose_hints("goose") is True + assert should_compile_goose_hints("all") is False + assert should_compile_goose_hints(frozenset({"agents"})) is False + # AGENTS.md (the imported roll-up) is still emitted for goose. + assert should_compile_agents_md("goose") is True + + +def test_goose_detect_target_echoes_explicit_and_config(tmp_path: Path) -> None: + # Regression: detect_target must round-trip an explicit/config ``goose`` + # rather than falling back to "minimal" -- otherwise `apm compile -t goose` + # silently drops the .goosehints stub (it only emits AGENTS.md). + from apm_cli.core.target_detection import detect_target + + explicit, _ = detect_target(tmp_path, explicit_target="goose") + assert explicit == "goose" + assert should_compile_goose_hints(explicit) is True + + config, _ = detect_target(tmp_path, config_target="goose") + assert config == "goose" + + +def test_goose_formatter_emits_dot_goosehints_stub() -> None: + formatter = GooseFormatter(".") + assert formatter._stub_filename == ".goosehints" + content = formatter._generate_stub() + assert "@./AGENTS.md" in content + + +def test_goose_compile_writes_agents_md_and_goosehints(tmp_path: Path) -> None: + instructions = tmp_path / ".apm" / "instructions" + instructions.mkdir(parents=True) + (instructions / "core.instructions.md").write_text( + '---\napplyTo: "**"\n---\n# Core\nBe concise.\n', encoding="utf-8" + ) + + result = AgentsCompiler(base_dir=str(tmp_path)).compile(CompilationConfig(target="goose")) + + assert result.success + assert (tmp_path / "AGENTS.md").exists() + hints = tmp_path / ".goosehints" + assert hints.exists() + assert "@./AGENTS.md" in hints.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Agents -> Goose recipes (.goose/recipes/.yaml) +# --------------------------------------------------------------------------- + + +def test_goose_agent_compiles_to_recipe_yaml(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-review.agent.md").write_text( + "---\n" + "name: security-review\n" + "description: Reviews diffs for OWASP issues.\n" + "model: ' gpt-5 '\n" + "---\n\n" + "You are a security reviewer. Inspect the diff.\n", + encoding="utf-8", + ) + + result = AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + assert result.files_integrated == 1 + recipe_path = tmp_path / ".goose" / "recipes" / "security-review.yaml" + assert recipe_path.exists() + + import yaml as _yaml + + recipe = _yaml.safe_load(recipe_path.read_text(encoding="utf-8")) + assert recipe["version"] == "1.0.0" + assert recipe["title"] == "security-review" + assert recipe["description"] == "Reviews diffs for OWASP issues." + assert recipe["instructions"] == "You are a security reviewer. Inspect the diff." + # A pinned model becomes settings.goose_model. + assert recipe["settings"] == {"goose_model": "gpt-5"} + # Headless-ready: a prompt is always present (generic default here). + assert recipe["prompt"] + # MCP extensions are NOT embedded (agents declare no MCP servers). + assert "extensions" not in recipe + + +def test_goose_recipe_uses_authored_prompt_verbatim(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "analyzer.agent.md").write_text( + "---\nname: analyzer\ndescription: d\nprompt: Analyze {{url}} end-to-end.\n---\n\nYou analyze URLs.\n", + encoding="utf-8", + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + import yaml as _yaml + + recipe = _yaml.safe_load( + (tmp_path / ".goose" / "recipes" / "analyzer.yaml").read_text(encoding="utf-8") + ) + assert recipe["prompt"] == "Analyze {{url}} end-to-end." + + +def test_goose_recipe_passes_parameters_and_preserves_templating(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "crawler.agent.md").write_text( + "---\n" + "name: crawler\n" + "description: Crawls a URL.\n" + "prompt: Crawl {{ url }} and summarize it.\n" + "parameters:\n" + " - key: url\n" + " input_type: string\n" + " requirement: required\n" + " description: The URL to crawl\n" + "---\n\n" + "Crawl {{ url }} using the available tools.\n", + encoding="utf-8", + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + raw = (tmp_path / ".goose" / "recipes" / "crawler.yaml").read_text(encoding="utf-8") + # {{ }} placeholders survive verbatim in prompt and instructions. + assert "{{ url }}" in raw + + import yaml as _yaml + + recipe = _yaml.safe_load(raw) + # parameters block passes through verbatim (Goose schema). + assert recipe["parameters"] == [ + { + "key": "url", + "input_type": "string", + "requirement": "required", + "description": "The URL to crawl", + } + ] + assert "{{ url }}" in recipe["prompt"] + assert "{{ url }}" in recipe["instructions"] + + +def test_goose_recipe_omits_parameters_when_none(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "plain.agent.md").write_text( + "---\nname: plain\ndescription: d\n---\n\nDo a thing.\n", encoding="utf-8" + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + import yaml as _yaml + + recipe = _yaml.safe_load( + (tmp_path / ".goose" / "recipes" / "plain.yaml").read_text(encoding="utf-8") + ) + assert "parameters" not in recipe + + +def test_goose_recipe_ignores_non_string_name_and_model(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + # Malformed frontmatter: name/model as lists must not propagate. + (agents_dir / "weird.agent.md").write_text( + "---\nname:\n - a\n - b\ndescription: ok\nmodel:\n - x\n---\n\nBody.\n", + encoding="utf-8", + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + import yaml as _yaml + + # Filename stem is used because `name` was not a string. + recipe = _yaml.safe_load( + (tmp_path / ".goose" / "recipes" / "weird.yaml").read_text(encoding="utf-8") + ) + assert recipe["title"] == "weird" + assert "settings" not in recipe # non-string model dropped + + +def test_goose_recipe_falls_back_to_default_prompt(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "persona.agent.md").write_text( + "---\nname: persona\ndescription: d\n---\n\nYou are a persona.\n", encoding="utf-8" + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + import yaml as _yaml + + recipe = _yaml.safe_load( + (tmp_path / ".goose" / "recipes" / "persona.yaml").read_text(encoding="utf-8") + ) + assert recipe["prompt"] == AgentIntegrator._DEFAULT_RECIPE_PROMPT + + +def test_goose_recipe_renders_multiline_instructions_as_block(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + body = "You are a reviewer.\n\n## Step 1\nDo the thing.\n\n## Step 2\n- a\n- b" + (agents_dir / "rev.agent.md").write_text( + f"---\nname: rev\ndescription: d\n---\n\n{body}\n", encoding="utf-8" + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + raw = (tmp_path / ".goose" / "recipes" / "rev.yaml").read_text(encoding="utf-8") + # Readable literal block scalar, not a quoted flow scalar. + assert "instructions: |" in raw + assert "instructions: '" not in raw + + import yaml as _yaml + + # ...and the text still round-trips exactly. + assert _yaml.safe_load(raw)["instructions"] == body + + +def test_goose_recipe_omits_settings_without_model(tmp_path: Path) -> None: + package_dir = tmp_path / "pkg" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "helper.agent.md").write_text( + "---\nname: helper\ndescription: A helper.\n---\n\nDo helpful things.\n", + encoding="utf-8", + ) + + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["goose"], _make_package_info(package_dir), tmp_path + ) + + import yaml as _yaml + + recipe = _yaml.safe_load( + (tmp_path / ".goose" / "recipes" / "helper.yaml").read_text(encoding="utf-8") + ) + assert "settings" not in recipe + assert recipe["instructions"] == "Do helpful things." + + +# --------------------------------------------------------------------------- +# Skills -> .agents/skills//SKILL.md (cross-tool standard) +# --------------------------------------------------------------------------- + + +def test_goose_skills_deploy_to_agents_skills(tmp_path: Path) -> None: + package_dir = tmp_path / "skill-pkg" + package_dir.mkdir() + (package_dir / "SKILL.md").write_text( + "---\nname: skill-pkg\ndescription: Demo skill\n---\n\n# Demo\n", + encoding="utf-8", + ) + + result = SkillIntegrator().integrate_package_skill( + _make_package_info(package_dir, "skill-pkg", PackageType.CLAUDE_SKILL), + tmp_path, + targets=[KNOWN_TARGETS["goose"]], + ) + + assert result.skill_created is True + target = tmp_path / ".agents" / "skills" / "skill-pkg" / "SKILL.md" + assert target.exists() + + +# --------------------------------------------------------------------------- +# Scope: skills at user scope -> ~/.agents/skills/; recipes are project-only +# --------------------------------------------------------------------------- + + +def test_goose_user_scope_keeps_skills_drops_recipes() -> None: + user_profile = KNOWN_TARGETS["goose"].for_scope(user_scope=True) + + assert user_profile is not None + assert "skills" in user_profile.primitives # ~/.agents/skills/ via deploy_root + assert "agents" not in user_profile.primitives # recipes are project-scope only diff --git a/tests/unit/integration/test_targets_registry_completeness.py b/tests/unit/integration/test_targets_registry_completeness.py index 47d5a5269..87ca7c67a 100644 --- a/tests/unit/integration/test_targets_registry_completeness.py +++ b/tests/unit/integration/test_targets_registry_completeness.py @@ -22,6 +22,7 @@ from apm_cli.adapters.client.copilot import CopilotClientAdapter from apm_cli.adapters.client.cursor import CursorClientAdapter from apm_cli.adapters.client.gemini import GeminiClientAdapter +from apm_cli.adapters.client.goose import GooseClientAdapter from apm_cli.adapters.client.hermes import HermesClientAdapter from apm_cli.adapters.client.intellij import IntelliJClientAdapter from apm_cli.adapters.client.kiro import KiroClientAdapter @@ -40,7 +41,7 @@ # key means a new MCP config schema; ``MCPConflictDetector`` must learn how # to parse it (today only ``mcp_servers`` needs the codex-style flattened- # key fallback -- the others are plain top-level dicts). -_KNOWN_MCP_KEYS = {"mcpServers", "mcp_servers", "servers"} +_KNOWN_MCP_KEYS = {"mcpServers", "mcp_servers", "servers", "extensions"} # Adapter target_names that are MCP-only pseudo-targets (no entry in # KNOWN_TARGETS). Code that joins adapter -> profile must tolerate misses @@ -63,6 +64,7 @@ VSCodeClientAdapter, WindsurfClientAdapter, HermesClientAdapter, + GooseClientAdapter, )