dirigent-cli 0.23.4__tar.gz → 0.25.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 (38) hide show
  1. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/PKG-INFO +9 -9
  2. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/pyproject.toml +9 -9
  3. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/commands.py +162 -17
  5. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/explain.py +16 -4
  6. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/graph.py +11 -1
  7. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/init_form.py +2 -0
  8. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/local.py +52 -6
  9. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/main.py +11 -5
  10. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/messages.py +19 -0
  11. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/project.py +86 -1
  12. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/schemas.py +4 -0
  13. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/seeding.py +117 -62
  14. dirigent_cli-0.25.0/src/dirigent_cli/source_commands.py +203 -0
  15. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/summaries.py +19 -1
  16. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/LICENSE +0 -0
  17. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/README.md +0 -0
  18. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/__init__.py +0 -0
  19. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/aliases.py +0 -0
  20. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/context.py +0 -0
  21. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/formatters.py +0 -0
  22. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/health.py +0 -0
  23. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/output.py +0 -0
  24. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/params.py +0 -0
  25. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/profiles.py +0 -0
  26. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/py.typed +0 -0
  27. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/reaper.py +0 -0
  28. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/scaffold.py +0 -0
  29. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/sources.py +0 -0
  30. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/starters.py +0 -0
  31. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/stream.py +0 -0
  32. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  33. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  34. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  35. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  36. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  37. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/timing.py +0 -0
  38. {dirigent_cli-0.23.4 → dirigent_cli-0.25.0}/src/dirigent_cli/triggers.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.23.4
3
+ Version: 0.25.0
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-block-execute==0.23.4
10
- Requires-Dist: dirigent-blocks==0.23.4
11
- Requires-Dist: dirigent-client==0.23.4
12
- Requires-Dist: dirigent-common==0.23.4
13
- Requires-Dist: dirigent-core==0.23.4
14
- Requires-Dist: dirigent-examples==0.23.4
15
- Requires-Dist: dirigent-plugin==0.23.4
16
- Requires-Dist: dirigent-server==0.23.4
9
+ Requires-Dist: dirigent-block-execute==0.25.0
10
+ Requires-Dist: dirigent-blocks==0.25.0
11
+ Requires-Dist: dirigent-client==0.25.0
12
+ Requires-Dist: dirigent-common==0.25.0
13
+ Requires-Dist: dirigent-core==0.25.0
14
+ Requires-Dist: dirigent-examples==0.25.0
15
+ Requires-Dist: dirigent-plugin==0.25.0
16
+ Requires-Dist: dirigent-server==0.25.0
17
17
  Requires-Dist: httpx2>=2.12.0
18
18
  Requires-Dist: python-dotenv>=1.1.0
19
19
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.23.4",
15
- "dirigent-blocks==0.23.4",
16
- "dirigent-client==0.23.4",
17
- "dirigent-common==0.23.4",
18
- "dirigent-core==0.23.4",
19
- "dirigent-examples==0.23.4",
20
- "dirigent-plugin==0.23.4",
21
- "dirigent-server==0.23.4",
14
+ "dirigent-block-execute==0.25.0",
15
+ "dirigent-blocks==0.25.0",
16
+ "dirigent-client==0.25.0",
17
+ "dirigent-common==0.25.0",
18
+ "dirigent-core==0.25.0",
19
+ "dirigent-examples==0.25.0",
20
+ "dirigent-plugin==0.25.0",
21
+ "dirigent-server==0.25.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.23.4",
15
- "dirigent-blocks==0.23.4",
16
- "dirigent-client==0.23.4",
17
- "dirigent-common==0.23.4",
18
- "dirigent-core==0.23.4",
19
- "dirigent-examples==0.23.4",
20
- "dirigent-plugin==0.23.4",
21
- "dirigent-server==0.23.4",
14
+ "dirigent-block-execute==0.25.0",
15
+ "dirigent-blocks==0.25.0",
16
+ "dirigent-client==0.25.0",
17
+ "dirigent-common==0.25.0",
18
+ "dirigent-core==0.25.0",
19
+ "dirigent-examples==0.25.0",
20
+ "dirigent-plugin==0.25.0",
21
+ "dirigent-server==0.25.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -1,6 +1,7 @@
1
1
  """The commands that talk to a server: definitions, execution, catalog, connections, admin."""
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import os
5
6
  import sys
