@gmickel/gno 1.11.0 → 1.12.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.
@@ -739,11 +739,11 @@ Install GNO skill for AI coding assistants.
739
739
  gno skill install [options]
740
740
  ```
741
741
 
742
- | Option | Default | Description |
743
- | -------------- | ------- | -------------------------------------------------------- |
744
- | `-t, --target` | claude | Target: `claude`, `codex`, `opencode`, `openclaw`, `all` |
745
- | `-s, --scope` | project | Scope: `project`, `user` |
746
- | `-f, --force` | false | Overwrite existing |
742
+ | Option | Default | Description |
743
+ | -------------- | ------- | ------------------------------------------------------------------ |
744
+ | `-t, --target` | claude | Target: `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `all` |
745
+ | `-s, --scope` | project | Scope: `project`, `user` |
746
+ | `-f, --force` | false | Overwrite existing |
747
747
 
748
748
  Examples:
749
749
 
@@ -751,6 +751,7 @@ Examples:
751
751
  gno skill install --target claude --scope project
752
752
  gno skill install --target codex --scope user
753
753
  gno skill install --target openclaw --scope user
754
+ gno skill install --target hermes --scope user
754
755
  gno skill install --target all --force # Install to all targets
755
756
  ```
756
757
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Install GNO agent skill to Claude Code or Codex.
2
+ * Install GNO agent skill to supported agent targets.
3
3
  * Atomic install via temp directory + rename.
4
4
  *
5
5
  * @module src/cli/commands/skill/install
@@ -149,7 +149,11 @@ export async function installSkillToTarget(
149
149
 
150
150
  // Remove existing if present (with safety check)
151
151
  if (destExists) {
152
- const validationError = validatePathForDeletion(paths.gnoDir, paths.base);
152
+ const validationError = validatePathForDeletion(
153
+ paths.gnoDir,
154
+ paths.base,
155
+ paths.gnoDir
156
+ );
153
157
  if (validationError) {
154
158
  throw new CliError(
155
159
  "RUNTIME",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Path resolution for skill installation.
3
- * Supports Claude Code, Codex, OpenCode, and OpenClaw targets with project/user scopes.
3
+ * Supports Claude Code, Codex, OpenCode, OpenClaw, and Hermes targets with project/user scopes.
4
4
  *
5
5
  * @module src/cli/commands/skill/paths
6
6
  */
@@ -30,18 +30,27 @@ export const ENV_OPENCODE_SKILLS_DIR = "OPENCODE_SKILLS_DIR";
30
30
  /** Override OpenClaw skills directory */
31
31
  export const ENV_OPENCLAW_SKILLS_DIR = "OPENCLAW_SKILLS_DIR";
32
32
 
33
+ /** Override Hermes skills directory */
34
+ export const ENV_HERMES_SKILLS_DIR = "HERMES_SKILLS_DIR";
35
+
33
36
  // ─────────────────────────────────────────────────────────────────────────────
34
37
  // Types
35
38
  // ─────────────────────────────────────────────────────────────────────────────
36
39
 
37
40
  export type SkillScope = "project" | "user";
38
- export type SkillTarget = "claude" | "codex" | "opencode" | "openclaw";
41
+ export type SkillTarget =
42
+ | "claude"
43
+ | "codex"
44
+ | "opencode"
45
+ | "openclaw"
46
+ | "hermes";
39
47
 
40
48
  export const SKILL_TARGETS: SkillTarget[] = [
41
49
  "claude",
42
50
  "codex",
43
51
  "opencode",
44
52
  "openclaw",
53
+ "hermes",
45
54
  ];
46
55
 
47
56
  export interface SkillPathOptions {
@@ -102,6 +111,12 @@ const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
102
111
  skillsSubdir: "skills",
103
112
  envVar: ENV_OPENCLAW_SKILLS_DIR,
104
113
  },
114
+ hermes: {
115
+ projectBase: ".hermes",
116
+ userBase: ".hermes",
117
+ skillsSubdir: "skills",
118
+ envVar: ENV_HERMES_SKILLS_DIR,
119
+ },
105
120
  };
106
121
 
107
122
  // ─────────────────────────────────────────────────────────────────────────────
@@ -201,17 +216,21 @@ function getExpectedSuffixes(): string[] {
201
216
  */
202
217
  export function validatePathForDeletion(
203
218
  destDir: string,
204
- base: string
219
+ base: string,
220
+ expectedDir?: string
205
221
  ): string | null {
206
222
  const normalized = normalize(destDir);
207
223
  const normalizedBase = normalize(base);
224
+ const normalizedExpected = expectedDir ? normalize(expectedDir) : undefined;
208
225
  const expectedSuffixes = getExpectedSuffixes();
209
226
 
210
- // Must end with /skills/gno or /skill/gno (platform-aware)
227
+ // Must be the resolved target directory, or end with a known skill suffix.
228
+ // The exact resolved path covers absolute skills-dir env overrides.
229
+ const matchesExpectedDir = normalizedExpected === normalized;
211
230
  const hasValidSuffix = expectedSuffixes.some((suffix) =>
212
231
  normalized.endsWith(suffix)
213
232
  );
214
- if (!hasValidSuffix) {
233
+ if (!(matchesExpectedDir || hasValidSuffix)) {
215
234
  return `Path does not end with expected suffix (${expectedSuffixes.join(" or ")})`;
216
235
  }
217
236
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Uninstall GNO agent skill from Claude Code or Codex.
2
+ * Uninstall GNO agent skill from supported agent targets.
3
3
  * Includes safety checks before deletion.
4
4
  *
5
5
  * @module src/cli/commands/skill/uninstall
@@ -58,7 +58,11 @@ async function uninstallFromTarget(
58
58
  }
59
59
 
60
60
  // Safety validation
61
- const validationError = validatePathForDeletion(paths.gnoDir, paths.base);
61
+ const validationError = validatePathForDeletion(
62
+ paths.gnoDir,
63
+ paths.base,
64
+ paths.gnoDir
65
+ );
62
66
  if (validationError) {
63
67
  throw new CliError(
64
68
  "RUNTIME",
@@ -1939,7 +1939,7 @@ function wireSkillCommands(program: Command): void {
1939
1939
 
1940
1940
  skillCmd
1941
1941
  .command("install")
1942
- .description("Install GNO skill to Claude Code or Codex")
1942
+ .description("Install GNO skill to supported agents")
1943
1943
  .option(
1944
1944
  "-s, --scope <scope>",
1945
1945
  "installation scope (project, user)",
@@ -1947,7 +1947,7 @@ function wireSkillCommands(program: Command): void {
1947
1947
  )
1948
1948
  .option(
1949
1949
  "-t, --target <target>",
1950
- "target agent (claude, codex, opencode, openclaw, all)",
1950
+ "target agent (claude, codex, opencode, openclaw, hermes, all)",
1951
1951
  "claude"
1952
1952
  )
1953
1953
  .option("-f, --force", "overwrite existing installation")
@@ -1965,18 +1965,26 @@ function wireSkillCommands(program: Command): void {
1965
1965
  }
1966
1966
  // Validate target
1967
1967
  if (
1968
- !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
1968
+ !["claude", "codex", "opencode", "openclaw", "hermes", "all"].includes(
1969
+ target
1970
+ )
1969
1971
  ) {
1970
1972
  throw new CliError(
1971
1973
  "VALIDATION",
1972
- `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
1974
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', 'hermes', or 'all'.`
1973
1975
  );
