apcore-cli 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 (71) hide show
  1. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/CHANGELOG.md +51 -0
  2. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/PKG-INFO +38 -5
  3. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/README.md +37 -4
  4. apcore_cli-0.3.0/commands/ops.py +4 -0
  5. apcore_cli-0.3.0/planning/grouped-commands.md +235 -0
  6. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/state.json +19 -1
  7. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/pyproject.toml +1 -1
  8. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/__main__.py +61 -11
  9. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/cli.py +251 -9
  10. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/discovery.py +28 -3
  11. apcore_cli-0.3.0/src/apcore_cli/display_helpers.py +30 -0
  12. apcore_cli-0.3.0/src/apcore_cli/init_cmd.py +154 -0
  13. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/output.py +71 -9
  14. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/shell.py +103 -4
  15. apcore_cli-0.3.0/tests/test_cli.py +882 -0
  16. apcore_cli-0.3.0/tests/test_discovery.py +297 -0
  17. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_e2e.py +10 -0
  18. apcore_cli-0.3.0/tests/test_init_cmd.py +82 -0
  19. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_output.py +34 -0
  20. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_shell.py +47 -0
  21. apcore_cli-0.2.2/tests/test_cli.py +0 -442
  22. apcore_cli-0.2.2/tests/test_discovery.py +0 -148
  23. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.github/CODEOWNERS +0 -0
  24. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.github/copilot-ignore +0 -0
  25. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  26. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.gitignore +0 -0
  27. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.gitmessage +0 -0
  28. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/.pre-commit-config.yaml +0 -0
  29. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/CLAUDE.md +0 -0
  30. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/math/add.py +0 -0
  31. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/math/multiply.py +0 -0
  32. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/sysutil/disk.py +0 -0
  33. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/sysutil/env.py +0 -0
  34. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/sysutil/info.py +0 -0
  35. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/text/reverse.py +0 -0
  36. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/text/upper.py +0 -0
  37. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/extensions/text/wordcount.py +0 -0
  38. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/examples/run_examples.sh +0 -0
  39. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/approval-gate.md +0 -0
  40. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/config-resolver.md +0 -0
  41. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/core-dispatcher.md +0 -0
  42. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/discovery.md +0 -0
  43. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/output-formatter.md +0 -0
  44. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/overview.md +0 -0
  45. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/schema-parser.md +0 -0
  46. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/security-manager.md +0 -0
  47. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/planning/shell-integration.md +0 -0
  48. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/__init__.py +0 -0
  49. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  50. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/approval.py +0 -0
  51. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/config.py +0 -0
  52. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/ref_resolver.py +0 -0
  53. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/schema_parser.py +0 -0
  54. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/security/__init__.py +0 -0
  55. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/security/audit.py +0 -0
  56. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/security/auth.py +0 -0
  57. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  58. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/src/apcore_cli/security/sandbox.py +0 -0
  59. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/__init__.py +0 -0
  60. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/conftest.py +0 -0
  61. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_approval.py +0 -0
  62. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_bugfixes.py +0 -0
  63. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_config.py +0 -0
  64. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_integration.py +0 -0
  65. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_ref_resolver.py +0 -0
  66. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_schema_parser.py +0 -0
  67. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_security/__init__.py +0 -0
  68. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_security/test_audit.py +0 -0
  69. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_security/test_auth.py +0 -0
  70. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_security/test_config_encryptor.py +0 -0
  71. {apcore_cli-0.2.2 → apcore_cli-0.3.0}/tests/test_security/test_sandbox.py +0 -0
