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
5 changes: 5 additions & 0 deletions .claude/skills/porting-to-canyonos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ description: Port existing Python agents—including LangChain, LangGraph, CrewA
Requires Python, Docker, and the `canyonos` CLI. `prepare.py` uses the Python
standard library; `validate.py` also requires `pyyaml`.

When invoked by an unattended `canyonos build -y`, never ask a question or wait
for approval. Use and report the documented defaults. If an action requires
approval or has no safe documented default, report it as a blocker and stop
without a question. Never deploy or ask whether to deploy from that flow.

## Progress

Copy this checklist into the response and update it while working:
Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/porting-to-canyonos/references/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ Prefer running `canyonos config` when an interactive terminal is available;
otherwise reproduce View/Change in conversation. Do not ask for derived values
such as entrypoints or requirements.

An unattended `canyonos integrate` run must not block on this interaction. Use
and report the displayed defaults. Never invent EC2 infrastructure identifiers:
without them, keep the entry `local`.
An unattended `canyonos build -y` run must not block on this interaction or ask
questions. Use and report the displayed defaults. Never invent EC2
infrastructure identifiers: without them, keep the entry `local`.

## Agent declarations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ Report:
- unresolved runtime blockers;
- intentionally omitted unreachable dependencies or source surfaces.

Then stop and ask exactly one direct approval question:
In an attended porting session, stop and ask exactly one direct approval
question:

> Gap validation exits 0 -- static checks only; no image was built and no
> request served. Run `canyonos deploy` now? This will build images and start
> the deployment.

Do not treat silence, an unattended run, or the original request to “port” as
approval.
For an unattended `canyonos build -y`, instead report the validation result and
stop without asking this question. The build command only creates and validates
the port; it never deploys. Do not treat silence, an unattended run, or the
original request to “port” as approval.

## Deploy only after approval

Expand Down
94 changes: 78 additions & 16 deletions cli/canyonos/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
then launch that agent with a prompt to apply it to the current project.

--agent/--scope/-y replace the two menus, so the command also runs where there
is no tty. The command's exit status is the agent's: what the port produced is
the skill's contract, not this command's.
is no tty, and -y is the unattended form whether or not one is attached. The
command's exit status is the port's: the agent can end its session having asked
a question nobody answered, so what the port produced is checked here with the
skill's own validator rather than taken on trust.
"""

import os
Expand Down Expand Up @@ -51,6 +53,19 @@ def _tarball_url(ref):
"modifications should be put into a new .car folder."
)

UNATTENDED_NOTE = (
" This build is unattended: there is no terminal and nobody can answer you. "
"Do not ask questions or request approval. Use the skill's documented unattended "
"defaults and report the choices you made. Complete the whole porting checklist: "
"the port is done only when validation of .car exits 0. Stop after reporting the "
"validation result; canyonos build never deploys or asks whether to deploy. If a "
"required decision has no safe documented default, report it as a blocker and stop "
"without a question."
)

CAR_DIR = ".car"
VALIDATOR = "validate.py"

# The leaf name of every install path must match the skill's own `name:`
# frontmatter or the agent won't resolve it.

Expand Down Expand Up @@ -234,31 +249,73 @@ def install_skill(dest, source=SKILL_SOURCE):
return False


def launch_agent(agent, prompt):
"""Run the agent over `prompt`. Returns its exit status, or None if there
was no agent to run.
def launch_agent(agent: str, prompt: str, unattended: bool) -> int | None:
"""Run the agent over `prompt`. Returns its exit status, or None if
there was no agent to run.

Attended, the agent owns the screen with its own TUI. Unattended it is
asked for a transcript instead, since nobody is watching one.
Attended, the agent owns the screen with its own TUI. Unattended it is asked
for a transcript instead, since nobody is watching one, and told as much in
the prompt so it stops posing questions into an empty room.
"""
spec = AGENTS[agent]
if not shutil.which(spec["cli"]):
ui.fail(f"`{spec['cli']}` not found on PATH; install {spec['label']} first.")
return None

