@descix/cli 1.0.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 (94) hide show
  1. package/README.md +95 -0
  2. package/SERVICE_README_sdk.md +399 -0
  3. package/agent-assets/guides/README.md +61 -0
  4. package/agent-assets/guides/dynamic-site-pattern.md +125 -0
  5. package/agent-assets/guides/knowledge-only-pattern.md +93 -0
  6. package/agent-assets/guides/microservice-pattern.md +152 -0
  7. package/agent-assets/guides/static-site-pattern.md +111 -0
  8. package/agent-assets/instructions/app-asset-validation.md +477 -0
  9. package/agent-assets/instructions/onboarding-flow.md +408 -0
  10. package/agent-assets/instructions/standard-folder-structure.md +125 -0
  11. package/agent-assets/reference/app-creation.md +381 -0
  12. package/agent-assets/reference/architecture.md +406 -0
  13. package/agent-assets/reference/local-dev.md +138 -0
  14. package/agent-assets/reference/mcp-integration.md +424 -0
  15. package/agent-assets/reference/workspace-config.md +415 -0
  16. package/agent-assets/rules/descix_mcp.template.mdc +243 -0
  17. package/bin/descix.js +3930 -0
  18. package/bin/mcp-server.js +382 -0
  19. package/descix-cli-1.0.0.tgz +0 -0
  20. package/lib/agent-files.js +162 -0
  21. package/lib/api-client.js +458 -0
  22. package/lib/auth-guard.js +67 -0
  23. package/lib/commands/app-wizard.js +627 -0
  24. package/lib/commands/auth.js +407 -0
  25. package/lib/commands/buy.js +207 -0
  26. package/lib/commands/clone.js +91 -0
  27. package/lib/commands/config.js +155 -0
  28. package/lib/commands/doctor.js +277 -0
  29. package/lib/commands/folder.js +291 -0
  30. package/lib/commands/init.js +257 -0
  31. package/lib/commands/kb.js +654 -0
  32. package/lib/commands/mcp.js +273 -0
  33. package/lib/commands/serve.js +12 -0
  34. package/lib/commands/status.js +239 -0
  35. package/lib/commands/update.js +653 -0
  36. package/lib/core/Chunker.js +610 -0
  37. package/lib/core/Hydrator.js +801 -0
  38. package/lib/core/PathContext.js +335 -0
  39. package/lib/core/Syncer.js +310 -0
  40. package/lib/core/index.js +32 -0
  41. package/lib/global-config.js +86 -0
  42. package/lib/google-storage-adc.js +511 -0
  43. package/lib/wallet-file.js +232 -0
  44. package/lib/wizard/setup.js +502 -0
  45. package/lib/workspace-config.js +712 -0
  46. package/lib/workspace-utils.js +126 -0
  47. package/package.json +41 -0
  48. package/templates/agent-claude.md +226 -0
  49. package/templates/agent-cline.md +133 -0
  50. package/templates/agent-copilot.md +133 -0
  51. package/templates/agent-cursor.md +133 -0
  52. package/templates/descix_mcp.mdc +122 -0
  53. package/templates/drive/README.md +42 -0
  54. package/templates/drive/app/assets/app_description.md +13 -0
  55. package/templates/drive/app/assets/icon.png +0 -0
  56. package/templates/drive/app/assets/system_instructions.md +17 -0
  57. package/templates/drive/app/kb/General/README.md +25 -0
  58. package/templates/drive/community/Apps/.gitkeep +4 -0
  59. package/templates/drive/community/community_assets/community_description.md +15 -0
  60. package/templates/drive/community/community_assets/icon.png +0 -0
  61. package/templates/drive_templates/README.md +104 -0
  62. package/templates/drive_templates/agent_app_template/assets/app_description.txt +13 -0
  63. package/templates/drive_templates/agent_app_template/assets/system_instructions.txt +17 -0
  64. package/templates/drive_templates/agent_app_template/kb/General/README.md +25 -0
  65. package/templates/drive_templates/agent_app_template/microservice/.gitkeep +10 -0
  66. package/templates/drive_templates/agent_app_template/site/.gitkeep +4 -0
  67. package/templates/drive_templates/community_template/Apps/.gitkeep +4 -0
  68. package/templates/drive_templates/community_template/community_assets/community_description.txt +15 -0
  69. package/templates/scaffolds/README.md +62 -0
  70. package/templates/scaffolds/microservice/Dockerfile +19 -0
  71. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +399 -0
  72. package/templates/scaffolds/microservice/app.js +50 -0
  73. package/templates/scaffolds/microservice/app.yaml +8 -0
  74. package/templates/scaffolds/microservice/defaults-config.json +5 -0
  75. package/templates/scaffolds/microservice/dev-overrides.json.example +5 -0
  76. package/templates/scaffolds/microservice/manifest.json +49 -0
  77. package/templates/scaffolds/microservice/package-lock.json +2645 -0
  78. package/templates/scaffolds/microservice/package.json +29 -0
  79. package/templates/scaffolds/microservice/scripts/register.js +55 -0
  80. package/templates/scaffolds/microservice/services/apiFront.js +75 -0
  81. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +57 -0
  82. package/templates/scaffolds/microservice/services/mcpClient.js +78 -0
  83. package/templates/scaffolds/microservice/services/utils.js +247 -0
  84. package/templates/scaffolds/microservice/setup-schema.json +18 -0
  85. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +143 -0
  86. package/templates/scaffolds/site/DeSciXAppSDK.js +62 -0
  87. package/templates/scaffolds/site/README.md +51 -0
  88. package/templates/scaffolds/site/app.js +22 -0
  89. package/templates/scaffolds/site/index.html +42 -0
  90. package/templates/scaffolds/site/styles.css +123 -0
  91. package/test-hydration.js +150 -0
  92. package/vendor/mcp/transports/http-sse-transport.js +196 -0
  93. package/vendor/mcp/transports/index.js +15 -0
  94. package/vendor/mcp/transports/stdio-transport.js +118 -0
