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
@@ -0,0 +1,137 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from pytest_mock import MockerFixture
5
+
6
+ from llm_tool_cli.paths import FileGlobPattern, ProjectPathId, ProjectRootPath, glob_capture_names, match_glob
7
+ from llm_tool_cli.paths.errors import InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
8
+ from llm_tool_cli.paths.globs import _compile_glob, _pattern_parts
9
+
10
+
11
+ class TestPatternParts:
12
+ def test_resolves_literal_prefix_before_normalizing_remaining_parts(self, tmp_path: Path) -> None:
13
+ assert _pattern_parts(FileGlobPattern("nested/../**/*.py"), ProjectRootPath(tmp_path)).unwrap() == (
14
+ "**",
15
+ "*.py",
16
+ )
17
+
18
+ def test_root_metacharacters_are_literal(self, tmp_path: Path) -> None:
19
+ root = tmp_path / "[project]*?"
20
+ root.mkdir()
21
+ (root / "link").symlink_to(root)
22
+
23
+ assert _pattern_parts(FileGlobPattern("link/*.py"), ProjectRootPath(root)).is_err(InvalidProjectPath)
24
+
25
+ def test_resolution_failure_retains_the_cause(self, tmp_path: Path, mocker: MockerFixture) -> None:
26
+ cause = PermissionError("denied")
27
+ mocker.patch.object(Path, "resolve", side_effect=cause)
28
+
29
+ failure = _pattern_parts(FileGlobPattern("src/*.py"), ProjectRootPath(tmp_path)).unwrap_err()[0]
30
+
31
+ assert isinstance(failure, PathResolutionFailed)
32
+ assert failure.path == str(tmp_path / "src/*.py")
33
+ assert failure.cause == cause
34
+
35
+ @pytest.mark.parametrize("pattern", ["", ".", "../*", "@/../*", "@/", "@/a//b"])
36
+ def test_invalid_path_is_not_a_pattern(self, tmp_path: Path, pattern: str) -> None:
37
+ assert _pattern_parts(FileGlobPattern(pattern), ProjectRootPath(tmp_path)).is_err(InvalidProjectPath)
38
+
39
+
40
+ class TestCompileGlob:
41
+ @pytest.mark.parametrize(
42
+ ("pattern", "matching", "nonmatching", "recursive"),
43
+ [
44
+ ("*.py", "a.py", "dir/a.py", False),
45
+ ("a?.py", "a1.py", "a/1.py", False),
46
+ ("[ab].py", "a.py", "c.py", False),
47
+ ("[!a].py", "b.py", "a.py", False),
48
+ ("[a-z].py", "b.py", "B.py", False),
49
+ ("star[*].py", "star*.py", "starfile.py", False),
50
+ ("[[]file].py", "[file].py", "file.py", False),
51
+ ("[]].py", "].py", "a.py", False),
52
+ ("[**].py", "*.py", "dir/a.py", False),
53
+ ("[abc", "[abc", "a", False),
54
+ ("[a/b]", "[a/b]", "a", False),
55
+ ("**/*.py", "a.py", "a.txt", True),
56
+ ("a**.py", "a/deep/file.py", "b.py", True),
57
+ ("**", "line\nbreak.py", "", True),
58
+ ("{variable}", "{variable}", "value", False),
59
+ ("a\\b", "a\\b", "a/b", False),
60
+ ],
61
+ )
62
+ def test_wildcard_semantics(self, pattern: str, matching: str, nonmatching: str, recursive: bool) -> None:
63
+ compiled, actual_recursive = _compile_glob(FileGlobPattern(pattern)).unwrap()
64
+
65
+ assert compiled.fullmatch(matching) is not None
66
+ if nonmatching:
67
+ assert compiled.fullmatch(nonmatching) is None
68
+ assert actual_recursive == recursive
69
+
70
+ @pytest.mark.parametrize("pattern", ["{*bad-name}", "{**}", "{***name}", "{*unclosed", "{*x}/{**x}"])
71
+ def test_invalid_captures_return_diagnostics(self, pattern: str) -> None:
72
+ failure = _compile_glob(FileGlobPattern(pattern)).unwrap_err()[0]
73
+
74
+ assert isinstance(failure, InvalidGlobPattern)
75
+ assert failure.code == "invalid_glob_pattern"
76
+ assert failure.pattern == pattern
77
+ assert failure.reason
78
+
79
+
80
+ class TestGlobCaptureNames:
81
+ def test_extracts_captures_without_interpreting_templates_or_escaped_stars(self) -> None:
82
+ assert glob_capture_names(FileGlobPattern("{base}/{**package}/{*module}.py")).unwrap() == {
83
+ "package",
84
+ "module",
85
+ }
86
+ assert glob_capture_names(FileGlobPattern("{[*]literal}.py")).unwrap() == set()
87
+ assert glob_capture_names(FileGlobPattern("")).unwrap() == set()
88
+
89
+ def test_invalid_capture_names_are_rejected(self) -> None:
90
+ assert glob_capture_names(FileGlobPattern("{*bad-name}")).is_err(InvalidGlobPattern)
91
+
92
+
93
+ class TestMatchGlob:
94
+ @pytest.mark.parametrize(
95
+ ("pattern", "path", "expected"),
96
+ [
97
+ ("src/{**package}/{*module}.py", "@/src/core/api.py", {"package": "core", "module": "api"}),
98
+ ("src/{**package}/{*module}.py", "@/src/api.py", {"package": "", "module": "api"}),
99
+ ("src/{**module_path}.py", "@/src/core/api.py", {"module_path": "core/api"}),
100
+ ("src/{**module_path}.py", "@/src/api.py", {"module_path": "api"}),
101
+ ("src/{*module}.py", "@/src/core/api.py", None),
102
+ ("src/{*module}.py", "@/src/.py", {"module": ""}),
103
+ ("src/{**package}/{*module}.py", "@/src/one/two/api.py", {"package": "one/two", "module": "api"}),
104
+ ("src/*.py", "@/src/file.py", {}),
105
+ ("src/**", "@/src", None),
106
+ ],
107
+ )
108
+ def test_matches_missing_references(
109
+ self, tmp_path: Path, pattern: str, path: str, expected: dict[str, str] | None
110
+ ) -> None:
111
+ assert match_glob(ProjectPathId(path), FileGlobPattern(pattern), tmp_path).unwrap() == expected
112
+
113
+ def test_does_not_resolve_glob_syntax_as_a_symlink(self, tmp_path: Path) -> None:
114
+ (tmp_path / "src").mkdir()
115
+ (tmp_path / "src/*.py").symlink_to(tmp_path / "missing")
116
+
117
+ assert match_glob(ProjectPathId("@/src/a.py"), FileGlobPattern("@/src/*.py"), tmp_path).unwrap() == {}
118
+
119
+ @pytest.mark.parametrize("pattern", ["link/*.py", "link/../*.py", "@/link/*.py"])
120
+ def test_literal_symlink_prefix_is_rejected(self, tmp_path: Path, pattern: str) -> None:
121
+ (tmp_path / "link").symlink_to(tmp_path)
122
+
123
+ assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern(pattern), tmp_path).is_err(InvalidProjectPath)
124
+
125
+ def test_root_resolution_precedes_pattern_validation(self, tmp_path: Path) -> None:
126
+ root = tmp_path / "loop"
127
+ root.symlink_to(root)
128
+
129
+ assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern("@/../*"), root).is_err(PathResolutionFailed)
130
+
131
+ def test_absolute_and_home_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
132
+ monkeypatch.setenv("HOME", str(tmp_path))
133
+ for pattern in [str(tmp_path / "*.py"), "~/*.py"]:
134
+ assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern(pattern), tmp_path).unwrap() == {}
135
+
136
+ def test_syntax_failures_are_preserved(self, tmp_path: Path) -> None:
137
+ assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern("{*bad-name}"), tmp_path).is_err(InvalidGlobPattern)
@@ -0,0 +1,163 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from llm_tool_cli.paths import is_project_path_id, normalize_project_path_id, project_path_parts
6
+ from llm_tool_cli.paths.errors import InvalidProjectPath
7
+
8
+
9
+ class TestProjectPathParts:
10
+ @pytest.mark.parametrize(
11
+ ("value", "expected"),
12
+ [
13
+ ("@/LICENSE", ("LICENSE",)),
14
+ ("@/notes/Project Plan.md", ("notes", "Project Plan.md")),
15
+ ("@/Case/FILE", ("Case", "FILE")),
16
+ ("@/café/資料", ("café", "資料")),
17
+ ("@/ directory / file ", (" directory ", " file ")),
18
+ ("@/a/.../b", ("a", "...", "b")),
19
+ ("@/~/file", ("~", "file")),
20
+ ("@/a\\b/file", ("a\\b", "file")),
21
+ ("@/name:section", ("name:section",)),
22
+ ("@/src/{**package}/*.[pP][yY]", ("src", "{**package}", "*.[pP][yY]")),
23
+ ],
24
+ )
25
+ def test_extracts_components(self, value: str, expected: tuple[str, ...]) -> None:
26
+ identifier = normalize_project_path_id(value).unwrap()
27
+
28
+ assert project_path_parts(identifier) == expected
29
+
30
+
31
+ class TestIsProjectPathId:
32
+ @pytest.mark.parametrize(
33
+ "value",
34
+ [
35
+ "@/README.md",
36
+ "@/README",
37
+ "@/LICENSE",
38
+ "@/Makefile",
39
+ "@/assets",
40
+ "@/assets/archive",
41
+ "@/src/package",
42
+ "@/notes/Проектный план",
43
+ "@/notes/project plan.md",
44
+ "@/Case/FILE",
45
+ "@/ directory / file ",
46
+ "@/a/.../b",
47
+ "@/~/file",
48
+ "@/a\\b/file",
49
+ "@/name:section",
50
+ "@/src/{**package_path}/*.[pP][yY]",
51
+ ],
52
+ )
53
+ def test_accepts_canonical_project_paths(self, value: str) -> None:
54
+ assert is_project_path_id(value)
55
+
56
+ @pytest.mark.parametrize(
57
+ "value",
58
+ [
59
+ "",
60
+ "@",
61
+ "@file",
62
+ "file",
63
+ "./file",
64
+ "/project/file",
65
+ " @/file",
66
+ "@/",
67
+ "@/.",
68
+ "@/..",
69
+ "@//file",
70
+ "@/a//b",
71
+ "@/a/",
72
+ "@/a/..",
73
+ "@/../a",
74
+ "@/a/../../a",
75
+ ],
76
+ )
77
+ def test_rejects_malformed_project_paths(self, value: str) -> None:
78
+ assert not is_project_path_id(value)
79
+
80
+ @pytest.mark.parametrize("value", ["@/./a", "@/a/.", "@/a/../b", "@/a/b/.."])
81
+ def test_rejects_paths_requiring_normalization(self, value: str) -> None:
82
+ assert not is_project_path_id(value)
83
+
84
+ @pytest.mark.parametrize("value", [None, 1, b"@/file", [], {}, Path("@/file")])
85
+ def test_rejects_non_strings(self, value: object) -> None:
86
+ assert not is_project_path_id(value)
87
+
88
+
89
+ class TestNormalizeProjectPathId:
90
+ @pytest.mark.parametrize(
91
+ ("value", "expected"),
92
+ [
93
+ ("@/file", "@/file"),
94
+ ("@/a/./b/../c", "@/a/c"),
95
+ ("@/./a", "@/a"),
96
+ ("@/a/.", "@/a"),
97
+ ("@/a/b/..", "@/a"),
98
+ ("@/a/../b", "@/b"),
99
+ ("@/a/b/../../c", "@/c"),
100
+ ],
101
+ )
102
+ def test_normalization(self, value: str, expected: str) -> None:
103
+ normalized = normalize_project_path_id(value).unwrap()
104
+
105
+ assert normalized == expected
106
+ assert normalize_project_path_id(normalized).unwrap() == normalized
107
+
108
+ @pytest.mark.parametrize(
109
+ "value",
110
+ [
111
+ "",
112
+ "@",
113
+ "@file",
114
+ "file",
115
+ "./file",
116
+ "/file",
117
+ " @/file",
118
+ "@/",
119
+ "@/.",
120
+ "@/..",
121
+ "@//file",
122
+ "@/a//b",
123
+ "@/a/",
124
+ "@/a/..",
125
+ "@/a/../.",
126
+ "@/../a",
127
+ "@/a/../../a",
128
+ ],
129
+ )
130
+ def test_invalid_path(self, value: str) -> None:
131
+ errors = normalize_project_path_id(value).unwrap_err()
132
+
133
+ assert len(errors) == 1
134
+ error = errors[0]
135
+ assert isinstance(error, InvalidProjectPath)
136
+ assert error.code == "invalid_project_path"
137
+ assert error.path == value.strip()
138
+
139
+ @pytest.mark.parametrize(
140
+ "value",
141
+ [
142
+ "@/Case/FILE",
143
+ "@/café/資料",
144
+ "@/ directory / file ",
145
+ "@/a/.../b",
146
+ "@/~/file",
147
+ "@/a\\b/file",
148
+ "@/name:section",
149
+ "@/src/{**package_path}/*.[pP][yY]",
150
+ ],
151
+ )
152
+ def test_preserves_segment_text(self, value: str) -> None:
153
+ assert normalize_project_path_id(value).unwrap() == value
154
+
155
+ def test_structured_error(self) -> None:
156
+ error = normalize_project_path_id("@/../file").unwrap_err()[0]
157
+
158
+ assert error.as_record() == {
159
+ "type": "error",
160
+ "code": "invalid_project_path",
161
+ "message": error.format_message(),
162
+ "path": "@/../file",
163
+ }
@@ -0,0 +1,4 @@
1
+ from llm_tool_cli.protocol.entities import Protocol
2
+ from llm_tool_cli.protocol.serialization import to_jsonl
3
+
4
+ __all__ = ["Protocol", "to_jsonl"]
@@ -0,0 +1,39 @@
1
+ from llm_tool_cli.core.errors import EnvironmentError
2
+ from llm_tool_cli.paths import ProjectConfigPath
3
+ from llm_tool_cli.protocol.logic_cells import ContentCell, EnvironmentErrorCell
4
+ from llm_tool_cli.protocol.output_cells.base import MetaValue
5
+
6
+
7
+ def operation_succeeded(message: str, **meta: MetaValue) -> ContentCell:
8
+ return ContentCell(
9
+ kind="operation_succeeded",
10
+ media_type="text/markdown",
11
+ content=message,
12
+ meta={"type": "operation_succeeded", **meta},
13
+ )
14
+
15
+
16
+ def configuration_created(path: ProjectConfigPath) -> ContentCell:
17
+ return operation_succeeded("Configuration created.", path=str(path))
18
+
19
+
20
+ def operation_failed(message: str, **meta: MetaValue) -> ContentCell:
21
+ return ContentCell(kind="operation_failed", media_type="text/markdown", content=message, meta=meta)
22
+
23
+
24
+ def info(message: str, **meta: MetaValue) -> ContentCell:
25
+ return ContentCell(kind="info", media_type="text/markdown", content=message, meta=meta)
26
+
27
+
28
+ def skill(document: str, content: str) -> ContentCell:
29
+ return ContentCell(
30
+ kind="skill", media_type="text/markdown", content=content, meta={"type": "skill", "document": document}
31
+ )
32
+
33
+
34
+ def version(value: str) -> ContentCell:
35
+ return ContentCell(kind="version", meta={"type": "version", "version": value})
36
+
37
+
38
+ def environment_error(error: EnvironmentError) -> EnvironmentErrorCell:
39
+ return EnvironmentErrorCell(error=error)
@@ -0,0 +1,7 @@
1
+ import enum
2
+
3
+
4
+ class Protocol(enum.StrEnum):
5
+ human = "human"
6
+ llm = "llm"
7
+ automation = "automation"
@@ -0,0 +1,21 @@
1
+ """Internal failures in output protocol construction and formatting."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from llm_tool_cli.core import errors as core_errors
6
+
7
+
8
+ class InternalError(core_errors.InternalError):
9
+ """Base for internal output protocol failures."""
10
+
11
+
12
+ class ContentWithoutMediaType(InternalError):
13
+ message_template: ClassVar[str] = "Cannot set content when media_type is None."
14
+
15
+
16
+ class UnsupportedFormatterMode(InternalError):
17
+ message_template: ClassVar[str] = "Formatter for mode '{mode}' is not implemented."
18
+
19
+
20
+ class JournalConversionUnsupported(InternalError):
21
+ message_template: ClassVar[str] = "Cell '{cell_type}' does not support journal conversion."
@@ -0,0 +1,21 @@
1
+ import datetime
2
+
3
+ import pydantic
4
+
5
+ from llm_tool_cli.core.entities import BaseEntity
6
+
7
+
8
+ class JournalRecord(BaseEntity):
9
+ """Protocol-independent journal data, ready for an external processor."""
10
+
11
+ message: str
12
+ timestamp: datetime.datetime = pydantic.Field(default_factory=lambda: datetime.datetime.now(datetime.UTC))
13
+ actor_id: str | None = None
14
+ attributes: dict[str, pydantic.JsonValue] = pydantic.Field(default_factory=dict)
15
+
16
+ def as_dict(self) -> dict[str, pydantic.JsonValue]:
17
+ """Flatten attributes without allowing them to replace common fields."""
18
+ fields = self.model_dump(mode="json")
19
+ attributes = fields.pop("attributes")
20
+ fields["timestamp"] = self.timestamp.isoformat()
21
+ return {**attributes, **fields}
@@ -0,0 +1,4 @@
1
+ from llm_tool_cli.protocol.logic_cells.content import ContentCell
2
+ from llm_tool_cli.protocol.logic_cells.environment_error import EnvironmentErrorCell
3
+
4
+ __all__ = ["ContentCell", "EnvironmentErrorCell"]
@@ -0,0 +1,41 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import assert_never
3
+
4
+ import pydantic
5
+
6
+ from llm_tool_cli.core.entities import BaseEntity
7
+ from llm_tool_cli.protocol.entities import Protocol
8
+ from llm_tool_cli.protocol.errors import JournalConversionUnsupported
9
+ from llm_tool_cli.protocol.journal import JournalRecord
10
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
11
+
12
+
13
+ class LogicCell(BaseEntity, ABC):
14
+ """Application data projected into prepared output cells for a protocol."""
15
+
16
+ is_journal: bool = pydantic.Field(default=False, exclude=True)
17
+ is_warning: bool = pydantic.Field(default=False, exclude=True)
18
+
19
+ def to_journal_record(self) -> JournalRecord:
20
+ """Journal-capable cells must describe their event explicitly."""
21
+ raise JournalConversionUnsupported(details={"cell_type": type(self).__name__})
22
+
23
+ def render(self, protocol: Protocol) -> list[OutputCell]:
24
+ match protocol:
25
+ case Protocol.human:
26
+ return self.render_human()
27
+ case Protocol.llm:
28
+ return self.render_llm()
29
+ case Protocol.automation:
30
+ return self.render_automation()
31
+ case _:
32
+ assert_never(protocol)
33
+
34
+ @abstractmethod
35
+ def render_human(self) -> list[OutputCell]: ... # noqa: E704
36
+
37
+ @abstractmethod
38
+ def render_llm(self) -> list[OutputCell]: ... # noqa: E704
39
+
40
+ @abstractmethod
41
+ def render_automation(self) -> list[OutputCell]: ... # noqa: E704
@@ -0,0 +1,26 @@
1
+ from typing import Literal, Self
2
+
3
+ import pydantic
4
+
5
+ from llm_tool_cli.protocol.errors import ContentWithoutMediaType
6
+ from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
7
+ from llm_tool_cli.protocol.output_cells.base import MetaValue, OutputCell
8
+
9
+
10
+ class ContentCell(UniformCell):
11
+ """Prepared content and metadata shared by all output protocols."""
12
+
13
+ is_journal: Literal[False] = pydantic.Field(default=False, exclude=True)
14
+ kind: str
15
+ media_type: str | None = None
16
+ content: str | None = None
17
+ meta: dict[str, MetaValue] = pydantic.Field(default_factory=dict)
18
+
19
+ @pydantic.model_validator(mode="after")
20
+ def validate_content(self) -> Self:
21
+ if self.media_type is None and self.content is not None:
22
+ raise ContentWithoutMediaType()
23
+ return self
24
+
25
+ def _render(self, cell_type: type[OutputCell]) -> OutputCell:
26
+ return cell_type(kind=self.kind, media_type=self.media_type, content=self.content, meta=self.meta)
@@ -0,0 +1,36 @@
1
+ from llm_tool_cli.core.errors import EnvironmentError
2
+ from llm_tool_cli.protocol.journal import JournalRecord
3
+ from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
4
+ from llm_tool_cli.protocol.output_cells import HumanErrorOutputCell
5
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, to_meta_value
6
+
7
+
8
+ class EnvironmentErrorCell(UniformCell):
9
+ """An operational failure retained as typed data until projection."""
10
+
11
+ error: EnvironmentError
12
+
13
+ def render_human(self) -> list[OutputCell]:
14
+ return [self._render(HumanErrorOutputCell)]
15
+
16
+ def to_journal_record(self) -> JournalRecord:
17
+ fields = self.error.as_record()
18
+ message = str(fields.pop("message"))
19
+ # Native error records already contain JSON-compatible serialized context.
20
+ return JournalRecord(message=message, attributes=fields) # type: ignore[arg-type]
21
+
22
+ def _render(self, cell_type: type[OutputCell]) -> OutputCell:
23
+ record = self.error.as_record()
24
+ content = str(record.pop("message"))
25
+ fixes = [fix.format(error=self.error).strip() for fix in self.error.ways_to_fix]
26
+ if len(fixes) == 1:
27
+ content = f"{content}\nWay to fix: {fixes[0]}"
28
+ elif fixes:
29
+ guidance = "\n".join(f"- {fix}" for fix in fixes)
30
+ content = f"{content}\n\nWays to fix:\n\n{guidance}"
31
+ return cell_type(
32
+ kind="error",
33
+ media_type="text/markdown",
34
+ content=content,
35
+ meta={key: to_meta_value(value) for key, value in record.items()},
36
+ )
@@ -0,0 +1,44 @@
1
+ import datetime
2
+
3
+ import pydantic
4
+
5
+ from llm_tool_cli.protocol.journal import JournalRecord
6
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
7
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, to_meta_value
8
+ from llm_tool_cli.protocol.output_cells.journal import JournalAutomationOutputCell, JournalTextOutputCell
9
+
10
+
11
+ class JournalCell(LogicCell):
12
+ """A journal event with compact text and structured automation projections."""
13
+
14
+ is_journal: bool = pydantic.Field(default=True, exclude=True)
15
+ timestamp: datetime.datetime = pydantic.Field(default_factory=lambda: datetime.datetime.now(datetime.UTC))
16
+ actor_id: str | None = None
17
+ message: str
18
+
19
+ def to_journal_record(self) -> JournalRecord:
20
+ return JournalRecord(
21
+ message=self.message,
22
+ timestamp=self.timestamp,
23
+ actor_id=self.actor_id,
24
+ attributes=self.model_dump(mode="json", exclude={"message", "timestamp", "actor_id"}),
25
+ )
26
+
27
+ def human_line(self) -> str:
28
+ return f"{self.timestamp.time().isoformat('seconds')} <{self.actor_id or '-'}> {self.message}"
29
+
30
+ def llm_line(self) -> str:
31
+ return f"{self.timestamp.isoformat()} <{self.actor_id or '-'}> {self.message}"
32
+
33
+ def render_human(self) -> list[OutputCell]:
34
+ return [JournalTextOutputCell.build(kind="journal", media_type="text/plain", content=self.human_line())]
35
+
36
+ def render_llm(self) -> list[OutputCell]:
37
+ return [JournalTextOutputCell.build(kind="journal", media_type="text/plain", content=self.llm_line())]
38
+
39
+ def render_automation(self) -> list[OutputCell]:
40
+ return [
41
+ JournalAutomationOutputCell.build_meta(
42
+ kind="journal", **{key: to_meta_value(value) for key, value in self.model_dump(mode="json").items()}
43
+ )
44
+ ]
File without changes
@@ -0,0 +1,47 @@
1
+ import pytest
2
+
3
+ from llm_tool_cli.protocol import Protocol
4
+ from llm_tool_cli.protocol.errors import JournalConversionUnsupported
5
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
6
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell
7
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
8
+
9
+
10
+ class ExampleLogicCell(LogicCell):
11
+ items: tuple[str, ...]
12
+
13
+ def render_human(self) -> list[OutputCell]:
14
+ return [HumanOutputCell.build_markdown(kind="item", content=item) for item in self.items]
15
+
16
+ def render_llm(self) -> list[OutputCell]:
17
+ return []
18
+
19
+ def render_automation(self) -> list[OutputCell]:
20
+ return [AutomationOutputCell.build_meta(kind="items", items=list(self.items))]
21
+
22
+
23
+ class TestLogicCell:
24
+ def test_to_journal_record__unsupported_conversion_is_explicit(self) -> None:
25
+ cell = ExampleLogicCell(items=("first", "second"))
26
+
27
+ with pytest.raises(JournalConversionUnsupported) as caught:
28
+ cell.to_journal_record()
29
+
30
+ assert caught.value.details == {"cell_type": "ExampleLogicCell"}
31
+
32
+ @pytest.mark.parametrize(
33
+ ("protocol", "expected"),
34
+ (
35
+ (Protocol.human, [("item", "first"), ("item", "second")]),
36
+ (Protocol.llm, []),
37
+ (Protocol.automation, [("items", None)]),
38
+ ),
39
+ )
40
+ def test_render__dispatches_to_protocol_projection(
41
+ self, protocol: Protocol, expected: list[tuple[str, str | None]]
42
+ ) -> None:
43
+ cell = ExampleLogicCell(items=("first", "second"))
44
+
45
+ result = cell.render(protocol)
46
+
47
+ assert [(output.kind, output.content) for output in result] == expected
@@ -0,0 +1,59 @@
1
+ import pydantic
2
+ import pytest
3
+
4
+ from llm_tool_cli.protocol import Protocol
5
+ from llm_tool_cli.protocol.errors import ContentWithoutMediaType
6
+ from llm_tool_cli.protocol.logic_cells import ContentCell
7
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
8
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
9
+
10
+
11
+ class TestContentCell:
12
+ def test_validation__prohibits_journaling_generic_content(self) -> None:
13
+ with pytest.raises(pydantic.ValidationError):
14
+ ContentCell.model_validate({"kind": "document", "is_journal": True})
15
+
16
+ @pytest.mark.parametrize(
17
+ ("protocol", "cell_type"),
18
+ [
19
+ (Protocol.human, HumanOutputCell),
20
+ (Protocol.llm, LLMOutputCell),
21
+ (Protocol.automation, AutomationOutputCell),
22
+ ],
23
+ )
24
+ @pytest.mark.parametrize(("media_type", "content"), [("text/markdown", "café"), (None, None), ("text/plain", "")])
25
+ def test_render__preserves_payload_and_creates_fresh_output(
26
+ self, protocol: Protocol, cell_type: type[OutputCell], media_type: str | None, content: str | None
27
+ ) -> None:
28
+ cell = ContentCell(
29
+ kind="item",
30
+ media_type=media_type,
31
+ content=content,
32
+ meta={"labels": ["one", "two"], "enabled": True, "empty": None},
33
+ )
34
+ original = cell.model_dump()
35
+
36
+ first = cell.render(protocol)
37
+ second = cell.render(protocol)
38
+
39
+ assert len(first) == len(second) == 1
40
+ assert isinstance(first[0], cell_type)
41
+ assert isinstance(second[0], cell_type)
42
+ assert first[0].model_dump(exclude={"id"}) == second[0].model_dump(exclude={"id"}) == original
43
+ assert first[0].id.version == second[0].id.version == 4
44
+ assert first[0].id != second[0].id
45
+ assert cell.model_dump() == original
46
+
47
+ @pytest.mark.parametrize("content", ["text", ""])
48
+ def test_validate_content__rejects_missing_media_type(self, content: str) -> None:
49
+ with pytest.raises(ContentWithoutMediaType):
50
+ ContentCell(kind="item", content=content)
51
+
52
+ def test_render__preserves_reserved_metadata_fields(self) -> None:
53
+ cell = ContentCell(kind="item", meta={"id": "custom", "content": "metadata", "kind": "metadata_kind"})
54
+
55
+ output = cell.render(Protocol.automation)[0]
56
+
57
+ assert output.kind == "item"
58
+ assert output.content is None
59
+ assert output.meta == cell.meta