@gmickel/gno 0.25.2 → 0.27.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.
Files changed (36) hide show
  1. package/README.md +5 -3
  2. package/assets/skill/SKILL.md +5 -0
  3. package/assets/skill/cli-reference.md +8 -6
  4. package/package.json +1 -1
  5. package/src/cli/commands/get.ts +21 -0
  6. package/src/cli/commands/skill/install.ts +2 -2
  7. package/src/cli/commands/skill/paths.ts +26 -4
  8. package/src/cli/commands/skill/uninstall.ts +2 -2
  9. package/src/cli/program.ts +18 -12
  10. package/src/core/document-capabilities.ts +113 -0
  11. package/src/mcp/tools/get.ts +10 -0
  12. package/src/mcp/tools/index.ts +434 -110
  13. package/src/sdk/documents.ts +12 -0
  14. package/src/serve/doc-events.ts +69 -0
  15. package/src/serve/public/app.tsx +81 -24
  16. package/src/serve/public/components/CaptureModal.tsx +138 -3
  17. package/src/serve/public/components/QuickSwitcher.tsx +248 -0
  18. package/src/serve/public/components/ShortcutHelpModal.tsx +1 -0
  19. package/src/serve/public/components/ai-elements/code-block.tsx +74 -26
  20. package/src/serve/public/components/editor/CodeMirrorEditor.tsx +51 -0
  21. package/src/serve/public/components/ui/command.tsx +2 -2
  22. package/src/serve/public/hooks/use-doc-events.ts +34 -0
  23. package/src/serve/public/hooks/useCaptureModal.tsx +12 -3
  24. package/src/serve/public/hooks/useKeyboardShortcuts.ts +2 -2
  25. package/src/serve/public/lib/deep-links.ts +68 -0
  26. package/src/serve/public/lib/document-availability.ts +22 -0
  27. package/src/serve/public/lib/local-history.ts +44 -0
  28. package/src/serve/public/lib/wiki-link.ts +36 -0
  29. package/src/serve/public/pages/Browse.tsx +11 -0
  30. package/src/serve/public/pages/Dashboard.tsx +2 -2
  31. package/src/serve/public/pages/DocView.tsx +241 -18
  32. package/src/serve/public/pages/DocumentEditor.tsx +399 -9
  33. package/src/serve/public/pages/Search.tsx +20 -1
  34. package/src/serve/routes/api.ts +359 -28
  35. package/src/serve/server.ts +48 -1
  36. package/src/serve/watch-service.ts +149 -0
package/README.md CHANGED
@@ -194,9 +194,11 @@ Check status: `gno mcp status`
194
194
  Skills integrate via CLI with no MCP overhead:
195
195
 
196
196
  ```bash
197
- gno skill install --scope user # User-wide
198
- gno skill install --target codex # Codex
199
- gno skill install --target all # Both Claude + Codex
197
+ gno skill install --scope user # User-wide
198
+ gno skill install --target codex # Codex
199
+ gno skill install --target opencode # OpenCode
200
+ gno skill install --target openclaw # OpenClaw
201
+ gno skill install --target all # All targets
200
202
  ```
201
203
 
