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,125 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ import typer
5
+ from typer.testing import CliRunner
6
+
7
+ from llm_tool_cli.cli.options import ConfigOption, ProtocolOption
8
+ from llm_tool_cli.core import settings
9
+ from llm_tool_cli.core.tests.fixtures import isolated_settings
10
+ from llm_tool_cli.paths import ProjectConfigPath
11
+ from llm_tool_cli.protocol import Protocol
12
+
13
+ __all__ = ["isolated_settings"]
14
+
15
+
16
+ @pytest.fixture
17
+ def protocol_app(isolated_settings: None) -> typer.Typer:
18
+ settings.initialize(label=settings.ToolLabel("TEST"))
19
+ app = typer.Typer()
20
+
21
+ @app.callback()
22
+ def initialize(protocol: ProtocolOption = None) -> None:
23
+ typer.echo("unspecified" if protocol is None else protocol.value)
24
+
25
+ @app.command()
26
+ def show() -> None:
27
+ pass
28
+
29
+ return app
30
+
31
+
32
+ class TestParseConfigPath:
33
+ @pytest.mark.parametrize(
34
+ ("value", "expected"),
35
+ [
36
+ (None, None),
37
+ ("", "."),
38
+ (".", "."),
39
+ ("missing/config.toml", "missing/config.toml"),
40
+ ("/absolute/config.toml", "/absolute/config.toml"),
41
+ ("relative/../config.toml", "relative/../config.toml"),
42
+ ("~/config.toml", "~/config.toml"),
43
+ (" café 日本語.toml ", " café 日本語.toml "),
44
+ ],
45
+ )
46
+ def test_parses_without_filesystem_validation(
47
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str | None, expected: str | None
48
+ ) -> None:
49
+ monkeypatch.chdir(tmp_path)
50
+ app = typer.Typer()
51
+ received: list[ProjectConfigPath | None] = []
52
+
53
+ @app.callback()
54
+ def initialize(config: ConfigOption = None) -> None:
55
+ received.append(config)
56
+
57
+ @app.command()
58
+ def show() -> None:
59
+ pass
60
+
61
+ arguments = [] if value is None else ["--config", value]
62
+ result = CliRunner().invoke(app, [*arguments, "show"])
63
+
64
+ assert result.exit_code == 0
65
+ assert not result.output
66
+ assert received == [None if expected is None else ProjectConfigPath(Path(expected))]
67
+
68
+
69
+ class TestParseProtocol:
70
+ @pytest.mark.parametrize("option", ["-p", "--protocol"])
71
+ @pytest.mark.parametrize("protocol", list(Protocol))
72
+ def test_parses_supported_values(self, protocol_app: typer.Typer, option: str, protocol: Protocol) -> None:
73
+ result = CliRunner().invoke(protocol_app, [option, protocol.value, "show"])
74
+
75
+ assert result.exit_code == 0
76
+ assert result.stdout == f"{protocol.value}\n"
77
+ assert not result.stderr
78
+
79
+ def test_leaves_missing_protocol_unspecified(self, protocol_app: typer.Typer) -> None:
80
+ result = CliRunner().invoke(protocol_app, ["show"])
81
+
82
+ assert result.exit_code == 0
83
+ assert result.stdout == "unspecified\n"
84
+ assert not result.stderr
85
+
86
+ @pytest.mark.parametrize("value", ["invalid", "HUMAN", " llm", "llm ", "", "{protocol}", "{"])
87
+ @pytest.mark.parametrize("prefix", [[], ["-p", "automation"]])
88
+ def test_invalid_value_uses_llm_cell_before_command_execution(
89
+ self, protocol_app: typer.Typer, value: str, prefix: list[str]
90
+ ) -> None:
91
+ result = CliRunner().invoke(protocol_app, [*prefix, "--protocol", value, "show"])
92
+
93
+ assert result.exit_code == 1
94
+ assert not result.stderr
95
+ assert result.stdout.startswith("--TEST-CELL ")
96
+ assert result.stdout.count(" BEGIN--\n") == 1
97
+ assert result.stdout.count(" END--\n") == 1
98
+ assert "kind=error\n" in result.stdout
99
+ assert "code=invalid_arguments\n" in result.stdout
100
+ assert "type=error\n" in result.stdout
101
+ assert "\nreason=" in result.stdout
102
+ assert f"`{value}`" in result.stdout
103
+ for protocol in Protocol:
104
+ assert protocol.value in result.stdout
105
+
106
+ @pytest.mark.parametrize("option", ["-p", "--protocol"])
107
+ def test_missing_value_remains_a_framework_error(self, protocol_app: typer.Typer, option: str) -> None:
108
+ result = CliRunner().invoke(protocol_app, [option])
109
+
110
+ assert result.exit_code == 2
111
+ assert not result.stdout
112
+ assert option in result.stderr
113
+ assert "--TEST-CELL " not in result.stderr
114
+
115
+ def test_help_describes_shared_option(self, protocol_app: typer.Typer) -> None:
116
+ result = CliRunner().invoke(protocol_app, ["--help"])
117
+
118
+ assert result.exit_code == 0
119
+ assert not result.stderr
120
+ assert "--protocol" in result.stdout
121
+ assert "-p" in result.stdout
122
+ assert "PROTOCOL" in result.stdout
123
+ for protocol in Protocol:
124
+ assert protocol.value in result.stdout
125
+ assert "skill" in result.stdout
@@ -0,0 +1,33 @@
1
+ """Configuration file mechanics and reusable schema fields.
2
+
3
+ The exported operations, ``config.fields``, and ``config.errors`` are public interfaces.
4
+ Applications declare their default filename in static settings and own template
5
+ resources, schemas, workspace construction, and presentation of failures.
6
+ """
7
+
8
+ from llm_tool_cli.config.files import (
9
+ create_config,
10
+ create_config_from_template,
11
+ find_config,
12
+ initialize_config,
13
+ load_config,
14
+ locate_config,
15
+ read_toml,
16
+ resolve_config_path,
17
+ resolve_init_config_path,
18
+ )
19
+ from llm_tool_cli.config.project import initialize_project, load_project
20
+
21
+ __all__ = [
22
+ "create_config",
23
+ "create_config_from_template",
24
+ "find_config",
25
+ "initialize_config",
26
+ "initialize_project",
27
+ "load_config",
28
+ "load_project",
29
+ "locate_config",
30
+ "read_toml",
31
+ "resolve_config_path",
32
+ "resolve_init_config_path",
33
+ ]
@@ -0,0 +1,92 @@
1
+ """Public configuration failures.
2
+
3
+ Concrete ``code`` values are stable compatibility identifiers. Callers may inspect
4
+ the returned values without parsing messages. ``path`` identifies the
5
+ failed operation's filesystem input; ``reason`` contains the original reason.
6
+ The producing operation preserves any underlying low-level exception as ``cause``.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import ClassVar
11
+
12
+ from llm_tool_cli.core import errors as core_errors
13
+ from llm_tool_cli.core.entities import ExitCode
14
+
15
+
16
+ class InternalError(core_errors.InternalError):
17
+ """Misuse of configuration setup interfaces."""
18
+
19
+
20
+ class ConfigFilenameNotSet(InternalError):
21
+ message_template = "Project setup requires a configuration filename in static settings."
22
+
23
+
24
+ class EnvironmentError(core_errors.EnvironmentError):
25
+ """Classification and filesystem context for configuration failures."""
26
+
27
+ cli_exit_code: ClassVar[ExitCode] = ExitCode.configuration_error
28
+ path: Path
29
+ reason: str
30
+ message: str = "{error.path}: {error.reason}"
31
+
32
+
33
+ class PathResolutionFailed(EnvironmentError):
34
+ """An explicit path could not be resolved."""
35
+
36
+ code: str = "config_path_resolution_failed"
37
+
38
+
39
+ class DiscoveryFailed(EnvironmentError):
40
+ """The search directory or a candidate could not be inspected."""
41
+
42
+ code: str = "config_discovery_failed"
43
+
44
+
45
+ class NotFound(EnvironmentError):
46
+ """No configuration file was found in the search directory or its parents."""
47
+
48
+ code: str = "config_not_found"
49
+
50
+
51
+ class Unreadable(EnvironmentError):
52
+ """A configuration file could not be opened or read."""
53
+
54
+ code: str = "config_unreadable"
55
+
56
+
57
+ class InvalidEncoding(EnvironmentError):
58
+ """A configuration file does not contain valid UTF-8."""
59
+
60
+ code: str = "config_invalid_encoding"
61
+
62
+
63
+ class InvalidToml(EnvironmentError):
64
+ """A configuration file does not contain valid TOML."""
65
+
66
+ code: str = "config_invalid_toml"
67
+
68
+
69
+ class ValidationFailed(EnvironmentError):
70
+ """Parsed configuration does not satisfy the supplied model."""
71
+
72
+ code: str = "config_validation_failed"
73
+
74
+
75
+ class AlreadyExists(EnvironmentError):
76
+ """The starter target already exists and was left untouched."""
77
+
78
+ code: str = "config_already_exists"
79
+
80
+
81
+ class TemplateUnreadable(EnvironmentError):
82
+ """A packaged starter could not be read as UTF-8 for the target path."""
83
+
84
+ code: str = "config_template_unreadable"
85
+ message: str = "could not read configuration template `{error.template}`: {error.reason}"
86
+ template: str
87
+
88
+
89
+ class Unwritable(EnvironmentError):
90
+ """Starter text could not be encoded or written to the target."""
91
+
92
+ code: str = "config_unwritable"
@@ -0,0 +1,39 @@
1
+ """Reusable fields for application-owned configuration schemas."""
2
+
3
+ from typing import Annotated
4
+
5
+ import pydantic
6
+
7
+ from llm_tool_cli.paths import RelativeProjectPath
8
+
9
+
10
+ def _validate_config_version(value: object, handler: pydantic.ValidatorFunctionWrapHandler) -> object:
11
+ """Require an integer and explain failures of the application's version constraint."""
12
+ if isinstance(value, bool) or not isinstance(value, int):
13
+ raise ValueError(f"Configuration version must be an integer; received {value!r}.")
14
+
15
+ try:
16
+ return handler(value)
17
+ except pydantic.ValidationError as error:
18
+ failures = error.errors()
19
+ if len(failures) != 1 or failures[0]["type"] != "literal_error":
20
+ raise
21
+ supported = failures[0]["ctx"]["expected"]
22
+ raise ValueError(f"Unsupported configuration version {value}; supported version: {supported}.") from error
23
+
24
+
25
+ type ConfigVersion[Version: int] = Annotated[Version, pydantic.WrapValidator(_validate_config_version)]
26
+
27
+
28
+ def validate_relative_project_path(path: RelativeProjectPath) -> RelativeProjectPath:
29
+ """Pydantic validator for project-relative paths without parent references."""
30
+ if path.is_absolute():
31
+ raise ValueError("Project paths must be relative to the project root.")
32
+
33
+ if any(part == ".." for part in path.parts):
34
+ raise ValueError("Project paths must not contain parent-directory references.")
35
+
36
+ return path
37
+
38
+
39
+ type SessionDir = Annotated[RelativeProjectPath, pydantic.AfterValidator(validate_relative_project_path)]
@@ -0,0 +1,175 @@
1
+ import importlib.resources
2
+ from pathlib import Path
3
+
4
+ import pydantic
5
+ import tomli
6
+
7
+ from llm_tool_cli.config import errors
8
+ from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
9
+ from llm_tool_cli.paths import PathInput, ProjectConfigPath, ProjectRootPath, resolve_inside_project
10
+
11
+
12
+ @unwrap_to_error
13
+ def find_config(filename: str, start_dir: Path) -> Result[ProjectConfigPath | None]:
14
+ """Find the nearest file from the resolved start directory through its root.
15
+
16
+ Matching directories are skipped. No match returns ``Ok(None)``; filesystem
17
+ failures reported by pathlib return ``Err([DiscoveryFailed(...)])``.
18
+ Shared path validation rejects matching symlinks, including broken links,
19
+ before checking file existence. No matching file is read.
20
+ """
21
+ candidate = start_dir
22
+ try:
23
+ current = start_dir.resolve()
24
+ for directory in (current, *current.parents):
25
+ candidate = directory / filename
26
+ resolve_inside_project(candidate, ProjectRootPath(directory)).unwrap()
27
+ if candidate.is_file():
28
+ return Ok(ProjectConfigPath(candidate))
29
+ except (OSError, RuntimeError) as exc:
30
+ return Err([errors.DiscoveryFailed(path=candidate, reason=str(exc)).with_cause(exc)])
31
+ return Ok(None)
32
+
33
+
34
+ @unwrap_to_error
35
+ def resolve_config_path(path: Path, cwd: Path) -> Result[ProjectConfigPath]:
36
+ """Expand an explicit path's home marker, then resolve it against cwd.
37
+
38
+ Resolve the containing directory as the project root, then apply shared
39
+ containment and symlink validation to the file. Neither needs to exist.
40
+ Do not search for a config or interpret application path syntax. Home
41
+ expansion and directory resolution failures return ``PathResolutionFailed``;
42
+ shared path diagnostics propagate unchanged.
43
+ """
44
+ candidate = path
45
+ try:
46
+ expanded = path.expanduser()
47
+ candidate = expanded if expanded.is_absolute() else cwd / expanded
48
+ root = ProjectRootPath(candidate.parent.resolve())
49
+ return Ok(ProjectConfigPath(resolve_inside_project(root / candidate.name, root).unwrap()))
50
+ except (OSError, RuntimeError) as exc:
51
+ return Err([errors.PathResolutionFailed(path=candidate, reason=str(exc)).with_cause(exc)])
52
+
53
+
54
+ def resolve_init_config_path(
55
+ filename: str, *, path: ProjectConfigPath | None = None, cwd: PathInput
56
+ ) -> Result[ProjectConfigPath]:
57
+ """Select and resolve a configuration target without upward discovery.
58
+
59
+ An explicit path takes precedence over the filename under ``cwd``.
60
+ Reuse ``resolve_config_path`` for home expansion, shared path validation, and
61
+ unchanged failure propagation. No file is read or created, and neither the
62
+ target nor its parent is required to exist.
63
+ """
64
+ return resolve_config_path(path if path is not None else Path(filename), cwd)
65
+
66
+
67
+ @unwrap_to_error
68
+ def locate_config(filename: str, *, path: Path | None = None, cwd: Path) -> Result[ProjectConfigPath]:
69
+ """Select an explicit configuration path or discover the nearest file.
70
+
71
+ Explicit paths use ``resolve_config_path`` without falling back to discovery
72
+ or requiring an existing target. Otherwise, search with ``find_config`` and
73
+ return ``Err([NotFound(...)])`` if no match exists. Its ``path`` is the supplied search
74
+ directory and its ``reason`` identifies the filename and search scope.
75
+ Resolution and discovery failures propagate unchanged. No file is read.
76
+ """
77
+ if path is not None:
78
+ return resolve_config_path(path, cwd)
79
+
80
+ config_path = find_config(filename, cwd).unwrap()
81
+ if config_path is None:
82
+ return Err([errors.NotFound(path=cwd, reason=f"{filename} was not found in this directory or its parents")])
83
+ return Ok(config_path)
84
+
85
+
86
+ def read_toml(path: Path) -> Result[dict[str, object]]:
87
+ """Read UTF-8 TOML 1.1 data without applying an application schema.
88
+
89
+ Filesystem, UTF-8, and TOML failures return ``Unreadable``, ``InvalidEncoding``,
90
+ and ``InvalidToml`` in error lists respectively. The supplied path is used without discovery
91
+ or normalization. An empty document returns ``Ok({})``.
92
+ """
93
+ try:
94
+ with path.open("rb") as stream:
95
+ return Ok(tomli.load(stream))
96
+ except OSError as exc:
97
+ return Err([errors.Unreadable(path=path, reason=str(exc)).with_cause(exc)])
98
+ except UnicodeDecodeError as exc:
99
+ return Err([errors.InvalidEncoding(path=path, reason=str(exc)).with_cause(exc)])
100
+ except tomli.TOMLDecodeError as exc:
101
+ return Err([errors.InvalidToml(path=path, reason=str(exc)).with_cause(exc)])
102
+
103
+
104
+ @unwrap_to_error
105
+ def load_config[T: pydantic.BaseModel](path: Path, config_class: type[T]) -> Result[T]:
106
+ """Read a TOML file and validate it using the caller's Pydantic model.
107
+
108
+ Use the supplied path unchanged, without discovery or normalization.
109
+ The model owns its schema, defaults, and validation rules. Read failures
110
+ propagate as documented by ``read_toml``; Pydantic validation failures return
111
+ ``Err([ValidationFailed(...)])`` with diagnostic text and the original exception cause.
112
+ Unexpected model failures propagate unchanged.
113
+ """
114
+ raw = read_toml(path).unwrap()
115
+ try:
116
+ return Ok(config_class.model_validate(raw))
117
+ except pydantic.ValidationError as exc:
118
+ return Err([errors.ValidationFailed(path=path, reason=str(exc)).with_cause(exc)])
119
+
120
+
121
+ def create_config(path: ProjectConfigPath, text: str) -> Result[None]:
122
+ """Create a starter from verbatim UTF-8 text, without overwriting any target.
123
+
124
+ The parent must already exist. No discovery, path normalization, directory
125
+ creation, or TOML validation occurs. Exclusive creation returns ``AlreadyExists``
126
+ for an existing target, including a symlink. Encoding and other filesystem
127
+ failures return ``Unwritable``. Failures are carried in error lists; success is
128
+ ``Ok(None)``. Failed writes may leave a partial new file.
129
+ """
130
+ try:
131
+ data = text.encode("utf-8")
132
+ with path.open("xb") as stream:
133
+ stream.write(data)
134
+ except FileExistsError as exc:
135
+ return Err([errors.AlreadyExists(path=path, reason=str(exc)).with_cause(exc)])
136
+ except (OSError, UnicodeEncodeError) as exc:
137
+ return Err([errors.Unwritable(path=path, reason=str(exc)).with_cause(exc)])
138
+ return Ok(None)
139
+
140
+
141
+ def create_config_from_template(path: ProjectConfigPath, *, package: str, template: str) -> Result[None]:
142
+ """Read a packaged UTF-8 starter and create it at the supplied target path.
143
+
144
+ Read ``fixtures/<template>`` from ``package`` before attempting creation.
145
+ Filesystem and decoding failures return ``Err([TemplateUnreadable(...)])``
146
+ with the target path, template name, reason, and private original cause.
147
+ Unexpected resource errors propagate. Otherwise, pass the loaded text to
148
+ ``create_config`` and return its result unchanged, including write failures.
149
+ No target selection, path resolution, or configuration validation occurs.
150
+ """
151
+ try:
152
+ text = importlib.resources.files(package).joinpath("fixtures", template).read_text(encoding="utf-8")
153
+ except (OSError, UnicodeDecodeError) as exc:
154
+ return Err([errors.TemplateUnreadable(path=path, template=template, reason=str(exc)).with_cause(exc)])
155
+ return create_config(path, text)
156
+
157
+
158
+ @unwrap_to_error
159
+ def initialize_config(
160
+ filename: str,
161
+ *,
162
+ package: str,
163
+ template: str,
164
+ cwd: PathInput,
165
+ path: ProjectConfigPath | None = None,
166
+ ) -> Result[ProjectConfigPath]:
167
+ """Resolve an initialization target and create the selected packaged starter.
168
+
169
+ Return the resolved path only after successful creation. Resolution, template
170
+ reading, and creation failures propagate unchanged, stopping the sequence.
171
+ No configuration loading, schema validation, or workspace construction occurs.
172
+ """
173
+ selected_path = resolve_init_config_path(filename, path=path, cwd=cwd).unwrap()
174
+ create_config_from_template(selected_path, package=package, template=template).unwrap()
175
+ return Ok(selected_path)
@@ -0,0 +1,87 @@
1
+ """Project preparation and scoped settings, independent of CLI frameworks."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+
6
+ import pydantic
7
+
8
+ from llm_tool_cli.config import errors
9
+ from llm_tool_cli.config.files import initialize_config, load_config, locate_config
10
+ from llm_tool_cli.core import settings
11
+ from llm_tool_cli.core.context import ContextValue
12
+ from llm_tool_cli.core.entities import BaseEntity
13
+ from llm_tool_cli.core.result import Err, Result
14
+ from llm_tool_cli.paths import PathInput, ProjectConfigPath, ProjectRootPath
15
+
16
+
17
+ class ProjectConfig(BaseEntity):
18
+ config_path: ProjectConfigPath
19
+
20
+ @pydantic.field_validator("config_path")
21
+ @classmethod
22
+ def validate_config_path(cls, value: ProjectConfigPath) -> ProjectConfigPath:
23
+ if not value.is_absolute():
24
+ raise ValueError("The active configuration path must be absolute.")
25
+ return value
26
+
27
+ @property
28
+ def project_dir(self) -> ProjectRootPath:
29
+ return ProjectRootPath(self.config_path.parent)
30
+
31
+
32
+ project_config = ContextValue[ProjectConfig]("project_config")
33
+
34
+
35
+ @contextmanager
36
+ def load_project[T: pydantic.BaseModel](
37
+ config_class: type[T], *, cwd: PathInput, path: ProjectConfigPath | None = None
38
+ ) -> Iterator[Result[T]]:
39
+ """Select and validate a configuration, binding its project for the scope.
40
+
41
+ Failures yield an error result without replacing the active project. The
42
+ caller owns the scope's lifetime and application-specific configuration use.
43
+ """
44
+ filename = settings.static_config.get().config_filename
45
+ if filename is None:
46
+ raise errors.ConfigFilenameNotSet()
47
+ selected = locate_config(filename, path=path, cwd=cwd)
48
+ with _load_project_config(selected, config_class) as result:
49
+ yield result
50
+
51
+
52
+ @contextmanager
53
+ def initialize_project[T: pydantic.BaseModel](
54
+ config_class: type[T],
55
+ *,
56
+ package: str,
57
+ template: str,
58
+ cwd: PathInput,
59
+ path: ProjectConfigPath | None = None,
60
+ ) -> Iterator[Result[T]]:
61
+ """Create and validate a starter, binding its project for the scope.
62
+
63
+ Creation never searches parent directories or overwrites an existing file.
64
+ A created file remains on disk if subsequent validation fails.
65
+ """
66
+ filename = settings.static_config.get().config_filename
67
+ if filename is None:
68
+ raise errors.ConfigFilenameNotSet()
69
+ selected = initialize_config(filename, package=package, template=template, cwd=cwd, path=path)
70
+ with _load_project_config(selected, config_class) as result:
71
+ yield result
72
+
73
+
74
+ @contextmanager
75
+ def _load_project_config[T: pydantic.BaseModel](
76
+ selected: Result[ProjectConfigPath], config_class: type[T]
77
+ ) -> Iterator[Result[T]]:
78
+ if selected.is_err():
79
+ yield Err(selected.unwrap_err())
80
+ return
81
+ path = selected.unwrap()
82
+ loaded = load_config(path, config_class)
83
+ if loaded.is_err():
84
+ yield loaded
85
+ return
86
+ with project_config.scope(ProjectConfig(config_path=path)):
87
+ yield loaded
@@ -0,0 +1,14 @@
1
+ """Effective runtime settings, independent of their input source."""
2
+
3
+ from llm_tool_cli.core.context import ContextValue
4
+ from llm_tool_cli.core.entities import BaseEntity
5
+ from llm_tool_cli.protocol import Protocol
6
+
7
+
8
+ class RuntimeConfig(BaseEntity):
9
+ """Complete settings for an execution, including commands without a project."""
10
+
11
+ protocol: Protocol
12
+
13
+
14
+ runtime_config = ContextValue[RuntimeConfig]("runtime_config")
File without changes
@@ -0,0 +1,34 @@
1
+ """Scoped application, runtime, and project settings for consumer tests."""
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from llm_tool_cli.config.project import ProjectConfig, project_config
9
+ from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
10
+ from llm_tool_cli.core.settings import ConfigFilename, StaticConfig, ToolLabel, static_config
11
+ from llm_tool_cli.paths import ProjectConfigPath
12
+ from llm_tool_cli.protocol import Protocol
13
+
14
+
15
+ @pytest.fixture
16
+ def project_static_settings() -> Iterator[StaticConfig]:
17
+ value = StaticConfig(tool_label=ToolLabel("TEST"), config_filename=ConfigFilename("tool.toml"))
18
+ with static_config.scope(value):
19
+ yield value
20
+
21
+
22
+ @pytest.fixture
23
+ def runtime_settings() -> Iterator[RuntimeConfig]:
24
+ value = RuntimeConfig(protocol=Protocol.human)
25
+ with runtime_config.scope(value):
26
+ yield value
27
+
28
+
29
+ @pytest.fixture
30
+ def project_settings(tmp_path: Path) -> Iterator[ProjectConfig]:
31
+ """Bind a project rooted at the test directory without filesystem loading."""
32
+ value = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "config.toml"))
33
+ with project_config.scope(value):
34
+ yield value