Skip to content

Commit be38bb6

Browse files
committed
feat: integrate apcore-toolkit as a required dependency to standardize CSV/JSONL output and remove branding from CLI help and documentation.
1 parent de44783 commit be38bb6

10 files changed

Lines changed: 105 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,31 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
9-
## [Unreleased]
8+
## [0.9.0] - 2026-05-11
109

1110
### Added
1211

1312
- **`tests/conformance/test_snake_case_kwargs.py`** — runs the cross-language Algorithm C-SNAKE fixture (`apcore-cli/conformance/fixtures/snake-case-kwargs/cases.json`) against `build_module_command` via `click.testing.CliRunner`. Five cases verify that schema property names with underscores (`has_solution`, `sort_by`, `sort_order`) survive the round trip from CLI parse to the input dict received by `executor.call`. No source change required — click natively maps `--has-solution` to `has_solution`; the Python SDK is the parity reference for the parallel TypeScript fix. Surfaced as part of the cross-SDK regression coverage gap audit.
1413

14+
### Fixed
15+
16+
- **CSV `--format csv` Python-repr bug**`csv.DictWriter` was called with `{k: str(v) for k, v in row.items()}` which emitted Python repr `{'k': 'v'}` (single quotes) for nested dict/list values. The output was not valid JSON and any downstream JSON parser would fail. Now delegates to `apcore_toolkit.format_csv(rows)` which emits canonical compact JSON. `src/apcore_cli/output.py:149, 378`.
17+
- **CSV heterogeneous-keys data loss** — header is now the union of keys across all rows (was first-row only via `list(rows[0].keys())`).
18+
- **CSV line terminator** — now `\r\n` per RFC 4180.
19+
- **JSONL canonical form** — now compact (no spaces between separators), matching the cross-SDK contract. Tests updated.
20+
21+
### Changed
22+
23+
- **User-visible help/man/completion text no longer leaks the `apcore` framework name** to end users of downstream CLIs built on apcore-cli. Affected strings: `init` group description (`Scaffold new apcore modules` → `Scaffold new modules`, `init_cmd.py:45`), `--extensions-dir` option help (`Path to apcore extensions directory.` → `Path to extensions directory.`, `factory.py:460`), zsh/fish completion descriptions for `exec` (`Execute an apcore module` → `Execute a module`, `shell.py:130, 211`), and man-page `ENVIRONMENT` section text (`shell.py:299, 314, 319, 458`) — drops `apcore` from the descriptive copy (`Path to the apcore extensions directory` → `Path to the extensions directory`, `Global apcore logging verbosity` → `Global logging verbosity`, `API key for authenticating with the apcore registry` → `API key for authenticating with the registry`). Logger names, source comments, module docstrings, and environment-variable identifiers (`APCORE_*`) are unchanged — only descriptive copy that appears in `--help`, shell completion, and `man` output. Cross-SDK parity with TypeScript 0.8.2 and Rust 0.8.1.
24+
25+
### Changed (breaking dependency semantics)
26+
27+
- **`apcore-toolkit` promoted from optional extra to REQUIRED runtime dependency** (`>=0.7.0`). The previous `pip install 'apcore-cli[toolkit]'` extras pattern is retained as a no-op for backward compat with install scripts, but the toolkit is now always installed alongside apcore-cli. All `--format` operations route through the toolkit's reference implementation for csv/jsonl/markdown/skill.
28+
29+
### Why
30+
31+
See ADR-09 in `apcore-cli/docs/tech-design.md` for the byte-equivalent toolkit-delegated tier rationale.
32+
1533

