bone-agent 1.3.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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/npm-wrapper.js +235 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +133 -0
- package/package.json +53 -0
- package/requirements.txt +9 -0
- package/src/__init__.py +11 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +1054 -0
- package/src/core/chat_manager.py +1552 -0
- package/src/core/config_manager.py +247 -0
- package/src/core/cron.py +527 -0
- package/src/core/cron_allowlist.py +118 -0
- package/src/core/memory.py +232 -0
- package/src/core/retry.py +71 -0
- package/src/core/sub_agent.py +326 -0
- package/src/core/tool_approval.py +220 -0
- package/src/core/tool_feedback.py +778 -0
- package/src/exceptions.py +79 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +171 -0
- package/src/llm/config.py +466 -0
- package/src/llm/prompts.py +735 -0
- package/src/llm/providers.py +417 -0
- package/src/llm/streaming.py +163 -0
- package/src/llm/token_tracker.py +368 -0
- package/src/tools/__init__.py +212 -0
- package/src/tools/constants.py +59 -0
- package/src/tools/create_file.py +136 -0
- package/src/tools/directory.py +389 -0
- package/src/tools/edit.py +543 -0
- package/src/tools/file_reader.py +322 -0
- package/src/tools/helpers/__init__.py +105 -0
- package/src/tools/helpers/base.py +550 -0
- package/src/tools/helpers/converters.py +44 -0
- package/src/tools/helpers/file_helpers.py +189 -0
- package/src/tools/helpers/formatters.py +411 -0
- package/src/tools/helpers/loader.py +231 -0
- package/src/tools/helpers/parallel_executor.py +231 -0
- package/src/tools/helpers/path_resolver.py +226 -0
- package/src/tools/helpers/plugin_manifest.py +156 -0
- package/src/tools/obsidian.py +96 -0
- package/src/tools/review_sub_agent.py +189 -0
- package/src/tools/rg_search.py +393 -0
- package/src/tools/search_plugins.py +109 -0
- package/src/tools/select_option.py +593 -0
- package/src/tools/shell.py +302 -0
- package/src/tools/sub_agent.py +139 -0
- package/src/tools/task_list.py +269 -0
- package/src/tools/web_search.py +61 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +87 -0
- package/src/ui/commands.py +2694 -0
- package/src/ui/displays.py +213 -0
- package/src/ui/loader.py +284 -0
- package/src/ui/main.py +646 -0
- package/src/ui/prompt_utils.py +113 -0
- package/src/ui/setting_selector.py +590 -0
- package/src/ui/setup_wizard.py +294 -0
- package/src/ui/sub_agent_panel.py +234 -0
- package/src/ui/tool_confirmation.py +215 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/citation_parser.py +199 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/paths.py +30 -0
- package/src/utils/result_parsers.py +108 -0
- package/src/utils/safe_commands.py +243 -0
- package/src/utils/settings.py +174 -0
- package/src/utils/validation.py +191 -0
- package/src/utils/web_search.py +173 -0
|
@@ -0,0 +1,136 @@
|
|
|
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)
|
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
)
|