@@ -5,6 +5,57 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-03-23
9
+
10
+ ### Added
11
+
12
+ - **Display overlay routing** (§5.13) — `LazyModuleGroup` now reads `metadata["display"]["cli"]` for alias and description when building the command list and routing `get_command()`. Commands are exposed under their CLI alias instead of raw module_id.
13
+ - `_alias_map`: built from `metadata["display"]["cli"]["alias"]` (with module_id fallback), enabling `apcore-cli alias-name` invocation.
14
+ - `_descriptor_cache`: populated during alias map build to avoid double `registry.get_definition()` calls in `get_command()`.
15
+ - `_alias_map_built` flag only set on successful build, allowing retry after transient registry errors.
16
+ - **Display overlay in JSON output** — `format_module_list(..., "json")` now reads `metadata["display"]["cli"]` for `id`, `description`, and `tags`, consistent with the table output branch.
17
+
18
+ ### Changed
19
+
20
+ - `_ERROR_CODE_MAP.get(error_code, 1)`: guarded with `isinstance(error_code, str)` to prevent `None`-key lookup.
21
+ - Runtime companion: `apcore-toolkit >= 0.4.0` enables `DisplayResolver` and `ConventionScanner` (graceful fallback when not installed).
22
+
23
+ ### Tests
24
+
25
+ - `TestDisplayOverlayAliasRouting` (6 tests): `list_commands` uses CLI alias, `get_command` by alias, cache hit path, module_id fallback, `build_module_command` alias and description.
26
+ - `test_format_list_json_uses_display_overlay`: JSON output uses display overlay alias/description/tags.
27
+ - `test_format_list_json_falls_back_to_scanner_when_no_overlay`: JSON output falls back to scanner values.
28
+
29
+ ### Added (Grouped Commands — FE-09)
30
+
31
+ - **`GroupedModuleGroup(LazyModuleGroup)`** — organizes modules into nested `click.Group` subcommands based on namespace prefixes. Auto-groups by first `.` segment, with `display.cli.group` override from binding.yaml.
32
+ - `_resolve_group()` — 3-tier group resolution: explicit `display.cli.group` > first `.` segment of CLI alias > top-level.
33
+ - `_build_group_map()` — lazy, idempotent group map builder with builtin collision detection and shell-safe group name validation.
34
+ - `format_help()` — collapsed root help with Commands, Modules, and Groups sections (with command counts).
35
+ - **`_LazyGroup(click.Group)`** — nested group that lazily builds subcommands from module descriptors.
36
+ - **`list --flat` flag** — opt-in flat display mode for `list` command; default is now grouped display.
37
+ - **`format_grouped_module_list()`** — Rich table output grouped by namespace.
38
+ - **Updated shell completions** — bash/zsh/fish completion scripts handle two-level group/command structure.
39
+
40
+ ### Changed (Grouped Commands)
41
+
42
+ - `create_cli()` now uses `GroupedModuleGroup` instead of `LazyModuleGroup`.
43
+
44
+ ### Tests (Grouped Commands)
45
+
46
+ - 48 new tests: `TestResolveGroup` (8+), `TestBuildGroupMap` (5+), `TestGroupedModuleGroupRouting` (7), `TestLazyGroupInner` (4), `TestGroupedHelpDisplay` (5), `TestCreateCliGrouped` (1), `TestGroupedE2E` (5), `TestGroupedDiscovery` (7+), `TestGroupedCompletion` (6).
47
+
48
+ ### Added (Convention Module Discovery — §5.14)
49
+
50
+ - **`apcore-cli init module <id>`** — scaffolding command with `--style` (decorator, convention, binding) and `--description` options. Generates module templates in the appropriate directory.
51
+ - **`--commands-dir` CLI option** — path to a convention commands directory. When set, `ConventionScanner` from `apcore-toolkit` scans for plain functions and registers them as modules.
52
+
53
+ ### Tests (Convention Module Discovery)
54
+
55
+ - 6 new tests in `tests/test_init_cmd.py` covering all three styles and options.
56
+
57
+ ---
58
+
8
59
  ## [0.2.2] - 2026-03-22
9
60
 
10
61
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Terminal adapter for apcore — execute AI-Perceivable modules from the command line
5
5
  Project-URL: Homepage, https://aiperceivable.com
6
6
  Project-URL: Repository, https://github.com/aiperceivable/apcore-cli-python
@@ -45,7 +45,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
45
45
 
