Files
bot-bottle/claude_bottle/backend/smolmachines/provision/skills.py
T
didericis-claude 9e3b7e441e
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 43s
feat(smolmachines): provision_prompt + provision_skills (PRD 0023 chunk 4a)
First slice of chunk 4: implement the two provisioning methods
that don't depend on agent-image tooling beyond `cp` and
`mkdir`. provision_ca / provision_git / provision_supervise
land once the agent-image gap is solved (chunk 4b+) — they need
update-ca-certificates, git, and the claude binary respectively,
none of which the chunk-2d alpine placeholder provides.

What this PR ships:

- `claude_bottle/backend/smolmachines/provision/` subpackage
  with `prompt.py` + `skills.py`. Each routes through
  `smolvm.machine_cp` / `machine_exec`. provision_prompt mirrors
  the docker contract (file always copied; return value drives
  --append-system-prompt-file iff the agent has a non-empty
  prompt). provision_skills mkdir + cp per skill, matching
  the docker backend's loop.
- prepare.py now writes the prompt file under
  agent_state_dir(slug) with the agent's `prompt` body, mode
  0o600. The in-guest path is `/root/.claude-bottle-prompt.txt`
  (alpine has no `node` user; will become `/home/node/...` once
  the real claude-bottle image lands).
- launch.py calls `provision(plan, machine_name)` after
  machine_start. The returned prompt path threads to
  SmolmachinesBottle so exec_claude can add
  --append-system-prompt-file when the agent has a prompt.
- backend.py: provision_prompt / provision_skills now real;
  provision_git is a deliberate stub (waiting on the git-gate
  inner Plan + git in the agent image). provision_supervise
  stays the chunk-2d stub.

Tests:
- 7 new unit cases (test_smolmachines_provision.py): argv
  shape (mocked smolvm.machine_cp / .machine_exec),
  prompt return-value contract, no-op-with-no-skills,
  CLAUDE_BOTTLE_GUEST_SKILLS_DIR override, fail-on-missing-skill.
- 1 new integration case in test_smolmachines_launch.py:
  end-to-end verification that the prompt file lands in the
  alpine guest at /root/.claude-bottle-prompt.txt with the
  expected content (via `bottle.exec("cat ...")`). The smoke +
  the two TSI probes stay green.

552 unit + 4 integration (Darwin+smolvm+docker gated) passing.

What's left in chunk 4:
- 4b: thread the inner Plans (PipelockProxyPlan / EgressPlan /
  GitGatePlan / SupervisePlan) through prepare + launch so the
  bundle daemons actually run (currently daemons_csv="").
- 4c: the agent-image-conversion gap — get claude-code + git +
  curl + ca-certificates into the guest image (build a
  .smolmachine via `pack create --from-vm` after manual setup,
  or push the docker image to a registry smolvm can pull).
- 4d: provision_ca + provision_git + provision_supervise once
  4b + 4c land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 05:08:17 -04:00

60 lines
2.1 KiB
Python

"""Copy host-side skill directories into a running smolmachines
bottle.
Skills are validated on the host before launch by
`BottleBackend._validate_skills`; this module assumes that
validation has already run. A skill that disappears between
validation and copy still dies loudly rather than silently
producing a partial guest."""
from __future__ import annotations
import os
from ....log import die, info
from ...util import host_skill_dir
from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan
# In-guest path mirrors the docker backend's claude-skills
# convention (~/.claude/skills/<name>/). For smolmachines the
# agent is root by default; chunk 5+ may swap to a node user
# in the real claude-bottle image, at which point this path
# follows /home/node/ — the env knob below provides the override.
_DEFAULT_SKILLS_DIR = "/root/.claude/skills"
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the guest's equivalent path.
For each skill: `mkdir -p` the destination, then
`smolvm machine cp` the host source dir over. No-op when the
agent has no skills.
smolvm machine cp on a directory copies recursively (same
semantics as `cp -r`); unlike docker cp's trailing-slash
convention, smolvm doesn't need the `/.` suffix dance."""
agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills:
return
skills_dir = os.environ.get(
"CLAUDE_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
)
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
for name in agent.skills:
src = host_skill_dir(name)
if not os.path.isdir(src):
die(
f"skill {name!r} disappeared from host between "
f"validation and copy at {src}."
)
dst = f"{skills_dir}/{name}"
info(f"copying skill {name} into {target}:{dst}")
# Wipe any prior copy so re-runs don't accumulate.
_smolvm.machine_exec(target, ["rm", "-rf", dst])
_smolvm.machine_cp(src, f"{target}:{dst}")