@@ -0,0 +1,654 @@
1
+ /**
2
+ * KB (Knowledge Base) Commands - SDK Architecture V2
3
+ *
4
+ * Commands for local KB processing using canonical core modules:
5
+ * - pull: Download from Drive, convert to text (Hydrator)
6
+ * - push: Upload staging files to Drive (Hydrator)
7
+ * - chunk: Generate chunks from text files (Chunker)
8
+ * - sync: Push chunks to Pinecone via API (Syncer)
9
+ * - build: Full pipeline (push staging → pull → chunk → sync)
10
+ * - status: Show sync status
11
+ * - compare: Show file-level deltas
12
+ *
13
+ * Architecture:
14
+ * - Uses WorkspaceConfig for all path resolution (supports v2.1 env.platform/products format)
15
+ * - All operations delegate to lib/core/ modules
16
+ * - Supports interactive and unattended (agent) modes
17
+ * - CLI handles UX (prompts, progress, errors)
18
+ */
19
+
20
+ import chalk from 'chalk';
21
+ import ora from 'ora';
22
+ import * as path from 'path';
23
+ import * as fs from 'fs/promises';
24
+ import inquirer from 'inquirer';
25
+ import { WorkspaceConfig } from '../workspace-config.js';
26
+ import { hydrateKb, pushStaging, checkStagingFiles } from '../core/Hydrator.js';
27
+ import { processKb } from '../core/Chunker.js';
28
+ import { syncKb, getSyncStatus } from '../core/Syncer.js';
29
+ import * as driveADC from '../google-storage-adc.js';
30
+
31
+ // ============ Pull Command ============
32
+
33
+ /**
34
+ * Pull KB content from Drive and convert to local text
35
+ * Delegates to Hydrator.hydrateKb()
36
+ *
37
+ * @param {DeSciXApiClient|null} apiClient - API client (unused, kept for interface)
38
+ * @param {Object} options - Command options
39
+ * @param {string} options.community - Community ID
40
+ * @param {string} options.app - App ID
41
+ * @param {string} options.kb - KB ID
42
+ * @param {boolean} options.verbose - Verbose output
43
+ * @param {string} options.mergeMode - 'merge' | 'overwrite' | 'force-overwrite'
44
+ * @param {boolean} options.interactive - Enable interactive prompts
45
+ * @param {boolean} options.dryRun - Show what would happen
46
+ */
47
+ export async function runKbPull(apiClient, options) {
48
+ const spinner = ora('Loading workspace configuration...').start();
49
+
50
+ try {
51
+ // 1. Load WorkspaceConfig (from workspace.json - no searching)
52
+ const workspaceConfig = await WorkspaceConfig.load();
53
+
54
+ // 2. Resolve context (auto-detect from cwd or use CLI flags)
55
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
56
+
57
+ spinner.text = `Pulling KB: ${communityId}/${appId}/${kbId}`;
58
+
59
+ // 3. Validate Drive configuration
60
+ const driveConfig = workspaceConfig.driveConfig;
61
+ if (!driveConfig?.base_folder_id) {
62
+ spinner.fail('Drive not configured');
63
+ console.log(chalk.yellow('\nšŸ’” Run "descix setup --dev" first to link Drive.\n'));
64
+ throw new Error('The base_folder_id is required for KB operations.');
65
+ }
66
+
67
+ // 4. Get paths
68
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
69
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
70
+
71
+ // 5. Delegate to Hydrator with merge mode options
72
+ spinner.text = 'Connecting to Google Drive...';
73
+
74
+ const result = await hydrateKb({
75
+ workspaceRoot,
76
+ communityId,
77
+ appId,
78
+ kbId,
79
+ baseFolderId: driveConfig.base_folder_id,
80
+ localPath: appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`
81
+ }, {
82
+ verbose: options.verbose,
83
+ mergeMode: options.mergeMode || 'merge',
84
+ dryRun: options.dryRun,
85
+ onProgress: (msg) => { spinner.text = msg; }
86
+ });
87
+
88
+ // Build status message
89
+ const parts = [];
90
+ if (result.pulled > 0) parts.push(`${result.pulled} downloaded`);
91
+ if (result.converted > 0) parts.push(`${result.converted} converted`);
92
+ if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
93
+ if (result.unchanged > 0) parts.push(`${result.unchanged} unchanged`);
94
+
95
+ const statusMsg = parts.length > 0 ? parts.join(', ') : 'No changes';
96
+ spinner.succeed(`Pull complete: ${statusMsg}`);
97
+
98
+ // Show next steps
99
+ if (!options.quiet) {
100
+ console.log(chalk.cyan('\nšŸ“‹ Next steps:'));
101
+ console.log(chalk.gray(' 1. Review files in kb/' + kbId + '/'));
102
+ console.log(chalk.gray(' 2. Run "descix kb chunk" to generate chunks'));
103
+ console.log(chalk.gray(' 3. Run "descix kb sync" to push to Pinecone\n'));
104
+ }
105
+
106
+ return result;
107
+
108
+ } catch (error) {
109
+ spinner.fail('Pull failed');
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ // ============ Push Command ============
115
+
116
+ /**
117
+ * Push staging files to Drive
118
+ * Delegates to Hydrator.pushStaging()
119
+ *
120
+ * @param {DeSciXApiClient|null} apiClient - API client (unused)
121
+ * @param {Object} options - Command options
122
+ * @param {boolean} options.interactive - Enable interactive prompts for conflicts
123
+ * @param {string} options.onConflict - 'overwrite' | 'skip' (default: 'overwrite')
124
+ * @param {boolean} options.moveToProcessed - Move files to .processed (default: true)
125
+ * @param {boolean} options.dryRun - Show what would happen
126
+ */
127
+ export async function runKbPush(apiClient, options) {
128
+ const spinner = ora('Loading workspace configuration...').start();
129
+
130
+ try {
131
+ // 1. Load WorkspaceConfig
132
+ const workspaceConfig = await WorkspaceConfig.load();
133
+
134
+ // 2. Resolve context
135
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
136
+
137
+ spinner.text = `Pushing staging: ${communityId}/${appId}/${kbId}`;
138
+
139
+ // 3. Validate Drive configuration
140
+ const driveConfig = workspaceConfig.driveConfig;
141
+ if (!driveConfig?.base_folder_id) {
142
+ spinner.fail('Drive not configured');
143
+ throw new Error('Run "descix setup --dev" first to link Drive.');
144
+ }
145
+
146
+ // 4. Get paths
147
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
148
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
149
+ const localPath = appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`;
150
+ const stagingDir = path.join(workspaceRoot, localPath, 'kb', 'staging');
151
+
152
+ // Check if staging has files
153
+ const stagingCheck = await checkStagingFiles(stagingDir);
154
+ if (!stagingCheck.hasFiles) {
155
+ spinner.info('No files in staging directory');
156
+ return { uploaded: 0, skipped: 0, errors: 0, processed: [] };
157
+ }
158
+
159
+ // 5. Delegate to Hydrator
160
+ spinner.text = 'Uploading to Google Drive...';
161
+
162
+ // Create conflict prompt callback for interactive mode
163
+ const onConflictPrompt = options.interactive ? async (fileName, fileInfo) => {
164
+ spinner.stop();
165
+ const { action } = await inquirer.prompt([{
166
+ type: 'list',
167
+ name: 'action',
168
+ message: `File "${fileName}" exists in Drive. What would you like to do?`,
169
+ choices: [
170
+ { name: 'Overwrite this file', value: 'overwrite' },
171
+ { name: 'Overwrite all conflicts', value: 'overwrite-all' },
172
+ { name: 'Skip this file', value: 'skip' },
173
+ { name: 'Skip all conflicts', value: 'skip-all' }
174
+ ]
175
+ }]);
176
+ spinner.start('Continuing upload...');
177
+ return action;
178
+ } : null;
179
+
180
+ const result = await pushStaging({
181
+ workspaceRoot,
182
+ communityId,
183
+ appId,
184
+ kbId,
185
+ baseFolderId: driveConfig.base_folder_id,
186
+ localPath
187
+ }, {
188
+ verbose: options.verbose,
189
+ interactive: options.interactive,
190
+ onConflict: options.onConflict || 'overwrite',
191
+ moveToProcessed: options.moveToProcessed !== false,
192
+ dryRun: options.dryRun,
193
+ onConflictPrompt
194
+ });
195
+
196
+ // Build status message
197
+ const parts = [];
198
+ if (result.uploaded > 0) parts.push(`${result.uploaded} uploaded`);
199
+ if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
200
+ if (result.errors > 0) parts.push(`${result.errors} errors`);
201
+
202
+ const statusMsg = parts.length > 0 ? parts.join(', ') : 'No files processed';
203
+ spinner.succeed(`Push complete: ${statusMsg}`);
204
+
205
+ if (result.processed?.length > 0 && options.moveToProcessed !== false) {
206
+ console.log(chalk.gray(` Files moved to kb/staging/.processed/`));
207
+ }
208
+
209
+ // Show next steps
210
+ if (!options.quiet) {
211
+ console.log(chalk.cyan('\nšŸ“‹ Next steps:'));
212
+ console.log(chalk.gray(' 1. Run "descix kb pull" to sync all Drive content'));
213
+ console.log(chalk.gray(' 2. Run "descix kb chunk" to generate chunks\n'));
214
+ }
215
+
216
+ return result;
217
+
218
+ } catch (error) {
219
+ spinner.fail('Push failed');
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ // ============ Chunk Command ============
225
+
226
+ /**
227
+ * Generate chunks from text files
228
+ * Delegates to Chunker.processKb()
229
+ *
230
+ * @param {Object} options - { community, app, kb, chunkSize, overlap, verbose }
231
+ */
232
+ export async function runKbChunk(options) {
233
+ const spinner = ora('Loading workspace configuration...').start();
234
+
235
+ try {
236
+ // 1. Load WorkspaceConfig
237
+ const workspaceConfig = await WorkspaceConfig.load();
238
+
239
+ // 2. Resolve context
240
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
241
+
242
+ spinner.text = `Chunking KB: ${communityId}/${appId}/${kbId}`;
243
+
244
+ // 3. Get paths
245
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
246
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
247
+
248
+ // 4. Delegate to Chunker
249
+ const chunkOptions = {
250
+ maxChunkSize: parseInt(options.chunkSize || 2000),
251
+ overlapSize: parseInt(options.overlap || 500),
252
+ verbose: options.verbose
253
+ };
254
+
255
+ if (options.verbose) {
256
+ console.log(chalk.gray(` Chunk size: ${chunkOptions.maxChunkSize}`));
257
+ console.log(chalk.gray(` Overlap: ${chunkOptions.overlapSize}`));
258
+ }
259
+
260
+ const result = await processKb({
261
+ workspaceRoot,
262
+ communityId,
263
+ appId,
264
+ kbId,
265
+ localPath: appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`
266
+ }, chunkOptions);
267
+
268
+ spinner.succeed(`Generated ${result.totalChunks} chunks from ${result.files} files`);
269
+
270
+ // Show next steps
271
+ if (!options.quiet) {
272
+ console.log(chalk.cyan('\nšŸ“‹ Next steps:'));
273
+ console.log(chalk.gray(' Run "descix kb sync" to push chunks to Pinecone\n'));
274
+ }
275
+
276
+ return result;
277
+
278
+ } catch (error) {
279
+ spinner.fail('Chunk failed');
280
+ throw error;
281
+ }
282
+ }
283
+
284
+ // ============ Sync Command ============
285
+
286
+ /**
287
+ * Sync chunks to Pinecone via API
288
+ * Delegates to Syncer.syncKb()
289
+ *
290
+ * @param {DeSciXApiClient} apiClient - API client for backend
291
+ * @param {Object} options - { community, app, kb, verbose }
292
+ */
293
+ export async function runKbSync(apiClient, options) {
294
+ const spinner = ora('Loading workspace configuration...').start();
295
+
296
+ try {
297
+ if (!apiClient) {
298
+ spinner.fail('Authentication required');
299
+ throw new Error('Run "descix login" first.');
300
+ }
301
+
302
+ // 1. Load WorkspaceConfig
303
+ const workspaceConfig = await WorkspaceConfig.load();
304
+
305
+ // 2. Resolve context
306
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
307
+
308
+ spinner.text = `Syncing KB: ${communityId}/${appId}/${kbId}`;
309
+
310
+ // 3. Get paths
311
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
312
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
313
+
314
+ // 4. Delegate to Syncer
315
+ const result = await syncKb(apiClient, {
316
+ workspaceRoot,
317
+ communityId,
318
+ appId,
319
+ kbId,
320
+ localPath: appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`
321
+ }, {
322
+ verbose: options.verbose,
323
+ onProgress: (msg) => { spinner.text = msg; }
324
+ });
325
+
326
+ if (result.synced > 0 || result.deleted > 0) {
327
+ spinner.succeed(`Synced ${result.synced} chunks (${result.deleted} deleted)`);
328
+ } else {
329
+ spinner.succeed('Already in sync');
330
+ }
331
+
332
+ return result;
333
+
334
+ } catch (error) {
335
+ spinner.fail('Sync failed');
336
+ throw error;
337
+ }
338
+ }
339
+
340
+ // ============ Build Command (Combined) ============
341
+
342
+ /**
343
+ * Full KB build pipeline: [push staging] → pull → chunk → sync
344
+ *
345
+ * Enhanced pipeline:
346
+ * 1. Check staging - if files exist, push to Drive first
347
+ * 2. Pull from Drive with merge mode
348
+ * 3. Chunk local files
349
+ * 4. Sync to Pinecone
350
+ *
351
+ * @param {DeSciXApiClient} apiClient - API client
352
+ * @param {Object} options - Combined options for all steps
353
+ * @param {boolean} options.interactive - Enable interactive prompts
354
+ * @param {boolean} options.skipStaging - Skip staging push step
355
+ * @param {string} options.mergeMode - 'merge' | 'overwrite' | 'force-overwrite'
356
+ * @param {boolean} options.dryRun - Show what would happen
357
+ */
358
+ export async function runKbBuild(apiClient, options) {
359
+ const { interactive = false, skipStaging = false, dryRun = false } = options;
360
+
361
+ console.log(chalk.cyan('\n=== KB Build Pipeline ===\n'));
362
+
363
+ if (dryRun) {
364
+ console.log(chalk.yellow('DRY RUN MODE - No changes will be made\n'));
365
+ }
366
+
367
+ try {
368
+ // Load context once for all steps
369
+ const workspaceConfig = await WorkspaceConfig.load();
370
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
371
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
372
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
373
+ const localPath = appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`;
374
+ const stagingDir = path.join(workspaceRoot, localPath, 'kb', 'staging');
375
+
376
+ // Show which app we're building
377
+ console.log(chalk.white(`Target: ${chalk.cyan(communityId)}/${chalk.cyan(appId)}/${chalk.gray(kbId)}\n`));
378
+
379
+ let stepNum = 1;
380
+ const totalSteps = skipStaging ? 3 : 4;
381
+
382
+ // Step 1: Check and push staging (unless skipped)
383
+ if (!skipStaging) {
384
+ const stagingCheck = await checkStagingFiles(stagingDir);
385
+
386
+ if (stagingCheck.hasFiles) {
387
+ console.log(chalk.white(`[${stepNum}/${totalSteps}] Push staging files to Drive`));
388
+ console.log(chalk.gray(` Found ${stagingCheck.count} file(s) in staging:`));
389
+ stagingCheck.files.forEach(f => console.log(chalk.gray(` - ${f}`)));
390
+ console.log('');
391
+
392
+ await runKbPush(apiClient, {
393
+ ...options,
394
+ quiet: true,
395
+ onConflict: interactive ? undefined : 'overwrite'
396
+ });
397
+ console.log('');
398
+ } else {
399
+ console.log(chalk.gray(`[${stepNum}/${totalSteps}] No staging files to push\n`));
400
+ }
401
+ stepNum++;
402
+ }
403
+
404
+ // Step 2: Pull from Drive
405
+ console.log(chalk.white(`[${stepNum}/${totalSteps}] Pull from Drive`));
406
+
407
+ // After initial setup, default to merge mode to preserve local files
408
+ const mergeMode = options.mergeMode || 'merge';
409
+ if (mergeMode !== 'force-overwrite') {
410
+ console.log(chalk.gray(` Mode: ${mergeMode} (use --force-overwrite to replace all)\n`));
411
+ }
412
+
413
+ await runKbPull(apiClient, {
414
+ ...options,
415
+ quiet: true,
416
+ mergeMode
417
+ });
418
+ console.log('');
419
+ stepNum++;
420
+
421
+ // Step 3: Chunk
422
+ console.log(chalk.white(`[${stepNum}/${totalSteps}] Generate chunks`));
423
+ await runKbChunk({ ...options, quiet: true });
424
+ console.log('');
425
+ stepNum++;
426
+
427
+ // Step 4: Sync
428
+ console.log(chalk.white(`[${stepNum}/${totalSteps}] Sync to Pinecone`));
429
+ await runKbSync(apiClient, { ...options, quiet: true });
430
+
431
+ console.log(chalk.green('\nāœ… KB build complete!\n'));
432
+
433
+ } catch (error) {
434
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
435
+ throw error;
436
+ }
437
+ }
438
+
439
+ // ============ Status Command ============
440
+
441
+ /**
442
+ * Show KB sync status
443
+ *
444
+ * @param {DeSciXApiClient} apiClient - API client
445
+ * @param {Object} options - { community, app, kb }
446
+ */
447
+ export async function runKbStatus(apiClient, options) {
448
+ const spinner = ora('Checking sync status...').start();
449
+
450
+ try {
451
+ // Load WorkspaceConfig
452
+ const workspaceConfig = await WorkspaceConfig.load();
453
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
454
+
455
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
456
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
457
+
458
+ const status = await getSyncStatus(apiClient, {
459
+ workspaceRoot,
460
+ communityId,
461
+ appId,
462
+ kbId,
463
+ localPath: appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`
464
+ });
465
+
466
+ spinner.stop();
467
+
468
+ console.log(chalk.cyan(`\nšŸ“Š KB Status: ${communityId}/${appId}/${kbId}\n`));
469
+ console.log(chalk.white(` Local chunks: ${status.local}`));
470
+ console.log(chalk.white(` Remote chunks: ${status.remote}`));
471
+
472
+ if (status.inSync) {
473
+ console.log(chalk.green('\n āœ“ In sync\n'));
474
+ } else {
475
+ console.log(chalk.yellow('\n ⚠ Out of sync:'));
476
+ if (status.toUpload > 0) {
477
+ console.log(chalk.gray(` - ${status.toUpload} chunks to upload`));
478
+ }
479
+ if (status.toDelete > 0) {
480
+ console.log(chalk.gray(` - ${status.toDelete} chunks to delete`));
481
+ }
482
+ console.log(chalk.gray('\n Run "descix kb sync" to synchronize\n'));
483
+ }
484
+
485
+ return status;
486
+
487
+ } catch (error) {
488
+ spinner.fail('Status check failed');
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ // ============ Compare Command ============
494
+
495
+ /**
496
+ * Show file-level deltas between local and Drive
497
+ *
498
+ * @param {DeSciXApiClient|null} apiClient - API client (unused)
499
+ * @param {Object} options - { community, app, kb, staging, local }
500
+ */
501
+ export async function runKbCompare(apiClient, options) {
502
+ const spinner = ora('Loading workspace configuration...').start();
503
+
504
+ try {
505
+ // Load WorkspaceConfig
506
+ const workspaceConfig = await WorkspaceConfig.load();
507
+ const { communityId, appId, kbId } = workspaceConfig.requireContext(options);
508
+
509
+ const driveConfig = workspaceConfig.driveConfig;
510
+ if (!driveConfig?.base_folder_id) {
511
+ spinner.fail('Drive not configured');
512
+ throw new Error('Run "descix setup --dev" first to link Drive.');
513
+ }
514
+
515
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
516
+ const appPath = workspaceConfig.getAppByAppId(appId)?.absolutePath;
517
+ const localPath = appPath ? (path.relative(workspaceRoot, appPath) || '.') : `${communityId}/${appId}`;
518
+
519
+ spinner.text = 'Connecting to Google Drive...';
520
+
521
+ await driveADC.verifyDriveAuth();
522
+
523
+ // Get Drive KB folder
524
+ const kbPath = `${communityId}/${appId}/kb/${kbId}`;
525
+ const kbDriveFolderId = await driveADC.findFolderByPath(driveConfig.base_folder_id, kbPath);
526
+
527
+ spinner.stop();
528
+
529
+ console.log(chalk.cyan(`\nšŸ“Š KB Compare: ${communityId}/${appId}/${kbId}\n`));
530
+
531
+ // Compare staging → Drive
532
+ if (options.staging !== false) {
533
+ const stagingDir = path.join(workspaceRoot, localPath, 'kb', 'staging');
534
+ const stagingCheck = await checkStagingFiles(stagingDir);
535
+
536
+ console.log(chalk.white('Staging → Drive:'));
537
+
538
+ if (!stagingCheck.hasFiles) {
539
+ console.log(chalk.gray(' (no files in staging)\n'));
540
+ } else {
541
+ // Get Drive files for comparison
542
+ const driveFiles = kbDriveFolderId
543
+ ? await driveADC.listDriveFiles(kbDriveFolderId)
544
+ : [];
545
+ const driveFileNames = new Set(driveFiles.map(f => f.name.toLowerCase()));
546
+
547
+ for (const fileName of stagingCheck.files) {
548
+ const existsInDrive = driveFileNames.has(fileName.toLowerCase());
549
+ if (existsInDrive) {
550
+ console.log(chalk.yellow(` ~ ${fileName}`) + chalk.gray(' (would overwrite)'));
551
+ } else {
552
+ console.log(chalk.green(` + ${fileName}`) + chalk.gray(' (to upload)'));
553
+ }
554
+ }
555
+ console.log('');
556
+ }
557
+ }
558
+
559
+ // Compare local KB → Drive
560
+ if (options.local !== false) {
561
+ const kbDir = path.join(workspaceRoot, localPath, 'kb', kbId);
562
+
563
+ console.log(chalk.white('Local KB ↔ Drive:'));
564
+
565
+ // Get local files
566
+ let localFiles = [];
567
+ try {
568
+ const entries = await fs.readdir(kbDir, { withFileTypes: true });
569
+ localFiles = entries
570
+ .filter(e => !e.isDirectory() && !e.name.startsWith('.'))
571
+ .map(e => e.name);
572
+ } catch {
573
+ // KB directory doesn't exist
574
+ }
575
+
576
+ // Get Drive files
577
+ const driveFiles = kbDriveFolderId
578
+ ? await driveADC.listDriveFiles(kbDriveFolderId)
579
+ : [];
580
+
581
+ // Load metadata for hash comparison
582
+ let metadata = { files: {} };
583
+ try {
584
+ const metaPath = path.join(kbDir, '.descix_metadata.json');
585
+ const metaContent = await fs.readFile(metaPath, 'utf-8');
586
+ metadata = JSON.parse(metaContent);
587
+ } catch {
588
+ // No metadata
589
+ }
590
+
591
+ const localSet = new Set(localFiles.map(f => f.toLowerCase()));
592
+ const driveSet = new Set(driveFiles.map(f => f.name.toLowerCase()));
593
+
594
+ let hasDeltas = false;
595
+
596
+ // Files in Drive but not local
597
+ for (const driveFile of driveFiles) {
598
+ const localName = driveFile.name.toLowerCase();
599
+ if (!localSet.has(localName)) {
600
+ console.log(chalk.cyan(` ↓ ${driveFile.name}`) + chalk.gray(' (Drive only)'));
601
+ hasDeltas = true;
602
+ }
603
+ }
604
+
605
+ // Files in local
606
+ for (const localFile of localFiles) {
607
+ const meta = metadata.files?.[localFile];
608
+
609
+ if (!driveSet.has(localFile.toLowerCase())) {
610
+ // Local only
611
+ console.log(chalk.magenta(` * ${localFile}`) + chalk.gray(' (local only)'));
612
+ hasDeltas = true;
613
+ } else if (meta?.drive_modified) {
614
+ // In both - check if in sync
615
+ const driveFile = driveFiles.find(f => f.name.toLowerCase() === localFile.toLowerCase());
616
+ if (driveFile && driveFile.modifiedTime !== meta.drive_modified) {
617
+ console.log(chalk.yellow(` ↻ ${localFile}`) + chalk.gray(' (Drive updated)'));
618
+ hasDeltas = true;
619
+ } else {
620
+ console.log(chalk.green(` = ${localFile}`) + chalk.gray(' (in sync)'));
621
+ }
622
+ } else {
623
+ console.log(chalk.gray(` ? ${localFile}`) + chalk.gray(' (unknown sync state)'));
624
+ hasDeltas = true;
625
+ }
626
+ }
627
+
628
+ if (!hasDeltas && localFiles.length === 0 && driveFiles.length === 0) {
629
+ console.log(chalk.gray(' (no files)\n'));
630
+ } else {
631
+ console.log('');
632
+ }
633
+ }
634
+
635
+ // Legend
636
+ console.log(chalk.gray('Legend: + new ~ overwrite ↓ Drive only * local only = in sync ↻ updated\n'));
637
+
638
+ return { communityId, appId, kbId };
639
+
640
+ } catch (error) {
641
+ spinner.fail('Compare failed');
642
+ throw error;
643
+ }
644
+ }
645
+
646
+ export default {
647
+ runKbPull,
648
+ runKbPush,
649
+ runKbChunk,
650
+ runKbSync,
651
+ runKbBuild,
652
+ runKbStatus,
653
+ runKbCompare
654
+ };