@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
@@ -0,0 +1,243 @@
1
+ import { canonicalizeTagPath, isTagPathWithinPrefix, renameTagPathWithPrefix, } from './model.js';
2
+ function dedupeTags(values) {
3
+ const result = [];
4
+ const seen = new Set();
5
+ for (const value of values) {
6
+ const tag = canonicalizeTagPath(value);
7
+ const key = tag.toLowerCase();
8
+ if (!tag || seen.has(key)) {
9
+ continue;
10
+ }
11
+ seen.add(key);
12
+ result.push(tag);
13
+ }
14
+ return result;
15
+ }
16
+ function sameArray(left, right) {
17
+ if (left.length !== right.length) {
18
+ return false;
19
+ }
20
+ return left.every((value, index) => value === right[index]);
21
+ }
22
+ function rewriteTagList(tags, oldPrefix, newPrefix, preserveSuffix) {
23
+ return dedupeTags(tags.map((tag) => (isTagPathWithinPrefix(tag, oldPrefix)
24
+ ? (preserveSuffix ? renameTagPathWithPrefix(tag, oldPrefix, newPrefix) : canonicalizeTagPath(newPrefix))
25
+ : tag)));
26
+ }
27
+ function pathKey(value) {
28
+ return canonicalizeTagPath(value).toLowerCase();
29
+ }
30
+ function getPathId(path, lookup) {
31
+ return lookup?.idByPath.get(pathKey(path)) ?? canonicalizeTagPath(path);
32
+ }
33
+ function parseJson(raw) {
34
+ if (!raw) {
35
+ return null;
36
+ }
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ return parsed && typeof parsed === 'object' ? parsed : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function extractHashtagPath(attrs, lookup) {
46
+ const label = typeof attrs?.label === 'string' ? canonicalizeTagPath(attrs.label) : '';
47
+ if (label) {
48
+ return label;
49
+ }
50
+ const id = typeof attrs?.id === 'string' ? attrs.id.trim() : '';
51
+ if (!id) {
52
+ return '';
53
+ }
54
+ const mapped = lookup?.pathById.get(id);
55
+ return mapped ? canonicalizeTagPath(mapped) : canonicalizeTagPath(id);
56
+ }
57
+ function rewriteHashtagsInJson(raw, oldPrefix, newPrefix, preserveSuffix, lookup) {
58
+ const parsed = parseJson(raw);
59
+ if (!parsed) {
60
+ return { changed: false, contentJson: raw, plainText: null };
61
+ }
62
+ const walk = (node) => {
63
+ let changed = false;
64
+ let current = node;
65
+ if (current.type === 'hashtag') {
66
+ const attrs = { ...(current.attrs ?? {}) };
67
+ const path = extractHashtagPath(attrs, lookup);
68
+ if (path && isTagPathWithinPrefix(path, oldPrefix)) {
69
+ const nextPath = preserveSuffix
70
+ ? renameTagPathWithPrefix(path, oldPrefix, newPrefix)
71
+ : canonicalizeTagPath(newPrefix);
72
+ const nextId = getPathId(nextPath, lookup);
73
+ if (attrs.label !== nextPath || attrs.id !== nextId) {
74
+ current = {
75
+ ...current,
76
+ attrs: {
77
+ ...attrs,
78
+ id: nextId,
79
+ label: nextPath,
80
+ },
81
+ };
82
+ changed = true;
83
+ }
84
+ }
85
+ }
86
+ if (Array.isArray(current.content)) {
87
+ const nextContent = [];
88
+ let childChanged = false;
89
+ for (const child of current.content) {
90
+ const result = walk(child);
91
+ nextContent.push(result.node);
92
+ childChanged ||= result.changed;
93
+ }
94
+ if (childChanged) {
95
+ current = { ...current, content: nextContent };
96
+ changed = true;
97
+ }
98
+ }
99
+ return { changed, node: current };
100
+ };
101
+ const result = walk(parsed);
102
+ if (!result.changed) {
103
+ return { changed: false, contentJson: raw, plainText: null };
104
+ }
105
+ return {
106
+ changed: true,
107
+ contentJson: JSON.stringify(result.node),
108
+ plainText: collectPlainText(result.node, lookup),
109
+ };
110
+ }
111
+ function collectPlainText(node, lookup) {
112
+ if (!node) {
113
+ return null;
114
+ }
115
+ const chunks = [];
116
+ const visit = (current) => {
117
+ if (current.type === 'hashtag') {
118
+ const path = extractHashtagPath(current.attrs, lookup);
119
+ if (path) {
120
+ chunks.push(`#${path}`);
121
+ }
122
+ return;
123
+ }
124
+ if (typeof current.text === 'string') {
125
+ chunks.push(current.text);
126
+ }
127
+ if (Array.isArray(current.content)) {
128
+ for (const child of current.content) {
129
+ visit(child);
130
+ }
131
+ chunks.push('\n');
132
+ }
133
+ };
134
+ visit(node);
135
+ const text = chunks.join('').trim();
136
+ return text || null;
137
+ }
138
+ function isSegmentChar(charCode) {
139
+ return ((charCode >= 0x30 && charCode <= 0x39) ||
140
+ (charCode >= 0x41 && charCode <= 0x5a) ||
141
+ (charCode >= 0x61 && charCode <= 0x7a) ||
142
+ charCode === 0x5f ||
143
+ charCode === 0x2d ||
144
+ (charCode >= 0x4e00 && charCode <= 0x9fff));
145
+ }
146
+ function isBoundaryBlockingChar(charCode) {
147
+ return ((charCode >= 0x30 && charCode <= 0x39) ||
148
+ (charCode >= 0x41 && charCode <= 0x5a) ||
149
+ (charCode >= 0x61 && charCode <= 0x7a) ||
150
+ charCode === 0x5f ||
151
+ (charCode >= 0x4e00 && charCode <= 0x9fff));
152
+ }
153
+ function parseHashtag(source, hashIndex) {
154
+ if (hashIndex > 0 && isBoundaryBlockingChar(source.charCodeAt(hashIndex - 1))) {
155
+ return null;
156
+ }
157
+ let index = hashIndex + 1;
158
+ let sawChar = false;
159
+ let segmentHasChar = false;
160
+ while (index < source.length) {
161
+ const code = source.charCodeAt(index);
162
+ if (isSegmentChar(code)) {
163
+ sawChar = true;
164
+ segmentHasChar = true;
165
+ index += 1;
166
+ continue;
167
+ }
168
+ if (code === 0x2f) {
169
+ const nextCode = source.charCodeAt(index + 1);
170
+ if (!segmentHasChar || !isSegmentChar(nextCode)) {
171
+ return null;
172
+ }
173
+ segmentHasChar = false;
174
+ index += 1;
175
+ continue;
176
+ }
177
+ break;
178
+ }
179
+ if (!sawChar || !segmentHasChar) {
180
+ return null;
181
+ }
182
+ return {
183
+ raw: source.slice(hashIndex, index),
184
+ label: source.slice(hashIndex + 1, index),
185
+ to: index,
186
+ };
187
+ }
188
+ function rewriteHashtagsInMarkdown(raw, oldPrefix, newPrefix, preserveSuffix) {
189
+ if (!raw) {
190
+ return { changed: false, contentMd: raw };
191
+ }
192
+ let output = '';
193
+ let changed = false;
194
+ for (let index = 0; index < raw.length;) {
195
+ if (raw.charCodeAt(index) !== 0x23) {
196
+ output += raw[index];
197
+ index += 1;
198
+ continue;
199
+ }
200
+ const match = parseHashtag(raw, index);
201
+ if (!match) {
202
+ output += raw[index];
203
+ index += 1;
204
+ continue;
205
+ }
206
+ const path = canonicalizeTagPath(match.label);
207
+ if (path && isTagPathWithinPrefix(path, oldPrefix)) {
208
+ const nextPath = preserveSuffix
209
+ ? renameTagPathWithPrefix(path, oldPrefix, newPrefix)
210
+ : canonicalizeTagPath(newPrefix);
211
+ output += `#${nextPath}`;
212
+ changed = true;
213
+ }
214
+ else {
215
+ output += match.raw;
216
+ }
217
+ index = match.to;
218
+ }
219
+ return { changed, contentMd: changed ? output : raw };
220
+ }
221
+ export function renameTagReferencesInNote(args) {
222
+ return rewriteTagReferencesInNote({ ...args, preserveSuffix: true });
223
+ }
224
+ export function mergeTagReferencesInNote(args) {
225
+ return rewriteTagReferencesInNote({ ...args, preserveSuffix: false });
226
+ }
227
+ function rewriteTagReferencesInNote(args) {
228
+ const nextTags = rewriteTagList(args.tags, args.oldPrefix, args.newPrefix, args.preserveSuffix);
229
+ const json = rewriteHashtagsInJson(args.contentJson, args.oldPrefix, args.newPrefix, args.preserveSuffix, args.lookup);
230
+ const markdown = rewriteHashtagsInMarkdown(args.contentMd, args.oldPrefix, args.newPrefix, args.preserveSuffix);
231
+ const tagsChanged = !sameArray(nextTags, args.tags);
232
+ const nextContentText = json.changed ? json.plainText : args.contentText;
233
+ return {
234
+ tagsChanged,
235
+ contentJsonChanged: json.changed,
236
+ contentMdChanged: markdown.changed,
237
+ contentTextChanged: json.changed,
238
+ tags: nextTags,
239
+ contentJson: json.contentJson,
240
+ contentMd: markdown.contentMd,
241
+ contentText: nextContentText,
242
+ };
243
+ }
@@ -1,49 +1,25 @@
1
1
  import { resolveAuthIdentity } from '../../auth/userInfo.js';
2
- import { loadConfig } from '../../config/store.js';
3
- import { resolveConfig } from '../../config/resolve.js';
4
- import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
5
- import { syncNoteTokenIndex } from '../../powersync/tokenIndex.js';
6
2
  import { registerCommandGroup } from '../descriptor/register.js';
7
3
  import { FORMAT_OPTIONS, OFFLINE_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
8
- import { buildTagAddDryRunPreview } from '../writeDryRun.js';
4
+ import { buildTagAddDryRunPreview, buildTagMutationDryRunPreview } from '../writeDryRun.js';
9
5
  import { printDryRunPreview } from '../../utils/dryRun.js';
10
6
  import { DinoxError } from '../../utils/errors.js';
11
7
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
12
- import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
8
+ import { commandStale, printStaleCacheWarning, printUploadDisabledWarning, syncBeforeCommand, waitForMutationUpload, withCommandRuntime, } from '../runtime.js';
13
9
  import { addTag, inspectAddTag, listTags } from './repo.js';
10
+ import { getTagStats, getTagTree, inspectTagCleanup, mergeTag, moveTag, renameTag, suggestTagMerges, } from './operations.js';
14
11
  const TAG_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
15
- async function syncBeforeTagCommand(db, offline, timeoutMs) {
16
- if (offline) {
17
- return;
18
- }
19
- await waitForIdleDataFlow(db, Math.min(timeoutMs, 10_000));
20
- await syncNoteTokenIndex(db);
21
- }
22
- async function withTagDb(context, run) {
23
- const rawConfig = await loadConfig();
24
- const config = resolveConfig(rawConfig);
25
- const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? config.sync.timeoutMs;
26
- const offline = Boolean(context.globals.offline);
27
- const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
28
- try {
29
- await run({ db, config, rawConfig, offline, timeoutMs, stale });
30
- }
31
- finally {
32
- await db.close().catch(() => undefined);
33
- }
34
- }
35
12
  async function runTagList(context) {
36
13
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
37
- await withTagDb(context, async ({ db, offline, timeoutMs, stale }) => {
38
- await syncBeforeTagCommand(db, offline, timeoutMs);
39
- const tags = await listTags(db);
14
+ await withCommandRuntime(context, async (runtime) => {
15
+ const identity = await resolveAuthIdentity(runtime.config);
16
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
17
+ const tags = await listTags(runtime.db, { userId: identity.userId });
40
18
  if (structuredOutput) {
41
- printSuccess({ stale, count: tags.length, tags });
19
+ printSuccess({ stale: commandStale(runtime), count: tags.length, tags });
42
20
  return;
43
21
  }
44
- if (stale && !offline) {
45
- printHumanReadableLine('warning: sync timed out; results may be stale');
46
- }
22
+ printStaleCacheWarning(runtime, 'warning: sync timed out; results may be stale');
47
23
  if (tags.length === 0) {
48
24
  printHumanReadableLine('No tags.');
49
25
  return;
@@ -53,6 +29,20 @@ async function runTagList(context) {
53
29
  }
54
30
  });
55
31
  }
32
+ async function runTagTree(context) {
33
+ await withCommandRuntime(context, async (runtime) => {
34
+ const identity = await resolveAuthIdentity(runtime.config);
35
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
36
+ printSuccess({ stale: commandStale(runtime), tree: await getTagTree(runtime.db, { userId: identity.userId }) });
37
+ });
38
+ }
39
+ async function runTagStats(context) {
40
+ await withCommandRuntime(context, async (runtime) => {
41
+ const identity = await resolveAuthIdentity(runtime.config);
42
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
43
+ printSuccess({ stale: commandStale(runtime), ...(await getTagStats(runtime.db, { userId: identity.userId })) });
44
+ });
45
+ }
56
46
  async function runTagAdd(context) {
57
47
  const nameArg = context.args.name;
58
48
  const optionName = typeof context.options.name === 'string' ? context.options.name : undefined;
@@ -64,15 +54,15 @@ async function runTagAdd(context) {
64
54
  throw new DinoxError('Conflicting tag name values from positional argument and --name option.');
65
55
  }
66
56
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
67
- await withTagDb(context, async ({ db, config, rawConfig, offline, timeoutMs, stale }) => {
68
- const identity = await resolveAuthIdentity(rawConfig, config);
69
- await syncBeforeTagCommand(db, offline, timeoutMs);
57
+ await withCommandRuntime(context, async (runtime) => {
58
+ const identity = await resolveAuthIdentity(runtime.config);
59
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
70
60
  const emoji = typeof context.options.emoji === 'string' ? context.options.emoji : undefined;
71
61
  if (Boolean(context.options.dryRun)) {
72
62
  printDryRunPreview(buildTagAddDryRunPreview({
73
- config,
74
- offline,
75
- ...(await inspectAddTag(db, {
63
+ config: runtime.config,
64
+ offline: runtime.offline,
65
+ ...(await inspectAddTag(runtime.db, {
76
66
  name,
77
67
  userId: identity.userId,
78
68
  emoji,
@@ -80,26 +70,137 @@ async function runTagAdd(context) {
80
70
  }));
81
71
  return;
82
72
  }
83
- const result = await addTag(db, {
73
+ const result = await addTag(runtime.db, {
84
74
  name,
85
75
  userId: identity.userId,
86
76
  emoji,
87
77
  });
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 waitForIdleDataFlow(db, Math.min(timeoutMs, 5_000));
93
- }
94
- const payload = { ok: true, stale: offline ? true : stale, ...result };
78
+ printUploadDisabledWarning(runtime);
79
+ await waitForMutationUpload(runtime);
80
+ const payload = { ok: true, stale: commandStale(runtime), ...result };
95
81
  if (structuredOutput) {
96
82
  printSuccess(payload);
97
83
  return;
98
84
  }
99
85
  printHumanReadableLine(result.id);
100
- if (stale && !offline) {
101
- printHumanReadableLine('warning: sync timed out; results may be stale');
86
+ printStaleCacheWarning(runtime, 'warning: sync timed out; results may be stale');
87
+ });
88
+ }
89
+ async function withTagWriteRuntime(context, run) {
90
+ return withCommandRuntime(context, async (runtime) => {
91
+ const identity = await resolveAuthIdentity(runtime.config);
92
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
93
+ return run({ runtime, userId: identity.userId });
94
+ });
95
+ }
96
+ function requireArg(value, message) {
97
+ const trimmed = value?.trim() ?? '';
98
+ if (!trimmed) {
99
+ throw new DinoxError(message);
100
+ }
101
+ return trimmed;
102
+ }
103
+ async function runTagRename(context) {
104
+ const path = requireArg(context.args.path, 'Tag path is required');
105
+ const newName = requireArg(context.args.newName, 'New tag name is required');
106
+ await withTagWriteRuntime(context, async ({ runtime, userId }) => {
107
+ const result = await renameTag(runtime.db, { path, newName, userId }, { dryRun: Boolean(context.options.dryRun) });
108
+ if (Boolean(context.options.dryRun)) {
109
+ printDryRunPreview(buildTagMutationDryRunPreview({
110
+ config: runtime.config,
111
+ offline: runtime.offline,
112
+ command: 'dino tag rename',
113
+ action: 'rename_tag',
114
+ risk: 'write',
115
+ summary: `Rename tag "${result.fromPath}" to "${result.toPath}".`,
116
+ inputs: { path, new_name: newName },
117
+ changes: { updated_tags: result.updatedTags, updated_notes: result.updatedNotes },
118
+ }));
119
+ return;
120
+ }
121
+ printUploadDisabledWarning(runtime);
122
+ await waitForMutationUpload(runtime);
123
+ printSuccess({ ok: true, stale: commandStale(runtime), ...result });
124
+ });
125
+ }
126
+ async function runTagMove(context) {
127
+ const path = requireArg(context.args.path, 'Tag path is required');
128
+ const to = typeof context.options.to === 'string' ? context.options.to.trim() : '';
129
+ if (!to) {
130
+ throw new DinoxError('--to is required');
131
+ }
132
+ await withTagWriteRuntime(context, async ({ runtime, userId }) => {
133
+ const result = await moveTag(runtime.db, { path, to, userId }, { dryRun: Boolean(context.options.dryRun) });
134
+ if (Boolean(context.options.dryRun)) {
135
+ printDryRunPreview(buildTagMutationDryRunPreview({
136
+ config: runtime.config,
137
+ offline: runtime.offline,
138
+ command: 'dino tag move',
139
+ action: 'move_tag',
140
+ risk: 'write',
141
+ summary: `Move tag "${result.fromPath}" to "${result.toPath}".`,
142
+ inputs: { path, to },
143
+ changes: { updated_tags: result.updatedTags, updated_notes: result.updatedNotes },
144
+ }));
145
+ return;
102
146
  }
147
+ printUploadDisabledWarning(runtime);
148
+ await waitForMutationUpload(runtime);
149
+ printSuccess({ ok: true, stale: commandStale(runtime), ...result });
150
+ });
151
+ }
152
+ async function runTagMerge(context) {
153
+ const from = requireArg(context.args.from, 'Source tag path is required');
154
+ const to = requireArg(context.args.to, 'Target tag path is required');
155
+ await withTagWriteRuntime(context, async ({ runtime, userId }) => {
156
+ const result = await mergeTag(runtime.db, { from, to, userId }, { dryRun: Boolean(context.options.dryRun) });
157
+ if (Boolean(context.options.dryRun)) {
158
+ printDryRunPreview(buildTagMutationDryRunPreview({
159
+ config: runtime.config,
160
+ offline: runtime.offline,
161
+ command: 'dino tag merge',
162
+ action: 'merge_tag',
163
+ risk: 'high-risk-write',
164
+ summary: `Merge tag subtree "${result.fromPath}" into "${result.toPath}".`,
165
+ inputs: { from, to },
166
+ changes: {
167
+ soft_deleted_tag_ids: result.softDeletedTagIds,
168
+ updated_notes: result.updatedNotes,
169
+ },
170
+ }));
171
+ return;
172
+ }
173
+ printUploadDisabledWarning(runtime);
174
+ await waitForMutationUpload(runtime);
175
+ printSuccess({ ok: true, stale: commandStale(runtime), ...result });
176
+ });
177
+ }
178
+ async function runTagSuggest(context) {
179
+ await withCommandRuntime(context, async (runtime) => {
180
+ const identity = await resolveAuthIdentity(runtime.config);
181
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
182
+ printSuccess({ stale: commandStale(runtime), ...(await suggestTagMerges(runtime.db, { userId: identity.userId })) });
183
+ });
184
+ }
185
+ async function runTagCleanup(context) {
186
+ await withCommandRuntime(context, async (runtime) => {
187
+ const identity = await resolveAuthIdentity(runtime.config);
188
+ await syncBeforeCommand(runtime, { syncTokenIndex: true });
189
+ const result = await inspectTagCleanup(runtime.db, { userId: identity.userId });
190
+ if (Boolean(context.options.dryRun)) {
191
+ printDryRunPreview(buildTagMutationDryRunPreview({
192
+ config: runtime.config,
193
+ offline: runtime.offline,
194
+ command: 'dino tag cleanup',
195
+ action: 'cleanup_tags',
196
+ risk: 'write',
197
+ summary: `Inspect tag cleanup candidates (${result.issues.length} issues).`,
198
+ inputs: {},
199
+ targets: result.issues,
200
+ }));
201
+ return;
202
+ }
203
+ printSuccess({ stale: commandStale(runtime), ...result });
103
204
  });
104
205
  }
105
206
  export const tagCommandGroup = {
@@ -123,6 +224,40 @@ export const tagCommandGroup = {
123
224
  },
124
225
  action: runTagList,
125
226
  },
227
+ {
228
+ name: 'tree',
229
+ description: 'Show tags as a hierarchy tree from c_tag_node',
230
+ schema: {
231
+ path: 'tag.tree',
232
+ risk: 'read',
233
+ auth: 'required',
234
+ globalOptions: TAG_GLOBAL_OPTIONS,
235
+ output: {
236
+ type: 'structured',
237
+ summary: 'Nested tag tree payload',
238
+ fields: ['tree.id', 'tree.tag', 'tree.name', 'tree.parentId', 'tree.isActual', 'tree.children', 'stale'],
239
+ },
240
+ examples: ['dino tag tree --format json'],
241
+ },
242
+ action: runTagTree,
243
+ },
244
+ {
245
+ name: 'stats',
246
+ description: 'Show tag usage counts and unused-tag status',
247
+ schema: {
248
+ path: 'tag.stats',
249
+ risk: 'read',
250
+ auth: 'required',
251
+ globalOptions: TAG_GLOBAL_OPTIONS,
252
+ output: {
253
+ type: 'structured',
254
+ summary: 'Tag statistics payload',
255
+ fields: ['totalTags', 'actualTags', 'unusedTags', 'tags.id', 'tags.tag', 'tags.directNoteCount', 'tags.descendantNoteCount', 'tags.latestNoteUpdatedAt', 'tags.isUnused', 'stale'],
256
+ },
257
+ examples: ['dino tag stats --format json'],
258
+ },
259
+ action: runTagStats,
260
+ },
126
261
  {
127
262
  name: 'add',
128
263
  description: 'Create a tag path, restoring deleted nodes when possible',
@@ -146,6 +281,107 @@ export const tagCommandGroup = {
146
281
  },
147
282
  action: runTagAdd,
148
283
  },
284
+ {
285
+ name: 'rename',
286
+ description: 'Rename a tag and cascade descendant paths and note references',
287
+ arguments: [
288
+ { syntax: '<path>', description: 'Existing tag path' },
289
+ { syntax: '<new-name>', description: 'New leaf name' },
290
+ ],
291
+ options: [{ flags: '--dry-run', description: 'Preview the write without executing it' }],
292
+ schema: {
293
+ path: 'tag.rename',
294
+ risk: 'write',
295
+ auth: 'required',
296
+ globalOptions: TAG_GLOBAL_OPTIONS,
297
+ output: {
298
+ type: 'structured',
299
+ summary: 'Tag rename result',
300
+ fields: ['id', 'fromPath', 'toPath', 'updatedTags', 'updatedNotes', 'stale'],
301
+ },
302
+ examples: ['dino tag rename "AI/old" "new" --format json --dry-run'],
303
+ },
304
+ action: runTagRename,
305
+ },
306
+ {
307
+ name: 'move',
308
+ description: 'Move a tag under another parent and cascade descendant paths and note references',
309
+ arguments: [{ syntax: '<path>', description: 'Existing tag path' }],
310
+ options: [
311
+ { flags: '--to <parent-path>', description: 'Target parent tag path', required: true, type: 'string' },
312
+ { flags: '--dry-run', description: 'Preview the write without executing it' },
313
+ ],
314
+ schema: {
315
+ path: 'tag.move',
316
+ risk: 'write',
317
+ auth: 'required',
318
+ globalOptions: TAG_GLOBAL_OPTIONS,
319
+ output: {
320
+ type: 'structured',
321
+ summary: 'Tag move result',
322
+ fields: ['id', 'fromPath', 'toPath', 'updatedTags', 'updatedNotes', 'stale'],
323
+ },
324
+ examples: ['dino tag move "AI" --to "Technology" --format json --dry-run'],
325
+ },
326
+ action: runTagMove,
327
+ },
328
+ {
329
+ name: 'merge',
330
+ description: 'Merge a tag subtree into another tag and soft-delete the source subtree',
331
+ arguments: [
332
+ { syntax: '<from>', description: 'Source tag path' },
333
+ { syntax: '<to>', description: 'Target tag path' },
334
+ ],
335
+ options: [{ flags: '--dry-run', description: 'Preview the write without executing it' }],
336
+ schema: {
337
+ path: 'tag.merge',
338
+ risk: 'high-risk-write',
339
+ auth: 'required',
340
+ globalOptions: TAG_GLOBAL_OPTIONS,
341
+ output: {
342
+ type: 'structured',
343
+ summary: 'Tag merge result',
344
+ fields: ['fromId', 'toId', 'fromPath', 'toPath', 'softDeletedTagIds', 'updatedNotes', 'stale'],
345
+ },
346
+ examples: ['dino tag merge "ai" "AI" --format json --dry-run'],
347
+ },
348
+ action: runTagMerge,
349
+ },
350
+ {
351
+ name: 'suggest',
352
+ description: 'Suggest likely duplicate tags to merge',
353
+ schema: {
354
+ path: 'tag.suggest',
355
+ risk: 'read',
356
+ auth: 'required',
357
+ globalOptions: TAG_GLOBAL_OPTIONS,
358
+ output: {
359
+ type: 'structured',
360
+ summary: 'Tag merge suggestions payload',
361
+ fields: ['suggestions.canonical', 'suggestions.variants', 'suggestions.noteCount', 'suggestions.reason', 'stale'],
362
+ },
363
+ examples: ['dino tag suggest --format json'],
364
+ },
365
+ action: runTagSuggest,
366
+ },
367
+ {
368
+ name: 'cleanup',
369
+ description: 'Inspect tag cleanup candidates',
370
+ options: [{ flags: '--dry-run', description: 'Preview cleanup candidates without writing' }],
371
+ schema: {
372
+ path: 'tag.cleanup',
373
+ risk: 'read',
374
+ auth: 'required',
375
+ globalOptions: TAG_GLOBAL_OPTIONS,
376
+ output: {
377
+ type: 'structured',
378
+ summary: 'Tag cleanup inspection payload',
379
+ fields: ['issues', 'nonActualNodes', 'unusedTags', 'duplicatePaths', 'orphanParents', 'deletedWithActiveChildren', 'softDeletedActual', 'stale'],
380
+ },
381
+ examples: ['dino tag cleanup --format json --dry-run'],
382
+ },
383
+ action: runTagCleanup,
384
+ },
149
385
  ],
150
386
  };
151
387
  export function registerTagsCommand(program) {
@@ -0,0 +1,38 @@
1
+ export declare const MAX_TAG_SEGMENT_LENGTH = 500;
2
+ export declare const MAX_TAG_PATH_LENGTH = 2000;
3
+ export declare const MAX_TAG_EMOJI_LENGTH = 32;
4
+ export declare const REQUIRED_TAG_NODE_COLUMNS: string[];
5
+ export declare const REQUIRED_NOTE_TAG_COLUMNS: string[];
6
+ export type TableInfoRow = {
7
+ name: string | null;
8
+ };
9
+ export type MutableTagNode = {
10
+ id: string;
11
+ fullPath: string;
12
+ pathKey: string;
13
+ parentId: string | null;
14
+ depth: number | null;
15
+ isActual: number | null;
16
+ isDel: number | null;
17
+ userId: string;
18
+ emoji: string;
19
+ };
20
+ export type TagListItem = {
21
+ id: string;
22
+ tag: string;
23
+ name: string;
24
+ parentId: string | null;
25
+ depth: number | null;
26
+ emoji: string;
27
+ };
28
+ export declare function trimString(value: string | null | undefined): string;
29
+ export declare function canonicalizeTagPath(value: string): string;
30
+ export declare function normalizeTagLookupKey(value: string): string;
31
+ export declare function getLeafName(path: string): string;
32
+ export declare function parentPathOf(path: string): string | null;
33
+ export declare function isActiveTagNode(node: MutableTagNode): boolean;
34
+ export declare function isActualTagNode(node: MutableTagNode): boolean;
35
+ export declare function isTagPathWithinPrefix(path: string, prefix: string): boolean;
36
+ export declare function renameTagPathWithPrefix(path: string, oldPrefix: string, newPrefix: string): string;
37
+ export declare function assertMaxLength(label: string, value: string, maxLength: number): void;
38
+ export declare function parseStoredTags(raw: string | null): string[];