Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ jobs:
# watching, which is how a total macOS breakage (#247) shipped.
run: uv run pytest -q

- name: Verify installed doctor wheel and generated legacy launcher outside checkout
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
shell: bash
run: |
uv build --wheel --out-dir "$RUNNER_TEMP/doctor-wheels"
uv venv "$RUNNER_TEMP/doctor-runtime"
uv pip install --python "$RUNNER_TEMP/doctor-runtime/bin/python" "$RUNNER_TEMP"/doctor-wheels/*.whl
cp tests/check_doctor_wheel.py tests/doctor_safety_probe.py "$RUNNER_TEMP/"
cd "$RUNNER_TEMP"
env -u PYTHONPATH -u PYTHONDONTWRITEBYTECODE "$RUNNER_TEMP/doctor-runtime/bin/python" check_doctor_wheel.py

integration:
name: pytest -m integration (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,20 @@ The Highway watchdog records append-only `wake-needed` advisories only: it must
not invoke Amplifier or consume markers, including triggers within its log
verbosity gap. Keep its bounded fake-command tests proving each trigger records
without starting host tmux or an Amplifier session.

## Passive doctor entry point

The published console script, legacy `main:main` launcher and module entry
dispatch exact leading `doctor` before normal CLI startup. Keep collection free
of key loading, preparation,
network, subprocesses and application-state writes; Python's initial import
cache is a separate interpreter boundary. Preserve ownership enforcement and
ordinary startup/TLS behavior. Per-scope declarations are not effective settings
or live-session evidence. Keep public package/command exports static across
argv routes; deferred `main.py` imports must initialize the real Click group
once on explicit `cli` access or later ordinary dispatch, in the same module
namespace so existing global patches still apply. Unknown attribute probes must
not initialize it. Run `tests/test_doctor.py`,
`tests/test_doctor_entrypoint.py`, `tests/test_cli_doctor_agent.py` and the
installed-wheel check in CI when changing this boundary. Keep doctor instructions
agent-scoped and reuse `amplifier-config` rather than duplicate its workflow.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,48 @@ saved selections and user-added bundles are unchanged.

## Commands

### Passive CLI diagnosis

```bash
amplifier doctor # Local runtime and settings declarations
amplifier doctor --format json # One structured report
amplifier doctor --session <exact-id> # Include this project's session settings
```

Doctor reads installed distribution metadata and bounded settings files from the
current directory and Amplifier home. It does not resolve effective settings,
fetch/install dependencies, load credentials, mount modules, or repair anything.
Existing-session state remains **unknown**, including when `--session` is used.
Exit codes: `0` collection complete, `1` partial/refused, `2` invalid arguments.
Collection complete is not a health verdict.
The JSON report always marks health `not_assessed`; `issues` records collection
limits, not every possible environment defect.
The bounded diagnostic YAML parser intentionally accepts less syntax than the
runtime parser: aliases, explicit tags, and duplicate keys report
`unsupported_yaml`, not malformed `invalid_yaml`. This is a collection limit,
not an instruction to repair settings that the runtime accepts.

Declaration values and arbitrary names use report-local equality refs. Scope,
container/member and zero-based parsed-order indices locate their declarations;
they do not establish precedence or serve as mutation selectors. Local paths
still identify the environment: review a report before sharing.

The installed console script and `python -m amplifier_app_cli doctor` take a
passive startup route, including launchers targeting the legacy
`amplifier_app_cli.main:main` entry. Python interpreter startup and its initial
import-cache handling are outside that guarantee; later application bytecode
is suppressed.
The passive route requires `doctor` as the exact first argument after the
launcher; preceding arguments do not take this route.
The lightweight console dispatcher avoids importing the full interactive CLI
implementation on that route. Explicit Python access to the public `cli`
export still initializes the ordinary Click group, regardless of process arguments.

For agentic interpretation, delegate to **`app-cli:cli-doctor`** in a CLI session.
It uses the report and loads the existing `amplifier-config` skill when needed.
The agent's own child state is not proof of another session's live state.
Diagnosis does not authorize changes or live probes.

### Configuration Commands

```bash
Expand Down
102 changes: 102 additions & 0 deletions agents/cli-doctor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
meta:
name: cli-doctor
description: >-
USE WHEN an Amplifier CLI environment needs diagnosis: runtime/source
identity, settings declarations, or missing capabilities. Returns collection
evidence, never a health or repair verdict; preserve that limit when
summarizing. DO NOT USE for project bugs or other hosts. For command usage,
use app-cli:cli-expert; for changes, consult amplifier-config.
model_role: general
---

# Amplifier CLI Doctor

Diagnose the CLI application, not the user's project or another host. Default
to inspection. A diagnosis request does not authorize repair.

## 1. Identify the target

Establish the intended CLI executable, invocation directory, and any affected
session. Use target information already supplied; ask only for missing
information that changes the diagnosis.

You run in a child session. Your interpreter, working directory and mounted
tools do not automatically describe your parent or another session. Attribute
each observation to its actual target; otherwise mark it unknown.

**Success criteria:** the target and the limits of your access are explicit.

## 2. Collect passive evidence

From the intended directory and selected installed CLI, invoke:

```text
amplifier doctor --format json
```

Use `--session SESSION_ID` only for an exact, supplied session in that project.
It selects the session's settings file; it neither resumes it nor observes its
live state. Do not guess a session ID or search private transcripts.

This command reports installed distribution metadata and bounded, per-scope
declarations. It does not resolve effective settings, fetch sources, mount
modules or prove live behavior. Exit 0 means collection completed, not healthy;
exit 1 means partial collection or refusal; exit 2 means invalid arguments.
Retain the actual command, tool result and issue codes, not just a summary.
The report's `health.status` is `not_assessed`. An empty `issues` list means no
collection problems were recorded, not that nothing needs fixing.

Older installed CLIs may not have this command. If unavailable, report that
limit. Do not install/update anything or substitute inspection-looking commands
that can activate dependencies. Consult `app-cli:cli-expert` for version-matched
command semantics when needed and available.

**Success criteria:** genuine target-command evidence or a concrete collection
limit, without environment changes.

## 3. Interpret without inventing runtime truth

Keep declarations, effective settings, installed code, mounted objects and
successful workflows separate. Equal opaque refs mean equality only within one
report. Locate declarations by scope, fixed container/member, and zero-based
parsed-order index. These are inspection locators, not safe mutation selectors.
Re-read and confirm any target before a later authorized edit.

For configuration analysis, load the existing `amplifier-config` skill with
`load_skill`. Confirm its returned source; report missing or shadowed guidance
rather than inventing a replacement. Reuse its configuration workflow instead
of reproducing merge, routing, skill-discovery or provider semantics here.
Do not load it for an identity-only request that does not need it.

Never expand or disclose credential values. The passive report withholds
arbitrary declaration names and source URLs, but local paths still identify
the environment. Review before sharing; do not upload reports automatically.

**Success criteria:** each conclusion has supporting evidence and unresolved
effective/live state remains unknown.

## 4. Return a bounded next step

Report findings, evidence, unknowns, the smallest supported next action and how
to verify it. A clean passive report does not establish repair or workflow
success. Root success does not certify a child.
State **"Health not assessed; no repair verdict from passive collection."**
Do not conclude "no repair needed", "nothing needs fixing" or "healthy" from
`collection_status: complete` or `issues: []`. If the question requires effective
or live state, name the missing observation instead of turning it into a pass.
Doctor always leaves effective and live state unknown, even after a repair or
with `--session`. Never use a changed `effective_settings.status` as its success
criterion. Verify an actual affected setting or behavior through a separately
supported, authorized observation. If no concrete symptom or failed behavior
was supplied, the next step is to identify that symptom, not to certify that no
repair is needed or invent another effective-settings command.

Do not reset caches, reinstall, switch roots/providers, alter permissions,
disable modes, edit guidance, load credentials, replay work or start live
probes under diagnosis authority. Propose those separately with scope,
activation effects and rollback; configuration changes must follow the loaded
skill's confirmation rules. Never bypass an ownership or access refusal.

**Success criteria:** an actionable diagnosis, not an unsupported health grade
or an unrequested repair.
52 changes: 47 additions & 5 deletions amplifier_app_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,60 @@
"""Amplifier CLI - Command-line interface for the Amplifier platform."""

import sys

# Only the exact public doctor route bypasses normal application startup.
# Python may already have cached this first __init__; suppress later bytecode.
if len(sys.argv) > 1 and sys.argv[1] == "doctor":
sys.dont_write_bytecode = True

import importlib
from types import ModuleType as _ModuleType

__version__ = "0.1.0"
__all__ = ["cli", "main", "__version__"]

# TEMPORARY, remove when truststore ships the fix. This must run before any
# TLS happens in this process, and nothing in the amplifier tree owns the
# truststore call site (httpx2/httpcore2 construct the context), so CLI
# package import is the earliest amplifier-owned point available. It is a
# no-op unless the installed truststore's wrap_bio is genuinely unlocked.
# See truststore_shim.py for the full story.
from . import truststore_shim as _truststore_shim
def _ensure_tls_shim():
from . import truststore_shim as _truststore_shim

_truststore_shim.apply()
_truststore_shim.apply()

from .main import cli
from .main import main

__all__ = ["cli", "main", "__version__"]
def __getattr__(name):
if name not in __all__:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module = importlib.import_module(".main", __name__)
if name == "cli":
module._initialize_cli()
globals()["cli"] = module.cli
# Importing the submodule sets package.main to that module. Restore the
# public dispatcher, including when the submodule was imported first.
globals()["main"] = module.main
return globals()[name]


if not (len(sys.argv) > 1 and sys.argv[1] == "doctor"):
_ensure_tls_shim()
__getattr__("cli")


class _PackageModule(_ModuleType):
def __getattribute__(self, name):
value = super().__getattribute__(name)
# Python publishes imported children on their parent, bypassing
# __getattr__. Resolve only the collision with our public dispatcher.
if (
name == "main"
and isinstance(value, _ModuleType)
and value is sys.modules.get(f"{__name__}.main")
):
return __getattr__("main")
return value


sys.modules[__name__].__class__ = _PackageModule
18 changes: 16 additions & 2 deletions amplifier_app_cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
"""Entry point for running as module."""
"""Published console and module entry point."""

import sys


def main():
"""Dispatch doctor before importing the normal application's runtime."""
if len(sys.argv) > 1 and sys.argv[1] == "doctor":
# The package can already be cached from an earlier normal invocation.
sys.dont_write_bytecode = True
from .commands.doctor import main as doctor_main

return doctor_main()
from .main import main as application_main

return application_main()

from .main import main

if __name__ == "__main__":
main()
67 changes: 57 additions & 10 deletions amplifier_app_cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
"""CLI command exports for amplifier-app-cli."""

from .init import auto_init_from_env
from .init import check_first_run
from .init import init_cmd
from .init import prompt_first_run_init
from .module import module
from .provider import provider
from .run import register_run_command
from .session import register_session_commands
from .source import source
from .tool import tool
import sys
from types import ModuleType as _ModuleType

__all__ = [
"auto_init_from_env",
Expand All @@ -23,3 +15,58 @@
"source",
"tool",
]
_exports_loaded = False


def _load_exports():
global _exports_loaded
global auto_init_from_env, check_first_run, init_cmd, prompt_first_run_init
global module, provider, register_run_command, register_session_commands, source, tool
if _exports_loaded:
return
from .. import _ensure_tls_shim

_ensure_tls_shim()
# Bind the complete fixed public surface together: importing a submodule
# otherwise leaves same-named attributes such as provider as module objects.
from .init import auto_init_from_env
from .init import check_first_run
from .init import init_cmd
from .init import prompt_first_run_init
from .module import module
from .provider import provider
from .run import register_run_command
from .session import register_session_commands
from .source import source
from .tool import tool

_exports_loaded = True


def __getattr__(name):
if name not in __all__:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
_load_exports()
return globals()[name]


if not (len(sys.argv) > 1 and sys.argv[1] == "doctor"):
_load_exports()


class _PackageModule(_ModuleType):
def __getattribute__(self, name):
value = super().__getattribute__(name)
# A directly imported child can mask its same-named public command;
# __getattr__ alone cannot see an attribute that Python already set.
if (
name in ("module", "provider", "source", "tool")
and isinstance(value, _ModuleType)
and value is sys.modules.get(f"{__name__}.{name}")
):
_load_exports()
return getattr(value, name)
return value


sys.modules[__name__].__class__ = _PackageModule
Loading
Loading