Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/<name>/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 (<pattern>)`) 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
Expand Down
142 changes: 142 additions & 0 deletions docs/src/content/docs/integrations/goose.md
Original file line number Diff line number Diff line change
@@ -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/<name>.yaml` (project scope) |
| skills | Skills (agentskills.io `SKILL.md`) | `.agents/skills/<name>/` (project) or `~/.agents/skills/<name>/` (`--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/<name>.md`) compiles to a Goose **recipe** at `.goose/recipes/<name>.yaml` -- the native packaged-agent unit you run with `goose run --recipe <name>`. 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 <name>`). 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 <name>` 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/<name>/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/).
6 changes: 4 additions & 2 deletions docs/src/content/docs/producer/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <flag>`, 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

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.{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/<name>/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/).
Expand Down
185 changes: 185 additions & 0 deletions src/apm_cli/adapters/client/_yaml_config.py
Original file line number Diff line number Diff line change
@@ -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 ``{<mcp_servers_key>: {...}}`` 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
Loading
Loading