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.
Files changed (111) hide show
  1. llm_tool_cli/__init__.py +0 -0
  2. llm_tool_cli/cli/__init__.py +1 -0
  3. llm_tool_cli/cli/application.py +31 -0
  4. llm_tool_cli/cli/commands/__init__.py +0 -0
  5. llm_tool_cli/cli/commands/initialization.py +36 -0
  6. llm_tool_cli/cli/commands/skills.py +25 -0
  7. llm_tool_cli/cli/commands/tests/__init__.py +0 -0
  8. llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
  9. llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
  10. llm_tool_cli/cli/commands/tests/test_version.py +98 -0
  11. llm_tool_cli/cli/commands/version.py +17 -0
  12. llm_tool_cli/cli/context.py +39 -0
  13. llm_tool_cli/cli/entities.py +3 -0
  14. llm_tool_cli/cli/errors.py +15 -0
  15. llm_tool_cli/cli/handling.py +41 -0
  16. llm_tool_cli/cli/options.py +48 -0
  17. llm_tool_cli/cli/tests/__init__.py +0 -0
  18. llm_tool_cli/cli/tests/test_application.py +167 -0
  19. llm_tool_cli/cli/tests/test_context.py +179 -0
  20. llm_tool_cli/cli/tests/test_handling.py +230 -0
  21. llm_tool_cli/cli/tests/test_options.py +125 -0
  22. llm_tool_cli/config/__init__.py +33 -0
  23. llm_tool_cli/config/errors.py +92 -0
  24. llm_tool_cli/config/fields.py +39 -0
  25. llm_tool_cli/config/files.py +175 -0
  26. llm_tool_cli/config/project.py +87 -0
  27. llm_tool_cli/config/runtime.py +14 -0
  28. llm_tool_cli/config/tests/__init__.py +0 -0
  29. llm_tool_cli/config/tests/fixtures.py +34 -0
  30. llm_tool_cli/config/tests/test_fields.py +124 -0
  31. llm_tool_cli/config/tests/test_files.py +826 -0
  32. llm_tool_cli/config/tests/test_project.py +257 -0
  33. llm_tool_cli/core/__init__.py +1 -0
  34. llm_tool_cli/core/context.py +50 -0
  35. llm_tool_cli/core/entities.py +35 -0
  36. llm_tool_cli/core/errors.py +97 -0
  37. llm_tool_cli/core/result.py +118 -0
  38. llm_tool_cli/core/settings.py +37 -0
  39. llm_tool_cli/core/tests/__init__.py +0 -0
  40. llm_tool_cli/core/tests/fixtures.py +13 -0
  41. llm_tool_cli/core/tests/test_context.py +152 -0
  42. llm_tool_cli/core/tests/test_entities.py +65 -0
  43. llm_tool_cli/core/tests/test_errors.py +211 -0
  44. llm_tool_cli/core/tests/test_result.py +256 -0
  45. llm_tool_cli/core/tests/test_settings.py +64 -0
  46. llm_tool_cli/output/__init__.py +3 -0
  47. llm_tool_cli/output/emitter.py +108 -0
  48. llm_tool_cli/output/errors.py +21 -0
  49. llm_tool_cli/output/journal.py +66 -0
  50. llm_tool_cli/output/tests/__init__.py +0 -0
  51. llm_tool_cli/output/tests/helpers.py +29 -0
  52. llm_tool_cli/output/tests/test_emitter.py +406 -0
  53. llm_tool_cli/output/tests/test_journal.py +141 -0
  54. llm_tool_cli/paths/__init__.py +51 -0
  55. llm_tool_cli/paths/enumeration.py +72 -0
  56. llm_tool_cli/paths/errors.py +42 -0
  57. llm_tool_cli/paths/filesystem.py +164 -0
  58. llm_tool_cli/paths/globs.py +124 -0
  59. llm_tool_cli/paths/normalization.py +58 -0
  60. llm_tool_cli/paths/tests/__init__.py +0 -0
  61. llm_tool_cli/paths/tests/test_enumeration.py +229 -0
  62. llm_tool_cli/paths/tests/test_filesystem.py +778 -0
  63. llm_tool_cli/paths/tests/test_globs.py +137 -0
  64. llm_tool_cli/paths/tests/test_normalization.py +163 -0
  65. llm_tool_cli/protocol/__init__.py +4 -0
  66. llm_tool_cli/protocol/cell_shortcuts.py +39 -0
  67. llm_tool_cli/protocol/entities.py +7 -0
  68. llm_tool_cli/protocol/errors.py +21 -0
  69. llm_tool_cli/protocol/journal.py +21 -0
  70. llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
  71. llm_tool_cli/protocol/logic_cells/base.py +41 -0
  72. llm_tool_cli/protocol/logic_cells/content.py +26 -0
  73. llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
  74. llm_tool_cli/protocol/logic_cells/journal.py +44 -0
  75. llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
  76. llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
  77. llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
  78. llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
  79. llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
  80. llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
  81. llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
  82. llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
  83. llm_tool_cli/protocol/logic_cells/warning.py +22 -0
  84. llm_tool_cli/protocol/output_cells/__init__.py +11 -0
  85. llm_tool_cli/protocol/output_cells/automation.py +14 -0
  86. llm_tool_cli/protocol/output_cells/base.py +65 -0
  87. llm_tool_cli/protocol/output_cells/human.py +24 -0
  88. llm_tool_cli/protocol/output_cells/journal.py +23 -0
  89. llm_tool_cli/protocol/output_cells/llm.py +25 -0
  90. llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
  91. llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
  92. llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
  93. llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
  94. llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
  95. llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
  96. llm_tool_cli/protocol/serialization.py +7 -0
  97. llm_tool_cli/protocol/tests/__init__.py +0 -0
  98. llm_tool_cli/protocol/tests/helpers.py +25 -0
  99. llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
  100. llm_tool_cli/protocol/tests/test_journal.py +44 -0
  101. llm_tool_cli/protocol/tests/test_serialization.py +33 -0
  102. llm_tool_cli/py.typed +0 -0
  103. llm_tool_cli/skills/__init__.py +6 -0
  104. llm_tool_cli/skills/errors.py +12 -0
  105. llm_tool_cli/skills/fixtures.py +14 -0
  106. llm_tool_cli/skills/tests/__init__.py +0 -0
  107. llm_tool_cli/skills/tests/test_fixtures.py +58 -0
  108. llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
  109. llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
  110. llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
  111. llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
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,3 @@
1
+ from llm_tool_cli.core.entities import ExitCode
2
+
3
+ __all__ = ["ExitCode"]
@@ -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