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 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 install_codex_skill, uninstall_codex_skill
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 Codex skill")
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
- uninstall = subparsers.add_parser("uninstall", help="remove the bundled Codex skill")
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 = install_codex_skill if args.command == "install" else uninstall_codex_skill
67
- path, action = operation(force=args.force)
68
- print(f"Codex skill {action}: {path}")
69
- if args.command == "install":
70
- print("Restart Codex to load the skill.")
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
 
@@ -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
- SKILL_FILES = (Path("SKILL.md"), Path("agents/openai.yaml"))
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
- codex_home = os.environ.get("CODEX_HOME")
15
- return (Path(codex_home).expanduser() if codex_home else Path.home() / ".codex") / "skills"
118
+ """Return the compatible Codex destination used by ``code-search install``."""
119
+ return agent_skills_root("codex")
16
120
 
17
121
 
18
- def install_codex_skill(*, force: bool = False) -> tuple[Path, InstallAction]:
19
- target = codex_skills_root() / "code-search"
20
- source = files("code_retrieval").joinpath("resources/code-search")
21
- existed = target.exists()
22
- changed = False
23
-
24
- for relative in SKILL_FILES:
25
- destination = target / relative
26
- content = source.joinpath(relative.as_posix()).read_text(encoding="utf-8")
27
- if destination.exists():
28
- existing = destination.read_text(encoding="utf-8")
29
- if existing == content:
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
- if not force:
32
- raise FileExistsError(
33
- f"refusing to overwrite modified skill file: {destination}; rerun with --force"
34
- )
35
- destination.parent.mkdir(parents=True, exist_ok=True)
36
- destination.write_text(content, encoding="utf-8")
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 target, action
233
+ return agent, destination, action
41
234
 
42
235
 
43
- def uninstall_codex_skill(*, force: bool = False) -> tuple[Path, Literal["removed", "not-found"]]:
44
- target = codex_skills_root() / "code-search"
45
- if not target.exists():
46
- return target, "not-found"
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
- source = files("code_retrieval").joinpath("resources/code-search")
49
- for relative in SKILL_FILES:
50
- destination = target / relative
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
- expected = source.joinpath(relative.as_posix()).read_text(encoding="utf-8")
54
- if destination.read_text(encoding="utf-8") != expected and not force:
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: {destination}; rerun with --force"
257
+ f"refusing to remove modified skill file: {output}; rerun with --force"
57
258
  )
58
- destination.unlink()
59
-
60
- agents = target / "agents"
61
- if agents.exists() and not any(agents.iterdir()):
62
- agents.rmdir()
63
- if target.exists() and not any(target.iterdir()):
64
- target.rmdir()
65
- return target, "removed"
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.2.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&amp;logo=python&amp;logoColor=white" />
42
- <img alt="Codex skill included" src="https://img.shields.io/badge/Codex-skill_included-5B47D6?style=flat-square" />
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 Codex integration
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
- This installs the bundled `code-search` skill into Codex. Restart Codex so it can discover the
101
- skill. The installer is idempotent and refuses to overwrite a locally modified skill unless
102
- `--force` is supplied.
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 Codex skill that teaches the agent when to retrieve, expand, or fall back to exact `rg`.
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 Codex skill:
195
+ Manage the optional Agent Skill:
189
196
 
190
197
  ```bash
191
- code-search install
192
- code-search uninstall
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 version 0.2.0.
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 breadth:** add safe CLI/skill installers for more coding agents. Consider MCP only
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 Codex skill are independently designed around the task-evidence workflow.
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=ACdzH_bfq-ZAYIm5hAw-FgjxmjrgDLScJaxRea4PF8g,6909
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=l4wdDk3jSe2EQT4vGZhZvY8oCIEJofprUyoy6XIOi9s,2415
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.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
14
- code_search_cli-0.2.0.dist-info/licenses/NOTICE,sha256=fORXMPhszVyKxagqcQGdbMdYt3irmab8QyyPnPOpk4c,117
15
- code_search_cli-0.2.0.dist-info/METADATA,sha256=JUIaWZlBNO_E-gmV3IrnDGWT2YCu905QlFaM85-2e1U,18655
16
- code_search_cli-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
17
- code_search_cli-0.2.0.dist-info/entry_points.txt,sha256=Y4u1GuLbinsDr-jdhyQZF9XFEdnsco_byL8NTrDYp7w,56
18
- code_search_cli-0.2.0.dist-info/top_level.txt,sha256=G2fxsJkpCt-QxmOlHVKR-XtsH9IZnM1lAhfurXOqsDw,15
19
- code_search_cli-0.2.0.dist-info/RECORD,,
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,,