1974
1976
  }
1975
1977
 
1976
1978
  const { installSkill } = await import("./commands/skill/install.js");
1977
1979
  await installSkill({
1978
1980
  scope: scope as "project" | "user",
1979
- target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
1981
+ target: target as
1982
+ | "claude"
1983
+ | "codex"
1984
+ | "opencode"
1985
+ | "openclaw"
1986
+ | "hermes"
1987
+ | "all",
1980
1988
  force: Boolean(cmdOpts.force),
1981
1989
  json: Boolean(cmdOpts.json),
1982
1990
  });
@@ -1992,7 +2000,7 @@ function wireSkillCommands(program: Command): void {
1992
2000
  )
1993
2001
  .option(
1994
2002
  "-t, --target <target>",
1995
- "target agent (claude, codex, opencode, openclaw, all)",
2003
+ "target agent (claude, codex, opencode, openclaw, hermes, all)",
1996
2004
  "claude"
1997
2005
  )
1998
2006
  .option("--json", "JSON output")
@@ -2009,18 +2017,26 @@ function wireSkillCommands(program: Command): void {
2009
2017
  }
2010
2018
  // Validate target
2011
2019
  if (
2012
- !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
2020
+ !["claude", "codex", "opencode", "openclaw", "hermes", "all"].includes(
2021
+ target
2022
+ )
2013
2023
  ) {
2014
2024
  throw new CliError(
2015
2025
  "VALIDATION",
2016
- `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
2026
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', 'hermes', or 'all'.`
2017
2027
  );
2018
2028
  }