202
204
  > **Full setup guide**: [MCP Integration](https://gno.sh/docs/MCP/) · [CLI Reference](https://gno.sh/docs/CLI/)
@@ -110,10 +110,15 @@ gno get gno://work/report.md --from 100 -l 20
110
110
  # With line numbers
111
111
  gno get gno://work/report.md --line-numbers
112
112
 
113
+ # JSON output with capabilities metadata
114
+ gno get gno://work/report.md --json
115
+
113
116
  # Multiple documents
114
117
  gno multi-get gno://work/doc1.md gno://work/doc2.md
115
118
  ```
116
119
 
120
+ **Editable vs read-only**: `gno get --json` returns a `capabilities` field showing whether a document is editable at its source. Markdown and plain text files are editable in place. Converted documents (PDF, DOCX, XLSX) are read-only -- to edit their content, create a new markdown note instead of overwriting the binary source.
121
+
117
122
  ## Search Then Get (common pipeline)
118
123
 
119
124
  ```bash
@@ -488,18 +488,20 @@ Install GNO skill for AI coding assistants.
488
488
  gno skill install [options]
489
489
  ```
490
490
 
491
- | Option | Default | Description |
492
- | -------------- | ------- | -------------------------------- |
493
- | `-t, --target` | claude | Target: `claude`, `codex`, `amp` |
494
- | `-s, --scope` | user | Scope: `user`, `project` |
495
- | `-f, --force` | false | Overwrite existing |
496
- | `--dry-run` | false | Preview changes |
491
+ | Option | Default | Description |
492
+ | -------------- | ------- | -------------------------------------------------------- |
493
+ | `-t, --target` | claude | Target: `claude`, `codex`, `opencode`, `openclaw`, `all` |
494
+ | `-s, --scope` | user | Scope: `user`, `project` |
495
+ | `-f, --force` | false | Overwrite existing |
496
+ | `--dry-run` | false | Preview changes |
497
497
 
498
498
  Examples:
499
499
 
500
500
  ```bash
501
501
  gno skill install --target claude --scope project
502
502
  gno skill install --target codex --scope user
503
+ gno skill install --target openclaw --scope user
504
+ gno skill install --target all --force # Install to all targets
503
505
  ```
504
506
 
505
507
  ### gno skill uninstall
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.25.2",
3
+ "version": "0.27.0",
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",
@@ -8,6 +8,10 @@
8
8
  import type { DocumentRow, StorePort, StoreResult } from "../../store/types";
9
9
  import type { ParsedRef } from "./ref-parser";
10
10
 
11
+ import {
12
+ getDocumentCapabilities,
13
+ type DocumentCapabilities,
14
+ } from "../../core/document-capabilities";
11
15
  import { parseRef } from "./ref-parser";
12
16
  import { initStore } from "./shared";
13
17
 
@@ -58,6 +62,7 @@ export interface GetResponse {
58
62
  converterVersion?: string;
59
63
  mirrorHash?: string;
60
64
  };
65
+ capabilities: DocumentCapabilities;
61
66
  }
62
67
 
63
68
  // ─────────────────────────────────────────────────────────────────────────────
@@ -203,6 +208,7 @@ function buildResponse(ctx: BuildResponseContext): GetResult {
203
208
  language: doc.languageHint ?? undefined,
204
209
  source: buildSourceMeta(doc, config),
205
210
  conversion: buildConversionMeta(doc),
211
+ capabilities: buildCapabilities(doc),
206
212
  },
207
213
  };
208
214
  }
@@ -233,6 +239,7 @@ function buildResponse(ctx: BuildResponseContext): GetResult {
233
239
  language: doc.languageHint ?? undefined,
234
240
  source: buildSourceMeta(doc, config),
235
241
  conversion: buildConversionMeta(doc),
242
+ capabilities: buildCapabilities(doc),
236
243
  },
237
244
  };
238
245
  }
@@ -247,6 +254,7 @@ interface DocRow {
247
254
  sourceMime: string;
248
255
  sourceExt: string;
249
256
  sourceSize: number;
257
+ sourceMtime?: string;
250
258
  sourceHash: string;
251
259
  }
252
260
 
@@ -262,11 +270,24 @@ function buildSourceMeta(
262
270
  relPath: doc.relPath,
263
271
  mime: doc.sourceMime,
264
272
  ext: doc.sourceExt,
273
+ modifiedAt: doc.sourceMtime ?? undefined,
265
274
  sizeBytes: doc.sourceSize,
266
275
  sourceHash: doc.sourceHash,
267
276
  };
268
277
  }
269
278
 
279
+ function buildCapabilities(doc: {
280
+ sourceExt: string;
281
+ sourceMime: string;
282
+ mirrorHash?: string | null;
283
+ }): DocumentCapabilities {
284
+ return getDocumentCapabilities({
285
+ sourceExt: doc.sourceExt,
286
+ sourceMime: doc.sourceMime,
287
+ contentAvailable: doc.mirrorHash !== null,
288
+ });
289
+ }
290
+
270
291
  interface ConversionDoc {
271
292
  converterId?: string | null;
272
293
  converterVersion?: string | null;
@@ -13,6 +13,7 @@ import { CliError } from "../../errors.js";
13
13
  import { getGlobals } from "../../program.js";
14
14
  import {
15
15
  resolveSkillPaths,
16
+ SKILL_TARGETS,
16
17
  type SkillScope,
17
18
  type SkillTarget,
18
19
  validatePathForDeletion,
@@ -171,8 +172,7 @@ export async function installSkill(opts: InstallOptions = {}): Promise<void> {
171
172
  const yes = opts.yes ?? globals.yes;
172
173
  const quiet = opts.quiet ?? globals.quiet;
173
174
 
174
- const targets: SkillTarget[] =
175
- target === "all" ? ["claude", "codex"] : [target];
175
+ const targets: SkillTarget[] = target === "all" ? SKILL_TARGETS : [target];
176
176
 
177
177
  const results: InstallResult[] = [];
178
178
 
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Path resolution for skill installation.
3
- * Supports Claude Code and Codex targets with project/user scopes.
4
- * Note: OpenCode and Amp use the same .claude path as Claude Code.
3
+ * Supports Claude Code, Codex, OpenCode, and OpenClaw targets with project/user scopes.
5
4
  *
6
5
  * @module src/cli/commands/skill/paths
7
6
  */
@@ -22,14 +21,25 @@ export const ENV_CLAUDE_SKILLS_DIR = "CLAUDE_SKILLS_DIR";
22
21
  /** Override Codex skills directory */
23
22
  export const ENV_CODEX_SKILLS_DIR = "CODEX_SKILLS_DIR";
24
23
 
24
+ /** Override OpenCode skills directory */
25
+ export const ENV_OPENCODE_SKILLS_DIR = "OPENCODE_SKILLS_DIR";
26
+
27
+ /** Override OpenClaw skills directory */
28
+ export const ENV_OPENCLAW_SKILLS_DIR = "OPENCLAW_SKILLS_DIR";
29
+
25
30
  // ─────────────────────────────────────────────────────────────────────────────
26
31
  // Types
27
32
  // ─────────────────────────────────────────────────────────────────────────────
28
33
 
29
34
  export type SkillScope = "project" | "user";
30
- export type SkillTarget = "claude" | "codex";
35
+ export type SkillTarget = "claude" | "codex" | "opencode" | "openclaw";
31
36
 
32
- export const SKILL_TARGETS: SkillTarget[] = ["claude", "codex"];
37
+ export const SKILL_TARGETS: SkillTarget[] = [
38
+ "claude",
39
+ "codex",
40
+ "opencode",
41
+ "openclaw",
42
+ ];
33
43
 
34
44
  export interface SkillPathOptions {
35
45
  scope: SkillScope;
@@ -77,6 +87,18 @@ const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
77
87
  skillsSubdir: "skills",
78
88
  envVar: ENV_CODEX_SKILLS_DIR,
79
89
  },
90
+ opencode: {
91
+ projectBase: ".opencode",
92
+ userBase: ".config/opencode",
93
+ skillsSubdir: "skills",
94
+ envVar: ENV_OPENCODE_SKILLS_DIR,
95
+ },
96
+ openclaw: {
97
+ projectBase: ".openclaw",
98
+ userBase: ".openclaw",
99
+ skillsSubdir: "skills",
100
+ envVar: ENV_OPENCLAW_SKILLS_DIR,
101
+ },
80
102
  };
81
103
 
82
104
  // ─────────────────────────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import { CliError } from "../../errors.js";
12
12
  import { getGlobals } from "../../program.js";
13
13
  import {
14
14
  resolveSkillPaths,
15
+ SKILL_TARGETS,
15
16
  type SkillScope,
16
17
  type SkillTarget,
17
18
  validatePathForDeletion,
@@ -100,8 +101,7 @@ export async function uninstallSkill(
100
101
  const json = opts.json ?? globals.json;
101
102
  const quiet = opts.quiet ?? globals.quiet;
102
103
 
103
- const targets: SkillTarget[] =
104
- target === "all" ? ["claude", "codex"] : [target];
104
+ const targets: SkillTarget[] = target === "all" ? SKILL_TARGETS : [target];
105
105
 
106
106
  const results: UninstallResult[] = [];
107
107
  const notFound: string[] = [];
@@ -1578,7 +1578,7 @@ function wireSkillCommands(program: Command): void {
1578
1578
  )
1579
1579
  .option(
1580
1580
  "-t, --target <target>",
1581
- "target agent (claude, codex, all)",
1581
+ "target agent (claude, codex, opencode, openclaw, all)",
1582
1582
  "claude"
1583
1583
  )
1584
1584
  .option("-f, --force", "overwrite existing installation")
@@ -1595,17 +1595,19 @@ function wireSkillCommands(program: Command): void {
1595
1595
  );
1596
1596
  }
1597
1597
  // Validate target
1598
- if (!["claude", "codex", "all"].includes(target)) {
1598
+ if (
1599
+ !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
1600
+ ) {
1599
1601
  throw new CliError(
1600
1602
  "VALIDATION",
1601
- `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1603
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
1602
1604
  );