46
46
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
47
47
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://python.org)
48
- [![Tests](https://img.shields.io/badge/tests-263%20passed-brightgreen.svg)]()
48
+ [![Tests](https://img.shields.io/badge/tests-319%2B%20passed-brightgreen.svg)]()
49
49
 
50
50
  | | |
51
51
  |---|---|
@@ -155,6 +155,37 @@ def cli():
155
155
  cli()
156
156
  ```
157
157
 
158
+ ## Adding Custom Commands
159
+
160
+ ### Fastest way (30 seconds)
161
+
162
+ ```bash
163
+ apcore-cli init module ops.deploy -d "Deploy to environment"
164
+ # Edit the generated file, add your logic
165
+ ```
166
+
167
+ ### Zero-import way (convention discovery)
168
+
169
+ Drop a plain Python function into `commands/`:
170
+
171
+ ```python
172
+ # commands/deploy.py
173
+ def deploy(env: str, tag: str = "latest") -> dict:
174
+ """Deploy the app to the given environment."""
175
+ return {"status": "deployed", "env": env}
176
+ ```
177
+
178
+ Then run with `--commands-dir commands/`:
179
+
180
+ ```bash
181
+ apcore-cli --commands-dir commands/ deploy deploy --env prod
182
+ ```
183
+
184
+ The `init module` command supports three styles via `--style`:
185
+ - **convention** (default) — generates a plain Python function in the commands directory
186
+ - **decorator** — generates a `@module`-decorated function in the extensions directory
187
+ - **binding** — generates a `.binding.yaml` file
188
+
158
189
  ## Integration with Existing Projects
159
190
 
160
191
  ### Typical apcore project structure
@@ -286,6 +317,8 @@ cli:
286
317
  ## Features
287
318
 
288
319
  - **Auto-discovery** -- all modules in the extensions directory are found and exposed as CLI commands
320
+ - **Display overlay** -- `metadata["display"]["cli"]` controls CLI command names, descriptions, and guidance per module (§5.13); set via `binding_path` in `create_cli()` / `fastapi-apcore`
321
+ - **Grouped commands** -- modules with dots in their names are auto-grouped into nested subcommands (`apcore-cli product list` instead of `apcore-cli product.list`); `display.cli.group` in binding.yaml overrides the auto-detected group
289
322
  - **Auto-generated flags** -- JSON Schema `input_schema` is converted to `--flag value` CLI options with type validation
290
323
  - **Boolean flag pairs** -- `--verbose` / `--no-verbose` from `"type": "boolean"` schema properties
291
324
  - **Enum choices** -- `"enum": ["json", "csv"]` becomes `--format json` with Click validation
@@ -304,8 +337,8 @@ cli:
304
337
 
305
338
  | apcore | CLI |
306
339
  |--------|-----|
307
- | `module_id` (`math.add`) | Command name (`apcore-cli math.add`) |
308
- | `description` | `--help` text |
340
+ | `metadata["display"]["cli"]["alias"]` or `module_id` | Command name — auto-grouped by first `.` segment (`apcore-cli product get`) |
341
+ | `metadata["display"]["cli"]["description"]` or `description` | `--help` text |
309
342
  | `input_schema.properties` | CLI flags (`--a`, `--b`) |
310
343
  | `input_schema.required` | Validated post-collection via `jsonschema.validate()` (required fields shown as `[required]` in `--help`) |
311
344
  | `annotations.requires_approval` | HITL approval prompt |
@@ -418,7 +451,7 @@ apcore-cli --extensions-dir ./extensions greet.hello --name Alice --greeting Hi
418
451
  git clone https://github.com/aiperceivable/apcore-cli-python.git
419
452
  cd apcore-cli-python
420
453
  pip install -e ".[dev]"
421
- pytest # 263 tests
454
+ pytest # 319+ tests
422
455
  pytest --cov # with coverage report
423
456
  bash examples/run_examples.sh # run all examples
424
457
  ```
@@ -8,7 +8,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
8
8
 
9
9
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://python.org)
11
- [![Tests](https://img.shields.io/badge/tests-263%20passed-brightgreen.svg)]()
11
+ [![Tests](https://img.shields.io/badge/tests-319%2B%20passed-brightgreen.svg)]()
12
12
 
13
13
  | | |
14
14
  |---|---|
@@ -118,6 +118,37 @@ def cli():
118
118
  cli()
119
119
  ```
120
120
 
121
+ ## Adding Custom Commands
122
+
123
+ ### Fastest way (30 seconds)
124
+
125
+ ```bash
126
+ apcore-cli init module ops.deploy -d "Deploy to environment"
127
+ # Edit the generated file, add your logic
128
+ ```
129
+
130
+ ### Zero-import way (convention discovery)
131
+
132
+ Drop a plain Python function into `commands/`:
133
+
134
+ ```python
135
+ # commands/deploy.py
136
+ def deploy(env: str, tag: str = "latest") -> dict:
137
+ """Deploy the app to the given environment."""
138
+ return {"status": "deployed", "env": env}
139
+ ```
140
+
141
+ Then run with `--commands-dir commands/`:
142
+
143
+ ```bash
144
+ apcore-cli --commands-dir commands/ deploy deploy --env prod
145
+ ```
146
+
147
+ The `init module` command supports three styles via `--style`:
148
+ - **convention** (default) — generates a plain Python function in the commands directory
149
+ - **decorator** — generates a `@module`-decorated function in the extensions directory
150
+ - **binding** — generates a `.binding.yaml` file
151
+
121
152
  ## Integration with Existing Projects
122
153
 
123
154
  ### Typical apcore project structure
@@ -249,6 +280,8 @@ cli:
249
280
  ## Features
250
281
 
251
282
  - **Auto-discovery** -- all modules in the extensions directory are found and exposed as CLI commands
283
+ - **Display overlay** -- `metadata["display"]["cli"]` controls CLI command names, descriptions, and guidance per module (§5.13); set via `binding_path` in `create_cli()` / `fastapi-apcore`
284
+ - **Grouped commands** -- modules with dots in their names are auto-grouped into nested subcommands (`apcore-cli product list` instead of `apcore-cli product.list`); `display.cli.group` in binding.yaml overrides the auto-detected group
252
285
  - **Auto-generated flags** -- JSON Schema `input_schema` is converted to `--flag value` CLI options with type validation
253
286
  - **Boolean flag pairs** -- `--verbose` / `--no-verbose` from `"type": "boolean"` schema properties
254
287
  - **Enum choices** -- `"enum": ["json", "csv"]` becomes `--format json` with Click validation
@@ -267,8 +300,8 @@ cli:
267
300
 
268
301
  | apcore | CLI |
269
302
  |--------|-----|
270
- | `module_id` (`math.add`) | Command name (`apcore-cli math.add`) |
271
- | `description` | `--help` text |
303
+ | `metadata["display"]["cli"]["alias"]` or `module_id` | Command name — auto-grouped by first `.` segment (`apcore-cli product get`) |
304
+ | `metadata["display"]["cli"]["description"]` or `description` | `--help` text |
272
305
  | `input_schema.properties` | CLI flags (`--a`, `--b`) |
273
306
  | `input_schema.required` | Validated post-collection via `jsonschema.validate()` (required fields shown as `[required]` in `--help`) |
274
307
  | `annotations.requires_approval` | HITL approval prompt |
@@ -381,7 +414,7 @@ apcore-cli --extensions-dir ./extensions greet.hello --name Alice --greeting Hi
381
414
  git clone https://github.com/aiperceivable/apcore-cli-python.git
382
415
  cd apcore-cli-python
383
416
  pip install -e ".[dev]"
384
- pytest # 263 tests
417
+ pytest # 319+ tests
385
418
  pytest --cov # with coverage report
386
419
  bash examples/run_examples.sh # run all examples
387
420
  ```
@@ -0,0 +1,4 @@
1
+ def deploy() -> dict:
2
+ """TODO: add description"""
3
+ # TODO: implement
4
+ return {"status": "ok"}
@@ -0,0 +1,235 @@
1
+ # Implementation Plan: Grouped CLI Commands
2
+
3
+ **Priority**: P0
4
+ **Source Spec**: `../apcore-cli/docs/features/grouped-commands.md`
5
+ **Module Paths**: `apcore_cli/cli.py`, `apcore_cli/__main__.py`, `apcore_cli/discovery.py`, `apcore_cli/output.py`, `apcore_cli/shell.py`
6
+ **Dependencies**: Core Dispatcher (FE-01), Display Overlay (§5.13)
7
+
8
+ ---
9
+
10
+ ## Tasks
11
+
12
+ ### Task 1: `_resolve_group()` — group resolution logic
13
+ **Status**: pending
14
+ **Type**: RED-GREEN-REFACTOR
15
+
16
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestResolveGroup`):
17
+ - `test_resolve_group_explicit_group`: descriptor with `display.cli.group = "admin"`, `display.cli.alias = "create"` → returns `("admin", "create")`
18
+ - `test_resolve_group_explicit_group_no_alias`: descriptor with `display.cli.group = "admin"` but no alias → returns `("admin", module_id)`
19
+ - `test_resolve_group_opt_out_empty_string`: descriptor with `display.cli.group = ""`, `display.cli.alias = "healthcheck"` → returns `(None, "healthcheck")`
20
+ - `test_resolve_group_auto_from_alias_dot`: descriptor with `display.cli.alias = "user.list"`, no group → returns `("user", "list")`
21
+ - `test_resolve_group_auto_from_module_id_dot`: module_id `"user.create"`, no display overlay → returns `("user", "create")`
22
+ - `test_resolve_group_no_dot_top_level`: module_id `"standalone"`, no display overlay → returns `(None, "standalone")`
23
+ - `test_resolve_group_multi_dot_first_only`: module_id `"a.b.c"`, no display overlay → returns `("a", "b.c")`
24
+ - `test_resolve_group_empty_module_id_warns`: module_id `""` → returns `(None, "")`, WARNING logged
25
+
26
+ **GREEN** — Implement `_resolve_group(module_id, descriptor)` as a static method on `GroupedModuleGroup`:
27
+ 1. Read `display = _get_display(descriptor)`, `cli_display = display.get("cli") or {}`.
28
+ 2. `explicit_group = cli_display.get("group")`.
29
+ 3. If `explicit_group` is non-empty string: return `(explicit_group, cli_display.get("alias") or module_id)`.
30
+ 4. If `explicit_group == ""`: return `(None, cli_display.get("alias") or module_id)`.
31
+ 5. `cli_name = cli_display.get("alias") or module_id`.
32
+ 6. If `"." in cli_name`: `group, _, cmd = cli_name.partition(".")` → return `(group, cmd)`.
33
+ 7. Else: return `(None, cli_name)`.
34
+
35
+ **REFACTOR** — None expected.
36
+
37
+ **Verification**: `pytest tests/test_cli.py::TestResolveGroup -v`
38
+
39
+ ---
40
+
41
+ ### Task 2: `_build_group_map()` and `GroupedModuleGroup.__init__`
42
+ **Status**: pending
43
+ **Type**: RED-GREEN-REFACTOR
44
+
45
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestBuildGroupMap`):
46
+ - `test_build_group_map_three_groups`: Registry has `product.list`, `product.get`, `user.create`, `user.list`, `standalone` → `_group_map = {"product": 2 entries, "user": 2 entries}`, `_top_level_modules = {"standalone": 1 entry}`
47
+ - `test_build_group_map_idempotent`: Call twice → second call is a no-op (check registry.list call count = 1)
48
+ - `test_build_group_map_builtin_collision_warns`: Module `list.something` exists → WARNING logged about collision with built-in command `list`
49
+ - `test_build_group_map_failure_allows_retry`: Registry raises on first call → `_group_map_built` stays False, second call retries
50
+ - `test_build_group_map_with_display_overlay_group`: descriptor with `display.cli.group = "admin"`, `display.cli.alias = "create"` → grouped under "admin" with command name "create"
51
+
52
+ **GREEN** — Implement:
53
+ - `GroupedModuleGroup(LazyModuleGroup)` with `__init__` adding `_group_map`, `_top_level_modules`, `_group_cache`, `_group_map_built`.
54
+ - `_build_group_map()`:
55
+ 1. Guard: if `_group_map_built`, return.
56
+ 2. Call `self._build_alias_map()` (parent method populates descriptor cache).
57
+ 3. Iterate `self._registry.list()`, get descriptors from cache, call `_resolve_group`.
58
+ 4. Partition into `_group_map` and `_top_level_modules`.
59
+ 5. Check group name collisions with `BUILTIN_COMMANDS`, log warnings.
60
+ 6. Set `_group_map_built = True` inside try block.
61
+
62
+ **REFACTOR** — None expected.
63
+
64
+ **Verification**: `pytest tests/test_cli.py::TestBuildGroupMap -v`
65
+
66
+ ---
67
+
68
+ ### Task 3: `list_commands()` and `get_command()` overrides
69
+ **Status**: pending
70
+ **Type**: RED-GREEN-REFACTOR
71
+
72
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestGroupedModuleGroupRouting`):
73
+ - `test_list_commands_shows_groups_and_top_level`: With product (2 modules) + user (2 modules) + standalone → returns sorted `[completion, describe, exec, list, man, product, standalone, user]`
74
+ - `test_get_command_returns_lazy_group`: `get_command(ctx, "product")` → returns a `click.Group` instance (the `_LazyGroup`)
75
+ - `test_get_command_returns_top_level_command`: `get_command(ctx, "standalone")` → returns a `click.Command` (not a group)
76
+ - `test_get_command_returns_builtin`: `get_command(ctx, "list")` → returns built-in list command (not a group named "list")
77
+ - `test_get_command_unknown_returns_none`: `get_command(ctx, "nonexistent")` → returns `None`
78
+ - `test_get_command_caches_lazy_group`: Two calls to `get_command(ctx, "product")` → same object
79
+
80
+ **GREEN** — Override `list_commands()` and `get_command()` on `GroupedModuleGroup`:
81
+ - `list_commands`: build group map, return sorted(builtins + group names (excluding collisions) + top-level module names).
82
+ - `get_command`: check builtins → check group cache → check group map (create `_LazyGroup`) → check top-level modules → None.
83
+
84
+ **REFACTOR** — None expected.
85
+
86
+ **Verification**: `pytest tests/test_cli.py::TestGroupedModuleGroupRouting -v`
87
+
88
+ ---
89
+
90
+ ### Task 4: `_LazyGroup` — nested group commands
91
+ **Status**: pending
92
+ **Type**: RED-GREEN-REFACTOR
93
+
94
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestLazyGroup`):
95
+ - `test_lazy_group_list_commands`: `_LazyGroup` with members `{"list": ..., "get": ..., "create": ...}` → `list_commands` returns `["create", "get", "list"]`
96
+ - `test_lazy_group_get_command`: `get_command(ctx, "list")` → returns a `click.Command` built from the descriptor
97
+ - `test_lazy_group_get_command_not_found`: `get_command(ctx, "nonexistent")` → returns `None`
98
+ - `test_lazy_group_caches_commands`: Two calls to `get_command(ctx, "list")` → same object
99
+ - `test_lazy_group_command_execution`: Via `CliRunner`, invoke `apcore-cli product list --category food` → executor called with correct module_id and args
100
+
101
+ **GREEN** — Implement `_LazyGroup(click.Group)`:
102
+ - `__init__`: store `members`, `executor`, `help_text_max_length`, init `_cmd_cache`.
103
+ - `list_commands`: return `sorted(self._members.keys())`.
104
+ - `get_command`: check cache → lookup in members → `build_module_command` → cache → return.
105
+
106
+ **REFACTOR** — None expected.
107
+
108
+ **Verification**: `pytest tests/test_cli.py::TestLazyGroup -v`
109
+
110
+ ---
111
+
112
+ ### Task 5: `format_help()` — collapsed root help display
113
+ **Status**: pending
114
+ **Type**: RED-GREEN-REFACTOR
115
+
116
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestGroupedHelpDisplay`):
117
+ - `test_root_help_shows_groups_section`: Via `CliRunner --help`, output contains "Groups:" section header
118
+ - `test_root_help_shows_group_with_count`: Output contains `product` with "(3 commands)" or similar count
119
+ - `test_root_help_shows_top_level_modules`: Output contains "Modules:" section with standalone command
120
+ - `test_root_help_shows_builtin_commands`: Output contains "Commands:" with exec, list, describe, etc.
121
+ - `test_group_help_shows_commands`: Via `CliRunner`, `apcore-cli product --help` shows individual commands (list, get, create)
122
+
123
+ **GREEN** — Override `format_help()` on `GroupedModuleGroup`:
124
+ 1. Build group map.
125
+ 2. Use Click's `HelpFormatter` to write sections: Options, Commands (builtins), Modules (top-level), Groups (with counts).
126
+ 3. `_LazyGroup` uses default Click help formatting (shows its commands normally).
127
+
128
+ **REFACTOR** — None expected.
129
+
130
+ **Verification**: `pytest tests/test_cli.py::TestGroupedHelpDisplay -v`
131
+
132
+ ---
133
+
134
+ ### Task 6: Wire `GroupedModuleGroup` into `create_cli()`
135
+ **Status**: pending
136
+ **Type**: RED-GREEN-REFACTOR
137
+
138
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestCreateCliGrouped`):
139
+ - `test_create_cli_uses_grouped_module_group`: Call `create_cli(extensions_dir=...)` → returned group is instance of `GroupedModuleGroup`
140
+
141
+ **GREEN** — Change `__main__.py`:
142
+ - Import `GroupedModuleGroup` instead of (or in addition to) `LazyModuleGroup`.
143
+ - Change `cls=LazyModuleGroup` → `cls=GroupedModuleGroup` in the `@click.group()` decorator.
144
+
145
+ **REFACTOR** — None expected.
146
+
147
+ **Verification**: `pytest tests/test_cli.py::TestCreateCliGrouped -v`
148
+
149
+ ---
150
+
151
+ ### Task 7: Discovery `list --flat` and `describe group.command`
152
+ **Status**: pending
153
+ **Type**: RED-GREEN-REFACTOR
154
+
155
+ **RED** — Write failing tests in `tests/test_discovery.py` (class `TestGroupedDiscovery`):
156
+ - `test_list_flat_flag`: `apcore-cli list --flat` → output matches flat table (all modules, no grouping)
157
+ - `test_list_default_grouped_display`: `apcore-cli list` with grouped modules → output shows group headers with commands underneath
158
+ - `test_describe_group_dot_command`: `apcore-cli describe product.list` → resolves to the correct module, shows metadata
159
+ - `test_describe_full_module_id`: `apcore-cli describe product.list_products.get` → works with canonical module_id
160
+
161
+ **GREEN** — Modify `discovery.py`:
162
+ - `list_cmd`: add `--flat` flag. When not flat, group modules by their display group and render grouped output.
163
+ - `describe_cmd`: before `validate_module_id`, try to resolve `group.command` notation → scan registry for matching module_id.
164
+
165
+ Modify `output.py`:
166
+ - Add `format_grouped_module_list()` that renders modules grouped under section headers.
167
+
168
+ **REFACTOR** — Ensure `format_module_list()` still works for `--flat` path.
169
+
170
+ **Verification**: `pytest tests/test_discovery.py::TestGroupedDiscovery -v`
171
+
172
+ ---
173
+
174
+ ### Task 8: Shell completion for nested groups
175
+ **Status**: pending
176
+ **Type**: RED-GREEN-REFACTOR
177
+
178
+ **RED** — Write failing tests in `tests/test_shell.py` (class `TestGroupedCompletion`):
179
+ - `test_bash_completion_includes_groups`: Generated bash completion for position 1 includes group names
180
+ - `test_bash_completion_nested_commands`: At position 2 after a group name, completes with group's commands
181
+ - `test_zsh_completion_includes_groups`: Generated zsh completion includes group names
182
+ - `test_fish_completion_includes_groups`: Generated fish completion includes group names and nested subcommands
183
+
184
+ **GREEN** — Modify `shell.py`:
185
+ - Update `_generate_bash_completion`: position 1 completes with builtins + group names + top-level modules; position 2 after a group name completes with that group's commands.
186
+ - Update `_generate_zsh_completion` and `_generate_fish_completion` similarly.
187
+ - Accept `registry` parameter (or group instance) to get group/command lists dynamically.
188
+
189
+ **REFACTOR** — Extract common group/command list generation.
190
+
191
+ **Verification**: `pytest tests/test_shell.py::TestGroupedCompletion -v`
192
+
193
+ ---
194
+
195
+ ### Task 9: Integration tests — end-to-end grouped invocation
196
+ **Status**: pending
197
+ **Type**: RED-GREEN-REFACTOR
198
+
199
+ **RED** — Write failing tests in `tests/test_cli.py` (class `TestGroupedE2E`):
200
+ - `test_grouped_invocation_product_get`: Via `CliRunner`, `apcore-cli product get --id 123` → executor called with correct module_id
201
+ - `test_single_command_group_works`: `apcore-cli health check` → executor called with `health.check` module_id
202
+ - `test_top_level_module_works`: `apcore-cli standalone --key val` → executor called with `standalone` module_id
203
+ - `test_unknown_group_exits_2`: `apcore-cli nonexistent` → exit code 2
204
+ - `test_unknown_command_in_group_exits_2`: `apcore-cli product nonexistent` → exit code 2
205
+
206
+ **GREEN** — No new production code (this validates the full stack from tasks 1–8).
207
+
208
+ **REFACTOR** — Fix any issues found during integration.
209
+
210
+ **Verification**: `pytest tests/test_cli.py::TestGroupedE2E -v`
211
+
212
+ ---
213
+
214
+ ## Implementation Order
215
+
216
+ Execute tasks sequentially: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9
217
+
218
+ Tasks 1–4 are the core grouped commands engine (cli.py only).
219
+ Task 5 is the help display.
220
+ Task 6 wires it in.
221
+ Tasks 7–8 update downstream features.
222
+ Task 9 is the integration test sweep.
223
+
224
+ ## Files Modified
225
+
226
+ | File | Tasks | Changes |
227
+ |------|-------|---------|
228
+ | `src/apcore_cli/cli.py` | 1–5 | Add `GroupedModuleGroup`, `_LazyGroup`, `_resolve_group`, `_build_group_map`, `format_help` |
229
+ | `src/apcore_cli/__main__.py` | 6 | Change `cls=LazyModuleGroup` → `cls=GroupedModuleGroup` |
230
+ | `src/apcore_cli/discovery.py` | 7 | Add `--flat` flag, `group.command` resolution in `describe` |
231
+ | `src/apcore_cli/output.py` | 7 | Add `format_grouped_module_list()` |
232
+ | `src/apcore_cli/shell.py` | 8 | Update completion generators for two-level groups |
233
+ | `tests/test_cli.py` | 1–6, 9 | ~30 new tests across 7 test classes |
234
+ | `tests/test_discovery.py` | 7 | ~4 new tests |
235
+ | `tests/test_shell.py` | 8 | ~4 new tests |
@@ -50,6 +50,23 @@
50
50
  "plan": "shell-integration.md",
51
51
  "priority": "P2",
52
52
  "depends_on": ["core-dispatcher", "schema-parser"]
53
+ },
54
+ "grouped-commands": {
55
+ "status": "completed",
56
+ "plan": "grouped-commands.md",
57
+ "priority": "P0",
58
+ "depends_on": ["core-dispatcher", "discovery", "output-formatter", "shell-integration"],
59
+ "tasks": {
60
+ "task-1-resolve-group": "completed",
61
+ "task-2-build-group-map": "completed",
62
+ "task-3-list-get-command": "completed",
63
+ "task-4-lazy-group": "completed",
64
+ "task-5-format-help": "completed",
65
+ "task-6-wire-create-cli": "completed",
66
+ "task-7-discovery-grouped": "completed",
67
+ "task-8-shell-completion": "completed",
68
+ "task-9-e2e-integration": "completed"
69
+ }
53
70
  }
54
71
  },
55
72
  "implementation_order": [
@@ -60,6 +77,7 @@
60
77
  "discovery",
61
78
  "approval-gate",
62
79
  "security-manager",
63
- "shell-integration"
80
+ "shell-integration",
81
+ "grouped-commands"
64
82
  ]
65
83
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-cli"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -9,7 +9,7 @@ import sys
9
9
  import click
10
10
 
11
11
  from apcore_cli import __version__
12
- from apcore_cli.cli import LazyModuleGroup, set_audit_logger
12
+ from apcore_cli.cli import GroupedModuleGroup, set_audit_logger
13
13
  from apcore_cli.config import ConfigResolver
14
14
  from apcore_cli.discovery import register_discovery_commands
15
15
  from apcore_cli.security.audit import AuditLogger
@@ -20,23 +20,36 @@ logger = logging.getLogger("apcore_cli")
20
20
  EXIT_CONFIG_NOT_FOUND = 47
21
21
 
22
22
 
23
- def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
24
- """Extract --extensions-dir value from argv before Click parses it.
23
+ def _extract_argv_option(argv: list[str] | None, flag: str) -> str | None:
24
+ """Extract an option value from argv before Click parses it.
25
25
 
26
- This is needed because the registry must be created before Click runs,
27
- but --extensions-dir is a Click option parsed at runtime.
26
+ This is needed because certain options must be resolved before Click runs.
28
27
  Returns None if the flag is not present.
29
28
  """
30
29
  args = argv if argv is not None else sys.argv[1:]
31
30
  for i, arg in enumerate(args):
32
- if arg == "--extensions-dir" and i + 1 < len(args):
31
+ if arg == flag and i + 1 < len(args):
33
32
  return args[i + 1]
34
- if arg.startswith("--extensions-dir="):
33
+ if arg.startswith(f"{flag}="):
35
34
  return arg.split("=", 1)[1]
36
35
  return None
37
36
 
38
37
 
39
- def create_cli(extensions_dir: str | None = None, prog_name: str | None = None) -> click.Group:
38
+ def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
39
+ """Extract --extensions-dir value from argv before Click parses it."""
40
+ return _extract_argv_option(argv, "--extensions-dir")
41
+
42
+
43
+ def _extract_commands_dir(argv: list[str] | None = None) -> str | None:
44
+ """Extract --commands-dir value from argv before Click parses it."""
45
+ return _extract_argv_option(argv, "--commands-dir")
46
+
47
+
48
+ def create_cli(
49
+ extensions_dir: str | None = None,
50
+ prog_name: str | None = None,
51
+ commands_dir: str | None = None,
52
+ ) -> click.Group:
40
53
  """Create the CLI application.
41
54
 
42
55
  Args:
@@ -46,6 +59,9 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
46
59
  Defaults to the basename of sys.argv[0], so downstream projects
47
60
  that install their own entry-point script get the correct name
48
61
  automatically (e.g. ``mycli`` instead of ``apcore-cli``).
62
+ commands_dir: Directory containing convention-based modules.
63
+ When set, scans for plain-function modules and registers
64
+ them via ConventionScanner (requires apcore-toolkit).
49
65
  """
50
66
  if prog_name is None:
51
67
  prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
@@ -113,6 +129,23 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
113
129
  except Exception as e:
114
130
  logger.warning("Discovery failed: %s", e)
115
131
 
132
+ # Convention module discovery
133
+ if commands_dir is not None:
134
+ try:
135
+ from apcore_toolkit import RegistryWriter
136
+ from apcore_toolkit.convention_scanner import ConventionScanner
137
+
138
+ conv_scanner = ConventionScanner()
139
+ conv_modules = conv_scanner.scan(commands_dir)
140
+ if conv_modules:
141
+ writer = RegistryWriter(registry=registry)
142
+ writer.write(conv_modules)
143
+ logger.info("Convention scanner: registered %d modules from %s", len(conv_modules), commands_dir)
144
+ except ImportError:
145
+ logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
146
+ except Exception as e:
147
+ logger.warning("Convention module scanning failed: %s", e)
148
+
116
149
  executor = Executor(registry)
117
150
  except Exception as e:
118
151
  click.echo(f"Error: Failed to initialize registry: {e}", err=True)
@@ -126,7 +159,7 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
126
159
  logger.warning("Failed to initialize audit logger: %s", e)
127
160
 
128
161
  @click.group(
129
- cls=LazyModuleGroup,
162
+ cls=GroupedModuleGroup,
130
163
  registry=registry,
131
164
  executor=executor,
132
165
  help_text_max_length=help_text_max_length,
@@ -143,6 +176,12 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
143
176
  default=None,
144
177
  help="Path to apcore extensions directory.",
145
178
  )
179
+ @click.option(
180
+ "--commands-dir",
181
+ "commands_dir_opt",
182
+ default=None,
183
+ help="Path to convention-based commands directory.",
184
+ )
146
185
  @click.option(
147
186
  "--log-level",
148
187
  default=None,
@@ -150,7 +189,12 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
150
189
  help="Log verbosity. Overrides APCORE_CLI_LOGGING_LEVEL and APCORE_LOGGING_LEVEL env vars.",
151
190
  )
152
191
  @click.pass_context
153
- def cli(ctx: click.Context, extensions_dir_opt: str | None = None, log_level: str | None = None) -> None:
192
+ def cli(
193
+ ctx: click.Context,
194
+ extensions_dir_opt: str | None = None,
195
+ commands_dir_opt: str | None = None,
196
+ log_level: str | None = None,
197
+ ) -> None:
154
198
  if log_level is not None:
155
199
  # basicConfig() is a no-op once handlers exist; set level on the root logger directly.
156
200
  level = getattr(logging, log_level.upper(), logging.WARNING)
@@ -167,6 +211,11 @@ def create_cli(extensions_dir: str | None = None, prog_name: str | None = None)
167
211
  # Register shell integration commands
168
212
  register_shell_commands(cli, prog_name=prog_name)
169
213
 
214
+ # Register init scaffolding command
215
+ from apcore_cli.init_cmd import register_init_command
216
+
217
+ register_init_command(cli)
218
+
170
219
  return cli
171
220
 
172
221
 
@@ -178,7 +227,8 @@ def main(prog_name: str | None = None) -> None:
178
227
  When None, inferred from sys.argv[0] automatically.
179
228
  """
180
229
  ext_dir = _extract_extensions_dir()
181
- cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name)
230
+ cmd_dir = _extract_commands_dir()
231
+ cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name, commands_dir=cmd_dir)
182
232
  cli(standalone_mode=True)
183
233
 
184
234