@dinoxx/dinox-cli 1.0.19 → 1.0.21

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 (88) hide show
  1. package/README.md +756 -472
  2. package/dist/auth/userInfo.d.ts +10 -1
  3. package/dist/auth/userInfo.js +66 -17
  4. package/dist/cli.js +0 -2
  5. package/dist/commands/auth/index.js +29 -14
  6. package/dist/commands/boxes/index.js +272 -52
  7. package/dist/commands/boxes/model.d.ts +34 -0
  8. package/dist/commands/boxes/model.js +78 -0
  9. package/dist/commands/boxes/operations.d.ts +86 -0
  10. package/dist/commands/boxes/operations.js +415 -0
  11. package/dist/commands/boxes/repo.d.ts +13 -6
  12. package/dist/commands/boxes/repo.js +272 -149
  13. package/dist/commands/boxes/store.d.ts +22 -0
  14. package/dist/commands/boxes/store.js +143 -0
  15. package/dist/commands/graph/index.js +2 -36
  16. package/dist/commands/mcp/noteContentTools.d.ts +222 -0
  17. package/dist/commands/mcp/noteContentTools.js +390 -0
  18. package/dist/commands/mcp/server.d.ts +3 -0
  19. package/dist/commands/mcp/server.js +45 -16
  20. package/dist/commands/mcp/tools.js +3 -1
  21. package/dist/commands/mcp/types.d.ts +6 -2
  22. package/dist/commands/meta/index.js +6 -22
  23. package/dist/commands/meta/repo.d.ts +0 -3
  24. package/dist/commands/meta/repo.js +1 -4
  25. package/dist/commands/notes/actions.d.ts +2 -0
  26. package/dist/commands/notes/actions.js +149 -55
  27. package/dist/commands/notes/bulk.d.ts +34 -0
  28. package/dist/commands/notes/bulk.js +204 -0
  29. package/dist/commands/notes/contentEngine.d.ts +95 -0
  30. package/dist/commands/notes/contentEngine.js +652 -0
  31. package/dist/commands/notes/contentPatch.d.ts +84 -0
  32. package/dist/commands/notes/contentPatch.js +337 -0
  33. package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
  34. package/dist/commands/notes/contentPatchCommand.js +126 -0
  35. package/dist/commands/notes/descriptors.js +192 -40
  36. package/dist/commands/notes/export.d.ts +21 -0
  37. package/dist/commands/notes/export.js +224 -0
  38. package/dist/commands/notes/metadataMutation.d.ts +28 -0
  39. package/dist/commands/notes/metadataMutation.js +127 -0
  40. package/dist/commands/notes/repo.d.ts +20 -0
  41. package/dist/commands/notes/repo.js +135 -18
  42. package/dist/commands/notes/runtime.d.ts +3 -12
  43. package/dist/commands/notes/runtime.js +4 -30
  44. package/dist/commands/notes/searchSql.js +1 -1
  45. package/dist/commands/prompt/index.js +15 -45
  46. package/dist/commands/prompt/repo.js +2 -2
  47. package/dist/commands/runtime.d.ts +26 -0
  48. package/dist/commands/runtime.js +76 -0
  49. package/dist/commands/schema/renderSkills.js +20 -7
  50. package/dist/commands/schema/spec.d.ts +1 -2
  51. package/dist/commands/schema/spec.js +2 -3
  52. package/dist/commands/schema/types.d.ts +0 -13
  53. package/dist/commands/storage/index.js +30 -60
  54. package/dist/commands/storage/repo.d.ts +1 -1
  55. package/dist/commands/storage/repo.js +20 -1
  56. package/dist/commands/sync.js +4 -2
  57. package/dist/commands/tags/content.d.ts +32 -0
  58. package/dist/commands/tags/content.js +243 -0
  59. package/dist/commands/tags/index.js +285 -49
  60. package/dist/commands/tags/model.d.ts +38 -0
  61. package/dist/commands/tags/model.js +100 -0
  62. package/dist/commands/tags/operations.d.ts +105 -0
  63. package/dist/commands/tags/operations.js +438 -0
  64. package/dist/commands/tags/repo.d.ts +5 -1
  65. package/dist/commands/tags/repo.js +142 -218
  66. package/dist/commands/tags/store.d.ts +28 -0
  67. package/dist/commands/tags/store.js +166 -0
  68. package/dist/commands/todo/index.js +30 -61
  69. package/dist/commands/writeDryRun.d.ts +25 -0
  70. package/dist/commands/writeDryRun.js +31 -1
  71. package/dist/config/paths.d.ts +2 -1
  72. package/dist/config/paths.js +11 -1
  73. package/dist/config/resolve.js +9 -7
  74. package/dist/daemon/index.d.ts +2 -0
  75. package/dist/daemon/index.js +176 -58
  76. package/dist/powersync/runtime.d.ts +2 -1
  77. package/dist/powersync/runtime.js +15 -3
  78. package/dist/powersync/schema/content.d.ts +2 -0
  79. package/dist/powersync/schema/content.js +2 -0
  80. package/dist/powersync/schema/index.d.ts +2 -0
  81. package/dist/skills/check.d.ts +1 -1
  82. package/dist/skills/check.js +27 -2
  83. package/dist/utils/authToken.d.ts +2 -0
  84. package/dist/utils/authToken.js +7 -2
  85. package/dist/utils/noteContentEngine.d.ts +333 -0
  86. package/dist/utils/noteContentEngine.js +740 -0
  87. package/dist/utils/tiptapMarkdownSchema.js +31 -0
  88. package/package.json +4 -8
