@gmickel/gno 1.38.0 → 1.39.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.
@@ -42,6 +42,8 @@ gno skill install --target hermes # Hermes
42
42
  gno skill install --target all # All supported agents
43
43
  ```
44
44
 
45
+ As of their current releases, `--target claude` also serves Grok Build and Cursor via automatic `.claude/skills` discovery, `--target codex` is also read by Cursor, and other skill-capable clients can copy the files listed by `gno skill paths`.
46
+
45
47
  Scope is configurable:
46
48
 
47
49
  ```bash
@@ -0,0 +1 @@
1
+ 2d89e464e97d48a276e92f3625ca90bb1983fcf22b5d175f3da6d47453b4f626 gno-browser-clipper-v1.39.1.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.38.0"
24
+ "version": "1.39.1"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.38.0",
3
+ "version": "1.39.1",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -113,6 +113,10 @@ equivalent files fail closed as ambiguous.
113
113
  | skill uninstall | yes | no | no | no | no | terminal |
114
114
  | skill show | no | no | no | no | no | terminal |
115
115
  | skill paths | yes | no | no | no | no | terminal |
116
+ | agents install | yes | no | no | no | no | terminal |
117
+ | agents update | yes | no | no | no | no | terminal |
118
+ | agents verify | yes | no | no | no | no | terminal |
119
+ | agents uninstall | yes | no | no | no | no | terminal |
116
120
  | tags list | yes | no | no | yes | no | terminal |
117
121
  | tags add | yes | no | no | no | no | terminal |
118
122
  | tags rm | yes | no | no | no | no | terminal |
