Skip to content

Commit 4ee74d9

Browse files
Ruari-Phippsclaude
andcommitted
feat: update AI agent skills as part of poly update
poly update now refreshes the installed skills after the CLI (also when the CLI is already current). --cli-only and --skills-only narrow it to one half; --skills-only works on editable installs, where the CLI half is refused. Skill failures in a combined update warn without failing the command; in --json mode npx output is captured so stdout stays a single object. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e3d6b97 commit 4ee74d9

3 files changed

Lines changed: 225 additions & 11 deletions

File tree

‎src/poly/cli_commands/skills.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,22 @@ def node_gate_reason() -> str | None:
5353
return None
5454

5555

56-
def _run_npx_skills(args: list[str]) -> bool:
56+
def _run_npx_skills(args: list[str], quiet: bool = False) -> bool:
5757
"""Run ``npx -y skills@<pin> <args>``, streaming output to the terminal.
5858
59+
Args:
60+
args: Arguments for the skills CLI, e.g. ``["update", "-y"]``.
61+
quiet: Capture output instead of streaming it — for callers whose own
62+
stdout must stay machine-readable (``--json``).
63+
5964
Returns:
6065
True if the command exited 0. Never raises — failures here must not
6166
abort the caller's remaining steps.
6267
"""
6368
command = ["npx", "-y", SKILLS_NPX_PACKAGE, *args]
6469
logger.debug("Running: %s", " ".join(command))
6570
try:
66-
result = subprocess.run(command, timeout=NPX_TIMEOUT_SECONDS)
71+
result = subprocess.run(command, timeout=NPX_TIMEOUT_SECONDS, capture_output=quiet)
6772
except Exception as e:
6873
logger.debug("npx skills invocation failed: %s", e)
6974
return False
@@ -94,13 +99,17 @@ def install_skills(
9499
return _run_npx_skills(args)
95100

96101

97-
def update_skills(global_only: bool = False) -> bool:
102+
def update_skills(global_only: bool = False, quiet: bool = False) -> bool:
98103
"""Update previously installed skills to their latest versions.
99104
105+
Args:
106+
global_only: Update only user-level skills, not the current project's.
107+
quiet: Capture npx output instead of streaming it (for ``--json``).
108+
100109
Returns:
101110
True on success, False otherwise (never raises).
102111
"""
103112
args = ["update", "-y"]
104113
if global_only:
105114
args.append("--global")
106-
return _run_npx_skills(args)
115+
return _run_npx_skills(args, quiet=quiet)

‎src/poly/cli_commands/update.py‎

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
get_package_version,
2424
is_newer_version,
2525
)
26+
from poly.cli_commands.skills import node_gate_reason, update_skills
2627
from poly.constants import POLY_HOME_DIR
2728
from poly.output.json_output import json_print
2829

@@ -68,13 +69,15 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P
6869
"update",
6970
parents=[parents.verbose, parents.debug, parents.json],
7071
formatter_class=RawTextHelpFormatter,
71-
help="Update the Poly CLI to the latest version.",
72+
help="Update the Poly CLI and its AI agent skills to the latest version.",
7273
description=(
73-
"Update the Poly CLI to the latest version.\n\n"
74+
"Update the Poly CLI and its AI agent skills to the latest version.\n\n"
7475
"Examples:\n"
7576
" poly update\n"
7677
" poly update --check\n"
7778
" poly update --to 0.52.0\n"
79+
" poly update --cli-only\n"
80+
" poly update --skills-only\n"
7881
"\n"
7982
"The CLI also notices new releases on its own, at most once every 12\n"
8083
"hours. Set POLY_NO_UPDATE_CHECK=1 to silence that; it is already\n"
@@ -96,16 +99,58 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P
9699
help="Install a specific version instead of the latest, e.g. --to 0.52.0.",
97100
)
98101

