agnara-cli 1.0.2__tar.gz → 1.0.4__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 (30) hide show
  1. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/.gitignore +7 -0
  2. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/PKG-INFO +42 -4
  3. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/README.md +39 -1
  4. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/pyproject.toml +3 -3
  5. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/__init__.py +11 -7
  6. agnara_cli-1.0.4/src/agnara_cli/_command.py +9 -0
  7. agnara_cli-1.0.4/src/agnara_cli/_doctor.py +222 -0
  8. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_generate.py +12 -10
  9. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_main.py +13 -3
  10. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_schema.py +18 -15
  11. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_target.py +18 -11
  12. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_view.py +0 -1
  13. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_write.py +6 -3
  14. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/LICENSE +0 -0
  15. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/__main__.py +0 -0
  16. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_app.py +0 -0
  17. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_app_template.py +0 -0
  18. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_apps.py +0 -0
  19. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_context.py +0 -0
  20. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_exposure_template.py +0 -0
  21. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_graph.py +0 -0
  22. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_inspect.py +0 -0
  23. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_manifest.py +0 -0
  24. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_minimal_template.py +0 -0
  25. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_names.py +0 -0
  26. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_project.py +0 -0
  27. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_render.py +0 -0
  28. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_template_format.py +0 -0
  29. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_templates.py +0 -0
  30. {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/py.typed +0 -0
@@ -34,6 +34,13 @@ uv.lock.bak
34
34
  .dist-test/
35
35
  .release-validation/
36
36
 
37
+ # Git worktrees for parallel agent work. MULTI_AGENT_PROTOCOL.md asks for a
38
+ # sibling directory outside the checkout; `.worktrees/` is the only supported
39
+ # location inside it. Untracked, a nested worktree makes `git status` dirty, so
40
+ # the `repository-clean` release gate reports uncommitted changes for a tree
41
+ # whose own content is untouched.
42
+ /.worktrees/
43
+
37
44
  # Editors / OS
38
45
  .idea/
39
46
  .vscode/
@@ -1,32 +1,40 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agnara-cli
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Project introspection and scaffolding CLI for Agnara.
5
5
  Project-URL: Homepage, https://github.com/Blandskron/agnara
6
6
  Project-URL: Repository, https://github.com/Blandskron/agnara
7
7
  Author-email: Agnara Maintainers <maintainers@agnara.dev>
8
8
  License: Apache-2.0
9
9
  License-File: LICENSE
10
- Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
16
16
  Requires-Python: >=3.14
17
- Requires-Dist: agnara==1.0.2
17
+ Requires-Dist: agnara==1.0.4
18
18
  Description-Content-Type: text/markdown
19
19
 
20
20
  # agnara-cli
21
21
 
22
22
  Project introspection and scaffolding CLI. Owns project/app generators, templates and diagnostics.
23
23
 
24
+ ```bash
25
+ pip install "agnara-cli==1.0.3"
26
+ agnara --help
27
+ ```
28
+
24
29
  This distribution is built and versioned with the synchronized workspace
25
30
  set. Which versions exist on an index is answered by its PyPI project page,
26
31
  not by this file: a README ships inside the artifact and cannot describe
27
32
  the state of a publication that happens after it is built.
28
33
 
29
34
  It installs the `agnara` console script.
35
+ The governed Python exports are listed in
36
+ [`docs/API_REFERENCE.md`](../../docs/API_REFERENCE.md); generated projects use
37
+ the governed kernel imports. Importing a project target executes that module.
30
38
 
31
39
  - Import package: `agnara_cli`
32
40
  - Depends on: the exact synchronized `agnara` version
@@ -40,11 +48,41 @@ allowed dependency graph.
40
48
  `agnara project create`, `agnara app create` with its `app-api`, `app-mcp`,
41
49
  `app-agent` and `app-worker` profile shorthands, `agnara apps`,
42
50
  `agnara inspect`, `agnara graph`, `agnara schema openapi` and `agnara context`
43
- are implemented. `agnara doctor` remains ahead in the backlog.
51
+ are implemented. The current checkout also implements the static `doctor`
52
+ baseline described below; a development-server command remains unimplemented.
53
+ The [maturity inventory](../../docs/MATURITY.md) records current boundaries;
54
+ proposed commands in a specification do not make them available.
44
55
 
45
56
  `agnara --help` lists the commands the installed version actually has, which is
46
57
  the answer to trust over any list written down elsewhere.
47
58
 
59
+ ## `agnara doctor` (unreleased checkout)
60
+
61
+ ```bash
62
+ agnara doctor --project path/to/project
63
+ agnara doctor --project path/to/project --json
64
+ ```
65
+
66
+ Read-only checks cover the executing Python >=3.14 baseline, strict project
67
+ manifest, contained app directories and installed core/CLI/declared HTTP/MCP
68
+ version metadata. A2A declarations fail with a message about missing diagnostic
69
+ coverage, rather than claiming the provisional adapter has no runtime. Events
70
+ declarations fail as a reserved namespace without an event runtime; Tasks
71
+ declarations fail because no task runtime is implemented. These checks do not
72
+ inspect those distributions' metadata and fail whether or not they are installed.
73
+ No project or adapter code is imported, no files are written and no dependencies
74
+ are installed.
75
+
76
+ Reports always list unchecked runtime and architecture boundaries. The project's
77
+ free-form Python constraint is not evaluated, and matching package metadata does
78
+ not prove importability or transitive dependency compatibility. A passing
79
+ report is not a runtime-readiness assertion. See the
80
+ [doctor contract](../../docs/CLI_SPEC.md#agnara-doctor) and proposed ADR 0096.
81
+
82
+ The versioned JSON or text report goes to stdout. Any failed check exits 1;
83
+ all checks passing exits 0. Argument errors retain exit 2 on stderr. This
84
+ command is unreleased checkout behavior, not a claim about the 1.0.3 wheel.
85
+
48
86
  ## `agnara inspect`
49
87
 
50
88
  ```bash
@@ -2,12 +2,20 @@
2
2
 
3
3
  Project introspection and scaffolding CLI. Owns project/app generators, templates and diagnostics.
4
4
 
5
+ ```bash
6
+ pip install "agnara-cli==1.0.3"
7
+ agnara --help
8
+ ```
9
+
5
10
  This distribution is built and versioned with the synchronized workspace
6
11
  set. Which versions exist on an index is answered by its PyPI project page,
7
12
  not by this file: a README ships inside the artifact and cannot describe
8
13
  the state of a publication that happens after it is built.
9
14
 
10
15
  It installs the `agnara` console script.
16
+ The governed Python exports are listed in
17
+ [`docs/API_REFERENCE.md`](../../docs/API_REFERENCE.md); generated projects use
18
+ the governed kernel imports. Importing a project target executes that module.
11
19
 
12
20
  - Import package: `agnara_cli`
13
21
  - Depends on: the exact synchronized `agnara` version
@@ -21,11 +29,41 @@ allowed dependency graph.
21
29
  `agnara project create`, `agnara app create` with its `app-api`, `app-mcp`,
22
30
  `app-agent` and `app-worker` profile shorthands, `agnara apps`,
23
31
  `agnara inspect`, `agnara graph`, `agnara schema openapi` and `agnara context`
24
- are implemented. `agnara doctor` remains ahead in the backlog.
32
+ are implemented. The current checkout also implements the static `doctor`
33
+ baseline described below; a development-server command remains unimplemented.
34
+ The [maturity inventory](../../docs/MATURITY.md) records current boundaries;
35
+ proposed commands in a specification do not make them available.
25
36
 
26
37
  `agnara --help` lists the commands the installed version actually has, which is
27
38
  the answer to trust over any list written down elsewhere.
28
39
 
40
+ ## `agnara doctor` (unreleased checkout)
41
+
42
+ ```bash
43
+ agnara doctor --project path/to/project
44
+ agnara doctor --project path/to/project --json
45
+ ```
46
+
47
+ Read-only checks cover the executing Python >=3.14 baseline, strict project
48
+ manifest, contained app directories and installed core/CLI/declared HTTP/MCP
49
+ version metadata. A2A declarations fail with a message about missing diagnostic
50
+ coverage, rather than claiming the provisional adapter has no runtime. Events
51
+ declarations fail as a reserved namespace without an event runtime; Tasks
52
+ declarations fail because no task runtime is implemented. These checks do not
53
+ inspect those distributions' metadata and fail whether or not they are installed.
54
+ No project or adapter code is imported, no files are written and no dependencies
55
+ are installed.
56
+
57
+ Reports always list unchecked runtime and architecture boundaries. The project's
58
+ free-form Python constraint is not evaluated, and matching package metadata does
59
+ not prove importability or transitive dependency compatibility. A passing
60
+ report is not a runtime-readiness assertion. See the
61
+ [doctor contract](../../docs/CLI_SPEC.md#agnara-doctor) and proposed ADR 0096.
62
+
63
+ The versioned JSON or text report goes to stdout. Any failed check exits 1;
64
+ all checks passing exits 0. Argument errors retain exit 2 on stderr. This
65
+ command is unreleased checkout behavior, not a claim about the 1.0.3 wheel.
66
+
29
67
  ## `agnara inspect`
30
68
 
31
69
  ```bash
@@ -1,20 +1,20 @@
1
1
  [project]
2
2
  name = "agnara-cli"
3
- version = "1.0.2"
3
+ version = "1.0.4"
4
4
  description = "Project introspection and scaffolding CLI for Agnara."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  authors = [{name = "Agnara Maintainers", email = "maintainers@agnara.dev"}]
8
8
  license = {text = "Apache-2.0"}
9
9
  classifiers = [
10
- "Development Status :: 3 - Alpha",
10
+ "Development Status :: 5 - Production/Stable",
11
11
  "Intended Audience :: Developers",
12
12
  "License :: OSI Approved :: Apache Software License",
13
13
  "Operating System :: OS Independent",
14
14
  "Programming Language :: Python :: 3.14",
15
15
  "Topic :: Software Development :: Libraries :: Application Frameworks",
16
16
  ]
17
- dependencies = ["agnara==1.0.2"]
17
+ dependencies = ["agnara==1.0.4"]
18
18
 
19
19
  [project.scripts]
20
20
  agnara = "agnara_cli._main:main"
@@ -1,8 +1,7 @@
1
1
  """Project introspection and scaffolding CLI for Agnara.
2
2
 
3
- Owns ``agnara project create``, ``agnara app create``, capability
4
- generation, introspection commands and diagnostics. Templates live here,
5
- never in ``agnara-core``.
3
+ Owns project/app scaffolding and introspection commands. Templates live here,
4
+ never in the ``agnara`` kernel.
6
5
 
7
6
  Currently implemented: ``agnara project create`` and ``agnara app create``,
8
7
  which generate a project and a bounded context from a reviewable plan,
@@ -12,11 +11,16 @@ application and presents its filtered protocol-neutral introspection
12
11
  snapshot as text or as deterministic JSON, ``agnara graph``, which draws the
13
12
  relationships in that same snapshot, ``agnara schema openapi``, which exports
14
13
  the OpenAPI document a composition already produced, and ``agnara context``,
15
- which writes the visible capabilities as Markdown for a model to read. Exposure
16
- selection and capability generation remain ahead in EPIC 0A.
14
+ which writes the visible capabilities as Markdown for a model to read.
15
+ App profiles and ``--with`` select generated adapter bindings. There is no
16
+ standalone capability generator or development-server command.
17
17
 
18
- Depends on ``agnara-core``. Must not import a sibling adapter.
19
- See ``ARCHITECTURE.md`` section 15, ``docs/CLI_SPEC.md`` and EPIC 0A.
18
+ The unreleased checkout's ``agnara doctor`` performs read-only static checks
19
+ of the manifest, app paths and installed version metadata. Runtime registration,
20
+ dependency cycles and architecture rules remain explicitly unchecked.
21
+
22
+ Depends on ``agnara``. Must not import a sibling adapter.
23
+ See ``ARCHITECTURE.md`` section 15, ``docs/CLI_SPEC.md`` and ``docs/MATURITY.md``.
20
24
  """
21
25
 
22
26
  #: The supported programmatic surface of this distribution.
@@ -0,0 +1,9 @@
1
+ """Private command output for diagnostics with an explicit exit status."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class CommandResult:
8
+ output: str
9
+ exit_code: int
@@ -0,0 +1,222 @@
1
+ """Check project metadata and, on request, an explicit runtime composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from agnara_cli._apps import _manifest as load_project_manifest
11
+ from agnara_cli._command import CommandResult
12
+ from agnara_cli._manifest import ManifestError, ProjectManifest
13
+ from agnara_cli._target import TargetError, resolve_target
14
+
15
+ _ADAPTERS = {"http": "agnara-http", "mcp": "agnara-mcp"}
16
+ _UNCHECKED_EXPOSURE_MESSAGES = {
17
+ "a2a": "adapter metadata checks are not implemented for this declared exposure",
18
+ "events": "reserved namespace; no implemented Agnara event runtime",
19
+ "tasks": "no implemented Agnara task runtime",
20
+ }
21
+ _UNCHECKED = (
22
+ "project_python_constraint",
23
+ "application_imports",
24
+ "runtime_registration",
25
+ "dependency_cycles",
26
+ "protocol_configuration",
27
+ "architecture_rules",
28
+ "transitive_dependencies",
29
+ )
30
+
31
+
32
+ def add_doctor_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
33
+ parser = subparsers.add_parser(
34
+ "doctor",
35
+ help="check project metadata and optionally an explicit runtime composition",
36
+ description=(
37
+ "Static checks import no project code. An explicit --runtime target imports "
38
+ "trusted project code and checks compilation and declared app registration."
39
+ ),
40
+ )
41
+ parser.add_argument("--project", metavar="DIR", help="directory to search and its ancestors")
42
+ parser.add_argument(
43
+ "--runtime", metavar="MODULE:ATTRIBUTE", help="import and compile this target"
44
+ )
45
+ parser.add_argument(
46
+ "--path", action="append", default=[], metavar="DIR", help="runtime import path"
47
+ )
48
+ parser.add_argument("--dependencies", metavar="ATTRIBUTE", help="runtime target's DIRegistry")
49
+ parser.add_argument("--json", action="store_true", help="emit a versioned diagnostic report")
50
+
51
+ def handle(arguments: argparse.Namespace) -> CommandResult:
52
+ if arguments.runtime is None:
53
+ if arguments.path:
54
+ parser.error("--path requires --runtime")
55
+ if arguments.dependencies is not None:
56
+ parser.error("--dependencies requires --runtime")
57
+ return run_doctor(arguments)
58
+
59
+ parser.set_defaults(handler=handle)
60
+
61
+
62
+ def _python_version() -> tuple[int, int, int]:
63
+ return sys.version_info[:3]
64
+
65
+
66
+ def _distribution_check(
67
+ name: str,
68
+ checks: list[dict[str, str]],
69
+ *,
70
+ kernel: str | None = None,
71
+ compare: bool = False,
72
+ ) -> str | None:
73
+ try:
74
+ installed = version(name)
75
+ except PackageNotFoundError:
76
+ checks.append({"id": f"distribution:{name}", "status": "fail", "message": "not installed"})
77
+ return None
78
+ if not isinstance(installed, str) or not installed.strip():
79
+ checks.append(
80
+ {
81
+ "id": f"distribution:{name}",
82
+ "status": "fail",
83
+ "message": "installed metadata has no non-empty version",
84
+ }
85
+ )
86
+ return None
87
+ status = "pass"
88
+ message = f"installed {installed}"
89
+ if compare and kernel is None:
90
+ status, message = "fail", "cannot compare versions without installed agnara metadata"
91
+ elif compare and installed != kernel:
92
+ status, message = "fail", f"installed {installed}; expected kernel version {kernel}"
93
+ checks.append({"id": f"distribution:{name}", "status": status, "message": message})
94
+ return installed
95
+
96
+
97
+ def _app_checks(manifest: ProjectManifest, checks: list[dict[str, str]]) -> None:
98
+ assert manifest.source is not None
99
+ root = manifest.source.parent.resolve()
100
+ for app in manifest.apps:
101
+ status = "pass"
102
+ message = f"directory exists: {app.path}"
103
+ try:
104
+ destination = (root / app.path).resolve()
105
+ if not destination.is_relative_to(root):
106
+ status, message = "fail", f"directory escapes project: {app.path}"
107
+ elif not destination.is_dir():
108
+ status, message = "fail", f"directory is missing or inaccessible: {app.path}"
109
+ except OSError, RuntimeError:
110
+ status, message = "fail", f"cannot resolve app directory: {app.path}"
111
+ checks.append({"id": f"app:{app.name}:path", "status": status, "message": message})
112
+
113
+
114
+ def run_doctor(arguments: argparse.Namespace) -> CommandResult:
115
+ checks: list[dict[str, str]] = []
116
+ interpreter = _python_version()
117
+ checks.append(
118
+ {
119
+ "id": "python",
120
+ "status": "pass" if interpreter >= (3, 14) else "fail",
121
+ "message": f"executing Python {'.'.join(map(str, interpreter))}; requires >=3.14",
122
+ }
123
+ )
124
+ manifest = None
125
+ try:
126
+ manifest = load_project_manifest(arguments)
127
+ checks.append({"id": "manifest", "status": "pass", "message": "valid agnara.toml"})
128
+ except ManifestError as error:
129
+ checks.append({"id": "manifest", "status": "fail", "message": str(error)})
130
+
131
+ kernel = _distribution_check("agnara", checks)
132
+ _distribution_check("agnara-cli", checks, kernel=kernel, compare=True)
133
+ unchecked = list(_UNCHECKED)
134
+ project = None
135
+ if manifest is None:
136
+ unchecked.extend(("declared_app_paths", "declared_adapters"))
137
+ else:
138
+ project = {"name": manifest.name, "python": manifest.python}
139
+ _app_checks(manifest, checks)
140
+ exposures = dict.fromkeys(exposure for app in manifest.apps for exposure in app.exposures)
141
+ for exposure in exposures:
142
+ distribution = _ADAPTERS.get(exposure)
143
+ if distribution is None:
144
+ checks.append(
145
+ {
146
+ "id": f"exposure:{exposure}",
147
+ "status": "fail",
148
+ "message": _UNCHECKED_EXPOSURE_MESSAGES[exposure],
149
+ }
150
+ )
151
+ else:
152
+ _distribution_check(distribution, checks, kernel=kernel, compare=True)
153
+
154
+ if arguments.runtime is not None:
155
+ _runtime_checks(arguments, manifest, checks, unchecked)
156
+
157
+ status = "fail" if any(check["status"] == "fail" for check in checks) else "pass"
158
+ report = {
159
+ "format": "agnara.doctor",
160
+ "format_version": 1,
161
+ "project": project,
162
+ "checks": checks,
163
+ "status": status,
164
+ "unchecked": unchecked,
165
+ }
166
+ if arguments.json:
167
+ output = json.dumps(report, indent=2, sort_keys=True)
168
+ else:
169
+ label = manifest.name if manifest is not None else "unknown project"
170
+ lines = [f"doctor {label}: {status}"]
171
+ lines.extend(f"[{check['status']}] {check['id']}: {check['message']}" for check in checks)
172
+ lines.append("Unchecked: " + ", ".join(unchecked))
173
+ output = "\n".join(lines)
174
+ return CommandResult(output, 1 if status == "fail" else 0)
175
+
176
+
177
+ def _runtime_checks(
178
+ arguments: argparse.Namespace,
179
+ manifest: ProjectManifest,
180
+ checks: list[dict[str, str]],
181
+ unchecked: list[str],
182
+ ) -> None:
183
+ unchecked.remove("application_imports")
184
+ try:
185
+ target = resolve_target(
186
+ arguments.runtime,
187
+ dependencies=arguments.dependencies,
188
+ )
189
+ except TargetError as error:
190
+ checks.append({"id": "runtime:target", "status": "fail", "message": str(error)})
191
+ return
192
+
193
+ checks.append({"id": "runtime:target", "status": "pass", "message": "target compiled"})
194
+ unchecked.remove("runtime_registration")
195
+ for app in manifest.apps:
196
+ registered = target.app.apps.get(app.name)
197
+ checks.append(
198
+ {
199
+ "id": f"app:{app.name}:registration",
200
+ "status": "pass" if registered is not None else "fail",
201
+ "message": "registered"
202
+ if registered is not None
203
+ else "declared app is not registered",
204
+ }
205
+ )
206
+ module_check = f"app:{app.name}:module"
207
+ module = registered.descriptor.module if registered is not None else None
208
+ if module is None:
209
+ unchecked.append(module_check)
210
+ continue
211
+ matches = module == app.module
212
+ checks.append(
213
+ {
214
+ "id": module_check,
215
+ "status": "pass" if matches else "fail",
216
+ "message": (
217
+ f"descriptor module matches {app.module}"
218
+ if matches
219
+ else f"descriptor module {module} differs from declared {app.module}"
220
+ ),
221
+ }
222
+ )
@@ -114,7 +114,7 @@ def build_plan(
114
114
  FileAction(
115
115
  path=path,
116
116
  contents=files[relative],
117
- exists=target.exists(),
117
+ exists=target.is_symlink() or target.exists(),
118
118
  intended_update=relative in intended,
119
119
  )
120
120
  )
@@ -152,13 +152,7 @@ def plan_json(plan: GenerationPlan) -> dict[str, object]:
152
152
 
153
153
 
154
154
  def apply_plan(plan: GenerationPlan, *, overwrite: bool = False) -> GenerationPlan:
155
- """Write a plan, or refuse before writing anything.
156
-
157
- Conflicts are checked against the whole plan first. A run that would
158
- replace a file the operator did not authorize must leave the directory
159
- exactly as it was, not half-written, so the refusal happens before the
160
- first ``write_text``.
161
- """
155
+ """Reject planned conflicts before writing and apply metadata updates last."""
162
156
  conflicts = plan.conflicts
163
157
  if conflicts and not overwrite:
164
158
  listed = ", ".join(str(action.path) for action in conflicts)
@@ -166,14 +160,22 @@ def apply_plan(plan: GenerationPlan, *, overwrite: bool = False) -> GenerationPl
166
160
  f"refusing to replace existing file(s) in {plan.root}: {listed}. "
167
161
  "Pass --overwrite to allow it."
168
162
  )
169
- for action in plan.actions:
163
+ for action in sorted(plan.actions, key=lambda action: action.intended_update):
170
164
  target = plan.resolve(action)
165
+ mode = "w" if overwrite or action.intended_update else "x"
171
166
  try:
172
167
  target.parent.mkdir(parents=True, exist_ok=True)
173
168
  # newline="\n" so a generated file is byte-identical on every
174
169
  # platform; a project is shared, and its line endings are its own
175
170
  # decision rather than that of the machine that created it.
176
- target.write_text(action.contents, encoding="utf-8", newline="\n")
171
+ with target.open(mode, encoding="utf-8", newline="\n") as stream:
172
+ stream.write(action.contents)
173
+ except FileExistsError as error:
174
+ if mode == "x" and error.filename == str(target):
175
+ raise GenerationError(
176
+ f"refusing to replace existing file {target}. Pass --overwrite to allow it."
177
+ ) from error
178
+ raise GenerationError(f"cannot write {target}: {error}") from error
177
179
  except OSError as error:
178
180
  raise GenerationError(f"cannot write {target}: {error}") from error
179
181
  return plan
@@ -10,7 +10,8 @@ Exit codes:
10
10
  visible" or nothing at all, as when a document was written to a file
11
11
  ``1``
12
12
  the operator's input or application could not be used, reported as a
13
- diagnostic on stderr rather than a traceback
13
+ diagnostic on stderr rather than a traceback; doctor instead emits its
14
+ structured failed-check report on stdout
14
15
  ``2``
15
16
  argparse rejected the command line
16
17
  """
@@ -20,18 +21,21 @@ from __future__ import annotations
20
21
  import argparse
21
22
  import sys
22
23
  from collections.abc import Sequence
24
+ from contextlib import redirect_stdout
23
25
  from importlib.metadata import PackageNotFoundError, version
24
26
 
25
27
  from agnara_cli._app import add_app_alias_parsers, add_app_parser
26
28
  from agnara_cli._apps import add_apps_parser
29
+ from agnara_cli._command import CommandResult
27
30
  from agnara_cli._context import add_context_parser
31
+ from agnara_cli._doctor import add_doctor_parser
28
32
  from agnara_cli._generate import GenerationError
29
33
  from agnara_cli._graph import add_graph_parser
30
34
  from agnara_cli._inspect import add_inspect_parser
31
35
  from agnara_cli._manifest import ManifestError
32
36
  from agnara_cli._project import add_project_parser
33
37
  from agnara_cli._schema import add_schema_parser
34
- from agnara_cli._target import TargetError
38
+ from agnara_cli._target import TargetError, target_search_path
35
39
 
36
40
  __all__ = ["main"]
37
41
 
@@ -62,6 +66,7 @@ def _parser() -> argparse.ArgumentParser:
62
66
  add_project_parser(subparsers)
63
67
  add_schema_parser(subparsers)
64
68
  add_context_parser(subparsers)
69
+ add_doctor_parser(subparsers)
65
70
  return parser
66
71
 
67
72
 
@@ -77,10 +82,15 @@ def main(argv: Sequence[str] | None = None) -> int:
77
82
  """
78
83
  arguments = _parser().parse_args(argv)
79
84
  try:
80
- output = arguments.handler(arguments)
85
+ # Handlers return their output. Imported targets can also print.
86
+ with target_search_path(getattr(arguments, "path", ())), redirect_stdout(sys.stderr):
87
+ output = arguments.handler(arguments)
81
88
  except (GenerationError, ManifestError, TargetError) as error:
82
89
  print(f"agnara: {error}", file=sys.stderr)
83
90
  return EXIT_FAILED
91
+ if isinstance(output, CommandResult):
92
+ _emit(output.output)
93
+ return output.exit_code
84
94
  _emit(output)
85
95
  return EXIT_OK
86
96
 
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
  import argparse
20
20
  import json
21
21
  from collections.abc import Callable, Mapping
22
- from typing import Any, cast
22
+ from typing import Any, Never, cast
23
23
 
24
24
  from agnara_cli._target import TargetError, resolve_attribute
25
25
  from agnara_cli._write import write_document
@@ -91,6 +91,10 @@ def add_schema_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentPa
91
91
  openapi.set_defaults(handler=run_schema)
92
92
 
93
93
 
94
+ def _reject_json_constant(token: str) -> Never:
95
+ raise ValueError(f"{token} is not a JSON number")
96
+
97
+
94
98
  def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
95
99
  """Read a target into a document, keeping its exact bytes when it has them.
96
100
 
@@ -119,8 +123,10 @@ def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
119
123
  except UnicodeDecodeError as error:
120
124
  raise TargetError("the OpenAPI document is not valid UTF-8") from error
121
125
  try:
122
- parsed = json.loads(decoded)
123
- except json.JSONDecodeError as error:
126
+ parsed = json.loads(decoded, parse_constant=_reject_json_constant)
127
+ except RecursionError as error:
128
+ raise TargetError("the OpenAPI document is nested too deeply to parse") from error
129
+ except ValueError as error:
124
130
  raise TargetError(f"the OpenAPI document is not valid JSON: {error}") from error
125
131
  elif isinstance(value, Mapping):
126
132
  parsed = dict(value)
@@ -144,27 +150,24 @@ def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
144
150
 
145
151
 
146
152
  def _encode(document: Mapping[str, Any], served: bytes | None, *, pretty: bool) -> bytes:
147
- if pretty:
148
- return json.dumps(
149
- document,
150
- allow_nan=False,
151
- ensure_ascii=False,
152
- indent=2,
153
- sort_keys=True,
154
- ).encode("utf-8")
155
- if served is not None:
153
+ if served is not None and not pretty:
156
154
  # Exactly what a server would send, including whatever the projection
157
155
  # chose. Re-serializing here would silently normalize it.
158
156
  return served
159
157
  try:
160
- return json.dumps(document, **_JSON_ARGUMENTS).encode("utf-8")
161
- except (TypeError, ValueError, UnicodeEncodeError) as error:
158
+ options = (
159
+ {**_JSON_ARGUMENTS, "separators": None, "indent": 2} if pretty else _JSON_ARGUMENTS
160
+ )
161
+ return json.dumps(document, **options).encode("utf-8")
162
+ except RecursionError as error:
163
+ raise TargetError("the OpenAPI document is nested too deeply to serialize") from error
164
+ except (TypeError, ValueError) as error:
162
165
  raise TargetError(f"the OpenAPI document cannot be serialized as JSON: {error}") from error
163
166
 
164
167
 
165
168
  def run_schema(arguments: argparse.Namespace) -> bytes | None:
166
169
  """Export the document, to stdout as exact bytes or to a file."""
167
- value = resolve_attribute(arguments.target, search_path=arguments.path)
170
+ value = resolve_attribute(arguments.target)
168
171
  document, served = _document(value)
169
172
  body = _encode(document, served, pretty=arguments.pretty)
170
173
  if arguments.output is None:
@@ -16,7 +16,8 @@ from __future__ import annotations
16
16
  import importlib
17
17
  import sys
18
18
  import traceback
19
- from collections.abc import Iterable, Sequence
19
+ from collections.abc import Iterator, Sequence
20
+ from contextlib import contextmanager
20
21
  from dataclasses import dataclass
21
22
  from pathlib import Path
22
23
 
@@ -105,11 +106,19 @@ def _split(target: str) -> tuple[str, str]:
105
106
  return module_name, attribute
106
107
 
107
108
 
108
- def _import(module_name: str, search_path: Sequence[str]) -> object:
109
- """Import the target module, with the requested paths ahead of the rest."""
109
+ @contextmanager
110
+ def target_search_path(search_path: Sequence[str]) -> Iterator[None]:
111
+ """Keep CLI search paths available while target code runs."""
110
112
  added = [str(Path(entry).resolve()) for entry in search_path]
111
113
  original = list(sys.path)
112
114
  sys.path[:0] = added
115
+ try:
116
+ yield
117
+ finally:
118
+ sys.path[:] = original
119
+
120
+
121
+ def _import(module_name: str) -> object:
113
122
  try:
114
123
  return importlib.import_module(module_name)
115
124
  except ImportError as error:
@@ -124,11 +133,9 @@ def _import(module_name: str, search_path: Sequence[str]) -> object:
124
133
  # A module that raises while executing is the user's code failing, not
125
134
  # a CLI failure, and the operator needs the reason rather than a stack.
126
135
  raise TargetError(f"importing {module_name!r} failed: {_reason(error)}") from error
127
- finally:
128
- sys.path[:] = original
129
136
 
130
137
 
131
- def resolve_attribute(target: str, *, search_path: Iterable[str] = ()) -> object:
138
+ def resolve_attribute(target: str) -> object:
132
139
  """Import ``module:attribute`` and return whatever it names.
133
140
 
134
141
  The generic half of target resolution. A command that needs an application
@@ -137,7 +144,7 @@ def resolve_attribute(target: str, *, search_path: Iterable[str] = ()) -> object
137
144
  and reads it structurally instead.
138
145
  """
139
146
  module_name, attribute = _split(target)
140
- module = _import(module_name, tuple(search_path))
147
+ module = _import(module_name)
141
148
  return _attribute(module, attribute)
142
149
 
143
150
 
@@ -153,7 +160,6 @@ def _registry(module: object, name: str | None) -> DIRegistry | None:
153
160
  def resolve_target(
154
161
  target: str,
155
162
  *,
156
- search_path: Iterable[str] = (),
157
163
  dependencies: str | None = None,
158
164
  ) -> ResolvedTarget:
159
165
  """Import ``module:attribute`` and compile every declared capability.
@@ -164,7 +170,7 @@ def resolve_target(
164
170
  fails with the reason rather than being described as if it had none.
165
171
  """
166
172
  module_name, attribute = _split(target)
167
- module = _import(module_name, tuple(search_path))
173
+ module = _import(module_name)
168
174
  app = _attribute(module, attribute)
169
175
  if not isinstance(app, Agnara):
170
176
  raise TargetError(
@@ -173,9 +179,10 @@ def resolve_target(
173
179
  registry = _registry(module, dependencies)
174
180
  compile_against = DIRegistry() if registry is None else registry
175
181
  try:
182
+ capabilities = app.compile()
176
183
  plans = tuple(
177
- ExecutionPlan.compile(app.capabilities[capability_id], compile_against)
178
- for capability_id in app.capabilities
184
+ ExecutionPlan.compile(capabilities[capability_id], compile_against)
185
+ for capability_id in capabilities
179
186
  )
180
187
  except Exception as error:
181
188
  # A schema error with no registry named is nearly always a dependency
@@ -130,7 +130,6 @@ def resolve_view(arguments: argparse.Namespace) -> View:
130
130
  """
131
131
  resolved = resolve_target(
132
132
  arguments.target,
133
- search_path=arguments.path,
134
133
  dependencies=arguments.dependencies,
135
134
  )
136
135
  visibility = POSTURES[arguments.visibility](_rule(arguments.as_scope, arguments.hide))
@@ -17,9 +17,12 @@ __all__ = ["write_document"]
17
17
  def write_document(destination: str, body: bytes, *, overwrite: bool) -> None:
18
18
  """Write ``body`` to ``destination``, refusing an unauthorized replacement."""
19
19
  path = Path(destination)
20
- if path.exists() and not overwrite:
21
- raise TargetError(f"{destination!r} already exists; pass --overwrite to replace it")
22
20
  try:
23
- path.write_bytes(body)
21
+ with path.open("wb" if overwrite else "xb") as stream:
22
+ stream.write(body)
23
+ except FileExistsError as error:
24
+ raise TargetError(
25
+ f"{destination!r} already exists; pass --overwrite to replace it"
26
+ ) from error
24
27
  except OSError as error:
25
28
  raise TargetError(f"cannot write {destination!r}: {error}") from error
File without changes