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
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,26 +74,34 @@ saved selections and user-added bundles are unchanged.
### 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
amplifier doctor # Short local-settings checklist
amplifier doctor --verbose # Original technical report
amplifier doctor --format json # Unchanged structured report
amplifier doctor --session <exact-id> --verbose # Include that session's 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.
The default checklist shows whether user, project and local settings could be
read. A session row appears only with `--session`, which selects this project's
exact session's saved settings, not its live state. Missing settings are optional.
AI connections and running sessions are not tested. `--verbose` shows technical
details and has no effect with `--format json`.
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.
Partial means some collection was limited; refused means collection stopped.
Collection complete is not a health verdict or proof that Amplifier works.
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;
In technical text and JSON, 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.

Expand All @@ -108,7 +116,8 @@ 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.
Next: Tell Amplifier what went wrong and ask for the CLI doctor agent
(**`app-cli:cli-doctor`**) in a CLI session. Doctor does not start an agent or model.
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.
Expand Down
28 changes: 22 additions & 6 deletions amplifier_app_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ..lib.diagnostics import collect_diagnostics
from ..lib.diagnostics import current_target
from ..lib.diagnostics import render_summary
from ..lib.diagnostics import render_text
from ..lib.diagnostics import valid_session_id

Expand All @@ -26,10 +27,20 @@ def parse_args(self, ctx, args):


@click.command(cls=_DoctorCommand)
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text")
@click.option("--session", "session_id", callback=_session, metavar="SESSION_ID")
def doctor(output_format, session_id):
"""Report local declarations without resolving or activating them."""
@click.option(
"--format", "output_format", type=click.Choice(["text", "json"]), default="text",
help="Show a text checklist (default) or the unchanged JSON report.",
)
@click.option(
"--session", "session_id", callback=_session, metavar="SESSION_ID",
help="Include this project's exact session's settings, not its live state.",
)
@click.option(
"--verbose", is_flag=True,
help="Show technical details (text only; does not affect JSON).",
)
def doctor(output_format, session_id, verbose):
"""Read local configuration without changing settings."""
try:
target = current_target(session_id)
report = collect_diagnostics(target)
Expand All @@ -38,7 +49,12 @@ def doctor(output_format, session_id):
from ..lib.diagnostics import unavailable_report

report = unavailable_report()
click.echo(json.dumps(report, ensure_ascii=True) if output_format == "json" else render_text(report))
if output_format == "json":
click.echo(json.dumps(report, ensure_ascii=True))
elif verbose:
click.echo(render_text(report))
else:
click.echo(render_summary(report))
if report["collection_status"] != "complete":
raise click.exceptions.Exit(1)

Expand All @@ -49,7 +65,7 @@ def main():
code = doctor.main(args=sys.argv[2:], prog_name="amplifier doctor", standalone_mode=False)
raise SystemExit(code or 0)
except click.ClickException:
click.echo("Usage: amplifier doctor [--format text|json] [--session SESSION_ID]", err=True)
click.echo("Usage: amplifier doctor [--format text|json] [--session SESSION_ID] [--verbose]", err=True)
click.echo("Error: invalid_doctor_arguments", err=True)
raise SystemExit(2) from None
except click.exceptions.Exit as error:
Expand Down
75 changes: 75 additions & 0 deletions amplifier_app_cli/lib/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,81 @@ def emit(row):
return report


def render_summary(report: dict) -> str:
"""Render a fixed-copy checklist, not effective settings or a health verdict."""
status_copy = {
"valid": "Read",
"empty": "No entries",
"missing": "Not present (optional)",
"invalid_yaml": "Could not parse format or encoding",
"invalid_shape": "Could not inspect: expected named settings",
"unsupported_yaml": "Doctor cannot inspect this YAML syntax",
"unsupported_file": "Not read: not a regular file",
"unsupported_symlink": "Not read: symbolic links are not inspected",
"unreadable": "Could not read",
"too_large": "Doctor's inspection limit was reached",
}
notice_copy = {
"ownership_conflict": "Collection stopped: This installation is linked to another Amplifier home.",
"ownership_unknown": "Collection stopped: Installation ownership could not be checked.",
"target_unavailable": "Collection stopped: The local environment could not be inspected.",
"invalid_session_id": "Collection stopped: The session selection was rejected.",
"distribution_metadata_unavailable": "Some installation details could not be read or shown.",
"distribution_version_withheld": "Some installation details could not be read or shown.",
"path_withheld": "Some location details were withheld.",
"declarations_truncated": "Some configuration entries were omitted.",
"environment_channels_truncated": "Some environment entries were omitted.",
}
collection = report["collection_status"]
lines = [
"Amplifier doctor",
{
"complete": "Configuration report collected",
"partial": "Configuration report incomplete",
}.get(collection, "Configuration report could not be collected"),
"",
]
scopes = [
("global", "User settings (global)"),
("project", "Project settings"),
("local", "Local settings"),
]
if report["target"]["session_selected"]:
scopes.append(("session", "Session settings"))
explained = set()
for scope, label in scopes:
row = next((row for row in report["scopes"] if row["scope"] == scope), {})
status = row.get("status")
copy = "Not read" if collection == "refused" else status_copy.get(status, "Not read")
lines.append(label + ": " + copy)
if collection != "refused" and status in status_copy:
explained.add((scope, "settings_" + status))
lines.append("AI connections and running session: Not tested")

