bone-agent 1.3.3 → 2.0.0

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 (121) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -184
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -141
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -36
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. package/src/utils/web_search.py +0 -173
@@ -1,136 +0,0 @@
1
- """File creation operations."""
2
-
3
- import os
4
- from pathlib import Path
5
- from typing import Optional, Tuple
6
-
7
- from utils.settings import MAX_FILE_PREVIEW_LINES
8
- from .helpers.base import tool
9
- from .helpers.path_resolver import PathResolver
10
- from .helpers.formatters import format_file_result
11
-
12
-
13
- def _validate_create_path(
14
- path_str: str,
15
- repo_root: Path,
16
- gitignore_spec,
17
- vault_root: Path = None,
18
- ) -> Tuple[Optional[Path], Optional[str]]:
19
- """Validate and resolve path for file creation.
20
-
21
- Args:
22
- path_str: Path string to validate
23
- repo_root: Repository root directory
24
- gitignore_spec: Optional PathSpec for .gitignore filtering
25
- vault_root: Optional Obsidian vault root path
26
-
27
- Returns:
28
- (resolved_path, error_message) - error_message is None if valid
29
- """
30
- vault_path = Path(vault_root) if vault_root else None
31
- resolver = PathResolver(repo_root=repo_root, gitignore_spec=gitignore_spec, vault_path=vault_path)
32
- return resolver.resolve_and_validate(
33
- path_str,
34
- check_gitignore=True,
35
- must_exist=False, # File doesn't need to exist yet
36
- enforce_boundary=vault_path is not None,
37
- )
38
-
39
-
40
- @tool(
41
- name="create_file",
42
- description="Create a new file with optional initial content. File must not exist.",
43
- parameters={
44
- "type": "object",
45
- "properties": {
46
- "path_str": {"type": "string", "description": "Path to create"},
47
- "content": {"type": "string", "description": "Initial content (omit for empty file)"}
48
- },
49
- "required": ["path_str"]
50
- },
51
- )
52
- def create_file(
53
- path_str: str,
54
- repo_root: Path,
55
- content: Optional[str] = None,
56
- gitignore_spec = None,
57
- vault_root: str = None,
58
- ) -> str:
59
- """Create a new file with optional initial content.
60
-
61
- Creates a new file at the specified path, creating parent directories
62
- if needed. The file must not already exist. Respects .gitignore.
63
-
64
- Args:
65
- path_str: Path string to the file to create
66
- repo_root: Repository root directory (for path resolution)
67
- content: Optional initial content for the file. If omitted, creates empty file.
68
- gitignore_spec: Optional PathSpec for .gitignore filtering
69
- vault_root: Optional Obsidian vault root path
70
-
71
- Returns:
72
- str: Formatted result with exit_code and status, including preview
73
- """
74
- try:
75
- # Validate path
76
- resolved, error = _validate_create_path(path_str, repo_root, gitignore_spec, vault_root=vault_root)
77
- if error:
78
- return format_file_result(exit_code=1, error=error, path=path_str)
79
-
80
- # Check if already exists
81
- if resolved.exists():
82
- try:
83
- rel_path = resolved.relative_to(repo_root)
84
- except ValueError:
85
- rel_path = resolved
86
- return format_file_result(
87
- exit_code=1,
88
- error="File already exists",
89
- path=str(rel_path)
90
- )
91
-
92
- # Create parent directories if needed
93
- parent_dir = resolved.parent
94
- if parent_dir != repo_root and not parent_dir.exists():
95
- parent_dir.mkdir(parents=True, exist_ok=True)
96
-
97
- # Write content or create empty file
98
- if content is not None:
99
- resolved.write_text(content, encoding="utf-8", newline="")
100
- else:
101
- content = ""
102
- resolved.touch()
103
-
104
- # Build result with content for display (truncate preview if needed)
105
- result_lines = []
106
- result_lines.append(f"exit_code=0")
107
- try:
108
- rel_path = resolved.relative_to(repo_root)
109
- except ValueError:
110
- rel_path = resolved
111
- result_lines.append(f"path={rel_path}")
112
- result_lines.append(f"content=File created successfully")
113
- result_lines.append("")
114
- result_lines.append(f"=== FILE_CONTENT ===")
115
-
116
- # Truncate content for preview if it exceeds max lines
117
- if content:
118
- content_lines = content.splitlines(keepends=True)
119
- if len(content_lines) > MAX_FILE_PREVIEW_LINES:
120
- truncated_content = "".join(content_lines[:MAX_FILE_PREVIEW_LINES])
121
- omitted = len(content_lines) - MAX_FILE_PREVIEW_LINES
122
- result_lines.append(truncated_content)
123
- result_lines.append(f"\n... ({omitted} more lines omitted from preview)")
124
- else:
125
- result_lines.append(content)
126
-
127
- result_lines.append("=== END_FILE_CONTENT ===")
128
-
129
- return "\n".join(result_lines) + "\n\n"
130
-
131
- except PermissionError:
132
- return format_file_result(exit_code=1, error="Permission denied", path=path_str)
133
- except OSError as e:
134
- return format_file_result(exit_code=1, error=f"Invalid filename: {e}", path=path_str)
135
- except Exception as e:
136
- return format_file_result(exit_code=1, error=str(e), path=path_str)
@@ -1,389 +0,0 @@
1
- """Directory listing operations."""
2
-
3
- import fnmatch
4
- import os
5
- from pathlib import Path
6
- from typing import Optional, Tuple, Dict, List
7
-
8
- from .helpers.base import tool
9
- from .helpers.path_resolver import PathResolver
10
- from .helpers.file_helpers import GitignoreFilter
11
- from .helpers.formatters import format_file_result
12
- from . import constants
13
-
14
-
15
- def _group_items_by_directory(items, show_files, show_dirs) -> Dict:
16
- """Group items by their parent directory for smart truncation.
17
-
18
- Args:
19
- items: List of (kind, rel_path, size, raw_path, line_count) tuples
20
- show_files: Whether files are included in results
21
- show_dirs: Whether directories are included in results
22
-
23
- Returns:
24
- Dict mapping parent_dir -> {'dirs': [dir_items], 'files': [file_items]}
25
- """
26
- groups = {}
27
-
28
- for kind, rel_path, size, raw_path, line_count in items:
29
- # Get parent directory
30
- if '/' in rel_path:
31
- parent_dir = Path(rel_path).parent
32
- else:
33
- parent_dir = Path('.') # Root level
34
-
35
- if parent_dir not in groups:
36
- groups[parent_dir] = {'dirs': [], 'files': []}
37
-
38
- if kind == 'DIR ':
39
- groups[parent_dir]['dirs'].append((kind, rel_path, size, raw_path, line_count))
40
- else: # FILE
41
- groups[parent_dir]['files'].append((kind, rel_path, size, raw_path, line_count))
42
-
43
- return groups
44
-
45
-
46
- def _apply_smart_truncation(items, show_files, show_dirs, hit_limit=False) -> Tuple[List, Optional[Dict]]:
47
- """Apply smart truncation to directory listing results.
48
-
49
- Truncation Strategy:
50
- - If total items < TRUNCATION_THRESHOLD: No truncation
51
- - If total items >= TRUNCATION_THRESHOLD:
52
- * ALL directories are shown (preserve structure)
53
- * Files are sampled: top MAX_FILES_PER_FOLDER per directory
54
- * Truncation metadata returned for message generation
55
-
56
- Args:
57
- items: List of (kind, rel_path, size, raw_path) tuples
58
- show_files: Whether files are included in results
59
- show_dirs: Whether directories are included in results
60
- hit_limit: Whether we hit MAX_TOTAL_ITEMS during collection
61
-
62
- Returns:
63
- Tuple of:
64
- - List of items after truncation
65
- - None if no truncation, or dict with truncation metadata
66
- """
67
- total_count = len(items)
68
-
69
- # Fast path: no truncation needed
70
- if total_count < constants.TRUNCATION_THRESHOLD:
71
- return items, None
72
-
73
- # Group items by parent directory
74
- groups = _group_items_by_directory(items, show_files, show_dirs)
75
-
76
- # Apply truncation per directory
77
- truncated_items = []
78
- total_dirs_shown = 0
79
- total_files_shown = 0
80
- total_files_omitted = 0
81
-
82
- # Sort directories alphabetically for consistent output
83
- for parent_dir in sorted(groups.keys(), key=lambda p: str(p)):
84
- group = groups[parent_dir]
85
-
86
- # Always include all directories
87
- if show_dirs:
88
- truncated_items.extend(group['dirs'])
89
- total_dirs_shown += len(group['dirs'])
90
-
91
- # Truncate files if needed
92
- if show_files and group['files']:
93
- # Sort files alphabetically and take top N
94
- sorted_files = sorted(group['files'], key=lambda x: x[1]) # Sort by rel_path
95
- files_to_show = sorted_files[:constants.MAX_FILES_PER_FOLDER]
96
- truncated_items.extend(files_to_show)
97
-
98
- total_files_shown += len(files_to_show)
99
- total_files_omitted += len(group['files']) - len(files_to_show)
100
-
101
- # Build truncation info
102
- truncation_info = {
103
- 'total': total_count,
104
- 'shown': len(truncated_items),
105
- 'omitted': total_count - len(truncated_items),
106
- 'dirs_shown': total_dirs_shown,
107
- 'files_shown': total_files_shown,
108
- 'files_omitted': total_files_omitted,
109
- 'all_dirs_shown': True
110
- }
111
-
112
- return truncated_items, truncation_info
113
-
114
-
115
- def _validate_directory_path(
116
- path_str: str,
117
- repo_root: Path,
118
- vault_root: str = None
119
- ) -> Tuple[Optional[Path], Optional[str]]:
120
- """Validate and resolve path for directory listing.
121
-
122
- This function wraps PathResolver.resolve_and_validate() for the directory
123
- tool's specific needs, ensuring the path is a directory.
124
-
125
- Args:
126
- path_str: Path string to validate
127
- repo_root: Repository root directory
128
- vault_root: Optional Obsidian vault root path
129
-
130
- Returns:
131
- (resolved_path, error_message) - error_message is None if valid
132
- """
133
- from pathlib import Path as P
134
- vault_path = P(vault_root) if vault_root else None
135
- # Use PathResolver for centralized validation
136
- resolver = PathResolver(repo_root=repo_root, gitignore_spec=None, vault_path=vault_path)
137
- resolved, error = resolver.resolve_and_validate(
138
- path_str,
139
- check_gitignore=False, # Directory listing shows everything
140
- must_exist=True,
141
- must_be_dir=True, # Must be a directory
142
- enforce_boundary=vault_path is not None
143
- )
144
-
145
- if error:
146
- return None, error
147
-
148
- return resolved, None
149
-
150
-
151
- @tool(
152
- name="list_directory",
153
- description="List directory contents (preferred over PowerShell).",
154
- parameters={
155
- "type": "object",
156
- "properties": {
157
- "path_str": {"type": "string", "description": "Path to list (default: '.')"},
158
- "recursive": {"type": "boolean", "description": "List recursively (default: false)"},
159
- "show_files": {"type": "boolean", "description": "Include files (default: true)"},
160
- "show_dirs": {"type": "boolean", "description": "Include directories (default: true)"},
161
- "pattern": {"type": "string", "description": "Glob filter (e.g., \"*.py\")"}
162
- },
163
- "required": ["path_str"]
164
- },
165
- )
166
- def list_directory(
167
- path_str: str,
168
- repo_root: Path,
169
- recursive: bool = False,
170
- show_files: bool = True,
171
- show_dirs: bool = True,
172
- pattern: Optional[str] = None,
173
- gitignore_spec = None,
174
- vault_root: str = None
175
- ) -> str:
176
- """List directory contents.
177
-
178
- Directory listing that respects .gitignore and shows file sizes in a
179
- consistent format.
180
-
181
- Args:
182
- path_str: Path string to the directory to list
183
- repo_root: Repository root directory (for path resolution)
184
- recursive: List recursively
185
- show_files: Include files in output
186
- show_dirs: Include directories in output
187
- pattern: Optional glob pattern to filter results (e.g., "*.py")
188
- gitignore_spec: Optional PathSpec for .gitignore filtering
189
- vault_root: Optional Obsidian vault root path
190
-
191
- Returns:
192
- str: Formatted result with exit_code, items_count, and directory listing
193
- """
194
- try:
195
- # Validate path
196
- resolved, error = _validate_directory_path(path_str, repo_root, vault_root=vault_root)
197
- if error:
198
- return format_file_result(
199
- exit_code=1,
200
- error=error,
201
- path=path_str
202
- )
203
-
204
- def _match_pattern(rel_path: Path) -> bool:
205
- if not pattern:
206
- return True
207
- return fnmatch.fnmatch(rel_path.as_posix(), pattern)
208
-
209
- # Create gitignore filter if spec is provided
210
- gitignore_filter = GitignoreFilter(repo_root=repo_root, gitignore_spec=gitignore_spec) if gitignore_spec else None
211
-
212
- def _is_ignored(path: Path) -> bool:
213
- if path.name == ".gitignore":
214
- return True
215
- if ".git" in path.parts:
216
- return True
217
- if gitignore_filter is None:
218
- return False
219
- # Use GitignoreFilter for consistent behavior
220
- return gitignore_filter.is_ignored(path)
221
-
222
- # Collect items
223
- items = []
224
- base_dir = resolved
225
- hit_limit = False # Track if we hit constants.MAX_TOTAL_ITEMS
226
-
227
- def _count_lines(file_path: Path) -> int:
228
- """Count lines in a file efficiently."""
229
- try:
230
- with open(file_path, 'rb') as f:
231
- return sum(1 for _ in f) - 1 # Subtract 1 for last newline, but handle empty files
232
- except (OSError, IOError):
233
- return 0
234
-
235
- def _add_item(kind, rel_path, size_str, raw_path, line_count=None):
236
- nonlocal hit_limit
237
- if kind == "FILE" and not show_files:
238
- return
239
- if kind == "DIR " and not show_dirs:
240
- return
241
- if not _match_pattern(rel_path):
242
- return
243
- if hit_limit:
244
- return
245
- if len(items) >= constants.MAX_TOTAL_ITEMS:
246
- hit_limit = True
247
- return
248
- items.append((kind, str(rel_path), size_str, raw_path, line_count))
249
-
250
- if recursive:
251
- stack = [resolved]
252
- while stack and not hit_limit:
253
- current = stack.pop()
254
- try:
255
- with os.scandir(current) as it:
256
- for entry in it:
257
- try:
258
- if entry.is_symlink():
259
- continue
260
- except OSError:
261
- continue
262
-
263
- is_dir = entry.is_dir(follow_symlinks=False)
264
- is_file = entry.is_file(follow_symlinks=False)
265
-
266
- if not is_dir and not is_file:
267
- continue
268
-
269
- entry_path = Path(entry.path)
270
-
271
- if _is_ignored(entry_path):
272
- continue
273
-
274
- rel_path = entry_path.relative_to(base_dir)
275
-
276
- if is_file:
277
- try:
278
- size = f"{entry.stat(follow_symlinks=False).st_size:>10}"
279
- line_count = _count_lines(entry_path)
280
- except OSError:
281
- size = " ?"
282
- line_count = 0
283
- _add_item("FILE", rel_path, size, entry_path, line_count)
284
- else:
285
- _add_item("DIR ", rel_path, " ", entry_path, line_count=0)
286
- stack.append(entry_path)
287
- except PermissionError:
288
- continue
289
- else:
290
- with os.scandir(resolved) as it:
291
- for entry in it:
292
- try:
293
- if entry.is_symlink():
294
- continue
295
- except OSError:
296
- continue
297
-
298
- is_dir = entry.is_dir(follow_symlinks=False)
299
- is_file = entry.is_file(follow_symlinks=False)
300
-
301
- if not is_dir and not is_file:
302
- continue
303
-
304
- entry_path = Path(entry.path)
305
-
306
- if _is_ignored(entry_path):
307
- continue
308
-
309
- rel_path = entry_path.relative_to(base_dir)
310
-
311
- if is_file:
312
- try:
313
- size = f"{entry.stat(follow_symlinks=False).st_size:>10}"
314
- line_count = _count_lines(entry_path)
315
- except OSError:
316
- size = " ?"
317
- line_count = 0
318
- _add_item("FILE", rel_path, size, entry_path, line_count)
319
- else:
320
- _add_item("DIR ", rel_path, " ", entry_path, line_count=0)
321
-
322
- # Sort: directories first, then alphabetically
323
- items.sort(key=lambda x: (0 if x[0] == "DIR " else 1, x[1]))
324
-
325
- # Format output
326
- if not items:
327
- try:
328
- rel_path = resolved.relative_to(repo_root)
329
- except ValueError:
330
- rel_path = resolved
331
- return format_file_result(
332
- exit_code=0,
333
- content="(empty directory)",
334
- path=str(rel_path),
335
- items_count=0
336
- )
337
-
338
- # Apply smart truncation if needed
339
- truncated_items, truncation_info = _apply_smart_truncation(items, show_files, show_dirs, hit_limit)
340
-
341
- # Build lines with truncation message
342
- lines = []
343
- for kind, rel_path, size, _, line_count in truncated_items:
344
- if kind == "FILE":
345
- lines.append(f"{kind} {rel_path} {line_count:6} lines {size} bytes")
346
- else:
347
- lines.append(f"{kind} {rel_path} {line_count:6} lines")
348
-
349
- # Add truncation message at end
350
- if truncation_info:
351
- lines.append("")
352
- msg = f"[{truncation_info['files_omitted']} file(s) omitted ({truncation_info['shown']} shown from {truncation_info['total']} total items)]"
353
- if hit_limit:
354
- msg += f"\n[WARNING: Listing stopped at {constants.MAX_TOTAL_ITEMS} items to prevent context overflow. Use filters or specific paths to explore further.]"
355
- lines.append(msg)
356
-
357
- content = "\n".join(lines)
358
-
359
- # Update truncation info to indicate if we hit the hard limit
360
- if truncation_info and hit_limit:
361
- truncation_info['hit_limit'] = True
362
- truncation_info['max_items'] = constants.MAX_TOTAL_ITEMS
363
-
364
- try:
365
- rel_path = resolved.relative_to(repo_root)
366
- except ValueError:
367
- rel_path = resolved
368
-
369
- return format_file_result(
370
- exit_code=0,
371
- content=content,
372
- path=str(rel_path),
373
- items_count=truncation_info['total'] if truncation_info else len(items),
374
- truncated=(truncation_info is not None),
375
- truncation_info=truncation_info
376
- )
377
-
378
- except PermissionError:
379
- return format_file_result(
380
- exit_code=1,
381
- error="Permission denied",
382
- path=path_str
383
- )
384
- except Exception as e:
385
- return format_file_result(
386
- exit_code=1,
387
- error=str(e),
388
- path=path_str
389
- )