llm-tool-cli 0.2.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.
- llm_tool_cli/__init__.py +0 -0
- llm_tool_cli/cli/__init__.py +1 -0
- llm_tool_cli/cli/application.py +31 -0
- llm_tool_cli/cli/commands/__init__.py +0 -0
- llm_tool_cli/cli/commands/initialization.py +36 -0
- llm_tool_cli/cli/commands/skills.py +25 -0
- llm_tool_cli/cli/commands/tests/__init__.py +0 -0
- llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
- llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
- llm_tool_cli/cli/commands/tests/test_version.py +98 -0
- llm_tool_cli/cli/commands/version.py +17 -0
- llm_tool_cli/cli/context.py +39 -0
- llm_tool_cli/cli/entities.py +3 -0
- llm_tool_cli/cli/errors.py +15 -0
- llm_tool_cli/cli/handling.py +41 -0
- llm_tool_cli/cli/options.py +48 -0
- llm_tool_cli/cli/tests/__init__.py +0 -0
- llm_tool_cli/cli/tests/test_application.py +167 -0
- llm_tool_cli/cli/tests/test_context.py +179 -0
- llm_tool_cli/cli/tests/test_handling.py +230 -0
- llm_tool_cli/cli/tests/test_options.py +125 -0
- llm_tool_cli/config/__init__.py +33 -0
- llm_tool_cli/config/errors.py +92 -0
- llm_tool_cli/config/fields.py +39 -0
- llm_tool_cli/config/files.py +175 -0
- llm_tool_cli/config/project.py +87 -0
- llm_tool_cli/config/runtime.py +14 -0
- llm_tool_cli/config/tests/__init__.py +0 -0
- llm_tool_cli/config/tests/fixtures.py +34 -0
- llm_tool_cli/config/tests/test_fields.py +124 -0
- llm_tool_cli/config/tests/test_files.py +826 -0
- llm_tool_cli/config/tests/test_project.py +257 -0
- llm_tool_cli/core/__init__.py +1 -0
- llm_tool_cli/core/context.py +50 -0
- llm_tool_cli/core/entities.py +35 -0
- llm_tool_cli/core/errors.py +97 -0
- llm_tool_cli/core/result.py +118 -0
- llm_tool_cli/core/settings.py +37 -0
- llm_tool_cli/core/tests/__init__.py +0 -0
- llm_tool_cli/core/tests/fixtures.py +13 -0
- llm_tool_cli/core/tests/test_context.py +152 -0
- llm_tool_cli/core/tests/test_entities.py +65 -0
- llm_tool_cli/core/tests/test_errors.py +211 -0
- llm_tool_cli/core/tests/test_result.py +256 -0
- llm_tool_cli/core/tests/test_settings.py +64 -0
- llm_tool_cli/output/__init__.py +3 -0
- llm_tool_cli/output/emitter.py +108 -0
- llm_tool_cli/output/errors.py +21 -0
- llm_tool_cli/output/journal.py +66 -0
- llm_tool_cli/output/tests/__init__.py +0 -0
- llm_tool_cli/output/tests/helpers.py +29 -0
- llm_tool_cli/output/tests/test_emitter.py +406 -0
- llm_tool_cli/output/tests/test_journal.py +141 -0
- llm_tool_cli/paths/__init__.py +51 -0
- llm_tool_cli/paths/enumeration.py +72 -0
- llm_tool_cli/paths/errors.py +42 -0
- llm_tool_cli/paths/filesystem.py +164 -0
- llm_tool_cli/paths/globs.py +124 -0
- llm_tool_cli/paths/normalization.py +58 -0
- llm_tool_cli/paths/tests/__init__.py +0 -0
- llm_tool_cli/paths/tests/test_enumeration.py +229 -0
- llm_tool_cli/paths/tests/test_filesystem.py +778 -0
- llm_tool_cli/paths/tests/test_globs.py +137 -0
- llm_tool_cli/paths/tests/test_normalization.py +163 -0
- llm_tool_cli/protocol/__init__.py +4 -0
- llm_tool_cli/protocol/cell_shortcuts.py +39 -0
- llm_tool_cli/protocol/entities.py +7 -0
- llm_tool_cli/protocol/errors.py +21 -0
- llm_tool_cli/protocol/journal.py +21 -0
- llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
- llm_tool_cli/protocol/logic_cells/base.py +41 -0
- llm_tool_cli/protocol/logic_cells/content.py +26 -0
- llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
- llm_tool_cli/protocol/logic_cells/journal.py +44 -0
- llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
- llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
- llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
- llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
- llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
- llm_tool_cli/protocol/logic_cells/warning.py +22 -0
- llm_tool_cli/protocol/output_cells/__init__.py +11 -0
- llm_tool_cli/protocol/output_cells/automation.py +14 -0
- llm_tool_cli/protocol/output_cells/base.py +65 -0
- llm_tool_cli/protocol/output_cells/human.py +24 -0
- llm_tool_cli/protocol/output_cells/journal.py +23 -0
- llm_tool_cli/protocol/output_cells/llm.py +25 -0
- llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
- llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
- llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
- llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
- llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
- llm_tool_cli/protocol/serialization.py +7 -0
- llm_tool_cli/protocol/tests/__init__.py +0 -0
- llm_tool_cli/protocol/tests/helpers.py +25 -0
- llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
- llm_tool_cli/protocol/tests/test_journal.py +44 -0
- llm_tool_cli/protocol/tests/test_serialization.py +33 -0
- llm_tool_cli/py.typed +0 -0
- llm_tool_cli/skills/__init__.py +6 -0
- llm_tool_cli/skills/errors.py +12 -0
- llm_tool_cli/skills/fixtures.py +14 -0
- llm_tool_cli/skills/tests/__init__.py +0 -0
- llm_tool_cli/skills/tests/test_fixtures.py +58 -0
- llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
- llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
- llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
- llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
llm_tool_cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared command-line options and policies."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.cli.options import ConfigOption, ProtocolOption
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_app(*, help: str) -> typer.Typer:
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
help=help,
|
|
9
|
+
add_completion=True,
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
@app.callback()
|
|
14
|
+
def initialize(
|
|
15
|
+
protocol: ProtocolOption = None,
|
|
16
|
+
config_path: ConfigOption = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Declare global options for command-specific runtime setup."""
|
|
19
|
+
# Typer uses this callback's signature to register --protocol/-p and
|
|
20
|
+
# --config on the root command group. Before calling it, the framework
|
|
21
|
+
# parses these options and stores them in the root context's params;
|
|
22
|
+
# this function does not need to store or forward the values itself.
|
|
23
|
+
# CommandContext reads them through context.find_root().params when the
|
|
24
|
+
# selected command starts. It can then choose the command's default
|
|
25
|
+
# protocol and bind RuntimeConfig, while leaving configuration path
|
|
26
|
+
# resolution and loading to commands that actually need a workspace.
|
|
27
|
+
# The callback therefore intentionally performs no initialization.
|
|
28
|
+
# Removing it would remove the global option declarations and could
|
|
29
|
+
# make a single-command application lose its command-group interface.
|
|
30
|
+
|
|
31
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.cli.context import command_context
|
|
7
|
+
from llm_tool_cli.config import initialize_project
|
|
8
|
+
from llm_tool_cli.config.project import project_config
|
|
9
|
+
from llm_tool_cli.output import output
|
|
10
|
+
from llm_tool_cli.paths import PathInput
|
|
11
|
+
from llm_tool_cli.protocol.cell_shortcuts import configuration_created
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_init_command(
|
|
15
|
+
app: typer.Typer,
|
|
16
|
+
*,
|
|
17
|
+
config_class: type[pydantic.BaseModel],
|
|
18
|
+
package: str,
|
|
19
|
+
template: str,
|
|
20
|
+
help: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Register project initialization using the supplied schema and starter."""
|
|
23
|
+
|
|
24
|
+
@app.command("init", help=help)
|
|
25
|
+
def init(context: typer.Context) -> None:
|
|
26
|
+
with command_context(context) as command:
|
|
27
|
+
context.with_resource(
|
|
28
|
+
initialize_project(
|
|
29
|
+
config_class,
|
|
30
|
+
package=package,
|
|
31
|
+
template=template,
|
|
32
|
+
cwd=PathInput(Path.cwd()),
|
|
33
|
+
path=command.config_input,
|
|
34
|
+
)
|
|
35
|
+
).unwrap()
|
|
36
|
+
output.get().emit([configuration_created(project_config.get().config_path)]).unwrap()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.cli.context import command_context
|
|
7
|
+
from llm_tool_cli.output import output
|
|
8
|
+
from llm_tool_cli.protocol import cell_shortcuts
|
|
9
|
+
from llm_tool_cli.skills import load_skill_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_skill_command(app: typer.Typer, *, package: str, documents: type[StrEnum]) -> None:
|
|
13
|
+
"""Register packaged documentation using the consumer's enum, including usage."""
|
|
14
|
+
|
|
15
|
+
def skill(
|
|
16
|
+
context: typer.Context,
|
|
17
|
+
document: StrEnum = documents("usage"),
|
|
18
|
+
) -> None:
|
|
19
|
+
with command_context(context):
|
|
20
|
+
content = load_skill_text(package=package, document=document.value).unwrap()
|
|
21
|
+
output.get().emit([cell_shortcuts.skill(document=document.value, content=content)]).unwrap()
|
|
22
|
+
|
|
23
|
+
# Typer reads the consumer's concrete enum for validation, help, and completion.
|
|
24
|
+
skill.__annotations__["document"] = Annotated[documents, typer.Argument()]
|
|
25
|
+
app.command("skill", help="Print built-in skill documentation.")(skill)
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
import pytest
|
|
6
|
+
import typer
|
|
7
|
+
from pytest_mock import MockerFixture
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.cli.application import create_app
|
|
11
|
+
from llm_tool_cli.cli.commands.initialization import register_init_command
|
|
12
|
+
from llm_tool_cli.config.project import project_config
|
|
13
|
+
from llm_tool_cli.config.runtime import runtime_config
|
|
14
|
+
from llm_tool_cli.config.tests.fixtures import project_static_settings
|
|
15
|
+
from llm_tool_cli.output import output
|
|
16
|
+
from llm_tool_cli.protocol.tests.helpers import cell_payloads
|
|
17
|
+
|
|
18
|
+
__all__ = ["project_static_settings"]
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.usefixtures("project_static_settings")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApplicationConfig(pydantic.BaseModel):
|
|
24
|
+
count: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def init_app(tmp_path: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> typer.Typer:
|
|
29
|
+
fixtures = tmp_path / "fixtures"
|
|
30
|
+
fixtures.mkdir()
|
|
31
|
+
(fixtures / "starter.toml").write_text("count = 5\n", encoding="utf-8")
|
|
32
|
+
mocker.patch("llm_tool_cli.config.files.importlib.resources.files", return_value=tmp_path)
|
|
33
|
+
monkeypatch.chdir(tmp_path)
|
|
34
|
+
app = create_app(help="Initialize a test project.")
|
|
35
|
+
register_init_command(app, config_class=ApplicationConfig, package="test_resources", template="starter.toml")
|
|
36
|
+
return app
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestRegisterInitCommand:
|
|
40
|
+
@pytest.mark.parametrize("protocol", [None, "human", "llm", "automation"])
|
|
41
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
42
|
+
def test_creates_validated_config_and_reports_selected_path(
|
|
43
|
+
self, init_app: typer.Typer, tmp_path: Path, protocol: str | None, explicit: bool
|
|
44
|
+
) -> None:
|
|
45
|
+
options = [] if protocol is None else ["-p", protocol]
|
|
46
|
+
if explicit:
|
|
47
|
+
options.extend(["--config", "custom.toml"])
|
|
48
|
+
path = tmp_path / ("custom.toml" if explicit else "tool.toml")
|
|
49
|
+
|
|
50
|
+
result = CliRunner().invoke(init_app, [*options, "init"])
|
|
51
|
+
|
|
52
|
+
assert result.exit_code == 0, result.exception
|
|
53
|
+
assert path.read_text(encoding="utf-8") == "count = 5\n"
|
|
54
|
+
assert not result.stderr
|
|
55
|
+
if protocol == "automation":
|
|
56
|
+
assert cell_payloads([json.loads(result.stdout)]) == [
|
|
57
|
+
{"type": "operation_succeeded", "content": "Configuration created.", "path": str(path)}
|
|
58
|
+
]
|
|
59
|
+
elif protocol == "llm":
|
|
60
|
+
assert "type=operation_succeeded\n" in result.stdout
|
|
61
|
+
assert f"path={path}\n" in result.stdout
|
|
62
|
+
else:
|
|
63
|
+
assert result.stdout == f"path: {path}\n\nConfiguration created.\n\n"
|
|
64
|
+
assert not project_config.is_set()
|
|
65
|
+
assert not runtime_config.is_set()
|
|
66
|
+
assert not output.is_set()
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize(
|
|
69
|
+
"text, code", [("count =", "config_invalid_toml"), ('count = "invalid"', "config_validation_failed")]
|
|
70
|
+
)
|
|
71
|
+
def test_invalid_starter_keeps_file_and_reports_failure_without_success(
|
|
72
|
+
self, init_app: typer.Typer, tmp_path: Path, text: str, code: str
|
|
73
|
+
) -> None:
|
|
74
|
+
(tmp_path / "fixtures/starter.toml").write_text(text, encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
result = CliRunner().invoke(init_app, ["-p", "automation", "init"])
|
|
77
|
+
|
|
78
|
+
assert result.exit_code == 2
|
|
79
|
+
record = json.loads(result.stdout)
|
|
80
|
+
assert record["type"] == "error"
|
|
81
|
+
assert record["code"] == code
|
|
82
|
+
assert (tmp_path / "tool.toml").read_text(encoding="utf-8") == text
|
|
83
|
+
assert not result.stderr
|
|
84
|
+
assert not project_config.is_set()
|
|
85
|
+
|
|
86
|
+
def test_existing_file_is_preserved(self, init_app: typer.Typer, tmp_path: Path) -> None:
|
|
87
|
+
path = tmp_path / "tool.toml"
|
|
88
|
+
path.write_text("count = 9", encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
result = CliRunner().invoke(init_app, ["-p", "automation", "init"])
|
|
91
|
+
|
|
92
|
+
assert result.exit_code == 2
|
|
93
|
+
assert json.loads(result.stdout)["code"] == "config_already_exists"
|
|
94
|
+
assert path.read_text(encoding="utf-8") == "count = 9"
|
|
95
|
+
assert not result.stderr
|
|
96
|
+
assert not project_config.is_set()
|
|
97
|
+
|
|
98
|
+
def test_unexpected_failure_propagates(self, init_app: typer.Typer, mocker: MockerFixture) -> None:
|
|
99
|
+
failure = RuntimeError("Unexpected creation failure")
|
|
100
|
+
mocker.patch("llm_tool_cli.config.project.initialize_config", side_effect=failure)
|
|
101
|
+
|
|
102
|
+
result = CliRunner().invoke(init_app, ["init"])
|
|
103
|
+
|
|
104
|
+
assert result.exception == failure
|
|
105
|
+
assert not result.stdout
|
|
106
|
+
assert not result.stderr
|
|
107
|
+
assert not project_config.is_set()
|
|
108
|
+
assert not runtime_config.is_set()
|
|
109
|
+
assert not output.is_set()
|
|
110
|
+
|
|
111
|
+
def test_registration_and_help_do_not_prepare_project(self, mocker: MockerFixture) -> None:
|
|
112
|
+
prepare = mocker.patch("llm_tool_cli.cli.commands.initialization.initialize_project", side_effect=RuntimeError)
|
|
113
|
+
app = create_app(help="Initialize a project.")
|
|
114
|
+
register_init_command(
|
|
115
|
+
app,
|
|
116
|
+
config_class=ApplicationConfig,
|
|
117
|
+
package="unavailable_resources",
|
|
118
|
+
template="missing.toml",
|
|
119
|
+
help="Create the application configuration.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = CliRunner().invoke(app, ["init", "-h"])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0
|
|
125
|
+
assert "Create the application configuration." in result.stdout
|
|
126
|
+
assert not result.stderr
|
|
127
|
+
assert not project_config.is_set()
|
|
128
|
+
assert not runtime_config.is_set()
|
|
129
|
+
assert not output.is_set()
|
|
130
|
+
prepare.assert_not_called()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import typer
|
|
9
|
+
from pytest_mock import MockerFixture
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
from llm_tool_cli.cli.application import create_app
|
|
13
|
+
from llm_tool_cli.cli.commands.skills import register_skill_command
|
|
14
|
+
from llm_tool_cli.core.settings import ToolLabel, initialize
|
|
15
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
16
|
+
|
|
17
|
+
__all__ = ["isolated_settings"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Document(StrEnum):
|
|
21
|
+
overview = "usage"
|
|
22
|
+
custom = "custom-guide"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def skill_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_settings: None) -> typer.Typer:
|
|
27
|
+
initialize(label=ToolLabel("TEST"))
|
|
28
|
+
initializer = tmp_path / "__init__.py"
|
|
29
|
+
initializer.write_text("", encoding="utf-8")
|
|
30
|
+
package = "cli_skill_test_package"
|
|
31
|
+
spec = importlib.util.spec_from_file_location(package, initializer)
|
|
32
|
+
assert spec is not None
|
|
33
|
+
monkeypatch.setitem(sys.modules, package, importlib.util.module_from_spec(spec))
|
|
34
|
+
(tmp_path / "fixtures").mkdir()
|
|
35
|
+
for document in Document:
|
|
36
|
+
(tmp_path / "fixtures" / f"{document.value}.md").write_text(
|
|
37
|
+
f" # {document.value}\nCafé 日本語\n\n", encoding="utf-8"
|
|
38
|
+
)
|
|
39
|
+
app = create_app(help="Read test documentation.")
|
|
40
|
+
|
|
41
|
+
register_skill_command(app, package=package, documents=Document)
|
|
42
|
+
return app
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestRegisterSkillCommand:
|
|
46
|
+
@pytest.mark.parametrize("document", [None, "usage", "custom-guide"])
|
|
47
|
+
@pytest.mark.parametrize("protocol", [None, "human", "llm", "automation"])
|
|
48
|
+
def test_selected_document_and_protocol( # noqa: CCR001
|
|
49
|
+
self, skill_app: typer.Typer, tmp_path: Path, protocol: str | None, document: str | None
|
|
50
|
+
) -> None:
|
|
51
|
+
arguments = [] if protocol is None else ["-p", protocol]
|
|
52
|
+
arguments.extend(["--config", str(tmp_path / "missing.toml"), "skill"])
|
|
53
|
+
if document is not None:
|
|
54
|
+
arguments.append(document)
|
|
55
|
+
|
|
56
|
+
result = CliRunner().invoke(skill_app, arguments)
|
|
57
|
+
|
|
58
|
+
assert result.exit_code == 0
|
|
59
|
+
assert not result.stderr
|
|
60
|
+
name = document or "usage"
|
|
61
|
+
content = (tmp_path / "fixtures" / f"{name}.md").read_text(encoding="utf-8").strip()
|
|
62
|
+
if protocol == "automation":
|
|
63
|
+
record = json.loads(result.stdout)
|
|
64
|
+
assert record.pop("id")
|
|
65
|
+
assert record == {"type": "skill", "document": name, "content": content}
|
|
66
|
+
else:
|
|
67
|
+
assert result.stdout.startswith("document: " if protocol == "human" else "--TEST-CELL ")
|
|
68
|
+
separator = ": " if protocol == "human" else "="
|
|
69
|
+
assert f"document{separator}{name}\n" in result.stdout
|
|
70
|
+
assert content in result.stdout
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("document", ["missing", "overview", "USAGE", " usage", "../usage", ""])
|
|
73
|
+
def test_unknown_document_fails_before_loading(
|
|
74
|
+
self, skill_app: typer.Typer, mocker: MockerFixture, document: str
|
|
75
|
+
) -> None:
|
|
76
|
+
loader = mocker.patch("llm_tool_cli.cli.commands.skills.load_skill_text")
|
|
77
|
+
|
|
78
|
+
result = CliRunner().invoke(skill_app, ["skill", document])
|
|
79
|
+
|
|
80
|
+
assert result.exit_code == 2
|
|
81
|
+
assert not result.stdout
|
|
82
|
+
assert "Invalid value" in result.stderr
|
|
83
|
+
assert "usage" in result.stderr
|
|
84
|
+
assert "custom-guide" in result.stderr
|
|
85
|
+
loader.assert_not_called()
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("protocol", ["human", "llm", "automation"])
|
|
88
|
+
@pytest.mark.parametrize("content", [None, b"\xff"])
|
|
89
|
+
def test_unreadable_document(
|
|
90
|
+
self, skill_app: typer.Typer, tmp_path: Path, protocol: str, content: bytes | None
|
|
91
|
+
) -> None:
|
|
92
|
+
path = tmp_path / "fixtures" / "usage.md"
|
|
93
|
+
if content is None:
|
|
94
|
+
path.unlink()
|
|
95
|
+
else:
|
|
96
|
+
path.write_bytes(content)
|
|
97
|
+
|
|
98
|
+
result = CliRunner().invoke(skill_app, ["-p", protocol, "skill"])
|
|
99
|
+
|
|
100
|
+
assert result.exit_code == 3
|
|
101
|
+
if protocol == "automation":
|
|
102
|
+
assert not result.stderr
|
|
103
|
+
record = json.loads(result.stdout)
|
|
104
|
+
assert record["type"] == "error"
|
|
105
|
+
assert record["code"] == "skill_unreadable"
|
|
106
|
+
assert record["document"] == "usage"
|
|
107
|
+
assert record["reason"]
|
|
108
|
+
assert record["content"]
|
|
109
|
+
else:
|
|
110
|
+
assert not result.stderr
|
|
111
|
+
separator = ": " if protocol == "human" else "="
|
|
112
|
+
assert f"code{separator}skill_unreadable\n" in result.stdout
|
|
113
|
+
assert f"document{separator}usage\n" in result.stdout
|
|
114
|
+
|
|
115
|
+
def test_empty_document(self, skill_app: typer.Typer, tmp_path: Path) -> None:
|
|
116
|
+
(tmp_path / "fixtures" / "usage.md").write_text("", encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
result = CliRunner().invoke(skill_app, ["-p", "automation", "skill"])
|
|
119
|
+
|
|
120
|
+
assert result.exit_code == 0
|
|
121
|
+
assert not result.stderr
|
|
122
|
+
assert json.loads(result.stdout)["content"] is None
|
|
123
|
+
|
|
124
|
+
def test_unexpected_failure_propagates(self, skill_app: typer.Typer, mocker: MockerFixture) -> None:
|
|
125
|
+
mocker.patch("llm_tool_cli.cli.commands.skills.load_skill_text", side_effect=RuntimeError("broken loader"))
|
|
126
|
+
|
|
127
|
+
result = CliRunner().invoke(skill_app, ["skill"])
|
|
128
|
+
|
|
129
|
+
assert result.exit_code != 0
|
|
130
|
+
assert isinstance(result.exception, RuntimeError)
|
|
131
|
+
assert not result.stdout
|
|
132
|
+
assert not result.stderr
|
|
133
|
+
|
|
134
|
+
def test_help_uses_document_values(self, skill_app: typer.Typer) -> None:
|
|
135
|
+
result = CliRunner().invoke(skill_app, ["skill", "-h"])
|
|
136
|
+
|
|
137
|
+
assert result.exit_code == 0
|
|
138
|
+
assert "Print built-in skill documentation." in result.stdout
|
|
139
|
+
assert "usage" in result.stdout
|
|
140
|
+
assert "custom-guide" in result.stdout
|
|
141
|
+
assert "overview" not in result.stdout
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize("prefix, expected", [("", ["usage", "custom-guide"]), ("c", ["custom-guide"])])
|
|
144
|
+
def test_completion_uses_document_values(self, skill_app: typer.Typer, prefix: str, expected: list[str]) -> None:
|
|
145
|
+
result = CliRunner().invoke(
|
|
146
|
+
skill_app,
|
|
147
|
+
[],
|
|
148
|
+
prog_name="sample",
|
|
149
|
+
env={"_SAMPLE_COMPLETE": "complete_bash", "COMP_WORDS": f"sample skill {prefix}", "COMP_CWORD": "2"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
assert result.exit_code == 0
|
|
153
|
+
assert not result.stderr
|
|
154
|
+
assert result.stdout.splitlines() == expected
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import typer
|
|
8
|
+
from pytest_mock import MockerFixture
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from llm_tool_cli.cli.application import create_app
|
|
12
|
+
from llm_tool_cli.cli.commands.version import register_version_command
|
|
13
|
+
from llm_tool_cli.core.settings import ToolLabel, initialize
|
|
14
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
15
|
+
|
|
16
|
+
__all__ = ["isolated_settings"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def version_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_settings: None) -> typer.Typer:
|
|
21
|
+
initialize(label=ToolLabel("TEST"))
|
|
22
|
+
distribution = tmp_path / "sample_distribution-9.8.7.dist-info"
|
|
23
|
+
distribution.mkdir()
|
|
24
|
+
(distribution / "METADATA").write_text(
|
|
25
|
+
"Metadata-Version: 2.1\nName: sample-distribution\nVersion: 9.8.7\n", encoding="utf-8"
|
|
26
|
+
)
|
|
27
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
28
|
+
monkeypatch.chdir(tmp_path)
|
|
29
|
+
app = create_app(help="Inspect the test package version.")
|
|
30
|
+
|
|
31
|
+
register_version_command(app, distribution="sample-distribution")
|
|
32
|
+
return app
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestRegisterVersionCommand:
|
|
36
|
+
@pytest.mark.parametrize("protocol", [None, "human", "llm", "automation"])
|
|
37
|
+
def test_installed_version_and_protocol(self, version_app: typer.Typer, protocol: str | None) -> None:
|
|
38
|
+
options = [] if protocol is None else ["-p", protocol]
|
|
39
|
+
|
|
40
|
+
result = CliRunner().invoke(version_app, [*options, "version"])
|
|
41
|
+
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert not result.stderr
|
|
44
|
+
if protocol == "automation":
|
|
45
|
+
record = json.loads(result.stdout)
|
|
46
|
+
assert record.pop("id")
|
|
47
|
+
assert record == {"type": "version", "version": "9.8.7", "content": None}
|
|
48
|
+
else:
|
|
49
|
+
output = re.sub(r"[A-Za-z0-9_-]{22}", "<id>", result.stdout)
|
|
50
|
+
if protocol == "llm":
|
|
51
|
+
assert output == (
|
|
52
|
+
"--TEST-CELL <id> BEGIN--\nkind=version\ntype=version\nversion=9.8.7\n" "--TEST-CELL <id> END--\n"
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
assert output == "version: 9.8.7\n\n"
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("config_kind", ["missing", "invalid", "directory"])
|
|
58
|
+
def test_ignores_configuration(self, version_app: typer.Typer, tmp_path: Path, config_kind: str) -> None:
|
|
59
|
+
config = tmp_path / "config.toml"
|
|
60
|
+
if config_kind == "invalid":
|
|
61
|
+
config.write_text("not valid TOML", encoding="utf-8")
|
|
62
|
+
elif config_kind == "directory":
|
|
63
|
+
config.mkdir()
|
|
64
|
+
|
|
65
|
+
result = CliRunner().invoke(version_app, ["--config", str(config), "-p", "automation", "version"])
|
|
66
|
+
|
|
67
|
+
assert result.exit_code == 0
|
|
68
|
+
assert not result.stderr
|
|
69
|
+
assert json.loads(result.stdout)["version"] == "9.8.7"
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize(
|
|
72
|
+
"failure", [metadata.PackageNotFoundError("sample-distribution"), RuntimeError("lookup failed")]
|
|
73
|
+
)
|
|
74
|
+
def test_metadata_failure_propagates(
|
|
75
|
+
self, version_app: typer.Typer, mocker: MockerFixture, failure: Exception
|
|
76
|
+
) -> None:
|
|
77
|
+
mocker.patch("llm_tool_cli.cli.commands.version.metadata.version", side_effect=failure)
|
|
78
|
+
|
|
79
|
+
result = CliRunner().invoke(version_app, ["-p", "automation", "version"])
|
|
80
|
+
|
|
81
|
+
assert result.exit_code != 0
|
|
82
|
+
assert isinstance(result.exception, type(failure))
|
|
83
|
+
assert not result.stdout
|
|
84
|
+
assert not result.stderr
|
|
85
|
+
|
|
86
|
+
def test_registration_and_help_do_not_lookup_metadata(
|
|
87
|
+
self, version_app: typer.Typer, mocker: MockerFixture
|
|
88
|
+
) -> None:
|
|
89
|
+
lookup = mocker.patch("llm_tool_cli.cli.commands.version.metadata.version", side_effect=RuntimeError)
|
|
90
|
+
app = create_app(help="An uninstalled package.")
|
|
91
|
+
register_version_command(app, distribution="uninstalled-distribution")
|
|
92
|
+
|
|
93
|
+
result = CliRunner().invoke(version_app, ["version", "-h"])
|
|
94
|
+
|
|
95
|
+
assert result.exit_code == 0
|
|
96
|
+
assert not result.stderr
|
|
97
|
+
assert "Print the installed package version." in result.stdout
|
|
98
|
+
lookup.assert_not_called()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from importlib import metadata
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.cli.context import command_context
|
|
6
|
+
from llm_tool_cli.output import output
|
|
7
|
+
from llm_tool_cli.protocol import cell_shortcuts
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_version_command(app: typer.Typer, *, distribution: str) -> None:
|
|
11
|
+
"""Register version output for the supplied installed distribution."""
|
|
12
|
+
|
|
13
|
+
@app.command("version", help="Print the installed package version.")
|
|
14
|
+
def version(context: typer.Context) -> None:
|
|
15
|
+
with command_context(context):
|
|
16
|
+
value = metadata.version(distribution)
|
|
17
|
+
output.get().emit([cell_shortcuts.version(value)]).unwrap()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.cli.handling import handle_command_errors
|
|
7
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
8
|
+
from llm_tool_cli.output import StreamOutputEmitter, output
|
|
9
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
10
|
+
from llm_tool_cli.protocol import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandContext:
|
|
14
|
+
__slots__ = ("_context",)
|
|
15
|
+
|
|
16
|
+
def __init__(self, context: typer.Context) -> None:
|
|
17
|
+
self._context = context
|
|
18
|
+
protocol = context.find_root().params.get("protocol")
|
|
19
|
+
if protocol is None:
|
|
20
|
+
protocol = Protocol.llm if context.info_name == "skill" else Protocol.human
|
|
21
|
+
context.with_resource(runtime_config.scope(RuntimeConfig(protocol=protocol)))
|
|
22
|
+
context.with_resource(output.scope(StreamOutputEmitter()))
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def config_input(self) -> ProjectConfigPath | None:
|
|
26
|
+
"""Unresolved CLI input for configuration loading or creation."""
|
|
27
|
+
return self._context.find_root().params.get("config_path")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def protocol(self) -> Protocol:
|
|
31
|
+
return runtime_config.get().protocol
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def command_context(context: typer.Context) -> Iterator[CommandContext]:
|
|
36
|
+
"""Set up command execution and finalize output before framework cleanup."""
|
|
37
|
+
command = CommandContext(context)
|
|
38
|
+
with handle_command_errors(protocol=command.protocol):
|
|
39
|
+
yield command
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.core import errors as core_errors
|
|
4
|
+
from llm_tool_cli.core.entities import ExitCode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EnvironmentError(core_errors.EnvironmentError):
|
|
8
|
+
"""An expected command-line input failure."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidArguments(EnvironmentError):
|
|
12
|
+
cli_exit_code: ClassVar[ExitCode] = ExitCode.invalid_arguments
|
|
13
|
+
code: str = "invalid_arguments"
|
|
14
|
+
message: str = "{error.reason}"
|
|
15
|
+
reason: str
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
8
|
+
from llm_tool_cli.core.errors import EnvironmentErrors, exit_code_for_errors
|
|
9
|
+
from llm_tool_cli.core.result import UnwrapError
|
|
10
|
+
from llm_tool_cli.output import OutputEmitter, StreamOutputEmitter, output
|
|
11
|
+
from llm_tool_cli.protocol import Protocol
|
|
12
|
+
from llm_tool_cli.protocol.cell_shortcuts import environment_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def report_errors_and_exit(
|
|
16
|
+
errors: EnvironmentErrors, *, protocol: Protocol, emitter: OutputEmitter | None = None
|
|
17
|
+
) -> NoReturn:
|
|
18
|
+
"""Deliver ordered diagnostics through the emitter and exit."""
|
|
19
|
+
# Parsing can fail before command setup has established runtime/output scopes.
|
|
20
|
+
emitter = emitter if emitter is not None else StreamOutputEmitter()
|
|
21
|
+
with runtime_config.scope(RuntimeConfig(protocol=protocol)):
|
|
22
|
+
emitter.discard()
|
|
23
|
+
emitter.emit([environment_error(error) for error in errors]).unwrap()
|
|
24
|
+
emitter.flush()
|
|
25
|
+
raise typer.Exit(exit_code_for_errors(errors))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def handle_command_errors(*, protocol: Protocol) -> Iterator[None]:
|
|
30
|
+
"""Report failed result unwrapping while leaving unrelated exceptions alone."""
|
|
31
|
+
try:
|
|
32
|
+
yield
|
|
33
|
+
except UnwrapError as error:
|
|
34
|
+
report_errors_and_exit(error.errors, protocol=protocol, emitter=output.get() if output.is_set() else None)
|
|
35
|
+
except BaseException:
|
|
36
|
+
if output.is_set():
|
|
37
|
+
output.get().discard()
|
|
38
|
+
raise
|
|
39
|
+
else:
|
|
40
|
+
if output.is_set():
|
|
41
|
+
output.get().flush()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.cli.errors import InvalidArguments
|
|
7
|
+
from llm_tool_cli.cli.handling import report_errors_and_exit
|
|
8
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
9
|
+
from llm_tool_cli.protocol import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_config_path(value: str) -> ProjectConfigPath:
|
|
13
|
+
return ProjectConfigPath(Path(value))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ConfigOption = Annotated[
|
|
17
|
+
ProjectConfigPath | None,
|
|
18
|
+
typer.Option(
|
|
19
|
+
"--config",
|
|
20
|
+
parser=_parse_config_path,
|
|
21
|
+
metavar="PATH",
|
|
22
|
+
help="Configuration file path. Overrides the command's default configuration location.",
|
|
23
|
+
),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_protocol(value: str) -> Protocol:
|
|
28
|
+
try:
|
|
29
|
+
return Protocol(value)
|
|
30
|
+
except ValueError:
|
|
31
|
+
choices = ", ".join(protocol.value for protocol in Protocol)
|
|
32
|
+
failure = InvalidArguments(reason=f"invalid protocol `{value}`; expected one of: {choices}")
|
|
33
|
+
report_errors_and_exit([failure], protocol=Protocol.llm)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ProtocolOption = Annotated[
|
|
37
|
+
Protocol | None,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--protocol",
|
|
40
|
+
"-p",
|
|
41
|
+
parser=_parse_protocol,
|
|
42
|
+
metavar="PROTOCOL",
|
|
43
|
+
help=(
|
|
44
|
+
f"Output protocol ({', '.join(protocol.value for protocol in Protocol)}). "
|
|
45
|
+
"Defaults to llm for skill and human for other commands."
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
]
|
|
File without changes
|