agentsquire 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {agentsquire-0.3.0 → agentsquire-0.4.0}/CHANGELOG.md +48 -0
  2. {agentsquire-0.3.0 → agentsquire-0.4.0}/PKG-INFO +2 -1
  3. {agentsquire-0.3.0 → agentsquire-0.4.0}/pyproject.toml +2 -1
  4. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/__init__.py +1 -1
  5. agentsquire-0.4.0/src/agentsquire/cli.py +512 -0
  6. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/harnesses.py +7 -0
  7. agentsquire-0.4.0/src/agentsquire/interactive.py +126 -0
  8. agentsquire-0.4.0/tests/test_cli_group.py +542 -0
  9. agentsquire-0.4.0/tests/test_interactive_install.py +437 -0
  10. agentsquire-0.4.0/tests/test_interactive_uninstall.py +262 -0
  11. agentsquire-0.4.0/tests/test_package.py +20 -0
  12. agentsquire-0.3.0/src/agentsquire/cli.py +0 -198
  13. agentsquire-0.3.0/tests/test_cli_group.py +0 -268
  14. agentsquire-0.3.0/tests/test_package.py +0 -5
  15. {agentsquire-0.3.0 → agentsquire-0.4.0}/.gitignore +0 -0
  16. {agentsquire-0.3.0 → agentsquire-0.4.0}/LICENSE +0 -0
  17. {agentsquire-0.3.0 → agentsquire-0.4.0}/README.md +0 -0
  18. {agentsquire-0.3.0 → agentsquire-0.4.0}/docs/api.md +0 -0
  19. {agentsquire-0.3.0 → agentsquire-0.4.0}/docs/harnesses.md +0 -0
  20. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/console.py +0 -0
  21. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/hashing.py +0 -0
  22. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/py.typed +0 -0
  23. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/roots.py +0 -0
  24. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/skills/developing-with-agentsquire/SKILL.md +0 -0
  25. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/skills.py +0 -0
  26. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/sources.py +0 -0
  27. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/staleness.py +0 -0
  28. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/stamping.py +0 -0
  29. {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/verbs.py +0 -0
  30. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_api_lifecycle.py +0 -0
  31. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_backends.py +0 -0
  32. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_bundled_skill.py +0 -0
  33. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_bundled_source.py +0 -0
  34. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console.py +0 -0
  35. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console_skills.py +0 -0
  36. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console_staleness.py +0 -0
  37. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_docs_snippets.py +0 -0
  38. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_entry_point.py +0 -0
  39. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_guide.py +0 -0
  40. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_guide_packaging.py +0 -0
  41. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_harness_registry.py +0 -0
  42. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_hash.py +0 -0
  43. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_install.py +0 -0
  44. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_reference_doc.py +0 -0
  45. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_root_overrides.py +0 -0
  46. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_skill_model.py +0 -0
  47. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_source_seam.py +0 -0
  48. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_staleness_check.py +0 -0
  49. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_status.py +0 -0
  50. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_uninstall.py +0 -0
  51. {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_update.py +0 -0
@@ -8,6 +8,54 @@ The version is declared in two places that are kept in sync by the release
8
8
  tooling: `__version__` in `src/agentsquire/__init__.py` and `version` in
9
9
  `pyproject.toml`.
10
10
 
11
+ ## [0.4.0]
12
+
13
+ ### Added
14
+ - **Interactive `skills uninstall`.** On an interactive terminal, a bare
15
+ `<consumer> skills uninstall` lists exactly the installed-and-ours skills -
16
+ enumerated from on-disk provenance stamps across every detected harness and
17
+ scope, so unstamped and foreign-stamped directories are never offered - then
18
+ removes the subset you pick after a destructive confirm summary. It re-checks
19
+ each stamp immediately before deleting, and is gated by the same TTY/CI/
20
+ explicit-flag rules as install. `-y/--yes` pre-answers the destructive
21
+ confirm (the picker still runs); `--no-input` skips the prompt entirely and
22
+ removes over the flag-selected `--harness NAME[:scope]` targets (default: all
23
+ detected at `--scope`, today's non-interactive meaning).
24
+ - **Interactive `skills install`.** On an interactive terminal, a bare
25
+ `<consumer> skills install` now prompts: a checkbox of the detected
26
+ harnesses, then a per-harness scope select (a harness with only one scope,
27
+ e.g. hermes, is never offered an impossible choice), then a confirm summary
28
+ listing every `(harness, scope)` pair before anything is written. It ships
29
+ inside the mountable `skills_command_group`, so every consumer gets it with
30
+ no extra code. The prompt is launched only when stdin is an interactive TTY,
31
+ `CI` is unset, and none of `--harness`, `--scope`, `-y/--yes`, or
32
+ `--no-input` was passed - agents and scripts always run non-interactively.
33
+ New `--no-input` (force non-interactive) and `-y/--yes` (assume yes) flags.
34
+ Cancelling any prompt aborts with a notice and no writes; selecting nothing
35
+ or declining the confirm is a clean no-op; and because the confirm is the
36
+ single last gate, aborting a multi-harness plan writes none of it (never a
37
+ partial subset). Zero detected harnesses is the same clean error as the
38
+ non-interactive path, with no empty checkbox shown.
39
+ - **`--harness` is now repeatable with an optional `:scope` suffix across all
40
+ four verbs (install, status, update, uninstall).** `install --harness
41
+ claude-code:project --harness pi` operates on that subset, each at its own
42
+ scope (a bare `NAME` uses the top-level `--scope`); omitting `--harness` still
43
+ targets every detected harness at `--scope`, and a single `--harness NAME`
44
+ behaves exactly as before. An unknown harness, a supported-but-undetected
45
+ harness, an invalid `:scope` value, or a `NAME:scope` the harness cannot
46
+ satisfy (e.g. `hermes:project`) each fail with a named error and write
47
+ nothing - a multi-target plan never partially installs.
48
+ - **`questionary` is now a hard runtime dependency.** It backs the interactive
49
+ skill installer; a base install pulls it in with no extras, so the
50
+ interactive path always imports with no optional-import branching. It is
51
+ imported only in the interactive front-end module, so a non-interactive or
52
+ CI invocation never loads a terminal-UI library.
53
+
54
+ ### Changed
55
+ - **install now resolves to an explicit `(harness, scope)` plan run by one
56
+ shared executor.** Internal refactor with no change to non-interactive
57
+ behavior; it is the seam the interactive front-end will hand its plan to.
58
+
11
59
  ## [0.3.0]
12
60
 
13
61
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentsquire
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Let a Python package carry its own agent integrations and install them into whatever agent harness is present.
5
5
  Author: TacoTakumi
6
6
  License-Expression: MIT
@@ -20,6 +20,7 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: click
22
22
  Requires-Dist: pyyaml
23
+ Requires-Dist: questionary
23
24
  Description-Content-Type: text/markdown
24
25
 
25
26
  # AgentSquire
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentsquire"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Let a Python package carry its own agent integrations and install them into whatever agent harness is present."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -28,6 +28,7 @@ classifiers = [
28
28
  dependencies = [
29
29
  "pyyaml",
30
30
  "click",
31
+ "questionary",
31
32
  ]
32
33
 
33
34
  [project.scripts]
@@ -6,7 +6,7 @@ enumerate skills from a source, detect harnesses, then install / status /
6
6
  update / uninstall. See docs/api.md for the reference.
7
7
  """
8
8
 
9
- __version__ = "0.3.0"
9
+ __version__ = "0.4.0"
10
10
 
11
11
  from agentsquire.harnesses import (
12
12
  CLAUDE_CODE,
@@ -0,0 +1,512 @@
1
+ """Ready-made CLI subcommand group consumers mount into their own CLI.
2
+
3
+ One factory call — parameterized by (package, resource path, default scope) —
4
+ returns a click group with install/status/update/uninstall, so a consumer CLI
5
+ exposes e.g. ``<consumer> skills install`` with no glue code (REQ-15). The
6
+ group works identically mounted into a click app or a typer app via typer's
7
+ click compatibility. It carries no consumer-specific logic; everything the
8
+ consumer-specific comes in through the factory parameters.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import importlib.metadata
15
+ import os
16
+ import shutil
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import click
22
+ from click.core import ParameterSource
23
+
24
+ from agentsquire.harnesses import (
25
+ SCOPES,
26
+ HarnessBackend,
27
+ HarnessNotDetectedError,
28
+ UnknownHarnessError,
29
+ UnsupportedScopeError,
30
+ default_registry,
31
+ )
32
+ from agentsquire.roots import resolve_roots
33
+ from agentsquire.stamping import read_stamp
34
+ from agentsquire.verbs import InstallResult
35
+ from agentsquire.verbs import install as install_verb
36
+ from agentsquire.verbs import status as status_verb
37
+ from agentsquire.verbs import uninstall as uninstall_verb
38
+ from agentsquire.verbs import update as update_verb
39
+ from agentsquire.sources import BundledPackageDataSource, SkillSource
40
+
41
+
42
+ def _consumer_version(package: str, source_package: str) -> str:
43
+ try:
44
+ return importlib.metadata.version(source_package)
45
+ except importlib.metadata.PackageNotFoundError:
46
+ return getattr(importlib.import_module(package), "__version__", "unknown")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class HarnessTarget:
51
+ """One (harness, scope) pair a verb operates on.
52
+
53
+ ``explicit`` is True when the user named this harness — then a scope the
54
+ backend lacks is a hard error; when False (a detected harness) it is a
55
+ named skip, never an aborted run. This is the unit of a *plan* shared by
56
+ every verb: install, status, update, and uninstall differ only in the verb
57
+ they run per target, and the interactive front-end differs from the flag
58
+ path only in how it builds the list of targets (REQ-14, REQ-15).
59
+ """
60
+
61
+ backend: HarnessBackend
62
+ scope: str
63
+ explicit: bool = False
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class InstallOutcome:
68
+ """One target's structured result; ``result`` is None when the target was
69
+ skipped because the backend lacks the requested scope (non-strict)."""
70
+
71
+ target: HarnessTarget
72
+ result: InstallResult | None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class PlanExecution:
77
+ """The structured outcome of running a whole install plan."""
78
+
79
+ outcomes: list[InstallOutcome]
80
+
81
+ @property
82
+ def ok(self) -> bool:
83
+ return all(o.result.ok for o in self.outcomes if o.result is not None)
84
+
85
+
86
+ def execute_install_plan(
87
+ source: SkillSource,
88
+ plan: list[HarnessTarget],
89
+ *,
90
+ home: Path,
91
+ project: Path,
92
+ source_package: str,
93
+ source_version: str,
94
+ ) -> PlanExecution:
95
+ """Run an install plan and return its structured per-target results.
96
+
97
+ One ``install`` verb call per (harness, scope) target, emitting the same
98
+ per-skill output for every target. This is the single executor both the
99
+ non-interactive flag path and the interactive front-end hand a plan to
100
+ (REQ-15); it imports nothing from questionary/prompt_toolkit. A scope the
101
+ backend lacks is a clean ClickException for an explicit target and a named
102
+ skip otherwise — never an aborted multi-harness run.
103
+ """
104
+ outcomes: list[InstallOutcome] = []
105
+ for target in plan:
106
+ backend, scope = target.backend, target.scope
107
+ try:
108
+ result = install_verb(
109
+ source, backend, scope=scope, home=home, project=project,
110
+ source_package=source_package, source_version=source_version,
111
+ )
112
+ except UnsupportedScopeError as error:
113
+ if target.explicit:
114
+ raise click.ClickException(str(error)) from error
115
+ click.echo(f"skipped {backend.name}: {error}", err=True)
116
+ outcomes.append(InstallOutcome(target=target, result=None))
117
+ continue
118
+ for skill in result.installed:
119
+ click.echo(f"installed {skill.name} -> {skill.path}")
120
+ for skill in result.up_to_date:
121
+ click.echo(f"up-to-date {skill.name} ({backend.name}/{scope})")
122
+ for skill in result.skipped:
123
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
124
+ for violation in result.rejected:
125
+ click.echo(f"invalid skill {violation.message}", err=True)
126
+ outcomes.append(InstallOutcome(target=target, result=result))
127
+ return PlanExecution(outcomes=outcomes)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class RemovableSkill:
132
+ """One installed-and-ours skill directory: our provenance stamp names both
133
+ ``agentsquire`` as installer and the consumer as source package. The unit of
134
+ the interactive uninstall picker (REQ-22)."""
135
+
136
+ name: str
137
+ backend: HarnessBackend
138
+ scope: str
139
+ path: Path
140
+
141
+
142
+ def _is_ours(manifest_text: str, source_package: str) -> bool:
143
+ """Whether a SKILL.md's provenance stamp marks it installed by us for this
144
+ consumer — the same ownership test the uninstall verb applies (REQ-13)."""
145
+ stamp = read_stamp(manifest_text)
146
+ return (
147
+ stamp is not None
148
+ and stamp.get("installer") == "agentsquire"
149
+ and stamp.get("source_package") == source_package
150
+ )
151
+
152
+
153
+ def installed_and_ours(
154
+ backends: list[HarnessBackend],
155
+ *,
156
+ source_package: str,
157
+ home: Path,
158
+ project: Path,
159
+ ) -> list[RemovableSkill]:
160
+ """Every installed-and-ours skill across the detected harnesses and each of
161
+ their scopes, read from on-disk provenance stamps (REQ-22).
162
+
163
+ Enumerates the actual skill directories on disk — not the source's skill
164
+ list — so what is offered is exactly what is installed and stamped as ours;
165
+ unstamped and foreign-stamped directories (and symlinks) are omitted. A
166
+ physical directory is listed once even when two scopes resolve to it (e.g.
167
+ claude-code's user and project skills dirs coincide when run from home).
168
+ """
169
+ found: list[RemovableSkill] = []
170
+ seen: set[Path] = set()
171
+ for backend in backends:
172
+ for scope in backend.supported_scopes():
173
+ root = backend.skills_dir(scope, home=home, project=project)
174
+ if not root.is_dir():
175
+ continue
176
+ for child in sorted(root.iterdir()):
177
+ if child.is_symlink() or not child.is_dir():
178
+ continue
179
+ resolved = child.resolve()
180
+ if resolved in seen:
181
+ continue
182
+ seen.add(resolved)
183
+ manifest = child / "SKILL.md"
184
+ if manifest.is_file() and _is_ours(manifest.read_text(), source_package):
185
+ found.append(
186
+ RemovableSkill(
187
+ name=child.name, backend=backend, scope=scope, path=child
188
+ )
189
+ )
190
+ return found
191
+
192
+
193
+ def execute_uninstall_plan(
194
+ entries: list[RemovableSkill], *, source_package: str
195
+ ) -> list[RemovableSkill]:
196
+ """Remove exactly the selected installed-and-ours entries, re-checking each
197
+ stamp immediately before deletion so a directory that changed hands since it
198
+ was enumerated is left in place. Returns the entries actually removed."""
199
+ removed: list[RemovableSkill] = []
200
+ for entry in entries:
201
+ manifest = entry.path / "SKILL.md"
202
+ if entry.path.is_symlink() or not (
203
+ manifest.is_file() and _is_ours(manifest.read_text(), source_package)
204
+ ):
205
+ click.echo(
206
+ f"skipped {entry.name}: no longer ours "
207
+ f"({entry.backend.name}/{entry.scope})",
208
+ err=True,
209
+ )
210
+ continue
211
+ shutil.rmtree(entry.path)
212
+ click.echo(f"removed {entry.name} ({entry.backend.name}/{entry.scope})")
213
+ removed.append(entry)
214
+ return removed
215
+
216
+
217
+ def _stdin_is_interactive() -> bool:
218
+ """Whether stdin is an interactive TTY. A thin, monkeypatchable wrapper so
219
+ the auto-gate can be exercised in tests without a real terminal (REQ-17)."""
220
+ try:
221
+ return sys.stdin.isatty()
222
+ except (AttributeError, ValueError):
223
+ return False
224
+
225
+
226
+ def _should_prompt(ctx, harnesses, assume_yes=False, no_input=False) -> bool:
227
+ """Whether a verb should launch its interactive TUI (REQ-03, REQ-04).
228
+
229
+ Only on an interactive TTY with none of --harness/--scope/-y/--no-input
230
+ explicitly passed and no CI marker set. Explicitness of --scope is read from
231
+ click's parameter source, so ``--scope user`` (equal to the default) still
232
+ disables the TUI; any explicit flag, a non-TTY stdin, or a set CI variable
233
+ runs the non-interactive flag path and constructs no prompt. Shared by
234
+ install and uninstall; ``assume_yes``/``no_input`` default off for verbs
235
+ that do not (yet) expose those flags.
236
+ """
237
+ if no_input or assume_yes or harnesses:
238
+ return False
239
+ if ctx.get_parameter_source("scope") == ParameterSource.COMMANDLINE:
240
+ return False
241
+ if os.environ.get("CI"):
242
+ return False
243
+ return _stdin_is_interactive()
244
+
245
+
246
+ def skills_command_group(
247
+ package: str,
248
+ resource_path: str = "skills",
249
+ default_scope: str = "user",
250
+ *,
251
+ name: str = "skills",
252
+ source_package: str | None = None,
253
+ source_version: str | None = None,
254
+ home: Path | None = None,
255
+ project: Path | None = None,
256
+ ) -> click.Group:
257
+ """Build the mountable ``skills`` subcommand group for one consumer.
258
+
259
+ ``package`` is the consumer's importable package carrying skills as
260
+ package data under ``resource_path``; ``default_scope`` is the scope a
261
+ plain invocation uses (--scope overrides it, REQ-14). ``source_package``
262
+ and ``source_version`` default to the package name and its installed
263
+ distribution (or ``__version__``) and end up in the provenance stamp.
264
+ ``home`` and ``project`` point the group at explicit directories - tests
265
+ pass fixture paths; production omits them and each invocation resolves
266
+ the real ``Path.home()``/``Path.cwd()``.
267
+ """
268
+ source = BundledPackageDataSource(package, resource_path)
269
+ src_pkg = source_package or package
270
+ src_version = source_version or _consumer_version(package, src_pkg)
271
+
272
+ scope_option = click.option(
273
+ "--scope",
274
+ type=click.Choice(SCOPES),
275
+ default=default_scope,
276
+ show_default=True,
277
+ help="Which harness skills directory to operate on.",
278
+ )
279
+ harness_multi_option = click.option(
280
+ "--harness",
281
+ "harnesses",
282
+ multiple=True,
283
+ metavar="NAME[:SCOPE]",
284
+ help="Operate on this harness; repeatable, with an optional :scope "
285
+ "suffix (default: all detected at --scope).",
286
+ )
287
+
288
+ def build_plan(harness_specs: tuple[str, ...], scope: str):
289
+ """Resolve the repeatable --harness specs into a (plan, home, project).
290
+
291
+ Shared by every verb (REQ-14). No specs keeps today's meaning — every
292
+ detected harness at the top-level ``scope`` (REQ-11). Each
293
+ ``NAME[:scope]`` spec selects one harness at its suffix scope, or the
294
+ top-level ``scope`` when the suffix is omitted (REQ-10); a named
295
+ harness that is unknown, undetected, or given an invalid/unsatisfiable
296
+ scope is a clean, named error validated up front (REQ-12, REQ-13).
297
+ """
298
+ target_home, target_project = resolve_roots(home, project)
299
+ registry = default_registry()
300
+ if not harness_specs:
301
+ backends = registry.detect(home=target_home, project=target_project)
302
+ if not backends:
303
+ raise click.ClickException("no supported harnesses detected")
304
+ plan = [
305
+ HarnessTarget(backend=backend, scope=scope, explicit=False)
306
+ for backend in backends
307
+ ]
308
+ return plan, target_home, target_project
309
+ plan = []
310
+ for spec in harness_specs:
311
+ name, _, suffix = spec.partition(":")
312
+ target_scope = suffix or scope
313
+ try:
314
+ backend = registry.resolve(
315
+ name, home=target_home, project=target_project
316
+ )
317
+ except (UnknownHarnessError, HarnessNotDetectedError) as error:
318
+ raise click.ClickException(str(error)) from error
319
+ if target_scope not in SCOPES:
320
+ raise click.ClickException(
321
+ f"harness {name!r}: unknown scope {target_scope!r}; "
322
+ f"expected one of {', '.join(SCOPES)}"
323
+ )
324
+ # Validate the whole plan before any write: an explicitly named
325
+ # NAME:scope the backend cannot satisfy is a hard error here, so a
326
+ # multi-target plan never partially installs (REQ-12, REQ-24).
327
+ try:
328
+ backend.skills_dir(
329
+ target_scope, home=target_home, project=target_project
330
+ )
331
+ except UnsupportedScopeError as error:
332
+ raise click.ClickException(str(error)) from error
333
+ plan.append(
334
+ HarnessTarget(backend=backend, scope=target_scope, explicit=True)
335
+ )
336
+ return plan, target_home, target_project
337
+
338
+ def run_target(target: HarnessTarget, invoke_verb):
339
+ """One verb call on one plan target. A scope the backend lacks is a
340
+ clean error for an explicitly named target and a named skip otherwise —
341
+ never an aborted multi-harness run. (Explicit targets are validated in
342
+ build_plan, so this branch is reached only for detected ones.)"""
343
+ try:
344
+ return invoke_verb()
345
+ except UnsupportedScopeError as error:
346
+ if target.explicit:
347
+ raise click.ClickException(str(error)) from error
348
+ click.echo(f"skipped {target.backend.name}: {error}", err=True)
349
+ return None
350
+
351
+ group = click.Group(name=name, help="Manage this package's bundled agent skills.")
352
+
353
+ @group.command("install")
354
+ @scope_option
355
+ @harness_multi_option
356
+ @click.option(
357
+ "--no-input", is_flag=True, help="Never prompt; run non-interactively."
358
+ )
359
+ @click.option(
360
+ "-y", "--yes", "assume_yes", is_flag=True,
361
+ help="Assume yes to any confirmation.",
362
+ )
363
+ @click.pass_context
364
+ def install(ctx, scope, harnesses, no_input, assume_yes):
365
+ """Install bundled skills into detected harnesses.
366
+
367
+ On an interactive terminal with no selection or control flag, prompts
368
+ for which harnesses and scopes to install into; otherwise installs into
369
+ the flag-selected targets (default: all detected at --scope).
370
+ """
371
+ if _should_prompt(ctx, harnesses, assume_yes, no_input):
372
+ # Lazy import keeps questionary/prompt_toolkit out of a plain
373
+ # ``import agentsquire.cli`` — they load only when actually
374
+ # prompting (REQ-16).
375
+ from agentsquire.interactive import gather_install_plan
376
+
377
+ target_home, target_project = resolve_roots(home, project)
378
+ backends = default_registry().detect(
379
+ home=target_home, project=target_project
380
+ )
381
+ if not backends:
382
+ raise click.ClickException("no supported harnesses detected")
383
+ plan = gather_install_plan(backends, default_scope=scope)
384
+ if plan is None:
385
+ # Cancelled (Esc/Ctrl-C at any prompt): confirm is the single
386
+ # last gate, so nothing was written (REQ-19, REQ-24).
387
+ click.echo("Aborted; nothing was changed.", err=True)
388
+ ctx.exit(1)
389
+ if not plan:
390
+ # Nothing selected, or the confirm was declined — a clean no-op.
391
+ click.echo("Nothing to install.")
392
+ return
393
+ execution = execute_install_plan(
394
+ source, plan, home=target_home, project=target_project,
395
+ source_package=src_pkg, source_version=src_version,
396
+ )
397
+ if not execution.ok:
398
+ ctx.exit(1)
399
+ return
400
+ plan, plan_home, plan_project = build_plan(harnesses, scope)
401
+ execution = execute_install_plan(
402
+ source, plan, home=plan_home, project=plan_project,
403
+ source_package=src_pkg, source_version=src_version,
404
+ )
405
+ if not execution.ok:
406
+ ctx.exit(1)
407
+
408
+ @group.command("status")
409
+ @scope_option
410
+ @harness_multi_option
411
+ def status(scope, harnesses):
412
+ """Show each bundled skill's state per harness."""
413
+ plan, home, project = build_plan(harnesses, scope)
414
+ for target in plan:
415
+ backend = target.backend
416
+ statuses = run_target(target, lambda: status_verb(
417
+ source, backend, scope=target.scope, home=home, project=project
418
+ ))
419
+ for skill in statuses or ():
420
+ click.echo(
421
+ f"{skill.state.value} {skill.name} ({backend.name}/{target.scope})"
422
+ )
423
+
424
+ @group.command("update")
425
+ @scope_option
426
+ @harness_multi_option
427
+ @click.option("--force", is_flag=True, help="Overwrite locally-modified installs.")
428
+ @click.pass_context
429
+ def update(ctx, scope, harnesses, force):
430
+ """Update stale installed skills to the bundled version."""
431
+ plan, home, project = build_plan(harnesses, scope)
432
+ failed = False
433
+ for target in plan:
434
+ backend = target.backend
435
+ result = run_target(target, lambda: update_verb(
436
+ source, backend, scope=target.scope, home=home, project=project,
437
+ source_package=src_pkg, source_version=src_version, force=force,
438
+ ))
439
+ if result is None:
440
+ continue
441
+ for skill in result.updated:
442
+ click.echo(f"updated {skill.name} -> {skill.path}")
443
+ for skill in result.up_to_date:
444
+ click.echo(f"up-to-date {skill.name} ({backend.name}/{target.scope})")
445
+ for skill in result.skipped:
446
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
447
+ for violation in result.rejected:
448
+ click.echo(f"invalid skill {violation.message}", err=True)
449
+ failed = failed or not result.ok
450
+ if failed:
451
+ ctx.exit(1)
452
+
453
+ @group.command("uninstall")
454
+ @scope_option
455
+ @harness_multi_option
456
+ @click.option(
457
+ "--no-input", is_flag=True, help="Never prompt; run non-interactively."
458
+ )
459
+ @click.option(
460
+ "-y", "--yes", "assume_yes", is_flag=True,
461
+ help="Assume yes to the destructive confirm.",
462
+ )
463
+ @click.pass_context
464
+ def uninstall(ctx, scope, harnesses, no_input, assume_yes):
465
+ """Remove installed skills this package's stamp owns.
466
+
467
+ On an interactive terminal with no selection flag, lists the
468
+ installed-and-ours skills across all detected harnesses and scopes and
469
+ lets you pick which to remove behind a destructive confirm (-y
470
+ pre-answers it); otherwise removes them over the flag-selected targets
471
+ (default: all detected at --scope).
472
+ """
473
+ # -y does not disable the picker here (unlike install): it pre-answers
474
+ # the destructive confirm, so the assume_yes gate flag stays False.
475
+ if _should_prompt(ctx, harnesses, no_input=no_input):
476
+ from agentsquire.interactive import gather_uninstall_plan
477
+
478
+ target_home, target_project = resolve_roots(home, project)
479
+ backends = default_registry().detect(
480
+ home=target_home, project=target_project
481
+ )
482
+ entries = installed_and_ours(
483
+ backends, source_package=src_pkg,
484
+ home=target_home, project=target_project,
485
+ )
486
+ if not entries:
487
+ click.echo("Nothing installed by this package to uninstall.")
488
+ return
489
+ selection = gather_uninstall_plan(entries, assume_yes=assume_yes)
490
+ if selection is None:
491
+ click.echo("Aborted; nothing was changed.", err=True)
492
+ ctx.exit(1)
493
+ if not selection:
494
+ click.echo("Nothing to uninstall.")
495
+ return
496
+ execute_uninstall_plan(selection, source_package=src_pkg)
497
+ return
498
+ plan, home_, project_ = build_plan(harnesses, scope)
499
+ for target in plan:
500
+ backend = target.backend
501
+ result = run_target(target, lambda: uninstall_verb(
502
+ source, backend, scope=target.scope, home=home_, project=project_,
503
+ source_package=src_pkg,
504
+ ))
505
+ if result is None:
506
+ continue
507
+ for skill in result.removed:
508
+ click.echo(f"removed {skill.name} ({backend.name}/{target.scope})")
509
+ for skill in result.skipped:
510
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
511
+
512
+ return group
@@ -65,6 +65,13 @@ class HarnessBackend:
65
65
  root = home if scope == "user" else project
66
66
  return root / relative
67
67
 
68
+ def supported_scopes(self) -> list[str]:
69
+ """The scopes this backend has a skills directory for, in canonical
70
+ order. A harness with only one (e.g. hermes: user only) yields just it,
71
+ so callers never offer or scan an impossible scope."""
72
+ relative = {"user": self.user_skills_dir, "project": self.project_skills_dir}
73
+ return [scope for scope in SCOPES if relative[scope] is not None]
74
+
68
75
  def detect(self, *, home: Path, project: Path) -> bool:
69
76
  return any((home / marker).is_dir() for marker in self.user_marker_dirs) or any(
70
77
  (project / marker).is_dir() for marker in self.project_marker_dirs