@@ -17,11 +17,14 @@ type MCPResponse = {
17
17
  };
18
18
  export declare class MCPServer {
19
19
  private db;
20
+ private readonly noteContent;
20
21
  initialize(): Promise<void>;
21
22
  handleRequest(req: MCPRequest): Promise<MCPResponse | null>;
22
23
  private handleInitialize;
23
24
  private handleToolsList;
24
25
  private handleToolsCall;
26
+ private requireDb;
27
+ private getRuntime;
25
28
  close(): Promise<void>;
26
29
  }
27
30
  export declare function startMCPServer(port?: number, host?: string, authToken?: string): Promise<{
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { DEFAULT_MCP_PORT } from './types.js';
3
3
  import { mcpTools } from './tools.js';
4
4
  import { isAuthorizedMCPRequest } from './auth.js';
5
+ import { NOTE_CONTENT_EDIT_TOOL_NAME, NOTE_CONTENT_READ_TOOL_NAME, NoteContentMcpService, buildNoteContentToolError, } from './noteContentTools.js';
5
6
  import { resolveAuthIdentity } from '../../auth/userInfo.js';
6
7
  import { loadConfig } from '../../config/store.js';
7
8
  import { resolveConfig } from '../../config/resolve.js';
@@ -141,6 +142,7 @@ async function readJsonBody(req) {
141
142
  }
142
143
  export class MCPServer {
143
144
  db = null;
145
+ noteContent = new NoteContentMcpService(() => this.requireDb());
144
146
  async initialize() {
145
147
  const config = resolveConfig(await loadConfig());
146
148
  const { db } = await connectPowerSync({ config, offline: false, timeoutMs: config.sync.timeoutMs });
@@ -204,9 +206,7 @@ export class MCPServer {
204
206
  return buildResponse(req, { result: { tools: mcpTools } });
205
207
  }
206
208
  async handleToolsCall(req) {
207
- if (!this.db) {
208
- throw new Error('Database not initialized');
209
- }
209
+ const db = this.requireDb();
210
210
  const name = req.params?.name;
211
211
  const args = req.params?.arguments ?? {};
212
212
  if (typeof name !== 'string') {
@@ -216,8 +216,9 @@ export class MCPServer {
216
216
  case 'dinox_search_notes': {
217
217
  const keyword = typeof args.query === 'string' ? args.query : '';
218
218
  const tagsExpression = typeof args.tags === 'string' ? args.tags.trim() : '';
219
- const { names: boxNames, matchEmpty: matchEmptyBoxes } = parseBoxFilter(args.box);
220
- const zettelBoxIds = await resolveZettelBoxIdsForSearch(this.db, boxNames);
219
+ const rawBoxes = args.boxes ?? args.box;
220
+ const { names: boxNames, matchEmpty: matchEmptyBoxes } = parseBoxFilter(rawBoxes);
221
+ const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, boxNames);
221
222
  const daysInput = typeof args.days === 'number' ? String(args.days) : args.days;
222
223
  const { createdAtFrom, createdAtTo } = parseCreatedAtRange({
223
224
  from: args.from,
@@ -227,7 +228,7 @@ export class MCPServer {
227
228
  const limit = normalizeLimit(args.limit);
228
229
  const offset = normalizeOffset(args.offset);
229
230
  const fields = parseNoteSearchFields(args.fields, 'dinox_search_notes.fields');
230
- const rows = await searchNotes(this.db, keyword, {
231
+ const rows = await searchNotes(db, keyword, {
231
232
  includeDeleted: false,
232
233
  tagsExpression: tagsExpression || undefined,
233
234
  createdAtFrom,
@@ -255,7 +256,7 @@ export class MCPServer {
255
256
  if (typeof args.id !== 'string' || !args.id.trim()) {
256
257
  throw new Error('dinox_get_note: id is required');
257
258
  }
258
- const notes = await getNoteDetails(this.db, [args.id.trim()]);
259
+ const notes = await getNoteDetails(db, [args.id.trim()]);
259
260
  const text = JSON.stringify(notes[0] ?? null);
260
261
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
261
262
  }
@@ -271,12 +272,12 @@ export class MCPServer {
271
272
  const rawConfig = await loadConfig();
272
273
  const config = resolveConfig(rawConfig);
273
274
  const identity = await resolveAuthIdentity(rawConfig, config);
274
- const canonicalContentMd = await canonicalizeBidlinksInMarkdown(this.db, contentMd, identity.userId);
275
+ const canonicalContentMd = await canonicalizeBidlinksInMarkdown(db, contentMd, identity.userId);
275
276
  const { contentJson, contentText } = convertMarkdownToNoteContent(canonicalContentMd);
276
- const tags = await validateTagsForCreate(this.db, normalizeStringArray(args.tags));
277
- const zettelBoxIds = await resolveZettelBoxIdsForCreate(this.db, normalizeStringArray(args.boxes));
277
+ const tags = await validateTagsForCreate(db, normalizeStringArray(args.tags));
278
+ const zettelBoxIds = await resolveZettelBoxIdsForCreate(db, normalizeStringArray(args.boxes));
278
279
  const id = generateId();
279
- await createNote(this.db, {
280
+ await createNote(db, {
280
281
  id,
281
282
  title,
282
283
  noteType: 'crawl',
@@ -294,7 +295,7 @@ export class MCPServer {
294
295
  if (typeof args.id !== 'string' || !args.id.trim()) {
295
296
  throw new Error('dinox_graph_backlinks: id is required');
296
297
  }
297
- const result = await getBacklinks(this.db, args.id.trim());
298
+ const result = await getBacklinks(db, args.id.trim());
298
299
  const text = JSON.stringify(result);
299
300
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
300
301
  }
@@ -302,7 +303,7 @@ export class MCPServer {
302
303
  if (typeof args.id !== 'string' || !args.id.trim()) {
303
304
  throw new Error('dinox_graph_outlinks: id is required');
304
305
  }
305
- const result = await getOutlinks(this.db, args.id.trim());
306
+ const result = await getOutlinks(db, args.id.trim());
306
307
  const text = JSON.stringify(result);
307
308
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
308
309
  }
@@ -314,12 +315,12 @@ export class MCPServer {
314
315
  ? Math.trunc(args.depth)
315
316
  : 2;
316
317
  const depth = Math.min(Math.max(depthRaw, 1), 5);
317
- const result = await getRelatedNotes(this.db, args.id.trim(), depth);
318
+ const result = await getRelatedNotes(db, args.id.trim(), depth);
318
319
  const text = JSON.stringify(result);
319
320
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
320
321
  }
321
322
  case 'dinox_graph_stats': {
322
- const result = await getGraphStats(this.db);
323
+ const result = await getGraphStats(db);
323
324
  const text = JSON.stringify(result);
324
325
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
325
326
  }
@@ -329,7 +330,7 @@ export class MCPServer {
329
330
  ? args.status
330
331
  : 'all';
331
332
  const limit = normalizeLimit(args.limit);
332
- const result = await searchTodoTasks(this.db, query, {
333
+ const result = await searchTodoTasks(db, query, {
333
334
  includeDeleted: false,
334
335
  taskStatus: status || 'all',
335
336
  taskTagFilter: normalizeStringArray(args.tags),
@@ -339,10 +340,38 @@ export class MCPServer {
339
340
  const text = JSON.stringify(result);
340
341
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
341
342
  }
343
+ case NOTE_CONTENT_READ_TOOL_NAME: {
344
+ const runtime = await this.getRuntime();
345
+ const result = await this.noteContent.read(args, runtime).catch((error) => buildNoteContentToolError(error));
346
+ const text = JSON.stringify(result);
347
+ return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
348
+ }
349
+ case NOTE_CONTENT_EDIT_TOOL_NAME: {
350
+ const runtime = await this.getRuntime();
351
+ const operations = Array.isArray(args.operations) ? args.operations : [];
352
+ const result = await this.noteContent.edit({
353
+ readToken: args.readToken,
354
+ operations,
355
+ }, runtime).catch((error) => buildNoteContentToolError(error, operations));
356
+ const text = JSON.stringify(result);
357
+ return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
358
+ }
342
359
  default:
343
360
  throw new Error(`Unknown tool: ${name}`);
344
361
  }
345
362
  }
363
+ requireDb() {
364
+ if (!this.db) {
365
+ throw new Error('Database not initialized');
366
+ }
367
+ return this.db;
368
+ }
369
+ async getRuntime() {
370
+ const rawConfig = await loadConfig();
371
+ const config = resolveConfig(rawConfig);
372
+ const identity = await resolveAuthIdentity(rawConfig, config);
373
+ return { userId: identity.userId };
374
+ }
346
375
  async close() {
347
376
  if (this.db) {
348
377
  await this.db.close().catch(() => undefined);
@@ -1,4 +1,5 @@
1
1
  import { listMcpCommandSchemas } from '../schema/spec.js';
2
+ import { noteContentMcpTools } from './noteContentTools.js';
2
3
  function toMcpProperty(property) {
3
4
  if (property.type === 'string[]') {
4
5
  return {
@@ -14,7 +15,7 @@ function toMcpProperty(property) {
14
15
  ...(property.enum ? { enum: property.enum } : {}),
15
16
  };
16
17
  }
17
- export const mcpTools = listMcpCommandSchemas().map((schema) => {
18
+ const schemaBackedMcpTools = listMcpCommandSchemas().map((schema) => {
18
19
  const mcp = schema.mcp;
19
20
  const properties = Object.fromEntries(mcp.properties.map((property) => [property.name, toMcpProperty(property)]));
20
21
  const required = mcp.properties.filter((property) => property.required).map((property) => property.name);
@@ -28,3 +29,4 @@ export const mcpTools = listMcpCommandSchemas().map((schema) => {
28
29
  },
29
30
  };
30
31
  });
32
+ export const mcpTools = [...schemaBackedMcpTools, ...noteContentMcpTools];
@@ -4,12 +4,16 @@ export type MCPTool = {
4
4
  inputSchema: {
5
5
  type: 'object';
6
6
  properties: Record<string, {
7
- type: 'string' | 'number' | 'boolean' | 'array';
7
+ type: 'string' | 'number' | 'boolean' | 'array' | 'object';
8
8
  description?: string;
9
9
  enum?: string[];
10
10
  items?: {
11
- type: 'string';
11
+ type: 'string' | 'object';
12
12
  };
13
+ properties?: Record<string, {
14
+ type: 'string' | 'number' | 'boolean' | 'array' | 'object';
15
+ description?: string;
16
+ }>;
13
17
  }>;
14
18
  required?: string[];
15
19
  };
@@ -1,32 +1,16 @@
1
- import { loadConfig } from '../../config/store.js';
2
- import { resolveConfig } from '../../config/resolve.js';
3
- import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
4
1
  import { registerCommandGroup } from '../descriptor/register.js';
5
2
  import { OFFLINE_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
6
3
  import { printSuccess } from '../../utils/output.js';
7
- import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
4
+ import { commandStale, syncBeforeCommand, withCommandRuntime } from '../runtime.js';
8
5
  import { getMetaSchema, getMetaStats } from './repo.js';
9
- async function syncBeforeMetaCommand(db, offline, timeoutMs) {
10
- if (offline) {
11
- return;
12
- }
13
- await waitForIdleDataFlow(db, Math.min(timeoutMs, 10_000));
14
- }
15
6
  async function runMetaStats(context) {
16
- const config = resolveConfig(await loadConfig());
17
- const offline = Boolean(context.globals.offline);
18
- const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? config.sync.timeoutMs;
19
- const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
20
- try {
21
- await syncBeforeMetaCommand(db, offline, timeoutMs);
7
+ await withCommandRuntime(context, async (runtime) => {
8
+ await syncBeforeCommand(runtime);
22
9
  printSuccess({
23
- stale: offline ? true : stale,
24
- stats: await getMetaStats(db),
10
+ stale: commandStale(runtime),
11
+ stats: await getMetaStats(runtime.db),
25
12
  });
26
- }
27
- finally {
28
- await db.close().catch(() => undefined);
29
- }
13
+ });
30
14
  }
31
15
  async function runMetaSchema() {
32
16
  printSuccess(getMetaSchema());
@@ -24,9 +24,6 @@ export type MetaSchema = {
24
24
  command_schema: {
25
25
  fields: string[];
26
26
  };
27
- mcp_tool: {
28
- fields: string[];
29
- };
30
27
  };
31
28
  };
32
29
  export declare function getMetaStats(db: AbstractPowerSyncDatabase): Promise<MetaStats>;
@@ -67,10 +67,7 @@ export function getMetaSchema() {
67
67
  fields: ['notes_count', 'tags_count', 'boxes_count', 'latest_note_updated_at'],
68
68
  },
69
69
  command_schema: {
70
- fields: ['path', 'command', 'summary', 'risk', 'auth', 'globalOptions', 'arguments', 'options', 'output', 'examples', 'mcp'],
71
- },
72
- mcp_tool: {
73
- fields: ['name', 'description', 'inputSchema'],
70
+ fields: ['path', 'command', 'summary', 'risk', 'auth', 'globalOptions', 'arguments', 'options', 'output', 'examples'],
74
71
  },
75
72
  },
76
73
  };
@@ -6,5 +6,7 @@ export declare function runNoteDetail({ command, options, args }: CommandExecuti
6
6
  export declare function runNoteCreate({ command, options }: CommandExecutionContext): Promise<void>;
7
7
  export declare function runNoteDelete({ command, options, args }: CommandExecutionContext): Promise<void>;
8
8
  export declare function runNoteUpdate({ command, options, args }: CommandExecutionContext): Promise<void>;
9
+ export declare function runNoteTag({ command, options, args }: CommandExecutionContext): Promise<void>;
10
+ export declare function runNoteMove({ command, options, args }: CommandExecutionContext): Promise<void>;
9
11
  export declare function runNoteStar({ command, options, args }: CommandExecutionContext): Promise<void>;
10
12
  export declare function runNoteUnstar({ command, options, args }: CommandExecutionContext): Promise<void>;
@@ -5,9 +5,10 @@ import { generateId } from '../../utils/id.js';
5
5
  import { convertMarkdownToNoteContent } from '../../utils/tiptapMarkdown.js';
6
6
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
7
7
  import { DinoxError } from '../../utils/errors.js';
8
- import { waitForIdleDataFlow } from '../../powersync/runtime.js';
8
+ import { commandStale, printStaleCacheWarning, printUploadDisabledWarning, waitForMutationUpload, } from '../runtime.js';
9
9
  import { createNote, getNote, getNoteContext, getNoteDetails, getNotePreview, assertNotesUpdatable, resolveZettelBoxIdsForCreate, resolveZettelBoxIdsForSearch, resolveZettelBoxIdsForUpdate, searchNotes, softDeleteNote, updateNote, validateTagsForCreate, validateTagsForUpdate, } from './repo.js';
10
10
  import { buildNoteCreateDryRunPreview, buildNoteDeleteDryRunPreview, buildNoteUpdateDryRunPreview, } from './dryRun.js';
11
+ import { applyNoteMetadataMutation, planNoteMetadataMutation, } from './metadataMutation.js';
11
12
  import { parseNoteSearchFields, projectNoteSearchFields } from './searchOutput.js';
12
13
  import { parseCreatedAtRange } from './searchTime.js';
13
14
  import { buildNoteSearchResponse, normalizeDaysForMeta } from './searchResponse.js';
@@ -39,13 +40,11 @@ function toPublicNote(note) {
39
40
  ...(zettel_boxes !== undefined ? { boxes: zettel_boxes } : {}),
40
41
  };
41
42
  }
42
- async function settleMutation(db, timeoutMs) {
43
- await waitForIdleDataFlow(db, Math.min(timeoutMs, 5_000));
44
- }
45
43
  async function executeNoteUpdateOperation(args) {
46
44
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
47
- await withNoteDatabase(args.command, async ({ db, config, offline, timeoutMs, stale }) => {
48
- await syncBeforeNoteCommand(db, offline, timeoutMs);
45
+ await withNoteDatabase(args.command, async (runtime) => {
46
+ const { db, config, offline } = runtime;
47
+ await syncBeforeNoteCommand(runtime);
49
48
  const ids = await parseTargetNoteIds(args.id, args.idsOption, 'update');
50
49
  const hasTags = typeof args.tagsOption === 'string';
51
50
  const hasBoxes = typeof args.boxOption === 'string';
@@ -85,12 +84,8 @@ async function executeNoteUpdateOperation(args) {
85
84
  }));
86
85
  }
87
86
  const updatedCount = results.filter((item) => item.updated).length;
88
- if (!offline && !config.powersync.uploadBaseUrl) {
89
- printHumanReadableLine('warning: upload disabled (powersync.uploadBaseUrl is unset); changes are local-only for now');
90
- }
91
- if (!offline) {
92
- await settleMutation(db, timeoutMs);
93
- }
87
+ printUploadDisabledWarning(runtime);
88
+ await waitForMutationUpload(runtime);
94
89
  const payload = {
95
90
  ok: true,
96
91
  total: results.length,
@@ -102,7 +97,7 @@ async function executeNoteUpdateOperation(args) {
102
97
  boxes: item.zettelBoxIds,
103
98
  is_starred: item.is_starred,
104
99
  })),
105
- stale: offline ? true : stale,
100
+ stale: commandStale(runtime),
106
101
  };
107
102
  if (structuredOutput) {
108
103
  printSuccess(payload);
@@ -125,15 +120,101 @@ async function executeNoteUpdateOperation(args) {
125
120
  else {
126
121
  printHumanReadableLine(`Updated ${updatedCount}/${results.length} notes`);
127
122
  }
128
- if (stale && !offline) {
129
- printHumanReadableLine('warning: sync timed out; local cache may be stale');
123
+ printStaleCacheWarning(runtime);
124
+ });
125
+ }
126
+ function selectMetadataOperation(options) {
127
+ const candidates = [
128
+ ['--add', options.add],
129
+ ['--remove', options.remove],
130
+ ['--replace', options.replace],
131
+ ].filter((entry) => typeof entry[1] === 'string');
132
+ if (candidates.length !== 1) {
133
+ throw new DinoxError('Provide exactly one of --add, --remove, or --replace');
134
+ }
135
+ const [optionName, rawValues] = candidates[0];
136
+ return {
137
+ operation: optionName === '--add' ? 'add' : optionName === '--remove' ? 'remove' : 'replace',
138
+ rawValues,
139
+ optionName,
140
+ };
141
+ }
142
+ async function executeNoteMetadataListOperation(args) {
143
+ const structuredOutput = getRequestedStructuredOutputFormat() !== null;
144
+ await withNoteDatabase(args.command, async (runtime) => {
145
+ const { db, config, offline } = runtime;
146
+ await syncBeforeNoteCommand(runtime);
147
+ const ids = await parseTargetNoteIds(args.id, args.idsOption, 'update');
148
+ const selected = selectMetadataOperation(args.options);
149
+ const requestedValues = await parseStringListInput(selected.rawValues, selected.optionName);
150
+ const values = args.field === 'tags'
151
+ ? await validateTagsForUpdate(db, requestedValues)
152
+ : await resolveZettelBoxIdsForUpdate(db, requestedValues);
153
+ const plan = await planNoteMetadataMutation(db, {
154
+ ids,
155
+ field: args.field,
156
+ operation: selected.operation,
157
+ values,
158
+ displayValues: requestedValues,
159
+ });
160
+ if (args.options.dryRun) {
161
+ printDryRunPreview({
162
+ dry_run: true,
163
+ command: args.commandName,
164
+ action: args.actionName,
165
+ risk: 'write',
166
+ summary: `Preview ${args.field} ${selected.operation} for ${plan.total} note(s); ${plan.changedCount} note(s) would change.`,
167
+ warnings: [
168
+ ...(offline ? ['Execution would run in offline mode and only use the local cache.'] : []),
169
+ ...(!offline && !config.powersync.uploadBaseUrl
170
+ ? ['Execution would be local-only because powersync.uploadBaseUrl is unset.']
171
+ : []),
172
+ ],
173
+ inputs: {
174
+ ids,
175
+ operation: selected.operation,
176
+ [args.field]: args.field === 'tags' ? values : requestedValues,
177
+ },
178
+ targets: plan.results.map((item) => ({ id: item.id, title: item.title })),
179
+ changes: plan.results.map((item) => ({
180
+ id: item.id,
181
+ title: item.title,
182
+ [`${args.field}_from`]: item.before,
183
+ [`${args.field}_to`]: item.after,
184
+ changed: item.changed,
185
+ })),
186
+ });
187
+ return;
188
+ }
189
+ await applyNoteMetadataMutation(db, plan);
190
+ printUploadDisabledWarning(runtime);
191
+ await waitForMutationUpload(runtime);
192
+ const payload = {
193
+ ok: true,
194
+ field: args.field,
195
+ operation: selected.operation,
196
+ total: plan.total,
197
+ updatedCount: plan.changedCount,
198
+ results: plan.results.map((item) => ({
199
+ id: item.id,
200
+ updated: item.changed,
201
+ [args.field]: item.after,
202
+ })),
203
+ stale: commandStale(runtime),
204
+ };
205
+ if (structuredOutput) {
206
+ printSuccess(payload);
207
+ return;
130
208
  }
209
+ printHumanReadableLine(`Updated ${plan.changedCount}/${plan.total} notes`);
210
+ printStaleCacheWarning(runtime);
131
211
  });
132
212
  }
133
213
  export async function runNoteSearch({ command, options, args }) {
134
214
  const searchOptions = options;
135
- await withNoteDatabase(command, async ({ db, offline, timeoutMs, stale }) => {
136
- await syncBeforeNoteCommand(db, offline, timeoutMs);
215
+ await withNoteDatabase(command, async (runtime) => {
216
+ const { db } = runtime;
217
+ await syncBeforeNoteCommand(runtime);
137
218
  const keyword = args.query?.trim() ?? '';
138
219
  const tagsExpression = typeof searchOptions.tags === 'string' ? searchOptions.tags.trim() : '';
139
220
  const { createdAtFrom, createdAtTo } = parseCreatedAtRange({
@@ -167,7 +248,7 @@ export async function runNoteSearch({ command, options, args }) {
167
248
  query: keyword,
168
249
  notes: projectNoteSearchFields(rows.slice(offset, offset + limit), fields),
169
250
  totalCount: rows.length,
170
- stale: offline ? true : stale,
251
+ stale: commandStale(runtime),
171
252
  includeDeleted: Boolean(searchOptions.includeDeleted),
172
253
  limit,
173
254
  offset,
@@ -191,8 +272,9 @@ export async function runNoteGet({ command, options, args }) {
191
272
  throw new DinoxError('Note id is required');
192
273
  }
193
274
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
194
- await withNoteDatabase(command, async ({ db, offline, timeoutMs, stale }) => {
195
- await syncBeforeNoteCommand(db, offline, timeoutMs);
275
+ await withNoteDatabase(command, async (runtime) => {
276
+ const { db } = runtime;
277
+ await syncBeforeNoteCommand(runtime);
196
278
  if (getOptions.contextOnly) {
197
279
  const context = await getNoteContext(db, id);
198
280
  if (!context) {
@@ -202,7 +284,7 @@ export async function runNoteGet({ command, options, args }) {
202
284
  recoverable: true,
203
285
  });
204
286
  }
205
- printSuccess({ stale: offline ? true : stale, note: toPublicNoteContext(context) });
287
+ printSuccess({ stale: commandStale(runtime), note: toPublicNoteContext(context) });
206
288
  return;
207
289
  }
208
290
  const note = await getNote(db, id);
@@ -214,12 +296,10 @@ export async function runNoteGet({ command, options, args }) {
214
296
  });
215
297
  }
216
298
  if (structuredOutput) {
217
- printSuccess({ stale, note: toPublicNote(note) });
299
+ printSuccess({ stale: commandStale(runtime), note: toPublicNote(note) });
218
300
  return;
219
301
  }
220
- if (stale && !offline) {
221
- printHumanReadableLine('warning: sync timed out; result may be stale');
222
- }
302
+ printStaleCacheWarning(runtime, 'warning: sync timed out; result may be stale');
223
303
  printHumanReadableLine(`${note.id}`);
224
304
  printHumanReadableLine(`title: ${note.title ?? ''}`);
225
305
  if (note.updated_at) {
@@ -239,8 +319,9 @@ export async function runNotePreview({ command, options, args }) {
239
319
  if (!id) {
240
320
  throw new DinoxError('Note id is required');
241
321
  }
242
- await withNoteDatabase(command, async ({ db, offline, timeoutMs, stale }) => {
243
- await syncBeforeNoteCommand(db, offline, timeoutMs);
322
+ await withNoteDatabase(command, async (runtime) => {
323
+ const { db } = runtime;
324
+ await syncBeforeNoteCommand(runtime);
244
325
  const preview = await getNotePreview(db, id, parsePreviewLineCount(previewOptions.lines));
245
326
  if (!preview) {
246
327
  throw new DinoxError(`Note not found: ${id}`, {
@@ -249,13 +330,14 @@ export async function runNotePreview({ command, options, args }) {
249
330
  recoverable: true,
250
331
  });
251
332
  }
252
- printSuccess({ stale: offline ? true : stale, note: preview });
333
+ printSuccess({ stale: commandStale(runtime), note: preview });
253
334
  });
254
335
  }
255
336
  export async function runNoteDetail({ command, options, args }) {
256
337
  const detailOptions = options;
257
- await withNoteDatabase(command, async ({ db, offline, timeoutMs }) => {
258
- await syncBeforeNoteCommand(db, offline, timeoutMs);
338
+ await withNoteDatabase(command, async (runtime) => {
339
+ const { db } = runtime;
340
+ await syncBeforeNoteCommand(runtime);
259
341
  const ids = await parseTargetNoteIds(args.id, detailOptions.ids, 'detail');
260
342
  const notes = await getNoteDetails(db, ids);
261
343
  const foundIds = new Set(notes.map((note) => note.id));
@@ -270,9 +352,10 @@ export async function runNoteDetail({ command, options, args }) {
270
352
  export async function runNoteCreate({ command, options }) {
271
353
  const createOptions = options;
272
354
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
273
- await withNoteDatabase(command, async ({ db, rawConfig, config, offline, timeoutMs, stale }) => {
274
- await syncBeforeNoteCommand(db, offline, timeoutMs);
275
- const identity = await resolveAuthIdentity(rawConfig, config);
355
+ await withNoteDatabase(command, async (runtime) => {
356
+ const { db, config, offline } = runtime;
357
+ await syncBeforeNoteCommand(runtime);
358
+ const identity = await resolveAuthIdentity(config);
276
359
  const contentMd = await parseStringInput(String(createOptions.content));
277
360
  const canonicalContentMd = await canonicalizeBidlinksInMarkdown(db, contentMd, identity.userId);
278
361
  const { contentJson, contentText } = convertMarkdownToNoteContent(canonicalContentMd);
@@ -309,21 +392,15 @@ export async function runNoteCreate({ command, options }) {
309
392
  zettelBoxIds: await resolveZettelBoxIdsForCreate(db, boxNames),
310
393
  userId: identity.userId,
311
394
  });
312
- if (!offline && !config.powersync.uploadBaseUrl) {
313
- printHumanReadableLine('warning: upload disabled (powersync.uploadBaseUrl is unset); changes are local-only for now');
314
- }
315
- if (!offline) {
316
- await settleMutation(db, timeoutMs);
317
- }
318
- const payload = { ok: true, id, stale: offline ? true : stale };
395
+ printUploadDisabledWarning(runtime);
396
+ await waitForMutationUpload(runtime);
397
+ const payload = { ok: true, id, stale: commandStale(runtime) };
319
398
  if (structuredOutput) {
320
399
  printSuccess(payload);
321
400
  return;
322
401
  }
323
402
  printHumanReadableLine(id);
324
- if (stale && !offline) {
325
- printHumanReadableLine('warning: sync timed out; local cache may be stale');
326
- }
403
+ printStaleCacheWarning(runtime);
327
404
  });
328
405
  }
329
406
  export async function runNoteDelete({ command, options, args }) {
@@ -333,28 +410,23 @@ export async function runNoteDelete({ command, options, args }) {
333
410
  throw new DinoxError('Note id is required');
334
411
  }
335
412
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
336
- await withNoteDatabase(command, async ({ db, config, offline, timeoutMs, stale }) => {
337
- await syncBeforeNoteCommand(db, offline, timeoutMs);
413
+ await withNoteDatabase(command, async (runtime) => {
414
+ const { db, config, offline } = runtime;
415
+ await syncBeforeNoteCommand(runtime);
338
416
  if (Boolean(deleteOptions.dryRun)) {
339
417
  printDryRunPreview(await buildNoteDeleteDryRunPreview({ db, config, offline, id }));
340
418
  return;
341
419
  }
342
420
  await softDeleteNote(db, id);
343
- if (!offline && !config.powersync.uploadBaseUrl) {
344
- printHumanReadableLine('warning: upload disabled (powersync.uploadBaseUrl is unset); changes are local-only for now');
345
- }
346
- if (!offline) {
347
- await settleMutation(db, timeoutMs);
348
- }
349
- const payload = { ok: true, id, stale: offline ? true : stale };
421
+ printUploadDisabledWarning(runtime);
422
+ await waitForMutationUpload(runtime);
423
+ const payload = { ok: true, id, stale: commandStale(runtime) };
350
424
  if (structuredOutput) {
351
425
  printSuccess(payload);
352
426
  return;
353
427
  }
354
428
  printHumanReadableLine('OK');
355
- if (stale && !offline) {
356
- printHumanReadableLine('warning: sync timed out; local cache may be stale');
357
- }
429
+ printStaleCacheWarning(runtime);
358
430
  });
359
431
  }
360
432
  export async function runNoteUpdate({ command, options, args }) {
@@ -373,6 +445,28 @@ export async function runNoteUpdate({ command, options, args }) {
373
445
  actionName: 'update_note',
374
446
  });
375
447
  }
448
+ export async function runNoteTag({ command, options, args }) {
449
+ await executeNoteMetadataListOperation({
450
+ command,
451
+ id: args.id,
452
+ idsOption: options.ids,
453
+ options: options,
454
+ field: 'tags',
455
+ commandName: 'dino note tag',
456
+ actionName: 'mutate_note_tags',
457
+ });
458
+ }
459
+ export async function runNoteMove({ command, options, args }) {
460
+ await executeNoteMetadataListOperation({
461
+ command,
462
+ id: args.id,
463
+ idsOption: options.ids,
464
+ options: options,
465
+ field: 'boxes',
466
+ commandName: 'dino note move',
467
+ actionName: 'mutate_note_boxes',
468
+ });
469
+ }
376
470
  export async function runNoteStar({ command, options, args }) {
377
471
  const starOptions = options;
378
472
  await executeNoteUpdateOperation({
@@ -0,0 +1,34 @@
1
+ import type { CommandExecutionContext } from '../descriptor/types.js';
2
+ import type { NoteMetadataListField, NoteMetadataOperation } from './metadataMutation.js';
3
+ type NoteBulkOptions = {
4
+ tags?: string;
5
+ from?: string;
6
+ to?: string;
7
+ days?: string;
8
+ starred?: string;
9
+ boxes?: string;
10
+ all?: boolean;
11
+ tagAdd?: string;
12
+ tagRemove?: string;
13
+ tagReplace?: string;
14
+ boxAdd?: string;
15
+ boxRemove?: string;
16
+ boxReplace?: string;
17
+ expectedCount?: string;
18
+ confirm?: boolean;
19
+ dryRun?: boolean;
20
+ };
21
+ type NoteBulkMutationSelection = {
22
+ field: NoteMetadataListField;
23
+ operation: NoteMetadataOperation;
24
+ rawValues: string;
25
+ optionName: '--tag-add' | '--tag-remove' | '--tag-replace' | '--box-add' | '--box-remove' | '--box-replace';
26
+ };
27
+ export declare function selectBulkMutation(options: NoteBulkOptions): NoteBulkMutationSelection;
28
+ export declare function parseExpectedCount(value: unknown): number | null;
29
+ export declare function assertBulkTargetFilter(input: {
30
+ query: string;
31
+ options: NoteBulkOptions;
32
+ }): void;
33
+ export declare function runNoteBulk({ command, options, args }: CommandExecutionContext): Promise<void>;
34
+ export {};