# The agent's TUI wants stdin and stdout; anything less and it gets the
# unattended flags instead.
attended = sys.stdin.isatty() and sys.stdout.isatty()
# The agent's TUI wants stdin and stdout; anything less -- or a caller who
# already said not to ask -- and it gets the unattended flags instead.
attended = not unattended and sys.stdin.isatty() and sys.stdout.isatty()
if not attended:
prompt += UNATTENDED_NOTE
Comment thread
coderabbitai[bot] marked this conversation as resolved.
argv = [spec["cli"], *([] if attended else spec["unattended"]), prompt]
# No check=True: the agent exiting non-zero (including the user quitting it)
# is an ordinary outcome, not something to raise a traceback over.
return subprocess.run(argv).returncode
return subprocess.run(argv, check=False).returncode


def run_build(agent=None, scope=None, yes=False):
"""Install the skill and hand the port to a coding agent.
def report_port(skill_dir: str) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed by PR #141 already right? Just look at that and verify whether it does it, because this would just be duplicate logic then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked both candidates — no overlap, so this is not duplicate logic.

build had the same class of bug for a different reason: its exit status was the agent CLI status, and the agent can exit 0 having stopped at checklist step 4 with an unanswered question (CAN-367). No existing code validated the produced .car, so report_port is the only place that verdict is computed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool agreed on merging then

"""Say whether the port landed, and answer True only when it did.

True if the agent ran and exited clean.
The verdict is the skill's own step 4 -- its validator exiting 0 over the
`.car` in this directory -- so a session that stopped early fails here
instead of passing for having exited cleanly. Unverifiable is a failure:
build cannot call a port complete on evidence it never saw.
"""
validator = os.path.join(skill_dir, VALIDATOR)
if not os.path.isdir(CAR_DIR):
ui.fail(f"Port incomplete: no {CAR_DIR}/ was produced.")
return False
if not os.path.isfile(validator):
ui.fail(f"Port unverified: no {VALIDATOR} in {skill_dir}.")
return False

check = subprocess.run(
[sys.executable, validator, CAR_DIR],
capture_output=True,
text=True,
check=False,
)
output = "\n".join(
text.strip() for text in (check.stdout, check.stderr) if text.strip()
)
if output:
ui.say(output)
if check.returncode == 0:
ui.ok(f"Port complete: {CAR_DIR}/ passed validation.")
return True

ui.fail(f"Port incomplete: {CAR_DIR}/ did not pass validation.")
return False


def run_build(
agent: str | None = None, scope: str | None = None, yes: bool = False
) -> bool:
"""Install the skill, hand the port to a coding agent, then check its work.

True only if the agent ran to completion and the `.car` it left validates.
A session that died says nothing about a `.car` an earlier run may have left
in the directory, so it fails without consulting it.
"""
# The menus read keys off stdin and draw on stderr; without both, flags are
# the only way in.
Expand Down Expand Up @@ -290,5 +347,10 @@ def run_build(agent=None, scope=None, yes=False):
return False

ui.say(f"Launching {spec['label']}...")
# None (nothing on PATH) and any non-zero status are both failures.
return launch_agent(agent, BUILD_PROMPT) == 0
status = launch_agent(agent, BUILD_PROMPT, unattended=yes)
if status is None:
return False
if status != 0:
ui.fail(f"Port incomplete: {spec['label']} exited with status {status}.")
return False
return report_port(dest)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 1 addition & 1 deletion cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def add(name, run):
action="store_true",
help=(
f"Never ask: take --agent {DEFAULT_AGENT} and --scope {DEFAULT_SCOPE} "
"for whichever of them was not given"
"for whichever of them was not given, and run the agent unattended"
),
)
add("doctor", lambda args: sys.exit(0 if run_doctor() else 1))
Expand Down
119 changes: 117 additions & 2 deletions tests/test_canyonos_build.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""`canyonos build`'s skill install: where the skill comes from, and what
happens when it can't be installed."""
"""`canyonos build`: where the skill comes from, how the agent is launched, and
what verdict the port it leaves behind gets."""

import pytest

Expand Down Expand Up @@ -76,3 +76,118 @@ def test_the_production_ref_is_never_read_as_a_directory(monkeypatch, tmp_path):

assert build_cmd.install_skill(str(tmp_path / "dest"), build_cmd.SKILL_REF) is True
assert fetched == [build_cmd.SKILL_REF]


