kiwimatecoder 0.1.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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,67 @@
1
+ """list_dir tool: list the entries of a directory in the workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from kiwimatecoder.session import Session
8
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
9
+ from kiwimatecoder.tools.paths import (
10
+ PathError,
11
+ get_workspace_ignore,
12
+ resolve_for_read,
13
+ )
14
+
15
+ MAX_ENTRIES = 500
16
+
17
+
18
+ def _list_dir(args: dict[str, Any], session: Session) -> ToolResult:
19
+ path = str(args.get("path", ".") or ".")
20
+ try:
21
+ resolved = resolve_for_read(
22
+ path,
23
+ session.workspace_root,
24
+ trusted=bool(getattr(session, "trusted_workspace", False)),
25
+ )
26
+ except PathError as exc:
27
+ return ToolResult.error(str(exc))
28
+
29
+ if not resolved.exists():
30
+ return ToolResult.error(f"Directory not found: {path}")
31
+ if not resolved.is_dir():
32
+ return ToolResult.error(f"'{path}' is not a directory, use read_file")
33
+
34
+ ignore = get_workspace_ignore(session.workspace_root)
35
+ entries: list[str] = []
36
+ for child in sorted(resolved.iterdir(), key=lambda p: (p.is_file(), p.name)):
37
+ is_dir = child.is_dir()
38
+ if ignore.is_ignored(child, is_dir=is_dir):
39
+ continue
40
+ suffix = "/" if is_dir else ""
41
+ entries.append(child.name + suffix)
42
+ if len(entries) >= MAX_ENTRIES:
43
+ entries.append(f"... [truncated at {MAX_ENTRIES} entries]")
44
+ break
45
+
46
+ if not entries:
47
+ return ToolResult(content="[empty directory]")
48
+ return ToolResult(content="\n".join(entries))
49
+
50
+
51
+ list_dir_tool = FunctionTool(
52
+ name="list_dir",
53
+ description="List the files and subdirectories of a directory in the workspace.",
54
+ parameters={
55
+ "type": "object",
56
+ "properties": {
57
+ "path": {
58
+ "type": "string",
59
+ "description": (
60
+ "Directory path relative to the workspace root. "
61
+ + "Defaults to the workspace root."
62
+ ),
63
+ },
64
+ },
65
+ },
66
+ func=_list_dir,
67
+ )
@@ -0,0 +1,254 @@
1
+ """Read-only LSP tools: diagnostics, definitions, and references.
2
+
3
+ These tools never mutate the workspace (``writes=False, runs=False``). They
4
+ report a clear error when LSP is disabled or no configured server matches the
5
+ file, and they never start a server under an extension the presets/overrides do
6
+ not claim.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from kiwimatecoder import config, lsp
15
+ from kiwimatecoder.session import Session
16
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
17
+ from kiwimatecoder.tools.paths import PathError, display_path, resolve_in_workspace
18
+
19
+ MAX_DIAGNOSTIC_FILES = 5
20
+
21
+
22
+ def _disabled_error() -> ToolResult:
23
+ return ToolResult.error(lsp.LSP_DISABLED_MESSAGE)
24
+
25
+
26
+ def format_diagnostic(path: str, diagnostic: dict[str, Any]) -> str:
27
+ line = (
28
+ f"{path}:{diagnostic['line']}:{diagnostic['character']} "
29
+ f"{diagnostic['severity']} {diagnostic['message']}"
30
+ )
31
+ source = diagnostic.get("source")
32
+ if source:
33
+ line += f" ({source})"
34
+ return line.rstrip()
35
+
36
+
37
+ def _format_location(location: dict[str, Any], session: Session) -> str:
38
+ raw_path = str(location.get("path") or "")
39
+ display = raw_path
40
+ if raw_path:
41
+ try:
42
+ display = display_path(Path(raw_path), session.workspace_root)
43
+ except (OSError, ValueError):
44
+ display = raw_path
45
+ return f"{display}:{location.get('line')}:{location.get('character')}"
46
+
47
+
48
+ def _parse_position(args: dict[str, Any]) -> tuple[int, int] | ToolResult:
49
+ raw_line = args.get("line")
50
+ raw_character = args.get("character")
51
+ if raw_line is None or raw_character is None:
52
+ return ToolResult.error("'line' and 'character' are required (1-based)")
53
+ try:
54
+ line = int(raw_line)
55
+ character = int(raw_character)
56
+ except (TypeError, ValueError):
57
+ return ToolResult.error("'line' and 'character' must be integers (1-based)")
58
+ if line < 1 or character < 1:
59
+ return ToolResult.error("'line' and 'character' are 1-based and must be >= 1")
60
+ return line, character
61
+
62
+
63
+ def _include_declaration(args: dict[str, Any]) -> bool:
64
+ value = args.get("include_declaration")
65
+ if value is None:
66
+ return True
67
+ if isinstance(value, bool):
68
+ return value
69
+ return str(value).strip().lower() not in {"false", "0", "no", "off"}
70
+
71
+
72
+ def _lsp_diagnostics(args: dict[str, Any], session: Session) -> ToolResult:
73
+ if not config.get_lsp()["enabled"]:
74
+ return _disabled_error()
75
+ raw_path = str(args.get("path") or "").strip()
76
+ if raw_path:
77
+ targets = [raw_path]
78
+ else:
79
+ targets = list(session.touched_files)[-MAX_DIAGNOSTIC_FILES:]
80
+ if not targets:
81
+ return ToolResult.error(
82
+ "No file given and none have been touched yet; pass a path."
83
+ )
84
+
85
+ manager = lsp.ensure_manager(root=session.workspace_root)
86
+ lines: list[str] = []
87
+ failures: list[str] = []
88
+ for target in targets:
89
+ try:
90
+ resolved = resolve_in_workspace(target, session.workspace_root)
91
+ except PathError as exc:
92
+ failures.append(str(exc))
93
+ continue
94
+ display = display_path(resolved, session.workspace_root)
95
+ try:
96
+ diagnostics = manager.diagnostics_for(resolved)
97
+ except lsp.LspError as exc:
98
+ failures.append(f"{display}: {exc}")
99
+ continue
100
+ lines.extend(format_diagnostic(display, item) for item in diagnostics)
101
+
102
+ if lines:
103
+ return ToolResult(content="\n".join(lines))
104
+ if failures:
105
+ return ToolResult.error("; ".join(failures[:3]))
106
+ return ToolResult(content="No diagnostics.")
107
+
108
+
109
+ def _lsp_definition(args: dict[str, Any], session: Session) -> ToolResult:
110
+ if not config.get_lsp()["enabled"]:
111
+ return _disabled_error()
112
+ path = str(args.get("path") or "").strip()
113
+ if not path:
114
+ return ToolResult.error("'path' is required")
115
+ position = _parse_position(args)
116
+ if isinstance(position, ToolResult):
117
+ return position
118
+ line, character = position
119
+ try:
120
+ resolved = resolve_in_workspace(path, session.workspace_root)
121
+ except PathError as exc:
122
+ return ToolResult.error(str(exc))
123
+
124
+ manager = lsp.ensure_manager(root=session.workspace_root)
125
+ try:
126
+ locations = manager.definition(resolved, line, character)
127
+ except lsp.LspError as exc:
128
+ return ToolResult.error(str(exc))
129
+ if not locations:
130
+ return ToolResult(
131
+ content=f"No definition found at {path}:{line}:{character}."
132
+ )
133
+ return ToolResult(
134
+ content="\n".join(_format_location(item, session) for item in locations)
135
+ )
136
+
137
+
138
+ def _lsp_references(args: dict[str, Any], session: Session) -> ToolResult:
139
+ if not config.get_lsp()["enabled"]:
140
+ return _disabled_error()
141
+ path = str(args.get("path") or "").strip()
142
+ if not path:
143
+ return ToolResult.error("'path' is required")
144
+ position = _parse_position(args)
145
+ if isinstance(position, ToolResult):
146
+ return position
147
+ line, character = position
148
+ try:
149
+ resolved = resolve_in_workspace(path, session.workspace_root)
150
+ except PathError as exc:
151
+ return ToolResult.error(str(exc))
152
+
153
+ manager = lsp.ensure_manager(root=session.workspace_root)
154
+ try:
155
+ locations = manager.references(
156
+ resolved,
157
+ line,
158
+ character,
159
+ include_declaration=_include_declaration(args),
160
+ )
161
+ except lsp.LspError as exc:
162
+ return ToolResult.error(str(exc))
163
+ if not locations:
164
+ return ToolResult(
165
+ content=f"No references found at {path}:{line}:{character}."
166
+ )
167
+ return ToolResult(
168
+ content="\n".join(_format_location(item, session) for item in locations)
169
+ )
170
+
171
+
172
+ lsp_diagnostics_tool = FunctionTool(
173
+ name="lsp_diagnostics",
174
+ description=(
175
+ "Get language-server diagnostics (errors, warnings) for a file, or for "
176
+ "the most recently touched files when no path is given. Requires LSP to "
177
+ "be enabled and a matching server installed. Use it after edits to "
178
+ "check the code compiles/type-checks."
179
+ ),
180
+ parameters={
181
+ "type": "object",
182
+ "properties": {
183
+ "path": {
184
+ "type": "string",
185
+ "description": "File to check, relative to the workspace root.",
186
+ },
187
+ },
188
+ },
189
+ func=_lsp_diagnostics,
190
+ writes=False,
191
+ runs=False,
192
+ )
193
+
194
+ lsp_definition_tool = FunctionTool(
195
+ name="lsp_definition",
196
+ description=(
197
+ "Find where the symbol at a 1-based line/character is defined using the "
198
+ "language server. Returns one location per line."
199
+ ),
200
+ parameters={
201
+ "type": "object",
202
+ "properties": {
203
+ "path": {
204
+ "type": "string",
205
+ "description": "File to inspect, relative to the workspace root.",
206
+ },
207
+ "line": {
208
+ "type": "integer",
209
+ "description": "1-based line number of the symbol.",
210
+ },
211
+ "character": {
212
+ "type": "integer",
213
+ "description": "1-based column number of the symbol.",
214
+ },
215
+ },
216
+ "required": ["path", "line", "character"],
217
+ },
218
+ func=_lsp_definition,
219
+ writes=False,
220
+ runs=False,
221
+ )
222
+
223
+ lsp_references_tool = FunctionTool(
224
+ name="lsp_references",
225
+ description=(
226
+ "Find every reference to the symbol at a 1-based line/character using "
227
+ "the language server. Returns one location per line."
228
+ ),
229
+ parameters={
230
+ "type": "object",
231
+ "properties": {
232
+ "path": {
233
+ "type": "string",
234
+ "description": "File to inspect, relative to the workspace root.",
235
+ },
236
+ "line": {
237
+ "type": "integer",
238
+ "description": "1-based line number of the symbol.",
239
+ },
240
+ "character": {
241
+ "type": "integer",
242
+ "description": "1-based column number of the symbol.",
243
+ },
244
+ "include_declaration": {
245
+ "type": "boolean",
246
+ "description": "Include the declaration itself (default true).",
247
+ },
248
+ },
249
+ "required": ["path", "line", "character"],
250
+ },
251
+ func=_lsp_references,
252
+ writes=False,
253
+ runs=False,
254
+ )
@@ -0,0 +1,115 @@
1
+ """remember (approval-gated) and recall (read-only) persistent-memory tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from kiwimatecoder import config
8
+ from kiwimatecoder import memory as memory_module
9
+ from kiwimatecoder.redaction import redact
10
+ from kiwimatecoder.session import Session
11
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
12
+
13
+
14
+ def _remember(args: dict[str, Any], session: Session) -> ToolResult:
15
+ content = str(args.get("content") or "").strip()
16
+ if not content:
17
+ return ToolResult.error("'content' is required")
18
+ scope = str(args.get("scope") or "project").strip().lower()
19
+ try:
20
+ path = memory_module.append_memory(scope, session.workspace_root, content)
21
+ except ValueError as exc:
22
+ return ToolResult.error(str(exc))
23
+ return ToolResult(content=f"Remembered ({scope}): {path}\n- {content}")
24
+
25
+
26
+ def _recall(args: dict[str, Any], session: Session) -> ToolResult:
27
+ query = str(args.get("query") or "").strip()
28
+ settings = config.get_memory()
29
+ cap = int(settings["max_bytes"])
30
+ sections: list[str] = []
31
+ for scope in memory_module.SCOPES:
32
+ text = memory_module.read_memory(scope, session.workspace_root, cap)
33
+ if not text:
34
+ continue
35
+ if query:
36
+ text = "\n".join(
37
+ line
38
+ for line in text.splitlines()
39
+ if query.lower() in line.lower()
40
+ )
41
+ if not text.strip():
42
+ continue
43
+ path = memory_module.memory_path(scope, session.workspace_root)
44
+ sections.append(f"{scope} memory ({path}):\n{text}")
45
+ if not sections:
46
+ if query:
47
+ return ToolResult(content=f"No memory entries match {query!r}.")
48
+ return ToolResult(content="No memory stored yet.")
49
+ body = "\n\n".join(sections)
50
+ if len(body) > cap:
51
+ body = body[:cap] + "\n[truncated]"
52
+ return ToolResult(content=body)
53
+
54
+
55
+ def preview(args: dict[str, Any], session: Session) -> str:
56
+ """Show the target file and bullet for the approval prompt."""
57
+ scope = str(args.get("scope") or "project").strip().lower()
58
+ try:
59
+ path = memory_module.memory_path(scope, session.workspace_root)
60
+ except ValueError as exc:
61
+ return str(exc)
62
+ content = str(args.get("content") or "").strip()
63
+ if not content:
64
+ return f"Append to memory {path}: (empty; will be rejected)"
65
+ return f"Append to memory {path}:\n- {redact(content)}"
66
+
67
+
68
+ remember_tool = FunctionTool(
69
+ name="remember",
70
+ description=(
71
+ "Persist a durable fact about the user or this project so it survives "
72
+ "future sessions. Use scope='project' for workspace-specific facts and "
73
+ "scope='user' for preferences that apply everywhere. Requires approval; "
74
+ "never store secrets, credentials, or API keys."
75
+ ),
76
+ parameters={
77
+ "type": "object",
78
+ "properties": {
79
+ "content": {
80
+ "type": "string",
81
+ "description": "The fact to remember, as a short statement.",
82
+ },
83
+ "scope": {
84
+ "type": "string",
85
+ "enum": list(memory_module.SCOPES),
86
+ "description": (
87
+ "Where to store it: project (this workspace) or user "
88
+ "(all workspaces). Defaults to project."
89
+ ),
90
+ },
91
+ },
92
+ "required": ["content"],
93
+ },
94
+ func=_remember,
95
+ writes=True,
96
+ )
97
+
98
+ recall_tool = FunctionTool(
99
+ name="recall",
100
+ description=(
101
+ "Read persistent memory for the project and user. Pass a query to "
102
+ "filter entries by case-insensitive substring. Read-only, so it is "
103
+ "available in plan mode."
104
+ ),
105
+ parameters={
106
+ "type": "object",
107
+ "properties": {
108
+ "query": {
109
+ "type": "string",
110
+ "description": "Optional case-insensitive filter.",
111
+ },
112
+ },
113
+ },
114
+ func=_recall,
115
+ )
@@ -0,0 +1,234 @@
1
+ """Workspace path resolution and sandboxing.
2
+
3
+ Every file-touching tool resolves its path through :func:`resolve_in_workspace`,
4
+ which rejects paths that escape the workspace root (via ``..`` or symlinks).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ from pathlib import Path
12
+
13
+ DEFAULT_SKIP_DIRS: set[str] = {
14
+ ".git",
15
+ "__pycache__",
16
+ ".venv",
17
+ "venv",
18
+ "env",
19
+ "node_modules",
20
+ ".mypy_cache",
21
+ ".pytest_cache",
22
+ ".ruff_cache",
23
+ ".tox",
24
+ ".eggs",
25
+ }
26
+
27
+
28
+ class WorkspaceIgnore:
29
+ """Evaluates whether paths should be ignored based on default skip dirs and .gitignore."""
30
+
31
+ workspace_root: Path
32
+ rules: list[tuple[bool, str, bool]]
33
+
34
+ def __init__(
35
+ self,
36
+ workspace_root: Path,
37
+ rules: list[tuple[bool, str, bool]] | None = None,
38
+ ) -> None:
39
+ self.workspace_root = workspace_root.resolve()
40
+ self.rules = (
41
+ rules if rules is not None else self._load_rules()
42
+ )
43
+
44
+ def _load_rules(self) -> list[tuple[bool, str, bool]]:
45
+ gitignore_path = self.workspace_root / ".gitignore"
46
+ if not gitignore_path.is_file():
47
+ return []
48
+ rules: list[tuple[bool, str, bool]] = []
49
+ try:
50
+ content = gitignore_path.read_text(encoding="utf-8", errors="replace")
51
+ except OSError:
52
+ return []
53
+
54
+ for line in content.splitlines():
55
+ line = line.strip()
56
+ if not line or line.startswith("#"):
57
+ continue
58
+ is_neg = line.startswith("!")
59
+ if is_neg:
60
+ line = line[1:].strip()
61
+ if not line:
62
+ continue
63
+ dir_only = line.endswith("/")
64
+ if dir_only:
65
+ line = line[:-1]
66
+ if line.startswith("/"):
67
+ line = line[1:]
68
+ rules.append((is_neg, line, dir_only))
69
+ return rules
70
+
71
+ def is_ignored(self, path: Path, is_dir: bool = False) -> bool:
72
+ """Check if path should be ignored."""
73
+ try:
74
+ resolved = path.resolve()
75
+ except OSError:
76
+ resolved = path
77
+
78
+ parts = resolved.parts
79
+ if any(part in DEFAULT_SKIP_DIRS for part in parts):
80
+ return True
81
+
82
+ if not self.rules:
83
+ return False
84
+
85
+ try:
86
+ rel = resolved.relative_to(self.workspace_root).as_posix()
87
+ except ValueError:
88
+ rel = path.as_posix()
89
+
90
+ ignored = False
91
+ name = resolved.name
92
+ for is_neg, pat, dir_only in self.rules:
93
+ matches = False
94
+ if dir_only:
95
+ if is_dir:
96
+ if "/" in pat:
97
+ matches = fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(
98
+ rel, f"{pat}/*"
99
+ )
100
+ else:
101
+ matches = (
102
+ fnmatch.fnmatch(name, pat)
103
+ or fnmatch.fnmatch(rel, f"**/{pat}")
104
+ or fnmatch.fnmatch(rel, f"{pat}/*")
105
+ )
106
+ else:
107
+ if "/" in pat:
108
+ matches = fnmatch.fnmatch(
109
+ rel, f"{pat}/*"
110
+ ) or fnmatch.fnmatch(rel, f"**/{pat}/*")
111
+ else:
112
+ matches = (
113
+ fnmatch.fnmatch(rel, f"{pat}/*")
114
+ or fnmatch.fnmatch(rel, f"**/{pat}/*")
115
+ or (pat in resolved.parts)
116
+ )
117
+ else:
118
+ if "/" in pat:
119
+ matches = fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(
120
+ rel, f"{pat}/*"
121
+ )
122
+ else:
123
+ matches = (
124
+ fnmatch.fnmatch(name, pat)
125
+ or fnmatch.fnmatch(rel, f"**/{pat}")
126
+ or fnmatch.fnmatch(rel, f"{pat}/*")
127
+ )
128
+
129
+ if matches:
130
+ ignored = not is_neg
131
+
132
+ return ignored
133
+
134
+
135
+ _IGNORE_CACHE: dict[str, tuple[float, WorkspaceIgnore]] = {}
136
+
137
+
138
+ def get_workspace_ignore(workspace_root: Path) -> WorkspaceIgnore:
139
+ """Return a cached WorkspaceIgnore instance for the given root."""
140
+ root = workspace_root.resolve()
141
+ key = str(root)
142
+ gi_path = root / ".gitignore"
143
+ try:
144
+ mtime = gi_path.stat().st_mtime if gi_path.exists() else 0.0
145
+ except OSError:
146
+ mtime = 0.0
147
+ cached = _IGNORE_CACHE.get(key)
148
+ if cached is not None and cached[0] == mtime:
149
+ return cached[1]
150
+ ignore = WorkspaceIgnore(root)
151
+ _IGNORE_CACHE[key] = (mtime, ignore)
152
+ return ignore
153
+
154
+
155
+ class PathError(ValueError):
156
+ """Raised when a path escapes the workspace root."""
157
+
158
+
159
+ def atomic_write_text(path: Path, content: str) -> None:
160
+ """Write ``content`` to ``path`` atomically as UTF-8.
161
+
162
+ Writes to a sibling temp file then ``os.replace``s it into place, so a
163
+ crash mid-write cannot truncate the destination. Parent directories must
164
+ already exist.
165
+ """
166
+ tmp = path.with_name(path.name + ".kiwi.tmp")
167
+ try:
168
+ tmp.write_text(content, encoding="utf-8")
169
+ os.replace(tmp, path)
170
+ except OSError:
171
+ try:
172
+ tmp.unlink()
173
+ except OSError:
174
+ pass
175
+ raise
176
+
177
+
178
+ def resolve_in_workspace(path: str, workspace_root: Path) -> Path:
179
+ """Resolve ``path`` against ``workspace_root`` and ensure it stays inside.
180
+
181
+ Symlinks are resolved before the containment check so they cannot be used
182
+ to escape the sandbox.
183
+ """
184
+ root = workspace_root.resolve()
185
+ candidate = Path(path)
186
+ if not candidate.is_absolute():
187
+ candidate = root / candidate
188
+ resolved = candidate.resolve()
189
+ if resolved != root and not resolved.is_relative_to(root):
190
+ raise PathError(
191
+ f"Path '{path}' is outside the workspace root ({root}). "
192
+ + "Access is restricted to the current project directory."
193
+ )
194
+ return resolved
195
+
196
+
197
+ def resolve_for_read(
198
+ path: str, workspace_root: Path, trusted: bool = False
199
+ ) -> Path:
200
+ """Resolve a read-only path, allowing escapes only in trusted mode.
201
+
202
+ Trusted workspaces still prevent writes outside the root because only
203
+ read-only tools call this helper.
204
+ """
205
+ if not trusted:
206
+ return resolve_in_workspace(path, workspace_root)
207
+ candidate = Path(path).expanduser()
208
+ if not candidate.is_absolute():
209
+ candidate = workspace_root / candidate
210
+ return candidate.resolve()
211
+
212
+
213
+ def display_path(path: Path, workspace_root: Path) -> str:
214
+ """Return a path relative to the workspace root for display, if possible."""
215
+ root = workspace_root.resolve()
216
+ try:
217
+ return str(path.resolve().relative_to(root))
218
+ except ValueError:
219
+ return str(path)
220
+
221
+
222
+ def workspace_ref(path: Path, workspace_root: Path) -> str:
223
+ """Return the portable form of a workspace-relative path, for storing in the session.
224
+
225
+ Unlike :func:`display_path` this always uses forward slashes, so entries in
226
+ ``touched_files`` / ``context_files`` mean the same thing in a session JSON saved
227
+ on Windows and loaded on POSIX (or vice versa). A path outside the workspace is
228
+ returned unchanged.
229
+ """
230
+ root = workspace_root.resolve()
231
+ try:
232
+ return path.resolve().relative_to(root).as_posix()
233
+ except ValueError:
234
+ return str(path)