agnara-cli 0.1.0a8__py3-none-any.whl

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.
agnara_cli/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Project introspection and scaffolding CLI for Agnara.
2
+
3
+ Owns ``agnara project create``, ``agnara app create``, capability
4
+ generation, introspection commands and diagnostics. Templates live here,
5
+ never in ``agnara-core``.
6
+
7
+ Currently implemented: ``agnara project create`` and ``agnara app create``,
8
+ which generate a project and a bounded context from a reviewable plan,
9
+ ``agnara apps``, which lists what ``agnara.toml`` declares without importing
10
+ anything, ``agnara inspect``, which imports a compiled
11
+ application and presents its filtered protocol-neutral introspection
12
+ snapshot as text or as deterministic JSON, ``agnara graph``, which draws the
13
+ relationships in that same snapshot, ``agnara schema openapi``, which exports
14
+ 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.
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.
20
+ """
21
+
22
+ #: The supported programmatic surface of this distribution.
23
+ #:
24
+ #: ``agnara-cli`` is consumed as the ``agnara`` command. The four names below
25
+ #: are what a caller needs to run that command in-process — a test harness, a
26
+ #: task runner, a wrapper script — and nothing else is a contract. Manifest
27
+ #: parsing, generation planning and target resolution are how the commands are
28
+ #: implemented; they were re-exported from underscore-prefixed modules without
29
+ #: ever being documented, used or designed as an API (ADR 0076).
30
+ from ._main import EXIT_FAILED, EXIT_OK, EXIT_USAGE, main
31
+
32
+ __all__ = [
33
+ "EXIT_FAILED",
34
+ "EXIT_OK",
35
+ "EXIT_USAGE",
36
+ "main",
37
+ ]
agnara_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m agnara_cli`` runs the same command as the ``agnara`` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agnara_cli._main import main
6
+
7
+ raise SystemExit(main())
agnara_cli/_app.py ADDED
@@ -0,0 +1,398 @@
1
+ """``agnara app create``: the second generator, and the one that proves the first.
2
+
3
+ It reuses `_generate` unchanged apart from one addition the first generator did
4
+ not need: declaring that a file is *meant* to be rewritten, so adding a table to
5
+ ``agnara.toml`` is an update rather than a conflict with itself.
6
+
7
+ The manifest edit appends to the existing text instead of re-serializing it.
8
+ Re-serializing would silently discard comments and ordering an operator wrote,
9
+ which AGENTS.md's "update project metadata safely" and "never silently delete
10
+ modified files" both rule out. See ADR 0061.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ from agnara_cli._app_template import app_files
21
+ from agnara_cli._generate import (
22
+ GenerationError,
23
+ GenerationPlan,
24
+ apply_plan,
25
+ build_plan,
26
+ plan_json,
27
+ render_plan,
28
+ )
29
+ from agnara_cli._manifest import (
30
+ ARCHITECTURES,
31
+ EXPOSURES,
32
+ MANIFEST_FILENAME,
33
+ ProjectManifest,
34
+ find_manifest,
35
+ load_manifest,
36
+ )
37
+ from agnara_cli._minimal_template import minimal_app_files
38
+ from agnara_cli._names import validated_identifier
39
+
40
+ __all__ = ["add_app_alias_parsers", "add_app_parser", "run_app_create"]
41
+
42
+ #: Convenience alias -> the profile it fixes, from `docs/CLI_SPEC.md`
43
+ #: "Convenience aliases". That document lists exactly these four and warns
44
+ #: against adding scaffolding surface casually, so `core` and `full` have no
45
+ #: alias.
46
+ #:
47
+ #: `app-agent` maps to the `agentic` profile: the alias and the profile are
48
+ #: spelled differently on purpose, and only this table relates them.
49
+ ALIASES: dict[str, str] = {
50
+ "app-api": "api",
51
+ "app-mcp": "mcp",
52
+ "app-agent": "agentic",
53
+ "app-worker": "worker",
54
+ }
55
+
56
+ #: Architecture -> the template that generates it. `ARCHITECTURES` is the
57
+ #: manifest vocabulary and is deliberately wider: `docs/CLI_SPEC.md` calls
58
+ #: `vertical` a "potential future profile", so it is a name a manifest may
59
+ #: carry before a generator exists for it. Selecting one that is not here is
60
+ #: refused rather than quietly generating a different layout.
61
+ TEMPLATES: dict[str, Callable[[str, str, tuple[str, ...]], dict[str, str]]] = {
62
+ "modular-hexagonal": app_files,
63
+ "minimal": minimal_app_files,
64
+ }
65
+
66
+ #: Profile -> the exposures it starts an app with, in the order it scaffolds
67
+ #: them. The mapping is the table in `docs/CLI_SPEC.md` "Profiles".
68
+ #:
69
+ #: A profile is a scaffolding alias and nothing more (ADR 0013): it resolves to
70
+ #: exposures and then disappears, so no profile name is ever written to
71
+ #: `agnara.toml`. Recording one would make it look like a runtime app type,
72
+ #: which is exactly what ADR 0013 refuses.
73
+ PROFILES: dict[str, tuple[str, ...]] = {
74
+ "core": (),
75
+ "api": ("http",),
76
+ "mcp": ("mcp",),
77
+ "agentic": ("mcp", "a2a"),
78
+ "worker": ("tasks", "events"),
79
+ "full": ("http", "mcp", "a2a", "events", "tasks"),
80
+ }
81
+
82
+
83
+ def _add_create_arguments(parser: argparse.ArgumentParser, *, profile: str | None) -> None:
84
+ """Define ``app create`` once, for the command and for every alias.
85
+
86
+ `docs/CLI_SPEC.md` requires the aliases to "not create separate code paths",
87
+ so they are not a second parser that happens to agree today -- they are this
88
+ one, with ``profile`` fixed.
89
+
90
+ When `profile` is given the parser does not offer ``--profile``: the alias
91
+ *is* the profile, and exposing both would let a user write
92
+ ``agnara app-mcp tools --profile worker``, a command that contradicts
93
+ itself and whose answer would only ever be arbitrary.
94
+ """
95
+ parser.add_argument("name", help="the app name; a single lower-case Python identifier")
96
+ parser.add_argument(
97
+ "--architecture",
98
+ # The manifest vocabulary, not just what is implemented: a reserved
99
+ # name earns the explanation in `_resolved_architecture` rather than
100
+ # argparse's "invalid choice", which reads like a typo.
101
+ choices=sorted(ARCHITECTURES),
102
+ help=(
103
+ "the layout to generate. Defaults to the project's "
104
+ "[defaults] architecture in agnara.toml."
105
+ ),
106
+ )
107
+ if profile is None:
108
+ parser.add_argument(
109
+ "--profile",
110
+ choices=sorted(PROFILES),
111
+ help=(
112
+ "start from a named set of exposures. Scaffolding only: the "
113
+ "profile is not recorded, its exposures are. Defaults to core."
114
+ ),
115
+ )
116
+ parser.add_argument(
117
+ "--with",
118
+ dest="exposures",
119
+ metavar="a,b",
120
+ help=(
121
+ "comma-separated inbound adapters to scaffold: "
122
+ + ", ".join(EXPOSURES)
123
+ + ". Each adds one adapters/inbound/<name>.py."
124
+ ),
125
+ )
126
+ parser.add_argument(
127
+ "--project",
128
+ metavar="DIR",
129
+ help=(
130
+ f"directory to search for {MANIFEST_FILENAME}; its ancestors are "
131
+ "searched too. Defaults to the working directory."
132
+ ),
133
+ )
134
+ parser.add_argument(
135
+ "--dry-run",
136
+ action="store_true",
137
+ help="show what would be written and stop, creating nothing",
138
+ )
139
+ parser.add_argument(
140
+ "--overwrite",
141
+ action="store_true",
142
+ help="allow replacing files that already exist",
143
+ )
144
+ parser.add_argument(
145
+ "--json",
146
+ action="store_true",
147
+ help="emit the plan as deterministic JSON",
148
+ )
149
+ parser.set_defaults(handler=run_app_create, profile=profile)
150
+
151
+
152
+ def add_app_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
153
+ """Register ``app`` and its subcommands on the root parser."""
154
+ parser = subparsers.add_parser(
155
+ "app",
156
+ help="create and manage apps inside a project",
157
+ description="App-level scaffolding. An app is one bounded context.",
158
+ )
159
+ actions = parser.add_subparsers(dest="app_command", required=True, metavar="ACTION")
160
+ create = actions.add_parser(
161
+ "create",
162
+ help="create an app in the current project",
163
+ description=(
164
+ "Generate a bounded context and declare it in agnara.toml. "
165
+ "Nothing is written until the whole plan is known. The command "
166
+ "never prompts."
167
+ ),
168
+ )
169
+ _add_create_arguments(create, profile=None)
170
+
171
+
172
+ def add_app_alias_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
173
+ """Register the convenience aliases from `docs/CLI_SPEC.md`.
174
+
175
+ Each is ``agnara app create`` with one profile fixed. They exist for
176
+ discoverability, and that document is explicit that they "MUST behave as
177
+ aliases only", so they share `_add_create_arguments` and `run_app_create`
178
+ rather than reimplementing either.
179
+ """
180
+ for alias, profile in ALIASES.items():
181
+ parser = subparsers.add_parser(
182
+ alias,
183
+ help=f"shorthand for 'app create --profile {profile}'",
184
+ description=(
185
+ f"Exactly 'agnara app create NAME --profile {profile}'. Every "
186
+ "other option of that command applies here unchanged."
187
+ ),
188
+ )
189
+ _add_create_arguments(parser, profile=profile)
190
+
191
+
192
+ #: Identifiers the generated ``module.py`` already binds at module scope. The
193
+ #: app's own name becomes ``<name> = App("<name>")`` in that module, so an app
194
+ #: called ``app`` or ``register`` would generate code that shadows itself and
195
+ #: fails the moment the project imports it.
196
+ _RESERVED = frozenset(
197
+ {
198
+ "annotations",
199
+ "app",
200
+ "dependencies",
201
+ "get_record",
202
+ "list_records",
203
+ "module",
204
+ "provide_records",
205
+ "provider",
206
+ "register",
207
+ }
208
+ )
209
+
210
+
211
+ def _validated_name(name: str) -> str:
212
+ return validated_identifier(
213
+ name,
214
+ subject="app name",
215
+ reserved=_RESERVED,
216
+ reserved_because="the generated module already binds that name",
217
+ )
218
+
219
+
220
+ def _manifest(arguments: argparse.Namespace) -> tuple[ProjectManifest, Path]:
221
+ start = Path(arguments.project) if arguments.project else Path.cwd()
222
+ if arguments.project and not start.is_dir():
223
+ raise GenerationError(f"{start}: is not a directory")
224
+ found = find_manifest(start)
225
+ if found is None:
226
+ raise GenerationError(
227
+ f"no {MANIFEST_FILENAME} found in {start.resolve()} or any parent "
228
+ "directory. Create a project first with 'agnara project create'."
229
+ )
230
+ return load_manifest(found), found
231
+
232
+
233
+ def _declaration(name: str, project: str, architecture: str, exposures: tuple[str, ...]) -> str:
234
+ """The manifest table this app adds, rendered deterministically."""
235
+ listed = ", ".join(f'"{exposure}"' for exposure in exposures)
236
+ return (
237
+ f"\n[apps.{name}]\n"
238
+ f'module = "{project}.apps.{name}"\n'
239
+ f'path = "src/{project}/apps/{name}"\n'
240
+ f'architecture = "{architecture}"\n'
241
+ f"exposures = [{listed}]\n"
242
+ )
243
+
244
+
245
+ def _updated_manifest(
246
+ manifest: ProjectManifest,
247
+ source: Path,
248
+ name: str,
249
+ architecture: str,
250
+ exposures: tuple[str, ...],
251
+ ) -> str:
252
+ """Append one app table, preserving everything already in the file.
253
+
254
+ Appending rather than re-serializing is deliberate: a manifest carries
255
+ comments and an ordering its author chose, and a generator that rewrote it
256
+ from a parsed model would delete both without saying so.
257
+ """
258
+ # Read without newline translation so the file keeps the line endings it
259
+ # already has: rewriting a CRLF manifest as LF would change every line an
260
+ # operator wrote, which is exactly what appending is meant to avoid.
261
+ with source.open(encoding="utf-8", newline="") as handle:
262
+ existing = handle.read()
263
+ newline = "\r\n" if "\r\n" in existing else "\n"
264
+ if not existing.endswith("\n"):
265
+ existing += newline
266
+ declaration = _declaration(name, manifest.name, architecture, exposures)
267
+ return existing + declaration.replace("\n", newline)
268
+
269
+
270
+ def _resolved_architecture(requested: str | None, manifest: ProjectManifest, source: Path) -> str:
271
+ """The architecture to generate, and to record for what was generated.
272
+
273
+ An explicit ``--architecture`` wins; otherwise the project's declared
274
+ default applies. Either way the result must name a template we have,
275
+ because the manifest entry is a claim about the files on disk: writing one
276
+ layout while declaring another is what makes ``agnara apps`` lie.
277
+ """
278
+ architecture = requested or manifest.default_architecture
279
+ if architecture in TEMPLATES:
280
+ return architecture
281
+
282
+ available = ", ".join(sorted(TEMPLATES))
283
+ if requested is None:
284
+ raise GenerationError(
285
+ f"{source}: [defaults] architecture is {architecture!r}, which has "
286
+ f"no template yet. Choose one with --architecture ({available}), "
287
+ "or change the project default."
288
+ )
289
+ raise GenerationError(
290
+ f"architecture {architecture!r} is a reserved name with no generator "
291
+ f"yet. Available: {available}."
292
+ )
293
+
294
+
295
+ def _requested_exposures(requested: str | None) -> list[str]:
296
+ """Parse ``--with``, rejecting a malformed list or an unknown exposure."""
297
+ if requested is None:
298
+ return []
299
+
300
+ seen: list[str] = []
301
+ for entry in requested.split(","):
302
+ exposure = entry.strip()
303
+ if not exposure:
304
+ raise GenerationError(
305
+ f"--with {requested!r} has an empty entry; list exposures as "
306
+ "'http,mcp' with no trailing or repeated commas"
307
+ )
308
+ if exposure not in EXPOSURES:
309
+ raise GenerationError(
310
+ f"unknown exposure {exposure!r}. Available: {', '.join(EXPOSURES)}"
311
+ )
312
+ if exposure not in seen:
313
+ seen.append(exposure)
314
+ return seen
315
+
316
+
317
+ def _resolved_exposures(
318
+ requested: str | None, profile: str | None, architecture: str
319
+ ) -> tuple[str, ...]:
320
+ """The inbound adapters to scaffold, and to record for what was scaffolded.
321
+
322
+ A profile contributes its initial exposures and then disappears: ADR 0013
323
+ makes profiles scaffolding aliases, not runtime app types, so nothing about
324
+ the profile reaches the manifest. Only the result does.
325
+
326
+ ``--with`` adds to a profile rather than replacing it. `docs/CLI_SPEC.md`
327
+ permits either reading -- "combined/overridden" -- and ADR 0064 records why
328
+ union wins.
329
+
330
+ Order is the profile's exposures first, then anything ``--with`` adds that
331
+ the profile did not already bring, with repeats dropped. A manifest should
332
+ read back as the request that produced it.
333
+
334
+ Raises:
335
+ GenerationError: an empty entry, an unknown exposure, or any exposure
336
+ at all on an architecture that has no adapters package.
337
+ """
338
+ seen = list(PROFILES[profile]) if profile is not None else []
339
+ for exposure in _requested_exposures(requested):
340
+ if exposure not in seen:
341
+ seen.append(exposure)
342
+
343
+ if seen and architecture != "modular-hexagonal":
344
+ source = "--with" if profile is None else f"--profile {profile}"
345
+ raise GenerationError(
346
+ f"the {architecture} architecture has no adapters package, so it "
347
+ f"cannot carry an inbound adapter for {', '.join(seen)}. Generate "
348
+ f"this app with --architecture modular-hexagonal, or leave {source} off."
349
+ )
350
+ return tuple(seen)
351
+
352
+
353
+ def _plan(arguments: argparse.Namespace) -> tuple[GenerationPlan, ProjectManifest, str]:
354
+ name = _validated_name(arguments.name)
355
+ manifest, source = _manifest(arguments)
356
+ if any(app.name == name for app in manifest.apps):
357
+ raise GenerationError(
358
+ f"{source}: app {name!r} is already declared. Remove it from the "
359
+ "manifest first, or choose another name."
360
+ )
361
+ architecture = _resolved_architecture(
362
+ getattr(arguments, "architecture", None), manifest, source
363
+ )
364
+ exposures = _resolved_exposures(
365
+ getattr(arguments, "exposures", None),
366
+ getattr(arguments, "profile", None),
367
+ architecture,
368
+ )
369
+
370
+ root = source.parent
371
+ files = TEMPLATES[architecture](manifest.name, name, exposures)
372
+ files[MANIFEST_FILENAME] = _updated_manifest(manifest, source, name, architecture, exposures)
373
+ plan = build_plan(root, files, updates=(MANIFEST_FILENAME,))
374
+ return plan, manifest, name
375
+
376
+
377
+ def _next_steps(project: str, name: str) -> str:
378
+ return (
379
+ f"\nDeclared {name} in {MANIFEST_FILENAME}. Wire it into the composition "
380
+ f"root, src/{project}/bootstrap.py:\n\n"
381
+ f" from {project}.apps.{name} import module as {name}_module\n\n"
382
+ f" {name}_module.register(app, dependencies)\n"
383
+ )
384
+
385
+
386
+ def run_app_create(arguments: argparse.Namespace) -> str:
387
+ """Plan the app, then write it unless this is a dry run."""
388
+ plan, manifest, name = _plan(arguments)
389
+ if arguments.dry_run:
390
+ if arguments.json:
391
+ return json.dumps(plan_json(plan), indent=2, sort_keys=True)
392
+ return render_plan(plan)
393
+
394
+ apply_plan(plan, overwrite=arguments.overwrite)
395
+ if arguments.json:
396
+ return json.dumps(plan_json(plan), indent=2, sort_keys=True)
397
+ written = "\n".join(f"{action.verb} {action.path}" for action in plan.actions)
398
+ return written + "\n" + _next_steps(manifest.name, name)