102+
scope_group = update_parser.add_mutually_exclusive_group()
103+
scope_group.add_argument(
104+
"--cli-only",
105+
action="store_true",
106+
help="Update the CLI only, skipping the AI agent skills.",
107+
)
108+
scope_group.add_argument(
109+
"--skills-only",
110+
action="store_true",
111+
help="Update the AI agent skills only, skipping the CLI.",
112+
)
113+
99114
@classmethod
100115
def run(cls, args: Namespace) -> None:
101116
"""Run the update command."""
102-
cls.update(args.check, args.json, args.to)
117+
cls.update(
118+
args.check,
119+
args.json,
120+
args.to,
121+
cli_only=args.cli_only,
122+
skills_only=args.skills_only,
123+
)
103124

104125
@classmethod
105-
def update(cls, check: bool, output_json: bool, target_version: str | None = None) -> None:
106-
"""Update the Poly CLI to the latest version, or to ``target_version`` if given."""
126+
def update(
127+
cls,
128+
check: bool,
129+
output_json: bool,
130+
target_version: str | None = None,
131+
cli_only: bool = False,
132+
skills_only: bool = False,
133+
) -> None:
134+
"""Update the Poly CLI (and its AI agent skills) to the latest version.
135+
136+
With ``target_version``, install that CLI version instead of the latest.
137+
``cli_only`` and ``skills_only`` narrow the update to one half; the
138+
skills half never applies to ``--check``, which is a CLI version check.
139+
"""
107140
from poly.output.console import info, success
108141