1603
1605
  }
1604
1606
 
1605
1607
  const { installSkill } = await import("./commands/skill/install.js");
1606
1608
  await installSkill({
1607
1609
  scope: scope as "project" | "user",
1608
- target: target as "claude" | "codex" | "all",
1610
+ target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
1609
1611
  force: Boolean(cmdOpts.force),
1610
1612
  json: Boolean(cmdOpts.json),
1611
1613
  });
@@ -1621,7 +1623,7 @@ function wireSkillCommands(program: Command): void {
1621
1623
  )
1622
1624
  .option(
1623
1625
  "-t, --target <target>",
1624
- "target agent (claude, codex, all)",
1626
+ "target agent (claude, codex, opencode, openclaw, all)",
1625
1627
  "claude"
1626
1628
  )
1627
1629
  .option("--json", "JSON output")
@@ -1637,17 +1639,19 @@ function wireSkillCommands(program: Command): void {
1637
1639
  );
1638
1640
  }
1639
1641
  // Validate target
1640
- if (!["claude", "codex", "all"].includes(target)) {
1642
+ if (
1643
+ !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
1644
+ ) {
1641
1645
  throw new CliError(
1642
1646
  "VALIDATION",
1643
- `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1647
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
1644
1648
  );
1645
1649
  }
1646
1650
 
1647
1651
  const { uninstallSkill } = await import("./commands/skill/uninstall.js");
1648
1652
  await uninstallSkill({
1649
1653
  scope: scope as "project" | "user",
1650
- target: target as "claude" | "codex" | "all",
1654
+ target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
1651
1655
  json: Boolean(cmdOpts.json),
1652
1656
  });
1653
1657
  });
@@ -1675,7 +1679,7 @@ function wireSkillCommands(program: Command): void {
1675
1679
  )
1676
1680
  .option(
1677
1681
  "-t, --target <target>",
1678
- "filter by target (claude, codex, all)",
1682
+ "filter by target (claude, codex, opencode, openclaw, all)",
1679
1683
  "all"
1680
1684
  )
1681
1685
  .option("--json", "JSON output")
@@ -1691,17 +1695,19 @@ function wireSkillCommands(program: Command): void {
1691
1695
  );
1692
1696
  }
1693
1697
  // Validate target
1694
- if (!["claude", "codex", "all"].includes(target)) {
1698
+ if (
1699
+ !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
1700
+ ) {
1695
1701
  throw new CliError(
1696
1702
  "VALIDATION",
1697
- `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1703
+ `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
1698
1704
  );
1699
1705
  }
1700
1706
 
1701
1707
  const { showPaths } = await import("./commands/skill/paths-cmd.js");
1702
1708
  await showPaths({
1703
1709
  scope: scope as "project" | "user" | "all",
1704
- target: target as "claude" | "codex" | "all",
1710
+ target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
1705
1711
  json: Boolean(cmdOpts.json),
1706
1712
  });
1707
1713
  });
@@ -0,0 +1,113 @@
1
+ // node:path has no Bun equivalent
2
+ import { posix as pathPosix } from "node:path";
3
+
4
+ export type DocumentCapabilityMode = "editable" | "read_only";
5
+
6
+ export interface DocumentCapabilities {
7
+ editable: boolean;
8
+ tagsEditable: boolean;
9
+ tagsWriteback: boolean;
10
+ canCreateEditableCopy: boolean;
11
+ mode: DocumentCapabilityMode;
12
+ reason?: string;
13
+ }
14
+
15
+ const EDITABLE_EXTENSIONS = new Set([
16
+ ".md",
17
+ ".markdown",
18
+ ".mdx",
19
+ ".txt",
20
+ ".text",
21
+ ]);
22
+
23
+ function isTextLikeMime(mime: string): boolean {
24
+ return mime.startsWith("text/");
25
+ }
26
+
27
+ export function getDocumentCapabilities(input: {
28
+ sourceExt: string;
29
+ sourceMime: string;
30
+ contentAvailable: boolean;
31
+ }): DocumentCapabilities {
32
+ const ext = input.sourceExt.toLowerCase();
33
+ const editable =
34
+ EDITABLE_EXTENSIONS.has(ext) || isTextLikeMime(input.sourceMime);
35
+ const tagsWriteback = ext === ".md" || ext === ".markdown" || ext === ".mdx";
36
+
37
+ if (editable) {
38
+ return {
39
+ editable: true,
40
+ tagsEditable: true,
41
+ tagsWriteback,
42
+ canCreateEditableCopy: false,
43
+ mode: "editable",
44
+ };
45
+ }
46
+
47
+ return {
48
+ editable: false,
49
+ tagsEditable: true,
50
+ tagsWriteback: false,
51
+ canCreateEditableCopy: input.contentAvailable,
52
+ mode: "read_only",
53
+ reason:
54
+ "This document is derived from a source format that GNO cannot safely write back in place.",
55
+ };
56
+ }
57
+
58
+ export function deriveEditableCopyRelPath(
59
+ relPath: string,
60
+ existingRelPaths: Iterable<string> = []
61
+ ): string {
62
+ const parsed = pathPosix.parse(relPath);
63
+ const prefix = parsed.dir ? `${parsed.dir}/` : "";
64
+ const baseName = parsed.name || "copy";
65
+ const existing = new Set(existingRelPaths);
66
+
67
+ const baseCandidate =
68
+ parsed.ext.toLowerCase() === ".md" ||
69
+ parsed.ext.toLowerCase() === ".markdown" ||
70
+ parsed.ext.toLowerCase() === ".mdx"
71
+ ? `${prefix}${baseName}.copy.md`
72
+ : `${prefix}${baseName}.md`;
73
+
74
+ if (!existing.has(baseCandidate)) {
75
+ return baseCandidate;
76
+ }
77
+
78
+ let counter = 2;
79
+ while (true) {
80
+ const candidate = `${prefix}${baseName}.copy-${counter}.md`;
81
+ if (!existing.has(candidate)) {
82
+ return candidate;
83
+ }
84
+ counter += 1;
85
+ }
86
+ }
87
+
88
+ export function buildEditableCopyContent(input: {
89
+ title: string;
90
+ sourceDocid: string;
91
+ sourceUri: string;
92
+ sourceMime: string;
93
+ sourceExt: string;
94
+ content: string;
95
+ tags?: string[];
96
+ }): string {
97
+ const frontmatterLines = [
98
+ `title: ${JSON.stringify(input.title)}`,
99
+ `gno_source_docid: ${JSON.stringify(input.sourceDocid)}`,
100
+ `gno_source_uri: ${JSON.stringify(input.sourceUri)}`,
101
+ `gno_source_mime: ${JSON.stringify(input.sourceMime)}`,
102
+ `gno_source_ext: ${JSON.stringify(input.sourceExt)}`,
103
+ ];
104
+
105
+ if (input.tags && input.tags.length > 0) {
106
+ frontmatterLines.push("tags:");
107
+ for (const tag of input.tags) {
108
+ frontmatterLines.push(` - ${JSON.stringify(tag)}`);
109
+ }
110
+ }
111
+
112
+ return `---\n${frontmatterLines.join("\n")}\n---\n\n${input.content}`;
113
+ }
@@ -11,6 +11,10 @@ import type { ToolContext } from "../server";
11
11
 
12
12
  import { parseUri } from "../../app/constants";
13
13
  import { parseRef } from "../../cli/commands/ref-parser";
14
+ import {
15
+ getDocumentCapabilities,
16
+ type DocumentCapabilities,
17
+ } from "../../core/document-capabilities";
14
18
  import { runTool, type ToolResult } from "./index";
15
19
 
16
20
  interface GetInput {
@@ -42,6 +46,7 @@ interface GetResponse {
42
46
  converterVersion?: string;
43
47
  mirrorHash?: string;
44
48
  };
49
+ capabilities: DocumentCapabilities;
45
50
  }
46
51
 
47
52
  /**
@@ -213,6 +218,11 @@ export function handleGet(
213
218
  mirrorHash: doc.mirrorHash,
214
219
  }
215
220
  : undefined,
221
+ capabilities: getDocumentCapabilities({
222
+ sourceExt: doc.sourceExt,
223
+ sourceMime: doc.sourceMime,
224
+ contentAvailable: doc.mirrorHash !== null,
225
+ }),
216
226
  };
217
227
 
218
228
  return response;