1634
## [0.8.0] - 2026-05-08
1735

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
4848
pip install apcore-cli
4949
```
5050

51-
Requires Python 3.11+ and `apcore >= 0.21.0`. The optional `toolkit` extra requires `apcore-toolkit >= 0.6`:
51+
Requires Python 3.11+, `apcore >= 0.21.0`, and `apcore-toolkit >= 0.7.0` (now a **required** runtime dependency as of v0.9.0 — previously optional; see the [tech-design ADR-09](https://github.com/aiperceivable/apcore-cli/blob/main/docs/tech-design.md) for the byte-equivalent toolkit-delegated tier rationale). The `[toolkit]` extras group is retained as a no-op for backward compat:
5252

5353
```bash
54-
pip install "apcore-cli[toolkit]"
54+
pip install "apcore-cli[toolkit]" # equivalent to plain `pip install apcore-cli`
5555
```
5656

5757
## Quick Start
@@ -323,7 +323,7 @@ When executing a module (e.g. `apcore-cli math add`), these built-in options are
323323
| `--input -` | Read JSON input from STDIN |
324324
| `--yes` / `-y` | Bypass approval prompts |
325325
| `--large-input` | Allow STDIN input larger than 10MB |
326-
| `--format` | Output format: `{json, table, csv, yaml, jsonl}` |
326+
| `--format` | Output format: `{json, table, csv, yaml, jsonl, markdown, skill}`. **v0.9.0:** `csv` and `jsonl` are byte-identical across SDKs (delegated to `apcore-toolkit.format_csv` / `format_jsonl`); fixes the prior `str(v)` Python-repr bug for nested values. |
327327
| `--sandbox` | Run module in subprocess sandbox *(not yet implemented)* |
328328
| `--dry-run` | Run preflight checks without executing (FE-11, v0.6.0) |
329329
| `--trace` | Emit execution pipeline trace (v0.6.0) |

pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-cli"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -27,6 +27,7 @@ classifiers = [
2727
]
2828
dependencies = [
2929
"apcore>=0.21.0",
30+
"apcore-toolkit>=0.7.0",
3031
"click>=8.1",
3132
"jsonschema>=4.20",
3233
"rich>=13.0",
@@ -45,9 +46,10 @@ dev = [
4546
"ruff>=0.1",
4647
"pre-commit>=3.5",
4748
]
48-
toolkit = [
49-
"apcore-toolkit>=0.6",
50-
]
49+
# `toolkit` is kept as a no-op extras group for backward compat with downstream
50+
# `apcore-cli[toolkit]` install commands; apcore-toolkit is now a required
51+
# runtime dependency (see ADR-09).
52+
toolkit = []
5153

5254
[project.scripts]
5355
apcore-cli = "apcore_cli.__main__:main"

src/apcore_cli/factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ def cli(
457457
click.Option(
458458
["--extensions-dir", "extensions_dir_opt"],
459459
default=None,
460-
help="Path to apcore extensions directory.",
460+
help="Path to extensions directory.",
461461
),
462462
click.Option(
463463
["--commands-dir", "commands_dir_opt"],

src/apcore_cli/init_cmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def register_init_command(cli: click.Group) -> None:
4242

4343
@cli.group("init")
4444
def init_group():
45-
"""Scaffold new apcore modules."""
45+
"""Scaffold new modules."""
4646
pass
4747

4848
@init_group.command("module")

src/apcore_cli/output.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import TYPE_CHECKING, Any
99

1010
import click
11+
from apcore_toolkit import format_csv, format_jsonl
1112
from rich.console import Console
1213
from rich.panel import Panel
1314
from rich.syntax import Syntax
@@ -28,6 +29,24 @@
2829
)
2930

3031

32+
def _to_rows_for_tabular(value: Any) -> list[dict] | None:
33+
"""Coerce an exec result into the row-shape expected by the toolkit's
34+
tabular formatters (csv / jsonl). Returns ``None`` for shapes that don't
35+
map to tabular (scalars, empty lists, lists of non-dicts).
36+
"""
37+
if value is None:
38+
return None
39+
if isinstance(value, list):
40+
if not value:
41+
return None
42+
if not all(isinstance(item, dict) for item in value):
43+
return None
44+
return value
45+
if isinstance(value, dict):
46+
return [value]
47+
return None
48+
49+
3150
def _descriptor_to_scanned(module_def: Any) -> Any:
3251
"""Adapt a registry `ModuleDescriptor` to the toolkit's `ScannedModule`.
3352
@@ -147,26 +166,19 @@ def format_module_list(
147166
if format == "json":
148167
click.echo(json.dumps(rows, indent=2))
149168
elif format == "csv":
150-
import csv
151-
import io
152-
169+
# Delegate to apcore-toolkit for byte-equivalent cross-SDK output.
170+
# Fixes the prior `str(v)` Python-repr bug for nested values.
153171
if rows:
154-
buf = io.StringIO()
155-
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
156-
writer.writeheader()
157-
for row in rows:
158-
writer.writerow({k: str(v) for k, v in row.items()})
159-
click.echo(buf.getvalue().rstrip())
172+
click.echo(format_csv(rows).rstrip())
160173
elif format == "yaml":
161174
try:
162175
import yaml
163176

164177
click.echo(yaml.dump(rows, default_flow_style=False, allow_unicode=True).rstrip())
165178
except ImportError:
166179
click.echo(json.dumps(rows, indent=2))
167-
elif format == "jsonl":
168-
for row in rows:
169-
click.echo(json.dumps(row))
180+
elif format == "jsonl" and rows:
181+
click.echo(format_jsonl(rows).rstrip())
170182

171183

172184
def _annotations_to_dict(annotations: Any) -> dict | None:
@@ -376,22 +388,9 @@ def format_exec_result(result: Any, format: str | None = None, fields: str | Non
376388
effective = resolve_format(format)
377389

378390
if effective == "csv":
379-
import csv
380-
import io
381-
382-
if isinstance(result, dict):
383-
buf = io.StringIO()
384-
writer = csv.DictWriter(buf, fieldnames=list(result.keys()))
385-
writer.writeheader()
386-
writer.writerow({k: str(v) for k, v in result.items()})
387-
click.echo(buf.getvalue().rstrip())
388-
elif isinstance(result, list) and result and isinstance(result[0], dict):
389-
buf = io.StringIO()
390-
writer = csv.DictWriter(buf, fieldnames=list(result[0].keys()))
391-
writer.writeheader()
392-
for row in result:
393-
writer.writerow({k: str(v) for k, v in row.items()})
394-
click.echo(buf.getvalue().rstrip())
391+
rows = _to_rows_for_tabular(result)
392+
if rows is not None:
393+
click.echo(format_csv(rows).rstrip())
395394
else:
396395
click.echo(json.dumps(result, default=str))
397396
elif effective == "yaml":
@@ -402,9 +401,9 @@ def format_exec_result(result: Any, format: str | None = None, fields: str | Non
402401
except ImportError:
403402
click.echo(json.dumps(result, indent=2, default=str))
404403
elif effective == "jsonl":
405-
if isinstance(result, list):
406-
for item in result:
407-
click.echo(json.dumps(item, default=str))
404+
rows = _to_rows_for_tabular(result)
405+
if rows is not None:
406+
click.echo(format_jsonl(rows).rstrip())
408407
else:
409408
click.echo(json.dumps(result, default=str))
410409
elif effective == "table" and isinstance(result, dict):

src/apcore_cli/shell.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def _generate_zsh_completion(prog_name: str) -> str:
127127
f"{fn}() {{\n"
128128
" local -a commands groups_and_top\n"
129129
" commands=(\n"
130-
" 'exec:Execute an apcore module'\n"
130+
" 'exec:Execute a module'\n"
131131
" 'list:List available modules'\n"
132132
" 'describe:Show module metadata and schema'\n"
133133
" 'completion:Generate shell completion script'\n"
@@ -208,7 +208,7 @@ def _generate_fish_completion(prog_name: str) -> str:
208208
f"{group_cmds_fish_fn}"
209209
f"\n"
210210
f'complete -c {quoted} -n "__fish_use_subcommand"'
211-
' -a exec -d "Execute an apcore module"\n'
211+
' -a exec -d "Execute a module"\n'
212212
f'complete -c {quoted} -n "__fish_use_subcommand"'
213213
' -a list -d "List available modules"\n'
214214
f'complete -c {quoted} -n "__fish_use_subcommand"'
@@ -296,7 +296,7 @@ def _generate_man_page(command_name: str, command: click.Command | None, prog_na
296296
sections.append(".SH ENVIRONMENT")
297297
sections.append(".TP")
298298
sections.append("\\fBAPCORE_EXTENSIONS_ROOT\\fR")
299-
sections.append("Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.")
299+
sections.append("Path to the extensions directory. Overrides the default \\fI./extensions\\fR.")
300300
sections.append(".TP")
301301
sections.append("\\fBAPCORE_CLI_AUTO_APPROVE\\fR")
302302
sections.append(
@@ -311,12 +311,12 @@ def _generate_man_page(command_name: str, command: click.Command | None, prog_na
311311
sections.append(".TP")
312312
sections.append("\\fBAPCORE_LOGGING_LEVEL\\fR")
313313
sections.append(
314-
"Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. "
314+
"Global logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. "
315315
"Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING."
316316
)
317317
sections.append(".TP")
318318
sections.append("\\fBAPCORE_AUTH_API_KEY\\fR")
319-
sections.append("API key for authenticating with the apcore registry.")
319+
sections.append("API key for authenticating with the registry.")
320320

321321
sections.append(".SH EXIT CODES")
322322
exit_codes = [
@@ -455,7 +455,7 @@ def build_program_man_page(
455455
s.append(".SH ENVIRONMENT")
456456
s.append(".TP")
457457
s.append("\\fBAPCORE_EXTENSIONS_ROOT\\fR")
458-
s.append("Path to the apcore extensions directory.")
458+
s.append("Path to the extensions directory.")
459459
s.append(".TP")
460460
s.append("\\fBAPCORE_CLI_AUTO_APPROVE\\fR")
461461
s.append("Set to \\fB1\\fR to bypass approval prompts.")

tests/conformance/test_snake_case_kwargs.py

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,8 @@
2727
from apcore_cli.cli import build_module_command
2828

2929
_DEFAULT_SPEC_REPO = Path(__file__).resolve().parents[3] / "apcore-cli"
30-
SPEC_REPO_ROOT = Path(
31-
os.environ.get("APCORE_CLI_SPEC_REPO", str(_DEFAULT_SPEC_REPO))
32-
)
33-
FIXTURE_PATH = (
34-
SPEC_REPO_ROOT
35-
/ "conformance"
36-
/ "fixtures"
37-
/ "snake-case-kwargs"
38-
/ "cases.json"
39-
)
30+
SPEC_REPO_ROOT = Path(os.environ.get("APCORE_CLI_SPEC_REPO", str(_DEFAULT_SPEC_REPO)))
31+
FIXTURE_PATH = SPEC_REPO_ROOT / "conformance" / "fixtures" / "snake-case-kwargs" / "cases.json"
4032

4133

4234
def _load_fixture() -> dict[str, Any]:
@@ -64,9 +56,7 @@ def _make_module_def(module_id: str, input_schema: dict[str, Any]) -> Any:
6456
ids=[c["id"] for c in _FIXTURE["test_cases"]],
6557
)
6658
def test_snake_case_kwargs_flow(case: dict[str, Any]) -> None:
67-
module_def = _make_module_def(
68-
_FIXTURE["module_id"], _FIXTURE["input_schema"]
69-
)
59+
module_def = _make_module_def(_FIXTURE["module_id"], _FIXTURE["input_schema"])
7060
captured: dict[str, Any] = {}
7161

7262
def _capture_call(module_id: str, input_data: dict[str, Any]) -> Any:
@@ -82,17 +72,11 @@ def _capture_call(module_id: str, input_data: dict[str, Any]) -> Any:
8272
runner = CliRunner()
8373
args = ["--format", "json", "-y", *case["args"]]
8474
result = runner.invoke(cmd, args, catch_exceptions=False)
85-
assert result.exit_code == 0, (
86-
f"args={case['args']} exit_code={result.exit_code} "
87-
f"stdout={result.output!r}"
88-
)
75+
assert result.exit_code == 0, f"args={case['args']} exit_code={result.exit_code} " f"stdout={result.output!r}"
8976

9077
actual = captured.get("input", {})
9178
for key, expected in case["expected_input"].items():
92-
assert key in actual, (
93-
f"missing key '{key}' in input; full input={actual!r}"
94-
)
79+
assert key in actual, f"missing key '{key}' in input; full input={actual!r}"
9580
assert actual[key] == expected, (
96-
f"input['{key}'] = {actual[key]!r}, expected {expected!r}; "
97-
f"full input={actual!r}"
81+
f"input['{key}'] = {actual[key]!r}, expected {expected!r}; " f"full input={actual!r}"
9882
)

tests/test_output_format_exec.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ def test_csv_non_dict_falls_back_to_json(self, capsys):
4949
# non-dict/list → JSON dump of the scalar string
5050
assert out == '"hello"'
5151

52+
# --- Regression: nested-object CSV (apcore-cli-python str() repr bug) ---
53+
def test_csv_nested_object_serializes_as_json(self, capsys):
54+
"""Before toolkit-delegate, str() emitted Python repr `{'k': 'v'}`
55+
with single quotes — invalid JSON. Toolkit emits canonical JSON."""
56+
format_exec_result(
57+
{"schema": {"type": "object", "properties": {"a": {"type": "integer"}}}},
58+
format="csv",
59+
)
60+
out = capsys.readouterr().out
61+
# Must contain canonical JSON (double-quoted), not Python repr.
62+
assert "'type'" not in out, "must not emit Python repr"
63+
assert '"type":"object"' in out.replace('""', '"') or '""type""' in out
64+
65+
# --- Regression: heterogeneous-keys data loss ---
66+
def test_csv_heterogeneous_keys_included(self, capsys):
67+
"""Before toolkit-delegate, csv.DictWriter was given only first-row
68+
keys, dropping later-row fields silently. Toolkit emits union of keys."""
69+
format_exec_result(
70+
[{"sn": 1, "title": "A"}, {"sn": 2, "title": "B", "description": "later-only"}],
71+
format="csv",
72+
)
73+
out = capsys.readouterr().out
74+
lines = [line for line in out.splitlines() if line.strip()]
75+
assert lines[0] == "sn,title,description"
76+
assert lines[1] == "1,A,"
77+
assert lines[2] == "2,B,later-only"
78+
5279
def test_yaml_format(self, capsys):
5380
format_exec_result({"a": 1, "b": [1, 2]}, format="yaml")
5481
out = capsys.readouterr().out
@@ -58,12 +85,13 @@ def test_yaml_format(self, capsys):
5885
def test_jsonl_list(self, capsys):
5986
format_exec_result([{"i": 1}, {"i": 2}], format="jsonl")
6087
out = capsys.readouterr().out
61-
assert '{"i": 1}' in out
62-
assert '{"i": 2}' in out
88+
# Toolkit-canonical compact JSON: no whitespace between separators.
89+
assert '{"i":1}' in out
90+
assert '{"i":2}' in out
6391

6492
def test_jsonl_non_list(self, capsys):
6593
format_exec_result({"i": 1}, format="jsonl")
66-
assert capsys.readouterr().out.strip() == '{"i": 1}'
94+
assert capsys.readouterr().out.strip() == '{"i":1}'
6795

6896
def test_table_dict_uses_rich(self, capsys):
6997
# Force table format even though stdout isn't a TTY — the function

tests/test_shell.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def list_cmd(tag):
2929
@cli.command("exec")
3030
@click.argument("module_id", required=False)
3131
def exec_cmd(module_id):
32-
"""Execute an apcore module."""
32+
"""Execute a module."""
3333
pass
3434

3535
register_shell_commands(cli, prog_name=prog_name)

0 commit comments

Comments
 (0)