Skip to content

Commit 5365196

Browse files
committed
feat(agents): deploy only the skills an agent references, inline
Fold referenced-skill deployment into deploy_agents.py: resolve_skills now publishes each skill the agent spec names (deploy_skills.deploy_skill — create or new version) and attaches it at latest, instead of relying on a blanket 'deploy_skills.py --skill all' step. Only the skills an agent actually uses are published. Drop that step from deploy-agents.yml. A missing repo skill dir now fails fast; dry-run publishes nothing. Standalone skill publishing still lives in deploy-skills.yml. Tests + README updated.
1 parent 9f22966 commit 5365196

4 files changed

Lines changed: 112 additions & 49 deletions

File tree

.github/workflows/deploy-agents.yml

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Deploy agent + environment specs under `claude_platform/` to Claude Managed Agents platform.
22
# Manually dispatched (like deploy-skills.yml / deploy-images.yml) — a maintainer chooses which agent
33
# to publish after the spec change has been reviewed and merged. Resources are resolved by name
4-
# (create-if-absent, else update in place); agents reference skills by name. Skills are deployed FIRST
5-
# in this same run so every referenced skill exists before an agent attaches it. See
4+
# (create-if-absent, else update in place); agents reference skills by name. deploy_agents.py deploys
5+
# exactly the skills each selected agent references (create or new version), then attaches them at
6+
# latest — only the skills an agent uses are published. See
67
# docs/plans/2026-07-24-001-feat-agent-spec-deploy-pipeline-plan.md.
78
#
89
# Prerequisites (set once in repo Settings, on the `prod` environment):
@@ -37,16 +38,11 @@ jobs:
3738
- name: Install uv
3839
uses: astral-sh/setup-uv@v7
3940

40-
# Skills first (R3): an agent references skills by name, resolved to skill_ids at deploy time —
41-
# they must already exist on the platform. Deploying all skills here keeps them current before
42-
# any agent attaches them.
43-
- name: Deploy skills
44-
working-directory: scripts
45-
env:
46-
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
47-
run: uv run python deploy_skills.py --skill all
48-
49-
- name: Deploy agent(s) and environment(s)
41+
# deploy_agents.py deploys exactly the skills each selected agent references (create or new
42+
# version) before attaching them at latest — so only the skills an agent uses are published,
43+
# and there's no separate skills step here. To publish a skill independently of any agent, use
44+
# the "Deploy skills to Claude platform" workflow (deploy-skills.yml).
45+
- name: Deploy agent(s), their skills, and environment(s)
5046
working-directory: scripts
5147
env:
5248
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

scripts/README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,25 @@ environment.
3232
## `deploy_agents.py` — publish agents + environments (recurring)
3333

3434
Deploys the agent and environment specs under `claude_platform/`, resolving each **by name**
35-
(create if absent, else update in place). An agent's `skills` are listed by name and translated to
36-
platform `skill_id`s at deploy time (reusing `deploy_skills.find_existing_skill_id`), pinned to
37-
`latest` — so the skill must be deployed first. Environments are always synced so an agent's
35+
(create if absent, else update in place). An agent's `skills` are listed by name; deploy_agents
36+
**publishes exactly those skills first** (via `deploy_skills.deploy_skill` — create or new version),
37+
then attaches them at `latest`. So only the skills an agent actually references are published — no
38+
separate skills step, no blanket `--skill all`. Environments are always synced so an agent's
3839
environment exists.
3940

40-
Normally run by the **Deploy agents to Managed Agents** GitHub Action (manual dispatch, choose which
41-
agent), which runs `deploy_skills.py --skill all` first. To run locally against the real API:
41+
Normally run by the **Deploy agents to Claude platform** GitHub Action (manual dispatch, choose which
42+
agent). To run locally against the real API:
4243

4344
```sh
44-
# deploy skills first, then agents (agents reference skills by name):
45-
ANTHROPIC_API_KEY=sk-ant-api03-... uv run python deploy_skills.py --skill all
4645
ANTHROPIC_API_KEY=sk-ant-api03-... uv run python deploy_agents.py --agent all
4746
# or a single agent (filename stem under claude_platform/agents/):
4847
ANTHROPIC_API_KEY=sk-ant-api03-... uv run python deploy_agents.py --agent lik-query-project-index
49-
# --dry-run prints the plan (still queries the platform to decide create-vs-update) without mutating.
48+
# --dry-run prints the plan (still queries the platform to decide create-vs-update) without publishing.
5049
```
5150