@pytest.fixture
def buildable(monkeypatch):
"""Everything run_build drives before the agent succeeds."""
monkeypatch.setattr(build_cmd, "install_skill", lambda *_a, **_k: True)
monkeypatch.setattr(build_cmd, "report_port", lambda _skill_dir: True)


@pytest.fixture
def agent_on_path(monkeypatch):
"""The agent CLI resolves; record the argv it would have been run with."""
monkeypatch.setattr(build_cmd.shutil, "which", lambda _cli: f"/usr/bin/{_cli}")
calls = []

class Completed:
returncode = 0

def run(argv, **_kwargs):
calls.append(argv)
return Completed()

monkeypatch.setattr(build_cmd.subprocess, "run", run)
return calls


def _set_tty(monkeypatch, attached):
monkeypatch.setattr(build_cmd.sys.stdin, "isatty", lambda: attached)
monkeypatch.setattr(build_cmd.sys.stdout, "isatty", lambda: attached)


def test_an_attended_launch_passes_no_unattended_flags(monkeypatch, agent_on_path):
_set_tty(monkeypatch, True)

assert build_cmd.launch_agent("claude", "port it", unattended=False) == 0
assert agent_on_path[0] == ["claude", "port it"]


def test_an_unattended_launch_keeps_its_flags_on_a_tty(monkeypatch, agent_on_path):
_set_tty(monkeypatch, True)

build_cmd.launch_agent("claude", "port it", unattended=True)

argv = agent_on_path[0]
assert argv[1:-1] == build_cmd.AGENTS["claude"]["unattended"]
assert argv[-1].endswith(build_cmd.UNATTENDED_NOTE)


def test_a_launch_without_a_tty_is_unattended(monkeypatch, agent_on_path):
_set_tty(monkeypatch, False)

build_cmd.launch_agent("claude", "port it", unattended=False)

argv = agent_on_path[0]
assert argv[1:-1] == build_cmd.AGENTS["claude"]["unattended"]
assert argv[-1].endswith(build_cmd.UNATTENDED_NOTE)


def test_a_missing_agent_cli_reports_no_status(monkeypatch):
monkeypatch.setattr(build_cmd.shutil, "which", lambda _cli: None)

assert build_cmd.launch_agent("claude", "port it", unattended=True) is None


def test_yes_runs_the_agent_unattended(monkeypatch, buildable, agent_on_path):
_set_tty(monkeypatch, True)

assert build_cmd.run_build(yes=True) is True
assert agent_on_path[0][1:-1] == build_cmd.AGENTS["claude"]["unattended"]


def test_a_failed_agent_never_consults_an_earlier_port(monkeypatch, buildable):
monkeypatch.setattr(build_cmd, "launch_agent", lambda *_a, **_k: 1)
monkeypatch.setattr(
build_cmd,
"report_port",
lambda _skill_dir: pytest.fail("a dead session is no evidence about .car"),
)

assert build_cmd.run_build(yes=True) is False


def test_a_missing_agent_cli_fails_the_build(monkeypatch, buildable):
monkeypatch.setattr(build_cmd, "launch_agent", lambda *_a, **_k: None)

assert build_cmd.run_build(yes=True) is False


def test_a_port_with_no_car_fails(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
skill = tmp_path / "skill"
skill.mkdir()
(skill / build_cmd.VALIDATOR).write_text("")

assert build_cmd.report_port(str(skill)) is False


def test_a_port_with_no_validator_fails(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
(tmp_path / build_cmd.CAR_DIR).mkdir()

assert build_cmd.report_port(str(tmp_path / "skill")) is False


@pytest.mark.parametrize(("status", "passed"), [(0, True), (1, False)])
def test_a_port_takes_its_verdict_from_the_validator(
monkeypatch, tmp_path, status, passed
):
monkeypatch.chdir(tmp_path)
(tmp_path / build_cmd.CAR_DIR).mkdir()
skill = tmp_path / "skill"
skill.mkdir()
(skill / build_cmd.VALIDATOR).write_text(f"raise SystemExit({status})")

assert build_cmd.report_port(str(skill)) is passed
Loading