@bigknoxy/hashpilot 4.6.6 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/docs/CLI-QUICKREF.md +21 -2
- package/docs/PLAN-search-adapter.md +130 -0
- package/docs/zvec-grep-integration.md +92 -0
- package/package.json +1 -1
- package/scripts/install.sh +263 -21
- package/src/cli.ts +2 -0
- package/src/commands/maintenance.ts +20 -3
- package/src/commands/search.ts +70 -0
- package/src/core/config.ts +18 -0
- package/src/core/index.ts +3 -1
- package/src/core/search.ts +228 -0
package/README.md
CHANGED
|
@@ -175,8 +175,9 @@ silent one.
|
|
|
175
175
|
### Upgrade
|
|
176
176
|
|
|
177
177
|
```bash
|
|
178
|
-
hashpilot upgrade # upgrade to latest
|
|
178
|
+
hashpilot upgrade # upgrade to the latest npm release (falls back to GitHub if npm is unreachable)
|
|
179
179
|
hashpilot upgrade --dry-run # preview what would happen
|
|
180
|
+
hashpilot upgrade --channel some-branch # bleeding-edge: install that exact git branch instead, skipping npm
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
### Uninstall
|
|
@@ -357,7 +358,7 @@ diffable, and CI fails on any case that regresses from green.
|
|
|
357
358
|
|
|
358
359
|
| Command | What It Does |
|
|
359
360
|
|---------|-------------|
|
|
360
|
-
| `upgrade [--dry-run] [--channel <branch>] [--target <dir>] [--keep-telemetry] [--force]` | Upgrade HashPilot
|
|
361
|
+
| `upgrade [--dry-run] [--channel <branch>] [--target <dir>] [--keep-telemetry] [--force]` | Upgrade HashPilot to the latest version — npm by default, falling back to GitHub; `--channel <branch>` installs that exact git branch instead |
|
|
361
362
|
|
|
362
363
|
### Edit — Hash Route
|
|
363
364
|
|
package/docs/CLI-QUICKREF.md
CHANGED
|
@@ -167,7 +167,7 @@ looks unrelated to AST. Green baseline is `bun test` fully passing (515 pass / 0
|
|
|
167
167
|
|
|
168
168
|
<!-- BEGIN GENERATED: command reference -->
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
_37 commands, generated from `--help`. Do not edit by hand — run `bun run gen:cli-quickref`._
|
|
171
171
|
|
|
172
172
|
### Global options
|
|
173
173
|
|
|
@@ -765,7 +765,7 @@ hashpilot doctor [options]
|
|
|
765
765
|
|
|
766
766
|
#### `upgrade`
|
|
767
767
|
|
|
768
|
-
Upgrade HashPilot to the latest version
|
|
768
|
+
Upgrade HashPilot to the latest version (npm, falling back to GitHub)
|
|
769
769
|
|
|
770
770
|
```
|
|
771
771
|
hashpilot upgrade [options]
|
|
@@ -824,4 +824,23 @@ hashpilot config [options]
|
|
|
824
824
|
|------|---------|
|
|
825
825
|
| `--config <path>` | Config file path override |
|
|
826
826
|
|
|
827
|
+
#### `search`
|
|
828
|
+
|
|
829
|
+
Search a workspace: zg (zvec-grep) semantic/lexical when available, grep fallback. Usage: search "<query>" (zg) or search --engine grep "<pattern>" [paths...]
|
|
830
|
+
|
|
831
|
+
```
|
|
832
|
+
hashpilot search [options] <query> [paths...]
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
| Positional | Meaning |
|
|
836
|
+
|------------|---------|
|
|
837
|
+
| `query` | Query text (plain language for zg; a regex only makes sense on the grep engine) |
|
|
838
|
+
| `paths` | Paths to search (grep engine only; zg searches its indexed workspace) |
|
|
839
|
+
|
|
840
|
+
| Flag | Meaning |
|
|
841
|
+
|------|---------|
|
|
842
|
+
| `--engine <engine>` | Search engine: auto, zg, grep, off (default: config or auto) (default: "auto") |
|
|
843
|
+
| `--glob <glob>` | Source glob filter, repeatable (default: code extensions) (default: []) |
|
|
844
|
+
| `--zg-bin <path>` | Path to the zg binary (default: ZG_BIN env, then PATH) |
|
|
845
|
+
|
|
827
846
|
<!-- END GENERATED: command reference -->
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# PLAN — Optional zg Search Adapter (`hashpilot search`)
|
|
2
|
+
|
|
3
|
+
Status: **scoped, NOT built.** Decision artifact for `docs/zvec-grep-integration.md` Option 2.
|
|
4
|
+
This is what it would take, enumerated as *falsifiers* (tests that invalidate the naive
|
|
5
|
+
design) plus a TDD implementation plan. Follow `test-driven-development`.
|
|
6
|
+
|
|
7
|
+
**Goal:** an optional `hashpilot search` subcommand that uses zg for semantic/lexical search
|
|
8
|
+
when available and configured, degrading to the existing `grep-many` otherwise — with zero
|
|
9
|
+
change to HashPilot's standalone behavior.
|
|
10
|
+
|
|
11
|
+
**Boundary rules (from Option 1 rejection):** zg stays an external CLI. No `@zvec/zvec-grep`
|
|
12
|
+
npm dependency. Node 22+ is a *documented* optional requirement, never an enforcement.
|
|
13
|
+
`grep-many` and the entire editing core are untouched.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Falsifier set (the design is wrong if any of these tests pass)
|
|
18
|
+
|
|
19
|
+
Each row: the naive assumption → the falsifying test → the design it forces.
|
|
20
|
+
|
|
21
|
+
- **F1 — "semantic output is strictly better, pass it through."**
|
|
22
|
+
Observed: default Model2Vec ranks `.md` docs over source. *Test:* pointer that the top zg
|
|
23
|
+
hit for a code query is a `.md` file → `search` MUST NOT return docs when
|
|
24
|
+
`sourceGlobs` is set. *Forces:* `sourceGlobs` filter (default `*.ts,*.js,*.py,*.go,*.rs`),
|
|
25
|
+
passed to zg as `-g`.
|
|
26
|
+
|
|
27
|
+
- **F2 — "zg is always on PATH."**
|
|
28
|
+
*Test:* `ZG_BIN` unset / zg absent → `search` returns grep-many-equivalent results, exit 0,
|
|
29
|
+
telemetry records a `search_degraded` reason. *Forces:* a resolve step + clean degraded
|
|
30
|
+
path with a named exit code, never a crash.
|
|
31
|
+
|
|
32
|
+
- **F3 — "semantic search works with no index."**
|
|
33
|
+
*Test:* fresh tree, no `.zvec-grep/` → `zg query` fails. `search` MUST surface an
|
|
34
|
+
actionable error ("run `zg index` first", a dedicated errorCode), not an opaque spawn
|
|
35
|
+
failure. *Forces:* index-state detection before querying.
|
|
36
|
+
|
|
37
|
+
- **F4 — "one output shape across all zg routes."**
|
|
38
|
+
*Test:* parser fed captured hybrid / fts / vector / rg outputs (with header + freshness
|
|
39
|
+
lines) yields the same `{file,startLine,endLine}` regardless of route. *Forces:* a
|
|
40
|
+
route-aware parser, golden-tested on fixture artifacts.
|
|
41
|
+
|
|
42
|
+
- **F5 — "grep fallback equals grep-many."**
|
|
43
|
+
*Test (parity):* `search "<regex>"` with engine=grep and zg absent produces the
|
|
44
|
+
*byte-identical* JSON body that `grep-many "<regex>"` produces for the same inputs.
|
|
45
|
+
*Forces:* a shared result mapper; no drift between the two search paths.
|
|
46
|
+
|
|
47
|
+
- **F6 — "config toggle is cosmetic."**
|
|
48
|
+
*Test:* `engine: "off"` with a real zg present → a fake zg records **zero** invocations.
|
|
49
|
+
*Forces:* the policy check runs *before* any spawn; `off` never touches zg.
|
|
50
|
+
|
|
51
|
+
- **F7 — "search may build convenience indexes."**
|
|
52
|
+
zg's own rule: *an agent must never silently create/rebuild a persistent index.*
|
|
53
|
+
*Test:* running `search` on an unindexed tree must NOT create `.zvec-grep/`.
|
|
54
|
+
*Forces:* query-only; missing index ⇒ error, never build.
|
|
55
|
+
|
|
56
|
+
- **F8 — "spawn exit 0 ⇒ success."**
|
|
57
|
+
HashPilot's grep lesson (`core/grep.ts:156-180`): code 1 + empty stderr = zero matches,
|
|
58
|
+
nonzero + JSON stdout = real error. *Test:* zg exits 2 with stderr but emits parseable
|
|
59
|
+
markdown → reported as a search *error*, not silently dropped. *Forces:* replicate grep.ts
|
|
60
|
+
`runCommand` semantics.
|
|
61
|
+
|
|
62
|
+
- **F9 — "results fit GrepResult, just add semantics."**
|
|
63
|
+
zg has no column and emits grouped line spans; forcing it into `{path,line,column,content}`
|
|
64
|
+
is lossy and lies. *Test:* a `SearchResult` must carry `{file,startLine,endLine,heading?,
|
|
65
|
+
scope?}` and NOT claim a `column`. *Forces:* a distinct type; no cross-field duplication
|
|
66
|
+
with `GrepResult`.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Files touched
|
|
71
|
+
|
|
72
|
+
- **Create** `src/core/search.ts` — the adapter + parser + resolve + fallback (models
|
|
73
|
+
`core/grep.ts:156` `runCommand` and `parseGrepLine`).
|
|
74
|
+
- **Create** `src/commands/search.ts` — commander registration mirroring `commands/read.ts`
|
|
75
|
+
(single `search` command: `<query>` positional, `--line`/`--glob`/`--engine` flags).
|
|
76
|
+
- **Modify** `src/cli.ts` — `register(searchCommands)`.
|
|
77
|
+
- **Modify** `src/core/config.ts` — add to `HashPilotConfig` (`#56-63`):
|
|
78
|
+
```ts
|
|
79
|
+
search?: {
|
|
80
|
+
engine?: "auto" | "zg" | "grep"; // auto: use zg if resolvable
|
|
81
|
+
sourceGlobs?: string[]; // default ["*.ts","*.js","*.py","*.go","*.rs"]
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
Read from `.hashpilot.json` via `loadConfig` (`config.ts:111`).
|
|
85
|
+
- **Modify** `src/core/doctor.ts` — report zg presence in the environment health check.
|
|
86
|
+
- **Modify** `src/core/index.ts` — export `search`.
|
|
87
|
+
- **Docs gate:** regenerate `docs/CLI-QUICKREF.md` (`bun run gen:cli-quickref`) and add a
|
|
88
|
+
ROADMAP row (`lint:roadmap`). Both are CI-enforced contracts.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## TDD implementation (vertical tracer bullets, RED→GREEN each)
|
|
93
|
+
|
|
94
|
+
A **fake `zg` fixture** (env-injected via `ZG_BIN`) keeps tests hermetic — real subprocess
|
|
95
|
+
(no mock), printed canned agent-markdown per query. Same style as `tests/grep.test.ts`
|
|
96
|
+
(real subprocess, temp trees, no mocks).
|
|
97
|
+
|
|
98
|
+
- **TB1 (F2/F4):** `search` resolves fake zg + parses one hybrid query → `SearchResult[]`.
|
|
99
|
+
RED: `tests/search.test.ts` — "search parses zg hybrid hits into file+span".
|
|
100
|
+
- **TB2 (F5):** zg absent / `--engine grep` → result JSON byte-identical to `grep-many`.
|
|
101
|
+
RED: parity test against a call of the real `grepMany`.
|
|
102
|
+
- **TB3 (F6):** `engine:"off"` never spawns zg (fake zg invocation counter stays 0).
|
|
103
|
+
RED: policy test; GREEN: check-policy-before-spawn.
|
|
104
|
+
- **TB4 (F1):** sourceGlobs filters the doc hit out of results.
|
|
105
|
+
RED: seed fake zg with a `.md`-first fixture; expect it dropped under source mode.
|
|
106
|
+
- **TB5 (F3/F7):** unindexed tree → actionable error and no `.zvec-grep/` created.
|
|
107
|
+
RED: assert errorCode + `!existsSync(".zvec-grep")`.
|
|
108
|
+
- **TB6:** CLI contract — `hashpilot search "<q>"` wires end-to-end; quickref regenerated.
|
|
109
|
+
Uses `tests/cli-contract.test.ts` pattern.
|
|
110
|
+
|
|
111
|
+
**Spike first (throwaway, delete after):** parse one real `zg query` hybrid/fts/vector/rg
|
|
112
|
+
output into a fixture, choose the regex — proves the parser before TDD (allowed: exploration
|
|
113
|
+
thrown away, then TDD).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Effort
|
|
118
|
+
|
|
119
|
+
~2 new files + 4 small edits (config, cli, index, doctor), ~400–550 LOC incl. tests.
|
|
120
|
+
~6 TDD bullets, one focused session each. Biggest risk is **agend-markdown parser
|
|
121
|
+
brittleness** — mitigated by golden fixtures; the durable fix is zg's MCP JSON endpoint
|
|
122
|
+
(swap the parser for an MCP call in a follow-up, keep the same `SearchResult` type).
|
|
123
|
+
|
|
124
|
+
## Risks / notes
|
|
125
|
+
- zg's CLI has **no `--json`** — the whole adapter's stability rests on the agent-markdown
|
|
126
|
+
format. Acceptable for a prototype; MCP path is the production answer.
|
|
127
|
+
- Default embedding ranks docs over source — hence `sourceGlobs` is a hard requirement, not
|
|
128
|
+
a nice-to-have (F1).
|
|
129
|
+
- zero behavior change to grep-many/editing (boundary rule) is itself a falsifier: the full
|
|
130
|
+
existing suite must stay green.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# zg (zvec-grep) × HashPilot Integration
|
|
2
|
+
|
|
3
|
+
Status: **proven prototype** (Option 3: docs-only integration). HashPilot does not
|
|
4
|
+
depend on zg. zg is an optional, separately-installed search layer; HashPilot edits.
|
|
5
|
+
|
|
6
|
+
## What zg is / is not
|
|
7
|
+
|
|
8
|
+
- **zg answers "WHERE is the code?"** — semantic (plain-language), BM25, and ripgrep
|
|
9
|
+
behind one local-first index. Repo: `zvec-ai/zvec-grep`, Apache 2.0, npm `@zvec/zvec-grep`,
|
|
10
|
+
Node 22+. Default embedder `local/potion-code-16m-v2` is a static Model2Vec — no GPU.
|
|
11
|
+
- **HashPilot answers "HOW do I change it safely once found?"** — hash-anchored, AST-aware,
|
|
12
|
+
provenance-tracked edits.
|
|
13
|
+
- **They do not overlap except at one point:** HashPilot's `grep-many`/`symbol-lookup-many`
|
|
14
|
+
(exact/token lookup) ≈ zg's `--rg`/index path. zg's *semantic* route is the capability
|
|
15
|
+
HashPilot genuinely lacks. Neither replaces the other — zg does zero editing, HashPilot
|
|
16
|
+
does zero semantic search.
|
|
17
|
+
|
|
18
|
+
## The recommended pipeline (search → edit)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
zg query "<plain language>" → file + line span (the NEIGHBORHOOD)
|
|
22
|
+
hashpilot read-hash <file> <line> → SHA-256 anchor (the precision anchor)
|
|
23
|
+
hashpilot replace-hash <file> <hash> <new> --range N:N (the guaranteed edit)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
zg locates *which file & which broader region*; HashPilot needs a *single precise line* to
|
|
27
|
+
anchor an edit. Feed zg's span, pick the anchor line, let HashPilot guarantee the edit.
|
|
28
|
+
|
|
29
|
+
## HashPilot anchor semantics (read before scripting edits)
|
|
30
|
+
|
|
31
|
+
`replace-hash <file> <oldHash> <newContent> --range N:M` verifies against **the hash of
|
|
32
|
+
exactly lines N..M joined by "\n"** (`content.split("\n").slice(N-1,M).join("\n")`,
|
|
33
|
+
`src/core/hash-edit.ts`). Getting the anchor wrong ⇒ every edit is `HASH_MISMATCH`.
|
|
34
|
+
|
|
35
|
+
- `read-hash <file> <line>` returns two anchors, keyed `lineHash` and `contextHash`:
|
|
36
|
+
- `lineHash` = hash of that one line → pair with `--range N:N`
|
|
37
|
+
- `contextHash` = hash of the 7-line window (3 before + line + 3 after) → pair with a
|
|
38
|
+
`--range` covering that same window. Widening/capping the range makes it no longer match.
|
|
39
|
+
- There is no generic `hash` key. Multi-line edits: compute the joined-lines hash yourself.
|
|
40
|
+
- **Stale edits are refused, never guessed past:** mismatch → `STALE_ANCHOR` (zero window
|
|
41
|
+
matches) or `AMBIGUOUS_ANCHOR` (two matches). Default recovery `relocate` only re-anchors
|
|
42
|
+
when exactly one same-width window matches.
|
|
43
|
+
- On success `newHash` is the hash of the just-written *range* (not the file) so it chains
|
|
44
|
+
directly into the next edit of the same region.
|
|
45
|
+
- **Scripting gotcha:** on failure hashpilot exits **status 3 but still writes JSON to
|
|
46
|
+
stdout**. In `execSync` a throw ≠ the failure signal — check `e.status`, parse `e.stdout`.
|
|
47
|
+
|
|
48
|
+
## zg CLI facts observed
|
|
49
|
+
|
|
50
|
+
- **No `--json` output mode** (removed). Default is agent-markdown; parse
|
|
51
|
+
`matchedBy=… (\S+):(\d+)-(\d+)`. Production JSON lives on zg's MCP server
|
|
52
|
+
(`http://127.0.0.1:7999/mcp`, Streamable HTTP).
|
|
53
|
+
- **Default embedding ranks docs over source on code queries.** `zg query "router chooses
|
|
54
|
+
edit strategy"` surfaced `*.md` before `src/core/router.ts`. Bias to source with
|
|
55
|
+
`-g '*.ts'` / a language glob (`zg query "…" -g '*.ts'`).
|
|
56
|
+
- **Freshness is state-aware:** results report `fresh` or `possibly_stale`, and zg detects
|
|
57
|
+
a HashPilot write — the edit flips the index to `possibly_stale`. Good cross-tool sensing;
|
|
58
|
+
re-run same query to confirm current state.
|
|
59
|
+
- Index of ~140 files / ~1300 entities builds in ~14s incl. model download.
|
|
60
|
+
Workspace index lives `<root>/.zvec-grep/`; runtime/model state lives in `ZVEC_GREP_HOME`.
|
|
61
|
+
|
|
62
|
+
## Working pipeline (proven end-to-end, 2026-09-03)
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
zg query "where the router decides which edit strategy" -g '*.ts'
|
|
66
|
+
→ src/core/router.ts:108-423
|
|
67
|
+
hashpilot read-hash src/core/router.ts 58
|
|
68
|
+
→ lineHash 940e4dd9ce34 "// 1. Check policy overrides first"
|
|
69
|
+
hashpilot replace-hash src/core/router.ts 940e4dd9ce34 \
|
|
70
|
+
" // 1. Check policy overrides first [zg→hashpilot pipeline live]" --range 58:58
|
|
71
|
+
→ ok=true success=true stale=false (Replaced 1 lines, range 58-58)
|
|
72
|
+
re-run zg query → possibly_stale (zg notices the edit)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Re-applying the now-stale hash was refused (`STALE_ANCHOR`, file untouched) — the anchor
|
|
76
|
+
guarantees the edit lands where pointed, or not at all.
|
|
77
|
+
|
|
78
|
+
## Adoption decision (kept deliberately out of HashPilot)
|
|
79
|
+
|
|
80
|
+
Three coupling tiers were considered and documented:
|
|
81
|
+
1. **Hard npm dependency** (`@zvec/zvec-grep` in package.json) — **rejected.** Drags the
|
|
82
|
+
embedding stack + Node 22+ into a stateless editing primitive; couples release cycles.
|
|
83
|
+
2. **Optional adapter** (`hashpilot search <q>` shells to zg, greps fallback) — scoped but
|
|
84
|
+
**not built**. See `docs/PLAN-search-adapter.md` for the falsifier + TDD breakdown.
|
|
85
|
+
3. **Docs-only (this file)** — adopted. The search→edit orchestration is *agent* behavior,
|
|
86
|
+
not editing-primitive behavior; it belongs outside the binary.
|
|
87
|
+
|
|
88
|
+
## Environment for trying it
|
|
89
|
+
|
|
90
|
+
- zg: Node 22+. `npm i -g @zvec/zvec-grep` or local install. Model downloads on first index.
|
|
91
|
+
- HashPilot: Bun 1.2+.
|
|
92
|
+
- For a full `/` disk, point `ZVEC_GREP_HOME` (and index the workspace) on a roomy path.
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -14,37 +14,233 @@ warn() { printf "${YELLOW}[hashpilot]${NC} %s\n" "$1"; }
|
|
|
14
14
|
err() { printf "${RED}[hashpilot]${NC} %s\n" "$1"; }
|
|
15
15
|
detail() { printf "${DIM} →${NC} %s\n" "$1"; }
|
|
16
16
|
|
|
17
|
+
# Single source of truth for the "use npm, not an explicit git ref" sentinel
|
|
18
|
+
# — referenced in this file and (as a literal, since it's a separate process)
|
|
19
|
+
# in src/commands/maintenance.ts's `--channel` default; keep both in sync.
|
|
20
|
+
DEFAULT_CHANNEL="main"
|
|
21
|
+
|
|
22
|
+
# None of this script's network calls bounded how long they'd wait — a host
|
|
23
|
+
# that accepts the TCP connection but never responds (common for corporate
|
|
24
|
+
# proxies blocking a specific destination, which is the exact scenario the
|
|
25
|
+
# npm-registry fallback below exists for) hung the installer indefinitely
|
|
26
|
+
# instead of ever reaching that fallback.
|
|
27
|
+
CURL_META_OPTS=(--connect-timeout 10 --max-time 20)
|
|
28
|
+
CURL_DOWNLOAD_OPTS=(--connect-timeout 10 --max-time 300)
|
|
29
|
+
|
|
30
|
+
# Extract one string field's value from a small JSON blob (grep+sed, no jq
|
|
31
|
+
# dependency, matching this script's existing style) — centralized so every
|
|
32
|
+
# call site gets the same handling instead of each reinventing it slightly
|
|
33
|
+
# differently. Pass "url" as $3 to additionally require the value look like
|
|
34
|
+
# a real http(s) URL: the naive sed substitution only fires on a genuine
|
|
35
|
+
# match, so a non-URL value (empty, relative, a mirror that rewrites the
|
|
36
|
+
# field to something else) would otherwise silently pass the whole
|
|
37
|
+
# grep-matched line through unchanged — still non-empty, so it would pass a
|
|
38
|
+
# bare `-n` check as if it were real.
|
|
39
|
+
json_field() {
|
|
40
|
+
local json="$1" field="$2" require="${3:-}" value
|
|
41
|
+
value=$(echo "$json" | grep -o "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 \
|
|
42
|
+
| sed "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\"/\\1/" || true)
|
|
43
|
+
if [ "$require" = "url" ]; then
|
|
44
|
+
case "$value" in
|
|
45
|
+
https://*|http://*) ;;
|
|
46
|
+
*) value="" ;;
|
|
47
|
+
esac
|
|
48
|
+
fi
|
|
49
|
+
echo "$value"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Download a tarball to a temp file (verifying its sha1 against $3 first, if
|
|
53
|
+
# given — npm's registry metadata includes one for free), then extract it.
|
|
54
|
+
# Shared by the npm and GitHub source fetches below so hardening (timeouts,
|
|
55
|
+
# checksum verification) only has to be added once. Leaves nothing behind
|
|
56
|
+
# and returns non-zero on any failure: download, checksum mismatch, or
|
|
57
|
+
# extraction — every failure mode here is handled identically by the caller
|
|
58
|
+
# (fall back to the next source), so there is no reason for them to differ.
|
|
59
|
+
fetch_and_extract_tarball() {
|
|
60
|
+
local url="$1" dest_dir="$2" expected_sha1="${3:-}" tmp_tarball
|
|
61
|
+
tmp_tarball="$(mktemp)"
|
|
62
|
+
if ! curl -fsSL "${CURL_DOWNLOAD_OPTS[@]}" "$url" -o "$tmp_tarball" 2>/dev/null; then
|
|
63
|
+
rm -f "$tmp_tarball"
|
|
64
|
+
return 1
|
|
65
|
+
fi
|
|
66
|
+
if [ -n "$expected_sha1" ]; then
|
|
67
|
+
local actual_sha1
|
|
68
|
+
# `sha1sum` is GNU coreutils only — stock macOS (BSD userland) has
|
|
69
|
+
# `shasum -a 1` instead, and neither exists on some minimal containers,
|
|
70
|
+
# where `openssl sha1` is the last resort. Checking only for `sha1sum`
|
|
71
|
+
# made every npm-path checksum check fail on macOS, silently forcing
|
|
72
|
+
# every install/upgrade there onto the GitHub fallback — defeating this
|
|
73
|
+
# PR's actual point on a major platform.
|
|
74
|
+
if command -v sha1sum >/dev/null 2>&1; then
|
|
75
|
+
actual_sha1="$(sha1sum "$tmp_tarball" 2>/dev/null | awk '{print $1}')"
|
|
76
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
77
|
+
actual_sha1="$(shasum -a 1 "$tmp_tarball" 2>/dev/null | awk '{print $1}')"
|
|
78
|
+
elif command -v openssl >/dev/null 2>&1; then
|
|
79
|
+
actual_sha1="$(openssl sha1 "$tmp_tarball" 2>/dev/null | awk '{print $NF}')"
|
|
80
|
+
else
|
|
81
|
+
# Fail closed, not open: forging a match here would silently disable
|
|
82
|
+
# the integrity guarantee this whole check exists for. Returning
|
|
83
|
+
# failure instead routes through this function's normal
|
|
84
|
+
# failure-handling — the caller already treats that identically to a
|
|
85
|
+
# checksum mismatch or a failed download, falling back to the GitHub
|
|
86
|
+
# source rather than installing something unverified.
|
|
87
|
+
warn "no sha1sum/shasum/openssl found; cannot verify tarball checksum"
|
|
88
|
+
rm -f "$tmp_tarball"
|
|
89
|
+
return 1
|
|
90
|
+
fi
|
|
91
|
+
if [ "$actual_sha1" != "$expected_sha1" ]; then
|
|
92
|
+
warn "tarball checksum mismatch (expected ${expected_sha1}, got ${actual_sha1:-<none>})"
|
|
93
|
+
rm -f "$tmp_tarball"
|
|
94
|
+
return 1
|
|
95
|
+
fi
|
|
96
|
+
fi
|
|
97
|
+
if ! tar -xz -C "$dest_dir" --strip-components=1 -f "$tmp_tarball" 2>&1 | while IFS= read -r line; do detail "$line"; done; then
|
|
98
|
+
rm -f "$tmp_tarball"
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
rm -f "$tmp_tarball"
|
|
102
|
+
}
|
|
103
|
+
|
|
17
104
|
# ── Detect source directory ──────────────────────────────────────────────
|
|
18
105
|
REMOTE_MODE=false
|
|
19
106
|
SOURCE_DIR=""
|
|
107
|
+
# Declared here (not just inside the remote-mode block below) so the
|
|
108
|
+
# dependency-install step can use it as the authoritative "did this come
|
|
109
|
+
# from npm" signal — local-clone mode and the GitHub-fallback path both
|
|
110
|
+
# leave it false, which is correct for both.
|
|
111
|
+
NPM_INSTALLED=false
|
|
112
|
+
|
|
113
|
+
# An explicit --source wins over everything else, checked here (before the
|
|
114
|
+
# real argument-parsing loop below, which runs too late for this) so that
|
|
115
|
+
# passing --source skips local-clone detection AND the auto-download below
|
|
116
|
+
# entirely — downloading anything when the caller already told us exactly
|
|
117
|
+
# where the source is would be pure waste, and previously caused a real bug:
|
|
118
|
+
# HASHPILOT_VERSION got read from the auto-fetched npm/GitHub tarball, not
|
|
119
|
+
# from the --source directory that was actually installed, silently
|
|
120
|
+
# mislabeling the manifest/version banner whenever the two versions differed.
|
|
121
|
+
# No `break`: --source given twice must resolve to the SAME occurrence the
|
|
122
|
+
# real argument-parsing loop below honors (it keeps the last one), or the
|
|
123
|
+
# version/manifest would be read from one directory while the actual
|
|
124
|
+
# install copies from another — reintroducing the exact class of mismatch
|
|
125
|
+
# this pre-scan exists to prevent.
|
|
126
|
+
EXPLICIT_SOURCE=""
|
|
127
|
+
_ARGV=("$@")
|
|
128
|
+
for ((_i = 0; _i < ${#_ARGV[@]}; _i++)); do
|
|
129
|
+
if [ "${_ARGV[$_i]}" = "--source" ] && [ $((_i + 1)) -lt ${#_ARGV[@]} ]; then
|
|
130
|
+
EXPLICIT_SOURCE="${_ARGV[$((_i + 1))]}"
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
20
133
|
|
|
134
|
+
if [ -n "$EXPLICIT_SOURCE" ]; then
|
|
135
|
+
SOURCE_DIR="$EXPLICIT_SOURCE"
|
|
21
136
|
# Try to resolve from script location (local clone mode)
|
|
22
|
-
|
|
137
|
+
elif SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null)"; then
|
|
23
138
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd 2>/dev/null || echo "")"
|
|
24
139
|
if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/package.json" ]; then
|
|
25
140
|
SOURCE_DIR="$REPO_ROOT"
|
|
26
141
|
fi
|
|
27
142
|
fi
|
|
28
143
|
|
|
29
|
-
# No local source —
|
|
144
|
+
# No local source — fetch a tarball (curl-pipe / remote mode).
|
|
145
|
+
#
|
|
146
|
+
# Primary source is the published npm package: it's the tested, minimal
|
|
147
|
+
# artifact (no devDependencies, no tests/docs bloat — see
|
|
148
|
+
# tests/packaging.test.ts for what it guarantees ships) instead of the full
|
|
149
|
+
# git source tree, and it stops this installer silently drifting from the
|
|
150
|
+
# npm distribution channel now that publishing actually works (#193). Only
|
|
151
|
+
# curl is used — no `npm`/`node` binary required, since bun is this script's
|
|
152
|
+
# only external prerequisite.
|
|
153
|
+
#
|
|
154
|
+
# Falls back to the GitHub source tarball (release tag, or a branch) when:
|
|
155
|
+
# - the npm registry is unreachable or the package/version can't be found
|
|
156
|
+
# (offline-but-git-reachable environments, corporate proxies that allow
|
|
157
|
+
# github.com but not registry.npmjs.org), or
|
|
158
|
+
# - HASHPILOT_SOURCE_CHANNEL is set to something other than "main" — an
|
|
159
|
+
# explicit non-default channel (e.g. `hashpilot upgrade --channel
|
|
160
|
+
# some-branch`) means the user wants that exact git ref, which npm's
|
|
161
|
+
# published releases can't provide.
|
|
162
|
+
#
|
|
163
|
+
# HASHPILOT_NPM_REGISTRY overrides the registry base URL — used by tests to
|
|
164
|
+
# deterministically force the npm path to fail without relying on a real
|
|
165
|
+
# outage, and by anyone behind an npm registry mirror/proxy.
|
|
30
166
|
if [ -z "$SOURCE_DIR" ]; then
|
|
31
167
|
REMOTE_MODE=true
|
|
32
168
|
CLONE_DIR=$(mktemp -d)
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
169
|
+
# A stale HASHPILOT_SOURCE_CHANNEL already exported in the caller's shell
|
|
170
|
+
# (or a CI job's environment) must not silently override the channel the
|
|
171
|
+
# user actually asked for on THIS invocation — src/commands/maintenance.ts
|
|
172
|
+
# always sets this explicitly (to "" on the default channel) precisely so
|
|
173
|
+
# `${HASHPILOT_SOURCE_CHANNEL:-$DEFAULT_CHANNEL}` can't see a leftover
|
|
174
|
+
# value from a previous run, but default it defensively here too for
|
|
175
|
+
# anyone invoking install.sh directly rather than through `hashpilot
|
|
176
|
+
# upgrade`.
|
|
177
|
+
SOURCE_CHANNEL="${HASHPILOT_SOURCE_CHANNEL:-$DEFAULT_CHANNEL}"
|
|
178
|
+
[ -z "$SOURCE_CHANNEL" ] && SOURCE_CHANNEL="$DEFAULT_CHANNEL"
|
|
179
|
+
NPM_REGISTRY="${HASHPILOT_NPM_REGISTRY:-https://registry.npmjs.org}"
|
|
180
|
+
NPM_INSTALLED=false
|
|
181
|
+
|
|
182
|
+
if [ "$SOURCE_CHANNEL" = "$DEFAULT_CHANNEL" ]; then
|
|
183
|
+
log "Fetching latest release info from npm..."
|
|
184
|
+
NPM_INFO=$(curl -fsSL "${CURL_META_OPTS[@]}" "${NPM_REGISTRY}/@bigknoxy/hashpilot/latest" 2>/dev/null || echo "")
|
|
185
|
+
NPM_TARBALL_URL="$(json_field "$NPM_INFO" "tarball" url)"
|
|
186
|
+
NPM_VERSION="$(json_field "$NPM_INFO" "version")"
|
|
187
|
+
NPM_SHASUM="$(json_field "$NPM_INFO" "shasum")"
|
|
188
|
+
if [ -n "$NPM_TARBALL_URL" ]; then
|
|
189
|
+
log "Downloading HashPilot v${NPM_VERSION} from npm..."
|
|
190
|
+
# A tarball can download, checksum-verify, and extract cleanly while
|
|
191
|
+
# still being useless — e.g. a registry response that resolved to
|
|
192
|
+
# some unrelated but validly-formed archive. Require a package.json
|
|
193
|
+
# to actually be there before trusting this source; otherwise every
|
|
194
|
+
# later step (the version read right after this block especially)
|
|
195
|
+
# fails with a bare, undiagnosed exit instead of falling back like
|
|
196
|
+
# every other failure mode here does.
|
|
197
|
+
if fetch_and_extract_tarball "$NPM_TARBALL_URL" "$CLONE_DIR" "$NPM_SHASUM"; then
|
|
198
|
+
if [ -f "$CLONE_DIR/package.json" ]; then
|
|
199
|
+
NPM_INSTALLED=true
|
|
200
|
+
else
|
|
201
|
+
warn "npm tarball extracted but had no package.json; falling back to GitHub source"
|
|
202
|
+
fi
|
|
203
|
+
else
|
|
204
|
+
warn "npm tarball download/extract failed; falling back to GitHub source"
|
|
205
|
+
fi
|
|
206
|
+
if [ "$NPM_INSTALLED" = "false" ]; then
|
|
207
|
+
rm -rf "$CLONE_DIR"
|
|
208
|
+
CLONE_DIR=$(mktemp -d)
|
|
209
|
+
fi
|
|
210
|
+
else
|
|
211
|
+
warn "npm registry unreachable or package not found; falling back to GitHub source"
|
|
212
|
+
fi
|
|
45
213
|
fi
|
|
46
|
-
|
|
47
|
-
|
|
214
|
+
|
|
215
|
+
if [ "$NPM_INSTALLED" = "false" ]; then
|
|
216
|
+
if [ "$SOURCE_CHANNEL" = "$DEFAULT_CHANNEL" ]; then
|
|
217
|
+
log "Fetching latest release info from GitHub..."
|
|
218
|
+
RELEASE_INFO=$(curl -fsSL "${CURL_META_OPTS[@]}" "https://api.github.com/repos/bigknoxy/HashPilot/releases/latest" 2>/dev/null || echo "")
|
|
219
|
+
TAG_NAME="$(json_field "$RELEASE_INFO" "tag_name")"
|
|
220
|
+
if [ -n "$TAG_NAME" ]; then
|
|
221
|
+
TARBALL_URL="https://github.com/bigknoxy/HashPilot/archive/refs/tags/${TAG_NAME}.tar.gz"
|
|
222
|
+
log "Downloading HashPilot ${TAG_NAME} from GitHub..."
|
|
223
|
+
else
|
|
224
|
+
# Fallback to main branch if no release
|
|
225
|
+
TARBALL_URL="https://github.com/bigknoxy/HashPilot/archive/refs/heads/main.tar.gz"
|
|
226
|
+
log "Downloading HashPilot from main branch..."
|
|
227
|
+
fi
|
|
228
|
+
else
|
|
229
|
+
TARBALL_URL="https://github.com/bigknoxy/HashPilot/archive/refs/heads/${SOURCE_CHANNEL}.tar.gz"
|
|
230
|
+
log "Downloading HashPilot from branch ${SOURCE_CHANNEL}..."
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# This is the last fallback — nothing after this if it fails — so an
|
|
234
|
+
# unguarded call here would let `set -e` kill the script with a bare,
|
|
235
|
+
# undiagnosed exit instead of the clear, actionable message every other
|
|
236
|
+
# failure mode in this block already gets.
|
|
237
|
+
if ! fetch_and_extract_tarball "$TARBALL_URL" "$CLONE_DIR"; then
|
|
238
|
+
err "Failed to download or extract HashPilot from ${TARBALL_URL}"
|
|
239
|
+
err "Check your network connection, or that '${SOURCE_CHANNEL}' is a real branch/tag."
|
|
240
|
+
exit 1
|
|
241
|
+
fi
|
|
242
|
+
fi
|
|
243
|
+
|
|
48
244
|
SOURCE_DIR="$CLONE_DIR"
|
|
49
245
|
detail "Extracted to $CLONE_DIR"
|
|
50
246
|
fi
|
|
@@ -71,13 +267,18 @@ while [ $# -gt 0 ]; do
|
|
|
71
267
|
echo "HashPilot Installer v${HASHPILOT_VERSION}"
|
|
72
268
|
echo "Usage: $0 [options]"
|
|
73
269
|
echo " --source <dir> Source directory (default: repo root)."
|
|
74
|
-
echo " If omitted and no local source found,"
|
|
75
|
-
echo "
|
|
270
|
+
echo " If omitted and no local source found, auto-downloads"
|
|
271
|
+
echo " from npm (falls back to the GitHub release/main tarball"
|
|
272
|
+
echo " if npm is unreachable)."
|
|
76
273
|
echo " --target <dir> Install target (default: ~/.agentic-tools)"
|
|
77
274
|
echo " --keep-telemetry Preserve existing telemetry on reinstall"
|
|
78
275
|
echo ' --force, -f Overwrite existing install without any prompt (including the non-interactive existing-install notice)'
|
|
79
276
|
echo " --help, -h Show this help"
|
|
80
277
|
echo ""
|
|
278
|
+
echo "Env vars: HASHPILOT_SOURCE_CHANNEL=<branch> skips npm and installs that exact"
|
|
279
|
+
echo " git branch instead (e.g. for bleeding-edge testing)."
|
|
280
|
+
echo " HASHPILOT_NPM_REGISTRY=<url> overrides the npm registry base URL."
|
|
281
|
+
echo ""
|
|
81
282
|
echo "One-liner: curl -fsSL https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/install.sh | bash"
|
|
82
283
|
exit 0
|
|
83
284
|
;;
|
|
@@ -186,9 +387,50 @@ detail "Core source copied to $TARGET_DIR/structured-editing"
|
|
|
186
387
|
|
|
187
388
|
# ── Install dependencies ────────────────────────────────────────────────
|
|
188
389
|
log "Installing dependencies..."
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
390
|
+
# Decide frozen-vs-production from $SOURCE_DIR (what we just copied FROM),
|
|
391
|
+
# not from whatever bun.lock might already be sitting in the target
|
|
392
|
+
# directory. The rsync fallback for hosts without rsync (`cp -r`, a few
|
|
393
|
+
# lines up) does not delete files absent from the source — an upgrade from
|
|
394
|
+
# a prior git-sourced install (which does ship bun.lock) to a new npm-
|
|
395
|
+
# sourced one (which doesn't) would otherwise leave the old lockfile
|
|
396
|
+
# behind, be found by a target-relative `[ -f bun.lock ]` check, and run
|
|
397
|
+
# --frozen-lockfile against the npm package's own package.json — which
|
|
398
|
+
# never matches, and hard-aborts the upgrade after node_modules has
|
|
399
|
+
# already been removed, leaving no working install at all.
|
|
400
|
+
#
|
|
401
|
+
# $NPM_INSTALLED (set above, always defined regardless of which branch was
|
|
402
|
+
# taken) is the authoritative signal for "this came from npm and has no
|
|
403
|
+
# lockfile" — cross-checked against bun.lock's presence rather than relied
|
|
404
|
+
# on alone, so a future source shape that disagrees with what we expect
|
|
405
|
+
# (e.g. an npm extraction that somehow shipped a lockfile, or a git/local
|
|
406
|
+
# source that's missing one) fails loudly here instead of silently
|
|
407
|
+
# guessing.
|
|
408
|
+
if [ "$NPM_INSTALLED" = "true" ] && [ -f "$SOURCE_DIR/bun.lock" ]; then
|
|
409
|
+
err "npm-sourced install unexpectedly has a bun.lock — refusing to guess which dependency mode is correct"
|
|
410
|
+
exit 1
|
|
411
|
+
fi
|
|
412
|
+
if [ "$NPM_INSTALLED" = "false" ] && [ ! -f "$SOURCE_DIR/bun.lock" ]; then
|
|
413
|
+
err "Source is missing bun.lock and wasn't installed from npm — refusing to guess which dependency mode is correct"
|
|
414
|
+
err "(local-clone and --source installs are expected to have bun.lock, same as the git repo does)"
|
|
415
|
+
exit 1
|
|
416
|
+
fi
|
|
417
|
+
|
|
418
|
+
if [ -f "$SOURCE_DIR/bun.lock" ]; then
|
|
419
|
+
cd "$TARGET_DIR/structured-editing"
|
|
420
|
+
bun install --frozen-lockfile 2>&1 | while IFS= read -r line; do detail "$line"; done
|
|
421
|
+
cd "$OLDPWD"
|
|
422
|
+
else
|
|
423
|
+
# The npm-published package.json still lists devDependencies (npm's
|
|
424
|
+
# `files` field controls which FILES ship, not which package.json fields
|
|
425
|
+
# do) — a plain `bun install` would resolve and install semantic-release,
|
|
426
|
+
# fast-check, and the rest of the dev toolchain for no reason on an end
|
|
427
|
+
# user's machine. --production skips them; the CLI never needs them.
|
|
428
|
+
detail "No bun.lock shipped (npm package install) — resolving production dependencies fresh"
|
|
429
|
+
rm -f "$TARGET_DIR/structured-editing/bun.lock"
|
|
430
|
+
cd "$TARGET_DIR/structured-editing"
|
|
431
|
+
bun install --production 2>&1 | while IFS= read -r line; do detail "$line"; done
|
|
432
|
+
cd "$OLDPWD"
|
|
433
|
+
fi
|
|
192
434
|
detail "Dependencies installed"
|
|
193
435
|
|
|
194
436
|
# ── Create CLI launcher ──────────────────────────────────────────────────
|
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { register as registerProvenance } from "./commands/provenance";
|
|
|
40
40
|
import { register as registerMcp } from "./commands/mcp";
|
|
41
41
|
import { register as registerMaintenance } from "./commands/maintenance";
|
|
42
42
|
import { register as registerRoute } from "./commands/route";
|
|
43
|
+
import { register as registerSearch } from "./commands/search";
|
|
43
44
|
|
|
44
45
|
const VERSION: string = pkg.version;
|
|
45
46
|
|
|
@@ -108,6 +109,7 @@ registerProvenance(program);
|
|
|
108
109
|
registerMcp(program);
|
|
109
110
|
registerMaintenance(program);
|
|
110
111
|
registerRoute(program);
|
|
112
|
+
registerSearch(program);
|
|
111
113
|
|
|
112
114
|
/** Node syscall codes that mean "the filesystem said no", not "HashPilot has a bug". */
|
|
113
115
|
const IO_SYSCALL_CODES = new Set([
|
|
@@ -25,7 +25,7 @@ export function register(program: Command): void {
|
|
|
25
25
|
|
|
26
26
|
program
|
|
27
27
|
.command("upgrade")
|
|
28
|
-
.description("Upgrade HashPilot to the latest version
|
|
28
|
+
.description("Upgrade HashPilot to the latest version (npm, falling back to GitHub)")
|
|
29
29
|
.option("--channel <channel>", "Release channel (default: main)", "main")
|
|
30
30
|
.option("--target <dir>", "Install target directory (default: ~/.agentic-tools)")
|
|
31
31
|
.option("--keep-telemetry", "Preserve existing telemetry on upgrade")
|
|
@@ -55,8 +55,12 @@ export function register(program: Command): void {
|
|
|
55
55
|
}
|
|
56
56
|
const script = await response.text();
|
|
57
57
|
|
|
58
|
-
// Write script to temp file and execute
|
|
58
|
+
// Write script to temp file and execute. targetDir may not exist yet
|
|
59
|
+
// on a genuinely first-time install (the `uninstall` command below
|
|
60
|
+
// already does this — `upgrade` didn't, and failed with a plain
|
|
61
|
+
// ENOENT on a brand-new target).
|
|
59
62
|
const tmpScript = join(targetDir, `.hashpilot-upgrade-${Date.now()}.sh`);
|
|
63
|
+
mkdirSync(targetDir, { recursive: true });
|
|
60
64
|
writeFileSync(tmpScript, script, { mode: 0o755 });
|
|
61
65
|
|
|
62
66
|
const args = ["--target", targetDir];
|
|
@@ -66,7 +70,20 @@ export function register(program: Command): void {
|
|
|
66
70
|
const proc = Bun.spawn(["bash", tmpScript, ...args], {
|
|
67
71
|
stdout: "pipe",
|
|
68
72
|
stderr: "pipe",
|
|
69
|
-
env: {
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
PATH: `${join(targetDir, "bin")}:${process.env.PATH || ""}`,
|
|
76
|
+
// Always set explicitly (never omitted) so a stale
|
|
77
|
+
// HASHPILOT_SOURCE_CHANNEL already exported in the caller's
|
|
78
|
+
// shell or a CI job's environment can't silently override the
|
|
79
|
+
// channel actually requested on *this* invocation — install.sh
|
|
80
|
+
// skips its npm-primary source fetch entirely when this is set
|
|
81
|
+
// to anything other than "main", since npm's published releases
|
|
82
|
+
// can't provide an arbitrary git ref, so an inherited stale
|
|
83
|
+
// value would silently install from git instead of npm with no
|
|
84
|
+
// warning at all.
|
|
85
|
+
HASHPILOT_SOURCE_CHANNEL: channel === "main" ? "" : channel,
|
|
86
|
+
},
|
|
70
87
|
});
|
|
71
88
|
|
|
72
89
|
const stdout = await new Response(proc.stdout).text();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
search,
|
|
4
|
+
loadConfig,
|
|
5
|
+
recordEvent,
|
|
6
|
+
finish,
|
|
7
|
+
DEFAULT_SOURCE_GLOBS,
|
|
8
|
+
} from "../core/index";
|
|
9
|
+
import type { SearchResult } from "../core/index";
|
|
10
|
+
|
|
11
|
+
/** Restrict `--engine` to the supported values; commander enforces via `.choices`. */
|
|
12
|
+
const ENGINE_CHOICES = ["auto", "zg", "grep", "off"] as const;
|
|
13
|
+
|
|
14
|
+
function collectGlob(value: string, previous: string[]): string[] {
|
|
15
|
+
return previous.concat([value]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Register the `search` command group. */
|
|
19
|
+
export function register(program: Command): void {
|
|
20
|
+
program
|
|
21
|
+
.command("search")
|
|
22
|
+
.description(
|
|
23
|
+
"Search a workspace: zg (zvec-grep) semantic/lexical when available, grep fallback. " +
|
|
24
|
+
"Usage: search \"<query>\" (zg) or search --engine grep \"<pattern>\" [paths...]",
|
|
25
|
+
)
|
|
26
|
+
.argument("<query>", "Query text (plain language for zg; a regex only makes sense on the grep engine)")
|
|
27
|
+
.argument("[paths...]", "Paths to search (grep engine only; zg searches its indexed workspace)")
|
|
28
|
+
.option(
|
|
29
|
+
"--engine <engine>",
|
|
30
|
+
`Search engine: ${ENGINE_CHOICES.join(", ")} (default: config or auto)`,
|
|
31
|
+
"auto",
|
|
32
|
+
)
|
|
33
|
+
.option(
|
|
34
|
+
"--glob <glob>",
|
|
35
|
+
"Source glob filter, repeatable (default: code extensions)",
|
|
36
|
+
collectGlob,
|
|
37
|
+
[] as string[],
|
|
38
|
+
)
|
|
39
|
+
.option("--zg-bin <path>", "Path to the zg binary (default: ZG_BIN env, then PATH)")
|
|
40
|
+
|
|
41
|
+
.action(async (query: string, paths: string[], opts) => {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
// Config defaults apply only when the CLI flag is left at its "auto" default.
|
|
45
|
+
const engine = (opts.engine === "auto" && config.search?.engine ? config.search.engine : opts.engine) as
|
|
46
|
+
| (typeof ENGINE_CHOICES)[number]
|
|
47
|
+
| undefined;
|
|
48
|
+
const sourceGlobs = opts.glob.length > 0 ? opts.glob : (config.search?.sourceGlobs ?? DEFAULT_SOURCE_GLOBS);
|
|
49
|
+
const zgBin = opts.zgBin ?? config.search?.zgBin;
|
|
50
|
+
|
|
51
|
+
const res: SearchResult = await search(query, paths ?? [], {
|
|
52
|
+
engine,
|
|
53
|
+
sourceGlobs,
|
|
54
|
+
zgBin,
|
|
55
|
+
root: process.cwd(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const hitCount = res.engine === "zg" ? res.hits.length : res.results.length;
|
|
59
|
+
recordEvent({
|
|
60
|
+
operation: "search",
|
|
61
|
+
engine: res.engine,
|
|
62
|
+
hits: hitCount,
|
|
63
|
+
degraded: "degraded" in res ? Boolean(res.degraded) : false,
|
|
64
|
+
noIndex: res.engine === "zg" && Boolean(res.noIndex),
|
|
65
|
+
success: !("error" in res && res.error),
|
|
66
|
+
elapsed_ms: Date.now() - start,
|
|
67
|
+
});
|
|
68
|
+
finish(res);
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -53,11 +53,26 @@ export interface SnapshotConfig {
|
|
|
53
53
|
maxAgeDays?: number;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Optional zg (zvec-grep) integration. zg is an external search layer — HashPilot
|
|
58
|
+
* never depends on it; these set the default behavior of `hashpilot search`.
|
|
59
|
+
* See docs/zvec-grep-integration.md.
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchConfig {
|
|
62
|
+
/** `auto` uses zg when a binary resolves, else grep. `off` never spawns zg. */
|
|
63
|
+
engine?: "auto" | "zg" | "grep" | "off";
|
|
64
|
+
/** Only these globs are returned from zg's results. Defaults to code extensions. */
|
|
65
|
+
sourceGlobs?: string[];
|
|
66
|
+
/** Path to the zg binary. Defaults to ZG_BIN env, then PATH. */
|
|
67
|
+
zgBin?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export interface HashPilotConfig {
|
|
57
71
|
routePolicy?: RoutePolicy;
|
|
58
72
|
telemetry?: TelemetryConfig;
|
|
59
73
|
provenance?: ProvenanceConfig;
|
|
60
74
|
snapshots?: SnapshotConfig;
|
|
75
|
+
search?: SearchConfig;
|
|
61
76
|
/** Extra directories writes may target, beyond the project root. Relative entries resolve against cwd. */
|
|
62
77
|
allowedRoots?: string[];
|
|
63
78
|
}
|
|
@@ -183,6 +198,9 @@ function mergeConfig(base: HashPilotConfig, override: Partial<HashPilotConfig>):
|
|
|
183
198
|
if (override.snapshots) {
|
|
184
199
|
base.snapshots = { ...base.snapshots, ...override.snapshots };
|
|
185
200
|
}
|
|
201
|
+
if (override.search) {
|
|
202
|
+
base.search = { ...base.search, ...override.search };
|
|
203
|
+
}
|
|
186
204
|
if (override.allowedRoots) {
|
|
187
205
|
base.allowedRoots = [...(base.allowedRoots || []), ...override.allowedRoots];
|
|
188
206
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { readMany, readHash, computeHash, computeLineHash } from "./read";
|
|
|
2
2
|
export type { ReadResult, ReadHashResult } from "./read";
|
|
3
3
|
export { grepMany, symbolLookupMany } from "./grep";
|
|
4
4
|
export type { GrepResult, GrepManyResult, SymbolLookupResult } from "./grep";
|
|
5
|
+
export { search, parseZgMarkdown, matchesSource, DEFAULT_SOURCE_GLOBS } from "./search";
|
|
6
|
+
export type { SearchResult, SearchHit, ZgSearchResult, GrepSearchResult, SearchOptions } from "./search";
|
|
5
7
|
export { replaceHash } from "./hash-edit";
|
|
6
8
|
export type { ReplaceHashResult, ReplaceHashOptions } from "./hash-edit";
|
|
7
9
|
export {
|
|
@@ -95,7 +97,7 @@ export type {
|
|
|
95
97
|
export { executeIntent, executePlan } from "./plan-executor";
|
|
96
98
|
export type { StepResult, PlanResult, IntentResult } from "./plan-executor";
|
|
97
99
|
export { loadConfig, policyForce } from "./config";
|
|
98
|
-
export type { HashPilotConfig, RoutePolicy, TelemetryConfig, ProvenanceConfig, SnapshotConfig } from "./config";
|
|
100
|
+
export type { HashPilotConfig, RoutePolicy, TelemetryConfig, ProvenanceConfig, SnapshotConfig, SearchConfig } from "./config";
|
|
99
101
|
export {
|
|
100
102
|
recordSnapshot,
|
|
101
103
|
listChangeSets,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { grepMany, type GrepResult } from "./grep";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SOURCE_GLOBS = ["*.ts", "*.js", "*.py", "*.go", "*.rs", "*.rb"];
|
|
7
|
+
|
|
8
|
+
/** One semantic hit parsed from zg's agent-markdown output. */
|
|
9
|
+
export interface SearchHit {
|
|
10
|
+
file: string;
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
symbol?: string;
|
|
14
|
+
status?: string;
|
|
15
|
+
heading?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ZgSearchResult {
|
|
19
|
+
engine: "zg";
|
|
20
|
+
query: string;
|
|
21
|
+
hits: SearchHit[];
|
|
22
|
+
elapsed_ms: number;
|
|
23
|
+
/** Set true when zg ran but the workspace index was missing. */
|
|
24
|
+
noIndex?: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
errorCode?: "SEARCH_NO_INDEX" | "SEARCH_FAILED";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GrepSearchResult {
|
|
30
|
+
engine: "grep";
|
|
31
|
+
query: string;
|
|
32
|
+
/** Passthrough of grep-many's own result object — result parity by construction. */
|
|
33
|
+
pattern: string;
|
|
34
|
+
results: GrepResult[];
|
|
35
|
+
error?: string;
|
|
36
|
+
/** True when zg was requested (auto/zg) but the binary was unavailable. */
|
|
37
|
+
degraded?: boolean;
|
|
38
|
+
elapsed_ms: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SearchResult = ZgSearchResult | GrepSearchResult;
|
|
42
|
+
|
|
43
|
+
export interface SearchOptions {
|
|
44
|
+
engine?: "auto" | "zg" | "grep" | "off";
|
|
45
|
+
sourceGlobs?: string[];
|
|
46
|
+
/** Workspace root used to detect the `.zvec-grep` index (default: cwd). */
|
|
47
|
+
root?: string;
|
|
48
|
+
/** Explicit zg binary path (overrides ZG_BIN env and PATH lookup). */
|
|
49
|
+
zgBin?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HIT_HEADER = /^#\d+\s+(?:matchedBy=\S+?\s+)?([^:\s][^:]*?):(\d+)-(\d+)$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse zg's agent-markdown query output into ordered `SearchHit`s.
|
|
56
|
+
*
|
|
57
|
+
* Each hit block opens with `#N matchedBy=<tags> <path>:<start>-<end>` (the
|
|
58
|
+
* matchedBy prefix is optional — some routes omit it), followed by zero or more
|
|
59
|
+
* `key: value` attribute lines (status, symbol, heading, scope) until the next
|
|
60
|
+
* `#N` header.
|
|
61
|
+
*/
|
|
62
|
+
export function parseZgMarkdown(text: string): SearchHit[] {
|
|
63
|
+
const hits: SearchHit[] = [];
|
|
64
|
+
let current: Partial<SearchHit> | null = null;
|
|
65
|
+
|
|
66
|
+
for (const raw of text.split("\n")) {
|
|
67
|
+
const line = raw.trimEnd();
|
|
68
|
+
const header = HIT_HEADER.exec(line);
|
|
69
|
+
if (header) {
|
|
70
|
+
if (current?.file) hits.push(current as SearchHit);
|
|
71
|
+
current = { file: header[1], startLine: Number(header[2]), endLine: Number(header[3]) };
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!current?.file) continue;
|
|
75
|
+
const attr = /^([a-zA-Z]+):\s*(.+)$/.exec(line.trim());
|
|
76
|
+
if (attr) {
|
|
77
|
+
const key = attr[1] as "symbol" | "status" | "heading";
|
|
78
|
+
if (key === "symbol" || key === "status" || key === "heading") current[key] = attr[2];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (current?.file) hits.push(current as SearchHit);
|
|
82
|
+
return hits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function matchesSource(file: string, globs: string[]): boolean {
|
|
86
|
+
if (!globs || globs.length === 0) return true;
|
|
87
|
+
return globs.some((g) => {
|
|
88
|
+
if (g.startsWith("*.")) {
|
|
89
|
+
const ext = g.slice(1); // e.g. ".ts"
|
|
90
|
+
// Check that the file's actual extension matches. We use the last "."
|
|
91
|
+
// in the final path segment as the extension boundary — same as path.extname.
|
|
92
|
+
const basename = file.split("/").pop()!;
|
|
93
|
+
const dotIdx = basename.lastIndexOf(".");
|
|
94
|
+
if (dotIdx === -1) return false;
|
|
95
|
+
return basename.slice(dotIdx) === ext;
|
|
96
|
+
}
|
|
97
|
+
return file.endsWith(g);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ZgProcessResult {
|
|
102
|
+
stdout: string;
|
|
103
|
+
stderr: string;
|
|
104
|
+
code: number | null;
|
|
105
|
+
timedOut?: boolean;
|
|
106
|
+
spawnError?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function runZg(argv: string[], bin: string, timeoutMs = 60_000): Promise<ZgProcessResult> {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let stdout = "";
|
|
112
|
+
let stderr = "";
|
|
113
|
+
let settled = false;
|
|
114
|
+
const done = (result: ZgProcessResult) => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
resolve(result);
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
const proc = spawn(bin, argv, { stdio: ["ignore", "pipe", "pipe"] });
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
proc.kill("SIGKILL");
|
|
123
|
+
done({ stdout, stderr, code: null, timedOut: true });
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
proc.stdout.on("data", (d) => (stdout += d));
|
|
126
|
+
proc.stderr.on("data", (d) => (stderr += d));
|
|
127
|
+
proc.on("error", (err) => done({ stdout, stderr, code: null, spawnError: err.message }));
|
|
128
|
+
proc.on("close", (code) => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
done({ stdout, stderr, code });
|
|
131
|
+
});
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
done({ stdout, stderr, code: null, spawnError: err instanceof Error ? err.message : String(err) });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveZgBinary(zgBin?: string): string | undefined {
|
|
139
|
+
const explicit = zgBin || process.env.ZG_BIN;
|
|
140
|
+
if (explicit) return explicit;
|
|
141
|
+
const pathDirs = (process.env.PATH || "").split(":");
|
|
142
|
+
for (const dir of pathDirs) {
|
|
143
|
+
if (dir && existsSync(join(dir, "zg"))) return join(dir, "zg");
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The search command surface for `hashpilot search`. */
|
|
149
|
+
export async function search(query: string, paths: string[], opts: SearchOptions = {}): Promise<SearchResult> {
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
const queryGlobs = opts.sourceGlobs ?? DEFAULT_SOURCE_GLOBS;
|
|
152
|
+
const engine: "auto" | "zg" | "grep" | "off" = opts.engine ?? "auto";
|
|
153
|
+
const searchRoots = paths.length ? paths : ["."];
|
|
154
|
+
|
|
155
|
+
const zgBin = resolveZgBinary(opts.zgBin);
|
|
156
|
+
const zgUsable = !!zgBin && existsSync(zgBin);
|
|
157
|
+
|
|
158
|
+
// Which engine do we run? "off" means search is disabled — return empty immediately.
|
|
159
|
+
// "grep" never touches zg. "auto" prefers zg when available. "zg" uses zg but
|
|
160
|
+
// degrades to grep rather than failing (F2): a misconfigured / missing binary
|
|
161
|
+
// must not hard-crash the search command.
|
|
162
|
+
if (engine === "off") {
|
|
163
|
+
return {
|
|
164
|
+
engine: "grep",
|
|
165
|
+
query,
|
|
166
|
+
pattern: "",
|
|
167
|
+
results: [],
|
|
168
|
+
degraded: false,
|
|
169
|
+
elapsed_ms: Date.now() - start,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const engineIsGrep = engine === "grep";
|
|
173
|
+
const degraded = engineIsGrep ? false : !zgUsable;
|
|
174
|
+
const useZg = !engineIsGrep && zgUsable;
|
|
175
|
+
|
|
176
|
+
if (!useZg) {
|
|
177
|
+
const grepRes = await grepMany(query, searchRoots);
|
|
178
|
+
return {
|
|
179
|
+
engine: "grep",
|
|
180
|
+
query,
|
|
181
|
+
pattern: grepRes.pattern,
|
|
182
|
+
results: grepRes.results,
|
|
183
|
+
error: grepRes.error,
|
|
184
|
+
degraded,
|
|
185
|
+
elapsed_ms: Date.now() - start,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const root = opts.root ?? process.cwd();
|
|
190
|
+
if (!existsSync(join(root, ".zvec-grep"))) {
|
|
191
|
+
return {
|
|
192
|
+
engine: "zg",
|
|
193
|
+
query,
|
|
194
|
+
hits: [],
|
|
195
|
+
noIndex: true,
|
|
196
|
+
errorCode: "SEARCH_NO_INDEX",
|
|
197
|
+
error: "No zg index found in this workspace. Run `zg index` first, then retry.",
|
|
198
|
+
elapsed_ms: Date.now() - start,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const args = ["query", query];
|
|
203
|
+
for (const g of queryGlobs) args.push("-g", g);
|
|
204
|
+
const { stdout, stderr, code, timedOut, spawnError } = await runZg(args, zgBin!);
|
|
205
|
+
|
|
206
|
+
if (code !== 0) {
|
|
207
|
+
if (code === 1 && !stderr) {
|
|
208
|
+
// zg mirrors ripgrep: exit 1 with no stderr = no matches.
|
|
209
|
+
return { engine: "zg", query, hits: [], elapsed_ms: Date.now() - start };
|
|
210
|
+
}
|
|
211
|
+
const diagnostic = timedOut
|
|
212
|
+
? `zg timed out after 60s`
|
|
213
|
+
: spawnError
|
|
214
|
+
? `zg spawn failed: ${spawnError}`
|
|
215
|
+
: (stderr || stdout || "zg exited unsuccessfully");
|
|
216
|
+
return {
|
|
217
|
+
engine: "zg",
|
|
218
|
+
query,
|
|
219
|
+
hits: [],
|
|
220
|
+
errorCode: "SEARCH_FAILED",
|
|
221
|
+
error: diagnostic.slice(0, 300),
|
|
222
|
+
elapsed_ms: Date.now() - start,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const parsed = parseZgMarkdown(stdout).filter((h) => matchesSource(h.file, queryGlobs));
|
|
227
|
+
return { engine: "zg", query, hits: parsed, elapsed_ms: Date.now() - start };
|
|
228
|
+
}
|