6
7
  from collections.abc import Callable, Iterator, Mapping, Sequence
@@ -43,6 +44,8 @@ from dirigent_cli.messages import (
43
44
  INIT_CANCELLED,
44
45
  INIT_REFUSED,
45
46
  INIT_WAY,
47
+ INPUT_FILE_UNREADABLE,
48
+ INPUT_REFUSED,
46
49
  INSTANCE_ALREADY_HERE,
47
50
  KEEP_IS_LOCAL_ONLY,
48
51
  LOCAL_RUN_HAD_NO_OUTCOME,
@@ -56,6 +59,7 @@ from dirigent_cli.messages import (
56
59
  NO_FAILURE_TO_RETRY,
57
60
  NO_FIRST_PASSWORD,
58
61
  NO_REPORT_DOCUMENT,
62
+ NO_SUCH_BLOCK,
59
63
  NO_SUCH_EXAMPLE,
60
64
  NOT_A_BLOCK_KIND,
61
65
  NOT_A_PRIORITY,
@@ -120,6 +124,7 @@ from dirigent_cli.project import (
120
124
  DEFAULT_PIPELINES_DIR,
121
125
  InitChoices,
122
126
  ProjectError,
127
+ agent_notes,
123
128
  check_choices,
124
129
  find_project,
125
130
  scaffold,
@@ -309,6 +314,18 @@ def _print_plan(result: ApplyResult, origin: str) -> bool:
309
314
  return True
310
315
 
311
316
 
317
+ def _print_source_tokens(result: ApplyResult, *, base_url: str) -> None:
318
+ """Print each token an apply minted for a source, once, with where it is presented.
319
+
320
+ The instance stores only a token's hash; there is no second chance to read it.
321
+ """
322
+ for minted in result.sources.tokens:
323
+ console.print(f"\n source [bold]{minted.code}[/] {base_url.rstrip('/')}{minted.url_path}")
324
+ console.print(f" Authorization: Bearer [bold]{minted.token}[/]")
325
+ if result.sources.tokens:
326
+ console.print("\n[yellow]Each token is shown once.[/] The instance stores only its hash.\n")
327
+
328
+
312
329
  def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
313
330
  """Print a pipeline's shape: roots at the margin, each step under what it waits for."""
314
331
  lines = render_graph(steps)
@@ -322,7 +339,7 @@ def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
322
339
  def graph_steps(definition: PipelineDefinition) -> list[GraphStep]:
323
340
  """Read a definition's steps in the shape the tree is drawn from."""
324
341
  return [
325
- GraphStep(name=name, block=step.block, depends_on=tuple(step.depends_on), rule=step.rule.value)
342
+ GraphStep(name=name, block=step.block_id, depends_on=tuple(step.depends_on), rule=step.rule.value)
326
343
  for name, step in definition.steps.items()
327
344
  ]
328
345
 
@@ -402,6 +419,8 @@ def apply_command(
402
419
  raise typer.Exit(code=1)
403
420
  return
404
421
  accepted = all(_print_plan(result, origin) for result, origin in results)
422
+ for result, _ in results:
423
+ _print_source_tokens(result, base_url=dg.url)
405
424
  if pruned is not None:
406
425
  for code in pruned.pruned:
407
426
  console.print(f"{styled('deactivated'):<10} {code} [dim](absent from the project)[/]")
@@ -484,10 +503,14 @@ def validate_command(
484
503
  bool, typer.Option("--explain", help="Also say what the work will cost: how wide, how many tries, how long.")
485
504
  ] = False,
486
505
  ) -> None:
487
- """Check a document offline, or against a server's catalog too with --server.
506
+ """Check a document against the blocks installed beside dg, or against a server's with --server.
488
507
 
489
- Offline, a triggers document is checked at the schema level: its clocks and its codes.
490
- `--server` adds the pipeline it names and the parameters its schedules pin.
508
+ Offline, every step's block and config is checked against the blocks installed beside dg,
509
+ and a connection or pipeline a document names is not, since only an instance knows what it
510
+ holds; a triggers document is checked at the schema level, its clocks and its codes.
511
+ `--server` checks against the instance's own catalog instead, and adds the connections and
512
+ pipelines it holds, the pipeline a triggers document names, and the parameters its
513
+ schedules pin.
491
514
 
492
515
  `--explain` adds the shape of the work ahead for every pipeline document that is valid:
493
516
  the fan-out widths, the retry budgets and the waits. With `--server` too, a sensor that
@@ -509,11 +532,18 @@ def validate_command(
509
532
  problems=[issue.model_dump(mode="json") for issue in error.problems],
510
533
  )
511
534
  failures += 1
512
- catalog: Catalog | None = None
513
- connections: list[str] = []
514
- pipelines: list[str] = []
535
+ from dirigent_common import format_checker_with
536
+ from dirigent_core.plugins import load_plugin_host
537
+
538
+ host = load_plugin_host()
539
+ catalog = host.catalog()
540
+ blocks: Mapping[str, Any] | None = host.blocks
541
+ checker = format_checker_with(host.formats)
542
+ connections: list[str] | None = None
543
+ pipelines: list[str] | None = None
515
544
  targets: dict[str, TriggerTarget] = {}
516
545
  if server:
546
+ blocks = None
517
547
  with client_for(state) as dg:
518
548
  catalog = dg.call(dg.blocks.catalog())
519
549
  connections = [
@@ -527,10 +557,11 @@ def validate_command(
527
557
  for definition, label in parsed:
528
558
  issues = validate_against_catalog(
529
559
  definition,
530
- catalog or Catalog(),
560
+ catalog,
531
561
  connections=connections,
532
562
  pipelines=pipelines,
533
- check_blocks=catalog is not None,
563
+ blocks=blocks,
564
+ format_checker=checker,
534
565
  target=targets.get(definition.pipeline) if isinstance(definition, TriggersDefinition) else None,
535
566
  )
536
567
  if issues:
@@ -544,7 +575,7 @@ def validate_command(
544
575
  )
545
576
  failures += 1
546
577
  continue
547
- checked = "document and catalog" if catalog is not None else "document, offline"
578
+ checked = "document and catalog" if server else "document and installed blocks"
548
579
  emit_fact(
549
580
  "validation",
550
581
  message="valid",
@@ -614,6 +645,10 @@ def init_command(
614
645
  ] = None,
615
646
  admin: Annotated[str, typer.Option(help="The first admin account's username.")] = "admin",
616
647
  password: Annotated[str | None, typer.Option(help="Its password; asked for when omitted.")] = None,
648
+ agents: Annotated[
649
+ bool,
650
+ typer.Option("--agents", help="Write only AGENTS.md and CLAUDE.md, into a project that already exists."),
651
+ ] = False,
617
652
  ) -> None:
618
653
  """Initialise a uv project and the instance it addresses, ready for `dg dev`.
619
654
 
@@ -624,7 +659,8 @@ def init_command(
624
659
  and mints it a token and a secret key, both kept in `.env`. `--template compose` writes the
625
660
  documents and a container stack instead, and initialises nothing locally: the instance is
626
661
  the containers. `--template documents` writes the documents alone, against an instance
627
- somebody else runs.
662
+ somebody else runs. `--agents` writes a coding agent's notes alone, into a project made
663
+ before `dg init` wrote them, and keeps any the project already has.
628
664
  """
629
665
  import asyncio
630
666
 
@@ -633,6 +669,20 @@ def init_command(
633
669
  root = directory.resolve()
634
670
  version = cli_version()
635
671
  unasked = template is None and service is None and pack is None and pipeline is None and not workflow
672
+ if agents:
673
+ if not unasked or password is not None or admin != "admin":
674
+ _init_fail(INIT_REFUSED, detail="--agents writes the notes alone and takes no other option")
675
+ if not directory.is_dir():
676
+ _init_fail(INIT_REFUSED, detail=f"{directory} is not a directory; --agents writes into a project")
677
+ notes = agent_notes(directory)
678
+ emit_fact(
679
+ "project.scaffolded",
680
+ message="scaffolded",
681
+ directory=str(directory),
682
+ files=[_within(path, directory) for path in notes.files],
683
+ **({"skipped": [_within(path, directory) for path in notes.skipped]} if notes.skipped else {}),
684
+ )
685
+ return
636
686
  asked = unasked and sys.stdin.isatty() and sys.stdout.isatty()
637
687
  # Refusing happens before anything is written or asked for, so a run that cannot finish
638
688
  # has not half-created a project, and nobody fills a form for a command that was going
@@ -947,6 +997,29 @@ def pipeline_validate(
947
997
  raise typer.Exit(code=1)
948
998
 
949
999
 
1000
+ @pipeline_app.command("schema")
1001
+ def pipeline_schema(
1002
+ file: Annotated[
1003
+ Path | None, typer.Option("-f", "--file", help="Write the schema to this file instead of into the record.")
1004
+ ] = None,
1005
+ ) -> None:
1006
+ """Compose the JSON Schema a document is written against, from the blocks installed beside dg.
1007
+
1008
+ It is the format and every installed block's config in one Draft 2020-12 schema, which an
1009
+ editor or a language model checks a document against before anything validates it. Written
1010
+ to a file, it is what a ``# yaml-language-server: $schema=`` comment names.
1011
+ """
1012
+ from dirigent_core.documentschema import document_schema
1013
+
1014
+ catalog = installed_catalog()
1015
+ schema = document_schema(catalog)
1016
+ if file is not None:
1017
+ file.parent.mkdir(parents=True, exist_ok=True)
1018
+ file.write_text(json.dumps(schema, indent=2) + "\n")
1019
+ return emit_fact("pipeline.schema", message="written", path=str(file), blocks=len(catalog.blocks))
1020
+ emit_fact("pipeline.schema", message="composed", blocks=len(catalog.blocks), schema=schema)
1021
+
1022
+
950
1023
  @pipeline_app.command("activate")
951
1024
  def pipeline_activate(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
952
1025
  """Make a pipeline runnable again."""
@@ -1080,6 +1153,23 @@ def parse_priority(value: str | None) -> RunPriority | None:
1080
1153
  )
1081
1154
 
1082
1155
 
1156
+ def parse_input(value: str | None) -> Any:
1157
+ """Read what a hand run hands its source: JSON or YAML written out, or ``@FILE`` holding either."""
1158
+ if value is None:
1159
+ return None
1160
+ text = value
1161
+ if value.startswith("@"):
1162
+ path = Path(value[1:])
1163
+ try:
1164
+ text = path.read_text()
1165
+ except OSError as error:
1166
+ fail(INPUT_FILE_UNREADABLE, path=str(path), detail=error.strerror or str(error))
1167
+ try:
1168
+ return yaml.safe_load(text)
1169
+ except yaml.YAMLError as error:
1170
+ fail(INPUT_REFUSED, detail=str(error).splitlines()[0])
1171
+
1172
+
1083
1173
  def parse_importance(value: str | None) -> Importance | None:
1084
1174
  """Read the importance a rule was given, naming the vocabulary when it is not one."""
1085
1175
  if value is None:
@@ -1164,6 +1254,20 @@ def run_command(
1164
1254
  "Omitted takes the pipeline's own.",
1165
1255
  ),
1166
1256
  ] = None,
1257
+ source: Annotated[
1258
+ str | None,
1259
+ typer.Option(
1260
+ "--source",
1261
+ help="The source step a pipeline with sources is entered at. Omitted takes its only one.",
1262
+ ),
1263
+ ] = None,
1264
+ given: Annotated[
1265
+ str | None,
1266
+ typer.Option(
1267
+ "--input",
1268
+ help="What arrives at that source, as JSON or YAML, or @FILE. Omitted takes the source's sample.",
1269
+ ),
1270
+ ] = None,
1167
1271
  ) -> None:
1168
1272
  """Run a pipeline by code, or apply and run a document in one command.
1169
1273
 
@@ -1182,6 +1286,7 @@ def run_command(
1182
1286
  wanted = parse_priority(priority)
1183
1287
  if wanted is not None and local:
1184
1288
  fail(PRIORITY_IS_NOT_LOCAL)
1289
+ arrives = parse_input(given)
1185
1290
  if local:
1186
1291
  _run_locally(
1187
1292
  state,
@@ -1197,6 +1302,8 @@ def run_command(
1197
1302
  strict=strict,
1198
1303
  keep=keep,
1199
1304
  root=root,
1305
+ source=source,
1306
+ given=arrives,
1200
1307
  )
1201
1308
  return
1202
1309
  with client_for(state) as dg:
@@ -1224,7 +1331,11 @@ def run_command(
1224
1331
  stored = dg.call(dg.pipelines.get(code))
1225
1332
  schema = dict((stored.document or {}).get("params") or {})
1226
1333
  params = parse_params(param, schema=schema, files=params_file)
1227
- accepted = dg.call(dg.pipelines.run(code, params=params, window=covered, log_levels=levels, priority=wanted))
1334
+ accepted = dg.call(
1335
+ dg.pipelines.run(
1336
+ code, params=params, window=covered, log_levels=levels, priority=wanted, source=source, input=arrives
1337
+ )
1338
+ )
1228
1339
  if accepted.run_id is None:
1229
1340
  if state.json_output:
1230
1341
  emit_problem(
@@ -1315,6 +1426,8 @@ def _run_locally(
1315
1426
  strict: bool,
1316
1427
  keep: bool = False,
1317
1428
  root: Path | None = None,
1429
+ source: str | None = None,
1430
+ given: Any = None,
1318
1431
  ) -> None:
1319
1432
  """Apply and run a document in a throwaway instance, streaming what it logs."""
1320
1433
  if state.url is not None or state.profile is not None or state.token is not None:
@@ -1344,6 +1457,8 @@ def _run_locally(
1344
1457
  state=state,
1345
1458
  keep=keep,
1346
1459
  root=root,
1460
+ source=source,
1461
+ given=given,
1347
1462
  )
1348
1463
  )
1349
1464
  if outcome is None: # pragma: no cover - the generator always ends with an outcome
@@ -1384,6 +1499,8 @@ async def _stream_local(
1384
1499
  state: CliState,
1385
1500
  keep: bool = False,
1386
1501
  root: Path | None = None,
1502
+ source: str | None = None,
1503
+ given: Any = None,
1387
1504
  ) -> LocalOutcome | None:
1388
1505
  """Drive a local run, printing each transition and log line as it lands."""
1389
1506
  outcome: LocalOutcome | None = None
@@ -1400,6 +1517,8 @@ async def _stream_local(
1400
1517
  inherited=settings,
1401
1518
  keep=keep,
1402
1519
  root=root,
1520
+ source=source,
1521
+ given=given,
1403
1522
  ):
1404
1523
  match item:
1405
1524
  case LocalStarted():
@@ -2246,16 +2365,35 @@ def blocks_new(
2246
2365
  console.print(" [bold]uv sync && uv run pytest[/]")
2247
2366
 
2248
2367
 
2368
+ #: What ``--local`` means on the block commands.
2369
+ LOCAL_BLOCKS = "Read the blocks installed beside dg, instead of the instance's own."
2370
+
2371
+
2372
+ def installed_catalog() -> Catalog:
2373
+ """Read the catalog every plugin installed beside this `dg` contributes, without addressing any server."""
2374
+ from dirigent_core.plugins import load_plugin_host
2375
+
2376
+ return load_plugin_host().catalog()
2377
+
2378
+
2249
2379
  @blocks_app.command("list")
2250
2380
  def blocks_list(
2251
2381
  ctx: typer.Context,
2252
2382
  kind: Annotated[str | None, typer.Option(help="Only operators, or only sensors.")] = None,
2383
+ local: Annotated[bool, typer.Option("--local", help=LOCAL_BLOCKS)] = False,
2253
2384
  ) -> None:
2254
- """List the blocks this instance can run."""
2385
+ """List the blocks this instance can run, or with --local the ones installed beside dg."""
2255
2386
  if kind is not None and kind not in set(BlockKind):
2256
2387
  fail(NOT_A_BLOCK_KIND, kind=repr(kind), allowed=", ".join(sorted(BlockKind)))
2257
- with client_for(state_of(ctx)) as dg:
2258
- catalog = dg.call(dg.blocks.catalog(kind=BlockKind(kind) if kind else None))
2388
+ wanted = BlockKind(kind) if kind else None
2389
+ if local:
2390
+ catalog = installed_catalog()
2391
+ catalog = catalog.model_copy(
2392
+ update={"blocks": [block for block in catalog.blocks if wanted is None or block.kind is wanted]}
2393
+ )
2394
+ else:
2395
+ with client_for(state_of(ctx)) as dg:
2396
+ catalog = dg.call(dg.blocks.catalog(kind=wanted))
2259
2397
  if state_of(ctx).json_output:
2260
2398
  emit_records("block", catalog.blocks)
2261
2399
  emit_records("storage_scheme", catalog.storage_schemes)
@@ -2276,10 +2414,17 @@ def blocks_list(
2276
2414
  def blocks_show(
2277
2415
  ctx: typer.Context,
2278
2416
  block_id: Annotated[str, typer.Argument(help="The block to describe.")],
2417
+ local: Annotated[bool, typer.Option("--local", help=LOCAL_BLOCKS)] = False,
2279
2418
  ) -> None:
2280
2419
  """Show one block's config and output schemas."""
2281
- with client_for(state_of(ctx)) as dg:
2282
- entry = dg.call(dg.blocks.get(block_id))
2420
+ if local:
2421
+ found = [block for block in installed_catalog().blocks if block.id == block_id]
2422
+ if not found:
2423
+ fail(NO_SUCH_BLOCK, block=repr(block_id))
2424
+ entry = found[0]
2425
+ else:
2426
+ with client_for(state_of(ctx)) as dg:
2427
+ entry = dg.call(dg.blocks.get(block_id))
2283
2428
  if state_of(ctx).json_output:
2284
2429
  return emit_one("block", entry)
2285
2430
  fields(
@@ -25,6 +25,7 @@ from dirigent_common import Duration, JsonMap, base_format_checker, format_durat
25
25
  from dirigent_core.engine.definition import ParameterError, PipelineDefinition, RetryPolicy, StepDefinition
26
26
  from dirigent_core.engine.failure import backoff_delay
27
27
  from dirigent_core.engine.references import ReferenceScope, UnknownReference, resolve
28
+ from dirigent_core.engine.state import item_wise_parent
28
29
 
29
30
  #: What a cardinality says when the document alone cannot fix it.
30
31
  UNKNOWN: Final = "unknown"
@@ -65,7 +66,15 @@ def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> D
65
66
  order = definition.topological_order()
66
67
  shapes: dict[str, StepShape] = {}
67
68
  for name in order:
68
- shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog, definition.grid_source(name))
69
+ shapes[name] = _step_shape(
70
+ name,
71
+ definition.steps[name],
72
+ params,
73
+ shapes,
74
+ catalog,
75
+ definition.grid_source(name),
76
+ item_wise_parent(definition, name),
77
+ )
69
78
  rows = [shapes[name] for name in order]
70
79
  return DocumentShape(
71
80
  attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
@@ -95,20 +104,23 @@ def _step_shape(
95
104
  done: Mapping[str, StepShape],
96
105
  catalog: Catalog | None,
97
106
  source: str | None,
107
+ paired: str | None = None,
98
108
  ) -> StepShape:
99
109
  """Read one step as the work it will become, against the steps already read.
100
110
 
101
- ``source`` is the step a late grid waits for, which the row names.
111
+ ``source`` is the step a late grid waits for, and ``paired`` the fan-out whose items this
112
+ step's items wait for one by one; the row names both.
102
113
  """
103
114
  cardinality, elements = _cardinality(step, params, done, source)
104
115
  poll, deadline, from_block = _waits(step, _entry(step, catalog))
105
116
  return StepShape(
106
117
  name=name,
107
- block=step.block,
118
+ block=step.block_id,
108
119
  depends_on=list(step.depends_on),
109
120
  cardinality=cardinality,
110
121
  elements=elements,
111
122
  grid_source=source,
123
+ item_wise_parent=paired,
112
124
  reference=step.for_each if isinstance(step.for_each, str) else None,
113
125
  items=step.items.value,
114
126
  max_attempts=step.retry.max_attempts,
@@ -147,7 +159,7 @@ def _cardinality(
147
159
 
148
160
  def _entry(step: StepDefinition, catalog: Catalog | None) -> BlockEntry | None:
149
161
  """Find what the catalog says about this step's block, where there is a catalog."""
150
- return None if catalog is None else catalog.block(step.block)
162
+ return None if catalog is None or step.block is None else catalog.block(step.block)
151
163
 
152
164
 
153
165
  def _waits(step: StepDefinition, entry: BlockEntry | None) -> tuple[timedelta | None, timedelta | None, list[str]]:
@@ -30,7 +30,7 @@ def steps_of_document(document: Mapping[str, Any]) -> list[GraphStep]:
30
30
  read.append(
31
31
  GraphStep(
32
32
  name=str(name),
33
- block=str(held.get("block", "-")),
33
+ block=_block_of(held),
34
34
  depends_on=tuple(str(item) for item in cast("list[Any]", depends or ())),
35
35
  rule=str(held.get("rule", DEFAULT_RULE)),
36
36
  )
@@ -38,6 +38,16 @@ def steps_of_document(document: Mapping[str, Any]) -> list[GraphStep]:
38
38
  return read
39
39
 
40
40
 
41
+ def _block_of(step: Mapping[str, Any]) -> str:
42
+ """Name what a step runs: its block, a source's kind, or ``reply``."""
43
+ source = step.get("source")
44
+ if isinstance(source, dict):
45
+ return str(cast("dict[str, Any]", source).get("kind", "source"))
46
+ if "reply" in step:
47
+ return "reply"
48
+ return str(step.get("block", "-"))
49
+
50
+
41
51
  def render_graph(steps: Sequence[GraphStep]) -> list[str]:
42
52
  """Draw the graph as indented lines, each step under the last step it waits for.
43
53
 
@@ -238,6 +238,8 @@ class InitForm(App[InitChoices | None]):
238
238
  ".dirigent/profiles.yaml",
239
239
  "pyproject.toml",
240
240
  "README.md",
241
+ "AGENTS.md",
242
+ "CLAUDE.md",
241
243
  ]
242
244
  if choices.stack:
243
245
  files += ["compose.yaml", "Dockerfile", ".env"]