Skip to content

Feature Spec: Core Dispatcher

Feature ID: FE-01 Status: Ready for Implementation Priority: P0 Parent: Tech Design v1.0 Section 8.2 SRS Requirements: FR-DISP-001, FR-DISP-002, FR-DISP-003, FR-DISP-004


1. Description

The Core Dispatcher is the primary entry point for apcore-cli. It provides the LazyModuleGroup Click Group subclass that lazily discovers modules from the apcore Registry, routes commands to built-in subcommands or dynamically generated module commands, handles STDIN JSON input, and delegates execution to the apcore Executor.


2. Requirements Traceability

Req ID SRS Ref Description
FR-01-01 FR-DISP-001 Base command apcore-cli entry point with --help and --version.
FR-01-02 FR-DISP-002 exec <module_id> subcommand routing and execution via Executor.
FR-01-03 FR-DISP-003 Extensions directory loading from configurable path.
FR-01-04 FR-DISP-001 AF-3 Version flag: apcore-cli --version prints apcore-cli, version X.Y.Z.
FR-01-05 FR-DISP-004 STDIN JSON input when --input - is specified.

3. Module Path

apcore_cli/cli.py


4. Implementation Details

4.1 Class: LazyModuleGroup

File: apcore_cli/cli.py

class LazyModuleGroup(click.Group):
    """Custom Click Group that lazily loads apcore modules as subcommands."""

    def __init__(self, registry: Registry, executor: Executor, **kwargs):
        super().__init__(**kwargs)
        self._registry = registry
        self._executor = executor
        self._module_cache: dict[str, click.Command] = {}

Method: list_commands(ctx) -> list[str]

Logic steps: 1. Define built-in commands list: ["exec", "list", "describe", "completion", "man"]. 2. Call self._registry.list() to get all module definitions. 3. Extract canonical_id from each module definition. 4. Return sorted(set(builtin + module_ids)).

Edge cases: - Registry returns empty list: return only built-in commands. - Registry raises exception during list(): catch, log WARNING, return only built-in commands.

Method: get_command(ctx, cmd_name) -> click.Command | None

Logic steps: 1. Check self.commands dict for built-in commands. If found, return it. 2. Check self._module_cache for previously resolved modules. If found, return it. 3. Call self._registry.get_definition(cmd_name). 4. If None, return None (Click will show "command not found"). 5. Call build_module_command(module_def, self._executor). 6. Store result in self._module_cache[cmd_name]. 7. Return the command.

4.2 Function: build_module_command

Signature: build_module_command(module_def: ModuleDefinition, executor: Executor) -> click.Command

Logic steps: 1. Get input_schema from module_def.input_schema. 2. Call resolve_refs(input_schema, max_depth=32) to inline all $ref references. 3. Call schema_to_click_options(resolved_schema) to generate Click options. 4. Create a Click command with: - name: module_def.canonical_id - help: module_def.description - Built-in options: --input, --yes, --large-input, --format, --sandbox 5. The command callback: a. Call collect_input(stdin_input, kwargs, large_input) to merge STDIN + CLI flags. b. Call jsonschema.validate(merged, resolved_schema). On failure: exit 45 with validation error detail. c. Call check_approval(module_def, auto_approve, ctx). On denial/timeout: exit 46. d. Record audit_start = time.monotonic(). e. Call executor.call(module_def.canonical_id, merged). f. Compute duration_ms = int((time.monotonic() - audit_start) * 1000). g. Call audit_logger.log_execution(module_id, merged, "success", 0, duration_ms). h. Call format_output(result, ctx) to write to stdout. i. Exit with code 0. 6. On Executor error: catch, write error to stderr, audit log with "error", exit with mapped error code. 7. On KeyboardInterrupt: write "Execution cancelled." to stderr, exit 130. 8. Append all generated Click options to command.params. 9. Return the command.

4.3 Function: collect_input

Signature: collect_input(stdin_flag: str | None, cli_kwargs: dict, large_input: bool) -> dict

Logic steps: 1. If stdin_flag is None or empty string: return cli_kwargs (with None values removed). 2. If stdin_flag == "-": a. Read sys.stdin.read() into raw. b. Check len(raw.encode('utf-8')): - If > 10_485_760 and large_input is False: exit code 2, message "STDIN input exceeds 10MB limit. Use --large-input to override." c. If raw is empty (0 bytes): set stdin_data = {}. d. Else: parse json.loads(raw) into stdin_data. - On json.JSONDecodeError: exit code 2, message "STDIN does not contain valid JSON: {e.msg}." e. If stdin_data is not a dict: exit code 2, message "STDIN JSON must be an object, got {type(stdin_data).name}." f. Merge: result = {**stdin_data, **cli_kwargs_non_none} (CLI flags override STDIN for duplicate keys). 3. Return result.

4.4 Function: validate_module_id

Signature: validate_module_id(module_id: str) -> None