51+
To publish a skill on its own — independent of any agent — use `deploy_skills.py` (or the
52+
**Deploy skills to Claude platform** workflow) directly.
53+
5254
An agent references skills by **name**, not `skill_id` — no platform ids live in the repo. lik-ui
5355
likewise resolves agent/environment names to ids at startup, so the roster (`lik-ui`'s `agents.toml`)
5456
and specs stay id-free and survive re-initializing into a new workspace.

scripts/deploy_agents.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44
docs/plans/2026-07-24-001-feat-agent-spec-deploy-pipeline-plan.md). Each agent is a raw
55
platform-format YAML under `claude_platform/agents/`; each environment is a raw-format YAML under
66
`claude_platform/environments/`. This script resolves each resource **by name** (create if absent,
7-
else update in place) and, for agents, translates a skill *name* into its platform `skill_id` before
8-
the SDK call — so no ids live in the repo.
7+
else update in place). An agent references its skills *by name*; this script deploys exactly those
8+
skills first (via `deploy_skills.deploy_skill` — create or new version) and attaches them at
9+
`latest`, so no ids live in the repo and only the skills an agent actually uses are published.
910
10-
Run from the repo root (deploy skills first so referenced skills exist — the CI workflow does this):
11+
Run from the repo root:
1112
1213
ANTHROPIC_API_KEY=sk-ant-... uv run --project scripts python scripts/deploy_agents.py --agent all
1314
1415
`--agent` accepts `all` (every spec under `claude_platform/agents/`) or a single agent spec name
1516
(the filename stem). Environments are always synced (all specs under `claude_platform/environments/`),
1617
so an agent's environment exists regardless of which agent is selected. `--dry-run` prints the planned
17-
actions (still queries the platform to decide create-vs-update) without mutating.
18+
actions (still queries the platform to decide create-vs-update) without publishing anything.
1819
19-
Skill-name resolution reuses `deploy_skills.find_existing_skill_id`, so the two scripts agree on how a
20-
name maps to a platform id. The Anthropic SDK is imported lazily (via `deploy_skills.build_client`) so
21-
the pure helpers below stay importable and unit-testable without the dependency.
20+
Skill deployment reuses `deploy_skills.deploy_skill` / `select_skill_dirs`, so agents and the
21+
standalone skill deploy agree on packaging and id resolution. The Anthropic SDK is imported lazily
22+
(via `deploy_skills.build_client`) so the pure helpers below stay importable and unit-testable.
2223
"""
2324

2425
from __future__ import annotations
@@ -101,21 +102,25 @@ def find_existing_environment(client, name: str):
101102
return _match_by_name(list(client.beta.environments.list()), name)
102103

103104

104-
def resolve_skills(client, skills_spec) -> list[dict]:
105+
def resolve_skills(client, skills_spec, *, deploy: bool = True) -> list[dict]:
105106
"""Translate a spec's by-name ``skills`` list into platform skill refs pinned to ``latest``.
106107
107-
Each entry must be ``{name: <skill dir name>}``. Errors if a referenced skill isn't on the
108-
platform yet — deploy it first with deploy_skills.py (the CI workflow runs that step first)."""
108+
Each entry must be ``{name: <skill dir name>}``. When ``deploy`` is true (a real run), the
109+
referenced skill is published first — ``deploy_skills.deploy_skill`` creates it or adds a new
110+
version — and its resulting id is used, so deploying an agent also deploys exactly the skills it
111+
references (and nothing else). Errors if a referenced skill has no directory under
112+
``claude_platform/skills/``. When ``deploy`` is false (dry run), nothing is published: the id is
113+
resolved from the platform if the skill already exists, else shown as a placeholder."""
109114
resolved: list[dict] = []
110115
for entry in skills_spec or []:
111116
name = entry.get("name") if isinstance(entry, dict) else None
112117
if not name:
113118
raise ValueError(f"skill entry must reference a skill by name: {entry!r}")
114-
skill_id = ds.find_existing_skill_id(client, name)
115-
if skill_id is None:
116-
raise ValueError(
117-
f"skill '{name}' not found on the platform — deploy it first with deploy_skills.py"
118-
)
119+
if deploy:
120+
skill_dir = ds.select_skill_dirs(name)[0] # validates the skill exists in the repo
121+
skill_id = ds.deploy_skill(client, skill_dir).skill_id
122+
else:
123+
skill_id = ds.find_existing_skill_id(client, name) or "<will-be-created>"
119124
resolved.append({"type": "custom", "skill_id": skill_id, "version": "latest"})
120125
return resolved
121126

