@shirlytaylor73/smart-search 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/README.zh-CN.md +180 -0
  4. package/npm/bin/smart-search.js +68 -0
  5. package/npm/scripts/postinstall.js +87 -0
  6. package/npm/scripts/resolve-prerelease-version.js +108 -0
  7. package/npm/scripts/set-package-version.js +35 -0
  8. package/npm/scripts/sync-python-version.js +22 -0
  9. package/npm/scripts/test-wrapper-repair.js +137 -0
  10. package/npm/scripts/test.js +76 -0
  11. package/package.json +42 -0
  12. package/pyproject.toml +36 -0
  13. package/skills/smart-search-cli/SKILL.md +41 -0
  14. package/skills/smart-search-cli/agents/openai.yaml +3 -0
  15. package/skills/smart-search-cli/references/cli-contract.md +7 -0
  16. package/skills/smart-search-cli/references/cli-core.md +5 -0
  17. package/skills/smart-search-cli/references/command-patterns.md +12 -0
  18. package/skills/smart-search-cli/references/provider-routing.md +3 -0
  19. package/skills/smart-search-cli/references/regression-release.md +28 -0
  20. package/skills/smart-search-cli/references/setup-config.md +3 -0
  21. package/src/smart_search/__init__.py +1 -0
  22. package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
  23. package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
  24. package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
  25. package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
  26. package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
  27. package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
  28. package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
  29. package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
  30. package/src/smart_search/cli.py +3118 -0
  31. package/src/smart_search/config.py +809 -0
  32. package/src/smart_search/embedding_presets.py +40 -0
  33. package/src/smart_search/intent_router.py +757 -0
  34. package/src/smart_search/logger.py +43 -0
  35. package/src/smart_search/providers/__init__.py +20 -0
  36. package/src/smart_search/providers/base.py +41 -0
  37. package/src/smart_search/providers/context7.py +141 -0
  38. package/src/smart_search/providers/exa.py +206 -0
  39. package/src/smart_search/providers/jina.py +136 -0
  40. package/src/smart_search/providers/openai_compatible.py +541 -0
  41. package/src/smart_search/providers/xai_responses.py +117 -0
  42. package/src/smart_search/providers/zhipu.py +143 -0
  43. package/src/smart_search/providers/zhipu_mcp.py +230 -0
  44. package/src/smart_search/service.py +3445 -0
  45. package/src/smart_search/skill_installer.py +342 -0
  46. package/src/smart_search/sources.py +429 -0
  47. package/src/smart_search/utils.py +220 -0
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from hashlib import sha256
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ SKILL_NAME = "smart-search-cli"
12
+ PACKAGE_ROOT_ENV = "SMART_SEARCH_PACKAGE_ROOT"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SkillTarget:
17
+ target_id: str
18
+ label: str
19
+ relative_root: str
20
+ default: bool = False
21
+
22
+ @property
23
+ def skill_relative_path(self) -> str:
24
+ return f"{self.relative_root}/{SKILL_NAME}"
25
+
26
+
27
+ SKILL_TARGETS: tuple[SkillTarget, ...] = (
28
+ SkillTarget("codex", "Codex", ".codex/skills", True),
29
+ SkillTarget("claude", "Claude Code", ".claude/skills", True),
30
+ SkillTarget("cursor", "Cursor", ".cursor/skills", True),
31
+ SkillTarget("opencode", "OpenCode", ".opencode/skills"),
32
+ SkillTarget("copilot", "GitHub Copilot", ".copilot/skills"),
33
+ SkillTarget("gemini", "Gemini CLI", ".gemini/skills"),
34
+ SkillTarget("kiro", "Kiro", ".kiro/skills"),
35
+ SkillTarget("qoder", "Qoder", ".qoder/skills"),
36
+ SkillTarget("codebuddy", "CodeBuddy", ".codebuddy/skills"),
37
+ SkillTarget("droid", "Factory Droid", ".factory/skills"),
38
+ SkillTarget("pi", "Pi Agent", ".pi/agent/skills"),
39
+ SkillTarget("kilo", "Kilo CLI", ".kilocode/skills"),
40
+ SkillTarget("antigravity", "Antigravity", ".agent/skills"),
41
+ SkillTarget("windsurf", "Windsurf", ".windsurf/skills"),
42
+ SkillTarget("hermes", "Hermes Agent", ".hermes/skills"),
43
+ )
44
+
45
+ SKILL_TARGET_BY_ID = {target.target_id: target for target in SKILL_TARGETS}
46
+ DEFAULT_SKILL_TARGET_IDS = [target.target_id for target in SKILL_TARGETS if target.default]
47
+
48
+ _TARGET_ALIASES = {
49
+ "agents": "codex",
50
+ "agentskills": "codex",
51
+ "agent-skills": "codex",
52
+ "claude-code": "claude",
53
+ "github-copilot": "copilot",
54
+ "gh-copilot": "copilot",
55
+ "factory": "droid",
56
+ "factory-droid": "droid",
57
+ "pi-agent": "pi",
58
+ "kilo-cli": "kilo",
59
+ "hermes-agent": "hermes",
60
+ "nous-hermes": "hermes",
61
+ }
62
+
63
+
64
+ class SkillInstallError(ValueError):
65
+ pass
66
+
67
+
68
+ def parse_skill_targets(raw: str) -> list[str]:
69
+ if not raw.strip():
70
+ return []
71
+ tokens = [part.strip().lower() for part in raw.replace(";", ",").replace("+", ",").split(",")]
72
+ if len(tokens) == 1 and " " in tokens[0]:
73
+ tokens = [part.strip().lower() for part in tokens[0].split()]
74
+
75
+ selected: list[str] = []
76
+ invalid: list[str] = []
77
+ for token in tokens:
78
+ if not token:
79
+ continue
80
+ if token in {"skip", "none", "no", "n", "跳过", "无", "否"}:
81
+ return []
82
+ if token in {"all", "全部"}:
83
+ return [target.target_id for target in SKILL_TARGETS]
84
+ target_id = _TARGET_ALIASES.get(token, token)
85
+ if target_id not in SKILL_TARGET_BY_ID:
86
+ invalid.append(token)
87
+ continue
88
+ if target_id not in selected:
89
+ selected.append(target_id)
90
+
91
+ if invalid:
92
+ valid = ", ".join(target.target_id for target in SKILL_TARGETS)
93
+ raise SkillInstallError(f"Unknown skill target(s): {', '.join(invalid)}. Valid targets: {valid}")
94
+ return selected
95
+
96
+
97
+ def _resource_skill_root() -> Any:
98
+ try:
99
+ root = resources.files("smart_search").joinpath("assets", "skills", SKILL_NAME)
100
+ if root.is_dir():
101
+ return root
102
+ except (FileNotFoundError, ModuleNotFoundError, AttributeError):
103
+ pass
104
+ return None
105
+
106
+
107
+ def _filesystem_skill_root() -> Path | None:
108
+ candidates: list[Path] = []
109
+ package_root = os.getenv(PACKAGE_ROOT_ENV, "").strip()
110
+ if package_root:
111
+ base = Path(package_root)
112
+ candidates.extend([
113
+ base / "src" / "smart_search" / "assets" / "skills" / SKILL_NAME,
114
+ base / "skills" / SKILL_NAME,
115
+ ])
116
+
117
+ repo_root = Path(__file__).resolve().parents[2]
118
+ candidates.extend([
119
+ repo_root / "src" / "smart_search" / "assets" / "skills" / SKILL_NAME,
120
+ repo_root / "skills" / SKILL_NAME,
121
+ ])
122
+
123
+ for candidate in candidates:
124
+ if candidate.is_dir():
125
+ return candidate
126
+ return None
127
+
128
+
129
+ def _iter_resource_files(root: Any) -> list[tuple[str, bytes]]:
130
+ files: list[tuple[str, bytes]] = []
131
+
132
+ def visit(node: Any, prefix: str = "") -> None:
133
+ for child in node.iterdir():
134
+ rel = f"{prefix}/{child.name}" if prefix else child.name
135
+ if child.is_dir():
136
+ visit(child, rel)
137
+ elif child.is_file():
138
+ files.append((rel, child.read_bytes()))
139
+
140
+ visit(root)
141
+ return files
142
+
143
+
144
+ def _iter_filesystem_files(root: Path) -> list[tuple[str, bytes]]:
145
+ return [
146
+ (str(path.relative_to(root)).replace("\\", "/"), path.read_bytes())
147
+ for path in root.rglob("*")
148
+ if path.is_file()
149
+ ]
150
+
151
+
152
+ def _load_skill_files(source_root: Path | None = None) -> list[tuple[str, bytes]]:
153
+ if source_root is not None:
154
+ if not source_root.is_dir():
155
+ raise SkillInstallError(f"Skill source directory not found: {source_root}")
156
+ return _iter_filesystem_files(source_root)
157
+
158
+ resource_root = _resource_skill_root()
159
+ if resource_root is not None:
160
+ files = _iter_resource_files(resource_root)
161
+ if files:
162
+ return files
163
+
164
+ filesystem_root = _filesystem_skill_root()
165
+ if filesystem_root is not None:
166
+ files = _iter_filesystem_files(filesystem_root)
167
+ if files:
168
+ return files
169
+
170
+ raise SkillInstallError("Bundled smart-search-cli skill files were not found.")
171
+
172
+
173
+ def _skill_digest(files: list[tuple[str, bytes]]) -> str:
174
+ digest = sha256()
175
+ for rel_path, content in sorted(files, key=lambda item: item[0]):
176
+ digest.update(rel_path.encode("utf-8"))
177
+ digest.update(b"\0")
178
+ digest.update(content)
179
+ digest.update(b"\0")
180
+ return digest.hexdigest()
181
+
182
+
183
+ def _target_installed_files(path: Path) -> list[tuple[str, bytes]]:
184
+ if not path.is_dir():
185
+ return []
186
+ return _iter_filesystem_files(path)
187
+
188
+
189
+ def status_skill_targets(
190
+ target_ids: list[str],
191
+ *,
192
+ project_root: str | Path | None = None,
193
+ source_root: str | Path | None = None,
194
+ ) -> dict[str, Any]:
195
+ root = Path(project_root).expanduser().resolve() if project_root else Path.home().expanduser().resolve()
196
+ selected = [SKILL_TARGET_BY_ID[target_id] for target_id in target_ids]
197
+ source = Path(source_root).expanduser().resolve() if source_root is not None else None
198
+ source_files = _load_skill_files(source)
199
+ source_by_path = {rel_path: content for rel_path, content in source_files}
200
+ bundled_digest = _skill_digest(source_files)
201
+ targets: list[dict[str, Any]] = []
202
+
203
+ for target in selected:
204
+ dest = root / Path(target.skill_relative_path)
205
+ item: dict[str, Any] = {
206
+ "target": target.target_id,
207
+ "label": target.label,
208
+ "path": str(dest),
209
+ "status": "missing",
210
+ "files": len(source_files),
211
+ "installed_files": 0,
212
+ "bundled_hash": bundled_digest,
213
+ "installed_hash": "",
214
+ "hash_match": False,
215
+ "managed_hash_match": False,
216
+ "extra_files": [],
217
+ "missing_files": sorted(source_by_path),
218
+ "stale_files": [],
219
+ }
220
+ try:
221
+ installed_files = _target_installed_files(dest)
222
+ installed_by_path = {rel_path: content for rel_path, content in installed_files}
223
+ item["installed_files"] = len(installed_files)
224
+ if not dest.exists():
225
+ targets.append(item)
226
+ continue
227
+ if not dest.is_dir():
228
+ item["status"] = "error"
229
+ item["error"] = "Installed skill path exists but is not a directory."
230
+ targets.append(item)
231
+ continue
232
+
233
+ installed_digest = _skill_digest(installed_files)
234
+ extra_files = sorted(rel_path for rel_path in installed_by_path if rel_path not in source_by_path)
235
+ missing_files = sorted(rel_path for rel_path in source_by_path if rel_path not in installed_by_path)
236
+ stale_files = sorted(
237
+ rel_path
238
+ for rel_path, content in source_by_path.items()
239
+ if rel_path in installed_by_path and installed_by_path[rel_path] != content
240
+ )
241
+ managed_hash_match = not missing_files and not stale_files
242
+ hash_match = managed_hash_match and not extra_files
243
+ item.update(
244
+ {
245
+ "installed_hash": installed_digest if installed_files else "",
246
+ "hash_match": hash_match,
247
+ "managed_hash_match": managed_hash_match,
248
+ "extra_files": extra_files,
249
+ "missing_files": missing_files,
250
+ "stale_files": stale_files,
251
+ }
252
+ )
253
+ if missing_files or stale_files:
254
+ item["status"] = "stale"
255
+ elif extra_files:
256
+ item["status"] = "extra_files"
257
+ else:
258
+ item["status"] = "up_to_date"
259
+ except OSError as e:
260
+ item["status"] = "error"
261
+ item["error"] = str(e)
262
+ targets.append(item)
263
+
264
+ status_counts: dict[str, int] = {}
265
+ for item in targets:
266
+ status = str(item.get("status", "error"))
267
+ status_counts[status] = status_counts.get(status, 0) + 1
268
+
269
+ return {
270
+ "ok": not any(item.get("status") == "error" for item in targets),
271
+ "root": str(root),
272
+ "selected": [target.target_id for target in selected],
273
+ "skill": SKILL_NAME,
274
+ "bundled_files": len(source_files),
275
+ "bundled_hash": bundled_digest,
276
+ "targets": targets,
277
+ "status_counts": status_counts,
278
+ }
279
+
280
+
281
+ def install_skill_targets(
282
+ target_ids: list[str],
283
+ *,
284
+ project_root: str | Path | None = None,
285
+ source_root: str | Path | None = None,
286
+ ) -> dict[str, Any]:
287
+ root = Path(project_root).expanduser().resolve() if project_root else Path.home().expanduser().resolve()
288
+ selected = [SKILL_TARGET_BY_ID[target_id] for target_id in target_ids]
289
+ if not selected:
290
+ return {
291
+ "ok": True,
292
+ "root": str(root),
293
+ "installed": [],
294
+ "skipped": [],
295
+ "failed": [],
296
+ "selected": [],
297
+ "installed_count": 0,
298
+ "skipped_count": 0,
299
+ "failed_count": 0,
300
+ }
301
+
302
+ source = Path(source_root).expanduser().resolve() if source_root is not None else None
303
+ files = _load_skill_files(source)
304
+ installed: list[dict[str, Any]] = []
305
+ failed: list[dict[str, str]] = []
306
+
307
+ for target in selected:
308
+ dest = root / Path(target.skill_relative_path)
309
+ try:
310
+ for rel_path, content in files:
311
+ file_path = dest / Path(rel_path)
312
+ file_path.parent.mkdir(parents=True, exist_ok=True)
313
+ file_path.write_bytes(content)
314
+ installed.append(
315
+ {
316
+ "target": target.target_id,
317
+ "label": target.label,
318
+ "path": str(dest),
319
+ "files": len(files),
320
+ }
321
+ )
322
+ except OSError as e:
323
+ failed.append(
324
+ {
325
+ "target": target.target_id,
326
+ "label": target.label,
327
+ "path": str(dest),
328
+ "error": str(e),
329
+ }
330
+ )
331
+
332
+ return {
333
+ "ok": not failed,
334
+ "root": str(root),
335
+ "installed": installed,
336
+ "skipped": [],
337
+ "failed": failed,
338
+ "selected": [target.target_id for target in selected],
339
+ "installed_count": len(installed),
340
+ "skipped_count": 0,
341
+ "failed_count": len(failed),
342
+ }