Skip to content

feat(bundle): an artifacts/<name>/ folder can be a CLI, installed into agents like a generated one - #198

Merged
earakely-scale merged 6 commits into
mainfrom
feat/bundle-cli-folders
Oct 14, 2026
Merged

earakely-scale merged 6 commits into
mainfrom
feat/bundle-cli-folders

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A bundle's artifacts/<name>/ folder can now be a CLI: an artifact.toml with type = "cli", command_name and entrypoint, over the folder's files. Until now such a folder was refused before any write (writing a cli artifact isn't supported yet).

artifacts/hello/
  artifact.toml     # type = "cli"
                    # command_name = "hello"
                    # entrypoint = "bin/hello"
  bin/hello
  lib/greeting.txt

A task installs it into an agent the way it installs a generated CLI: load_artifact with artifact_id = "hello" (with an env, whose gateway URL install writes beside the entrypoint), then add_skills with cli_artifact_ids = ["hello"].

How

  • CliArtifact.put:
    • Takes either cli_dir or files (the folder walk's listing), as SkillArtifact.put does. Only a listing must hold the entrypoint as one of its keys; a cli_dir put checks the file as before, so an entrypoint like ./bin/x still works.
    • Uploads under a prefix of its own for each attempt (ArtifactStore.attempt_prefix), so a retry after a failed upload works. Before, a failed upload left write-once objects at the version's prefix and blocked the retry.
    • This is the hot-path part. The worker's build_mcp_cli writes CLIs through put, and new CLIs now land at artifacts/cli/<key>/<version>-<attempt>/. Readers go through the file universe's object URLs, so nothing reads the old layout.
  • CliArtifact.from_toml / accept_toml: these check before any write, and every problem is reported together:
    • command_name and entrypoint are required, and nothing else is taken. env_id / env_version record the env a generated CLI came from, so they aren't keys.
    • command_name is a plain name, since the CLI is installed at /opt/cli/<command_name>.
    • The entrypoint is one of the folder's files.
    • A .env next to the entrypoint is refused, because installing writes the gateway URL there. install_cli and this check share the name, GATEWAY_ENV_FILE.
  • A plan check for CLI loads: a load_artifact step that names one of the bundle's CLIs must name an agent and an env. No two of a task's loads may install one command_name into one agent, where the second would replace the first. Both used to fail mid-run, after the deploys. The check reads the steps the plan already built (_Planner.steps).
  • plan._FOLDER_WALKS: CLIs use entry_files, as skills do, so the ledger tracks them and rewrites one when its files or toml change.
  • AuthoringContext.try_files(): this is the folder's files, or None when the walk itself fails. Without it, accept_toml would report the walk's problem a second time.
  • The test echo agent: a run <command> prompt line runs that command in its container and adds a ran <command>: <output> line.

Tests

  • Unit:
    • cli_folder_test.py: a folder written over its files (not its toml) under an attempt prefix; reuse, and rewrites on a file or toml edit; a walk problem reported once; seven refused artifact.toml shapes, a load missing its agent or its env, and two CLIs of one command in one agent (refused) or in two (allowed).
    • cli_artifact_test.py: a folder and a listing write the same files; one of the two is required; the entrypoint must be listed; an interrupted put can be retried.
    • The composition test now expects the attempt form of a CLI's key.
    • Unit tier: 7,943 passed.
  • Slow tier, run_bundle_cli_folder_test.py (72 s on a Mac):
    • A task deploys the items MCP server through the gateway and the echo agent, loads the CLI folder into the agent, and attaches it with add_skills.
    • The agent runs it. The CLI prints its own lib/greeting.txt and the gateway URL's scheme from the .env install wrote. The check also sees the hello-cli skill.
    • An edit to lib/greeting.txt writes v2, and the next run installs and runs the new one.
    • The same task also builds the env's CLI with build_mcp_cli, as the worker does, through put's cli_dir path. It installs that CLI too, and the agent adds an item through it and lists it back from the gateway.
    • The other run_bundle_* slow tests that use the echo agent pass with its new run line.
  • Live on modal_vm: the same bundle ran with --sandbox modal_vm on local stores: the gateway env and the echo agent on Modal VMs, both CLIs installed into the remote agent. It passed in 203 s, and both sandboxes were torn down. The agent's reply:
    ran /opt/cli/hello/bin/hello: hello from a bundle cli, gateway https
    ran /opt/cli/items/bin/items items-add-item --item from-cli: {"count": 1}
    ran /opt/cli/items/bin/items list-items: {"items": ["from-cli"]}
    skill hello-cli: SKILL.md=659d0c074059
    

Limits

  • Installing makes only the entrypoint executable, as for a generated CLI, and uploads don't keep file modes. A CLI whose entrypoint runs another of its files directly, rather than through sh, needs to make it executable itself.
  • Loading a CLI still needs an env, as for a generated CLI, since install writes that env's gateway URL.
  • As with skills, a CLI reads its file universe at its latest version, not the version it was written with.

Hot path, through the worker's build_mcp_cli, so this takes the bump-version label.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with a non-blocking cleanup issue remaining in the test agent.

Fix All in CursorFindings

  1. P2 Stopped requests leave commands running ▶
Fix with agent prompt
### Issue 1
tst/data/a2a_agent/agent.py:91-104
`_ran` does not stop its command when the request is canceled. Its timeout branch also kills only the shell, so commands with child processes can keep running after the timeout reply. These processes can keep writing files or affect later requests in the same test container.

Start the command in its own process group, stop that group on timeout or cancellation, and wait for it to exit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds support for CLI folders in bundles and uploads each CLI under a fresh attempt prefix.

  • Bundle folders can now be installed as agent CLIs.
  • Bundle plans check CLI loads before writing.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["CLI folder"] --> B["Check settings and files"]
  B --> C["Upload under fresh attempt prefix"]
  C --> D["Store CLI and file records"]
  D --> E["load_artifact"]
  E --> F["Install files and gateway .env"]
  F --> G["Agent runs CLI"]
Loading

Reviews (5) · Last reviewed commit: "test(bundle): cover build_mcp_cli's put ..." · Reviewed by Greptile

earakely-scale and others added 2 commits October 14, 2026 08:18
…o agents like a generated one

A folder whose artifact.toml says type = "cli", with command_name and entrypoint, is written as a CliArtifact over
the files its walk lists, and checked before any write: the entrypoint is one of its files, command_name is a plain
name, and no .env sits beside the entrypoint, where installing writes the gateway URL. CliArtifact.put takes a
file listing as SkillArtifact.put does and uploads under a prefix of its own per attempt, so a retry after a failed
upload works. The test echo agent runs a prompt's "run <command>" lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/artifact/artifacts/cli.py Outdated
Comment on lines +87 to +93
process = await asyncio.create_subprocess_shell(command, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT)
try:
output, _ = await asyncio.wait_for(process.communicate(), RUN_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
process.kill()
return f"ran {command}: timed out after {RUN_TIMEOUT_SECONDS}s"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Stopped requests leave commands running

_ran does not stop its command when the request is canceled. Its timeout branch also kills only the shell, so commands with child processes can keep running after the timeout reply. These processes can keep writing files or affect later requests in the same test container.

Start the command in its own process group, stop that group on timeout or cancellation, and wait for it to exit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tst/data/a2a_agent/agent.py
Line: 87-93

Comment:
**Stopped requests leave commands running**

`_ran` does not stop its command when the request is canceled. Its timeout branch also kills only the shell, so commands with child processes can keep running after the timeout reply. These processes can keep writing files or affect later requests in the same test container.

Start the command in its own process group, stop that group on timeout or cancellation, and wait for it to exit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

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.

Valid, thanks. Fixed in b0c3cbf: the command starts in a process group of its own, which is killed on timeout or cancellation and then awaited. Checked by hand: a timed-out sleep 30 & sleep 30 leaves no process behind.

earakely-scale and others added 4 commits October 14, 2026 08:49
… a test agent's run with all it started

Files below the .env install writes beside the entrypoint are refused too, since install writes that path as a file.
The echo agent's run lines start their command in a process group of their own and stop the group, then wait for
it, when the command times out or the request is cancelled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fore any write

A load_artifact step naming one of the bundle's CLIs must name an agent and an env, whose gateway the CLI is pointed
at, and no two of a task's loads may install one command_name into one agent, where the second would replace the
first. Both used to fail mid-run, after the deploys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…le once, reuse the plan's built steps

CliArtifact.put checks the entrypoint is among the files only for a listing, so a cli_dir put takes an entrypoint
like ./bin/x as before. install_cli and the bundle's check share GATEWAY_ENV_FILE. The plan keeps the steps
_build_tasks builds, so the CLI load check reads them rather than building every step again, and checks a step's
agent and env once however many CLIs it loads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… slow test

The task also builds the items env's CLI with build_mcp_cli, as the worker does, through CliArtifact.put's cli_dir
path, installs it into the agent beside the folder's CLI, and has the agent add an item through it and list it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 5e5ff90 into main Oct 14, 2026
13 of 14 checks passed
@earakely-scale
earakely-scale deleted the feat/bundle-cli-folders branch October 14, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant