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/__init__.py +3 -0
- apcore_cli/__main__.py +142 -0
- apcore_cli/_sandbox_runner.py +25 -0
- apcore_cli/approval.py +167 -0
- apcore_cli/cli.py +315 -0
- apcore_cli/config.py +94 -0
- apcore_cli/discovery.py +75 -0
- apcore_cli/output.py +190 -0
- apcore_cli/ref_resolver.py +113 -0
- apcore_cli/schema_parser.py +168 -0
- apcore_cli/security/__init__.py +8 -0
- apcore_cli/security/audit.py +53 -0
- apcore_cli/security/auth.py +37 -0
- apcore_cli/security/config_encryptor.py +94 -0
- apcore_cli/security/sandbox.py +60 -0
- apcore_cli/shell.py +185 -0
- apcore_cli-0.1.0.dist-info/METADATA +459 -0
- apcore_cli-0.1.0.dist-info/RECORD +20 -0
- apcore_cli-0.1.0.dist-info/WHEEL +4 -0
- apcore_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|
apcore_cli/discovery.py
ADDED
|
@@ -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"]
|