apcore-cli 0.1.0__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.
apcore_cli/config.py ADDED
@@ -0,0 +1,94 @@
1
+ """Configuration resolver with 4-tier precedence (FE-07)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ logger = logging.getLogger("apcore_cli.config")
12
+
13
+
14
+ class ConfigResolver:
15
+ """Resolves configuration values using 4-tier precedence:
16
+ CLI flag > Environment variable > Config file > Default.
17
+ """
18
+
19
+ DEFAULTS: dict[str, Any] = {
20
+ "extensions.root": "./extensions",
21
+ "logging.level": "INFO",
22
+ "sandbox.enabled": False,
23
+ "cli.stdin_buffer_limit": 10_485_760, # 10 MB
24
+ "cli.auto_approve": False,
25
+ }
26
+
27
+ def __init__(
28
+ self,
29
+ cli_flags: dict[str, Any] | None = None,
30
+ config_path: str = "apcore.yaml",
31
+ ) -> None:
32
+ self._cli_flags = cli_flags or {}
33
+ self._config_path = config_path
34
+ self._config_file: dict[str, Any] | None = self._load_config_file()
35
+
36
+ def resolve(
37
+ self,
38
+ key: str,
39
+ cli_flag: str | None = None,
40
+ env_var: str | None = None,
41
+ ) -> Any:
42
+ """Resolve a configuration value using 4-tier precedence."""
43
+ # Tier 1: CLI flag
44
+ if cli_flag is not None and cli_flag in self._cli_flags:
45
+ value = self._cli_flags[cli_flag]
46
+ if value is not None:
47
+ return value
48
+
49
+ # Tier 2: Environment variable
50
+ if env_var is not None:
51
+ env_value = os.environ.get(env_var)
52
+ if env_value is not None and env_value != "":
53
+ return env_value
54
+
55
+ # Tier 3: Config file
56
+ if self._config_file is not None and key in self._config_file:
57
+ return self._config_file[key]
58
+
59
+ # Tier 4: Default
60
+ return self.DEFAULTS.get(key)
61
+
62
+ def _load_config_file(self) -> dict[str, Any] | None:
63
+ """Load and flatten a YAML config file."""
64
+ try:
65
+ with open(self._config_path) as f:
66
+ config = yaml.safe_load(f)
67
+ except FileNotFoundError:
68
+ return None
69
+ except yaml.YAMLError:
70
+ logger.warning(
71
+ "Configuration file '%s' is malformed, using defaults.",
72
+ self._config_path,
73
+ )
74
+ return None
75
+
76
+ if not isinstance(config, dict):
77
+ logger.warning(
78
+ "Configuration file '%s' is malformed, using defaults.",
79
+ self._config_path,
80
+ )
81
+ return None
82
+
83
+ return self._flatten_dict(config)
84
+
85
+ def _flatten_dict(self, d: dict, prefix: str = "") -> dict[str, Any]:
86
+ """Flatten nested dict to dot-notation keys."""
87
+ result: dict[str, Any] = {}
88
+ for key, value in d.items():
89
+ full_key = f"{prefix}.{key}" if prefix else key
90
+ if isinstance(value, dict):
91
+ result.update(self._flatten_dict(value, full_key))
92
+ else:
93
+ result[full_key] = value
94
+ return result
@@ -0,0 +1,75 @@
1
+ """Discovery commands — list and describe (FE-04)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from apcore_cli.cli import validate_module_id
12
+ from apcore_cli.output import format_module_detail, format_module_list, resolve_format
13
+
14
+ _TAG_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
15
+
16
+
17
+ def _validate_tag(tag: str) -> None:
18
+ """Validate tag format."""
19
+ if not _TAG_PATTERN.match(tag):
20
+ click.echo(
21
+ f"Error: Invalid tag format: '{tag}'. Tags must match [a-z][a-z0-9_-]*.",
22
+ err=True,
23
+ )
24
+ sys.exit(2)
25
+
26
+
27
+ def register_discovery_commands(cli: click.Group, registry: Any) -> None:
28
+ """Register list and describe commands on the CLI group."""
29
+
30
+ @cli.command("list")
31
+ @click.option("--tag", multiple=True, help="Filter modules by tag (AND logic). Repeatable.")
32
+ @click.option(
33
+ "--format",
34
+ "output_format",
35
+ type=click.Choice(["table", "json"]),
36
+ default=None,
37
+ help="Output format. Default: table (TTY) or json (non-TTY).",
38
+ )
39
+ def list_cmd(tag: tuple[str, ...], output_format: str | None) -> None:
40
+ # Validate tag format
41
+ for t in tag:
42
+ _validate_tag(t)
43
+
44
+ modules = []
45
+ for mid in registry.list():
46
+ mdef = registry.get_definition(mid)
47
+ if mdef is not None:
48
+ modules.append(mdef)
49
+
50
+ if tag:
51
+ filter_tags = set(tag)
52
+ modules = [m for m in modules if filter_tags.issubset(set(getattr(m, "tags", [])))]
53
+
54
+ fmt = resolve_format(output_format)
55
+ format_module_list(modules, fmt, filter_tags=tag)
56
+
57
+ @cli.command("describe")
58
+ @click.argument("module_id")
59
+ @click.option(
60
+ "--format",
61
+ "output_format",
62
+ type=click.Choice(["table", "json"]),
63
+ default=None,
64
+ help="Output format. Default: table (TTY) or json (non-TTY).",
65
+ )
66
+ def describe_cmd(module_id: str, output_format: str | None) -> None:
67
+ validate_module_id(module_id)
68
+
69
+ module_def = registry.get_definition(module_id)
70
+ if module_def is None:
71
+ click.echo(f"Error: Module '{module_id}' not found.", err=True)
72
+ sys.exit(44)
73
+
74
+ fmt = resolve_format(output_format)
75
+ format_module_detail(module_def, fmt)
apcore_cli/output.py ADDED
@@ -0,0 +1,190 @@
1
+ """Output Formatter — TTY-adaptive output rendering (FE-08)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+
15
+ if TYPE_CHECKING:
16
+ from apcore.registry.types import ModuleDescriptor
17
+
18
+
19
+ def resolve_format(explicit_format: str | None) -> str:
20
+ """Resolve output format with TTY-adaptive default."""
21
+ if explicit_format is not None:
22
+ return explicit_format
23
+ if sys.stdout.isatty():
24
+ return "table"
25
+ return "json"
26
+
27
+
28
+ def _truncate(text: str, max_length: int = 80) -> str:
29
+ """Truncate text to max_length, appending '...' if needed."""
30
+ if len(text) <= max_length:
31
+ return text
32
+ return text[: max_length - 3] + "..."
33
+
34
+
35
+ def format_module_list(
36
+ modules: list[ModuleDescriptor],
37
+ format: str,
38
+ filter_tags: tuple[str, ...] = (),
39
+ ) -> None:
40
+ """Format and print a list of modules."""
41
+ if format == "table":
42
+ if not modules and filter_tags:
43
+ click.echo(f"No modules found matching tags: {', '.join(filter_tags)}.")
44
+ return
45
+ if not modules:
46
+ click.echo("No modules found.")
47
+ return
48
+
49
+ table = Table(title="Modules")
50
+ table.add_column("ID")
51
+ table.add_column("Description")
52
+ table.add_column("Tags")
53
+
54
+ for m in modules:
55
+ desc = _truncate(m.description, 80)
56
+ tags = ", ".join(m.tags) if hasattr(m, "tags") and m.tags else ""
57
+ mid = m.canonical_id if hasattr(m, "canonical_id") else m.module_id
58
+ table.add_row(mid, desc, tags)
59
+
60
+ Console().print(table)
61
+ elif format == "json":
62
+ result = []
63
+ for m in modules:
64
+ mid = m.canonical_id if hasattr(m, "canonical_id") else m.module_id
65
+ result.append(
66
+ {
67
+ "id": mid,
68
+ "description": m.description,
69
+ "tags": m.tags if hasattr(m, "tags") else [],
70
+ }
71
+ )
72
+ click.echo(json.dumps(result, indent=2))
73
+
74
+
75
+ def _annotations_to_dict(annotations: Any) -> dict | None:
76
+ """Convert annotations (dict or dataclass) to a plain dict, or None."""
77
+ if annotations is None:
78
+ return None
79
+ if isinstance(annotations, dict):
80
+ return annotations if annotations else None
81
+ # Dataclass-like object (e.g. ModuleAnnotations) — convert non-default fields
82
+ try:
83
+ import dataclasses
84
+
85
+ if dataclasses.is_dataclass(annotations):
86
+ return {
87
+ k: v
88
+ for k, v in dataclasses.asdict(annotations).items()
89
+ if v is not None and v is not False and v != 0 and v != []
90
+ }
91
+ except Exception:
92
+ pass
93
+ # Fallback: try vars()
94
+ try:
95
+ d = {
96
+ k: v
97
+ for k, v in vars(annotations).items()
98
+ if not k.startswith("_") and v is not None and v is not False and v != 0
99
+ }
100
+ return d if d else None
101
+ except Exception:
102
+ return None
103
+
104
+
105
+ def format_module_detail(module_def: ModuleDescriptor, format: str) -> None:
106
+ """Format and print full module metadata."""
107
+ mid = module_def.canonical_id if hasattr(module_def, "canonical_id") else module_def.module_id
108
+
109
+ if format == "table":
110
+ console = Console()
111
+ console.print(Panel(f"Module: {mid}"))
112
+ click.echo(f"\nDescription:\n {module_def.description}\n")
113
+
114
+ if hasattr(module_def, "input_schema") and module_def.input_schema:
115
+ click.echo("\nInput Schema:")
116
+ console.print(Syntax(json.dumps(module_def.input_schema, indent=2), "json", theme="monokai"))
117
+
118
+ if hasattr(module_def, "output_schema") and module_def.output_schema:
119
+ click.echo("\nOutput Schema:")
120
+ console.print(Syntax(json.dumps(module_def.output_schema, indent=2), "json", theme="monokai"))
121
+
122
+ ann_dict = _annotations_to_dict(getattr(module_def, "annotations", None))
123
+ if ann_dict:
124
+ click.echo("\nAnnotations:")
125
+ for k, v in ann_dict.items():
126
+ click.echo(f" {k}: {v}")
127
+
128
+ # Extension metadata (x- prefixed)
129
+ x_fields = {}
130
+ if hasattr(module_def, "metadata") and isinstance(module_def.metadata, dict):
131
+ x_fields = {k: v for k, v in module_def.metadata.items() if k.startswith("x-") or k.startswith("x_")}
132
+ # Also check vars() for x_ prefixed attributes
133
+ try:
134
+ for k, v in vars(module_def).items():
135
+ if (k.startswith("x_") or k.startswith("x-")) and k not in x_fields:
136
+ x_fields[k] = v
137
+ except TypeError:
138
+ pass
139
+ if x_fields:
140
+ click.echo("\nExtension Metadata:")
141
+ for k, v in x_fields.items():
142
+ click.echo(f" {k}: {v}")
143
+
144
+ tags = getattr(module_def, "tags", [])
145
+ if tags:
146
+ click.echo(f"\nTags: {', '.join(tags)}")
147
+
148
+ elif format == "json":
149
+ result: dict[str, Any] = {
150
+ "id": mid,
151
+ "description": module_def.description,
152
+ }
153
+ if hasattr(module_def, "input_schema") and module_def.input_schema:
154
+ result["input_schema"] = module_def.input_schema
155
+ if hasattr(module_def, "output_schema") and module_def.output_schema:
156
+ result["output_schema"] = module_def.output_schema
157
+
158
+ ann_dict = _annotations_to_dict(getattr(module_def, "annotations", None))
159
+ if ann_dict:
160
+ result["annotations"] = ann_dict
161
+
162
+ tags = getattr(module_def, "tags", [])
163
+ if tags:
164
+ result["tags"] = tags
165
+
166
+ # Extension metadata
167
+ if hasattr(module_def, "metadata") and isinstance(module_def.metadata, dict):
168
+ for k, v in module_def.metadata.items():
169
+ if k.startswith("x-") or k.startswith("x_"):
170
+ result[k] = v
171
+ try:
172
+ for k, v in vars(module_def).items():
173
+ if (k.startswith("x_") or k.startswith("x-")) and k not in result:
174
+ result[k] = v
175
+ except TypeError:
176
+ pass
177
+
178
+ click.echo(json.dumps(result, indent=2))
179
+
180
+
181
+ def format_exec_result(result: Any, format: str | None = None) -> None:
182
+ """Format and print module execution result."""
183
+ if result is None:
184
+ return
185
+ if isinstance(result, dict | list):
186
+ click.echo(json.dumps(result, indent=2, default=str))
187
+ elif isinstance(result, str):
188
+ click.echo(result)
189
+ else:
190
+ click.echo(str(result))
@@ -0,0 +1,113 @@
1
+ """$ref resolution and schema composition (FE-02)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import sys
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+
12
+ def resolve_refs(schema: dict, max_depth: int = 32, module_id: str = "") -> dict:
13
+ """Resolve all $ref references in a JSON Schema.
14
+
15
+ Returns a fully inlined schema with $defs/definitions removed.
16
+ """
17
+ schema = copy.deepcopy(schema)
18
+ defs = schema.get("$defs", schema.get("definitions", {}))
19
+ result = _resolve_node(schema, defs, visited=set(), depth=0, max_depth=max_depth, module_id=module_id)
20
+
21
+ # Remove definition keys
22
+ result.pop("$defs", None)
23
+ result.pop("definitions", None)
24
+ return result
25
+
26
+
27
+ def _resolve_node(
28
+ node: Any,
29
+ defs: dict,
30
+ visited: set,
31
+ depth: int,
32
+ max_depth: int,
33
+ module_id: str = "",
34
+ ) -> Any:
35
+ """Recursively resolve $ref, allOf, anyOf, oneOf in a schema node."""
36
+ if not isinstance(node, dict):
37
+ return node
38
+
39
+ # Handle $ref
40
+ if "$ref" in node:
41
+ ref_path = node["$ref"]
42
+
43
+ if depth >= max_depth:
44
+ click.echo(
45
+ f"Error: $ref resolution depth exceeded maximum of {max_depth} for module '{module_id}'.",
46
+ err=True,
47
+ )
48
+ sys.exit(48)
49
+
50
+ if ref_path in visited:
51
+ click.echo(
52
+ f"Error: Circular $ref detected in schema for module '{module_id}' at path '{ref_path}'.",
53
+ err=True,
54
+ )
55
+ sys.exit(48)
56
+
57
+ # Parse ref target: extract key from "#/$defs/Address" → "Address"
58
+ parts = ref_path.split("/")
59
+ key = parts[-1]
60
+
61
+ if key not in defs:
62
+ click.echo(
63
+ f"Error: Unresolvable $ref '{ref_path}' in schema for module '{module_id}'.",
64
+ err=True,
65
+ )
66
+ sys.exit(45)
67
+
68
+ visited = visited | {ref_path}
69
+ return _resolve_node(defs[key], defs, visited, depth + 1, max_depth, module_id)
70
+
71
+ # Handle allOf
72
+ if "allOf" in node:
73
+ merged: dict[str, Any] = {"properties": {}, "required": []}
74
+ for sub_schema in node["allOf"]:
75
+ resolved = _resolve_node(sub_schema, defs, visited, depth + 1, max_depth, module_id)
76
+ if "properties" in resolved:
77
+ merged["properties"].update(resolved["properties"])
78
+ if "required" in resolved:
79
+ merged["required"].extend(resolved["required"])
80
+ # Copy non-composition keys
81
+ for k, v in node.items():
82
+ if k != "allOf" and k not in merged:
83
+ merged[k] = v
84
+ return merged
85
+
86
+ # Handle anyOf / oneOf
87
+ for keyword in ("anyOf", "oneOf"):
88
+ if keyword in node:
89
+ merged = {"properties": {}, "required": []}
90
+ all_required_sets: list[set[str]] = []
91
+ for sub_schema in node[keyword]:
92
+ resolved = _resolve_node(sub_schema, defs, visited, depth + 1, max_depth, module_id)
93
+ if "properties" in resolved:
94
+ merged["properties"].update(resolved["properties"])
95
+ if "required" in resolved:
96
+ all_required_sets.append(set(resolved["required"]))
97
+ # Required = intersection of all branches
98
+ if all_required_sets:
99
+ merged["required"] = list(set.intersection(*all_required_sets))
100
+ else:
101
+ merged["required"] = []
102
+ # Copy non-composition keys
103
+ for k, v in node.items():
104
+ if k != keyword and k not in merged:
105
+ merged[k] = v
106
+ return merged
107
+
108
+ # Recursively process nested properties
109
+ if "properties" in node:
110
+ for prop_name, prop_schema in node["properties"].items():
111
+ node["properties"][prop_name] = _resolve_node(prop_schema, defs, visited, depth + 1, max_depth, module_id)
112
+
113
+ return node
@@ -0,0 +1,168 @@
1
+ """Schema Parser — JSON Schema to Click options mapping (FE-02)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ logger = logging.getLogger("apcore_cli.schema_parser")
12
+
13
+ # Sentinel for boolean flag marker
14
+ _BOOLEAN_FLAG = object()
15
+
16
+
17
+ def _map_type(prop_name: str, prop_schema: dict) -> Any:
18
+ """Map JSON Schema type to Click parameter type."""
19
+ schema_type = prop_schema.get("type")
20
+
21
+ # Check file convention
22
+ if schema_type == "string" and (prop_name.endswith("_file") or prop_schema.get("x-cli-file") is True):
23
+ return click.Path(exists=True)
24
+
25
+ type_map = {
26
+ "string": click.STRING,
27
+ "integer": click.INT,
28
+ "number": click.FLOAT,
29
+ "boolean": _BOOLEAN_FLAG,
30
+ "object": click.STRING,
31
+ "array": click.STRING,
32
+ }
33
+
34
+ if schema_type is None:
35
+ logger.warning(
36
+ "No type specified for property '%s', defaulting to string.",
37
+ prop_name,
38
+ )
39
+ return click.STRING
40
+
41
+ result = type_map.get(schema_type)
42
+ if result is None:
43
+ logger.warning(
44
+ "Unknown schema type '%s' for property '%s', defaulting to string.",
45
+ schema_type,
46
+ prop_name,
47
+ )
48
+ return click.STRING
49
+
50
+ return result
51
+
52
+
53
+ def _extract_help(prop_schema: dict) -> str | None:
54
+ """Extract help text from schema property, preferring x-llm-description."""
55
+ text = prop_schema.get("x-llm-description")
56
+ if not text:
57
+ text = prop_schema.get("description")
58
+ if not text:
59
+ return None
60
+ if len(text) > 200:
61
+ return text[:197] + "..."
62
+ return text
63
+
64
+
65
+ def schema_to_click_options(schema: dict) -> list[click.Option]:
66
+ """Convert JSON Schema properties to a list of Click options."""
67
+ properties = schema.get("properties", {})
68
+ required_list = schema.get("required", [])
69
+ options: list[click.Option] = []
70
+ flag_names: dict[str, str] = {}
71
+
72
+ # Warn about required properties not found in properties
73
+ for req_name in required_list:
74
+ if req_name not in properties:
75
+ logger.warning(
76
+ "Required property '%s' not found in properties, skipping.",
77
+ req_name,
78
+ )
79
+
80
+ for prop_name, prop_schema in properties.items():
81
+ flag_name = "--" + prop_name.replace("_", "-")
82
+
83
+ # Collision detection
84
+ if flag_name in flag_names:
85
+ click.echo(
86
+ f"Error: Flag name collision: properties '{prop_name}' and "
87
+ f"'{flag_names[flag_name]}' both map to '{flag_name}'.",
88
+ err=True,
89
+ )
90
+ sys.exit(48)
91
+
92
+ flag_names[flag_name] = prop_name
93
+
94
+ click_type = _map_type(prop_name, prop_schema)
95
+ is_required = prop_name in required_list
96
+ help_text = _extract_help(prop_schema)
97
+ default = prop_schema.get("default", None)
98
+
99
+ if click_type is _BOOLEAN_FLAG:
100
+ # Boolean flag pair
101
+ default_val = prop_schema.get("default", False)
102
+ flag_base = prop_name.replace("_", "-")
103
+ option = click.Option(
104
+ [f"--{flag_base}/--no-{flag_base}"],
105
+ default=default_val,
106
+ help=help_text,
107
+ show_default=True,
108
+ )
109
+ elif "enum" in prop_schema and click_type is not _BOOLEAN_FLAG:
110
+ enum_values = prop_schema["enum"]
111
+ if not enum_values:
112
+ logger.warning(
113
+ "Empty enum for property '%s', no values allowed.",
114
+ prop_name,
115
+ )
116
+ option = click.Option(
117
+ [flag_name],
118
+ type=click.STRING,
119
+ required=is_required,
120
+ default=default,
121
+ help=help_text,
122
+ )
123
+ else:
124
+ string_values = [str(v) for v in enum_values]
125
+ option = click.Option(
126
+ [flag_name],
127
+ type=click.Choice(string_values),
128
+ required=is_required,
129
+ default=str(default) if default is not None else None,
130
+ help=help_text,
131
+ )
132
+ # Store original types for post-parse reconversion
133
+ option._enum_original_types = {str(v): type(v) for v in enum_values}
134
+ else:
135
+ option = click.Option(
136
+ [flag_name],
137
+ type=click_type,
138
+ required=is_required,
139
+ default=default,
140
+ help=help_text,
141
+ )
142
+
143
+ options.append(option)
144
+
145
+ return options
146
+
147
+
148
+ def reconvert_enum_values(kwargs: dict[str, Any], options: list[click.Option]) -> dict[str, Any]:
149
+ """Reconvert enum values from string back to their original types."""
150
+ result = dict(kwargs)
151
+ for opt in options:
152
+ original_types = getattr(opt, "_enum_original_types", None)
153
+ if original_types is None:
154
+ continue
155
+ # Get the parameter name (Click uses the dest name)
156
+ param_name = opt.name
157
+ if param_name not in result or result[param_name] is None:
158
+ continue
159
+ str_val = str(result[param_name])
160
+ if str_val in original_types:
161
+ orig_type = original_types[str_val]
162
+ if orig_type is int:
163
+ result[param_name] = int(str_val)
164
+ elif orig_type is float:
165
+ result[param_name] = float(str_val)
166
+ elif orig_type is bool:
167
+ result[param_name] = str_val.lower() == "true"
168
+ return result
@@ -0,0 +1,8 @@
1
+ """Security sub-package (FE-05)."""
2
+
3
+ from apcore_cli.security.audit import AuditLogger
4
+ from apcore_cli.security.auth import AuthProvider
5
+ from apcore_cli.security.config_encryptor import ConfigEncryptor
6
+ from apcore_cli.security.sandbox import Sandbox
7
+
8
+ __all__ = ["AuthProvider", "ConfigEncryptor", "AuditLogger", "Sandbox"]