@gmickel/gno 0.8.6 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.8.6",
3
+ "version": "0.9.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",
@@ -54,6 +54,7 @@
54
54
  "website:dev": "cd website && make serve",
55
55
  "website:build": "cd website && make build",
56
56
  "website:demos": "cd website/demos && ./build-demos.sh",
57
+ "sync:agents": "scripts/sync-agents.sh",
57
58
  "build:css": "bunx @tailwindcss/cli -i src/serve/public/globals.css -o src/serve/public/globals.built.css --minify",
58
59
  "serve": "bun src/index.ts serve",
59
60
  "serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
@@ -0,0 +1,96 @@
1
+ # CLI Commands
2
+
3
+ GNO command-line interface using Commander.js.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ src/cli/
9
+ ├── program.ts # Main Commander program with all commands
10
+ ├── run.ts # Entry point, error handling
11
+ ├── context.ts # CliContext with adapters
12
+ ├── options.ts # Shared option definitions
13
+ ├── errors.ts # Error types and exit codes
14
+ ├── colors.ts # Terminal color utilities
15
+ ├── progress.ts # Progress indicators
16
+ ├── ui.ts # User interaction helpers
17
+ ├── format/ # Output formatters (json, csv, md, xml)
18
+ └── commands/ # Command implementations
19
+ ├── search.ts
20
+ ├── query.ts
21
+ ├── ask.ts
22
+ └── ...
23
+ ```
24
+
25
+ ## Specification
26
+
27
+ See `spec/cli.md` for full CLI specification including:
28
+
29
+ - Exit codes (0=success, 1=validation, 2=runtime)
30
+ - Global flags (--index, --json, --verbose, etc.)
31
+ - Output format support matrix
32
+ - All command schemas
33
+
34
+ **Always update spec/cli.md first** when adding/modifying commands.
35
+
36
+ ## Command Pattern
37
+
38
+ Commands follow this structure in `program.ts`:
39
+
40
+ ```typescript
41
+ program
42
+ .command("search <query>")
43
+ .description("BM25 keyword search")
44
+ .option("-l, --limit <n>", "Max results", "10")
45
+ .option("-c, --collection <name>", "Filter by collection")
46
+ .addOption(formatOption) // From options.ts
47
+ .action(async (query, opts) => {
48
+ await runSearch(query, opts);
49
+ });
50
+ ```
51
+
52
+ ## Exit Codes
53
+
54
+ ```typescript
55
+ export const EXIT = {
56
+ SUCCESS: 0, // Command completed successfully
57
+ VALIDATION: 1, // Bad args, missing params
58
+ RUNTIME: 2, // IO, DB, model, network errors
59
+ } as const;
60
+ ```
61
+
62
+ ## Output Formats
63
+
64
+ All search/query commands support multiple formats:
65
+
66
+ - `--json` - Machine-readable JSON
67
+ - `--files` - Line protocol for piping
68
+ - `--csv` - Spreadsheet compatible
69
+ - `--md` - Markdown tables
70
+ - `--xml` - XML format
71
+
72
+ Format handlers in `src/cli/format/`.
73
+
74
+ ## CliContext
75
+
76
+ Created at command execution, holds adapters:
77
+
78
+ ```typescript
79
+ interface CliContext {
80
+ store: SqliteAdapter;
81
+ config: Config;
82
+ embedPort?: EmbeddingPort;
83
+ genPort?: GenerationPort;
84
+ rerankPort?: RerankPort;
85
+ }
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ CLI tests in `test/cli/`:
91
+
92
+ ```bash
93
+ bun test test/cli/
94
+ ```
95
+
96
+ Use `--json` output for assertions in tests.
@@ -0,0 +1,96 @@
1
+ # CLI Commands
2
+
3
+ GNO command-line interface using Commander.js.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ src/cli/
9
+ ├── program.ts # Main Commander program with all commands
10
+ ├── run.ts # Entry point, error handling
11
+ ├── context.ts # CliContext with adapters
12
+ ├── options.ts # Shared option definitions
13
+ ├── errors.ts # Error types and exit codes
14
+ ├── colors.ts # Terminal color utilities
15
+ ├── progress.ts # Progress indicators
16
+ ├── ui.ts # User interaction helpers
17
+ ├── format/ # Output formatters (json, csv, md, xml)
18
+ └── commands/ # Command implementations
19
+ ├── search.ts
20
+ ├── query.ts
21
+ ├── ask.ts
22
+ └── ...
23
+ ```
24
+
25
+ ## Specification
26
+
27
+ See `spec/cli.md` for full CLI specification including:
28
+
29
+ - Exit codes (0=success, 1=validation, 2=runtime)
30
+ - Global flags (--index, --json, --verbose, etc.)
31
+ - Output format support matrix
32
+ - All command schemas
33
+
34
+ **Always update spec/cli.md first** when adding/modifying commands.
35
+
36
+ ## Command Pattern
37
+
38
+ Commands follow this structure in `program.ts`:
39
+
40
+ ```typescript
41
+ program
42
+ .command("search <query>")
43
+ .description("BM25 keyword search")
44
+ .option("-l, --limit <n>", "Max results", "10")
45
+ .option("-c, --collection <name>", "Filter by collection")
46
+ .addOption(formatOption) // From options.ts
47
+ .action(async (query, opts) => {
48
+ await runSearch(query, opts);
49
+ });
50
+ ```
51
+
52
+ ## Exit Codes
53
+
54
+ ```typescript
55
+ export const EXIT = {
56
+ SUCCESS: 0, // Command completed successfully
57
+ VALIDATION: 1, // Bad args, missing params
58
+ RUNTIME: 2, // IO, DB, model, network errors
59
+ } as const;
60
+ ```
61
+
62
+ ## Output Formats
63
+
64
+ All search/query commands support multiple formats:
65
+
66
+ - `--json` - Machine-readable JSON
67
+ - `--files` - Line protocol for piping
68
+ - `--csv` - Spreadsheet compatible
69
+ - `--md` - Markdown tables
70
+ - `--xml` - XML format
71
+
72
+ Format handlers in `src/cli/format/`.
73
+
74
+ ## CliContext
75
+
76
+ Created at command execution, holds adapters:
77
+
78
+ ```typescript
79
+ interface CliContext {
80
+ store: SqliteAdapter;
81
+ config: Config;
82
+ embedPort?: EmbeddingPort;
83
+ genPort?: GenerationPort;
84
+ rerankPort?: RerankPort;
85
+ }
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ CLI tests in `test/cli/`:
91
+
92
+ ```bash
93
+ bun test test/cli/
94
+ ```
95
+
96
+ Use `--json` output for assertions in tests.
@@ -35,6 +35,7 @@ export interface InstallOptions {
35
35
  scope?: McpScope;
36
36
  force?: boolean;
37
37
  dryRun?: boolean;
38
+ enableWrite?: boolean;
38
39
  /** Override cwd (testing) */
39
40
  cwd?: string;
40
41
  /** Override home dir (testing) */
@@ -147,6 +148,7 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
147
148
  const scope = opts.scope ?? "user";
148
149
  const force = opts.force ?? false;
149
150
  const dryRun = opts.dryRun ?? false;
151
+ const enableWrite = opts.enableWrite ?? false;
150
152
  const globals = safeGetGlobals();
151
153
  const json = opts.json ?? globals.json;
152
154
  const quiet = opts.quiet ?? globals.quiet;
@@ -160,7 +162,7 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
160
162
  }
