@gmickel/gno 1.38.0 → 1.39.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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Harness matrix for `gno agents`: which global (user-scope) instruction
3
+ * file each supported harness reads, how the harness is detected, and which
4
+ * import chains make a separate install redundant.
5
+ *
6
+ * Discovery is standard documented locations only — nonstandard/multi-
7
+ * instance layouts are served by the explicit `--extra-dir` flag, never by
8
+ * guessed discovery.
9
+ *
10
+ * @module src/cli/commands/agents/harnesses
11
+ */
12
+
13
+ // node:fs: detection needs synchronous stat/realpath (symlink-aware identity)
14
+ // with no Bun equivalent.
15
+ import { existsSync, realpathSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import {
18
+ basename,
19
+ dirname,
20
+ isAbsolute,
21
+ join,
22
+ normalize,
23
+ resolve,
24
+ } from "node:path";
25
+
26
+ import { CliError } from "../../errors.js";
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Environment Variables
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ /** Override home dir for testing / sandboxed live verification. */
33
+ export const ENV_AGENTS_HOME_OVERRIDE = "GNO_AGENTS_HOME_OVERRIDE";
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Types
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ export type HarnessId =
40
+ | "claude"
41
+ | "codex"
42
+ | "cursor"
43
+ | "opencode"
44
+ | "grok"
45
+ | "hermes"
46
+ | "openclaw";
47
+
48
+ export const HARNESS_IDS: HarnessId[] = [
49
+ "claude",
50
+ "codex",
51
+ "cursor",
52
+ "opencode",
53
+ "grok",
54
+ "hermes",
55
+ "openclaw",
56
+ ];
57
+
58
+ interface HarnessDef {
59
+ id: HarnessId;
60
+ label: string;
61
+ /** Harness config dir (detection root), relative to home. */
62
+ configDir: string;
63
+ /** Instruction file: inside the config dir, or directly under home. */
64
+ instructionFile: { dir: "config" | "home"; name: string };
65
+ /**
66
+ * Env var naming the harness's documented config-dir override
67
+ * (honored only when no explicit homeDir override is active).
68
+ */
69
+ configDirEnvVar?: string;
70
+ /**
71
+ * Import chain: this harness reads another harness's instruction file, so
72
+ * a separate install would double the block. Data-driven so future chains
73
+ * are matrix entries, not code changes.
74
+ */
75
+ coveredBy?: HarnessId;
76
+ }
77
+
78
+ const HARNESS_DEFS: Record<HarnessId, HarnessDef> = {
79
+ claude: {
80
+ id: "claude",
81
+ label: "Claude Code",
82
+ configDir: ".claude",
83
+ instructionFile: { dir: "config", name: "CLAUDE.md" },
84
+ configDirEnvVar: "CLAUDE_CONFIG_DIR",
85
+ },
86
+ codex: {
87
+ id: "codex",
88
+ label: "Codex",
89
+ configDir: ".codex",
90
+ instructionFile: { dir: "config", name: "AGENTS.md" },
91
+ configDirEnvVar: "CODEX_HOME",
92
+ },
93
+ cursor: {
94
+ id: "cursor",
95
+ label: "Cursor Agent",
96
+ configDir: ".cursor",
97
+ // Cursor Agent discovers AGENTS.md walking from the working directory
98
+ // towards the home directory; ~/AGENTS.md is its user-global surface.
99
+ instructionFile: { dir: "home", name: "AGENTS.md" },
100
+ },
101
+ opencode: {
102
+ id: "opencode",
103
+ label: "OpenCode",
104
+ configDir: ".config/opencode",
105
+ instructionFile: { dir: "config", name: "AGENTS.md" },
106
+ },
107
+ grok: {
108
+ id: "grok",
109
+ label: "Grok Build",
110
+ configDir: ".grok",
111
+ // Grok imports the Claude global instruction file — no file of its own;
112
+ // `coveredBy` makes resolution report Claude's file for it.
113
+ instructionFile: { dir: "config", name: "AGENTS.md" },
114
+ coveredBy: "claude",
115
+ },
116
+ hermes: {
117
+ id: "hermes",
118
+ label: "Hermes",
119
+ configDir: ".hermes",
120
+ instructionFile: { dir: "config", name: "SOUL.md" },
121
+ },
122
+ openclaw: {
123
+ id: "openclaw",
124
+ label: "OpenClaw",
125
+ configDir: ".openclaw/workspace",
126
+ instructionFile: { dir: "config", name: "AGENTS.md" },
127
+ },
128
+ };
129
+
130
+ export interface ResolvedTarget {
131
+ /** Harness id, or "extra-dir" for explicit --extra-dir paths. */
132
+ id: HarnessId | "extra-dir";
133
+ label: string;
134
+ /** Detection root (harness config dir, or the extra dir itself). */
135
+ configDir: string;
136
+ /** Absolute path to the instruction file the harness reads. */
137
+ file: string;
138
+ /** Real (symlink-resolved) identity of the file, for dedupe + writes. */
139
+ realFile: string;
140
+ /** Whether the harness is detected on this machine. */
141
+ detected: boolean;
142
+ /** Import chain target this harness is covered by, when applicable. */
143
+ coveredBy?: HarnessId;
144
+ }
145
+
146
+ export interface ResolveOptions {
147
+ /** Explicit home override (testing / sandboxed verification). When set,
148
+ * harness config-dir env overrides are ignored for determinism. */
149
+ homeDir?: string;
150
+ /** Explicit extra instruction dirs (nonstandard/multi-instance layouts). */
151
+ extraDirs?: string[];
152
+ }
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // Resolution
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Symlink-resolved identity of an instruction file. An existing file resolves
160
+ * fully (so a canonical file linked into several harnesses is written once,
161
+ * through the link); a file that does not exist yet resolves its parent dir,
162
+ * so two harnesses whose config dirs are links to one place still share an
163
+ * identity. Anything unresolvable falls back to the normalized path.
164
+ */
165
+ export function realIdentity(file: string): string {
166
+ try {
167
+ return realpathSync(file);
168
+ } catch {
169
+ const normalized = normalize(file);
170
+ try {
171
+ return join(realpathSync(dirname(normalized)), basename(normalized));
172
+ } catch {
173
+ return normalized;
174
+ }
175
+ }
176
+ }
177
+
178
+ function isDirectory(path: string): boolean {
179
+ try {
180
+ return statSync(path).isDirectory();
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ function resolveHarness(
187
+ def: HarnessDef,
188
+ home: string,
189
+ explicitHome: boolean
190
+ ): ResolvedTarget {
191
+ let configDir = join(home, def.configDir);
192
+
193
+ if (!explicitHome && def.configDirEnvVar) {
194
+ const envOverride = process.env[def.configDirEnvVar];
195
+ if (envOverride) {
196
+ if (!isAbsolute(envOverride)) {
197
+ throw new CliError(
198
+ "VALIDATION",
199
+ `${def.configDirEnvVar} must be an absolute path`
200
+ );
201
+ }
202
+ configDir = normalize(envOverride);
203
+ }
204
+ }
205
+
206
+ // A covered harness (grok → claude) reads its covering harness's file and
207
+ // has none of its own, so that is the file its rows report.
208
+ const file = def.coveredBy
209
+ ? resolveHarness(HARNESS_DEFS[def.coveredBy], home, explicitHome).file
210
+ : def.instructionFile.dir === "home"
211
+ ? join(home, def.instructionFile.name)
212
+ : join(configDir, def.instructionFile.name);
213
+
214
+ return {
215
+ id: def.id,
216
+ label: def.label,
217
+ configDir,
218
+ file,
219
+ realFile: realIdentity(file),
220
+ detected: isDirectory(configDir),
221
+ coveredBy: def.coveredBy,
222
+ };
223
+ }
224
+
225
+ /** Instruction file candidates for --extra-dir, in priority order. */
226
+ const EXTRA_DIR_FILE_CANDIDATES = ["CLAUDE.md", "AGENTS.md", "SOUL.md"];
227
+ const DEFAULT_EXTRA_DIR_FILE = "AGENTS.md";
228
+
229
+ function resolveExtraDir(dir: string): ResolvedTarget {
230
+ const abs = resolve(dir);
231
+ if (!isDirectory(abs)) {
232
+ throw new CliError(
233
+ "VALIDATION",
234
+ `--extra-dir ${dir} does not exist or is not a directory. The installer never fabricates harness directories.`
235
+ );
236
+ }
237
+ const existing = EXTRA_DIR_FILE_CANDIDATES.find((name) =>
238
+ existsSync(join(abs, name))
239
+ );
240
+ const file = join(abs, existing ?? DEFAULT_EXTRA_DIR_FILE);
241
+ return {
242
+ id: "extra-dir",
243
+ label: `extra dir ${abs}`,
244
+ configDir: abs,
245
+ file,
246
+ realFile: realIdentity(file),
247
+ detected: true,
248
+ };
249
+ }
250
+
251
+ /** Covering chain for an explicit target (e.g. grok → claude), covering first. */
252
+ function coveringChain(id: HarnessId): HarnessId[] {
253
+ const chain: HarnessId[] = [];
254
+ let cursor = HARNESS_DEFS[id].coveredBy;
255
+ while (cursor && !chain.includes(cursor) && cursor !== id) {
256
+ chain.unshift(cursor);
257
+ cursor = HARNESS_DEFS[cursor].coveredBy;
258
+ }
259
+ return chain;
260
+ }
261
+
262
+ /**
263
+ * Resolve all requested targets to concrete instruction files.
264
+ * `target: "all"` = every supported harness (detection filters at plan time).
265
+ * An explicit covered target (e.g. `grok`) also resolves its covering
266
+ * target(s) so the file it actually reads is planned/verified — but only when
267
+ * the requested target itself is detected; an absent one is reported
268
+ * `not-detected` without touching the covering harness's file.
269
+ */
270
+ export function resolveTargets(
271
+ target: HarnessId | "all",
272
+ opts: ResolveOptions = {}
273
+ ): ResolvedTarget[] {
274
+ // Any home override (option or env) suppresses harness config-dir env
275
+ // overrides too — an overridden home is an isolation request.
276
+ const explicitHome =
277
+ opts.homeDir !== undefined ||
278
+ process.env[ENV_AGENTS_HOME_OVERRIDE] !== undefined;
279
+ const home =
280
+ opts.homeDir ?? process.env[ENV_AGENTS_HOME_OVERRIDE] ?? homedir();
281
+ const one = (id: HarnessId): ResolvedTarget =>
282
+ resolveHarness(HARNESS_DEFS[id], home, explicitHome);
283
+
284
+ let results: ResolvedTarget[];
285
+ if (target === "all") {
286
+ results = HARNESS_IDS.map(one);
287
+ } else {
288
+ const leaf = one(target);
289
+ results = leaf.detected
290
+ ? [...coveringChain(target).map(one), leaf]
291
+ : [leaf];
292
+ }
293
+
294
+ for (const dir of opts.extraDirs ?? []) {
295
+ results.push(resolveExtraDir(dir));
296
+ }
297
+ return results;
298
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Agents command exports.
3
+ *
4
+ * @module src/cli/commands/agents
5
+ */
6
+
7
+ export {
8
+ BEGIN_MARKER,
9
+ BLOCK_VERSION,
10
+ END_MARKER,
11
+ extractBlock,
12
+ hashBlockBody,
13
+ renderBlock,
14
+ renderBlockBody,
15
+ } from "./block.js";
16
+ export {
17
+ type AgentsOptions,
18
+ installAgents,
19
+ parseTargetOption,
20
+ uninstallAgents,
21
+ verifyAgents,
22
+ } from "./commands.js";
23
+ export {
24
+ applyPlan,
25
+ planTargets,
26
+ type TargetPlan,
27
+ unifiedDiff,
28
+ } from "./engine.js";
29
+ export {
30
+ ENV_AGENTS_HOME_OVERRIDE,
31
+ type HarnessId,
32
+ HARNESS_IDS,
33
+ type ResolvedTarget,
34
+ resolveTargets,
35
+ } from "./harnesses.js";
@@ -57,6 +57,11 @@ const COMMANDS = [
57
57
  "skill uninstall",
58
58
  "skill show",
59
59
  "skill paths",
60
+ "agents",
61
+ "agents install",
62
+ "agents update",
63
+ "agents verify",
64
+ "agents uninstall",
60
65
  "completion",
61
66
  "completion output",
62
67
  "completion install",
@@ -364,6 +364,7 @@ export function createProgram(): Command {
364
364
  wireKnowledgeDeltaCommands(program);
365
365
  wireMcpCommand(program);
366
366
  wireSkillCommands(program);
367
+ wireAgentsCommands(program);
367
368
  wireDaemonCommand(program);
368
369
  wireServeCommand(program);
369
370
  wireCompletionCommand(program);
@@ -3320,6 +3321,109 @@ function wireSkillCommands(program: Command): void {
3320
3321
  });
3321
3322
  }
3322
3323
 
3324
+ // ─────────────────────────────────────────────────────────────────────────────
3325
+ // Agents Commands (install, update, verify, uninstall)
3326
+ // ─────────────────────────────────────────────────────────────────────────────
3327
+
3328
+ function wireAgentsCommands(program: Command): void {
3329
+ const agentsCmd = program
3330
+ .command("agents")
3331
+ .description(
3332
+ "Manage the GNO protocol block in global harness instruction files"
3333
+ );
3334
+
3335
+ const targetDescription =
3336
+ "target harness (claude, codex, cursor, opencode, grok, hermes, openclaw, all)";
3337
+
3338
+ const collectExtraDir = (value: string, previous: string[]): string[] => [
3339
+ ...previous,
3340
+ value,
3341
+ ];
3342
+
3343
+ const parseCommonOpts = async (cmdOpts: Record<string, unknown>) => {
3344
+ const { parseTargetOption } = await import("./commands/agents/commands.js");
3345
+ return {
3346
+ target: parseTargetOption((cmdOpts.target as string) ?? "all"),
3347
+ extraDirs: (cmdOpts.extraDir as string[] | undefined) ?? [],
3348
+ dryRun: Boolean(cmdOpts.dryRun),
3349
+ // The root program also defines a global --json and may consume the
3350
+ // flag; leave undefined here so the runner falls back to globals.
3351
+ json: cmdOpts.json ? true : undefined,
3352
+ };
3353
+ };
3354
+
3355
+ agentsCmd
3356
+ .command("install")
3357
+ .description(
3358
+ "Install the GNO protocol block into detected harnesses' global instruction files"
3359
+ )
3360
+ .option("-t, --target <target>", targetDescription, "all")
3361
+ .option(
3362
+ "--extra-dir <path>",
3363
+ "additional instruction dir (repeatable, for nonstandard layouts)",
3364
+ collectExtraDir,
3365
+ [] as string[]
3366
+ )
3367
+ .option("--dry-run", "print the unified diff and write nothing")
3368
+ .option("--json", "JSON output")
3369
+ .action(async (cmdOpts: Record<string, unknown>) => {
3370
+ const { installAgents } = await import("./commands/agents/commands.js");
3371
+ await installAgents(await parseCommonOpts(cmdOpts), "install");
3372
+ });
3373
+
3374
+ agentsCmd
3375
+ .command("update")
3376
+ .description("Refresh an installed GNO protocol block in place")
3377
+ .option("-t, --target <target>", targetDescription, "all")
3378
+ .option(
3379
+ "--extra-dir <path>",
3380
+ "additional instruction dir (repeatable, for nonstandard layouts)",
3381
+ collectExtraDir,
3382
+ [] as string[]
3383
+ )
3384
+ .option("--dry-run", "print the unified diff and write nothing")
3385
+ .option("--json", "JSON output")
3386
+ .action(async (cmdOpts: Record<string, unknown>) => {
3387
+ const { installAgents } = await import("./commands/agents/commands.js");
3388
+ await installAgents(await parseCommonOpts(cmdOpts), "update");
3389
+ });
3390
+
3391
+ agentsCmd
3392
+ .command("verify")
3393
+ .description(
3394
+ "Verify installed GNO protocol blocks (one block, version + hash)"
3395
+ )
3396
+ .option("-t, --target <target>", targetDescription, "all")
3397
+ .option(
3398
+ "--extra-dir <path>",
3399
+ "additional instruction dir (repeatable, for nonstandard layouts)",
3400
+ collectExtraDir,
3401
+ [] as string[]
3402
+ )
3403
+ .option("--json", "JSON output")
3404
+ .action(async (cmdOpts: Record<string, unknown>) => {
3405
+ const { verifyAgents } = await import("./commands/agents/commands.js");
3406
+ await verifyAgents(await parseCommonOpts(cmdOpts));
3407
+ });
3408
+
3409
+ agentsCmd
3410
+ .command("uninstall")
3411
+ .description("Remove the GNO protocol block and markers from harness files")
3412
+ .option("-t, --target <target>", targetDescription, "all")
3413
+ .option(
3414
+ "--extra-dir <path>",
3415
+ "additional instruction dir (repeatable, for nonstandard layouts)",
3416
+ collectExtraDir,
3417
+ [] as string[]
3418
+ )
3419
+ .option("--dry-run", "print the unified diff and write nothing")
3420
+ .option("--json", "JSON output")
3421
+ .action(async (cmdOpts: Record<string, unknown>) => {
3422
+ const { uninstallAgents } = await import("./commands/agents/commands.js");
3423
+ await uninstallAgents(await parseCommonOpts(cmdOpts));
3424
+ });
3425
+ }
3426
+
3323
3427
  // ─────────────────────────────────────────────────────────────────────────────
3324
3428
  // Tags Commands (list, add, rm)
3325
3429
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1 +0,0 @@
1
- 59f8dc960d1910f7b9dec3c4fea286c46bfe63487a43212fbb8ba61a207753ae gno-browser-clipper-v1.38.0.zip