notices = []
if report["ownership"]["status"] == "override":
notices.append("An existing ownership override allowed collection.")
for issue in report["issues"]:
code = issue["code"]
if code in notice_copy:
copy = notice_copy[code]
elif (issue.get("scope"), code) in explained:
continue
else:
copy = "Other collection limits were reported; see technical details."
if copy not in notices:
notices.append(copy)
lines.extend(notices)
lines.extend((
"Effective settings were not resolved.",
"No settings were changed.",
"These results do not confirm Amplifier is working.",
"Next: Tell Amplifier what went wrong and ask for the CLI doctor agent.",
"Technical details: amplifier doctor --verbose (review before sharing).",
))
return "\n".join(lines)


def render_text(report: dict) -> str:
"""Render the allowlisted report, with no resolution or health inference."""
lines = [
Expand Down
70 changes: 56 additions & 14 deletions tests/check_doctor_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,31 +65,56 @@ def _run(command, args, env, project, code, *, collection=None):
if code == 2:
assert result.stdout == ""
assert errors == [
"Usage: amplifier doctor [--format text|json] [--session SESSION_ID]",
"Usage: amplifier doctor [--format text|json] [--session SESSION_ID] [--verbose]",
"Error: invalid_doctor_arguments",
]
else:
assert errors == [], errors
if "--help" in args:
assert "Show technical details (text only; does not affect JSON)." in " ".join(result.stdout.split())
if collection is not None:
assert result.stdout.count("\n") == 1
report = json.loads(result.stdout)
assert report["collection_status"] == collection
assert report["health"] == {"status": "not_assessed", "reason": "collection_only"}
return report
if "json" in args:
assert result.stdout.count("\n") == 1
report = json.loads(result.stdout)
assert report["collection_status"] == collection
assert report["health"] == {"status": "not_assessed", "reason": "collection_only"}
return report
if "--verbose" in args:
assert result.stdout.startswith("Amplifier doctor (passive)\n")
assert "Collection: " + collection + " (not a health verdict)" in result.stdout
assert "Effective settings: unknown (effective_resolution_not_performed)" in result.stdout
else:
header = {
"complete": "Configuration report collected",
"partial": "Configuration report incomplete",
"refused": "Configuration report could not be collected",
}[collection]
assert result.stdout.startswith("Amplifier doctor\n" + header + "\n")
assert "AI connections and running session: Not tested" in result.stdout
assert "These results do not confirm Amplifier is working." in result.stdout
assert str(project) not in result.stdout
assert env["AMPLIFIER_HOME"] not in result.stdout
return result.stdout


def _check_entry(command, root, env, ownership_pth):
project = root / "project"
app_home = Path(env["AMPLIFIER_HOME"])
for args, code, status in (
(["--format", "json"], 0, "complete"), ([], 0, None),
(["--format", "json"], 0, "complete"), ([], 0, "complete"),
(["--verbose"], 0, "complete"),
(["--format", "text", "--verbose"], 0, "complete"),
(["--format", "json", "--verbose"], 0, "complete"),
(["--help"], 0, None),
(["--verbose", "--help"], 0, None),
(["--session", "synthetic_session-01", "--format", "json"], 0, "complete"),
(["--format", "synthetic-secret"], 2, None),
(["--unknown=synthetic-secret"], 2, None),
(["--session", "../synthetic-secret"], 2, None),
(["--session"], 2, None),
(["synthetic-secret"], 2, None),
(["--verbose=synthetic-secret"], 2, None),
(["--verbose", "--format", "synthetic-secret"], 2, None),
):
_run(command, args, env, project, code, collection=status)
assert not app_home.exists()
Expand All @@ -100,7 +125,13 @@ def _check_entry(command, root, env, ownership_pth):
settings.write_text("bundle: [synthetic-secret", encoding="utf-8")
before = settings.read_bytes()
try:
_run(command, ["--format", "json"], env, project, 1, collection="partial")
for args in (
["--format", "json"], ["--format", "json", "--verbose"],
[], ["--verbose"], ["--format", "text", "--verbose"],
):
output = _run(command, args, env, project, 1, collection="partial")
if not args:
assert "User settings (global): Could not parse format or encoding" in output
assert settings.read_bytes() == before
assert list(app_home.iterdir()) == [settings]
finally:
Expand All @@ -116,12 +147,23 @@ def _check_entry(command, root, env, ownership_pth):
guarded_env = dict(env)
if override:
guarded_env["AMPLIFIER_ALLOW_SHARED_VENV"] = "1"
report = _run(
command, ["--format", "json"], guarded_env, project, code,
collection=collection,
)
assert report["ownership"]["status"] == status
assert report["ownership"]["foreign_count"] == 1
for args in (
["--format", "json"], ["--format", "json", "--verbose"],
[], ["--verbose"], ["--format", "text", "--verbose"],
):
output = _run(command, args, guarded_env, project, code, collection=collection)
if "json" in args:
assert output["ownership"]["status"] == status
assert output["ownership"]["foreign_count"] == 1
elif "--verbose" in args:
assert "Ownership: " + status in output
elif override:
assert "An existing ownership override allowed collection." in output
else:
assert "User settings (global): Not read" in output
assert "Project settings: Not read" in output
assert "Local settings: Not read" in output
assert "Collection stopped: This installation is linked to another Amplifier home." in output
assert not app_home.exists()
assert not list(project.iterdir())
finally:
Expand Down
Loading
Loading