161
163
 
162
164
  // Build server entry (uses process.execPath, always succeeds)
163
- const serverEntry = buildMcpServerEntry();
165
+ const serverEntry = buildMcpServerEntry({ enableWrite });
164
166
 
165
167
  // Install
166
168
  const result = await installToTarget(target, scope, serverEntry, {
@@ -398,7 +398,11 @@ export function findBunPath(): string {
398
398
  * Uses absolute paths because Claude Desktop has a limited PATH.
399
399
  * Cross-platform: avoids shelling out to `which`.
400
400
  */
401
- export function buildMcpServerEntry(): McpServerEntry {
401
+ export function buildMcpServerEntry(
402
+ options: {
403
+ enableWrite?: boolean;
404
+ } = {}
405
+ ): McpServerEntry {
402
406
  const bunPath = findBunPath();
403
407
  const home = homedir();
404
408
  const isWindows = platform() === "win32";
@@ -411,7 +415,11 @@ export function buildMcpServerEntry(): McpServerEntry {
411
415
  ) {
412
416
  // Dev mode: run the entry script directly with bun
413
417
  const entryScript = scriptPath.replace(COMMANDS_PATH_PATTERN, "/index.ts");
414
- return { command: bunPath, args: ["run", entryScript, "mcp"] };
418
+ const args = ["run", entryScript, "mcp"];
419
+ if (options.enableWrite) {
420
+ args.push("--enable-write");
421
+ }
422
+ return { command: bunPath, args };
415
423
  }
416
424
 
417
425
  // 2. Check common gno install locations (cross-platform)
@@ -428,13 +436,21 @@ export function buildMcpServerEntry(): McpServerEntry {
428
436
 
429
437
  for (const gnoPath of gnoCandidates) {
430
438
  if (existsSync(gnoPath)) {
431
- return { command: bunPath, args: [gnoPath, "mcp"] };
439
+ const args = [gnoPath, "mcp"];
440
+ if (options.enableWrite) {
441
+ args.push("--enable-write");
442
+ }
443
+ return { command: bunPath, args };
432
444
  }
433
445
  }
434
446
 
435
447
  // 3. Fallback to bunx (works if gno is published to npm)
436
448
  // Note: This may trigger network access on first run
437
- return { command: bunPath, args: ["x", "@gmickel/gno", "mcp"] };
449
+ const args = ["x", "@gmickel/gno", "mcp"];
450
+ if (options.enableWrite) {
451
+ args.push("--enable-write");
452
+ }
453
+ return { command: bunPath, args };
438
454
  }
439
455
 
440
456
  /**
@@ -10,11 +10,15 @@ import type { GlobalOptions } from "../context";
10
10
  * Start the MCP server.
11
11
  * Reads global options for --index and --config flags.
12
12
  */
13
- export async function mcpCommand(options: GlobalOptions): Promise<void> {
13
+ export async function mcpCommand(
14
+ options: GlobalOptions,
15
+ commandOptions: { enableWrite?: boolean } = {}
16
+ ): Promise<void> {
14
17
  const { startMcpServer } = await import("../../mcp/server.js");
15
18
  await startMcpServer({
16
19
  indexName: options.index,
17
20
  configPath: options.config,
18
21
  verbose: options.verbose,
22
+ enableWrite: commandOptions.enableWrite,
19
23
  });
20
24
  }
@@ -766,11 +766,17 @@ function wireMcpCommand(program: Command): void {
766
766
  .command("serve", { isDefault: true })
767
767
  .description("Start MCP server (stdio transport)")
768
768
  .helpOption(false)
769
- .action(async () => {
769
+ .option(
770
+ "--enable-write",
771
+ "Enable write operations (capture, add-collection, sync, remove-collection)"
772
+ )
773
+ .action(async (cmdOpts: Record<string, unknown>) => {
770
774
  const { mcpCommand } = await import("./commands/mcp.js");
771
775
  const globalOpts = program.opts();
772
776
  const globals = parseGlobalOptions(globalOpts);
773
- await mcpCommand(globals);
777
+ await mcpCommand(globals, {
778
+ enableWrite: cmdOpts.enableWrite === true ? true : undefined,
779
+ });
774
780
  });
775
781
 
776
782
  // install - Install gno MCP server to client configs
@@ -789,6 +795,10 @@ function wireMcpCommand(program: Command): void {
789
795
  )
790
796
  .option("-f, --force", "overwrite existing configuration")
791
797
  .option("--dry-run", "show what would be done without making changes")
798
+ .option(
799
+ "--enable-write",
800
+ "Enable write operations in installed MCP configuration"
801
+ )
792
802
  .option("--json", "JSON output")
793
803
  .action(async (cmdOpts: Record<string, unknown>) => {
794
804
  const target = cmdOpts.target as string;
@@ -820,6 +830,7 @@ function wireMcpCommand(program: Command): void {
820
830
  scope: scope as "user" | "project",
821
831
  force: Boolean(cmdOpts.force),
822
832
  dryRun: Boolean(cmdOpts.dryRun),
833
+ enableWrite: Boolean(cmdOpts.enableWrite),
823
834
  // Pass undefined if not set, so global --json can take effect
824
835
  json: cmdOpts.json === true ? true : undefined,
825
836
  });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Config mutation helper shared by Web UI and MCP.
3
+ *
4
+ * @module src/core/config-mutation
5
+ */
6
+
7
+ import type { Config } from "../config/types";
8
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
9
+
10
+ import { loadConfig, saveConfig } from "../config";
11
+
12
+ export interface ConfigMutationContext {
13
+ store: SqliteAdapter;
14
+ configPath?: string;
15
+ onConfigUpdated: (config: Config) => void;
16
+ }
17
+
18
+ export type MutationResult<T = void> =
19
+ | { ok: true; config: Config; value?: T }
20
+ | { ok: false; error: string; code: string };
21
+
22
+ export type ApplyConfigResult<T = void> =
23
+ | { ok: true; config: Config; value?: T }
24
+ | { ok: false; error: string; code: string };
25
+
26
+ /**
27
+ * In-memory mutex for serializing config mutations.
28
+ * Prevents lost updates when multiple requests try to modify config concurrently.
29
+ */
30
+ let configMutex: Promise<void> = Promise.resolve();
31
+
32
+ export async function applyConfigChange<T = void>(
33
+ ctx: ConfigMutationContext,
34
+ mutate: (config: Config) => Promise<MutationResult<T>> | MutationResult<T>
35
+ ): Promise<ApplyConfigResult<T>> {
36
+ const previousMutex = configMutex;
37
+ let resolveMutex: () => void = () => {
38
+ /* no-op until assigned */
39
+ };
40
+
41
+ configMutex = new Promise((resolve) => {
42
+ resolveMutex = resolve;
43
+ });
44
+
45
+ try {
46
+ await previousMutex;
47
+
48
+ const loadResult = await loadConfig(ctx.configPath);
49
+ if (!loadResult.ok) {
50
+ return {
51
+ ok: false,
52
+ error: loadResult.error.message,
53
+ code: "LOAD_ERROR",
54
+ };
55
+ }
56
+
57
+ const mutationResult = await mutate(loadResult.value);
58
+ if (!mutationResult.ok) {
59
+ return {
60
+ ok: false,
61
+ error: mutationResult.error,
62
+ code: mutationResult.code,
63
+ };
64
+ }
65
+
66
+ const newConfig = mutationResult.config;
67
+ const saveResult = await saveConfig(newConfig, ctx.configPath);
68
+ if (!saveResult.ok) {
69
+ return {
70
+ ok: false,
71
+ error: saveResult.error.message,
72
+ code: "SAVE_ERROR",
73
+ };
74
+ }
75
+
76
+ const syncCollResult = await ctx.store.syncCollections(
77
+ newConfig.collections
78
+ );
79
+ if (!syncCollResult.ok) {
80
+ console.warn(
81
+ `Config saved but DB sync failed: ${syncCollResult.error.message}`
82
+ );
83
+ return {
84
+ ok: false,
85
+ error: `DB sync failed: ${syncCollResult.error.message}`,
86
+ code: "SYNC_ERROR",
87
+ };
88
+ }
89
+
90
+ const syncCtxResult = await ctx.store.syncContexts(
91
+ newConfig.contexts ?? []
92
+ );
93
+ if (!syncCtxResult.ok) {
94
+ console.warn(
95
+ `Config saved but context sync failed: ${syncCtxResult.error.message}`
96
+ );
97
+ return {
98
+ ok: false,
99
+ error: `Context sync failed: ${syncCtxResult.error.message}`,
100
+ code: "SYNC_ERROR",
101
+ };
102
+ }
103
+
104
+ ctx.onConfigUpdated(newConfig);
105
+
106
+ return { ok: true, config: newConfig, value: mutationResult.value };
107
+ } finally {
108
+ resolveMutex();
109
+ }
110
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Standardized error codes/messages for MCP write operations.
3
+ *
4
+ * @module src/core/errors
5
+ */
6
+
7
+ export const MCP_ERRORS = {
8
+ LOCKED: {
9
+ code: "LOCKED",
10
+ message: "Another GNO write operation is running. Try again later.",
11
+ },
12
+ JOB_CONFLICT: {
13
+ code: "JOB_CONFLICT",
14
+ message: "Another job is already running.",
15
+ },
16
+ INVALID_PATH: {
17
+ code: "INVALID_PATH",
18
+ message: "Path violates safety rules.",
19
+ },
20
+ PATH_NOT_FOUND: {
21
+ code: "PATH_NOT_FOUND",
22
+ message: "Path not found.",
23
+ },
24
+ DUPLICATE: {
25
+ code: "DUPLICATE",
26
+ message: "Resource already exists.",
27
+ },
28
+ NOT_FOUND: {
29
+ code: "NOT_FOUND",
30
+ message: "Resource not found.",
31
+ },
32
+ CONFLICT: {
33
+ code: "CONFLICT",
34
+ message: "Conflict with existing resource.",
35
+ },
36
+ HAS_REFERENCES: {
37
+ code: "HAS_REFERENCES",
38
+ message: "Resource has references.",
39
+ },
40
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * OS-backed advisory file locking for MCP write operations.
3
+ *
4
+ * @module src/core/file-lock
5
+ */
6
+
7
+ // node:fs/promises for mkdir (no Bun equivalent for recursive dir creation)
8
+ import { mkdir } from "node:fs/promises";
9
+ // node:path for dirname (no Bun path utils)
10
+ import { dirname } from "node:path";
11
+
12
+ import { MCP_ERRORS } from "./errors";
13
+ const DEFAULT_TIMEOUT_MS = 5000;
14
+ const HOLD_SECONDS = 60 * 60 * 24 * 365;
15
+ const READY_TOKEN = "READY";
16
+
17
+ export interface WriteLockHandle {
18
+ release: () => Promise<void>;
19
+ }
20
+
21
+ interface LockCommand {
22
+ path: string;
23
+ args: (
24
+ lockPath: string,
25
+ timeoutSeconds: number,
26
+ holdCommand: string
27
+ ) => string[];
28
+ }
29
+
30
+ function resolveLockCommand(): LockCommand | null {
31
+ const lockfPath = Bun.which("lockf");
32
+ if (lockfPath) {
33
+ return {
34
+ path: lockfPath,
35
+ args: (lockPath, timeoutSeconds, holdCommand) => [
36
+ "-t",
37
+ String(timeoutSeconds),
38
+ lockPath,
39
+ "sh",
40
+ "-c",
41
+ holdCommand,
42
+ ],
43
+ };
44
+ }
45
+
46
+ const flockPath = Bun.which("flock");
47
+ if (flockPath) {
48
+ return {
49
+ path: flockPath,
50
+ args: (lockPath, timeoutSeconds, holdCommand) => [
51
+ "-w",
52
+ String(timeoutSeconds),
53
+ lockPath,
54
+ "sh",
55
+ "-c",
56
+ holdCommand,
57
+ ],
58
+ };
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ function buildHoldCommand(): string {
65
+ return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
66
+ }
67
+
68
+ async function waitForReady(
69
+ proc: ReturnType<typeof Bun.spawn>
70
+ ): Promise<boolean> {
71
+ if (!proc.stdout || typeof proc.stdout === "number") {
72
+ return false;
73
+ }
74
+
75
+ const reader = proc.stdout.getReader();
76
+ try {
77
+ const result = await Promise.race([
78
+ reader.read(),
79
+ proc.exited.then(() => null),
80
+ ]);
81
+
82
+ if (!result || result.done || !result.value) {
83
+ return false;
84
+ }
85
+
86
+ const text = new TextDecoder().decode(result.value);
87
+ return text.includes(READY_TOKEN);
88
+ } finally {
89
+ await reader.cancel().catch(() => undefined);
90
+ }
91
+ }
92
+
93
+ export async function acquireWriteLock(
94
+ lockPath: string,
95
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
96
+ ): Promise<WriteLockHandle | null> {
97
+ const cmd = resolveLockCommand();
98
+ if (!cmd) {
99
+ throw new Error("No lockf/flock available for write locking");
100
+ }
101
+
102
+ await mkdir(dirname(lockPath), { recursive: true });
103
+
104
+ const timeoutSeconds = Math.max(0, Math.ceil(timeoutMs / 1000));
105
+ const holdCommand = buildHoldCommand();
106
+ const proc = Bun.spawn(
107
+ [cmd.path, ...cmd.args(lockPath, timeoutSeconds, holdCommand)],
108
+ {
109
+ stdout: "pipe",
110
+ stderr: "pipe",
111
+ }
112
+ );
113
+
114
+ const ready = await waitForReady(proc);
115
+ if (!ready) {
116
+ proc.kill();
117
+ await proc.exited.catch(() => undefined);
118
+ return null;
119
+ }
120
+
121
+ return {
122
+ release: async () => {
123
+ proc.kill();
124
+ await proc.exited.catch(() => undefined);
125
+ },
126
+ };
127
+ }
128
+
129
+ export async function withWriteLock<T>(
130
+ lockPath: string,
131
+ fn: () => Promise<T>,
132
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
133
+ ): Promise<T> {
134
+ const lock = await acquireWriteLock(lockPath, timeoutMs);
135
+ if (!lock) {
136
+ throw new Error(`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`);
137
+ }
138
+
139
+ try {
140
+ return await fn();
141
+ } finally {
142
+ await lock.release();
143
+ }
144
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared file operations.
3
+ *
4
+ * @module src/core/file-ops
5
+ */
6
+
7
+ // node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
8
+ import { rename, unlink } from "node:fs/promises";
9
+
10
+ export async function atomicWrite(
11
+ path: string,
12
+ content: string
13
+ ): Promise<void> {
14
+ const tempPath = `${path}.tmp.${crypto.randomUUID()}`;
15
+ await Bun.write(tempPath, content);
16
+ try {
17
+ await rename(tempPath, path);
18
+ } catch (e) {
19
+ await unlink(tempPath).catch(() => {
20
+ /* ignore cleanup errors */
21
+ });
22
+ throw e;
23
+ }
24
+ }