142+
if skills_only:
143+
# Deliberately not gated on refuse_if_not_upgradable: an editable/dev
144+
# install cannot upgrade the CLI in place, but its skills still can be.
145+
updated = cls.update_skills_step(output_json, required=True)
146+
if output_json:
147+
json_print({"success": updated, "skills_updated": updated})
148+
elif updated:
149+
success("AI agent skills updated.")
150+
if not updated:
151+
sys.exit(1)
152+
return
153+
109154
# Refuse before hitting the network or announcing anything, so an install we
110155
# cannot upgrade is not told that an update is on the way.
111156
if cls.refuse_if_not_upgradable(output_json):
@@ -122,8 +167,16 @@ def update(cls, check: bool, output_json: bool, target_version: str | None = Non
122167
else:
123168
update_available, target = cls.check_for_updates()
124169
if not update_available:
170+
# The CLI is current, but the skills may not be — the default
171+
# update still refreshes them.
172+
skills_updated = None
173+
if not check and not cli_only:
174+
skills_updated = cls.update_skills_step(output_json, required=False)
125175
if output_json:
126-
json_print({"update_available": False, "latest_version": target})
176+
result = {"update_available": False, "latest_version": target}
177+
if skills_updated is not None:
178+
result["skills_updated"] = skills_updated
179+
json_print(result)
127180
else:
128181
info("Poly CLI is already up to date, no update needed.")
129182
info(f"Current version: {get_package_version()}")
@@ -140,11 +193,56 @@ def update(cls, check: bool, output_json: bool, target_version: str | None = Non
140193
info(f"Updating Poly CLI to version {target}...")
141194
if not cls.perform_update(output_json, target_version):
142195
return
196+
skills_updated = None
197+
if not cli_only:
198+
skills_updated = cls.update_skills_step(output_json, required=False)
143199
if output_json:
144-
json_print({"success": True, "latest_version": target})
200+
result = {"success": True, "latest_version": target}
201+
if skills_updated is not None:
202+
result["skills_updated"] = skills_updated
203+
json_print(result)
145204
else:
146205
success(f"Poly CLI updated to version {target}.")
147206

207+
@classmethod
208+
def update_skills_step(cls, output_json: bool, required: bool) -> bool:
209+
"""Update installed AI agent skills via the pinned ``npx skills`` package.
210+
211+
Args:
212+
output_json: Keep stdout machine-readable — npx output is captured
213+
and no console messages are printed.
214+
required: The skills are the whole point of the invocation
215+
(``--skills-only``): report failure as an error instead of a
216+
warning. Never exits — the caller owns the exit code.
217+
218+
Returns:
219+
True if the skills were updated.
220+
"""
221+
from poly.output.console import error, info, warning
222+
223+
gate_reason = node_gate_reason()
224+
if gate_reason:
225+
message = f"Cannot update AI agent skills: {gate_reason}."
226+
if output_json:
227+
# The caller folds the failure into its own JSON output.
228+
logger.debug(message)
229+
elif required:
230+
error(message)
231+
else:
232+
warning(f"{message} Skipping.")
233+
return False
234+
235+
if not output_json:
236+
info("Updating AI agent skills...")
237+
updated = update_skills(quiet=output_json)
238+
if not updated and not output_json:
239+
message = "AI agent skill update failed."
240+
if required:
241+
error(message)
242+
else:
243+
warning(f"{message} Run 'poly update --skills-only' to retry.")
244+
return updated
245+
148246
@staticmethod
149247
def check_for_updates() -> tuple[bool, str]:
150248
"""Check whether a newer Poly CLI release is available on PyPI.

‎src/poly/tests/cli_test.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5223,6 +5223,7 @@ def setUp(self):
52235223
self.check_for_updates = self._patch_command("check_for_updates", (True, "0.54.0"))
52245224
self.check_version_exists = self._patch_command("check_version_exists", True)
52255225
self.perform_update = self._patch_command("perform_update", True)
5226+
self.update_skills_step = self._patch_command("update_skills_step", True)
52265227
for console_function in ("info", "success"):
52275228
patcher = patch(f"poly.output.console.{console_function}")
52285229
patcher.start()
@@ -5287,6 +5288,69 @@ def test_update_available_installs_latest_with_no_pinned_version(self):
52875288

52885289
self.perform_update.assert_called_once_with(False, None)
52895290

5291+
def test_default_update_also_updates_the_skills(self):
5292+
"""A plain 'poly update' refreshes the AI agent skills after the CLI."""
5293+
UpdateCommand.update(check=False, output_json=False)
5294+
5295+
self.perform_update.assert_called_once()
5296+
self.update_skills_step.assert_called_once_with(False, required=False)
5297+
5298+
def test_cli_only_skips_the_skills(self):
5299+
"""'poly update --cli-only' updates the CLI without touching the skills."""
5300+
UpdateCommand.update(check=False, output_json=False, cli_only=True)
5301+
5302+
self.perform_update.assert_called_once()
5303+
self.update_skills_step.assert_not_called()
5304+
5305+
def test_check_does_not_touch_the_skills(self):
5306+
"""'--check' is a CLI version check; the skills are left alone."""
5307+
UpdateCommand.update(check=True, output_json=False)
5308+
5309+
self.update_skills_step.assert_not_called()
5310+
5311+
def test_up_to_date_cli_still_updates_the_skills(self):
5312+
"""Being on the latest CLI version still refreshes the skills."""
5313+
self.check_for_updates.return_value = (False, "0.53.0")
5314+
5315+
UpdateCommand.update(check=False, output_json=False)
5316+
5317+
self.perform_update.assert_not_called()
5318+
self.update_skills_step.assert_called_once_with(False, required=False)
5319+
5320+
def test_skills_only_updates_skills_and_nothing_else(self):
5321+
"""'--skills-only' skips the CLI paths entirely, including the upgradable gate."""
5322+
UpdateCommand.update(check=False, output_json=False, skills_only=True)
5323+
5324+
self.refuse_if_not_upgradable.assert_not_called()
5325+
self.check_for_updates.assert_not_called()
5326+
self.perform_update.assert_not_called()
5327+
self.update_skills_step.assert_called_once_with(False, required=True)
5328+
5329+
def test_skills_only_failure_exits_non_zero(self):
5330+
"""A failed '--skills-only' run is the whole command failing."""
5331+
self.update_skills_step.return_value = False
5332+
5333+
with self.assertRaises(SystemExit) as raised:
5334+
UpdateCommand.update(check=False, output_json=False, skills_only=True)
5335+
5336+
self.assertEqual(raised.exception.code, 1)
5337+
5338+
def test_skills_failure_does_not_fail_a_combined_update(self):
5339+
"""In a default update the skills half is best-effort — no exit, CLI result stands."""
5340+
self.update_skills_step.return_value = False
5341+
5342+
UpdateCommand.update(check=False, output_json=False)
5343+
5344+
self.perform_update.assert_called_once()
5345+
5346+
def test_cli_only_and_skills_only_are_mutually_exclusive(self):
5347+
"""The parser rejects '--cli-only --skills-only' as contradictory."""
5348+
cli = AgentStudioCLI()
5349+
cli.register_commands()
5350+
5351+
with self.assertRaises(SystemExit):
5352+
cli._create_parser().parse_args(["update", "--cli-only", "--skills-only"])
5353+
52905354
def test_not_upgradable_install_is_refused_before_any_lookup(self):
52915355
"""An editable or ephemeral install refuses without consulting PyPI first."""
52925356
self.refuse_if_not_upgradable.return_value = True
@@ -5381,6 +5445,49 @@ def isatty(self) -> bool:
53815445
return self.is_a_tty
53825446

53835447

5448+
class UpdateSkillsStepTest(unittest.TestCase):
5449+
"""Tests for UpdateCommand.update_skills_step, the npx-backed skills half."""
5450+
5451+
def setUp(self):
5452+
"""Stub the Node gate and the npx runner, and silence console output."""
5453+
self.node_gate = patch("poly.cli_commands.update.node_gate_reason", return_value=None)
5454+
self.mock_gate = self.node_gate.start()
5455+
self.addCleanup(self.node_gate.stop)
5456+
self.skills_update = patch("poly.cli_commands.update.update_skills", return_value=True)
5457+
self.mock_update = self.skills_update.start()
5458+
self.addCleanup(self.skills_update.stop)
5459+
for console_function in ("info", "error", "warning"):
5460+
patcher = patch(f"poly.output.console.{console_function}")
5461+
patcher.start()
5462+
self.addCleanup(patcher.stop)
5463+
5464+
def test_missing_node_skips_without_running_npx(self):
5465+
"""A gated environment reports failure without attempting the update."""
5466+
self.mock_gate.return_value = "Node.js (with npx) was not found on your PATH"
5467+
5468+
self.assertFalse(UpdateCommand.update_skills_step(output_json=False, required=False))
5469+
self.mock_update.assert_not_called()
5470+
5471+
def test_json_mode_runs_npx_quietly(self):
5472+
"""--json output stays a single object: npx output is captured, not streamed."""
5473+
updated = UpdateCommand.update_skills_step(output_json=True, required=False)
5474+
5475+
self.assertTrue(updated)
5476+
self.mock_update.assert_called_once_with(quiet=True)
5477+
5478+
def test_interactive_mode_streams_npx_output(self):
5479+
"""Without --json the npx output is streamed for the user to follow."""
5480+
UpdateCommand.update_skills_step(output_json=False, required=False)
5481+
5482+
self.mock_update.assert_called_once_with(quiet=False)
5483+
5484+
def test_failure_returns_false_without_exiting(self):
5485+
"""The step never exits — the caller owns the exit code."""
5486+
self.mock_update.return_value = False
5487+
5488+
self.assertFalse(UpdateCommand.update_skills_step(output_json=False, required=True))
5489+
5490+
53845491
class StartupUpdateMessageTest(unittest.TestCase):
53855492
"""Tests for display_update_message, the passive check run at the start of every command."""
53865493

0 commit comments

Comments
 (0)