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.
- {agentsquire-0.3.0 → agentsquire-0.4.0}/CHANGELOG.md +48 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/PKG-INFO +2 -1
- {agentsquire-0.3.0 → agentsquire-0.4.0}/pyproject.toml +2 -1
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/__init__.py +1 -1
- agentsquire-0.4.0/src/agentsquire/cli.py +512 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/harnesses.py +7 -0
- agentsquire-0.4.0/src/agentsquire/interactive.py +126 -0
- agentsquire-0.4.0/tests/test_cli_group.py +542 -0
- agentsquire-0.4.0/tests/test_interactive_install.py +437 -0
- agentsquire-0.4.0/tests/test_interactive_uninstall.py +262 -0
- agentsquire-0.4.0/tests/test_package.py +20 -0
- agentsquire-0.3.0/src/agentsquire/cli.py +0 -198
- agentsquire-0.3.0/tests/test_cli_group.py +0 -268
- agentsquire-0.3.0/tests/test_package.py +0 -5
- {agentsquire-0.3.0 → agentsquire-0.4.0}/.gitignore +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/LICENSE +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/README.md +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/docs/api.md +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/docs/harnesses.md +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/console.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/hashing.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/py.typed +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/roots.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/skills/developing-with-agentsquire/SKILL.md +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/skills.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/sources.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/staleness.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/stamping.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/src/agentsquire/verbs.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_api_lifecycle.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_backends.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_bundled_skill.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_bundled_source.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console_skills.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_console_staleness.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_docs_snippets.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_entry_point.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_guide.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_guide_packaging.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_harness_registry.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_hash.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_install.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_reference_doc.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_root_overrides.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_skill_model.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_source_seam.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_staleness_check.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_status.py +0 -0
- {agentsquire-0.3.0 → agentsquire-0.4.0}/tests/test_uninstall.py +0 -0
- {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
|
+
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.
|
|
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]
|
|
@@ -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
|