agentsquire 0.2.2__tar.gz → 0.3.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 (45) hide show
  1. {agentsquire-0.2.2 → agentsquire-0.3.0}/.gitignore +4 -0
  2. {agentsquire-0.2.2 → agentsquire-0.3.0}/CHANGELOG.md +33 -0
  3. {agentsquire-0.2.2 → agentsquire-0.3.0}/PKG-INFO +37 -1
  4. {agentsquire-0.2.2 → agentsquire-0.3.0}/README.md +36 -0
  5. {agentsquire-0.2.2 → agentsquire-0.3.0}/pyproject.toml +20 -1
  6. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/__init__.py +1 -1
  7. agentsquire-0.3.0/src/agentsquire/console.py +90 -0
  8. agentsquire-0.3.0/src/agentsquire/skills/developing-with-agentsquire/SKILL.md +101 -0
  9. agentsquire-0.3.0/tests/test_bundled_skill.py +73 -0
  10. agentsquire-0.3.0/tests/test_console.py +51 -0
  11. agentsquire-0.3.0/tests/test_console_skills.py +81 -0
  12. agentsquire-0.3.0/tests/test_console_staleness.py +120 -0
  13. agentsquire-0.3.0/tests/test_guide.py +116 -0
  14. agentsquire-0.3.0/tests/test_guide_packaging.py +77 -0
  15. {agentsquire-0.2.2 → agentsquire-0.3.0}/LICENSE +0 -0
  16. {agentsquire-0.2.2 → agentsquire-0.3.0}/docs/api.md +0 -0
  17. {agentsquire-0.2.2 → agentsquire-0.3.0}/docs/harnesses.md +0 -0
  18. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/cli.py +0 -0
  19. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/harnesses.py +0 -0
  20. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/hashing.py +0 -0
  21. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/py.typed +0 -0
  22. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/roots.py +0 -0
  23. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/skills.py +0 -0
  24. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/sources.py +0 -0
  25. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/staleness.py +0 -0
  26. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/stamping.py +0 -0
  27. {agentsquire-0.2.2 → agentsquire-0.3.0}/src/agentsquire/verbs.py +0 -0
  28. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_api_lifecycle.py +0 -0
  29. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_backends.py +0 -0
  30. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_bundled_source.py +0 -0
  31. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_cli_group.py +0 -0
  32. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_docs_snippets.py +0 -0
  33. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_entry_point.py +0 -0
  34. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_harness_registry.py +0 -0
  35. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_hash.py +0 -0
  36. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_install.py +0 -0
  37. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_package.py +0 -0
  38. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_reference_doc.py +0 -0
  39. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_root_overrides.py +0 -0
  40. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_skill_model.py +0 -0
  41. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_source_seam.py +0 -0
  42. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_staleness_check.py +0 -0
  43. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_status.py +0 -0
  44. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_uninstall.py +0 -0
  45. {agentsquire-0.2.2 → agentsquire-0.3.0}/tests/test_update.py +0 -0
@@ -10,3 +10,7 @@ checkouts/
10
10
  # Local scratch / dev notes (never committed, never published)
11
11
  bugs.md
12
12
  feedback.md
13
+
14
+ # specflo local state: the active-project pointer is per-clone session state,
15
+ # not shared config. The durable record lives under docs/projects/.
16
+ .specflo/
@@ -8,6 +8,39 @@ 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.3.0]
12
+
13
+ ### Added
14
+ - **agentsquire now ships its own runnable CLI, `squire` (long alias
15
+ `agentsquire`).** It dogfoods the library's consumer contract end to end,
16
+ carrying one bundled skill, mounting the ready-made skills group, emitting the
17
+ proactive staleness notice, and serving its own reference docs.
18
+ - `squire skills install|status|update|uninstall` - the standard group,
19
+ mounted the production way at user scope by default, operating on
20
+ agentsquire's one bundled skill.
21
+ - `squire guide [TOPIC]` - with no argument lists the reference topics (api,
22
+ harnesses, integration); with a topic, pages that canonical doc as raw
23
+ markdown. An unknown topic is a clean, non-zero error naming the valid
24
+ topics.
25
+ - `squire --version` prints the installed agentsquire version; `squire --help`
26
+ and `agentsquire --help` are identical below the usage line.
27
+ - A proactive staleness notice on stderr points at `squire skills update` when
28
+ an installed copy is out of date; suppressed under CI or
29
+ `AGENTSQUIRE_NO_UPDATE_CHECK`, and it never changes stdout or the exit code.
30
+ - **`developing-with-agentsquire` bundled skill.** agentsquire carries one Agent
31
+ Skill as package data and marks itself skill-carrying under the
32
+ `agentsquire.skills` entry-point group - the same contract it documents for
33
+ consumers.
34
+ - **The three reference docs are force-included into the wheel.** `docs/api.md`,
35
+ `docs/harnesses.md`, and `README.md` are packaged under `agentsquire/_docs/`
36
+ (as `api.md`, `harnesses.md`, `integration.md`) so `guide` has one
37
+ authoritative source that works from an installed wheel, with no committed
38
+ in-package duplicate.
39
+ - **README Installation section.** Documents `pip install agentsquire`,
40
+ installing from a source clone, and - for developers building on agentsquire -
41
+ how to install the bundled `developing-with-agentsquire` skill and reference
42
+ docs into their own harness (`squire skills install`, `squire guide`).
43
+
11
44
  ## [0.2.2]