@@ -146,7 +151,9 @@ def deploy_agent(client, spec_path: Path, *, apply: bool = True) -> DeployResult
146151
agent's current version so the new version does not drop fields."""
147152
spec = read_spec(spec_path)
148153
name = spec["name"]
149-
payload = {**spec, "skills": resolve_skills(client, spec.get("skills"))}
154+
# Deploy exactly the skills this agent references (create/version), then attach at latest.
155+
# In a dry run nothing is published.
156+
payload = {**spec, "skills": resolve_skills(client, spec.get("skills"), deploy=apply)}
150157
existing = find_existing_agent(client, name)
151158
if not apply:
152159
return DeployResult(

scripts/test_deploy_agents.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,35 @@ def update(self, env_id, **kwargs):
5757
return SimpleNamespace(id=env_id, name=kwargs.get("name"))
5858

5959

60+
class FakeSkills:
61+
"""Records create / versions.create and answers list() from a seeded set (mirrors
62+
test_deploy_skills.FakeSkills) — deploy_agents now publishes referenced skills itself."""
63+
64+
def __init__(self, existing=None):
65+
# existing: list of (skill_id, display_title)
66+
self._existing = [SimpleNamespace(id=i, display_title=t) for i, t in (existing or [])]
67+
self.create_calls = []
68+
self.version_calls = []
69+
self.versions = SimpleNamespace(create=self._version_create)
70+
71+
def list(self):
72+
return list(self._existing)
73+
74+
def create(self, *, files, display_title):
75+
self.create_calls.append({"files": files, "display_title": display_title})
76+
return SimpleNamespace(id="skill_new", latest_version=111)
77+
78+
def _version_create(self, skill_id, *, files):
79+
self.version_calls.append({"skill_id": skill_id, "files": files})
80+
return SimpleNamespace(version=222)
81+
82+
6083
class FakeClient:
6184
def __init__(self, *, agents=None, environments=None, skills_list=None):
6285
self.beta = SimpleNamespace(
6386
agents=FakeAgents(agents),
6487
environments=FakeEnvironments(environments),
65-
skills=SimpleNamespace(
66-
list=lambda: [SimpleNamespace(id=i, display_title=t) for i, t in (skills_list or [])]
67-
),
88+
skills=FakeSkills(skills_list),
6889
)
6990

7091

@@ -97,17 +118,25 @@ def __init__(self, *, agents=None, environments=None, skills_list=None):
97118
"""
98119

99120

100-
def _wire(tmp_path: Path, monkeypatch, *, agent=AGENT_YAML, env=ENV_YAML):
121+
def _wire(tmp_path: Path, monkeypatch, *, agent=AGENT_YAML, env=ENV_YAML, skill="lik-query-project-index"):
101122
agents_root = tmp_path / "agents"
102123
envs_root = tmp_path / "environments"
124+
skills_root = tmp_path / "skills"
103125
agents_root.mkdir()
104126
envs_root.mkdir()
127+
skills_root.mkdir()
105128
if agent is not None:
106129
(agents_root / "lik-query-project-index.yaml").write_text(agent, encoding="utf-8")
107130
if env is not None:
108131
(envs_root / "lik-ui.yaml").write_text(env, encoding="utf-8")
132+
if skill is not None:
133+
# The skill the agent references, so deploy_agents can publish it (dir name == SKILL.md name).
134+
sdir = skills_root / skill
135+
sdir.mkdir()
136+
(sdir / "SKILL.md").write_text(f"---\nname: {skill}\ndescription: test\n---\n# {skill}\n", encoding="utf-8")
109137
monkeypatch.setattr(da, "AGENTS_ROOT", agents_root)
110138
monkeypatch.setattr(da, "ENVIRONMENTS_ROOT", envs_root)
139+
monkeypatch.setattr(da.ds, "SKILLS_ROOT", skills_root)
111140
return agents_root, envs_root
112141

113142

@@ -148,27 +177,50 @@ def test_match_by_name_ambiguous_raises():
148177
da._match_by_name(items, "A")
149178

150179

151-
# --- resolve_skills (name -> id substitution) --------------------------------------------------
180+
# --- resolve_skills (deploy referenced skills + name -> id substitution) -----------------------
181+
182+
183+
def test_resolve_skills_publishes_new_skill_and_substitutes_id(tmp_path, monkeypatch):
184+
_wire(tmp_path, monkeypatch)
185+
client = FakeClient(skills_list=[]) # skill not on platform yet -> create
186+
out = da.resolve_skills(client, [{"name": "lik-query-project-index"}])
187+
assert out == [{"type": "custom", "skill_id": "skill_new", "version": "latest"}]
188+
assert client.beta.skills.create_calls # the referenced skill was published
152189

153190

154-
def test_resolve_skills_substitutes_ids():
191+
def test_resolve_skills_publishes_new_version_when_skill_exists(tmp_path, monkeypatch):
192+
_wire(tmp_path, monkeypatch)
155193
client = FakeClient(skills_list=[("skill_abc", "lik-query-project-index")])
156194
out = da.resolve_skills(client, [{"name": "lik-query-project-index"}])
157195
assert out == [{"type": "custom", "skill_id": "skill_abc", "version": "latest"}]
196+
assert client.beta.skills.version_calls # new version, not a duplicate create
197+
assert client.beta.skills.create_calls == []
158198

159199

160-
def test_resolve_skills_missing_skill_fails_fast():
161-
client = FakeClient(skills_list=[("skill_abc", "some-other-skill")])
200+
def test_resolve_skills_missing_repo_dir_fails_fast(tmp_path, monkeypatch):
201+
_wire(tmp_path, monkeypatch)
202+
client = FakeClient(skills_list=[])
162203
with pytest.raises(ValueError):
163-
da.resolve_skills(client, [{"name": "lik-query-project-index"}])
204+
da.resolve_skills(client, [{"name": "no-such-skill-in-repo"}])
205+
assert client.beta.skills.create_calls == [] # nothing published
164206

165207

166-
def test_resolve_skills_entry_without_name_fails():
208+
def test_resolve_skills_entry_without_name_fails(tmp_path, monkeypatch):
209+
_wire(tmp_path, monkeypatch)
167210
client = FakeClient(skills_list=[])
168211
with pytest.raises(ValueError):
169212
da.resolve_skills(client, [{"skill_id": "skill_abc"}])
170213

171214

215+
def test_resolve_skills_dry_run_does_not_publish(tmp_path, monkeypatch):
216+
_wire(tmp_path, monkeypatch)
217+
client = FakeClient(skills_list=[("skill_abc", "lik-query-project-index")])
218+
out = da.resolve_skills(client, [{"name": "lik-query-project-index"}], deploy=False)
219+
assert out == [{"type": "custom", "skill_id": "skill_abc", "version": "latest"}]
220+
assert client.beta.skills.create_calls == []
221+
assert client.beta.skills.version_calls == []
222+
223+
172224
# --- deploy_agent: create vs update ------------------------------------------------------------
173225

174226

@@ -204,13 +256,15 @@ def test_deploy_existing_agent_updates_preserving_version(tmp_path, monkeypatch)
204256
assert call["skills"] == [{"type": "custom", "skill_id": "skill_abc", "version": "latest"}]
205257

206258

207-
def test_deploy_agent_missing_skill_makes_no_write(tmp_path, monkeypatch):
208-
agents_root, _ = _wire(tmp_path, monkeypatch)
209-
client = FakeClient(agents=[], skills_list=[]) # skill not on platform
259+
def test_deploy_agent_missing_skill_dir_makes_no_write(tmp_path, monkeypatch):
260+
# The referenced skill has no directory under claude_platform/skills/ -> fail fast, nothing written.
261+
agents_root, _ = _wire(tmp_path, monkeypatch, skill=None) # no skill dir in the repo
262+
client = FakeClient(agents=[], skills_list=[])
210263
with pytest.raises(ValueError):
211264
da.deploy_agent(client, agents_root / "lik-query-project-index.yaml")
212265
assert client.beta.agents.create_calls == []
213266
assert client.beta.agents.update_calls == []
267+
assert client.beta.skills.create_calls == []
214268

215269

216270
def test_deploy_agent_dry_run_makes_no_write(tmp_path, monkeypatch):
@@ -257,6 +311,8 @@ def test_main_syncs_envs_then_selected_agent(tmp_path, monkeypatch):
257311
# environment created before the agent
258312
assert client.beta.environments.create_calls[0]["name"] == "lik-ui"
259313
assert client.beta.agents.create_calls[0]["name"] == "LIK Query: Project Index"
314+
# the agent's referenced skill was published as part of the same run
315+
assert client.beta.skills.version_calls # skill existed -> new version published
260316

261317

262318
def test_main_dry_run_mutates_nothing(tmp_path, monkeypatch):
@@ -268,3 +324,5 @@ def test_main_dry_run_mutates_nothing(tmp_path, monkeypatch):
268324
assert rc == 0
269325
assert client.beta.agents.create_calls == []
270326
assert client.beta.environments.create_calls == []
327+
assert client.beta.skills.create_calls == [] # dry run publishes no skills either
328+
assert client.beta.skills.version_calls == []

0 commit comments

Comments
 (0)