Logic steps: 1. Check len(module_id) > 128: exit code 2, message "Invalid module ID format: '{module_id}'. Maximum length is 128 characters." 2. Check re.fullmatch(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$', module_id): if None, exit code 2, message "Invalid module ID format: '{module_id}'."

4.5 Function: main

Signature: main() -> None

File: apcore_cli/__main__.py

Logic steps: 1. Instantiate ConfigResolver(). 2. Resolve extensions_root via config.resolve("extensions.root", cli_flag="--extensions-dir", env_var="APCORE_EXTENSIONS_ROOT"). 3. Verify extensions_root path exists and is a directory: - Not exists: exit 47, message "Extensions directory not found: '{path}'. Set APCORE_EXTENSIONS_ROOT or verify the path." - Not readable: exit 47, message "Cannot read extensions directory: '{path}'. Check file permissions." 4. Instantiate Registry(extensions_root). 5. Call registry.discover(). Log DEBUG: "Loading extensions from {path}". 6. Log INFO: "Initialized apcore-cli with {N} modules." 7. If zero modules and corrupt modules were skipped: continue (no error). 8. Instantiate Executor(registry). 9. Instantiate LazyModuleGroup(registry, executor, name="apcore-cli", help="CLI adapter for the apcore module ecosystem."). 10. Register built-in commands: list, describe, completion, man. 11. Invoke cli(standalone_mode=True).


5. Boundary Values & Limits

Parameter Minimum Maximum Default SRS Reference
Module ID length 1 char 128 chars FR-DISP-002
STDIN buffer 0 bytes 10 MB (configurable) 10 MB FR-DISP-004
Registry module count 0 1,000 (design target) NFR-PERF-003
Startup time 100 ms NFR-PERF-001
Adapter overhead 50 ms NFR-PERF-002

6. Error Handling

Condition Exit Code Error Message SRS Reference
Invalid module ID format 2 "Error: Invalid module ID format: '{id}'." FR-DISP-002 AF-2
Module not found 44 "Error: Module '{id}' not found in registry." FR-DISP-002 AF-1
Module disabled 44 "Error: Module '{id}' is disabled." FR-DISP-002 AF-5
Module load error 44 "Error: Module '{id}' failed to load: {detail}." FR-DISP-002 AF-7
Schema validation failure 45 "Error: Validation failed for '{prop}': {constraint}." FR-DISP-002 AF-3
Module execution error 1 "Error: Module '{id}' execution failed: {detail}." FR-DISP-002 AF-4
STDIN exceeds buffer 2 "Error: STDIN input exceeds 10MB limit. Use --large-input to override." FR-DISP-004 AF-1
STDIN invalid JSON 2 "Error: STDIN does not contain valid JSON: {detail}." FR-DISP-004 AF-2
STDIN not object 2 "Error: STDIN JSON must be an object, got {type}." FR-DISP-004 AF-3
Extensions dir missing 47 "Error: Extensions directory not found: '{path}'. Set APCORE_EXTENSIONS_ROOT or verify the path." FR-DISP-003 AF-1
Extensions dir unreadable 47 "Error: Cannot read extensions directory: '{path}'. Check file permissions." FR-DISP-003 AF-2
ACL denied 77 "Error: Permission denied for module '{id}'." FR-DISP-002 AF-8
User interruption 130 "Execution cancelled." FR-DISP-002 AF-6

7. Verification

Test ID Description Expected Result
T-DISP-01 Run apcore-cli --help with valid extensions Output contains "exec", "list", "describe" and module IDs. Exit 0.
T-DISP-02 Run apcore-cli --version Output: "apcore-cli, version X.Y.Z". Exit 0.
T-DISP-03 Run apcore-cli exec non.existent stderr: "not found". Exit 44.
T-DISP-04 Run apcore-cli exec "INVALID!ID" stderr: "Invalid module ID format". Exit 2.
T-DISP-05 Run apcore-cli exec math.add --a 5 --b 10 stdout: module result. Exit 0.
T-DISP-06 Pipe echo '{"a":5,"b":10}' \| apcore-cli exec math.add --input - Module receives {a:5, b:10}. Exit 0.
T-DISP-07 Pipe echo '{"a":5}' \| apcore-cli exec math.add --input - --a 99 Module receives {a:99} (CLI flag overrides STDIN).
T-DISP-08 Pipe 15MB JSON without --large-input stderr: "exceeds 10MB limit". Exit 2.
T-DISP-09 Pipe invalid JSON stderr: "does not contain valid JSON". Exit 2.
T-DISP-10 Set APCORE_EXTENSIONS_ROOT=/tmp/test Modules loaded from /tmp/test.
T-DISP-11 Extensions dir does not exist stderr: "not found". Exit 47.
T-DISP-12 Extensions dir with one corrupt module Corrupt module skipped with WARNING. Valid modules available.
T-DISP-13 --extensions-dir overrides APCORE_EXTENSIONS_ROOT CLI flag path is used.