ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,380 @@
1
+ """Tool search helper for deferred tool loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter, defaultdict
8
+ from difflib import SequenceMatcher
9
+ from typing import Any, AsyncGenerator, Dict, List, Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from ripperdoc.core.tool import (
14
+ Tool,
15
+ ToolOutput,
16
+ ToolResult,
17
+ ToolUseContext,
18
+ ToolUseExample,
19
+ ValidationResult,
20
+ build_tool_description,
21
+ )
22
+ from ripperdoc.utils.log import get_logger
23
+
24
+
25
+ logger = get_logger()
26
+
27
+
28
+ class ToolSearchInput(BaseModel):
29
+ """Input for tool search and activation."""
30
+
31
+ query: Optional[str] = Field(
32
+ default=None,
33
+ description="Search phrase describing the capability or tool name you need.",
34
+ )
35
+ names: Optional[List[str]] = Field(
36
+ default=None,
37
+ description="Explicit tool names to activate. Use after seeing search results.",
38
+ )
39
+ max_results: int = Field(
40
+ default=5,
41
+ ge=1,
42
+ le=25,
43
+ description="Maximum number of matching tools to return.",
44
+ )
45
+ include_active: bool = Field(
46
+ default=False,
47
+ description="Include already-active tools in the search results.",
48
+ )
49
+ auto_activate: bool = Field(
50
+ default=True,
51
+ description="If true, activate the returned matches so they can be called immediately.",
52
+ )
53
+ include_examples: bool = Field(
54
+ default=False,
55
+ description="Include input examples in the returned tool descriptions.",
56
+ )
57
+ model_config = ConfigDict(extra="ignore")
58
+
59
+
60
+ class ToolSearchMatch(BaseModel):
61
+ """Metadata about a matching tool."""
62
+
63
+ name: str
64
+ user_facing_name: Optional[str] = None
65
+ description: Optional[str] = None
66
+ active: bool = False
67
+ deferred: bool = False
68
+
69
+
70
+ class ToolSearchOutput(BaseModel):
71
+ """Search results and activation summary."""
72
+
73
+ matches: List[ToolSearchMatch] = Field(default_factory=list)
74
+ activated: List[str] = Field(default_factory=list)
75
+ missing: List[str] = Field(default_factory=list)
76
+ deferred_remaining: int = 0
77
+
78
+
79
+ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
80
+ """Search across available tools and activate deferred ones on demand."""
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ return "ToolSearch"
85
+
86
+ async def description(self) -> str:
87
+ return (
88
+ "Search available tools by name or description, returning a small set of candidates. "
89
+ "Use this when you suspect a capability exists but is not currently active. "
90
+ "Matching deferred tools are automatically activated so you can call them next."
91
+ )
92
+
93
+ @property
94
+ def input_schema(self) -> type[ToolSearchInput]:
95
+ return ToolSearchInput
96
+
97
+ def input_examples(self) -> List[ToolUseExample]:
98
+ return [
99
+ ToolUseExample(
100
+ description="Search for notebook-related tools and activate top results",
101
+ example={"query": "notebook", "max_results": 3},
102
+ ),
103
+ ToolUseExample(
104
+ description="Activate a known tool by name",
105
+ example={"names": ["mcp__search__query"], "auto_activate": True},
106
+ ),
107
+ ]
108
+
109
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
110
+ return (
111
+ "Search for a tool by providing a short description (e.g., 'query database', 'render notebook'). "
112
+ "Use names to activate tools you've already discovered. "
113
+ "Keep queries concise to retrieve the 3-5 most relevant tools."
114
+ )
115
+
116
+ def is_read_only(self) -> bool:
117
+ return True
118
+
119
+ def is_concurrency_safe(self) -> bool:
120
+ return True
121
+
122
+ def needs_permissions(self, input_data: Optional[ToolSearchInput] = None) -> bool: # noqa: ARG002
123
+ return False
124
+
125
+ async def validate_input(
126
+ self,
127
+ input_data: ToolSearchInput,
128
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
129
+ ) -> ValidationResult:
130
+ if not (input_data.query or input_data.names):
131
+ return ValidationResult(
132
+ result=False,
133
+ message="Provide a search query or explicit tool names to load.",
134
+ )
135
+ return ValidationResult(result=True)
136
+
137
+ def render_result_for_assistant(self, output: ToolSearchOutput) -> str:
138
+ lines = []
139
+ if output.activated:
140
+ lines.append(f"Activated: {', '.join(sorted(output.activated))}")
141
+ if output.matches:
142
+ lines.append("Matches:")
143
+ for match in output.matches:
144
+ status = []
145
+ if match.active:
146
+ status.append("active")
147
+ if match.deferred:
148
+ status.append("deferred")
149
+ status_text = f" ({', '.join(status)})" if status else ""
150
+ lines.append(f"- {match.name}{status_text}: {match.description or ''}".strip())
151
+ if output.missing:
152
+ lines.append(f"Unknown tool names: {', '.join(sorted(output.missing))}")
153
+ if output.deferred_remaining:
154
+ lines.append(f"Deferred tools remaining: {output.deferred_remaining}")
155
+ return "\n".join(lines) if lines else "No matching tools found."
156
+
157
+ def render_tool_use_message(self, input_data: ToolSearchInput, verbose: bool = False) -> str:
158
+ detail = f'"{input_data.query}"' if input_data.query else ", ".join(input_data.names or [])
159
+ return f"Search tools for {detail}"
160
+
161
+ async def _search(
162
+ self,
163
+ query: str,
164
+ registry: Any,
165
+ *,
166
+ include_active: bool,
167
+ include_examples: bool,
168
+ limit: int,
169
+ ) -> List[Dict[str, Any]]:
170
+ """Regex + BM25-style search over tool metadata."""
171
+ normalized = (query or "").strip().lower()
172
+ if not normalized:
173
+ return []
174
+
175
+ regex: Optional[re.Pattern[str]] = None
176
+ if normalized.startswith("/") and normalized.endswith("/") and len(normalized) > 2:
177
+ try:
178
+ regex = re.compile(normalized[1:-1], re.IGNORECASE)
179
+ except re.error:
180
+ regex = None
181
+ logger.exception("[tool_search] Invalid regex search query", extra={"query": query})
182
+
183
+ def _tokenize(text: str) -> List[str]:
184
+ return re.findall(r"[a-z0-9]+", text.lower())
185
+
186
+ corpus: List[tuple[str, Any, List[str], int, str]] = []
187
+ for name, tool in registry.iter_named_tools():
188
+ try:
189
+ description = await build_tool_description(
190
+ tool, include_examples=include_examples, max_examples=2
191
+ )
192
+ except (OSError, RuntimeError, ValueError, TypeError, AttributeError, KeyError) as exc:
193
+ description = ""
194
+ logger.warning(
195
+ "[tool_search] Failed to build tool description: %s: %s",
196
+ type(exc).__name__, exc,
197
+ extra={"tool_name": getattr(tool, "name", None)},
198
+ )
199
+ doc_text = " ".join([name, tool.user_facing_name(), description])
200
+ tokens = _tokenize(doc_text)
201
+ corpus.append((name, tool, tokens, len(tokens), description))
202
+
203
+ if not corpus:
204
+ return []
205
+
206
+ avg_len = sum(doc_len for _, _, _, doc_len, _ in corpus) / len(corpus)
207
+ query_terms = _tokenize(normalized)
208
+ df: Dict[str, int] = defaultdict(int)
209
+ for _, _, tokens, _, _ in corpus:
210
+ seen_terms = set(tokens)
211
+ for term in query_terms:
212
+ if term in seen_terms:
213
+ df[term] += 1
214
+
215
+ k1 = 1.5
216
+ b = 0.75
217
+
218
+ def _bm25_score(tokens: List[str], doc_len: int) -> float:
219
+ score = 0.0
220
+ counts = Counter(tokens)
221
+ for term in query_terms:
222
+ if term not in counts:
223
+ continue
224
+ tf = counts[term]
225
+ df_term = df.get(term, 0) or 1
226
+ idf = math.log((len(corpus) - df_term + 0.5) / (df_term + 0.5) + 1)
227
+ numerator = tf * (k1 + 1)
228
+ denom = tf + k1 * (1 - b + b * (doc_len / (avg_len or 1)))
229
+ score += idf * (numerator / denom)
230
+ return score
231
+
232
+ results: List[Dict[str, Any]] = []
233
+ for name, tool, tokens, doc_len, description in corpus:
234
+ if not include_active and registry.is_active(name):
235
+ continue
236
+
237
+ combined_text = " ".join([name, tool.user_facing_name(), description]).lower()
238
+ score = _bm25_score(tokens, doc_len)
239
+ if regex and regex.search(combined_text):
240
+ score += 5.0
241
+ if normalized in combined_text:
242
+ score += 3.0
243
+ score += SequenceMatcher(None, normalized, name.lower()).ratio() * 2
244
+ score += SequenceMatcher(None, normalized, tool.user_facing_name().lower()).ratio()
245
+
246
+ results.append(
247
+ {
248
+ "name": name,
249
+ "user_facing_name": tool.user_facing_name(),
250
+ "active": registry.is_active(name),
251
+ "deferred": name in getattr(registry, "deferred_names", set()),
252
+ "description": description,
253
+ "input_schema": tool.input_schema.model_json_schema(),
254
+ "score": score,
255
+ }
256
+ )
257
+
258
+ return sorted(results, key=lambda item: item.get("score", 0), reverse=True)[:limit]
259
+
260
+ async def _describe_by_name(
261
+ self,
262
+ registry: Any,
263
+ names: List[str],
264
+ include_examples: bool,
265
+ limit: int,
266
+ ) -> List[Dict[str, Any]]:
267
+ seen = set()
268
+ results: List[Dict[str, Any]] = []
269
+ for name in names:
270
+ if not name or name in seen:
271
+ continue
272
+ seen.add(name)
273
+ tool = registry.get(name) if hasattr(registry, "get") else None
274
+ if not tool:
275
+ continue
276
+ description = await build_tool_description(
277
+ tool, include_examples=include_examples, max_examples=2
278
+ )
279
+ results.append(
280
+ {
281
+ "name": name,
282
+ "user_facing_name": tool.user_facing_name(),
283
+ "description": description,
284
+ "active": (
285
+ getattr(registry, "is_active", lambda *_: False)(name)
286
+ if hasattr(registry, "is_active")
287
+ else False
288
+ ),
289
+ "deferred": name in getattr(registry, "deferred_names", set()),
290
+ "score": 0.0,
291
+ }
292
+ )
293
+ if len(results) >= limit:
294
+ break
295
+ return results
296
+
297
+ async def call(
298
+ self,
299
+ input_data: ToolSearchInput,
300
+ context: ToolUseContext,
301
+ ) -> AsyncGenerator[ToolOutput, None]:
302
+ registry = getattr(context, "tool_registry", None)
303
+ if not registry:
304
+ yield ToolResult(
305
+ data=ToolSearchOutput(),
306
+ result_for_assistant="Tool registry unavailable; cannot search tools.",
307
+ )
308
+ return
309
+
310
+ matches: List[Dict[str, Any]] = []
311
+ if input_data.query:
312
+ matches = await self._search(
313
+ input_data.query,
314
+ registry,
315
+ include_active=input_data.include_active,
316
+ include_examples=input_data.include_examples,
317
+ limit=input_data.max_results,
318
+ )
319
+
320
+ if input_data.names:
321
+ named_matches = await self._describe_by_name(
322
+ registry,
323
+ input_data.names,
324
+ input_data.include_examples,
325
+ input_data.max_results,
326
+ )
327
+ # Merge in explicit names that weren't returned by the search query.
328
+ known = {m["name"] for m in matches}
329
+ matches.extend([m for m in named_matches if m["name"] not in known])
330
+
331
+ if matches:
332
+ matches = sorted(matches, key=lambda item: item.get("score", 0), reverse=True)
333
+ if input_data.max_results:
334
+ matches = matches[: input_data.max_results]
335
+
336
+ max_description_chars = 600
337
+ for match in matches:
338
+ desc = match.get("description")
339
+ if (
340
+ max_description_chars
341
+ and isinstance(desc, str)
342
+ and len(desc) > max_description_chars
343
+ ):
344
+ match["description"] = desc[:max_description_chars] + "..."
345
+
346
+ # Activate tools as requested.
347
+ activation_targets: List[str] = []
348
+ if input_data.names:
349
+ activation_targets.extend(input_data.names)
350
+ elif input_data.auto_activate:
351
+ activation_targets.extend([match["name"] for match in matches])
352
+
353
+ activated: List[str] = []
354
+ missing: List[str] = []
355
+ if activation_targets:
356
+ activated, missing = registry.activate_tools(activation_targets)
357
+
358
+ normalized_matches: List[ToolSearchMatch] = []
359
+ for match in matches[: input_data.max_results]:
360
+ normalized_matches.append(
361
+ ToolSearchMatch(
362
+ name=match.get("name", ""),
363
+ user_facing_name=match.get("user_facing_name"),
364
+ description=match.get("description"),
365
+ active=bool(match.get("active")),
366
+ deferred=bool(match.get("deferred")),
367
+ )
368
+ )
369
+
370
+ output = ToolSearchOutput(
371
+ matches=normalized_matches,
372
+ activated=activated,
373
+ missing=missing,
374
+ deferred_remaining=len(getattr(registry, "deferred_names", [])),
375
+ )
376
+
377
+ yield ToolResult(
378
+ data=output,
379
+ result_for_assistant=self.render_result_for_assistant(output),
380
+ )
@@ -0,0 +1 @@
1
+ """Utility modules for Ripperdoc."""
@@ -0,0 +1,51 @@
1
+ """Bash-related constants and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ # Baseline defaults (kept in sync with the reference implementation)
9
+ _BASH_DEFAULT_TIMEOUT_MS = 120_000
10
+ _BASH_MAX_TIMEOUT_MS = 600_000
11
+ _BASH_MAX_OUTPUT_LENGTH = 30_000
12
+
13
+
14
+ def _parse_positive_int(value: Optional[str]) -> Optional[int]:
15
+ """Best-effort conversion of an environment variable to a positive int."""
16
+ if value is None:
17
+ return None
18
+ try:
19
+ parsed = int(value)
20
+ except (TypeError, ValueError):
21
+ return None
22
+ return parsed if parsed > 0 else None
23
+
24
+
25
+ def get_bash_max_output_length() -> int:
26
+ """Return the maximum output length, honoring an env override when valid."""
27
+ override = _parse_positive_int(os.getenv("BASH_MAX_OUTPUT_LENGTH"))
28
+ return override or _BASH_MAX_OUTPUT_LENGTH
29
+
30
+
31
+ def get_bash_default_timeout_ms() -> int:
32
+ """Return the default timeout, honoring an env override when valid."""
33
+ override = _parse_positive_int(os.getenv("BASH_DEFAULT_TIMEOUT_MS"))
34
+ return override or _BASH_DEFAULT_TIMEOUT_MS
35
+
36
+
37
+ def get_bash_max_timeout_ms() -> int:
38
+ """Return the maximum timeout, never lower than the default timeout."""
39
+ override = _parse_positive_int(os.getenv("BASH_MAX_TIMEOUT_MS"))
40
+ baseline = _parse_positive_int(os.getenv("BASH_DEFAULT_TIMEOUT_MS"))
41
+ default_timeout = baseline or _BASH_DEFAULT_TIMEOUT_MS
42
+ if override:
43
+ return max(override, default_timeout)
44
+ return max(_BASH_MAX_TIMEOUT_MS, default_timeout)
45
+
46
+
47
+ __all__ = [
48
+ "get_bash_max_output_length",
49
+ "get_bash_default_timeout_ms",
50
+ "get_bash_max_timeout_ms",
51
+ ]
@@ -0,0 +1,43 @@
1
+ """Output helpers for BashTool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ripperdoc.utils.output_utils import trim_blank_lines, truncate_output
8
+ from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
9
+
10
+
11
+ def append_cwd_reset_message(message: str) -> str:
12
+ """Append a notice when the working directory gets reset."""
13
+ cleaned = message.rstrip()
14
+ suffix = f"Shell cwd was reset to {get_original_cwd()}"
15
+ if cleaned:
16
+ return f"{cleaned}\n{suffix}"
17
+ return suffix
18
+
19
+
20
+ def reset_cwd_if_needed(allowed_directories: set[str] | None = None) -> bool:
21
+ """Placeholder that mirrors the reference contract.
22
+
23
+ In this environment we simply report whether the current cwd is outside the
24
+ provided allowed set and reset to the original cwd if so.
25
+ """
26
+ allowed_directories = allowed_directories or set()
27
+ current = safe_get_cwd()
28
+ if not allowed_directories:
29
+ return False
30
+ if current in allowed_directories:
31
+ return False
32
+ os.chdir(get_original_cwd())
33
+ return True
34
+
35
+
36
+ __all__ = [
37
+ "append_cwd_reset_message",
38
+ "reset_cwd_if_needed",
39
+ "trim_blank_lines",
40
+ "truncate_output",
41
+ "safe_get_cwd",
42
+ "get_original_cwd",
43
+ ]
@@ -0,0 +1,34 @@
1
+ """Lightweight parsing helpers for permissive type coercion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def parse_boolish(value: object, default: bool = False) -> bool:
9
+ """Parse a truthy/falsey value from common representations."""
10
+ if value is None:
11
+ return default
12
+ if isinstance(value, bool):
13
+ return value
14
+ if isinstance(value, (int, float)):
15
+ return bool(value)
16
+ if isinstance(value, str):
17
+ normalized = value.strip().lower()
18
+ if normalized in {"1", "true", "yes", "on"}:
19
+ return True
20
+ if normalized in {"0", "false", "no", "off"}:
21
+ return False
22
+ return default
23
+
24
+
25
+ def parse_optional_int(value: object) -> Optional[int]:
26
+ """Best-effort int parsing; returns None on failure."""
27
+ try:
28
+ if value is None:
29
+ return None
30
+ if isinstance(value, bool):
31
+ return int(value)
32
+ return int(str(value).strip())
33
+ except (ValueError, TypeError):
34
+ return None