@@ -2582,6 +2586,8 @@ gno skill install [--scope <project|user>] [--target <claude|codex|opencode|open
2582
2586
  3. Atomically installs skill directory (temp + rename)
2583
2587
  4. Copies SKILL.md, reference files, and nested recipe files
2584
2588
 
2589
+ **Compatibility:** As of their current releases, Grok Build (the xAI grok CLI) automatically reads Claude skill directories (`.claude/skills/` and `~/.claude/skills/`) with no extra configuration, and Cursor loads agent skills from `.claude/skills/` and `.codex/skills/` (project and user scope) in addition to its own `.cursor/skills/`. `--target claude` therefore covers Grok Build and Cursor; `--target codex` is also read by Cursor. Other skill-capable clients can copy the installed files; `gno skill paths` prints their locations. Dedicated `grok` and `cursor` installer targets are not shipped: they would duplicate the claude/codex paths those tools already read. Revisit only if users keep `~/.grok/skills` or `~/.cursor/skills` separate and ask for first-class targets.
2590
+
2585
2591
  **Output (JSON):**
2586
2592
 
2587
2593
  ```json
@@ -2623,7 +2629,7 @@ Remove GNO agent skill.
2623
2629
  gno skill uninstall [--scope <project|user>] [--target <claude|codex|opencode|openclaw|hermes|all>] [--json]
2624
2630
  ```
2625
2631
 
2626
- **Options:** Same as `skill install` (except `--force`)
2632
+ **Options:** Same as `skill install` (except `--force`).
2627
2633
 
2628
2634
  **Safety Checks:**
2629
2635
 
@@ -2728,6 +2734,195 @@ gno skill paths [--scope <project|user>] [--target <claude|codex|opencode|opencl
2728
2734
 
2729
2735
  ---
2730
2736
 
2737
+ ### gno agents install
2738
+
2739
+ Install the compact, versioned GNO protocol block into the global (user-scope)
2740
+ instruction files of every detected harness. The block is bounded by stable
2741
+ markers (`<!-- gno:agents:begin -->` / `<!-- gno:agents:end -->`); install and
2742
+ update touch ONLY the owned block — content outside the markers stays
2743
+ byte-identical. The block content is static (identical on every machine), so
2744
+ a block is current exactly when its stamp version and hash match the installed
2745
+ release.
2746
+
2747
+ **Synopsis:**
2748
+
2749
+ ```bash
2750
+ gno agents install [--target <claude|codex|cursor|opencode|grok|hermes|openclaw|all>] [--extra-dir <path>]... [--dry-run] [--json]
2751
+ ```
2752
+
2753
+ **Options:**
2754
+
2755
+ | Option | Type | Default | Description |
2756
+ | ------------- | ------- | ------- | ----------------------------------------------------------------------------------------------- |
2757
+ | `--target` | string | all | Harness to install for; `all` = every harness detected on this machine |
2758
+ | `--extra-dir` | string | — | Additional instruction dir (repeatable, for nonstandard/multi-instance layouts). Never guessed. |
2759
+ | `--dry-run` | boolean | false | Print per-target unified diffs; write nothing |
2760
+
2761
+ **Harness matrix (standard documented locations):**
2762
+
2763
+ | Target | Instruction file | Detection root | Notes |
2764
+ | ---------- | ------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------- |
2765
+ | `claude` | `$CLAUDE_CONFIG_DIR/CLAUDE.md` or `~/.claude/CLAUDE.md` | `~/.claude` | Honors `CLAUDE_CONFIG_DIR` |
2766
+ | `codex` | `$CODEX_HOME/AGENTS.md` or `~/.codex/AGENTS.md` | `~/.codex` | Honors `CODEX_HOME` |
2767
+ | `cursor` | `~/AGENTS.md` | `~/.cursor` | Cursor Agent walks cwd → home for AGENTS.md |
2768
+ | `opencode` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode` | |
2769
+ | `grok` | — (import chain) | `~/.grok` | Reads the Claude global file; reported `covered via claude`, never double-installed |
2770
+ | `hermes` | `~/.hermes/SOUL.md` | `~/.hermes` | |
2771
+ | `openclaw` | `~/.openclaw/workspace/AGENTS.md` | `~/.openclaw/workspace` | |
2772
+
2773
+ **Behavior:**
2774
+
2775
+ 1. Detects harnesses (config dir exists); undetected targets are reported
2776
+ `not-detected` and skipped — the installer never fabricates harness dirs
2777
+ (creating the instruction FILE inside a detected dir is fine). An explicit
2778
+ detected covered target (e.g. `--target grok`) also resolves its covering
2779
+ target so the file it actually reads converges; when the explicit target is
2780
+ absent, the covering chain never activates.
2781
+ 2. Backup-first, atomic: an existing file is copied to
2782
+ `<file>.gno-agents.bak.<timestamp>` (same permission mode as the source),
2783
+ then the new content is written to a sibling temp file and renamed over the
2784
+ destination, so a failed write leaves the live file unchanged. A fresh
2785
+ install appends the block after one blank line (a file without a final
2786
+ newline gets that newline first).
2787
+ 3. Idempotent: a current block is a `current` no-op (no write, no backup).
2788
+ 4. Fail-closed: malformed or duplicate markers, a file that is not valid
2789
+ UTF-8, or any read/write failure produce a per-target `error` row, nothing
2790
+ is written to that file, and — for install/update — the command prints the
2791
+ complete block (`manualBlock` in JSON) so the operator can apply it by
2792
+ hand. Other targets in the same run still proceed. A leading UTF-8 BOM is
2793
+ preserved across every operation.
2794
+ 5. Symlink-aware: writes go through the resolved real file (an operator's
2795
+ canonical-file-linked-everywhere scheme survives); targets resolving to the
2796
+ same real file are written once (`covered via <target> (same file)`).
2797
+ 6. `GNO_AGENTS_HOME_OVERRIDE` overrides the home directory (testing/sandboxed
2798
+ verification); any home override suppresses harness config-dir env vars.
2799
+
2800
+ **Output (JSON):**
2801
+
2802
+ ```json
2803
+ {
2804
+ "command": "install",
2805
+ "blockVersion": 2,
2806
+ "dryRun": false,
2807
+ "results": [
2808
+ {
2809
+ "target": "claude",
2810
+ "label": "Claude Code",
2811
+ "path": "/home/user/.claude/CLAUDE.md",
2812
+ "action": "install",
2813
+ "detected": true,
2814
+ "backup": null
2815
+ },
2816
+ {
2817
+ "target": "grok",
2818
+ "label": "Grok Build",
2819
+ "path": "/home/user/.claude/CLAUDE.md",
2820
+ "action": "covered",
2821
+ "detected": true,
2822
+ "via": "claude",
2823
+ "detail": "covered via claude"
2824
+ }
2825
+ ]
2826
+ }
2827
+ ```
2828
+
2829
+ `action` is one of `install`, `update`, `current`, `covered`, `not-detected`,
2830
+ `error` (and `remove`, `absent` for uninstall). A covered row's `path` is the
2831
+ file the harness actually reads (its covering target's file). With
2832
+ `--dry-run`, a `diffs` array of unified diffs is included. When
2833
+ any target failed on install/update, `manualBlock` carries the complete block
2834
+ text to paste. Schema: `spec/output-schemas/agents-mutation.schema.json`
2835
+ (shared by install, update, and uninstall).
2836
+
2837
+ **Exit Codes:**
2838
+
2839
+ - 0: Success (including no-op)
2840
+ - 1: Validation failure (unknown target, missing `--extra-dir`, malformed
2841
+ markers, non-UTF-8 file)
2842
+ - 2: Runtime failure — every failing target hit an I/O error (unreadable file,
2843
+ backup or write failed); the receipt still lists every target's outcome
2844
+
2845
+ ---
2846
+
2847
+ ### gno agents update
2848
+
2849
+ Refresh an installed block in place (block-version migration). Same options,
2850
+ output, and exit codes as `agents install`; both verbs converge the block to
2851
+ the current release — `update` exists for operator intent clarity.
2852
+
2853
+ ```bash
2854
+ gno agents update [--target <...>] [--extra-dir <path>]... [--dry-run] [--json]
2855
+ ```
2856
+
2857
+ ---
2858
+
2859
+ ### gno agents verify
2860
+
2861
+ Deterministic per-target verification of installed blocks. Fresh-session
2862
+ behavioral canaries are an operator practice, not automated here.
2863
+
2864
+ **Synopsis:**
2865
+
2866
+ ```bash
2867
+ gno agents verify [--target <...>] [--extra-dir <path>]... [--json]
2868
+ ```
2869
+
2870
+ **Checks per target:**
2871
+
2872
+ - Exactly one marker block (`malformed` otherwise)
2873
+ - Block stamp hash matches the block body (tamper detection)
2874
+ - Block version and content match the installed release (`outdated` otherwise)
2875
+ - The block carries no filesystem references by construction, so the spec's
2876
+ link-resolution check is vacuously satisfied
2877
+ - Targets resolving to the same real file are verified once, using install's
2878
+ ownership order; the rest report `covered via <target> (same file)`
2879
+
2880
+ **Output (JSON):**
2881
+
2882
+ ```json
2883
+ {
2884
+ "command": "verify",
2885
+ "blockVersion": 2,
2886
+ "ok": true,
2887
+ "results": [
2888
+ {
2889
+ "target": "claude",
2890
+ "label": "Claude Code",
2891
+ "path": "/home/user/.claude/CLAUDE.md",
2892
+ "status": "ok",
2893
+ "detected": true,
2894
+ "blockVersion": 2,
2895
+ "hashOk": true
2896
+ }
2897
+ ]
2898
+ }
2899
+ ```
2900
+
2901
+ `status` is one of `ok`, `outdated`, `missing`, `malformed`, `error`,
2902
+ `covered`, `not-detected`. `error` means the file exists but could not be read
2903
+ — a "could not check", never a content verdict. Schema:
2904
+ `spec/output-schemas/agents-verify.schema.json`.
2905
+
2906
+ **Exit Codes:**
2907
+
2908
+ - 0: All verified targets ok (covered / not-detected do not fail)
2909
+ - 1: One or more targets `outdated`, `missing`, or `malformed`
2910
+ - 2: Every failing target is an `error` (I/O) row
2911
+
2912
+ ---
2913
+
2914
+ ### gno agents uninstall
2915
+
2916
+ Remove the block, its markers, and the blank line install added above it;
2917
+ everything else stays byte-identical. Same options, output shape
2918
+ (`action: remove|absent|...`), and exit codes as `agents install`.
2919
+
2920
+ ```bash
2921
+ gno agents uninstall [--target <...>] [--extra-dir <path>]... [--dry-run] [--json]
2922
+ ```
2923
+
2924
+ ---
2925
+
2731
2926
  ### gno tags list
2732
2927
 
2733
2928
  List all tags with document counts.
@@ -3643,16 +3838,18 @@ Write-lease contention on `index` / `update` / `embed` does not use the generic
3643
3838
 
3644
3839
  ## Environment Variables
3645
3840
 
3646
- | Variable | Description |
3647
- | -------------------------- | --------------------------------------------------------------------- |
3648
- | `GNO_CONFIG_DIR` | Override config directory |
3649
- | `GNO_DATA_DIR` | Override data directory (DB location) |
3650
- | `GNO_CACHE_DIR` | Override cache directory (models) |
3651
- | `NO_COLOR` | Disable colored output (standard) |
3652
- | `PAGER` | Pager for long output (default: less -R on Unix, built-in on Windows) |
3653
- | `GNO_SKILLS_HOME_OVERRIDE` | Override home dir for skill user scope (testing) |
3654
- | `CLAUDE_SKILLS_DIR` | Override Claude skills directory |
3655
- | `CODEX_SKILLS_DIR` | Override Codex skills directory |
3841
+ | Variable | Description |
3842
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
3843
+ | `GNO_CONFIG_DIR` | Override config directory |
3844
+ | `GNO_DATA_DIR` | Override data directory (DB location) |
3845
+ | `GNO_CACHE_DIR` | Override cache directory (models) |
3846
+ | `NO_COLOR` | Disable colored output (standard) |
3847
+ | `PAGER` | Pager for long output (default: less -R on Unix, built-in on Windows) |
3848
+ | `GNO_SKILLS_HOME_OVERRIDE` | Override home dir for skill user scope (testing) |
3849
+ | `CLAUDE_SKILLS_DIR` | Override Claude skills directory |
3850
+ | `CODEX_SKILLS_DIR` | Override Codex skills directory |
3851
+ | `CLAUDE_CONFIG_DIR` | Claude Code config dir; `gno agents` resolves Claude's instruction file under it (suppressed by an explicit home override) |
3852
+ | `CODEX_HOME` | Codex config dir; same rule as `CLAUDE_CONFIG_DIR` |
3656
3853
 
3657
3854
  ---
3658
3855
 
@@ -0,0 +1,108 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "gno://schemas/agents-mutation@1.0",
4
+ "title": "GNO Agents Mutation Receipt",
5
+ "description": "JSON receipt for `gno agents install|update|uninstall --json`",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["command", "blockVersion", "dryRun", "results"],
9
+ "allOf": [
10
+ {
11
+ "if": {
12
+ "properties": { "dryRun": { "const": false } },
13
+ "required": ["dryRun"]
14
+ },
15
+ "then": {
16
+ "not": { "properties": { "diffs": true }, "required": ["diffs"] }
17
+ }
18
+ }
19
+ ],
20
+ "properties": {
21
+ "command": {
22
+ "enum": ["install", "update", "uninstall"],
23
+ "description": "The mutation verb that produced this receipt"
24
+ },
25
+ "blockVersion": {
26
+ "type": "integer",
27
+ "minimum": 1,
28
+ "description": "Protocol block version of the installed release"
29
+ },
30
+ "dryRun": {
31
+ "type": "boolean",
32
+ "description": "True when nothing was written; diffs carries the planned changes"
33
+ },
34
+ "results": {
35
+ "type": "array",
36
+ "description": "One row per resolved target",
37
+ "items": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": ["target", "label", "path", "action", "detected"],
41
+ "allOf": [
42
+ {
43
+ "if": {
44
+ "properties": { "action": { "const": "covered" } },
45
+ "required": ["action"]
46
+ },
47
+ "then": {
48
+ "properties": { "via": true, "detail": true },
49
+ "required": ["via", "detail"]
50
+ }
51
+ }
52
+ ],
53
+ "properties": {
54
+ "target": {
55
+ "type": "string",
56
+ "description": "Harness id (claude, codex, cursor, ...) or extra-dir id"
57
+ },
58
+ "label": {
59
+ "type": "string",
60
+ "description": "Human-readable harness label"
61
+ },
62
+ "path": {
63
+ "type": "string",
64
+ "description": "Instruction file path for this target"
65
+ },
66
+ "action": {
67
+ "enum": [
68
+ "install",
69
+ "update",
70
+ "current",
71
+ "remove",
72
+ "absent",
73
+ "covered",
74
+ "not-detected",
75
+ "error"
76
+ ],
77
+ "description": "What the run did (or would do) for this target"
78
+ },
79
+ "detected": {
80
+ "type": "boolean",
81
+ "description": "Whether the harness config dir exists on this machine"
82
+ },
83
+ "via": {
84
+ "type": "string",
85
+ "description": "Target id that covers this one (import chain or shared real file)"
86
+ },
87
+ "detail": {
88
+ "type": "string",
89
+ "description": "Human-readable detail (coverage reason, error guidance)"
90
+ },
91
+ "backup": {
92
+ "type": ["string", "null"],
93
+ "description": "Backup file path for a written target; null when no backup was made (new file or dry run). Omitted for no-op rows."
94
+ }
95
+ }
96
+ }
97
+ },
98
+ "diffs": {
99
+ "type": "array",
100
+ "description": "Unified diffs of planned writes; present only with dryRun",
101
+ "items": { "type": "string" }
102
+ },
103
+ "manualBlock": {
104
+ "type": "string",
105
+ "description": "The complete block to paste by hand; present on install/update when at least one target reported an error"
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "gno://schemas/agents-verify@1.0",
4
+ "title": "GNO Agents Verify Receipt",
5
+ "description": "JSON receipt for `gno agents verify --json`",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["command", "blockVersion", "ok", "results"],
9
+ "properties": {
10
+ "command": { "const": "verify" },
11
+ "blockVersion": {
12
+ "type": "integer",
13
+ "minimum": 1,
14
+ "description": "Protocol block version of the installed release"
15
+ },
16
+ "ok": {
17
+ "type": "boolean",
18
+ "description": "True when no target is outdated, missing, or malformed (covered / not-detected do not fail)"
19
+ },
20
+ "results": {
21
+ "type": "array",
22
+ "description": "One row per resolved target",
23
+ "items": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["target", "label", "path", "status", "detected"],
27
+ "allOf": [
28
+ {
29
+ "if": {
30
+ "properties": { "status": { "const": "covered" } },
31
+ "required": ["status"]
32
+ },
33
+ "then": {
34
+ "properties": { "via": true, "detail": true },
35
+ "required": ["via", "detail"]
36
+ }
37
+ }
38
+ ],
39
+ "properties": {
40
+ "target": {
41
+ "type": "string",
42
+ "description": "Harness id (claude, codex, cursor, ...) or extra-dir id"
43
+ },
44
+ "label": {
45
+ "type": "string",
46
+ "description": "Human-readable harness label"
47
+ },
48
+ "path": {
49
+ "type": "string",
50
+ "description": "Instruction file path for this target"
51
+ },
52
+ "status": {
53
+ "enum": [
54
+ "ok",
55
+ "outdated",
56
+ "missing",
57
+ "malformed",
58
+ "error",
59
+ "covered",
60
+ "not-detected"
61
+ ],
62
+ "description": "Verification outcome for this target (`error` = the file exists but could not be read)"
63
+ },
64
+ "detected": {
65
+ "type": "boolean",
66
+ "description": "Whether the harness config dir exists on this machine"
67
+ },
68
+ "via": {
69
+ "type": "string",
70
+ "description": "Target id whose row carries the actual block status (import chain or shared real file)"
71
+ },
72
+ "detail": {
73
+ "type": "string",
74
+ "description": "Human-readable detail (coverage reason, failure guidance)"
75
+ },
76
+ "blockVersion": {
77
+ "type": "integer",
78
+ "minimum": 0,
79
+ "description": "Version stamped on the found block (a legacy/pre-release stamp may read 0 — the `outdated` migration case); omitted when the stamp is unparseable"
80
+ },
81
+ "hashOk": {
82
+ "type": "boolean",
83
+ "description": "Block body matches its stamp hash (tamper detection)"
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * GNO agents protocol block: content, markers, hashing, extraction.
3
+ *
4
+ * One compact, versioned instruction block bounded by stable BEGIN/END
5
+ * markers. Install/update/uninstall touch ONLY the owned block; content
6
+ * outside the markers stays byte-identical. The block content is static —
7
+ * identical on every machine — so a block is current exactly when its stamp
8
+ * version matches the installed release and its hash matches its body.
9
+ *
10
+ * @module src/cli/commands/agents/block
11
+ */
12
+
13
+ import { CliError } from "../../errors.js";
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Constants
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /** Version of the protocol block content. Bump on any content change. */
20
+ export const BLOCK_VERSION = 2;
21
+
22
+ /** Stable across block versions — never change these once shipped. */
23
+ export const BEGIN_MARKER = "<!-- gno:agents:begin -->";
24
+ export const END_MARKER = "<!-- gno:agents:end -->";
25
+
26
+ const STAMP_RE = /^<!-- gno-agents block v(\d+) sha256:([0-9a-f]{16}) /;
27
+ const HASH_PREFIX_LENGTH = 16;
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Rendering
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * The protocol block body (between stamp line and end marker). Compact by
35
+ * design: the retrieval ladder + the writing contract. Detailed workflows live
36
+ * in the GNO skill; the block just points at it. No filesystem paths — the
37
+ * text is the same on every machine.
38
+ */
39
+ export function renderBlockBody(): string {
40
+ return `## GNO knowledge retrieval
41
+
42
+ Local knowledge search over indexed collections. Source files are the truth; the GNO index is disposable, machine-local.
43
+
44
+ Ladder — scope to a collection first (\`--collection <name>\`):
45
+
46
+ 1. Exact term/identifier/quote/error: \`gno search "<text>"\`
47
+ 2. Entity or known document: \`gno query "<question>" --fast -n 10\`
48
+ 3. Multi-document evidence for a goal: \`gno context build "<goal>" --budget 12000\`
49
+ 4. Change/dependency questions: \`gno changes\` / \`gno diff <doc>\` / \`gno impact <doc>\`
50
+ 5. Generated factual answer: \`gno ask "<question>" --verify\` (abstention is valid)
51
+ 6. Expected document missing: reformulate + re-check collection scope (\`gno query diagnose "<query>" --target <doc>\`) before any grep fallback.
52
+
53
+ Writing: retrieve first — a question alone is read-only. Edit an existing canonical note in its source file; \`gno capture\` creates genuinely new notes (collection, title/path, source kind, provenance) — never an update API. After writes: reindex the collection, verify retrieval.
54
+
55
+ Cite with gno:// URIs. Advanced retrieval (structured queries, filters, backlinks, similar, capture recipes) lives in the \`gno\` skill: load it (\`/gno\`) when installed, otherwise run \`gno skill install --scope user\` first.`;
56
+ }
57
+
58
+ /** SHA-256 hex digest of the body, truncated for the stamp line. */
59
+ export function hashBlockBody(body: string): string {
60
+ const hasher = new Bun.CryptoHasher("sha256");
61
+ hasher.update(body);
62
+ return hasher.digest("hex").slice(0, HASH_PREFIX_LENGTH);
63
+ }
64
+
65
+ /** Render the stamp line carrying version + body hash. */
66
+ export function renderStampLine(body: string): string {
67
+ return `<!-- gno-agents block v${BLOCK_VERSION} sha256:${hashBlockBody(body)} — managed by \`gno agents\`; manual edits inside the markers are overwritten -->`;
68
+ }
69
+
70
+ /** Render the complete block: BEGIN marker, stamp, body, END marker. */
71
+ export function renderBlock(): string {
72
+ const body = renderBlockBody();
73
+ return `${BEGIN_MARKER}\n${renderStampLine(body)}\n${body}\n${END_MARKER}`;
74
+ }
75
+
76
+ /** True when the extracted stamp's hash matches the extracted body. */
77
+ export function stampAuthenticates(block: ExtractedBlock): boolean {
78
+ return block.stamp !== null && block.stamp.hash === hashBlockBody(block.body);
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // Extraction & Validation
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ export interface ExtractedBlock {
86
+ /** Offset of the BEGIN marker in the file content. */
87
+ start: number;
88
+ /** Offset just past the END marker. */
89
+ end: number;
90
+ /** Everything between the markers (stamp line + body), without markers. */
91
+ inner: string;
92
+ /** Body without the stamp line (equal to inner when no stamp present). */
93
+ body: string;
94
+ /** Parsed stamp, when present and well-formed. */
95
+ stamp: { version: number; hash: string } | null;
96
+ }
97
+
98
+ export type BlockExtraction =
99
+ | { found: false }
100
+ | { found: true; block: ExtractedBlock };
101
+
102
+ function countOccurrences(haystack: string, needle: string): number {
103
+ let count = 0;
104
+ let idx = haystack.indexOf(needle);
105
+ while (idx !== -1) {
106
+ count += 1;
107
+ idx = haystack.indexOf(needle, idx + needle.length);
108
+ }
109
+ return count;
110
+ }
111
+
112
+ const MARKER_GUIDANCE =
113
+ "Fix or remove the markers manually (or restore the file from its .gno-agents.bak backup), then re-run.";
114
+
115
+ /**
116
+ * Extract the managed block from file content.
117
+ * Fail-closed: malformed or duplicate markers throw with guidance — the
118
+ * installer never guesses or "repairs".
119
+ */
120
+ export function extractBlock(
121
+ content: string,
122
+ filePath: string
123
+ ): BlockExtraction {
124
+ const begins = countOccurrences(content, BEGIN_MARKER);
125
+ const ends = countOccurrences(content, END_MARKER);
126
+
127
+ if (begins === 0 && ends === 0) {
128
+ return { found: false };
129
+ }
130
+ if (begins !== 1 || ends !== 1) {
131
+ throw new CliError(
132
+ "VALIDATION",
133
+ `Malformed GNO agents markers in ${filePath}: found ${begins} BEGIN and ${ends} END marker(s), expected exactly one of each. ${MARKER_GUIDANCE}`
134
+ );
135
+ }
136
+
137
+ const start = content.indexOf(BEGIN_MARKER);
138
+ const endMarkerStart = content.indexOf(END_MARKER);
139
+ if (endMarkerStart < start) {
140
+ throw new CliError(
141
+ "VALIDATION",
142
+ `Malformed GNO agents markers in ${filePath}: END marker appears before BEGIN marker. ${MARKER_GUIDANCE}`
143
+ );
144
+ }
145
+
146
+ const end = endMarkerStart + END_MARKER.length;
147
+ const rawInner = content.slice(start + BEGIN_MARKER.length, endMarkerStart);
148
+ // Trim exactly the structural newlines install added around the inner text.
149
+ const inner = rawInner.replace(/^\n/, "").replace(/\n$/, "");
150
+
151
+ const newlineIdx = inner.indexOf("\n");
152
+ const firstLine = newlineIdx === -1 ? inner : inner.slice(0, newlineIdx);
153
+ const stampMatch = STAMP_RE.exec(firstLine);
154
+ const version = stampMatch ? Number(stampMatch[1]) : Number.NaN;
155
+ // An absurd version (not a safe integer) is an unparseable stamp, not a
156
+ // numeric verdict — it would otherwise serialize as null in JSON receipts.
157
+ const stamp =
158
+ stampMatch && Number.isSafeInteger(version)
159
+ ? { version, hash: stampMatch[2] ?? "" }
160
+ : null;
161
+ const body = stamp && newlineIdx !== -1 ? inner.slice(newlineIdx + 1) : inner;
162
+
163
+ return { found: true, block: { start, end, inner, body, stamp } };
164
+ }