2019
2029
 
2020
2030
  const { uninstallSkill } = await import("./commands/skill/uninstall.js");
2021
2031
  await uninstallSkill({
2022
2032
  scope: scope as "project" | "user",
2023
- target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
2033
+ target: target as
2034
+ | "claude"
2035
+ | "codex"
2036
+ | "opencode"
2037
+ | "openclaw"
2038
+ | "hermes"
2039
+ | "all",
2024
2040
  json: Boolean(cmdOpts.json),
2025
2041
  });
2026
2042
  });
@@ -2048,7 +2064,7 @@ function wireSkillCommands(program: Command): void {
2048
2064
  )
2049
2065
  .option(
2050
2066
  "-t, --target <target>",
2051
- "filter by target (claude, codex, opencode, openclaw, all)",
2067
+ "filter by target (claude, codex, opencode, openclaw, hermes, all)",
2052
2068
  "all"
2053
2069
  )
2054
2070
  .option("--json", "JSON output")
@@ -2065,18 +2081,26 @@ function wireSkillCommands(program: Command): void {
2065
2081
  }
2066
2082
  // Validate target
2067
2083
  if (
2068
- !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
2084
+ !["claude", "codex", "opencode", "openclaw", "hermes", "all"].includes(
2085
+ target
2086
+ )
2069
2087
  ) {
2070
2088
  throw new CliError(
2071
2089
  "VALIDATION",
2072
- `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
2090
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', 'hermes', or 'all'.`
2073
2091
  );
2074
2092
  }
2075
2093
 
2076
2094
  const { showPaths } = await import("./commands/skill/paths-cmd.js");
2077
2095
  await showPaths({
2078
2096
  scope: scope as "project" | "user" | "all",
2079
- target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
2097
+ target: target as
2098
+ | "claude"
2099
+ | "codex"
2100
+ | "opencode"
2101
+ | "openclaw"
2102
+ | "hermes"
2103
+ | "all",
2080
2104
  json: Boolean(cmdOpts.json),
2081
2105
  });
2082
2106
  });
@@ -40,6 +40,7 @@ const CFB_SIGNATURE = new Uint8Array([
40
40
  ]);
41
41
  const ENCRYPTION_INFO = utf16le("EncryptionInfo");
42
42
  const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
43
+ const PDF_TRAILER_SCAN_BYTES = 2048;
43
44
  const MAX_MESSAGE_LENGTH = 200;
44
45
  const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
45
46
 
@@ -100,6 +101,19 @@ function isPasswordProtectedPdf(bytes: Uint8Array): boolean {
100
101
  return /\/Encrypt\b/.test(tail);
101
102
  }
102
103
 
