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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from contextvars import Context
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core import settings
|
|
6
|
+
from llm_tool_cli.core.errors import ContextValueAlreadySet, ContextValueNotSet, ToolLabelAlreadyInitialized
|
|
7
|
+
from llm_tool_cli.core.settings import ConfigFilename, ToolLabel, initialize
|
|
8
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
9
|
+
|
|
10
|
+
__all__ = ["isolated_settings"]
|
|
11
|
+
|
|
12
|
+
pytestmark = pytest.mark.usefixtures("isolated_settings")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestInitialize:
|
|
16
|
+
@pytest.mark.parametrize("label", ["TOOL", "", " café 日本語 "])
|
|
17
|
+
def test_installs_exact_label(self, label: str) -> None:
|
|
18
|
+
initialize(label=ToolLabel(label))
|
|
19
|
+
|
|
20
|
+
assert settings.static_config.get().tool_label == label
|
|
21
|
+
|
|
22
|
+
def test_same_label_is_idempotent(self) -> None:
|
|
23
|
+
initialize(label=ToolLabel("TOOL"))
|
|
24
|
+
initialize(label=ToolLabel("TOOL"))
|
|
25
|
+
|
|
26
|
+
assert settings.static_config.get().tool_label == "TOOL"
|
|
27
|
+
|
|
28
|
+
def test_same_application_settings_are_idempotent(self) -> None:
|
|
29
|
+
initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
|
|
30
|
+
initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
|
|
31
|
+
|
|
32
|
+
assert settings.static_config.get().config_filename == "tool.toml"
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("filename", [None, ConfigFilename("other.toml")])
|
|
35
|
+
def test_conflicting_filename_preserves_original(self, filename: ConfigFilename | None) -> None:
|
|
36
|
+
initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
|
|
37
|
+
|
|
38
|
+
with pytest.raises(ContextValueAlreadySet) as caught:
|
|
39
|
+
initialize(label=ToolLabel("TOOL"), config_filename=filename)
|
|
40
|
+
|
|
41
|
+
assert caught.value.details == {"name": "static_config"}
|
|
42
|
+
assert settings.static_config.get().config_filename == "tool.toml"
|
|
43
|
+
|
|
44
|
+
def test_conflicting_label_preserves_original(self) -> None:
|
|
45
|
+
initialize(label=ToolLabel(""))
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ToolLabelAlreadyInitialized) as caught:
|
|
48
|
+
initialize(label=ToolLabel("OTHER"))
|
|
49
|
+
|
|
50
|
+
assert caught.value.details == {"current": "", "requested": "OTHER"}
|
|
51
|
+
assert settings.static_config.get().tool_label == ""
|
|
52
|
+
|
|
53
|
+
def test_initialization_uses_current_execution_context(self) -> None:
|
|
54
|
+
initialize(label=ToolLabel("OUTER"))
|
|
55
|
+
execution = Context()
|
|
56
|
+
|
|
57
|
+
with pytest.raises(ContextValueNotSet) as caught:
|
|
58
|
+
execution.run(settings.static_config.get)
|
|
59
|
+
|
|
60
|
+
assert caught.value.details == {"name": "static_config"}
|
|
61
|
+
execution.run(initialize, label=ToolLabel("INNER"))
|
|
62
|
+
|
|
63
|
+
assert execution.run(settings.static_config.get).tool_label == "INNER"
|
|
64
|
+
assert settings.static_config.get().tool_label == "OUTER"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from types import EllipsisType
|
|
3
|
+
from typing import Protocol, TextIO
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.config.runtime import runtime_config
|
|
6
|
+
from llm_tool_cli.core import settings
|
|
7
|
+
from llm_tool_cli.core.context import ContextValue
|
|
8
|
+
from llm_tool_cli.core.result import Ok, Result, unwrap_to_error
|
|
9
|
+
from llm_tool_cli.output.journal import JournalProcessor, NoopJournalProcessor
|
|
10
|
+
from llm_tool_cli.protocol import Protocol as OutputProtocol
|
|
11
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
12
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
13
|
+
from llm_tool_cli.protocol.output_cells.base import RenderContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JournalCellFactory(Protocol):
|
|
17
|
+
__slots__ = ()
|
|
18
|
+
|
|
19
|
+
def __call__(self, *, message: str, actor_id: str | None) -> JournalCell: ... # noqa: E704
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputEmitter(Protocol):
|
|
23
|
+
__slots__ = ()
|
|
24
|
+
|
|
25
|
+
def emit(self, cells: list[LogicCell]) -> Result[None]: ... # noqa: E704
|
|
26
|
+
|
|
27
|
+
def journal(self, message: str, *, actor_id: str | None | EllipsisType = ...) -> Result[None]: ... # noqa: E704
|
|
28
|
+
|
|
29
|
+
def flush(self) -> None: ... # noqa: E704
|
|
30
|
+
|
|
31
|
+
def discard(self) -> None:
|
|
32
|
+
"""Discard pending ordinary results, retaining warnings for failure reporting."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
output = ContextValue[OutputEmitter]("output")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StreamOutputEmitter:
|
|
40
|
+
"""Deliver journals now, then buffered results and warnings at finalization."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("_cells", "_warnings", "_stream", "_journal_failed", "journal_processor", "journal_factory")
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
stream: TextIO | None = None,
|
|
48
|
+
journal_processor: JournalProcessor | None = None,
|
|
49
|
+
journal_factory: JournalCellFactory = JournalCell,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._stream = stream
|
|
52
|
+
self.journal_processor = journal_processor if journal_processor is not None else NoopJournalProcessor()
|
|
53
|
+
self.journal_factory = journal_factory
|
|
54
|
+
# A processor failure disables forwarding for this emitter's lifetime.
|
|
55
|
+
self._journal_failed = False
|
|
56
|
+
self._cells: list[LogicCell] = []
|
|
57
|
+
self._warnings: list[LogicCell] = []
|
|
58
|
+
|
|
59
|
+
def journal(self, message: str, *, actor_id: str | None | EllipsisType = ...) -> Result[None]:
|
|
60
|
+
if actor_id is ...:
|
|
61
|
+
protocol = runtime_config.get().protocol
|
|
62
|
+
actor_id = "agent" if protocol == OutputProtocol.llm else protocol.value
|
|
63
|
+
return self.emit([self.journal_factory(message=message, actor_id=actor_id)])
|
|
64
|
+
|
|
65
|
+
@unwrap_to_error
|
|
66
|
+
def emit(self, cells: list[LogicCell]) -> Result[None]: # noqa: CCR001
|
|
67
|
+
for cell in cells:
|
|
68
|
+
if cell.is_journal and not self._journal_failed:
|
|
69
|
+
processed = self.journal_processor.process(cell.to_journal_record())
|
|
70
|
+
if processed.is_err():
|
|
71
|
+
self._journal_failed = True
|
|
72
|
+
return processed
|
|
73
|
+
if cell.is_warning:
|
|
74
|
+
self._warnings.append(cell)
|
|
75
|
+
elif cell.is_journal:
|
|
76
|
+
self._write([cell])
|
|
77
|
+
else:
|
|
78
|
+
self._cells.append(cell)
|
|
79
|
+
return Ok(None)
|
|
80
|
+
|
|
81
|
+
def flush(self) -> None:
|
|
82
|
+
cells = [*self._cells, *self._warnings]
|
|
83
|
+
self._cells.clear()
|
|
84
|
+
self._warnings.clear()
|
|
85
|
+
self._write(cells)
|
|
86
|
+
|
|
87
|
+
def discard(self) -> None:
|
|
88
|
+
self._cells.clear()
|
|
89
|
+
|
|
90
|
+
def _write(self, cells: list[LogicCell]) -> None:
|
|
91
|
+
if not cells:
|
|
92
|
+
return
|
|
93
|
+
# Render the complete batch before writing so rendering failures cannot
|
|
94
|
+
# expose partial output. A failed stream write is not retried.
|
|
95
|
+
text = self._render(cells)
|
|
96
|
+
stream = self._stream if self._stream is not None else sys.stdout
|
|
97
|
+
stream.write(text)
|
|
98
|
+
stream.flush()
|
|
99
|
+
|
|
100
|
+
def _render(self, cells: list[LogicCell]) -> str:
|
|
101
|
+
"""Prepare complete batch text; subclasses may override without handling streams."""
|
|
102
|
+
protocol = runtime_config.get().protocol
|
|
103
|
+
tool_label = settings.static_config.get().tool_label
|
|
104
|
+
sequence = [output_cell for cell in cells for output_cell in cell.render(protocol)]
|
|
105
|
+
return b"".join(
|
|
106
|
+
cell.render(RenderContext(index=index, total=len(sequence), tool_label=tool_label))
|
|
107
|
+
for index, cell in enumerate(sequence)
|
|
108
|
+
).decode("utf-8")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from llm_tool_cli.core.errors import EnvironmentError as BaseEnvironmentError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EnvironmentError(BaseEnvironmentError):
|
|
5
|
+
"""Expected failures in output delivery."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JournalCommandConfigInvalid(EnvironmentError):
|
|
9
|
+
code: str = "journal_command_config_invalid"
|
|
10
|
+
message: str = "Journal command config is invalid: {error.details}"
|
|
11
|
+
argument: str
|
|
12
|
+
details: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JournalCommandFailed(EnvironmentError):
|
|
16
|
+
code: str = "journal_command_failed"
|
|
17
|
+
message: str = "Journal command failed: {error.details}"
|
|
18
|
+
ways_to_fix: list[str] = ["Check the configured journal command and its arguments."]
|
|
19
|
+
command: list[str]
|
|
20
|
+
returncode: int | None
|
|
21
|
+
details: str
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import subprocess # noqa: S404
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
7
|
+
from llm_tool_cli.core.result import Err, Ok, Result
|
|
8
|
+
from llm_tool_cli.output.errors import JournalCommandConfigInvalid, JournalCommandFailed
|
|
9
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JournalProcessor(Protocol):
|
|
13
|
+
__slots__ = ()
|
|
14
|
+
|
|
15
|
+
def process(self, record: JournalRecord) -> Result[None]: ... # noqa: E704
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoopJournalProcessor:
|
|
19
|
+
__slots__ = ()
|
|
20
|
+
|
|
21
|
+
def process(self, record: JournalRecord) -> Result[None]:
|
|
22
|
+
return Ok(None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandJournalProcessor:
|
|
26
|
+
"""Forward record fields as whole arguments, without a shell or text flattening."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("command",)
|
|
29
|
+
|
|
30
|
+
def __init__(self, command: list[str]) -> None:
|
|
31
|
+
self.command = tuple(command)
|
|
32
|
+
|
|
33
|
+
def process(self, record: JournalRecord) -> Result[None]: # noqa: CCR001
|
|
34
|
+
if not self.command:
|
|
35
|
+
return Err([JournalCommandConfigInvalid(argument="", details="Configured command is empty.")])
|
|
36
|
+
|
|
37
|
+
fields = record.as_dict()
|
|
38
|
+
args = []
|
|
39
|
+
for argument in self.command:
|
|
40
|
+
if len(argument) >= 2 and argument.startswith("{") and argument.endswith("}"):
|
|
41
|
+
value = fields.get(argument[1:-1])
|
|
42
|
+
argument = "" if value is None else str(value)
|
|
43
|
+
args.append(argument)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(args, check=False, capture_output=True, text=True) # noqa: S603
|
|
47
|
+
except OSError as error:
|
|
48
|
+
return Err([JournalCommandFailed(command=args, returncode=None, details=str(error))])
|
|
49
|
+
|
|
50
|
+
if result.returncode:
|
|
51
|
+
details = f"exit code {result.returncode}"
|
|
52
|
+
if result.stderr.strip():
|
|
53
|
+
details += f"; stderr: {result.stderr.strip()}"
|
|
54
|
+
return Err([JournalCommandFailed(command=args, returncode=result.returncode, details=details)])
|
|
55
|
+
return Ok(None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JournalConfig(BaseEntity):
|
|
59
|
+
"""Optional external command forwarding."""
|
|
60
|
+
|
|
61
|
+
cmd: list[str] | None = pydantic.Field(default=None, min_length=1)
|
|
62
|
+
|
|
63
|
+
def processor(self) -> JournalProcessor:
|
|
64
|
+
if self.cmd is None:
|
|
65
|
+
return NoopJournalProcessor()
|
|
66
|
+
return CommandJournalProcessor(self.cmd)
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from llm_tool_cli.core.result import Ok, Result
|
|
2
|
+
from llm_tool_cli.output import JournalCellFactory, StreamOutputEmitter, output
|
|
3
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
4
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
5
|
+
from llm_tool_cli.protocol.logic_cells.warning import WarningCell
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CollectingEmitter(StreamOutputEmitter):
|
|
9
|
+
"""Capture submitted logical cells without rendering or external side effects."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *, journal_factory: JournalCellFactory = JournalCell) -> None:
|
|
12
|
+
super().__init__(journal_factory=journal_factory)
|
|
13
|
+
self.cells: list[LogicCell] = []
|
|
14
|
+
|
|
15
|
+
def emit(self, cells: list[LogicCell]) -> Result[None]:
|
|
16
|
+
self.cells.extend(cells)
|
|
17
|
+
return Ok(None)
|
|
18
|
+
|
|
19
|
+
def flush(self) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def discard(self) -> None:
|
|
23
|
+
self.cells = [cell for cell in self.cells if cell.is_warning]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def warning_messages() -> list[str]:
|
|
27
|
+
emitter = output.get()
|
|
28
|
+
assert isinstance(emitter, CollectingEmitter)
|
|
29
|
+
return [cell.message for cell in emitter.cells if isinstance(cell, WarningCell)]
|