cocoindex-code-plus 0.1.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.
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# © 2025 CocoIndex Inc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-CocoIndex-Proprietary
|
|
3
|
+
"""The ``ccx`` client CLI.
|
|
4
|
+
|
|
5
|
+
Commands stay close to the CocoIndex Code open source edition, but talk to a
|
|
6
|
+
remote query server over HTTP instead of a local daemon.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from . import client, settings
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
# Matches the namespace/repo path in a GitHub or GitLab remote URL (SSH or
|
|
25
|
+
# HTTPS, optional trailing `.git`). The path may be nested for GitLab subgroups,
|
|
26
|
+
# e.g. `git@gitlab.com:group/subgroup/project.git` -> `group/subgroup/project`;
|
|
27
|
+
# `https://github.com/owner/repo` -> `owner/repo`.
|
|
28
|
+
_REMOTE_RE = re.compile(r"(?:github|gitlab)\.com[:/](?P<path>.+?)(?:\.git)?/?$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_remote_url(url: str) -> str | None:
|
|
32
|
+
"""Extract the ``<namespace>/<repo>`` path from a GitHub/GitLab remote URL,
|
|
33
|
+
or ``None`` if it isn't one. The server resolves this name to a ``repo_key``."""
|
|
34
|
+
match = _REMOTE_RE.search(url)
|
|
35
|
+
return match["path"] if match else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detect_local_repo() -> str | None:
|
|
39
|
+
"""Return the ``<namespace>/<repo>`` of the CWD's ``origin`` remote (GitHub
|
|
40
|
+
or GitLab), or ``None`` if not in a git checkout / the remote isn't one of
|
|
41
|
+
those hosts."""
|
|
42
|
+
try:
|
|
43
|
+
proc = subprocess.run(
|
|
44
|
+
["git", "remote", "get-url", "origin"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=5,
|
|
48
|
+
check=True,
|
|
49
|
+
)
|
|
50
|
+
except (OSError, subprocess.SubprocessError):
|
|
51
|
+
return None
|
|
52
|
+
return _parse_remote_url(proc.stdout.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
app = typer.Typer(
|
|
56
|
+
name="ccx",
|
|
57
|
+
help="CocoIndex Code Plus — query indexed codebases from the command line.",
|
|
58
|
+
no_args_is_help=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.callback()
|
|
63
|
+
def _load_env() -> None:
|
|
64
|
+
"""Auto-load .env (non-overriding) before running any command."""
|
|
65
|
+
settings.load_env()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def status(
|
|
70
|
+
server: str | None = typer.Option(
|
|
71
|
+
None,
|
|
72
|
+
"--server",
|
|
73
|
+
help="Query server base URL (defaults to $CCX_SERVER_URL).",
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Check whether the query server is healthy."""
|
|
77
|
+
base_url = server or settings.server_url()
|
|
78
|
+
try:
|
|
79
|
+
result = client.health(base_url)
|
|
80
|
+
except httpx.HTTPError as exc:
|
|
81
|
+
typer.secho(f"Query server unreachable at {base_url}: {exc}", fg=typer.colors.RED)
|
|
82
|
+
raise typer.Exit(code=1) from exc
|
|
83
|
+
|
|
84
|
+
typer.secho(f"Query server: {result.status}", fg=typer.colors.GREEN)
|
|
85
|
+
typer.echo(f" url: {base_url}")
|
|
86
|
+
typer.echo(f" version: {result.version}")
|
|
87
|
+
typer.echo(f" uptime: {result.uptime_seconds:.1f}s")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _error_detail(exc: httpx.HTTPStatusError) -> str:
|
|
91
|
+
"""Pull the server's ``detail`` message out of an error response."""
|
|
92
|
+
try:
|
|
93
|
+
body = exc.response.json()
|
|
94
|
+
except ValueError:
|
|
95
|
+
return exc.response.text or str(exc)
|
|
96
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
97
|
+
return str(detail) if detail else str(exc)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def search(
|
|
102
|
+
query: list[str] = typer.Argument(..., help="Search query (words are joined)."),
|
|
103
|
+
top_k: int = typer.Option(5, "-k", "--top-k", help="Max results to return."),
|
|
104
|
+
repo: str | None = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--repo",
|
|
107
|
+
help="Restrict results to one indexed repo, as '<owner>/<repo>' or bare "
|
|
108
|
+
"'<repo>'. Default: auto-detect from the current git checkout.",
|
|
109
|
+
),
|
|
110
|
+
all_repos: bool = typer.Option(
|
|
111
|
+
False,
|
|
112
|
+
"--all-repos",
|
|
113
|
+
help="Search across all indexed repos (disables repo auto-detection).",
|
|
114
|
+
),
|
|
115
|
+
git_ref: str | None = typer.Option(
|
|
116
|
+
None,
|
|
117
|
+
"--git-ref",
|
|
118
|
+
help="Restrict results to one indexed git ref of the repo, qualified as "
|
|
119
|
+
"'heads/<branch>' or 'tags/<tag>' (requires a repo scope).",
|
|
120
|
+
),
|
|
121
|
+
server: str | None = typer.Option(
|
|
122
|
+
None,
|
|
123
|
+
"--server",
|
|
124
|
+
help="Query server base URL (defaults to $CCX_SERVER_URL).",
|
|
125
|
+
),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Semantic search across the indexed codebases.
|
|
128
|
+
|
|
129
|
+
By default this filters to the repo of the current git checkout (detected
|
|
130
|
+
from its ``origin`` GitHub remote). Pass ``--repo`` to target another, or
|
|
131
|
+
``--all-repos`` to search everything.
|
|
132
|
+
"""
|
|
133
|
+
if all_repos and repo is not None:
|
|
134
|
+
typer.secho("Pass either --repo or --all-repos, not both.", fg=typer.colors.RED)
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
if all_repos:
|
|
138
|
+
selected_repo: str | None = None
|
|
139
|
+
elif repo is not None:
|
|
140
|
+
selected_repo = repo
|
|
141
|
+
else:
|
|
142
|
+
selected_repo = _detect_local_repo()
|
|
143
|
+
if selected_repo is not None:
|
|
144
|
+
typer.secho(
|
|
145
|
+
f"Filtering to {selected_repo} (use --all-repos to search everything).",
|
|
146
|
+
fg=typer.colors.BRIGHT_BLACK,
|
|
147
|
+
err=True,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
typer.secho(
|
|
151
|
+
"No GitHub repo detected in the current directory; searching all "
|
|
152
|
+
"indexed repos (use --repo <owner>/<repo> to scope).",
|
|
153
|
+
fg=typer.colors.BRIGHT_BLACK,
|
|
154
|
+
err=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if git_ref is not None and selected_repo is None:
|
|
158
|
+
typer.secho(
|
|
159
|
+
"--git-ref requires a repo scope; pass --repo <owner>/<repo> "
|
|
160
|
+
"(not available with --all-repos).",
|
|
161
|
+
fg=typer.colors.RED,
|
|
162
|
+
)
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
|
|
165
|
+
base_url = server or settings.server_url()
|
|
166
|
+
query_text = " ".join(query)
|
|
167
|
+
try:
|
|
168
|
+
result = client.search(
|
|
169
|
+
query_text,
|
|
170
|
+
top_k=top_k,
|
|
171
|
+
repo=selected_repo,
|
|
172
|
+
git_ref=git_ref,
|
|
173
|
+
base_url=base_url,
|
|
174
|
+
)
|
|
175
|
+
except httpx.HTTPStatusError as exc:
|
|
176
|
+
typer.secho(
|
|
177
|
+
f"Search failed (HTTP {exc.response.status_code}): {_error_detail(exc)}",
|
|
178
|
+
fg=typer.colors.RED,
|
|
179
|
+
)
|
|
180
|
+
raise typer.Exit(code=1) from exc
|
|
181
|
+
except httpx.HTTPError as exc:
|
|
182
|
+
typer.secho(f"Query server unreachable at {base_url}: {exc}", fg=typer.colors.RED)
|
|
183
|
+
raise typer.Exit(code=1) from exc
|
|
184
|
+
|
|
185
|
+
if not result.results:
|
|
186
|
+
typer.echo("No results.")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
for item in result.results:
|
|
190
|
+
typer.secho(
|
|
191
|
+
f"[{item.score:.3f}] {item.repo_key} {item.filename} "
|
|
192
|
+
f"(L{item.start_line}-L{item.end_line})",
|
|
193
|
+
fg=typer.colors.CYAN,
|
|
194
|
+
)
|
|
195
|
+
typer.echo(item.code.rstrip("\n"))
|
|
196
|
+
typer.echo("---")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def grep(
|
|
201
|
+
pattern: str = typer.Argument(
|
|
202
|
+
...,
|
|
203
|
+
help=r"Structural pattern matched against the AST (not text). The '\' sigil "
|
|
204
|
+
r"marks metavariables, e.g. 'def \NAME(\(ARGS*\)):' or 'foo(\X)'.",
|
|
205
|
+
),
|
|
206
|
+
language: str = typer.Option(
|
|
207
|
+
..., "-l", "--language", help="Source language to parse + match (e.g. python, rust, c++)."
|
|
208
|
+
),
|
|
209
|
+
git_ref: str = typer.Option(
|
|
210
|
+
..., "--git-ref", help="Indexed git ref, qualified as 'heads/<branch>' or 'tags/<tag>'."
|
|
211
|
+
),
|
|
212
|
+
repo: str | None = typer.Option(
|
|
213
|
+
None, "--repo", help="Indexed repo (default: auto-detect from the git checkout)."
|
|
214
|
+
),
|
|
215
|
+
paths: list[str] = typer.Option(
|
|
216
|
+
None, "--path", help="Restrict to files matching this glob (repeatable, e.g. 'src/*.py')."
|
|
217
|
+
),
|
|
218
|
+
limit: int = typer.Option(100, "-k", "--limit", help="Max matches to return."),
|
|
219
|
+
offset: int = typer.Option(0, "--offset", help="Skip this many matches (pagination)."),
|
|
220
|
+
server: str | None = typer.Option(None, "--server", help="Query server base URL."),
|
|
221
|
+
) -> None:
|
|
222
|
+
"""AST-based structural grep over an indexed repo at a given ref.
|
|
223
|
+
|
|
224
|
+
The pattern is parsed and matched against the code's syntax tree, so
|
|
225
|
+
``foo(\\X)`` matches calls to ``foo`` regardless of formatting and never matches
|
|
226
|
+
the text inside comments or strings.
|
|
227
|
+
"""
|
|
228
|
+
base_url = server or settings.server_url()
|
|
229
|
+
result = _run(
|
|
230
|
+
base_url,
|
|
231
|
+
lambda: client.grep(
|
|
232
|
+
pattern=pattern,
|
|
233
|
+
language=language,
|
|
234
|
+
repo=_require_repo(repo),
|
|
235
|
+
git_ref=git_ref,
|
|
236
|
+
paths=list(paths or []),
|
|
237
|
+
limit=limit,
|
|
238
|
+
offset=offset,
|
|
239
|
+
base_url=base_url,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
if not result.matches:
|
|
243
|
+
typer.echo("No matches.")
|
|
244
|
+
return
|
|
245
|
+
for m in result.matches:
|
|
246
|
+
header = f"{m.filename}:{m.start_line}-{m.end_line} [{m.kind}]"
|
|
247
|
+
typer.secho(header, fg=typer.colors.CYAN)
|
|
248
|
+
if m.captures:
|
|
249
|
+
caps = " ".join(f"{name}={text!r}" for name, text in sorted(m.captures.items()))
|
|
250
|
+
typer.secho(f" captures: {caps}", fg=typer.colors.BRIGHT_BLACK)
|
|
251
|
+
typer.echo(m.code.rstrip("\n"))
|
|
252
|
+
typer.echo("---")
|
|
253
|
+
if result.truncated:
|
|
254
|
+
typer.secho(
|
|
255
|
+
f"… more matches past {offset + len(result.matches)}; page with --offset/--limit.",
|
|
256
|
+
fg=typer.colors.BRIGHT_BLACK,
|
|
257
|
+
err=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _require_repo(repo: str | None) -> str:
|
|
262
|
+
"""Resolve the repo for a single-repo command: ``--repo`` if given, else the
|
|
263
|
+
current git checkout's ``origin`` remote. Exits 1 with guidance if neither."""
|
|
264
|
+
selected = repo if repo is not None else _detect_local_repo()
|
|
265
|
+
if selected is None:
|
|
266
|
+
typer.secho(
|
|
267
|
+
"No repo specified and none detected from the current git checkout; "
|
|
268
|
+
"pass --repo <owner>/<repo>.",
|
|
269
|
+
fg=typer.colors.RED,
|
|
270
|
+
)
|
|
271
|
+
raise typer.Exit(code=1)
|
|
272
|
+
return selected
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _run(base_url: str, call: Callable[[], T]) -> T:
|
|
276
|
+
"""Run a ``client`` request, mapping HTTP/transport errors to a clean CLI exit
|
|
277
|
+
(mirrors the ``search`` command's error handling)."""
|
|
278
|
+
try:
|
|
279
|
+
return call()
|
|
280
|
+
except httpx.HTTPStatusError as exc:
|
|
281
|
+
typer.secho(
|
|
282
|
+
f"Request failed (HTTP {exc.response.status_code}): {_error_detail(exc)}",
|
|
283
|
+
fg=typer.colors.RED,
|
|
284
|
+
)
|
|
285
|
+
raise typer.Exit(code=1) from exc
|
|
286
|
+
except httpx.HTTPError as exc:
|
|
287
|
+
typer.secho(f"Query server unreachable at {base_url}: {exc}", fg=typer.colors.RED)
|
|
288
|
+
raise typer.Exit(code=1) from exc
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command(name="read-file")
|
|
292
|
+
def read_file(
|
|
293
|
+
path: str = typer.Argument(..., help="Repo-relative file path to read."),
|
|
294
|
+
git_ref: str = typer.Option(
|
|
295
|
+
..., "--git-ref", help="Indexed git ref, qualified as 'heads/<branch>' or 'tags/<tag>'."
|
|
296
|
+
),
|
|
297
|
+
repo: str | None = typer.Option(
|
|
298
|
+
None, "--repo", help="Indexed repo (default: auto-detect from the git checkout)."
|
|
299
|
+
),
|
|
300
|
+
offset: int = typer.Option(1, "--offset", help="1-based first line to print."),
|
|
301
|
+
limit: int | None = typer.Option(None, "--limit", help="Number of lines (default: to EOF)."),
|
|
302
|
+
server: str | None = typer.Option(None, "--server", help="Query server base URL."),
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Print a file's contents as they exist in a given ref."""
|
|
305
|
+
base_url = server or settings.server_url()
|
|
306
|
+
result = _run(
|
|
307
|
+
base_url,
|
|
308
|
+
lambda: client.read_file(
|
|
309
|
+
repo=_require_repo(repo),
|
|
310
|
+
git_ref=git_ref,
|
|
311
|
+
path=path,
|
|
312
|
+
offset=offset,
|
|
313
|
+
limit=limit,
|
|
314
|
+
base_url=base_url,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
typer.echo(result.content, nl=False)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@app.command(name="find-files")
|
|
321
|
+
def find_files(
|
|
322
|
+
patterns: list[str] = typer.Argument(
|
|
323
|
+
None, help="Path globs (e.g. 'src/*.py'); none lists all files."
|
|
324
|
+
),
|
|
325
|
+
git_ref: str = typer.Option(..., "--git-ref", help="Indexed git ref (e.g. 'heads/main')."),
|
|
326
|
+
repo: str | None = typer.Option(
|
|
327
|
+
None, "--repo", help="Indexed repo (default: auto-detect from the git checkout)."
|
|
328
|
+
),
|
|
329
|
+
case: str = typer.Option("smart", "--case", help="smart | sensitive | insensitive."),
|
|
330
|
+
limit: int = typer.Option(100, "--limit", help="Max paths to return."),
|
|
331
|
+
offset: int = typer.Option(0, "--offset", help="Skip this many matches (pagination)."),
|
|
332
|
+
server: str | None = typer.Option(None, "--server", help="Query server base URL."),
|
|
333
|
+
) -> None:
|
|
334
|
+
"""List indexed files in a ref, optionally filtered by glob patterns."""
|
|
335
|
+
base_url = server or settings.server_url()
|
|
336
|
+
result = _run(
|
|
337
|
+
base_url,
|
|
338
|
+
lambda: client.find_files(
|
|
339
|
+
repo=_require_repo(repo),
|
|
340
|
+
git_ref=git_ref,
|
|
341
|
+
patterns=list(patterns or []),
|
|
342
|
+
case=case,
|
|
343
|
+
limit=limit,
|
|
344
|
+
offset=offset,
|
|
345
|
+
base_url=base_url,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
for p in result.paths:
|
|
349
|
+
typer.echo(p)
|
|
350
|
+
shown = len(result.paths)
|
|
351
|
+
total = result.total
|
|
352
|
+
if total > shown + offset:
|
|
353
|
+
typer.secho(
|
|
354
|
+
f"… {total} total (showing {shown}); page with --offset/--limit.",
|
|
355
|
+
fg=typer.colors.BRIGHT_BLACK,
|
|
356
|
+
err=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.command()
|
|
361
|
+
def repositories(
|
|
362
|
+
repo: str | None = typer.Argument(
|
|
363
|
+
None, help="Indexed repo (default: auto-detect from the git checkout)."
|
|
364
|
+
),
|
|
365
|
+
server: str | None = typer.Option(None, "--server", help="Query server base URL."),
|
|
366
|
+
) -> None:
|
|
367
|
+
"""List a repo's indexed git refs and the commit SHA indexed for each."""
|
|
368
|
+
base_url = server or settings.server_url()
|
|
369
|
+
result = _run(base_url, lambda: client.repositories(_require_repo(repo), base_url=base_url))
|
|
370
|
+
typer.secho(result.repo_key, fg=typer.colors.CYAN)
|
|
371
|
+
for ref in result.refs:
|
|
372
|
+
typer.echo(f" {ref.git_ref}\t{ref.commit_sha}")
|
|
373
|
+
if not result.refs:
|
|
374
|
+
typer.echo(" (no indexed refs)")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@app.command()
|
|
378
|
+
def version() -> None:
|
|
379
|
+
"""Print the CLI version."""
|
|
380
|
+
typer.echo(__version__)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
app()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# © 2025 CocoIndex Inc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-CocoIndex-Proprietary
|
|
3
|
+
"""Client-side helpers for talking to the query server over HTTP.
|
|
4
|
+
|
|
5
|
+
The CLI uses these functions; each opens its own short-lived HTTP request, so
|
|
6
|
+
there is no persistent client object to manage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from . import settings
|
|
16
|
+
from .protocol import (
|
|
17
|
+
FIND_FILES_PATH,
|
|
18
|
+
GREP_PATH,
|
|
19
|
+
READ_FILE_PATH,
|
|
20
|
+
REPOSITORIES_PATH,
|
|
21
|
+
SEMANTIC_SEARCH_PATH,
|
|
22
|
+
FindFilesResponse,
|
|
23
|
+
GrepResponse,
|
|
24
|
+
HealthResponse,
|
|
25
|
+
ReadFileResponse,
|
|
26
|
+
RepositoriesResponse,
|
|
27
|
+
SearchResponse,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT = 10.0
|
|
31
|
+
# Searching embeds the query and hits the database, so allow a bit more time.
|
|
32
|
+
SEARCH_TIMEOUT = 30.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _auth_headers() -> dict[str, str]:
|
|
36
|
+
"""``Authorization: Bearer`` header when a token is configured, else empty.
|
|
37
|
+
|
|
38
|
+
The token comes from ``CCX_API_TOKEN`` (see ``settings.api_token``). Sending
|
|
39
|
+
it is harmless against a ``none``-mode server and required by an
|
|
40
|
+
``apiKey``-mode one; ``/health`` is auth-exempt but carrying the header there
|
|
41
|
+
too keeps one code path.
|
|
42
|
+
"""
|
|
43
|
+
token = settings.api_token()
|
|
44
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def health(base_url: str | None = None) -> HealthResponse:
|
|
48
|
+
"""Query the server's health endpoint.
|
|
49
|
+
|
|
50
|
+
Raises ``httpx.HTTPError`` if the server is unreachable or returns a
|
|
51
|
+
non-2xx status.
|
|
52
|
+
"""
|
|
53
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
54
|
+
resp = httpx.get(f"{url}/health", timeout=DEFAULT_TIMEOUT, headers=_auth_headers())
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
return HealthResponse.model_validate(resp.json())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def search(
|
|
60
|
+
query: str,
|
|
61
|
+
*,
|
|
62
|
+
top_k: int = 5,
|
|
63
|
+
offset: int = 0,
|
|
64
|
+
repo: str | None = None,
|
|
65
|
+
git_ref: str | None = None,
|
|
66
|
+
paths: list[str] | None = None,
|
|
67
|
+
base_url: str | None = None,
|
|
68
|
+
) -> SearchResponse:
|
|
69
|
+
"""Run a semantic search against the query server.
|
|
70
|
+
|
|
71
|
+
``repo`` names one indexed repo as ``<owner>/<repo>`` or bare ``<repo>``;
|
|
72
|
+
``None`` searches across all indexed repos. ``offset`` paginates the ranked
|
|
73
|
+
results; ``paths`` (globs) scopes to matching filenames.
|
|
74
|
+
|
|
75
|
+
Raises ``httpx.HTTPError`` if the server is unreachable or returns a
|
|
76
|
+
non-2xx status (e.g. 503 when the index hasn't been built yet).
|
|
77
|
+
"""
|
|
78
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
79
|
+
payload: dict[str, object] = {"query": query, "top_k": top_k, "offset": offset}
|
|
80
|
+
if repo is not None:
|
|
81
|
+
payload["repo"] = repo
|
|
82
|
+
if git_ref is not None:
|
|
83
|
+
payload["git_ref"] = git_ref
|
|
84
|
+
if paths:
|
|
85
|
+
payload["paths"] = paths
|
|
86
|
+
resp = httpx.post(
|
|
87
|
+
f"{url}{SEMANTIC_SEARCH_PATH}",
|
|
88
|
+
json=payload,
|
|
89
|
+
timeout=SEARCH_TIMEOUT,
|
|
90
|
+
headers=_auth_headers(),
|
|
91
|
+
)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
return SearchResponse.model_validate(resp.json())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def grep(
|
|
97
|
+
*,
|
|
98
|
+
pattern: str,
|
|
99
|
+
language: str,
|
|
100
|
+
repo: str,
|
|
101
|
+
git_ref: str,
|
|
102
|
+
paths: list[str] | None = None,
|
|
103
|
+
limit: int = 100,
|
|
104
|
+
offset: int = 0,
|
|
105
|
+
base_url: str | None = None,
|
|
106
|
+
) -> GrepResponse:
|
|
107
|
+
"""Run an AST structural grep against the query server.
|
|
108
|
+
|
|
109
|
+
``pattern`` is a by-example structural pattern (``\\`` sigil for metavariables);
|
|
110
|
+
``language`` is the source language to parse + match. Matching is scoped to
|
|
111
|
+
``git_ref`` of ``repo``; ``paths`` (globs) narrows the file set. Raises
|
|
112
|
+
``httpx.HTTPError`` on an unreachable server or a non-2xx status (400 bad
|
|
113
|
+
pattern / unknown repo, 503 grep unavailable or index not built yet).
|
|
114
|
+
"""
|
|
115
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
116
|
+
payload: dict[str, object] = {
|
|
117
|
+
"pattern": pattern,
|
|
118
|
+
"language": language,
|
|
119
|
+
"repo": repo,
|
|
120
|
+
"git_ref": git_ref,
|
|
121
|
+
"limit": limit,
|
|
122
|
+
"offset": offset,
|
|
123
|
+
}
|
|
124
|
+
if paths:
|
|
125
|
+
payload["paths"] = paths
|
|
126
|
+
resp = httpx.post(
|
|
127
|
+
f"{url}{GREP_PATH}", json=payload, timeout=SEARCH_TIMEOUT, headers=_auth_headers()
|
|
128
|
+
)
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
return GrepResponse.model_validate(resp.json())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_file(
|
|
134
|
+
*,
|
|
135
|
+
repo: str,
|
|
136
|
+
git_ref: str,
|
|
137
|
+
path: str,
|
|
138
|
+
offset: int = 1,
|
|
139
|
+
limit: int | None = None,
|
|
140
|
+
base_url: str | None = None,
|
|
141
|
+
) -> ReadFileResponse:
|
|
142
|
+
"""Read a line window of ``path`` as it exists in ``git_ref`` of ``repo``.
|
|
143
|
+
|
|
144
|
+
``offset`` is the 1-based first line; ``limit`` the number of lines (``None``
|
|
145
|
+
→ to end of file). Raises ``httpx.HTTPError`` on an unreachable server or a
|
|
146
|
+
non-2xx status (400 unknown repo / bad range, 404 path not in ref, 503 index
|
|
147
|
+
not built yet).
|
|
148
|
+
"""
|
|
149
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
150
|
+
payload: dict[str, object] = {"repo": repo, "git_ref": git_ref, "path": path, "offset": offset}
|
|
151
|
+
if limit is not None:
|
|
152
|
+
payload["limit"] = limit
|
|
153
|
+
resp = httpx.post(
|
|
154
|
+
f"{url}{READ_FILE_PATH}", json=payload, timeout=DEFAULT_TIMEOUT, headers=_auth_headers()
|
|
155
|
+
)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
return ReadFileResponse.model_validate(resp.json())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def find_files(
|
|
161
|
+
*,
|
|
162
|
+
repo: str,
|
|
163
|
+
git_ref: str,
|
|
164
|
+
patterns: list[str] | None = None,
|
|
165
|
+
case: str = "smart",
|
|
166
|
+
limit: int = 100,
|
|
167
|
+
offset: int = 0,
|
|
168
|
+
base_url: str | None = None,
|
|
169
|
+
) -> FindFilesResponse:
|
|
170
|
+
"""List files in ``git_ref`` of ``repo`` matching any glob in ``patterns``.
|
|
171
|
+
|
|
172
|
+
Empty ``patterns`` lists all files. Raises ``httpx.HTTPError`` on an
|
|
173
|
+
unreachable server or a non-2xx status (400 unknown repo / bad args, 503
|
|
174
|
+
index not built yet).
|
|
175
|
+
"""
|
|
176
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
177
|
+
payload: dict[str, object] = {
|
|
178
|
+
"repo": repo,
|
|
179
|
+
"git_ref": git_ref,
|
|
180
|
+
"patterns": patterns or [],
|
|
181
|
+
"case": case,
|
|
182
|
+
"limit": limit,
|
|
183
|
+
"offset": offset,
|
|
184
|
+
}
|
|
185
|
+
resp = httpx.post(
|
|
186
|
+
f"{url}{FIND_FILES_PATH}", json=payload, timeout=DEFAULT_TIMEOUT, headers=_auth_headers()
|
|
187
|
+
)
|
|
188
|
+
resp.raise_for_status()
|
|
189
|
+
return FindFilesResponse.model_validate(resp.json())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def repositories(repo: str, *, base_url: str | None = None) -> RepositoriesResponse:
|
|
193
|
+
"""List a repo's indexed refs and their HEAD commit SHAs.
|
|
194
|
+
|
|
195
|
+
``repo`` is ``<owner>/<repo>``, a bare ``<repo>``, or the exact ``repo_key``
|
|
196
|
+
(slashes preserved for a GitLab nested namespace). Raises ``httpx.HTTPError``
|
|
197
|
+
on an unreachable server or a non-2xx status (400 unknown repo, 503 index not
|
|
198
|
+
built yet).
|
|
199
|
+
"""
|
|
200
|
+
url = (base_url or settings.server_url()).rstrip("/")
|
|
201
|
+
# Keep slashes — the route captures the rest of the path as the repo id.
|
|
202
|
+
resp = httpx.get(
|
|
203
|
+
f"{url}{REPOSITORIES_PATH}/{quote(repo, safe='/')}",
|
|
204
|
+
timeout=DEFAULT_TIMEOUT,
|
|
205
|
+
headers=_auth_headers(),
|
|
206
|
+
)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return RepositoriesResponse.model_validate(resp.json())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cocoindex-code-plus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client CLI for CocoIndex Code Plus: query indexed codebases from your workstation.
|
|
5
|
+
License-Expression: LicenseRef-CocoIndex-Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: cocoindex-code-plus-common
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: typer>=0.12.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# cocoindex-code-plus (CLI)
|
|
13
|
+
|
|
14
|
+
The `ccx` client CLI for CocoIndex Code Plus. See the repo root README.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
cocoindex_code_plus/cli.py,sha256=qNj5ebJZ8Mkz7IZ9Paf1vvL_AaLkOEneoWNe1dAasdA,13517
|
|
2
|
+
cocoindex_code_plus/client.py,sha256=CBYFbPm3YJuR5MI132DC3Nkj4kcIUzZkJxKRhLZQ_IE,6818
|
|
3
|
+
cocoindex_code_plus-0.1.0.dist-info/METADATA,sha256=Tjf4eFLotcWRdNTVFZC4S0prgfcRdmcL5BqV87prlHI,474
|
|
4
|
+
cocoindex_code_plus-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
cocoindex_code_plus-0.1.0.dist-info/entry_points.txt,sha256=osXgNNJtf-AaZGSdNL8vbfCPbMqrA8i-UVuDdii4fqs,52
|
|
6
|
+
cocoindex_code_plus-0.1.0.dist-info/RECORD,,
|