code-search-cli 0.2.0__py3-none-any.whl → 0.3.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.
- code_retrieval/cli.py +42 -9
- code_retrieval/installer.py +445 -42
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/METADATA +62 -15
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/RECORD +9 -9
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/WHEEL +0 -0
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/licenses/NOTICE +0 -0
- {code_search_cli-0.2.0.dist-info → code_search_cli-0.3.0.dist-info}/top_level.txt +0 -0
code_retrieval/cli.py
CHANGED
|
@@ -9,7 +9,13 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
9
9
|
|
|
10
10
|
from .engine import RetrievalEngine
|
|
11
11
|
from .index_store import PersistentIndex
|
|
12
|
-
from .installer import
|
|
12
|
+
from .installer import (
|
|
13
|
+
AGENT_SELECTORS,
|
|
14
|
+
AgentName,
|
|
15
|
+
agent_display_name,
|
|
16
|
+
install_skills,
|
|
17
|
+
uninstall_skills,
|
|
18
|
+
)
|
|
13
19
|
from .models import Evidence
|
|
14
20
|
from .repository import RETRIEVAL_SCOPES, RepositoryReader
|
|
15
21
|
|
|
@@ -51,11 +57,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
51
57
|
clean = subparsers.add_parser("clean", help="remove the persistent index for a repository")
|
|
52
58
|
_repository_args(clean)
|
|
53
59
|
|
|
54
|
-
install = subparsers.add_parser("install", help="install the bundled
|
|
60
|
+
install = subparsers.add_parser("install", help="install the bundled skill for a coding agent")
|
|
61
|
+
install.add_argument(
|
|
62
|
+
"--target",
|
|
63
|
+
choices=AGENT_SELECTORS,
|
|
64
|
+
default="codex",
|
|
65
|
+
help="agent destination; 'detected' uses best-effort local signals (default: codex)",
|
|
66
|
+
)
|
|
55
67
|
install.add_argument("--force", action="store_true", help="replace a locally modified installed skill")
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
install.add_argument("--dry-run", action="store_true", help="preview changes without writing files")
|
|
69
|
+
|
|
70
|
+
uninstall = subparsers.add_parser("uninstall", help="remove the bundled skill for a coding agent")
|
|
71
|
+
uninstall.add_argument(
|
|
72
|
+
"--target",
|
|
73
|
+
choices=AGENT_SELECTORS,
|
|
74
|
+
default="codex",
|
|
75
|
+
help="agent destination; 'detected' uses best-effort local signals (default: codex)",
|
|
76
|
+
)
|
|
58
77
|
uninstall.add_argument("--force", action="store_true", help="remove a locally modified installed skill")
|
|
78
|
+
uninstall.add_argument("--dry-run", action="store_true", help="preview changes without removing files")
|
|
59
79
|
return parser
|
|
60
80
|
|
|
61
81
|
|
|
@@ -63,11 +83,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
63
83
|
args = build_parser().parse_args(argv)
|
|
64
84
|
try:
|
|
65
85
|
if args.command in {"install", "uninstall"}:
|
|
66
|
-
operation =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
print("
|
|
86
|
+
operation = install_skills if args.command == "install" else uninstall_skills
|
|
87
|
+
results = operation(target=args.target, force=args.force, dry_run=args.dry_run)
|
|
88
|
+
for result in results:
|
|
89
|
+
action = f"would be {result.action}" if args.dry_run else result.action
|
|
90
|
+
print(f"{agent_display_name(result.agent)} skill {action}: {result.path}")
|
|
91
|
+
if args.command == "install" and not args.dry_run:
|
|
92
|
+
for result in results:
|
|
93
|
+
print(_skill_reload_hint(result.agent))
|
|
71
94
|
return 0
|
|
72
95
|
|
|
73
96
|
if args.command in {"index", "status", "clean"}:
|
|
@@ -128,6 +151,16 @@ def _non_empty(value: str) -> str:
|
|
|
128
151
|
return value
|
|
129
152
|
|
|
130
153
|
|
|
154
|
+
def _skill_reload_hint(agent: AgentName) -> str:
|
|
155
|
+
if agent == "codex":
|
|
156
|
+
return "Restart Codex if the skill does not appear automatically."
|
|
157
|
+
if agent == "gemini":
|
|
158
|
+
return "Run /skills reload in Gemini CLI if the skill does not appear automatically."
|
|
159
|
+
if agent == "kiro":
|
|
160
|
+
return "Kiro loads Agent Skills automatically; reopen the session if it is not listed."
|
|
161
|
+
return f"Restart {agent_display_name(agent)} if the skill does not appear automatically."
|
|
162
|
+
|
|
163
|
+
|
|
131
164
|
def _evidence_json(evidence: list[Evidence]) -> str:
|
|
132
165
|
from dataclasses import asdict
|
|
133
166
|
|
code_retrieval/installer.py
CHANGED
|
@@ -1,65 +1,468 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
3
5
|
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
4
9
|
from importlib.resources import files
|
|
5
10
|
from pathlib import Path
|
|
6
|
-
from typing import Literal
|
|
11
|
+
from typing import Literal, cast
|
|
7
12
|
|
|
8
13
|
|
|
14
|
+
AgentName = Literal[
|
|
15
|
+
"codex",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"cursor",
|
|
18
|
+
"gemini",
|
|
19
|
+
"opencode",
|
|
20
|
+
"github-copilot",
|
|
21
|
+
"kiro",
|
|
22
|
+
]
|
|
23
|
+
AgentSelector = AgentName | Literal["detected"]
|
|
9
24
|
InstallAction = Literal["created", "updated", "unchanged"]
|
|
10
|
-
|
|
25
|
+
UninstallAction = Literal["removed", "not-found"]
|
|
26
|
+
SkillAction = InstallAction | UninstallAction
|
|
27
|
+
|
|
28
|
+
AGENT_TARGETS: tuple[AgentName, ...] = (
|
|
29
|
+
"codex",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"cursor",
|
|
32
|
+
"gemini",
|
|
33
|
+
"opencode",
|
|
34
|
+
"github-copilot",
|
|
35
|
+
"kiro",
|
|
36
|
+
)
|
|
37
|
+
AGENT_SELECTORS: tuple[AgentSelector, ...] = (*AGENT_TARGETS, "detected")
|
|
38
|
+
|
|
39
|
+
PORTABLE_SKILL_FILES = (Path("SKILL.md"),)
|
|
40
|
+
CODEX_SKILL_FILES = (*PORTABLE_SKILL_FILES, Path("agents/openai.yaml"))
|
|
41
|
+
INSTALL_MANIFEST = Path(".code-search-install.json")
|
|
42
|
+
MANIFEST_SCHEMA = 1
|
|
43
|
+
|
|
44
|
+
# Official files shipped by the first public release predate install manifests. Recognizing
|
|
45
|
+
# their exact digests lets an untouched v0.1.0 Codex skill upgrade safely without --force.
|
|
46
|
+
LEGACY_FILE_DIGESTS: dict[Path, frozenset[str]] = {
|
|
47
|
+
Path("SKILL.md"): frozenset(
|
|
48
|
+
{"559892c5964086beb68446931479bdf60585eca198b2ab871ac81e247ab9b1fb"}
|
|
49
|
+
),
|
|
50
|
+
Path("agents/openai.yaml"): frozenset(
|
|
51
|
+
{"433e80c4c8bd5a0d19c6a3bec70b2fd849fe48343a2c1438b708bca69ff4bc1b"}
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class SkillResult:
|
|
58
|
+
agent: AgentName
|
|
59
|
+
path: Path
|
|
60
|
+
action: SkillAction
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def agent_display_name(agent: AgentName) -> str:
|
|
64
|
+
return {
|
|
65
|
+
"codex": "Codex",
|
|
66
|
+
"claude-code": "Claude Code",
|
|
67
|
+
"cursor": "Cursor",
|
|
68
|
+
"gemini": "Gemini CLI",
|
|
69
|
+
"opencode": "OpenCode",
|
|
70
|
+
"github-copilot": "GitHub Copilot",
|
|
71
|
+
"kiro": "Kiro",
|
|
72
|
+
}[agent]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def agent_skills_root(agent: AgentName) -> Path:
|
|
76
|
+
home = _home()
|
|
77
|
+
if agent == "codex":
|
|
78
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
79
|
+
if codex_home:
|
|
80
|
+
return _configured_path(codex_home, "CODEX_HOME") / "skills"
|
|
81
|
+
legacy_root = home / ".codex" / "skills"
|
|
82
|
+
if (legacy_root / "code-search").exists():
|
|
83
|
+
return legacy_root
|
|
84
|
+
return home / ".agents" / "skills"
|
|
85
|
+
if agent == "claude-code":
|
|
86
|
+
claude_home = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
87
|
+
return (
|
|
88
|
+
_configured_path(claude_home, "CLAUDE_CONFIG_DIR")
|
|
89
|
+
if claude_home
|
|
90
|
+
else home / ".claude"
|
|
91
|
+
) / "skills"
|
|
92
|
+
if agent == "cursor":
|
|
93
|
+
return home / ".cursor" / "skills"
|
|
94
|
+
if agent == "gemini":
|
|
95
|
+
gemini_home = os.environ.get("GEMINI_CLI_HOME")
|
|
96
|
+
return (
|
|
97
|
+
_configured_path(gemini_home, "GEMINI_CLI_HOME") if gemini_home else home
|
|
98
|
+
) / ".gemini" / "skills"
|
|
99
|
+
|
|
100
|
+
if agent == "opencode":
|
|
101
|
+
opencode_home = os.environ.get("OPENCODE_CONFIG_DIR")
|
|
102
|
+
if opencode_home:
|
|
103
|
+
return _configured_path(opencode_home, "OPENCODE_CONFIG_DIR") / "skills"
|
|
104
|
+
return _xdg_config_home(home) / "opencode" / "skills"
|
|
105
|
+
if agent == "github-copilot":
|
|
106
|
+
copilot_home = os.environ.get("COPILOT_HOME")
|
|
107
|
+
return (
|
|
108
|
+
_configured_path(copilot_home, "COPILOT_HOME")
|
|
109
|
+
if copilot_home
|
|
110
|
+
else home / ".copilot"
|
|
111
|
+
) / "skills"
|
|
112
|
+
if agent == "kiro":
|
|
113
|
+
return home / ".kiro" / "skills"
|
|
114
|
+
raise ValueError(f"unsupported agent target: {agent}")
|
|
11
115
|
|
|
12
116
|
|
|
13
117
|
def codex_skills_root() -> Path:
|
|
14
|
-
|
|
15
|
-
return (
|
|
118
|
+
"""Return the compatible Codex destination used by ``code-search install``."""
|
|
119
|
+
return agent_skills_root("codex")
|
|
16
120
|
|
|
17
121
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
122
|
+
def detect_agents() -> tuple[AgentName, ...]:
|
|
123
|
+
return tuple(agent for agent in AGENT_TARGETS if _agent_is_detected(agent))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def install_skills(
|
|
127
|
+
target: AgentSelector = "codex", *, force: bool = False, dry_run: bool = False
|
|
128
|
+
) -> tuple[SkillResult, ...]:
|
|
129
|
+
agents = _resolve_agents(target)
|
|
130
|
+
prepared = [_prepare_install(agent, force=force) for agent in agents]
|
|
131
|
+
|
|
132
|
+
if not dry_run:
|
|
133
|
+
for agent, destination, _action in prepared:
|
|
134
|
+
source = _skill_source()
|
|
135
|
+
for relative in _managed_files(agent):
|
|
136
|
+
output = destination / relative
|
|
137
|
+
content = source.joinpath(relative.as_posix()).read_bytes()
|
|
138
|
+
if output.exists() and _content_digest(output.read_bytes()) == _content_digest(
|
|
139
|
+
content
|
|
140
|
+
):
|
|
141
|
+
continue
|
|
142
|
+
_atomic_write(output, content)
|
|
143
|
+
manifest = destination / INSTALL_MANIFEST
|
|
144
|
+
content = _manifest_content(agent).encode("utf-8")
|
|
145
|
+
if not manifest.exists() or manifest.read_bytes() != content:
|
|
146
|
+
_atomic_write(manifest, content)
|
|
147
|
+
|
|
148
|
+
return tuple(
|
|
149
|
+
SkillResult(agent=agent, path=destination, action=action)
|
|
150
|
+
for agent, destination, action in prepared
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def uninstall_skills(
|
|
155
|
+
target: AgentSelector = "codex", *, force: bool = False, dry_run: bool = False
|
|
156
|
+
) -> tuple[SkillResult, ...]:
|
|
157
|
+
agents = _resolve_agents(target)
|
|
158
|
+
prepared = [_prepare_uninstall(agent, force=force) for agent in agents]
|
|
159
|
+
|
|
160
|
+
if not dry_run:
|
|
161
|
+
for agent, destination, action in prepared:
|
|
162
|
+
if action == "not-found":
|
|
30
163
|
continue
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
164
|
+
managed_files = _managed_paths(agent)
|
|
165
|
+
for relative in managed_files:
|
|
166
|
+
output = destination / relative
|
|
167
|
+
if output.exists():
|
|
168
|
+
output.unlink()
|
|
169
|
+
_remove_empty_managed_directories(destination, managed_files)
|
|
170
|
+
|
|
171
|
+
return tuple(
|
|
172
|
+
SkillResult(agent=agent, path=destination, action=action)
|
|
173
|
+
for agent, destination, action in prepared
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def install_codex_skill(*, force: bool = False) -> tuple[Path, InstallAction]:
|
|
178
|
+
result = install_skills(force=force)[0]
|
|
179
|
+
return result.path, cast(InstallAction, result.action)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def uninstall_codex_skill(*, force: bool = False) -> tuple[Path, UninstallAction]:
|
|
183
|
+
result = uninstall_skills(force=force)[0]
|
|
184
|
+
return result.path, cast(UninstallAction, result.action)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_agents(target: AgentSelector) -> tuple[AgentName, ...]:
|
|
188
|
+
if target == "detected":
|
|
189
|
+
agents = detect_agents()
|
|
190
|
+
if not agents:
|
|
191
|
+
choices = ", ".join(AGENT_TARGETS)
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"no supported agent targets matched local signals; "
|
|
194
|
+
f"choose one explicitly with --target ({choices})"
|
|
195
|
+
)
|
|
196
|
+
return agents
|
|
197
|
+
if target not in AGENT_TARGETS:
|
|
198
|
+
raise ValueError(f"unsupported agent target: {target}")
|
|
199
|
+
return (cast(AgentName, target),)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _prepare_install(
|
|
203
|
+
agent: AgentName, *, force: bool
|
|
204
|
+
) -> tuple[AgentName, Path, InstallAction]:
|
|
205
|
+
destination = agent_skills_root(agent) / "code-search"
|
|
206
|
+
_validate_target(agent, destination, _managed_paths(agent))
|
|
207
|
+
existed = destination.exists()
|
|
208
|
+
installed_digests = _installed_digests(destination, force=force)
|
|
209
|
+
expected_manifest = _manifest_content(agent).encode("utf-8")
|
|
210
|
+
manifest = destination / INSTALL_MANIFEST
|
|
211
|
+
changed = not manifest.exists() or manifest.read_bytes() != expected_manifest
|
|
212
|
+
source = _skill_source()
|
|
213
|
+
|
|
214
|
+
for relative in _managed_files(agent):
|
|
215
|
+
output = destination / relative
|
|
216
|
+
content = source.joinpath(relative.as_posix()).read_bytes()
|
|
217
|
+
if not output.exists():
|
|
218
|
+
changed = True
|
|
219
|
+
continue
|
|
220
|
+
digest = _content_digest(output.read_bytes())
|
|
221
|
+
if digest == _content_digest(content):
|
|
222
|
+
continue
|
|
223
|
+
trusted = installed_digests.get(relative) == digest or digest in LEGACY_FILE_DIGESTS.get(
|
|
224
|
+
relative, ()
|
|
225
|
+
)
|
|
226
|
+
if not force and not trusted:
|
|
227
|
+
raise FileExistsError(
|
|
228
|
+
f"refusing to overwrite modified skill file: {output}; rerun with --force"
|
|
229
|
+
)
|
|
37
230
|
changed = True
|
|
38
231
|
|
|
39
232
|
action: InstallAction = "unchanged" if not changed else ("updated" if existed else "created")
|
|
40
|
-
return
|
|
233
|
+
return agent, destination, action
|
|
41
234
|
|
|
42
235
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
236
|
+
def _prepare_uninstall(
|
|
237
|
+
agent: AgentName, *, force: bool
|
|
238
|
+
) -> tuple[AgentName, Path, UninstallAction]:
|
|
239
|
+
destination = agent_skills_root(agent) / "code-search"
|
|
240
|
+
_validate_target(agent, destination, _managed_paths(agent))
|
|
241
|
+
installed_digests = _installed_digests(destination, force=force)
|
|
242
|
+
source = _skill_source()
|
|
243
|
+
found = (destination / INSTALL_MANIFEST).exists()
|
|
47
244
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if not destination.exists():
|
|
245
|
+
for relative in _managed_files(agent):
|
|
246
|
+
output = destination / relative
|
|
247
|
+
if not output.exists():
|
|
52
248
|
continue
|
|
53
|
-
|
|
54
|
-
|
|
249
|
+
found = True
|
|
250
|
+
expected = source.joinpath(relative.as_posix()).read_bytes()
|
|
251
|
+
digest = _content_digest(output.read_bytes())
|
|
252
|
+
trusted = installed_digests.get(relative) == digest or digest in LEGACY_FILE_DIGESTS.get(
|
|
253
|
+
relative, ()
|
|
254
|
+
)
|
|
255
|
+
if digest != _content_digest(expected) and not force and not trusted:
|
|
55
256
|
raise FileExistsError(
|
|
56
|
-
f"refusing to remove modified skill file: {
|
|
257
|
+
f"refusing to remove modified skill file: {output}; rerun with --force"
|
|
57
258
|
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
259
|
+
|
|
260
|
+
return agent, destination, "removed" if found else "not-found"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _validate_target(
|
|
264
|
+
agent: AgentName, destination: Path, managed_files: tuple[Path, ...]
|
|
265
|
+
) -> None:
|
|
266
|
+
skills_root = destination.parent
|
|
267
|
+
config_root = skills_root.parent
|
|
268
|
+
guarded_roots = (config_root, *_additional_configuration_roots(agent))
|
|
269
|
+
guarded_paths = tuple(
|
|
270
|
+
dict.fromkeys(
|
|
271
|
+
path
|
|
272
|
+
for root in guarded_roots
|
|
273
|
+
for path in _configuration_path_components(root)
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
for path in guarded_paths:
|
|
277
|
+
if path.is_symlink():
|
|
278
|
+
raise OSError(f"refusing to manage a symlinked agent configuration path: {path}")
|
|
279
|
+
if path.exists() and not path.is_dir():
|
|
280
|
+
raise NotADirectoryError(f"agent configuration path is not a directory: {path}")
|
|
281
|
+
if skills_root.is_symlink():
|
|
282
|
+
raise OSError(f"refusing to manage a symlinked skills root: {skills_root}")
|
|
283
|
+
if skills_root.exists() and not skills_root.is_dir():
|
|
284
|
+
raise NotADirectoryError(f"skills root is not a directory: {skills_root}")
|
|
285
|
+
if destination.is_symlink():
|
|
286
|
+
raise OSError(f"refusing to manage a symlinked skill directory: {destination}")
|
|
287
|
+
if destination.exists() and not destination.is_dir():
|
|
288
|
+
raise NotADirectoryError(f"skill destination is not a directory: {destination}")
|
|
289
|
+
|
|
290
|
+
for relative in managed_files:
|
|
291
|
+
current = destination
|
|
292
|
+
for index, part in enumerate(relative.parts):
|
|
293
|
+
current /= part
|
|
294
|
+
if current.is_symlink():
|
|
295
|
+
raise OSError(f"refusing to manage a symlinked skill path: {current}")
|
|
296
|
+
if index < len(relative.parts) - 1 and current.exists() and not current.is_dir():
|
|
297
|
+
raise NotADirectoryError(f"skill path parent is not a directory: {current}")
|
|
298
|
+
if current.exists() and not current.is_file():
|
|
299
|
+
raise OSError(f"skill file destination is not a regular file: {current}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _remove_empty_managed_directories(destination: Path, managed_files: tuple[Path, ...]) -> None:
|
|
303
|
+
directories = {
|
|
304
|
+
destination / parent
|
|
305
|
+
for relative in managed_files
|
|
306
|
+
for parent in relative.parents
|
|
307
|
+
if parent != Path(".")
|
|
308
|
+
}
|
|
309
|
+
for directory in sorted(directories, key=lambda path: len(path.parts), reverse=True):
|
|
310
|
+
if directory.exists() and not any(directory.iterdir()):
|
|
311
|
+
directory.rmdir()
|
|
312
|
+
if destination.exists() and not any(destination.iterdir()):
|
|
313
|
+
destination.rmdir()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _agent_is_detected(agent: AgentName) -> bool:
|
|
317
|
+
destination = agent_skills_root(agent) / "code-search"
|
|
318
|
+
if destination.exists():
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
home = _home()
|
|
322
|
+
if agent == "codex":
|
|
323
|
+
return bool(os.environ.get("CODEX_HOME")) or (home / ".codex").exists() or bool(
|
|
324
|
+
shutil.which("codex")
|
|
325
|
+
)
|
|
326
|
+
if agent == "claude-code":
|
|
327
|
+
return bool(os.environ.get("CLAUDE_CONFIG_DIR")) or (home / ".claude").exists() or bool(
|
|
328
|
+
shutil.which("claude")
|
|
329
|
+
)
|
|
330
|
+
if agent == "cursor":
|
|
331
|
+
return (home / ".cursor").exists()
|
|
332
|
+
if agent == "gemini":
|
|
333
|
+
return bool(os.environ.get("GEMINI_CLI_HOME")) or (home / ".gemini").exists() or bool(
|
|
334
|
+
shutil.which("gemini")
|
|
335
|
+
)
|
|
336
|
+
if agent == "opencode":
|
|
337
|
+
configured = bool(os.environ.get("OPENCODE_CONFIG_DIR"))
|
|
338
|
+
config_home = _xdg_config_home(home)
|
|
339
|
+
return configured or (config_home / "opencode").exists() or bool(shutil.which("opencode"))
|
|
340
|
+
if agent == "github-copilot":
|
|
341
|
+
return bool(os.environ.get("COPILOT_HOME")) or (home / ".copilot").exists() or bool(
|
|
342
|
+
shutil.which("copilot")
|
|
343
|
+
)
|
|
344
|
+
return (home / ".kiro").exists() or bool(shutil.which("kiro-cli"))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _managed_files(agent: AgentName) -> tuple[Path, ...]:
|
|
348
|
+
return CODEX_SKILL_FILES if agent == "codex" else PORTABLE_SKILL_FILES
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _managed_paths(agent: AgentName) -> tuple[Path, ...]:
|
|
352
|
+
return (*_managed_files(agent), INSTALL_MANIFEST)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _manifest_content(agent: AgentName) -> str:
|
|
356
|
+
source = _skill_source()
|
|
357
|
+
digests = {
|
|
358
|
+
relative.as_posix(): _content_digest(
|
|
359
|
+
source.joinpath(relative.as_posix()).read_bytes()
|
|
360
|
+
)
|
|
361
|
+
for relative in _managed_files(agent)
|
|
362
|
+
}
|
|
363
|
+
return json.dumps(
|
|
364
|
+
{"schema": MANIFEST_SCHEMA, "files": digests},
|
|
365
|
+
indent=2,
|
|
366
|
+
sort_keys=True,
|
|
367
|
+
) + "\n"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _installed_digests(destination: Path, *, force: bool) -> dict[Path, str]:
|
|
371
|
+
manifest = destination / INSTALL_MANIFEST
|
|
372
|
+
if not manifest.exists():
|
|
373
|
+
return {}
|
|
374
|
+
try:
|
|
375
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
376
|
+
if data.get("schema") != MANIFEST_SCHEMA or not isinstance(data.get("files"), dict):
|
|
377
|
+
raise ValueError("unsupported manifest schema")
|
|
378
|
+
digests: dict[Path, str] = {}
|
|
379
|
+
for name, digest in data["files"].items():
|
|
380
|
+
relative = Path(name)
|
|
381
|
+
if (
|
|
382
|
+
not isinstance(name, str)
|
|
383
|
+
or not isinstance(digest, str)
|
|
384
|
+
or relative.is_absolute()
|
|
385
|
+
or ".." in relative.parts
|
|
386
|
+
or len(digest) != 64
|
|
387
|
+
or any(character not in "0123456789abcdef" for character in digest)
|
|
388
|
+
):
|
|
389
|
+
raise ValueError("invalid managed file entry")
|
|
390
|
+
digests[relative] = digest
|
|
391
|
+
return digests
|
|
392
|
+
except (AttributeError, json.JSONDecodeError, TypeError, ValueError) as error:
|
|
393
|
+
if force:
|
|
394
|
+
return {}
|
|
395
|
+
raise ValueError(
|
|
396
|
+
f"refusing to use invalid install manifest: {manifest}; rerun with --force"
|
|
397
|
+
) from error
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _content_digest(content: bytes) -> str:
|
|
401
|
+
try:
|
|
402
|
+
normalized = content.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
|
403
|
+
canonical = normalized.encode("utf-8")
|
|
404
|
+
except UnicodeDecodeError:
|
|
405
|
+
canonical = b"\x00binary\x00" + content
|
|
406
|
+
return hashlib.sha256(canonical).hexdigest()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _configuration_path_components(config_root: Path) -> tuple[Path, ...]:
|
|
410
|
+
home = _home()
|
|
411
|
+
components = [config_root]
|
|
412
|
+
if config_root != home and config_root.is_relative_to(home):
|
|
413
|
+
current = config_root.parent
|
|
414
|
+
while current != home:
|
|
415
|
+
components.append(current)
|
|
416
|
+
current = current.parent
|
|
417
|
+
return tuple(components)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _additional_configuration_roots(agent: AgentName) -> tuple[Path, ...]:
|
|
421
|
+
if agent == "gemini" and (value := os.environ.get("GEMINI_CLI_HOME")):
|
|
422
|
+
return (_configured_path(value, "GEMINI_CLI_HOME"),)
|
|
423
|
+
if agent == "opencode" and not os.environ.get("OPENCODE_CONFIG_DIR"):
|
|
424
|
+
value = os.environ.get("XDG_CONFIG_HOME")
|
|
425
|
+
if value:
|
|
426
|
+
path = Path(value).expanduser()
|
|
427
|
+
if path.is_absolute():
|
|
428
|
+
return (path,)
|
|
429
|
+
return ()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _atomic_write(destination: Path, content: bytes) -> None:
|
|
433
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
435
|
+
dir=destination.parent,
|
|
436
|
+
prefix=f".{destination.name}.",
|
|
437
|
+
)
|
|
438
|
+
try:
|
|
439
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
440
|
+
output.write(content)
|
|
441
|
+
os.replace(temporary, destination)
|
|
442
|
+
finally:
|
|
443
|
+
if os.path.exists(temporary):
|
|
444
|
+
os.unlink(temporary)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _skill_source():
|
|
448
|
+
return files("code_retrieval").joinpath("resources/code-search")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _configured_path(value: str, variable: str) -> Path:
|
|
452
|
+
path = Path(value).expanduser()
|
|
453
|
+
if not path.is_absolute():
|
|
454
|
+
raise ValueError(f"{variable} must be an absolute path: {value}")
|
|
455
|
+
return path
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _xdg_config_home(home: Path) -> Path:
|
|
459
|
+
value = os.environ.get("XDG_CONFIG_HOME")
|
|
460
|
+
if value:
|
|
461
|
+
candidate = Path(value).expanduser()
|
|
462
|
+
if candidate.is_absolute():
|
|
463
|
+
return candidate
|
|
464
|
+
return home / ".config"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _home() -> Path:
|
|
468
|
+
return Path.home()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-search-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Task-aware code search with compact, auditable evidence for coding agents
|
|
5
5
|
Author: Code Search contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -9,7 +9,7 @@ Project-URL: Documentation, https://code-search.quguai.cc/
|
|
|
9
9
|
Project-URL: Repository, https://github.com/quguai/code-search
|
|
10
10
|
Project-URL: Issues, https://github.com/quguai/code-search/issues
|
|
11
11
|
Project-URL: Changelog, https://github.com/quguai/code-search/releases
|
|
12
|
-
Keywords: code-search,code-retrieval,coding-agents,cli,codex
|
|
12
|
+
Keywords: code-search,code-retrieval,coding-agents,agent-skills,cli,codex
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Environment :: Console
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -39,7 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
<a href="https://pypi.org/project/code-search-cli/"><img alt="PyPI" src="https://img.shields.io/pypi/v/code-search-cli?style=flat-square" /></a>
|
|
40
40
|
<a href="https://github.com/quguai/code-search/blob/main/LICENSE"><img alt="Apache-2.0 license" src="https://img.shields.io/github/license/quguai/code-search?style=flat-square" /></a>
|
|
41
41
|
<img alt="Python 3.11+" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" />
|
|
42
|
-
<img alt="
|
|
42
|
+
<img alt="Agent Skill installers for seven agents" src="https://img.shields.io/badge/Agent_Skills-7_targets-5B47D6?style=flat-square" />
|
|
43
43
|
<img alt="Experimental status" src="https://img.shields.io/badge/status-experimental-D97706?style=flat-square" />
|
|
44
44
|
</p>
|
|
45
45
|
|
|
@@ -91,15 +91,19 @@ index inside it. It incrementally maintains a per-user SQLite index under the op
|
|
|
91
91
|
directory, honors Git ignore rules for working trees, and uses `code-search clean` to remove the
|
|
92
92
|
cached source snapshot and derived metadata.
|
|
93
93
|
|
|
94
|
-
### Optional
|
|
94
|
+
### Optional agent integration
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
|
-
code-search install
|
|
97
|
+
code-search install # Codex remains the default
|
|
98
|
+
code-search install --target cursor # Or select one target explicitly
|
|
98
99
|
```
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
The bundled `code-search` Agent Skill can be installed for Codex, Claude Code, Cursor, Gemini CLI,
|
|
102
|
+
OpenCode, GitHub Copilot, or Kiro. Installation is user-level and idempotent. It tracks hashes of
|
|
103
|
+
the files it installed, so an untouched older release can upgrade safely while a locally modified
|
|
104
|
+
skill is still protected unless `--force` is supplied. Add `--dry-run` to preview the operation.
|
|
105
|
+
The CLI remains the primary interface; installing the skill adds neither an MCP server nor a runtime
|
|
106
|
+
model.
|
|
103
107
|
|
|
104
108
|
### Why there is no model underneath
|
|
105
109
|
|
|
@@ -125,7 +129,10 @@ versioned comparison methods; they are not Code Search runtime dependencies.
|
|
|
125
129
|
default path.
|
|
126
130
|
- Compact exact-symbol windows across all indexed source formats when the whole task is an
|
|
127
131
|
identifier or qualified name.
|
|
128
|
-
- A bundled
|
|
132
|
+
- A bundled Agent Skill, with user-level filesystem installers for Codex, Claude Code, Cursor,
|
|
133
|
+
Gemini CLI, OpenCode, GitHub Copilot, and Kiro, that teaches the agent when to retrieve, expand,
|
|
134
|
+
or fall back to exact `rg`. Their paths, dry-run behavior, idempotence, safe upgrades, and
|
|
135
|
+
file-protection behavior have automated coverage.
|
|
129
136
|
|
|
130
137
|
## Task evidence beyond ranked matches
|
|
131
138
|
|
|
@@ -185,13 +192,52 @@ code-search expand \
|
|
|
185
192
|
--relation enclosing
|
|
186
193
|
```
|
|
187
194
|
|
|
188
|
-
Manage the
|
|
195
|
+
Manage the optional Agent Skill:
|
|
189
196
|
|
|
190
197
|
```bash
|
|
191
|
-
code-search install
|
|
192
|
-
code-search
|
|
198
|
+
code-search install # Codex
|
|
199
|
+
code-search install --target claude-code
|
|
200
|
+
code-search install --target cursor
|
|
201
|
+
code-search install --target gemini
|
|
202
|
+
code-search install --target opencode
|
|
203
|
+
code-search install --target github-copilot
|
|
204
|
+
code-search install --target kiro
|
|
205
|
+
code-search install --target detected # Best-effort convenience selector
|
|
206
|
+
code-search install --target detected --dry-run # Preview without writing
|
|
207
|
+
code-search uninstall --target cursor
|
|
208
|
+
code-search uninstall --target detected
|
|
193
209
|
```
|
|
194
210
|
|
|
211
|
+
The installer writes only managed Skill files and its hash manifest inside the selected agent's
|
|
212
|
+
user configuration:
|
|
213
|
+
|
|
214
|
+
| `--target` | User-level destination |
|
|
215
|
+
|---|---|
|
|
216
|
+
| `codex` | `$CODEX_HOME/skills/code-search/` when set; otherwise `~/.agents/skills/code-search/` |
|
|
217
|
+
| `claude-code` | `$CLAUDE_CONFIG_DIR/skills/code-search/` when set; otherwise `~/.claude/skills/code-search/` |
|
|
218
|
+
| `cursor` | `~/.cursor/skills/code-search/` |
|
|
219
|
+
| `gemini` | `$GEMINI_CLI_HOME/.gemini/skills/code-search/` when set; otherwise `~/.gemini/skills/code-search/` |
|
|
220
|
+
| `opencode` | `$OPENCODE_CONFIG_DIR/skills/code-search/` when set; otherwise `${XDG_CONFIG_HOME:-~/.config}/opencode/skills/code-search/` |
|
|
221
|
+
| `github-copilot` | `$COPILOT_HOME/skills/code-search/` when set; otherwise `~/.copilot/skills/code-search/` |
|
|
222
|
+
| `kiro` | `~/.kiro/skills/code-search/` |
|
|
223
|
+
|
|
224
|
+
Agent-specific environment overrides must be absolute paths; a relative `XDG_CONFIG_HOME` is ignored
|
|
225
|
+
as required by the XDG base-directory specification. An existing legacy Codex installation under
|
|
226
|
+
`~/.codex/skills/code-search/` is updated and removed in place instead of being silently migrated.
|
|
227
|
+
|
|
228
|
+
`detected` is a best-effort convenience selector based on known commands, configuration directories,
|
|
229
|
+
environment overrides, and an already-installed target. It does not launch a client or verify that
|
|
230
|
+
the client can load the skill. Cursor detection deliberately relies on `~/.cursor` rather than the
|
|
231
|
+
ambiguous `agent` executable name. Because some clients also scan another client's compatibility
|
|
232
|
+
directories, prefer an explicit target and use one if the same skill appears more than once.
|
|
233
|
+
|
|
234
|
+
Every selected destination is preflighted before writing or removing files. A small hidden manifest
|
|
235
|
+
records hashes of the managed files; it is installer metadata, not another instruction loaded by the
|
|
236
|
+
agent. This lets an untouched older installation upgrade without treating it as a user edit.
|
|
237
|
+
Uninstall removes only managed files whose content is still recognized and leaves extra user files
|
|
238
|
+
in place. Use `--force` only when you intentionally want to replace or remove a modified managed
|
|
239
|
+
file. `--dry-run` performs the same checks and reports the planned action without changing files.
|
|
240
|
+
|
|
195
241
|
Prewarm, inspect, or remove the per-repository index:
|
|
196
242
|
|
|
197
243
|
```bash
|
|
@@ -211,7 +257,7 @@ The repository publishes two recorded development comparisons. Both measure
|
|
|
211
257
|
scores must not be merged into one leaderboard. The shared Java runner reproduces all six methods;
|
|
212
258
|
the multilingual public runner currently reproduces BM25, Probe, and ripgrep, while the other three
|
|
213
259
|
rows retain their recorded implementation identities and per-task output. The Code Search rows
|
|
214
|
-
describe the source revisions recorded by each benchmark, not a new measurement of
|
|
260
|
+
describe the source revisions recorded by each benchmark, not a new measurement of the current release.
|
|
215
261
|
|
|
216
262
|
### Shared Java comparison
|
|
217
263
|
|
|
@@ -329,7 +375,8 @@ validation remain future work.
|
|
|
329
375
|
executable patch tests and repeated model runs.
|
|
330
376
|
- **P1, retrieval quality:** close the long-query ranking gaps surfaced by the multilingual
|
|
331
377
|
diagnostic, and add stronger AST/symbol parsers.
|
|
332
|
-
- **P1, onboarding
|
|
378
|
+
- **P1, onboarding validation:** expand beyond the seven current user-level Agent Skill destinations
|
|
379
|
+
only when another integration has a real discovery path and automated coverage. Consider MCP only
|
|
333
380
|
if a resident index or structured tool discovery provides a measured benefit over CLI invocation.
|
|
334
381
|
- **P2, distribution:** ship signed standalone binaries.
|
|
335
382
|
|
|
@@ -338,7 +385,7 @@ validation remain future work.
|
|
|
338
385
|
This is a testable experimental MVP, not a production release. The public development benchmark
|
|
339
386
|
measures retrieval, while the deterministic fixtures and tests cover output behavior and package
|
|
340
387
|
quality. None of them proves held-out retrieval superiority or executable patch success. The
|
|
341
|
-
implementation and bundled
|
|
388
|
+
implementation and bundled Agent Skill are independently designed around the task-evidence workflow.
|
|
342
389
|
|
|
343
390
|
## License
|
|
344
391
|
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
code_retrieval/__init__.py,sha256=zNqqZe91zhRnR4onrqkRk9iW7xxG60wiZeQmTgZ8awQ,220
|
|
2
2
|
code_retrieval/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
-
code_retrieval/cli.py,sha256
|
|
3
|
+
code_retrieval/cli.py,sha256=-ziVT56Uyxv_0pL8rvEgxvLxiBXVqSkFCufuYVt3ovE,8358
|
|
4
4
|
code_retrieval/engine.py,sha256=40K728-ktbCNkLrGYTW1SsYIVIcSSzwS-ZMtk3osM08,42442
|
|
5
5
|
code_retrieval/index_store.py,sha256=WMZlghrQA9wPiIpp7QkQ_wlLCaFbNhSqCdFUQiUvgLY,11580
|
|
6
|
-
code_retrieval/installer.py,sha256=
|
|
6
|
+
code_retrieval/installer.py,sha256=FGaKpMfV-N8sG5FNn2vN8_96GuOwC2indA278BldEKM,16820
|
|
7
7
|
code_retrieval/models.py,sha256=MXziQGHlgPLgzbwSWNvpmstW5oLga27D0fPZwwbKNKY,2736
|
|
8
8
|
code_retrieval/query.py,sha256=RrAYC-DuH78PEosjrt_rsEwqQwI4AGhTEZkV6dGaGys,3837
|
|
9
9
|
code_retrieval/repository.py,sha256=RbCN6XzampK9880mQbSoNkbVplY0qln1jNw5JpoD_v8,7806
|
|
10
10
|
code_retrieval/structure.py,sha256=pJ6ZFXXWqhGiw5rio8RY28NQ3GJiIVzSzdNKlyAsybE,7471
|
|
11
11
|
code_retrieval/resources/code-search/SKILL.md,sha256=mOv8zHkPUJDDjdznLHwo7t4KoBIlwtBta3wVADPWblo,2300
|
|
12
12
|
code_retrieval/resources/code-search/agents/openai.yaml,sha256=Qz6AxMi9Wg0ZxqO-xwsv2En-SDQ6LBQ4twi8pp_0vBs,212
|
|
13
|
-
code_search_cli-0.
|
|
14
|
-
code_search_cli-0.
|
|
15
|
-
code_search_cli-0.
|
|
16
|
-
code_search_cli-0.
|
|
17
|
-
code_search_cli-0.
|
|
18
|
-
code_search_cli-0.
|
|
19
|
-
code_search_cli-0.
|
|
13
|
+
code_search_cli-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
+
code_search_cli-0.3.0.dist-info/licenses/NOTICE,sha256=fORXMPhszVyKxagqcQGdbMdYt3irmab8QyyPnPOpk4c,117
|
|
15
|
+
code_search_cli-0.3.0.dist-info/METADATA,sha256=Jkq3svJC2bP2lYQJ0FBbX18ZKmEo6q8oQJe4c6902d8,22155
|
|
16
|
+
code_search_cli-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
17
|
+
code_search_cli-0.3.0.dist-info/entry_points.txt,sha256=Y4u1GuLbinsDr-jdhyQZF9XFEdnsco_byL8NTrDYp7w,56
|
|
18
|
+
code_search_cli-0.3.0.dist-info/top_level.txt,sha256=G2fxsJkpCt-QxmOlHVKR-XtsH9IZnM1lAhfurXOqsDw,15
|
|
19
|
+
code_search_cli-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|