12
45
 
13
46
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentsquire
3
- Version: 0.2.2
3
+ Version: 0.3.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
@@ -42,6 +42,42 @@ is by marker directory; every operation is local (no network in any verb). Each
42
42
  harness's directories, scopes, and behaviour are recorded in
43
43
  [docs/harnesses.md](docs/harnesses.md).
44
44
 
45
+ ## Installation
46
+
47
+ pip install agentsquire
48
+
49
+ This installs the library and its own CLI, `squire` (aliased `agentsquire`). As
50
+ a consumer you normally add `agentsquire` to your package's dependencies rather
51
+ than having users install it directly - it rides in your wheel, and your users
52
+ only ever see your tool.
53
+
54
+ agentsquire dogfoods its own contract: it carries the
55
+ `developing-with-agentsquire` skill and its reference docs as package data. When
56
+ you build an app on agentsquire, install that skill into your own agent harness
57
+ so the harness has the integration know-how on hand while it works:
58
+
59
+ squire skills install
60
+
61
+ That copies `developing-with-agentsquire` (how to ship skills, mount the group,
62
+ and wire the staleness hook) into every detected harness. The same reference
63
+ docs are served at the terminal from the installed wheel - no checkout needed:
64
+
65
+ squire guide # topics: api, harnesses, integration
66
+ squire guide api # the Python API reference
67
+ squire guide integration # this consumer integration guide
68
+
69
+ ### From source
70
+
71
+ Clone the repository and install it into your environment:
72
+
73
+ git clone https://github.com/TacoTakumi/AgentSquire.git
74
+ cd AgentSquire
75
+ pip install .
76
+
77
+ Add `-e` (`pip install -e .`) for an editable install if you plan to work on
78
+ agentsquire itself. The repo uses [uv](https://docs.astral.sh/uv/): `uv sync`
79
+ provisions a dev environment and `uv run pytest` runs the test suite.
80
+
45
81
  ## Consumer integration guide
46
82
 
47
83
  ### 1. Ship skills as package data
@@ -18,6 +18,42 @@ is by marker directory; every operation is local (no network in any verb). Each
18
18
  harness's directories, scopes, and behaviour are recorded in
19
19
  [docs/harnesses.md](docs/harnesses.md).
20
20
 
21
+ ## Installation
22
+
23
+ pip install agentsquire
24
+
25
+ This installs the library and its own CLI, `squire` (aliased `agentsquire`). As
26
+ a consumer you normally add `agentsquire` to your package's dependencies rather
27
+ than having users install it directly - it rides in your wheel, and your users
28
+ only ever see your tool.
29
+
30
+ agentsquire dogfoods its own contract: it carries the
31
+ `developing-with-agentsquire` skill and its reference docs as package data. When
32
+ you build an app on agentsquire, install that skill into your own agent harness
33
+ so the harness has the integration know-how on hand while it works:
34
+
35
+ squire skills install
36
+
37
+ That copies `developing-with-agentsquire` (how to ship skills, mount the group,
38
+ and wire the staleness hook) into every detected harness. The same reference
39
+ docs are served at the terminal from the installed wheel - no checkout needed:
40
+
41
+ squire guide # topics: api, harnesses, integration
42
+ squire guide api # the Python API reference
43
+ squire guide integration # this consumer integration guide
44
+
45
+ ### From source
46
+
47
+ Clone the repository and install it into your environment:
48
+
49
+ git clone https://github.com/TacoTakumi/AgentSquire.git
50
+ cd AgentSquire
51
+ pip install .
52
+
53
+ Add `-e` (`pip install -e .`) for an editable install if you plan to work on
54
+ agentsquire itself. The repo uses [uv](https://docs.astral.sh/uv/): `uv sync`
55
+ provisions a dev environment and `uv run pytest` runs the test suite.
56
+
21
57
  ## Consumer integration guide
22
58
 
23
59
  ### 1. Ship skills as package data
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentsquire"
7
- version = "0.2.2"
7
+ version = "0.3.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"
@@ -30,6 +30,16 @@ dependencies = [
30
30
  "click",
31
31
  ]
32
32
 
33
+ [project.scripts]
34
+ squire = "agentsquire.console:main"
35
+ agentsquire = "agentsquire.console:main"
36
+
37
+ # agentsquire dogfoods its own consumer contract: it carries one bundled skill
38
+ # (developing-with-agentsquire) and marks itself skill-carrying under the same
39
+ # entry-point group it tells consumers to use. Nothing reads this at launch.
40
+ [project.entry-points."agentsquire.skills"]
41
+ agentsquire = "agentsquire"
42
+
33
43
  [dependency-groups]
34
44
  dev = [
35
45
  "build",
@@ -53,6 +63,15 @@ include = [
53
63
  "/docs/harnesses.md",
54
64
  ]
55
65
 
66
+ # Force-include the three canonical docs into the wheel so `guide` has a single
67
+ # authoritative source with no hand-maintained in-package duplicate. The sources
68
+ # live only under docs/ and README.md; nothing is committed under the package.
69
+ # README.md becomes the `integration` topic.
70
+ [tool.hatch.build.targets.wheel.force-include]
71
+ "docs/api.md" = "agentsquire/_docs/api.md"
72
+ "docs/harnesses.md" = "agentsquire/_docs/harnesses.md"
73
+ "README.md" = "agentsquire/_docs/integration.md"
74
+
56
75
  [tool.pytest.ini_options]
57
76
  testpaths = ["tests"]
58
77
  pythonpath = ["src"]
@@ -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.2.2"
9
+ __version__ = "0.3.0"
10
10
 
11
11
  from agentsquire.harnesses import (
12
12
  CLAUDE_CODE,
@@ -0,0 +1,90 @@
1
+ """The ``squire`` root CLI - agentsquire's own runnable executable.
2
+
3
+ ``squire`` (and its long alias ``agentsquire``) is itself an agentsquire
4
+ consumer: it carries one bundled skill (developing-with-agentsquire), mounts the
5
+ skills subcommand group and a ``guide`` command, and emits the proactive
6
+ staleness notice. This module is that root command group; the two console
7
+ scripts in ``[project.scripts]`` both point here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import version
13
+ from importlib.resources import files as resource_files
14
+ from pathlib import Path
15
+
16
+ import click
17
+
18
+ import agentsquire
19
+ from agentsquire import BundledPackageDataSource, check_stale
20
+ from agentsquire.cli import skills_command_group
21
+
22
+ # The installed agentsquire version, and the single source of truth for
23
+ # ``--version`` (equals agentsquire.__version__, kept in sync by release tooling).
24
+ _VERSION = version("agentsquire")
25
+
26
+ # The `guide` topics, one per canonical doc. Content is force-included into the
27
+ # wheel under agentsquire/_docs/<topic>.md (see pyproject); nothing is committed
28
+ # under the package, so a source checkout falls back to the repo docs/ + README.
29
+ _GUIDE_TOPICS = ("api", "harnesses", "integration")
30
+ _GUIDE_SOURCE_FALLBACK = {
31
+ "api": ("docs", "api.md"),
32
+ "harnesses": ("docs", "harnesses.md"),
33
+ "integration": ("README.md",),
34
+ }
35
+
36
+
37
+ def _read_guide_doc(topic: str) -> str | None:
38
+ """Content for a guide topic: the packaged agentsquire/_docs/<topic>.md when
39
+ installed from a wheel, else the repo docs/ + README in a source checkout."""
40
+ packaged = resource_files("agentsquire") / "_docs" / f"{topic}.md"
41
+ if packaged.is_file():
42
+ return packaged.read_text(encoding="utf-8")
43
+ # Source checkout: <repo>/src/agentsquire/__init__.py -> repo root is parents[2].
44
+ repo_root = Path(agentsquire.__file__).resolve().parents[2]
45
+ fallback = repo_root.joinpath(*_GUIDE_SOURCE_FALLBACK[topic])
46
+ if fallback.is_file():
47
+ return fallback.read_text(encoding="utf-8")
48
+ return None
49
+
50
+
51
+ @click.group()
52
+ @click.version_option(version=_VERSION, prog_name="squire", message="%(version)s")
53
+ def main() -> None:
54
+ """squire - install agentsquire's own agent skills into your harness."""
55
+ # Proactive skill-staleness notice, wired the production way (no home=/
56
+ # project=, so the real roots resolve). Safe by design: notice-only on
57
+ # stderr, never prompts, never mutates, never changes the exit code.
58
+ check_stale(
59
+ BundledPackageDataSource("agentsquire"),
60
+ prog_name="squire",
61
+ update_command="squire skills update",
62
+ )
63
+
64
+
65
+ @main.command()
66
+ @click.argument("topic", required=False)
67
+ def guide(topic: str | None) -> None:
68
+ """Show agentsquire's reference docs. Run with no TOPIC to list them."""
69
+ if topic is None:
70
+ click.echo("Available guide topics:")
71
+ for name in _GUIDE_TOPICS:
72
+ click.echo(f" {name}")
73
+ return
74
+ if topic not in _GUIDE_TOPICS:
75
+ raise click.BadParameter(
76
+ f"unknown topic {topic!r}. Available topics: "
77
+ f"{', '.join(_GUIDE_TOPICS)}.",
78
+ param_hint="TOPIC",
79
+ )
80
+ content = _read_guide_doc(topic)
81
+ if content is None:
82
+ raise click.ClickException(f"the {topic!r} guide doc is not available.")
83
+ # Raw markdown via click.echo (no pager) for deterministic, capturable output.
84
+ click.echo(content)
85
+
86
+
87
+ # Dogfood the ready-made group agentsquire publishes: mount it the production
88
+ # way (no home=/project=), so each invocation resolves the real roots. Tests
89
+ # redirect those roots with AGENTSQUIRE_HOME / AGENTSQUIRE_PROJECT.
90
+ main.add_command(skills_command_group("agentsquire", default_scope="user"))
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: developing-with-agentsquire
3
+ description: "Use when building a Python CLI that carries its own Agent Skills with agentsquire - wiring the skills subcommand group, the staleness notice, console entry points, and package-data layout into a consumer package."
4
+ ---
5
+
6
+ # Developing with agentsquire
7
+
8
+ agentsquire lets your Python package carry its own Agent Skills and install them
9
+ into whatever agent harness is present (Claude Code, pi, Hermes, opencode). Your
10
+ users only ever run your tool - `your-cli skills install` - never a second CLI.
11
+
12
+ Add `agentsquire` as a plain dependency, then wire the four points below. This is
13
+ a checklist, not the reference: run `squire guide` (topics `api`, `harnesses`,
14
+ `integration`) or read `docs/api.md` for full signatures and behaviour.
15
+
16
+ ## 1. Ship skills as package data
17
+
18
+ Lay each skill out as a directory with a `SKILL.md` (agentskills.io format)
19
+ under a `skills/` resource inside your importable package:
20
+
21
+ ```
22
+ your_pkg/
23
+ __init__.py
24
+ skills/
25
+ my-skill/
26
+ SKILL.md
27
+ ```
28
+
29
+ The skills ride inside your wheel, so no source checkout is needed at run time.
30
+ hatchling includes package data by default; setuptools needs
31
+ `include-package-data`. This skills-as-package-data layout is what
32
+ `BundledPackageDataSource("your_pkg")` reads from.
33
+
34
+ ## 2. Mount the skills subcommand group
35
+
36
+ `skills_command_group` returns a click group with `install`, `status`, `update`,
37
+ and `uninstall` subcommands, parameterized by your package name and default
38
+ scope. Mount it on your root group:
39
+
40
+ ```python
41
+ from agentsquire.cli import skills_command_group
42
+
43
+ cli.add_command(skills_command_group("your_pkg", default_scope="user"))
44
+ ```
45
+
46
+ For typer, mount onto `typer.main.get_command(app)`. Every subcommand takes
47
+ `--scope user|project` and `--harness NAME`. Choose `user` scope for
48
+ general-purpose tools, `project` for repo-specific skills.
49
+
50
+ ## 3. Expose a console entry point
51
+
52
+ Give your CLI a `[project.scripts]` console entry in `pyproject.toml` so users
53
+ invoke it by name:
54
+
55
+ ```toml
56
+ [project.scripts]
57
+ your-cli = "your_pkg.console:main"
58
+ ```
59
+
60
+ ## 4. Surface updates proactively (optional)
61
+
62
+ Call the `check_stale` hook at your entry point. When an installed skill has a
63
+ newer shipped copy it prints one advisory line on stderr and nothing else - it
64
+ never prompts, never writes stdout, never changes your exit code:
65
+
66
+ ```python
67
+ from agentsquire import BundledPackageDataSource, check_stale
68
+
69
+ def main():
70
+ check_stale(
71
+ BundledPackageDataSource("your_pkg"),
72
+ prog_name="your-cli",
73
+ update_command="your-cli skills update",
74
+ )
75
+ # ... rest of your entry point
76
+ ```
77
+
78
+ Suppressed when `CI` or `AGENTSQUIRE_NO_UPDATE_CHECK` is set to any non-empty
79
+ value.
80
+
81
+ ## 5. Mark the package as skill-carrying (optional)
82
+
83
+ One pyproject line registers your package under the `agentsquire.skills`
84
+ entry-point marker. Nothing reads it today; it is reserved for a future
85
+ environment-wide listing and changes no behaviour:
86
+
87
+ ```toml
88
+ [project.entry-points."agentsquire.skills"]
89
+ your_pkg = "your_pkg"
90
+ ```
91
+
92
+ ## The provenance and update model
93
+
94
+ Installs are plain copies - no symlinks, no lockfile - so an installed skill
95
+ survives upgrade or removal of your package. Each installed `SKILL.md` carries a
96
+ provenance stamp in its frontmatter `metadata.agentsquire` map (installer,
97
+ versions, source package, content hash). Staleness detection is a local hash
98
+ compare: `status` classifies each skill as not-installed, up-to-date,
99
+ update-available, or locally-modified, and `update` refreshes only the stale
100
+ ones. User-modified installs and pre-existing symlinks are reported and skipped,
101
+ never clobbered. See `squire guide integration` for the full model.
@@ -0,0 +1,73 @@
1
+ """T-01 / REQ-11..13: the one bundled skill is a valid, wheel-shipped skill
2
+ whose body covers every agentsquire wiring point and points at the reference."""
3
+
4
+ import subprocess
5
+ import sys
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from agentsquire.skills import load_skill, validate_skill_dir
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parent.parent
14
+ SKILL_DIR = REPO_ROOT / "src" / "agentsquire" / "skills" / "developing-with-agentsquire"
15
+ SKILL_MD = SKILL_DIR / "SKILL.md"
16
+
17
+ # REQ-13: the body must cover each wiring point. Grep-checkable tokens, one per
18
+ # point the consumer guide names.
19
+ WIRING_TOKENS = [
20
+ "package data", # skills-as-package-data layout
21
+ "skills_command_group", # the mount call
22
+ "check_stale", # the staleness notice hook
23
+ "[project.scripts]", # console entry
24
+ "agentsquire.skills", # entry-point marker
25
+ "provenance", # provenance/update (staleness) model
26
+ "staleness",
27
+ ]
28
+ # ...and it must point at the full reference rather than duplicate it.
29
+ DOCS_POINTER_TOKENS = ["squire guide", "docs/"]
30
+
31
+
32
+ def test_skill_dir_is_structurally_valid():
33
+ # REQ-11: validate_skill_dir reports no violations.
34
+ assert validate_skill_dir(SKILL_DIR) == []
35
+
36
+
37
+ def test_frontmatter_name_matches_directory():
38
+ # REQ-11: frontmatter name is developing-with-agentsquire.
39
+ assert load_skill(SKILL_DIR).name == "developing-with-agentsquire"
40
+
41
+
42
+ def test_body_covers_every_wiring_point():
43
+ # REQ-13: token checklist over the body.
44
+ body = SKILL_MD.read_text()
45
+ missing = [token for token in WIRING_TOKENS if token not in body]
46
+ assert not missing, f"SKILL.md body is missing wiring tokens: {missing}"
47
+
48
+
49
+ def test_body_points_to_guide_or_docs():
50
+ # REQ-13: a pointer to the full reference, not a duplicate of it.
51
+ body = SKILL_MD.read_text()
52
+ assert any(token in body for token in DOCS_POINTER_TOKENS)
53
+
54
+
55
+ @pytest.fixture(scope="module")
56
+ def wheel_record(tmp_path_factory):
57
+ """Build the real agentsquire wheel and return its RECORD text (REQ-12)."""
58
+ outdir = tmp_path_factory.mktemp("dist")
59
+ subprocess.run(
60
+ [sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", str(outdir)],
61
+ cwd=REPO_ROOT,
62
+ check=True,
63
+ capture_output=True,
64
+ )
65
+ wheel = next(outdir.glob("*.whl"))
66
+ with zipfile.ZipFile(wheel) as zf:
67
+ record_name = next(n for n in zf.namelist() if n.endswith(".dist-info/RECORD"))
68
+ return zf.read(record_name).decode()
69
+
70
+
71
+ def test_bundled_skill_ships_in_the_wheel(wheel_record):
72
+ # REQ-12: the skill's SKILL.md is listed in the built wheel's RECORD.
73
+ assert "agentsquire/skills/developing-with-agentsquire/SKILL.md" in wheel_record
@@ -0,0 +1,51 @@
1
+ """T-02 / REQ-01..03: the squire root CLI, its agentsquire alias, and --version."""
2
+
3
+ from importlib.metadata import entry_points, version
4
+
5
+ import agentsquire
6
+ from agentsquire.console import main
7
+ from click.testing import CliRunner
8
+
9
+
10
+ def test_help_exits_zero_and_renders_the_root_group():
11
+ # REQ-01: `squire --help` exits 0 and prints the root command group.
12
+ result = CliRunner().invoke(main, ["--help"])
13
+ assert result.exit_code == 0
14
+ assert result.output.startswith("Usage:")
15
+ assert "install agentsquire's own agent skills" in result.output
16
+
17
+
18
+ def test_version_prints_the_installed_agentsquire_version():
19
+ # REQ-03: `squire --version` prints exactly importlib.metadata.version('agentsquire').
20
+ result = CliRunner().invoke(main, ["--version"])
21
+ assert result.exit_code == 0
22
+ assert result.output.strip() == version("agentsquire")
23
+
24
+
25
+ def test_reported_version_equals_dunder_version():
26
+ # REQ-03: the two declared version sources agree.
27
+ assert version("agentsquire") == agentsquire.__version__
28
+
29
+
30
+ def test_both_console_scripts_resolve_to_the_same_callable():
31
+ # REQ-01/REQ-02: squire and its agentsquire alias are the identical CLI.
32
+ scripts = {
33
+ ep.name: ep.value
34
+ for ep in entry_points(group="console_scripts")
35
+ if ep.name in ("squire", "agentsquire")
36
+ }
37
+ assert scripts == {
38
+ "squire": "agentsquire.console:main",
39
+ "agentsquire": "agentsquire.console:main",
40
+ }
41
+
42
+
43
+ def test_alias_help_is_identical_below_the_usage_line():
44
+ # REQ-02: `agentsquire --help` is the same root-group help as `squire --help`;
45
+ # the two invocations differ only in the program name on the Usage line.
46
+ runner = CliRunner()
47
+ squire = runner.invoke(main, ["--help"], prog_name="squire").output.splitlines()
48
+ alias = runner.invoke(main, ["--help"], prog_name="agentsquire").output.splitlines()
49
+ assert squire[0].startswith("Usage: squire")
50
+ assert alias[0].startswith("Usage: agentsquire")
51
+ assert squire[1:] == alias[1:]
@@ -0,0 +1,81 @@
1
+ """T-03 / REQ-04, REQ-05, REQ-11, REQ-01: the squire CLI mounts the skills group
2
+ and installs its one bundled skill into a detected harness end-to-end.
3
+
4
+ The group is wired the production way (no home=/project=); these CLI-level tests
5
+ redirect the roots with AGENTSQUIRE_HOME / AGENTSQUIRE_PROJECT (0.2.0 overrides)
6
+ rather than monkeypatching Path.home / chdir."""
7
+
8
+ import agentsquire
9
+ from agentsquire.console import main
10
+ from agentsquire.stamping import read_stamp
11
+ from click.testing import CliRunner
12
+
13
+ SKILL = "developing-with-agentsquire"
14
+
15
+
16
+ def invoke(*args, **env):
17
+ return CliRunner().invoke(main, list(args), env=env, catch_exceptions=False)
18
+
19
+
20
+ def fixture_roots(tmp_path):
21
+ """A home carrying the Claude Code marker, and an empty project dir."""
22
+ home = tmp_path / "home"
23
+ project = tmp_path / "project"
24
+ (home / ".claude").mkdir(parents=True)
25
+ project.mkdir()
26
+ return home, project
27
+
28
+
29
+ def test_skills_help_lists_exactly_the_four_verbs():
30
+ # REQ-04: the mounted group exposes exactly install/status/update/uninstall.
31
+ skills_group = main.commands["skills"]
32
+ assert sorted(skills_group.commands) == ["install", "status", "uninstall", "update"]
33
+
34
+ result = invoke("skills", "--help")
35
+ assert result.exit_code == 0
36
+ for verb in ("install", "status", "update", "uninstall"):
37
+ assert verb in result.output
38
+
39
+
40
+ def test_install_help_shows_scope_default_user_and_harness():
41
+ # REQ-04: install exposes --scope (shown default user) and --harness.
42
+ result = invoke("skills", "install", "--help")
43
+ assert result.exit_code == 0
44
+ assert "--scope" in result.output
45
+ assert "[default: user]" in result.output
46
+ assert "--harness" in result.output
47
+
48
+
49
+ def test_install_writes_stamped_skill_then_reports_up_to_date(tmp_path):
50
+ # REQ-05/REQ-11: install writes the one bundled skill into the user-scope
51
+ # Claude Code dir with an agentsquire provenance stamp, idempotently.
52
+ home, project = fixture_roots(tmp_path)
53
+ env = {"AGENTSQUIRE_HOME": str(home), "AGENTSQUIRE_PROJECT": str(project)}
54
+
55
+ first = invoke("skills", "install", **env)
56
+ assert first.exit_code == 0, first.output
57
+
58
+ installed = home / ".claude" / "skills" / SKILL / "SKILL.md"
59
+ assert installed.is_file()
60
+ stamp = read_stamp(installed.read_text())
61
+ assert stamp is not None
62
+ assert stamp["installer"] == "agentsquire"
63
+ assert stamp["source_package"] == "agentsquire"
64
+ assert stamp["installer_version"] == agentsquire.__version__
65
+
66
+ second = invoke("skills", "install", **env)
67
+ assert second.exit_code == 0, second.output
68
+ assert "up-to-date" in second.output
69
+
70
+
71
+ def test_status_lists_exactly_the_one_bundled_skill(tmp_path):
72
+ # REQ-11: status over the user scope names exactly developing-with-agentsquire.
73
+ home, project = fixture_roots(tmp_path)
74
+ env = {"AGENTSQUIRE_HOME": str(home), "AGENTSQUIRE_PROJECT": str(project)}
75
+
76
+ result = invoke("skills", "status", "--scope", "user", **env)
77
+ assert result.exit_code == 0, result.output
78
+
79
+ lines = [line for line in result.output.splitlines() if line.strip()]
80
+ assert len(lines) == 1
81
+ assert SKILL in lines[0]
@@ -0,0 +1,120 @@
1
+ """T-04 / REQ-06: the squire root callback wires check_stale, so any subcommand
2
+ emits exactly one stderr update notice - naming 'squire skills update' - without
3
+ touching stdout or the exit code, and the suppression env vars silence it.
4
+
5
+ The hook is wired the production way (no home=/project=); these tests redirect
6
+ the roots with AGENTSQUIRE_HOME / AGENTSQUIRE_PROJECT."""
7
+
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from agentsquire.console import main
14
+ from agentsquire.harnesses import CLAUDE_CODE
15
+ from agentsquire.sources import DirectorySource
16
+ from agentsquire.verbs import install
17
+ from click.testing import CliRunner
18
+
19
+ SKILL = "developing-with-agentsquire"
20
+ SRC = Path(__file__).parent.parent / "src"
21
+
22
+
23
+ def stale_home(tmp_path):
24
+ """Home + project fixtures with an installed-but-stale developing-with-agentsquire.
25
+
26
+ An 'old' copy (content differing from the real bundled skill) is installed
27
+ with a correct stamp, so status against BundledPackageDataSource('agentsquire')
28
+ - the source the console wires - classifies it UPDATE_AVAILABLE."""
29
+ home = tmp_path / "home"
30
+ project = tmp_path / "project"
31
+ (home / ".claude").mkdir(parents=True)
32
+ project.mkdir()
33
+
34
+ bundle = tmp_path / "old-bundle"
35
+ old = bundle / SKILL
36
+ old.mkdir(parents=True)
37
+ (old / "SKILL.md").write_text(
38
+ f"---\nname: {SKILL}\ndescription: An older bundled copy.\n---\n\nold body\n"
39
+ )
40
+ install(
41
+ DirectorySource(bundle), CLAUDE_CODE, scope="user",
42
+ home=home, project=project,
43
+ source_package="agentsquire", source_version="0.0.1",
44
+ )
45
+ return home, project
46
+
47
+
48
+ def env_for(home, project, **extra):
49
+ return {"AGENTSQUIRE_HOME": str(home), "AGENTSQUIRE_PROJECT": str(project), **extra}
50
+
51
+
52
+ def run(home, project, **extra_env):
53
+ return CliRunner().invoke(
54
+ main, ["skills", "status", "--scope", "user"],
55
+ env=env_for(home, project, **extra_env), catch_exceptions=False,
56
+ )
57
+
58
+
59
+ def test_stale_install_emits_exactly_one_update_notice_on_stderr(tmp_path):
60
+ home, project = stale_home(tmp_path)
61
+
62
+ result = run(home, project)
63
+
64
+ assert result.exit_code == 0
65
+ notices = result.stderr.splitlines()
66
+ assert len(notices) == 1
67
+ assert "squire skills update" in notices[0]
68
+ assert SKILL in notices[0]
69
+
70
+
71
+ def test_notice_is_stderr_only_and_leaves_stdout_intact(tmp_path):
72
+ home, project = stale_home(tmp_path)
73
+
74
+ result = run(home, project)
75
+
76
+ # stdout is exactly the subcommand's own output; the notice never leaks in.
77
+ assert "a skills update is available" not in result.stdout
78
+ assert SKILL in result.stdout
79
+
80
+
81
+ def test_ci_env_suppresses_the_notice(tmp_path):
82
+ home, project = stale_home(tmp_path)
83
+
84
+ result = run(home, project, CI="1")
85
+
86
+ assert result.exit_code == 0
87
+ assert result.stderr == ""
88
+
89
+
90
+ def test_no_update_check_env_suppresses_the_notice(tmp_path):
91
+ home, project = stale_home(tmp_path)
92
+
93
+ result = run(home, project, AGENTSQUIRE_NO_UPDATE_CHECK="1")
94
+
95
+ assert result.exit_code == 0
96
+ assert result.stderr == ""
97
+
98
+
99
+ def test_real_entry_point_keeps_stdout_stderr_and_exit_code_separate(tmp_path):
100
+ # Strongest evidence: the wired callback fires before dispatch in a real
101
+ # process, the notice lands on stderr, and stdout/exit code are untouched.
102
+ home, project = stale_home(tmp_path)
103
+ env = {k: v for k, v in os.environ.items() if k not in ("CI", "AGENTSQUIRE_NO_UPDATE_CHECK")}
104
+ env.update({
105
+ "PYTHONPATH": str(SRC),
106
+ "AGENTSQUIRE_HOME": str(home),
107
+ "AGENTSQUIRE_PROJECT": str(project),
108
+ })
109
+
110
+ proc = subprocess.run(
111
+ [sys.executable, "-c",
112
+ "import sys; sys.argv = ['squire', 'skills', 'status', '--scope', 'user'];"
113
+ " from agentsquire.console import main; main()"],
114
+ stdin=subprocess.DEVNULL, capture_output=True, timeout=60, env=env,
115
+ )
116
+
117
+ assert proc.returncode == 0
118
+ assert SKILL.encode() in proc.stdout
119
+ assert b"squire skills update" in proc.stderr
120
+ assert b"a skills update is available" not in proc.stdout
@@ -0,0 +1,116 @@
1
+ """T-06 / REQ-08, REQ-09, REQ-10, REQ-01: the `squire guide` command.
2
+
3
+ Bare `guide` lists the topics; `guide <topic>` pages the canonical doc via
4
+ click.echo; an unknown topic is a clean non-zero error naming the valid topics.
5
+ Content resolves from the packaged agentsquire/_docs (so it works from an
6
+ installed wheel), falling back to the repo docs/ + README only in a source
7
+ checkout. The wheel-resolution claim is proved by build-and-install, following
8
+ the tests/test_bundled_source.py pattern.
9
+ """
10
+
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from agentsquire.console import main
16
+ from click.testing import CliRunner
17
+
18
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
19
+
20
+ # Topic -> a heading known to appear only in that topic's canonical source.
21
+ TOPIC_HEADINGS = {
22
+ "api": "# AgentSquire Python API reference",
23
+ "harnesses": "# Harness reference",
24
+ "integration": "## Consumer integration guide",
25
+ }
26
+
27
+
28
+ def invoke(*args):
29
+ # Suppress the root staleness notice so guide output is deterministic.
30
+ return CliRunner().invoke(
31
+ main, list(args), env={"AGENTSQUIRE_NO_UPDATE_CHECK": "1"}
32
+ )
33
+
34
+
35
+ def test_root_help_lists_the_guide_command():
36
+ # REQ-01: guide is mounted on the root group alongside skills.
37
+ result = invoke("--help")
38
+ assert result.exit_code == 0
39
+ assert "guide" in result.output
40
+
41
+
42
+ def test_bare_guide_lists_the_three_topics():
43
+ # REQ-08: no argument -> exit 0, listing api, harnesses, integration.
44
+ result = invoke("guide")
45
+ assert result.exit_code == 0
46
+ for topic in TOPIC_HEADINGS:
47
+ assert topic in result.output
48
+
49
+
50
+ def test_guide_topic_pages_its_canonical_doc():
51
+ # REQ-09: each topic prints its own doc's known heading.
52
+ for topic, heading in TOPIC_HEADINGS.items():
53
+ result = invoke("guide", topic)
54
+ assert result.exit_code == 0, result.output
55
+ assert heading in result.output
56
+
57
+
58
+ def test_unknown_topic_is_a_clean_error_naming_valid_topics():
59
+ # REQ-09: a bogus topic exits non-zero and names the valid topics.
60
+ result = invoke("guide", "bogus")
61
+ assert result.exit_code != 0
62
+ for topic in TOPIC_HEADINGS:
63
+ assert topic in result.output
64
+
65
+
66
+ def test_guide_resolves_from_the_installed_wheel(tmp_path):
67
+ """REQ-10: with only the built wheel on the path (no repo checkout),
68
+ `squire guide api` still prints api.md content - proving it resolves from
69
+ the packaged agentsquire/_docs, not the source tree."""
70
+ outdir = tmp_path / "dist"
71
+ subprocess.run(
72
+ [
73
+ sys.executable, "-m", "build", "--wheel", "--no-isolation",
74
+ "--outdir", str(outdir),
75
+ ],
76
+ cwd=PROJECT_ROOT,
77
+ check=True,
78
+ capture_output=True,
79
+ )
80
+ wheel = next(outdir.glob("*.whl"))
81
+
82
+ site = tmp_path / "site"
83
+ subprocess.run(
84
+ [
85
+ sys.executable, "-m", "pip", "install",
86
+ "--no-deps", "--no-index", "--target", str(site), str(wheel),
87
+ ],
88
+ check=True,
89
+ capture_output=True,
90
+ )
91
+
92
+ # Runner asserts agentsquire imports from the wheel-only site (not the
93
+ # editable source install), then runs `guide api`. Running outside the repo
94
+ # with PYTHONPATH=site means the source-checkout fallback cannot reach the
95
+ # repo docs/, so a printed heading can only come from the packaged _docs.
96
+ runner = tmp_path / "run_guide.py"
97
+ runner.write_text(
98
+ "import sys\n"
99
+ "import agentsquire\n"
100
+ "assert agentsquire.__file__.startswith(sys.argv[1]), agentsquire.__file__\n"
101
+ "from agentsquire.console import main\n"
102
+ "main(args=sys.argv[2:], prog_name='squire', standalone_mode=False)\n"
103
+ )
104
+ result = subprocess.run(
105
+ [sys.executable, str(runner), str(site), "guide", "api"],
106
+ cwd=tmp_path,
107
+ env={
108
+ "PYTHONPATH": str(site),
109
+ "AGENTSQUIRE_NO_UPDATE_CHECK": "1",
110
+ "PATH": "/usr/bin:/bin",
111
+ },
112
+ check=True,
113
+ capture_output=True,
114
+ text=True,
115
+ )
116
+ assert TOPIC_HEADINGS["api"] in result.stdout
@@ -0,0 +1,77 @@
1
+ """T-05 / REQ-07, REQ-10: the wheel force-includes the three canonical docs.
2
+
3
+ The `guide` command needs a single authoritative source for its pages with no
4
+ hand-maintained in-package duplicate. The build force-includes docs/api.md,
5
+ docs/harnesses.md, and README.md into the wheel under agentsquire/_docs/, and the
6
+ package source commits no copy of them. Verified by build-and-install, following
7
+ the tests/test_bundled_source.py pattern.
8
+ """
9
+
10
+ import subprocess
11
+ import sys
12
+ import zipfile
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
18
+
19
+ # Force-include maps the three sources to these packaged paths.
20
+ PACKAGED_DOCS = {
21
+ "agentsquire/_docs/api.md": PROJECT_ROOT / "docs" / "api.md",
22
+ "agentsquire/_docs/harnesses.md": PROJECT_ROOT / "docs" / "harnesses.md",
23
+ "agentsquire/_docs/integration.md": PROJECT_ROOT / "README.md",
24
+ }
25
+
26
+
27
+ @pytest.fixture(scope="module")
28
+ def built_wheel(tmp_path_factory):
29
+ """Build the agentsquire wheel into a temp dir and hand back its path."""
30
+ outdir = tmp_path_factory.mktemp("dist")
31
+ subprocess.run(
32
+ [
33
+ sys.executable, "-m", "build", "--wheel", "--no-isolation",
34
+ "--outdir", str(outdir),
35
+ ],
36
+ cwd=PROJECT_ROOT,
37
+ check=True,
38
+ capture_output=True,
39
+ )
40
+ return next(outdir.glob("*.whl"))
41
+
42
+
43
+ def _record_paths(wheel: Path) -> set[str]:
44
+ """The path column of the wheel's dist-info RECORD."""
45
+ with zipfile.ZipFile(wheel) as zf:
46
+ record = next(n for n in zf.namelist() if n.endswith(".dist-info/RECORD"))
47
+ text = zf.read(record).decode()
48
+ return {line.split(",", 1)[0] for line in text.splitlines() if line}
49
+
50
+
51
+ def test_wheel_record_lists_the_three_packaged_docs(built_wheel):
52
+ paths = _record_paths(built_wheel)
53
+ for packaged in PACKAGED_DOCS:
54
+ assert packaged in paths, f"{packaged} missing from wheel RECORD"
55
+
56
+
57
+ def test_packaged_docs_carry_their_source_bytes(built_wheel):
58
+ with zipfile.ZipFile(built_wheel) as zf:
59
+ for packaged, source in PACKAGED_DOCS.items():
60
+ assert zf.read(packaged).decode() == source.read_text()
61
+
62
+
63
+ def test_no_canonical_doc_is_committed_under_the_package_source():
64
+ out = subprocess.run(
65
+ ["git", "ls-files", "src/agentsquire"],
66
+ cwd=PROJECT_ROOT,
67
+ check=True,
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ tracked = out.stdout.splitlines()
72
+ offenders = [
73
+ p for p in tracked
74
+ if Path(p).name in {"api.md", "harnesses.md", "integration.md"}
75
+ or "/_docs/" in p
76
+ ]
77
+ assert offenders == [], f"canonical docs must live in docs/ only, not: {offenders}"
File without changes
File without changes