Feature Spec: Discovery¶
Feature ID: FE-04 Status: Ready for Implementation Priority: P1 Parent: Tech Design v1.0 Section 8.5 SRS Requirements: FR-DISC-001, FR-DISC-002, FR-DISC-003, FR-DISC-004
1. Description¶
The Discovery component provides apcore-cli list and apcore-cli describe subcommands for browsing available modules in the Registry. It supports tag-based filtering (AND logic), TTY-adaptive output format selection (table vs JSON), and rich terminal rendering with syntax-highlighted JSON schemas.
2. Requirements Traceability¶
| Req ID | SRS Ref | Description |
|---|---|---|
| FR-04-01 | FR-DISC-001 | list subcommand displaying modules as formatted table. |
| FR-04-02 | FR-DISC-003 | describe subcommand displaying full module metadata. |
| FR-04-03 | FR-DISC-002 | Tag filtering with AND logic on list. |
| FR-04-04 | FR-DISC-003 | Syntax-highlighted JSON schemas in describe output. |
| FR-04-05 | FR-DISC-004 | --format flag with TTY-adaptive defaults. |
3. Module Path¶
apcore_cli/discovery.py
4. Implementation Details¶
4.1 Command: list_cmd¶
Registration: @cli.command("list")
Signature: list_cmd(tag: tuple[str, ...], format: str | None) -> None
Click decorators:
@click.option("--tag", multiple=True, help="Filter modules by tag (AND logic). Repeatable.")
@click.option("--format", "output_format", type=click.Choice(["table", "json"]),
default=None, help="Output format. Default: table (TTY) or json (non-TTY).")
Logic steps:
1. Call registry.list() to get all module definitions.
2. If tag tuple is non-empty:
a. Convert to set: filter_tags = set(tag).
b. Filter: modules = [m for m in modules if filter_tags.issubset(set(m.tags))].
3. Resolve output format:
a. If output_format is not None: use it.
b. Else if sys.stdout.isatty(): use "table".
c. Else: use "json".
4. If format is "table":
a. Create rich.table.Table with columns: "ID", "Description", "Tags".
b. For each module:
- description: truncate to 80 chars + "..." if longer.
- tags: ", ".join(module.tags).
- Add row.
c. If no modules and tags were specified: display "No modules found matching tags: {tags}.".
d. If no modules and no tags: display "No modules found.".
e. Print table via rich.console.Console().print(table).
5. If format is "json":
a. Build list of dicts: [{"id": m.canonical_id, "description": m.description, "tags": m.tags} for m in modules].
b. Print json.dumps(result, indent=2) to stdout.
6. Exit code 0.
4.2 Command: describe_cmd¶
Registration: @cli.command("describe")
Signature: describe_cmd(module_id: str, output_format: str | None) -> None
Click decorators:
@click.argument("module_id")
@click.option("--format", "output_format", type=click.Choice(["table", "json"]),
default=None, help="Output format. Default: table (TTY) or json (non-TTY).")
Logic steps:
1. Call validate_module_id(module_id). On failure: exit 2.
2. Call module_def = registry.get_definition(module_id).
3. If module_def is None: write to stderr "Error: Module '{module_id}' not found." Exit 44.
4. Access schemas from module_def.input_schema and module_def.output_schema.
5. Resolve output format (same logic as list_cmd).
6. If format is "table":
a. Print section header "Module: {module_id}" with rich.panel.Panel.
b. Print "Description:" followed by full module_def.description.
c. If module_def.input_schema is not None:
- Print "Input Schema:" header.
- Print rich.syntax.Syntax(json.dumps(input_schema, indent=2), "json").
d. If module_def.output_schema is not None:
- Print "Output Schema:" header.
- Print rich.syntax.Syntax(json.dumps(output_schema, indent=2), "json").
e. If module_def.annotations is not None and non-empty:
- Print "Annotations:" header.
- For each (key, value) in annotations: print " {key}: {value}".
f. If any x- prefixed keys in module metadata:
- Print "Extension Metadata:" header.
- For each x- key: print " {key}: {value}".
g. Print "Tags:" followed by ", ".join(module_def.tags).
7. If format is "json":
a. Build dict with all fields: id, description, input_schema, output_schema, annotations, tags, and all x- fields.
b. Omit keys with None values.
c. Print json.dumps(result, indent=2).
8. Exit code 0.
4.3 Description Truncation Helper¶
Function: _truncate(text: str, max_length: int = 80) -> str
def _truncate(text: str, max_length: int = 80) -> str:
if len(text) <= max_length:
return text
return text[:max_length - 3] + "..."
5. Parameter Validation¶
| Parameter | Type | Valid Values | Invalid Handling | SRS Reference |
|---|---|---|---|---|
--tag |
str (multiple) |
Each: ^[a-z][a-z0-9_-]*$ |
Invalid format: exit 2. Non-existent tag: empty result (not error). | FR-DISC-002 |
--format |
click.Choice |
"table", "json" |
Other values: Click rejects, exit 2. | FR-DISC-004 |
module_id (describe) |
str |
Canonical ID regex, max 128 chars. | Invalid format: exit 2. Not found: exit 44. | FR-DISC-003 |
6. Output Format Examples¶
6.1 Table Output (list)¶
+----------------+--------------------------+------------+
| ID | Description | Tags |
+----------------+--------------------------+------------+
| math.add | Add two numbers. | math, core |
| text.summarize | Summarize a text docum...| text |
+----------------+--------------------------+------------+
6.2 JSON Output (list)¶
[
{
"id": "math.add",
"description": "Add two numbers.",
"tags": ["math", "core"]
},
{
"id": "text.summarize",
"description": "Summarize a text document using extractive methods.",
"tags": ["text"]
}
]
6.3 Table Output (describe)¶
--- Module: math.add ---
Description:
Add two numbers together and return the sum.
Input Schema:
{
"properties": {
"a": {"type": "integer", "description": "First operand"},
"b": {"type": "integer", "description": "Second operand"}
},
"required": ["a", "b"]
}
Annotations:
readonly: true
requires_approval: false
Tags: math, core
7. Error Handling¶
| Condition | Exit Code | Error Message | SRS Reference |
|---|---|---|---|
| Module not found (describe) | 44 | "Error: Module '{id}' not found." | FR-DISC-003 AF-1 |
| Invalid format value | 2 | Click auto-generates: "Invalid value for '--format'..." | FR-DISC-004 AF-1 |
| Empty registry (list) | 0 | Table shows "No modules found." or JSON []. |
FR-DISC-001 AF-1 |
| No matching tags (list) | 0 | "No modules found matching tags: {tags}." or JSON []. |
FR-DISC-002 AF-1 |
8. Verification¶
| Test ID | Description | Expected Result |
|---|---|---|
| T-DISC-01 | apcore-cli list with 2 modules |
Table with both module IDs, descriptions, tags. Exit 0. |
| T-DISC-02 | apcore-cli list with empty registry |
"No modules found." Exit 0. |
| T-DISC-03 | apcore-cli list --tag math |
Only modules with math tag shown. |
| T-DISC-04 | apcore-cli list --tag math --tag core |
Only modules with both math AND core tags. |
| T-DISC-05 | apcore-cli list --tag nonexistent |
"No modules found matching tags: nonexistent." Exit 0. |
| T-DISC-06 | apcore-cli list --format json |
Valid JSON array output. |
| T-DISC-07 | apcore-cli list in non-TTY (piped) |
JSON output by default. |
| T-DISC-08 | apcore-cli list --format table in non-TTY |
Table output (flag overrides). |
| T-DISC-09 | apcore-cli describe math.add |
Full metadata with syntax-highlighted schemas. Exit 0. |
| T-DISC-10 | apcore-cli describe non.existent |
stderr: "not found". Exit 44. |
| T-DISC-11 | apcore-cli describe math.add --format json |
JSON object output with all metadata. |
| T-DISC-12 | Module with 120-char description in list |
Description truncated to 80 chars + "...". |
| T-DISC-13 | Module without output_schema in describe |
Output Schema section omitted. |
| T-DISC-14 | Module without annotations in describe |
Annotations section omitted. |
| T-DISC-15 | apcore-cli list --format yaml |
Click rejects. Exit 2. |