104
+ function hasCompletePdfTrailer(bytes: Uint8Array): boolean {
105
+ if (!hasPrefix(bytes, PDF_SIGNATURE)) {
106
+ return false;
107
+ }
108
+
109
+ const tailStart = Math.max(0, bytes.length - PDF_TRAILER_SCAN_BYTES);
110
+ const tail = Buffer.from(bytes.subarray(tailStart)).toString("latin1");
111
+ const eofIndex = tail.lastIndexOf("%%EOF");
112
+ const startXrefIndex = tail.lastIndexOf("startxref");
113
+
114
+ return startXrefIndex >= 0 && eofIndex > startXrefIndex;
115
+ }
116
+
103
117
  function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
104
118
  return (
105
119
  hasPrefix(bytes, CFB_SIGNATURE) &&
@@ -162,7 +176,20 @@ export const markitdownAdapter: Converter = {
162
176
  return { ok: false, error: tooLargeError(input, CONVERTER_ID) };
163
177
  }
164
178
 
165
- // 1b. Detect password-protected documents before calling markitdown-ts.
179
+ // 1b. Reject obviously truncated PDFs before markitdown-ts logs a parser
180
+ // stack trace. ISO 32000 requires the final %%EOF marker near the file end.
181
+ if (input.ext === ".pdf" && !hasCompletePdfTrailer(input.bytes)) {
182
+ return {
183
+ ok: false,
184
+ error: corruptError(
185
+ input,
186
+ CONVERTER_ID,
187
+ "Invalid or incomplete PDF structure"
188
+ ),
189
+ };
190
+ }
191
+
192
+ // 1c. Detect password-protected documents before calling markitdown-ts.
166
193
  // markitdown-ts logs dependency stack traces to stderr for these files.
167
194
  if (isPasswordProtected(input)) {
168
195
  return {
@@ -75,6 +75,12 @@ const MAX_CONCURRENCY = 16;
75
75
  export const INGEST_VERSION = 6;
76
76
  const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
77
77
  const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
78
+ const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
79
+ "CORRUPT",
80
+ "PERMISSION",
81
+ "TOO_LARGE",
82
+ "UNSUPPORTED",
83
+ ]);
78
84
 
79
85
  type RelationMap = Record<string, string[]>;
80
86
 
@@ -219,6 +225,20 @@ function decideAction(
219
225
 
220
226
  // Source unchanged, but check for repair cases:
221
227
 
228
+ // Preserve non-retryable conversion failures until the source or ingest
229
+ // version changes. Re-running an unchanged corrupt/protected file on every
230
+ // sync only repeats expensive work and noisy diagnostics.
231
+ if (
232
+ existing.lastErrorCode &&
233
+ NON_RETRYABLE_CONVERSION_ERROR_CODES.has(existing.lastErrorCode) &&
234
+ existing.ingestVersion === INGEST_VERSION
235
+ ) {
236
+ return {
237
+ kind: "skip",
238
+ reason: "unchanged non-retryable conversion failure",
239
+ };
240
+ }
241
+
222
242
  // 1. Previous conversion failed (mirrorHash is null)
223
243
  if (!existing.mirrorHash) {
224
244
  return { kind: "repair", reason: "previous conversion failed" };
@@ -738,6 +758,8 @@ export class SyncService {
738
758
  sourceCtime,
739
759
  lastErrorCode: convertResult.error.code,
740
760
  lastErrorMessage: convertResult.error.message,
761
+ ingestVersion: INGEST_VERSION,
762
+ contentTypeRulesFingerprint,
741
763
  // mirrorHash intentionally omitted (will be null)
742
764
  });
743
765
 
@@ -118,6 +118,18 @@ const CONNECTOR_DEFINITIONS: ConnectorDefinition[] = [
118
118
  "Recommended default for local agent access without manual file edits.",
119
119
  },
120
120
  },
121
+ {
122
+ id: "hermes-skill",
123
+ appName: "Hermes Agent",
124
+ installKind: "skill",
125
+ target: "hermes",
126
+ scope: "user",
127
+ mode: {
128
+ label: "Read/search via skill",
129
+ detail:
130
+ "Recommended default for Hermes Agent. Uses the standard ~/.hermes/skills path.",
131
+ },
132
+ },
121
133
  ] as const;
122
134
 
123
135
  export async function getConnectorStatuses(overrides?: {