@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
package/bin/descix.js ADDED
@@ -0,0 +1,3930 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * DeSciX Unified CLI
5
+ *
6
+ * Production command-line interface for DeSciX platform.
7
+ * Combines CLI and MCP functionality with unified authentication.
8
+ */
9
+
10
+ import { Command } from 'commander';
11
+ import chalk from 'chalk';
12
+ import { DeSciXApiClient } from '../lib/api-client.js';
13
+ import { requireAuth } from '../lib/auth-guard.js';
14
+ import { WorkspaceConfig } from '../lib/workspace-config.js';
15
+ import { GlobalConfig } from '../lib/global-config.js';
16
+ import * as authCommands from '../lib/commands/auth.js';
17
+ import * as configCommands from '../lib/commands/config.js';
18
+ import { runAppWizard } from '../lib/commands/app-wizard.js';
19
+ import * as buyCommands from '../lib/commands/buy.js';
20
+ import { runInit } from '../lib/commands/init.js';
21
+ import * as folderCommands from '../lib/commands/folder.js';
22
+ import * as updateCommands from '../lib/commands/update.js';
23
+ import { runStatus } from '../lib/commands/status.js';
24
+ import { runDoctor } from '../lib/commands/doctor.js';
25
+ import * as kbCommands from '../lib/commands/kb.js';
26
+ import fs from 'fs/promises';
27
+ import path from 'path';
28
+ import os from 'os';
29
+
30
+ const program = new Command();
31
+
32
+ program
33
+ .name('descix')
34
+ .description('DeSciX CLI - Unified command-line interface')
35
+ .version('1.0.0');
36
+
37
+ // ============ Authentication Commands (No Auth Required) ============
38
+
39
+
40
+ program
41
+ .command('login')
42
+ .description('Authenticate with DeSciX via device login (opens browser)')
43
+ .option('-u, --url <url>', 'API URL override')
44
+ .option('--dev', 'Use development server (https://localhost:4000)')
45
+ .option('--wallet', 'Use direct wallet connection (advanced, not yet implemented)')
46
+ .action(async (options) => {
47
+ try {
48
+ // Handle --dev flag
49
+ if (options.dev) {
50
+ const workspaceConfig = await WorkspaceConfig.tryLoad(process.cwd());
51
+ if (workspaceConfig) {
52
+ workspaceConfig.apiUrl = 'https://localhost:4000';
53
+ workspaceConfig.environment = 'development';
54
+ await workspaceConfig.save(process.cwd());
55
+ } else {
56
+ const gc = await GlobalConfig.load();
57
+ gc.api_url = 'https://localhost:4000';
58
+ gc.environment = 'development';
59
+ await gc.save();
60
+ }
61
+ console.log(chalk.cyan('āœ“ Configured for development (https://localhost:4000)\n'));
62
+ }
63
+
64
+ if (options.wallet) {
65
+ await authCommands.loginWallet();
66
+ } else {
67
+ await authCommands.loginDevice(options);
68
+ }
69
+ } catch (error) {
70
+ process.exit(1);
71
+ }
72
+ });
73
+
74
+ program
75
+ .command('logout')
76
+ .description('Clear saved credentials')
77
+ .action(async () => {
78
+ try {
79
+ await authCommands.logout();
80
+ } catch (error) {
81
+ process.exit(1);
82
+ }
83
+ });
84
+
85
+ // ============ Init Command (Git-aware workspace setup) ============
86
+
87
+ program
88
+ .command('init')
89
+ .description('Initialize workspace for DeSciX app development (Git-aware)')
90
+ .option('-c, --community <id>', 'Community ID')
91
+ .option('-a, --app <name>', 'App name')
92
+ .option('-p, --path <path>', 'Project path (defaults to current directory)')
93
+ .option('-f, --force', 'Overwrite existing workspace.json')
94
+ .option('--from-invite <token>', 'Resolve an invite token to pre-fill app context')
95
+ .action(async (options) => {
96
+ try {
97
+ // API client is optional for init (used for listing communities)
98
+ let apiClient = null;
99
+ try {
100
+ apiClient = new DeSciXApiClient();
101
+ await apiClient.loadCredentials();
102
+ } catch {
103
+ // Continue without API client
104
+ }
105
+
106
+ await runInit(apiClient, options);
107
+ } catch (error) {
108
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
109
+ process.exit(1);
110
+ }
111
+ });
112
+
113
+ // ============ Auth-Required Commands ============
114
+
115
+ program
116
+ .command('whoami')
117
+ .description('Show current authentication status')
118
+ .action(async () => {
119
+ try {
120
+ const apiClient = new DeSciXApiClient();
121
+ await requireAuth(apiClient);
122
+ await authCommands.whoami();
123
+ } catch (error) {
124
+ process.exit(1);
125
+ }
126
+ });
127
+
128
+ program
129
+ .command('reconnect')
130
+ .description('Reconnect using saved wallet credentials')
131
+ .action(async () => {
132
+ try {
133
+ await authCommands.reconnect();
134
+ } catch (error) {
135
+ process.exit(1);
136
+ }
137
+ });
138
+
139
+ program
140
+ .command('status')
141
+ .description('Show comprehensive status of developer environment')
142
+ .action(async () => {
143
+ try {
144
+ await runStatus();
145
+ } catch (error) {
146
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
147
+ process.exit(1);
148
+ }
149
+ });
150
+
151
+ program
152
+ .command('doctor')
153
+ .description('Diagnose environment issues (Node, Auth, gcloud, ADC)')
154
+ .action(async () => {
155
+ try {
156
+ await runDoctor();
157
+ } catch (error) {
158
+ // runDoctor handles its own errors mostly, but catch-all here
159
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
160
+ process.exit(1);
161
+ }
162
+ });
163
+
164
+ // ============ Buy Commands (Crypto Payments) ============
165
+
166
+ const buyCommand = program
167
+ .command('buy')
168
+ .description('Crypto payment operations');
169
+
170
+ buyCommand
171
+ .command('quote')
172
+ .description('Create a crypto payment quote')
173
+ .requiredOption('-a, --amount <usd>', 'USD amount to purchase')
174
+ .requiredOption('-c, --chain <chain>', 'Blockchain to use (polygon, ethereum, bnb)')
175
+ .option('-t, --type <type>', 'Purchase type: usdcx or community_token', 'usdcx')
176
+ .option('-e, --email <email>', 'Email for claim link (guest mode)')
177
+ .option('--community <id>', 'Community ID for community token purchase')
178
+ .action(async (options) => {
179
+ try {
180
+ await buyCommands.createQuote(options);
181
+ } catch (error) {
182
+ process.exit(1);
183
+ }
184
+ });
185
+
186
+ buyCommand
187
+ .command('status <quoteId>')
188
+ .description('Check status of a payment quote')
189
+ .action(async (quoteId) => {
190
+ try {
191
+ await buyCommands.checkStatus(quoteId);
192
+ } catch (error) {
193
+ process.exit(1);
194
+ }
195
+ });
196
+
197
+ buyCommand
198
+ .command('poll <quoteId>')
199
+ .description('Poll for quote status until completed (with exponential backoff)')
200
+ .option('--max <attempts>', 'Maximum poll attempts', '120')
201
+ .option('--interval <ms>', 'Initial poll interval in ms', '5000')
202
+ .action(async (quoteId, options) => {
203
+ try {
204
+ await buyCommands.pollStatus(quoteId, {
205
+ maxAttempts: parseInt(options.max),
206
+ initialInterval: parseInt(options.interval)
207
+ });
208
+ } catch (error) {
209
+ process.exit(1);
210
+ }
211
+ });
212
+
213
+ buyCommand
214
+ .command('chains')
215
+ .description('List supported blockchains for payments')
216
+ .action(() => {
217
+ buyCommands.listChains();
218
+ });
219
+
220
+ // ============ Sync Commands ============
221
+
222
+ const syncCommand = program
223
+ .command('sync')
224
+ .description('Content sync operations (context-aware)');
225
+
226
+ // Context-aware sync: assets
227
+ syncCommand
228
+ .command('assets')
229
+ .description('Sync app assets (icon, description, system_instructions) - context-aware')
230
+ .option('--push', 'Push local assets to Drive (default)')
231
+ .option('--pull', 'Pull assets from Drive to local')
232
+ .option('--status', 'Show sync status without syncing')
233
+ .option('-c, --community <id>', 'Community ID (uses context if not provided)')
234
+ .option('-a, --app <id>', 'App ID (uses context if not provided)')
235
+ .action(async (options) => {
236
+ try {
237
+ const apiClient = new DeSciXApiClient();
238
+ await requireAuth(apiClient);
239
+
240
+ // Load workspace context
241
+ const workspaceConfig = await WorkspaceConfig.load();
242
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
243
+
244
+ const communityId = ctx.communityId;
245
+ const appId = ctx.appId;
246
+
247
+ if (!communityId || !appId) {
248
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
249
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
250
+ process.exit(1);
251
+ }
252
+
253
+ if (options.status) {
254
+ console.log(chalk.cyan(`\nšŸ“Š Asset Sync Status: ${communityId}/${appId}\n`));
255
+ // TODO: Implement status check
256
+ console.log(chalk.gray(' Status check not yet implemented for assets.\n'));
257
+ return;
258
+ }
259
+
260
+ if (options.pull) {
261
+ console.log(chalk.cyan(`\nšŸ“„ Pulling assets from Drive: ${communityId}/${appId}\n`));
262
+ // TODO: Implement pull
263
+ console.log(chalk.gray(' Pull not yet implemented. Use descix update app for push.\n'));
264
+ return;
265
+ }
266
+
267
+ // Default: push
268
+ await updateCommands.updateApp(options);
269
+
270
+ } catch (error) {
271
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
272
+ process.exit(1);
273
+ }
274
+ });
275
+
276
+ // Context-aware sync: kb
277
+ syncCommand
278
+ .command('kb')
279
+ .description('Sync knowledge base files - context-aware')
280
+ .argument('[stage]', 'Specific stage: stage1, stage2, stage3, all (default: all)')
281
+ .option('--push', 'Push local to Drive (Stage 1)')
282
+ .option('--pull', 'Pull from Drive to local (Stage 1)')
283
+ .option('--status', 'Show sync status')
284
+ .option('-c, --community <id>', 'Community ID (uses context if not provided)')
285
+ .option('-a, --app <id>', 'App ID (uses context if not provided)')
286
+ .option('-k, --kb <name>', 'KB name (default: General)')
287
+ .action(async (stage, options) => {
288
+ try {
289
+ const apiClient = new DeSciXApiClient();
290
+ await requireAuth(apiClient);
291
+
292
+ // Load workspace context
293
+ const workspaceConfig = await WorkspaceConfig.load();
294
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
295
+
296
+ const communityId = ctx.communityId;
297
+ const appId = ctx.appId;
298
+ const kbId = options.kb || ctx.kbId || 'General';
299
+
300
+ if (!communityId || !appId) {
301
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
302
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
303
+ process.exit(1);
304
+ }
305
+
306
+ if (options.status) {
307
+ console.log(chalk.cyan(`\nšŸ“Š KB Sync Status: ${communityId}/${appId}/${kbId}\n`));
308
+ // Call existing status logic
309
+ const stages = stage === 'stage1' ? [1] : stage === 'stage2' ? [2] : stage === 'stage3' ? [3] : [1, 2, 3];
310
+
311
+ for (const s of stages) {
312
+ if (s === 2) {
313
+ const response = await apiClient.invoke('get_drive_gcs_sync_status', {
314
+ community_id: communityId, app_id: appId, kb_id: kbId
315
+ });
316
+ const result = response.message || response;
317
+ console.log(chalk.yellow('Stage 2: Drive → GCS'));
318
+ console.log(chalk.gray(` To Extract: ${result.to_extract || 0}`));
319
+ console.log(chalk.gray(` In Sync: ${result.in_sync || 0}\n`));
320
+ }
321
+ if (s === 3) {
322
+ const response = await apiClient.invoke('get_gcs_pinecone_sync_status', {
323
+ community_id: communityId, app_id: appId, kb_id: kbId
324
+ });
325
+ const result = response.message || response;
326
+ console.log(chalk.yellow('Stage 3: GCS → Pinecone'));
327
+ console.log(chalk.gray(` To Vectorize: ${result.to_vectorize || 0}`));
328
+ console.log(chalk.gray(` Total Vectors: ${result.total_vectors || 0}\n`));
329
+ }
330
+ }
331
+ return;
332
+ }
333
+
334
+ // Determine what to sync
335
+ const stage1Only = stage === 'stage1' || options.push;
336
+ const stageToRun = stage || 'all';
337
+
338
+ console.log(chalk.cyan(`\nšŸ“¤ KB Sync: ${communityId}/${appId}/${kbId} (${stageToRun})\n`));
339
+
340
+ await updateCommands.updateKB({
341
+ ...options,
342
+ kb: kbId,
343
+ stage1Only: stage1Only,
344
+ skipStage1: stage === 'stage2' || stage === 'stage3'
345
+ });
346
+
347
+ } catch (error) {
348
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
349
+ process.exit(1);
350
+ }
351
+ });
352
+
353
+ // Context-aware sync: site
354
+ syncCommand
355
+ .command('site')
356
+ .description('Sync CodeSite to GCS - context-aware')
357
+ .option('--push', 'Deploy to GCS (default)')
358
+ .option('--preview', 'Deploy to preview URL')
359
+ .option('--status', 'Show deployment status')
360
+ .option('--port <number>', 'Register local dev server port')
361
+ .option('-c, --community <id>', 'Community ID (uses context if not provided)')
362
+ .option('-a, --app <id>', 'App ID (uses context if not provided)')
363
+ .action(async (options) => {
364
+ try {
365
+ const apiClient = new DeSciXApiClient();
366
+ await requireAuth(apiClient);
367
+
368
+ // Load workspace context
369
+ const workspaceConfig = await WorkspaceConfig.load();
370
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
371
+
372
+ const communityId = ctx.communityId;
373
+ const appId = ctx.appId;
374
+
375
+ if (!communityId || !appId) {
376
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
377
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
378
+ process.exit(1);
379
+ }
380
+
381
+ if (options.status) {
382
+ console.log(chalk.cyan(`\nšŸ“Š Site Status: ${communityId}/${appId}\n`));
383
+ const response = await apiClient.invoke('get_site_manifest', {
384
+ community_id: communityId, app_id: appId
385
+ });
386
+ const result = response.message || response;
387
+ if (result.manifest) {
388
+ console.log(chalk.gray(` Files: ${Object.keys(result.manifest.files || {}).length}`));
389
+ console.log(chalk.gray(` URL: ${result.site_url}\n`));
390
+ } else {
391
+ console.log(chalk.gray(' No site deployed.\n'));
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Default: push
397
+ await updateCommands.updateSite({
398
+ ...options,
399
+ preview: options.preview,
400
+ port: options.port ? parseInt(options.port) : undefined
401
+ });
402
+
403
+ } catch (error) {
404
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
405
+ process.exit(1);
406
+ }
407
+ });
408
+
409
+ // ============ Community/App Commands ============
410
+
411
+ const communityCommand = program
412
+ .command('community')
413
+ .description('Community operations');
414
+
415
+ communityCommand
416
+ .command('list')
417
+ .description('List all communities')
418
+ .action(async () => {
419
+ try {
420
+ const apiClient = new DeSciXApiClient();
421
+ await requireAuth(apiClient);
422
+
423
+ const response = await apiClient.invoke('find_communities', {}, { allowGuest: false });
424
+ const result = response.message || response;
425
+ const communities = result.communities || [];
426
+
427
+ console.log(chalk.green(`\nāœ… Found ${communities.length} communities\n`));
428
+ console.log(chalk.cyan('Communities:'));
429
+ communities.forEach((c, idx) => {
430
+ console.log(chalk.yellow(`${idx + 1}. ${c.community_name} (${c.community_id})`));
431
+ console.log(chalk.gray(` Token: ${c.token_symbol}`));
432
+ console.log(chalk.gray(` Description: ${c.community_description || 'N/A'}`));
433
+ console.log();
434
+ });
435
+ } catch (error) {
436
+ console.error(chalk.red(error.message));
437
+ process.exit(1);
438
+ }
439
+ });
440
+
441
+ communityCommand
442
+ .command('info')
443
+ .description('Get community information')
444
+ .requiredOption('-c, --community <id>', 'Community ID')
445
+ .action(async (options) => {
446
+ try {
447
+ const apiClient = new DeSciXApiClient();
448
+ await requireAuth(apiClient);
449
+
450
+ const response = await apiClient.invoke('get_community', { community_id: options.community }, { allowGuest: false });
451
+ const result = response.message || response;
452
+ const community = result.community;
453
+
454
+ console.log(chalk.green('\nāœ… Community Information:\n'));
455
+ console.log(chalk.cyan(` Name: ${community.community_name}`));
456
+ console.log(chalk.gray(` ID: ${community.community_id}`));
457
+ console.log(chalk.gray(` Token: ${community.token_symbol}`));
458
+ console.log(chalk.gray(` Description: ${community.community_description || 'N/A'}\n`));
459
+ } catch (error) {
460
+ console.error(chalk.red(error.message));
461
+ process.exit(1);
462
+ }
463
+ });
464
+
465
+ communityCommand
466
+ .command('check-token')
467
+ .description('Check if a token symbol is available')
468
+ .argument('<symbol>', 'Token symbol to check (1-7 chars)')
469
+ .action(async (symbol) => {
470
+ try {
471
+ const apiClient = new DeSciXApiClient();
472
+ await requireAuth(apiClient);
473
+
474
+ const tokenSymbol = symbol.toUpperCase().replace(/[^A-Z0-9]/g, '');
475
+ if (tokenSymbol.length < 1 || tokenSymbol.length > 7) {
476
+ console.error(chalk.red('\nāŒ Token symbol must be 1-7 alphanumeric characters\n'));
477
+ process.exit(1);
478
+ }
479
+
480
+ console.log(chalk.cyan(`\nšŸ” Checking token symbol '${tokenSymbol}'...\n`));
481
+
482
+ const response = await apiClient.invoke('check_token_symbol_available', { token_symbol: tokenSymbol });
483
+ const result = response.message || response;
484
+
485
+ if (result.available) {
486
+ console.log(chalk.green(`āœ… Token symbol '${result.symbol}' is available!\n`));
487
+ } else {
488
+ console.log(chalk.red(`āŒ Token symbol '${result.symbol}' is NOT available\n`));
489
+ if (result.existing_contract) {
490
+ console.log(chalk.yellow(` Already used by contract: ${result.existing_contract}`));
491
+ }
492
+ if (result.existing_community) {
493
+ console.log(chalk.yellow(` Already used by community: ${result.existing_community}`));
494
+ }
495
+ console.log();
496
+ }
497
+ } catch (error) {
498
+ console.error(chalk.red(error.message));
499
+ process.exit(1);
500
+ }
501
+ });
502
+
503
+ communityCommand
504
+ .command('create')
505
+ .description('Community creation is via PWA (Workspace Config). Admins: use descix-admin community create')
506
+ .action(async () => {
507
+ console.log(chalk.cyan('\nšŸ“¦ Community creation\n'));
508
+ console.log(chalk.white('Create communities in the PWA (Workspace Config / Device Setup).'));
509
+ console.log(chalk.white('Platform admins can use: descix-admin community create -n "Name" -t SYMBOL\n'));
510
+ console.log(chalk.gray(' descix-admin uses the same workspace root and .descix/wallet.json as this CLI.\n'));
511
+ });
512
+
513
+ // Deploy a token contract without creating community
514
+ communityCommand
515
+ .command('deploy-token')
516
+ .description('Deploy a new token contract (without creating a community)')
517
+ .requiredOption('-t, --token <symbol>', 'Token symbol (e.g., POWCH)')
518
+ .requiredOption('-n, --name <name>', 'Token name (e.g., "Powch Token")')
519
+ .option('--treasury <address>', 'Treasury wallet address (defaults to platform wallet)')
520
+ .option('-c, --community <id>', 'Community ID to link the token to (optional)')
521
+ .action(async (options) => {
522
+ try {
523
+ const apiClient = new DeSciXApiClient();
524
+ await requireAuth(apiClient);
525
+
526
+ const tokenSymbol = options.token.toUpperCase();
527
+ const tokenName = options.name;
528
+
529
+ console.log(chalk.cyan('\nšŸ“¦ Deploying token contract...\n'));
530
+ console.log(chalk.gray(` Token Symbol: ${tokenSymbol}`));
531
+ console.log(chalk.gray(` Token Name: ${tokenName}`));
532
+ if (options.treasury) {
533
+ console.log(chalk.gray(` Treasury: ${options.treasury}`));
534
+ }
535
+ if (options.community) {
536
+ console.log(chalk.gray(` Community: ${options.community}`));
537
+ }
538
+ console.log();
539
+
540
+ console.log(chalk.yellow('ā³ This may take a few minutes while transactions confirm on-chain...\n'));
541
+
542
+ const response = await apiClient.invoke('deploy_contract', {
543
+ template_type: 'DAITA_v2',
544
+ token_symbol: tokenSymbol,
545
+ token_name: tokenName,
546
+ treasury_wallet: options.treasury || null,
547
+ community_id: options.community || null
548
+ });
549
+
550
+ // Handle response wrapping
551
+ const result = response.message || response;
552
+
553
+ if (result.status !== 'OK') {
554
+ throw new Error(result.message || 'Deployment failed');
555
+ }
556
+
557
+ const deployment = result.deployment;
558
+
559
+ console.log(chalk.green('āœ… Token contract deployed successfully!\n'));
560
+ console.log(chalk.white('Contract Details:'));
561
+ console.log(chalk.gray(` Proxy Address: ${deployment.proxy_address}`));
562
+ console.log(chalk.gray(` Implementation: ${deployment.implementation_address}`));
563
+ console.log(chalk.gray(` Token Symbol: ${deployment.token_symbol}`));
564
+ console.log(chalk.gray(` Token Name: ${deployment.token_name}`));
565
+ console.log(chalk.gray(` Network: ${deployment.network} (chainId: ${deployment.chain_id})`));
566
+ console.log(chalk.gray(` TX Hash: ${deployment.tx_hash}`));
567
+ console.log();
568
+
569
+ if (deployment.network === 'polygon') {
570
+ console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${deployment.proxy_address}`));
571
+ } else if (deployment.network === 'amoy') {
572
+ console.log(chalk.cyan(`View on Amoy: https://amoy.polygonscan.com/address/${deployment.proxy_address}`));
573
+ }
574
+
575
+ if (!options.community) {
576
+ console.log(chalk.yellow('\nNext steps:'));
577
+ console.log(chalk.gray(` 1. Create community: descix community create -n "Name" -t ${tokenSymbol} -p ./path --no-token-contract`));
578
+ console.log(chalk.gray(` 2. Link token: descix community link-token -c <community-id> -a ${deployment.proxy_address}`));
579
+ }
580
+ console.log();
581
+
582
+ } catch (error) {
583
+ console.error(chalk.red(`\nāŒ Error: ${error.message}`));
584
+ process.exit(1);
585
+ }
586
+ });
587
+
588
+ // Link existing token contract to community
589
+ communityCommand
590
+ .command('link-token')
591
+ .description('Link an existing token contract to a community')
592
+ .requiredOption('-c, --community <id>', 'Community ID to link to')
593
+ .requiredOption('-a, --address <address>', 'Contract address to link')
594
+ .option('-t, --token <symbol>', 'Token symbol (optional, uses contract symbol if not provided)')
595
+ .action(async (options) => {
596
+ try {
597
+ const apiClient = new DeSciXApiClient();
598
+ await requireAuth(apiClient);
599
+
600
+ console.log(chalk.cyan('\nšŸ”— Linking token contract to community...\n'));
601
+ console.log(chalk.gray(` Community ID: ${options.community}`));
602
+ console.log(chalk.gray(` Contract Address: ${options.address}`));
603
+ if (options.token) {
604
+ console.log(chalk.gray(` Token Symbol: ${options.token}`));
605
+ }
606
+ console.log();
607
+
608
+ const response = await apiClient.invoke('link_contract_to_community', {
609
+ community_id: options.community,
610
+ contract_address: options.address,
611
+ token_symbol: options.token || null
612
+ });
613
+
614
+ // Handle response wrapping
615
+ const result = response.message || response;
616
+
617
+ if (result.status !== 'OK') {
618
+ throw new Error(result.message || 'Linking failed');
619
+ }
620
+
621
+ const link = result.link;
622
+
623
+ console.log(chalk.green('āœ… Token contract linked successfully!\n'));
624
+ console.log(chalk.white('Link Details:'));
625
+ console.log(chalk.gray(` Community: ${link.community_name} (${link.community_id})`));
626
+ console.log(chalk.gray(` Contract: ${link.contract_address}`));
627
+ console.log(chalk.gray(` Token Symbol: ${link.token_symbol}`));
628
+ console.log(chalk.gray(` Contract Type: ${link.contract_type}`));
629
+ console.log(chalk.gray(` Network: ${link.network}`));
630
+ console.log();
631
+
632
+ } catch (error) {
633
+ console.error(chalk.red(`\nāŒ Error: ${error.message}`));
634
+ process.exit(1);
635
+ }
636
+ });
637
+
638
+ // Get contract details
639
+ communityCommand
640
+ .command('contract')
641
+ .description('Get details about a deployed contract')
642
+ .option('-a, --address <address>', 'Contract address')
643
+ .option('-t, --token <symbol>', 'Token symbol')
644
+ .option('-c, --community <id>', 'Community ID')
645
+ .action(async (options) => {
646
+ try {
647
+ const apiClient = new DeSciXApiClient();
648
+ await requireAuth(apiClient);
649
+
650
+ if (!options.address && !options.token && !options.community) {
651
+ console.error(chalk.red('\nāŒ Must provide --address, --token, or --community\n'));
652
+ process.exit(1);
653
+ }
654
+
655
+ const response = await apiClient.invoke('get_contract_details', {
656
+ contract_address: options.address || null,
657
+ token_symbol: options.token || null,
658
+ community_id: options.community || null
659
+ });
660
+
661
+ // Handle response wrapping
662
+ const result = response.message || response;
663
+
664
+ if (result.status === 'NOT_FOUND') {
665
+ console.log(chalk.yellow('\n⚠ Contract not found\n'));
666
+ return;
667
+ }
668
+
669
+ if (result.status !== 'OK' || !result.contract) {
670
+ throw new Error(result.message || 'Failed to get contract details');
671
+ }
672
+
673
+ const contract = result.contract;
674
+
675
+ console.log(chalk.cyan('\nšŸ“‹ Contract Details\n'));
676
+ console.log(chalk.gray(` Address: ${contract.address}`));
677
+ if (contract.implementation_address) {
678
+ console.log(chalk.gray(` Implementation: ${contract.implementation_address}`));
679
+ }
680
+ console.log(chalk.gray(` Type: ${contract.contract_type}`));
681
+ console.log(chalk.gray(` ERC Spec: ${contract.erc_spec}`));
682
+ console.log(chalk.gray(` Token Symbol: ${contract.token_symbol}`));
683
+ console.log(chalk.gray(` Token Name: ${contract.token_name}`));
684
+ console.log(chalk.gray(` Chain: ${contract.chain} (${contract.chain_id})`));
685
+ if (contract.product_path) {
686
+ console.log(chalk.gray(` Linked To: ${contract.product_path}`));
687
+ }
688
+ console.log(chalk.gray(` Deployed At: ${contract.deployed_at || 'N/A'}`));
689
+ console.log(chalk.gray(` Deployed By: ${contract.deployed_by || 'N/A'}`));
690
+ console.log(chalk.gray(` TX Hash: ${contract.deployment_tx_hash || 'N/A'}`));
691
+ console.log();
692
+
693
+ if (contract.chain === 'polygon') {
694
+ console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${contract.address}`));
695
+ }
696
+ console.log();
697
+
698
+ } catch (error) {
699
+ console.error(chalk.red(`\nāŒ Error: ${error.message}`));
700
+ process.exit(1);
701
+ }
702
+ });
703
+
704
+ // ============ App Commands ============
705
+
706
+ const appCommand = program
707
+ .command('app')
708
+ .description('App management operations');
709
+
710
+ appCommand
711
+ .command('list')
712
+ .description('List apps you have access to (purchases + owned)')
713
+ .action(async () => {
714
+ try {
715
+ const apiClient = new DeSciXApiClient();
716
+ await requireAuth(apiClient);
717
+ const response = await apiClient.invoke('fetch_my_purchases', { product_type: 'APP' });
718
+ const apps = (response.message || response).apps || [];
719
+ if (apps.length === 0) {
720
+ console.log(chalk.yellow('\nNo apps found. Run bootstrap or purchase an app.\n'));
721
+ return;
722
+ }
723
+ const idW = Math.max(6, ...apps.map(a => (a.app_id || '').length));
724
+ const comW = Math.max(9, ...apps.map(a => (a.community_id || '').length));
725
+ const nameW = Math.max(8, ...apps.map(a => (a.app_name || '').length));
726
+ console.log('\n' + chalk.bold(
727
+ 'APP ID'.padEnd(idW + 2) + 'COMMUNITY'.padEnd(comW + 2) + 'APP NAME'
728
+ ));
729
+ console.log('─'.repeat(idW + comW + nameW + 4));
730
+ for (const app of apps) {
731
+ console.log(
732
+ chalk.cyan((app.app_id || '').padEnd(idW + 2)) +
733
+ chalk.gray((app.community_id || '').padEnd(comW + 2)) +
734
+ (app.app_name || '')
735
+ );
736
+ }
737
+ console.log();
738
+ } catch (error) {
739
+ console.error(chalk.red(error.message));
740
+ process.exit(1);
741
+ }
742
+ });
743
+
744
+ appCommand
745
+ .command('init')
746
+ .description('Initialize local workspace and Firestore KB for an app (idempotent)')
747
+ .requiredOption('-a, --app <app_id>', 'App ID (e.g. daita)')
748
+ .option('--kb <name>', 'Knowledge base name', 'General')
749
+ .option('-p, --path <dir>', 'Local app directory (default: auto-detected or cwd)')
750
+ .option('-c, --community <community_id>', 'Community to create app in (if not yet in Products registry)')
751
+ .action(async (options) => {
752
+ try {
753
+ const apiClient = new DeSciXApiClient();
754
+ await requireAuth(apiClient);
755
+ const appId = options.app;
756
+ const kbId = options.kb || 'General';
757
+
758
+ // Resolve community_id from Products registry
759
+ let communityId;
760
+ try {
761
+ const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
762
+ communityId = (productCtx.message || productCtx).community_id;
763
+ } catch (e) {
764
+ // Product not found — fall through to creation below
765
+ }
766
+ // Product missing or returned null community_id — create if --community was provided
767
+ if (!communityId) {
768
+ if (options.community) {
769
+ console.log(chalk.gray(` App '${appId}' not in Products registry. Creating in community '${options.community}'...`));
770
+ await apiClient.invoke('create_app_for_community', {
771
+ community_id: options.community,
772
+ app_name: appId,
773
+ });
774
+ communityId = options.community;
775
+ console.log(chalk.green(` āœ“ Created App + Products + Purchase for ${appId}`));
776
+ } else {
777
+ throw new Error(`App '${appId}' not found in Products registry. Use --community to create it.`);
778
+ }
779
+ }
780
+
781
+ // 1. Workspace.json — register app if not already mapped
782
+ const workspaceConfig = await WorkspaceConfig.tryLoad();
783
+ const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
784
+ let appPath = alreadyMapped?.absolutePath;
785
+
786
+ if (!alreadyMapped) {
787
+ const localPath = options.path || '.';
788
+ const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
789
+ const cfg = workspaceConfig || new WorkspaceConfig({}, wsRoot);
790
+ cfg.registerApp(communityId, appId, { localPath, kbId });
791
+ await cfg.save(wsRoot);
792
+ appPath = path.resolve(wsRoot, localPath);
793
+ console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
794
+ }
795
+
796
+ // 2. Create rigid app folder structure (site, kb, microservice)
797
+ if (appPath) {
798
+ const siteDir = path.join(appPath, 'site');
799
+ const kbDir = path.join(appPath, 'kb', kbId);
800
+ const msDir = path.join(appPath, 'microservice');
801
+ await fs.mkdir(siteDir, { recursive: true });
802
+ await fs.mkdir(kbDir, { recursive: true });
803
+ await fs.mkdir(msDir, { recursive: true });
804
+ console.log(chalk.gray(` Created: site/, kb/${kbId}/, microservice/`));
805
+ }
806
+
807
+ // 3. Create KnowledgeBase Firestore doc (Git Mode — no Drive required)
808
+ const kbResponse = await apiClient.invoke('init_git_mode_kb', { app_id: appId, kb_name: kbId });
809
+ const kbResult = kbResponse.message || kbResponse;
810
+
811
+ console.log(chalk.green(`\nāœ“ ${appId} initialized`));
812
+ console.log(chalk.gray(` Community: ${communityId}`));
813
+ console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
814
+ console.log(chalk.cyan('Next steps:'));
815
+ console.log(chalk.gray(` Add markdown files to kb/${kbId}/ then run:`));
816
+ console.log(chalk.white(` descix update kb -a ${appId}\n`));
817
+ } catch (error) {
818
+ console.error(chalk.red(error.message));
819
+ process.exit(1);
820
+ }
821
+ });
822
+
823
+ appCommand
824
+ .command('register-folder')
825
+ .description('Register your base Drive folder (must be shared with dip@descix.net)')
826
+ .option('-u, --url <folderUrl>', 'Drive folder URL or ID')
827
+ .action(async (options) => {
828
+ try {
829
+ const apiClient = new DeSciXApiClient();
830
+ await requireAuth(apiClient);
831
+
832
+ if (!options.url) {
833
+ console.error(chalk.red('Error: Folder URL or ID required. Use -u or --url'));
834
+ process.exit(1);
835
+ }
836
+
837
+ const response = await apiClient.invoke('register_base_folder', {
838
+ folder_url: options.url
839
+ });
840
+ const result = response.message || response;
841
+
842
+ console.log(chalk.green('\nāœ… Folder registered successfully!\n'));
843
+ console.log(chalk.cyan(` Folder ID: ${result.folder_id}`));
844
+ console.log(chalk.gray(` Folder Name: ${result.folder_name}\n`));
845
+ } catch (error) {
846
+ console.error(chalk.red(error.message));
847
+ process.exit(1);
848
+ }
849
+ });
850
+
851
+ appCommand
852
+ .command('create')
853
+ .description('Create an app in a community. --quick with -c/-a for CLI-driven creation.')
854
+ .option('-c, --community <id>', 'Community ID')
855
+ .option('-a, --app <name>', 'App name')
856
+ .option('--quick', 'Create app via CLI (registers in Products, creates Drive skeleton); requires -c and -a')
857
+ .option('--overwrite', 'Overwrite existing (with --quick)')
858
+ .action(async (options) => {
859
+ try {
860
+ if (options.quick && options.community && options.app) {
861
+ const apiClient = new DeSciXApiClient();
862
+ await requireAuth(apiClient);
863
+
864
+ // Ensure Drive base folder is registered in workspace.json
865
+ let wsConfig;
866
+ try {
867
+ wsConfig = await WorkspaceConfig.load(process.cwd());
868
+ } catch {
869
+ console.error(chalk.red('\nāŒ No workspace found. Run `descix init` first.\n'));
870
+ process.exit(1);
871
+ }
872
+ const baseFolderId = wsConfig.driveConfig?.base_folder_id;
873
+
874
+ if (!baseFolderId) {
875
+ console.log(chalk.yellow('\n⚠ No Drive base folder registered in workspace.json.'));
876
+ console.log(chalk.white(' App creation requires a shared Drive folder for KB and asset storage.'));
877
+ console.log(chalk.white(' Share a Google Drive folder with dip@descix.net, then provide its URL.\n'));
878
+
879
+ const readline = await import('readline');
880
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
881
+ const folderUrl = await new Promise((resolve) => {
882
+ rl.question(chalk.cyan(' Drive folder URL or ID: '), (answer) => {
883
+ rl.close();
884
+ resolve(answer.trim());
885
+ });
886
+ });
887
+
888
+ if (!folderUrl) {
889
+ console.error(chalk.red('\nāŒ Drive folder is required for app creation.\n'));
890
+ process.exit(1);
891
+ }
892
+
893
+ // Register the folder via backend (validates sharing + extracts folder ID)
894
+ const regResponse = await apiClient.invoke('register_base_folder', {
895
+ folder_url: folderUrl
896
+ });
897
+ const regResult = regResponse.message || regResponse;
898
+ console.log(chalk.green(` āœ“ Folder registered: ${regResult.folder_name} (${regResult.folder_id})`));
899
+
900
+ // Persist to workspace.json
901
+ wsConfig.driveConfig = { base_folder_id: regResult.folder_id };
902
+ await wsConfig.save();
903
+ console.log(chalk.green(' āœ“ Saved to workspace.json\n'));
904
+ }
905
+
906
+ const response = await apiClient.invoke('create_app_for_community', {
907
+ community_id: options.community,
908
+ app_name: options.app,
909
+ create_skeleton: true,
910
+ overwrite: options.overwrite
911
+ });
912
+ const result = response.message || response;
913
+ console.log(chalk.green('\nāœ… App created successfully!\n'));
914
+ console.log(chalk.cyan(` App ID: ${result.app_id}`));
915
+ console.log(chalk.cyan(` Community: ${result.community_id}`));
916
+ console.log(chalk.gray(` Products: Products/${result.app_id}`));
917
+ console.log(chalk.gray(` Firestore: Community/${result.community_id}/Apps/${result.app_id}`));
918
+ if (result.skeleton_result) {
919
+ console.log(chalk.gray(` Drive: ${result.skeleton_result.folder_id || 'created'}`));
920
+ }
921
+ console.log(chalk.cyan('\n Next: descix app init -a ' + result.app_id + '\n'));
922
+ return;
923
+ }
924
+ console.log(chalk.cyan('\nšŸ“¦ App creation\n'));
925
+ console.log(chalk.white('Create apps in the PWA (Device Setup / App Manager) or via CLI:\n'));
926
+ console.log(chalk.white(' descix app create --quick -c <community> -a <app-name>\n'));
927
+ console.log(chalk.gray('This registers the app in the Products registry, creates Firestore'));
928
+ console.log(chalk.gray('documents, grants entitlement, and creates Drive skeleton.\n'));
929
+ } catch (error) {
930
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
931
+ process.exit(1);
932
+ }
933
+ });
934
+
935
+ appCommand
936
+ .command('upload')
937
+ .description('Upload local files to app knowledge base')
938
+ .requiredOption('-c, --community <id>', 'Community ID')
939
+ .requiredOption('-a, --app <id>', 'App ID')
940
+ .requiredOption('-p, --path <localPath>', 'Local folder path')
941
+ .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
942
+ .action(async (options) => {
943
+ try {
944
+ const apiClient = new DeSciXApiClient();
945
+ await requireAuth(apiClient);
946
+
947
+ const fs = await import('fs/promises');
948
+ const path = await import('path');
949
+
950
+ // Read files from local path
951
+ const localPath = options.path;
952
+ const stats = await fs.stat(localPath);
953
+ if (!stats.isDirectory()) {
954
+ throw new Error(`Path is not a directory: ${localPath}`);
955
+ }
956
+
957
+ console.log(chalk.gray(`Reading files from ${localPath}...`));
958
+
959
+ // Recursively read files
960
+ const files = [];
961
+ async function readDir(dirPath, basePath = '') {
962
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
963
+ for (const entry of entries) {
964
+ const fullPath = path.join(dirPath, entry.name);
965
+ const relPath = path.join(basePath, entry.name);
966
+
967
+ if (entry.isDirectory()) {
968
+ await readDir(fullPath, relPath);
969
+ } else if (entry.isFile()) {
970
+ const content = await fs.readFile(fullPath, 'utf-8');
971
+ files.push({
972
+ name: entry.name,
973
+ path: relPath,
974
+ content: Buffer.from(content).toString('base64') // Base64 encode
975
+ });
976
+ }
977
+ }
978
+ }
979
+
980
+ await readDir(localPath);
981
+
982
+ if (files.length === 0) {
983
+ throw new Error('No files found in the specified directory');
984
+ }
985
+
986
+ console.log(chalk.gray(`Found ${files.length} files. Uploading...`));
987
+
988
+ const response = await apiClient.invoke('upload_files_to_kb', {
989
+ community_id: options.community,
990
+ app_id: options.app,
991
+ kb_id: options.kb,
992
+ files: files
993
+ });
994
+ const result = response.message || response;
995
+
996
+ console.log(chalk.green('\nāœ… Files uploaded successfully!\n'));
997
+ console.log(chalk.cyan(` Uploaded: ${result.uploaded || 0} files`));
998
+ console.log(chalk.gray(` Total Vectors: ${result.totalVectors || 0}\n`));
999
+
1000
+ if (result.errors && result.errors.length > 0) {
1001
+ console.log(chalk.yellow(' Errors:'));
1002
+ result.errors.forEach(err => {
1003
+ console.log(chalk.red(` - ${err.file}: ${err.error}`));
1004
+ });
1005
+ }
1006
+ } catch (error) {
1007
+ console.error(chalk.red(error.message));
1008
+ process.exit(1);
1009
+ }
1010
+ });
1011
+
1012
+ appCommand
1013
+ .command('upload-tree')
1014
+ .description('Upload folder tree with subfolders as KBs')
1015
+ .requiredOption('-c, --community <id>', 'Community ID')
1016
+ .requiredOption('-a, --app <id>', 'App ID')
1017
+ .requiredOption('-p, --path <localPath>', 'Local folder path')
1018
+ .option('--confirm', 'Confirm each subfolder as KB')
1019
+ .option('--dry-run', 'Show what would be uploaded without uploading')
1020
+ .action(async (options) => {
1021
+ try {
1022
+ const apiClient = new DeSciXApiClient();
1023
+ await requireAuth(apiClient);
1024
+
1025
+ const fs = await import('fs/promises');
1026
+ const path = await import('path');
1027
+ const readline = await import('readline');
1028
+
1029
+ const localPath = options.path;
1030
+ const stats = await fs.stat(localPath);
1031
+ if (!stats.isDirectory()) {
1032
+ throw new Error(`Path is not a directory: ${localPath}`);
1033
+ }
1034
+
1035
+ console.log(chalk.gray(`Scanning ${localPath}...\n`));
1036
+
1037
+ // Find top-level subdirectories
1038
+ const entries = await fs.readdir(localPath, { withFileTypes: true });
1039
+ const subdirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
1040
+ const rootFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.'));
1041
+
1042
+ if (subdirs.length === 0) {
1043
+ throw new Error('No subdirectories found. Use "app upload" for single KB upload.');
1044
+ }
1045
+
1046
+ console.log(chalk.cyan(`Found ${subdirs.length} subdirectories:\n`));
1047
+
1048
+ // Count files in each subdirectory
1049
+ const subdirInfo = [];
1050
+ for (const dir of subdirs) {
1051
+ const dirPath = path.join(localPath, dir.name);
1052
+ const files = await readDirectoryFiles(dirPath);
1053
+ subdirInfo.push({
1054
+ name: dir.name,
1055
+ path: dirPath,
1056
+ fileCount: files.length
1057
+ });
1058
+ console.log(chalk.gray(` [${subdirInfo.length}] ${dir.name}/ (${files.length} files)`));
1059
+ }
1060
+
1061
+ if (rootFiles.length > 0) {
1062
+ console.log(chalk.yellow(`\n Note: ${rootFiles.length} root-level files will go to "General" KB\n`));
1063
+ }
1064
+
1065
+ if (options.dryRun) {
1066
+ console.log(chalk.cyan('\nāœ… Dry run complete. Use without --dry-run to upload.\n'));
1067
+ return;
1068
+ }
1069
+
1070
+ // Confirm each subdirectory if --confirm flag
1071
+ const rl = readline.createInterface({
1072
+ input: process.stdin,
1073
+ output: process.stdout
1074
+ });
1075
+
1076
+ const askYesNo = (question, defaultYes = true) => {
1077
+ return new Promise((resolve) => {
1078
+ const suffix = defaultYes ? '(Y/n)' : '(y/N)';
1079
+ rl.question(`${question} ${suffix}: `, (answer) => {
1080
+ const trimmed = answer.trim().toLowerCase();
1081
+ resolve(trimmed === '' ? defaultYes : (trimmed === 'y' || trimmed === 'yes'));
1082
+ });
1083
+ });
1084
+ };
1085
+
1086
+ const kbsToUpload = [];
1087
+ for (const info of subdirInfo) {
1088
+ if (options.confirm) {
1089
+ const proceed = await askYesNo(`Create KB "${info.name}" and upload ${info.fileCount} files?`, true);
1090
+ if (!proceed) {
1091
+ console.log(chalk.gray(` Skipping ${info.name}\n`));
1092
+ continue;
1093
+ }
1094
+ }
1095
+ kbsToUpload.push(info);
1096
+ }
1097
+
1098
+ rl.close();
1099
+
1100
+ if (kbsToUpload.length === 0) {
1101
+ console.log(chalk.yellow('\nāš ļø No KBs selected for upload.\n'));
1102
+ return;
1103
+ }
1104
+
1105
+ // Upload each subdirectory as a KB
1106
+ for (const info of kbsToUpload) {
1107
+ console.log(chalk.cyan(`\nšŸ“¦ Uploading to KB "${info.name}"...`));
1108
+
1109
+ // Create KB if it doesn't exist
1110
+ try {
1111
+ await apiClient.invoke('create_skeleton_kb', {
1112
+ community_id: options.community,
1113
+ app_id: options.app,
1114
+ kb_name: info.name
1115
+ });
1116
+ console.log(chalk.gray(` āœ“ KB "${info.name}" created`));
1117
+ } catch (err) {
1118
+ // KB might already exist, continue
1119
+ if (!err.message.includes('already exists')) {
1120
+ console.log(chalk.yellow(` āš ļø Could not create KB: ${err.message}`));
1121
+ }
1122
+ }
1123
+
1124
+ // Read files from subdirectory
1125
+ const files = await readDirectoryFiles(info.path);
1126
+
1127
+ if (files.length === 0) {
1128
+ console.log(chalk.yellow(` āš ļø No files found in ${info.name}/`));
1129
+ continue;
1130
+ }
1131
+
1132
+ // Upload in batches
1133
+ const BATCH_SIZE = 10;
1134
+ let uploaded = 0;
1135
+ let errors = [];
1136
+
1137
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
1138
+ const batch = files.slice(i, i + BATCH_SIZE);
1139
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
1140
+ const totalBatches = Math.ceil(files.length / BATCH_SIZE);
1141
+
1142
+ try {
1143
+ process.stdout.write(chalk.gray(` Batch ${batchNum}/${totalBatches}...`));
1144
+ const response = await apiClient.invoke('upload_files_to_kb', {
1145
+ community_id: options.community,
1146
+ app_id: options.app,
1147
+ kb_id: info.name,
1148
+ files: batch
1149
+ });
1150
+ const result = response.message || response;
1151
+ uploaded += result.uploaded || batch.length;
1152
+ if (result.errors) {
1153
+ errors.push(...result.errors);
1154
+ }
1155
+ console.log(chalk.green(' āœ“'));
1156
+ } catch (err) {
1157
+ console.log(chalk.red(` āœ— ${err.message}`));
1158
+ errors.push({ batch: batchNum, error: err.message });
1159
+ }
1160
+ }
1161
+
1162
+ console.log(chalk.green(` āœ… Uploaded ${uploaded} files to KB "${info.name}"`));
1163
+ if (errors.length > 0) {
1164
+ console.log(chalk.yellow(` āš ļø ${errors.length} errors during upload`));
1165
+ }
1166
+ }
1167
+
1168
+ // Handle root files if any
1169
+ if (rootFiles.length > 0) {
1170
+ console.log(chalk.cyan(`\nšŸ“¦ Uploading ${rootFiles.length} root files to "General" KB...`));
1171
+ const rootFilesData = [];
1172
+ for (const file of rootFiles) {
1173
+ const filePath = path.join(localPath, file.name);
1174
+ const content = await fs.readFile(filePath);
1175
+ rootFilesData.push({
1176
+ name: file.name,
1177
+ path: file.name,
1178
+ content: content.toString('base64')
1179
+ });
1180
+ }
1181
+
1182
+ try {
1183
+ const response = await apiClient.invoke('upload_files_to_kb', {
1184
+ community_id: options.community,
1185
+ app_id: options.app,
1186
+ kb_id: 'General',
1187
+ files: rootFilesData
1188
+ });
1189
+ const result = response.message || response;
1190
+ console.log(chalk.green(` āœ… Uploaded ${result.uploaded || 0} root files`));
1191
+ } catch (err) {
1192
+ console.log(chalk.yellow(` āš ļø Could not upload root files: ${err.message}`));
1193
+ }
1194
+ }
1195
+
1196
+ console.log(chalk.green('\nāœ… Upload tree complete!\n'));
1197
+
1198
+ // Helper function to read directory files
1199
+ async function readDirectoryFiles(dirPath, basePath = '') {
1200
+ const files = [];
1201
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
1202
+
1203
+ for (const entry of entries) {
1204
+ const fullPath = path.join(dirPath, entry.name);
1205
+ const relPath = path.join(basePath, entry.name);
1206
+
1207
+ if (entry.isDirectory()) {
1208
+ const subFiles = await readDirectoryFiles(fullPath, relPath);
1209
+ files.push(...subFiles);
1210
+ } else if (entry.isFile()) {
1211
+ // Skip hidden files
1212
+ if (entry.name.startsWith('.')) {
1213
+ continue;
1214
+ }
1215
+ const content = await fs.readFile(fullPath);
1216
+ files.push({
1217
+ name: entry.name,
1218
+ path: relPath,
1219
+ content: content.toString('base64')
1220
+ });
1221
+ }
1222
+ }
1223
+
1224
+ return files;
1225
+ }
1226
+ } catch (error) {
1227
+ console.error(chalk.red(error.message));
1228
+ process.exit(1);
1229
+ }
1230
+ });
1231
+
1232
+ appCommand
1233
+ .command('sync')
1234
+ .description('Sync knowledge base (vectorize files in Drive folder)')
1235
+ .requiredOption('-c, --community <id>', 'Community ID')
1236
+ .requiredOption('-a, --app <id>', 'App ID')
1237
+ .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
1238
+ .action(async (options) => {
1239
+ try {
1240
+ const apiClient = new DeSciXApiClient();
1241
+ await requireAuth(apiClient);
1242
+
1243
+ const response = await apiClient.invoke('sync_knowledge_base', {
1244
+ community_id: options.community,
1245
+ app_id: options.app,
1246
+ kb_id: options.kb
1247
+ });
1248
+ const result = response.message || response;
1249
+
1250
+ console.log(chalk.green('\nāœ… Sync initiated!\n'));
1251
+ console.log(chalk.cyan(` KB: ${options.community}/${options.app}/${options.kb}`));
1252
+ if (result.note) {
1253
+ console.log(chalk.gray(` Note: ${result.note}\n`));
1254
+ }
1255
+ } catch (error) {
1256
+ console.error(chalk.red(error.message));
1257
+ process.exit(1);
1258
+ }
1259
+ });
1260
+
1261
+ appCommand
1262
+ .command('set-codesite')
1263
+ .description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
1264
+ .option('-c, --community <id>', 'Community ID (uses context if not provided)')
1265
+ .option('-a, --app <id>', 'App ID (uses context if not provided)')
1266
+ .requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
1267
+ .action(async (options) => {
1268
+ try {
1269
+ const apiClient = new DeSciXApiClient();
1270
+ await requireAuth(apiClient);
1271
+
1272
+ // Load workspace context
1273
+ const workspaceConfig = await WorkspaceConfig.load();
1274
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
1275
+
1276
+ const communityId = ctx.communityId;
1277
+ const appId = ctx.appId;
1278
+
1279
+ if (!communityId || !appId) {
1280
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
1281
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1282
+ process.exit(1);
1283
+ }
1284
+
1285
+ const response = await apiClient.invoke('update_app_metadata', {
1286
+ community_id: communityId,
1287
+ app_id: appId,
1288
+ ip_site_gcs_path_url: options.url
1289
+ });
1290
+ const result = response.message || response;
1291
+
1292
+ console.log(chalk.green('\nāœ… CodeSite URL updated!\n'));
1293
+ console.log(chalk.cyan(` App: ${communityId}/${appId}`));
1294
+ console.log(chalk.gray(` CodeSite URL: ${options.url}\n`));
1295
+ } catch (error) {
1296
+ console.error(chalk.red(error.message));
1297
+ process.exit(1);
1298
+ }
1299
+ });
1300
+
1301
+ appCommand
1302
+ .command('set-price')
1303
+ .description('Set app price in USDCX')
1304
+ .requiredOption('-c, --community <id>', 'Community ID')
1305
+ .requiredOption('-a, --app <id>', 'App ID')
1306
+ .requiredOption('-p, --price <amount>', 'Price in USDCX (decimal number)')
1307
+ .action(async (options) => {
1308
+ try {
1309
+ const apiClient = new DeSciXApiClient();
1310
+ await requireAuth(apiClient);
1311
+
1312
+ const price = parseFloat(options.price);
1313
+ if (isNaN(price) || price < 0) {
1314
+ throw new Error('Price must be a non-negative number');
1315
+ }
1316
+
1317
+ const response = await apiClient.invoke('update_app_metadata', {
1318
+ community_id: options.community,
1319
+ app_id: options.app,
1320
+ price: price
1321
+ });
1322
+ const result = response.message || response;
1323
+
1324
+ console.log(chalk.green('\nāœ… App price updated!\n'));
1325
+ console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
1326
+ console.log(chalk.gray(` Price: ${price} USDCX\n`));
1327
+ } catch (error) {
1328
+ console.error(chalk.red(error.message));
1329
+ process.exit(1);
1330
+ }
1331
+ });
1332
+
1333
+ appCommand
1334
+ .command('set-api-url')
1335
+ .description('Set API base URL for app\'s own backend')
1336
+ .requiredOption('-c, --community <id>', 'Community ID')
1337
+ .requiredOption('-a, --app <id>', 'App ID')
1338
+ .requiredOption('-u, --url <url>', 'API base URL (e.g., https://api.example.com)')
1339
+ .action(async (options) => {
1340
+ try {
1341
+ const apiClient = new DeSciXApiClient();
1342
+ await requireAuth(apiClient);
1343
+
1344
+ // Validate URL format
1345
+ try {
1346
+ new URL(options.url);
1347
+ } catch {
1348
+ throw new Error('Invalid URL format');
1349
+ }
1350
+
1351
+ const response = await apiClient.invoke('update_app_metadata', {
1352
+ community_id: options.community,
1353
+ app_id: options.app,
1354
+ api_base_url: options.url
1355
+ });
1356
+ const result = response.message || response;
1357
+
1358
+ console.log(chalk.green('\nāœ… API base URL updated!\n'));
1359
+ console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
1360
+ console.log(chalk.gray(` API URL: ${options.url}\n`));
1361
+ } catch (error) {
1362
+ console.error(chalk.red(error.message));
1363
+ process.exit(1);
1364
+ }
1365
+ });
1366
+
1367
+ appCommand
1368
+ .command('deploy')
1369
+ .description('Deploy a microservice for an app you own (registers manifest.json)')
1370
+ .option('-m, --manifest <path>', 'Path to manifest.json (default: ./manifest.json)')
1371
+ .action(async (options) => {
1372
+ try {
1373
+ const apiClient = new DeSciXApiClient();
1374
+ await requireAuth(apiClient);
1375
+
1376
+ // Read manifest.json from current directory or specified path
1377
+ const manifestPath = path.resolve(options.manifest || './manifest.json');
1378
+
1379
+ console.log(chalk.cyan(`\nšŸ“¦ Deploying microservice from ${manifestPath}\n`));
1380
+
1381
+ let manifest;
1382
+ try {
1383
+ const manifestContent = await fs.readFile(manifestPath, 'utf-8');
1384
+ manifest = JSON.parse(manifestContent);
1385
+ } catch (error) {
1386
+ if (error.code === 'ENOENT') {
1387
+ console.error(chalk.red(`āŒ manifest.json not found at ${manifestPath}`));
1388
+ console.error(chalk.gray('\nCreate a manifest.json with your service configuration.'));
1389
+ console.error(chalk.gray('See DeSciX_Core/descix-cli/templates/scaffolds/microservice/manifest.json for a template.\n'));
1390
+ process.exit(1);
1391
+ }
1392
+ throw new Error(`Failed to parse manifest.json: ${error.message}`);
1393
+ }
1394
+
1395
+ // Validate required fields
1396
+ if (!manifest.service?.name) {
1397
+ throw new Error('manifest.service.name is required');
1398
+ }
1399
+ if (!manifest.service?.app_id) {
1400
+ throw new Error('manifest.service.app_id is required (the app you own)');
1401
+ }
1402
+ if (!manifest.service?.community_id) {
1403
+ throw new Error('manifest.service.community_id is required');
1404
+ }
1405
+ if (!manifest.service?.domain) {
1406
+ throw new Error('manifest.service.domain is required');
1407
+ }
1408
+
1409
+ console.log(chalk.gray(` Service: ${manifest.service.name}`));
1410
+ console.log(chalk.gray(` App: ${manifest.service.community_id}/${manifest.service.app_id}`));
1411
+ console.log(chalk.gray(` Domain: ${manifest.service.domain}`));
1412
+ console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}\n`));
1413
+
1414
+ // Call register_service
1415
+ const response = await apiClient.invoke('register_service', { manifest });
1416
+ const result = response.message || response;
1417
+
1418
+ console.log(chalk.green('āœ… Microservice deployed successfully!\n'));
1419
+ console.log(chalk.cyan(` Service: ${manifest.service.name}`));
1420
+ console.log(chalk.gray(` Commands registered: ${Object.keys(manifest.commands || {}).length}`));
1421
+
1422
+ if (response.app_updated) {
1423
+ console.log(chalk.gray(` App api_base_url: ${response.api_base_url}`));
1424
+ }
1425
+
1426
+ console.log(chalk.gray('\n The service manifest cache will refresh within 5 minutes.'));
1427
+ console.log(chalk.gray(' For immediate effect, an admin can run: descix microservice reload\n'));
1428
+
1429
+ } catch (error) {
1430
+ console.error(chalk.red(`\nāŒ Deployment failed: ${error.message}\n`));
1431
+ process.exit(1);
1432
+ }
1433
+ });
1434
+
1435
+ appCommand
1436
+ .command('set-repo')
1437
+ .description('Link a GitHub repository to your app')
1438
+ .requiredOption('-a, --app <id>', 'App ID')
1439
+ .option('--repo <url>', 'SSH git URL (e.g., git@github.com:user/repo.git)')
1440
+ .option('--unlink', 'Remove the linked repository')
1441
+ .option('--branch <name>', 'Default branch', 'main')
1442
+ .option('--subfolder <path>', 'Subfolder within repo')
1443
+ .action(async (options) => {
1444
+ if (!options.repo && !options.unlink) {
1445
+ console.error('Either --repo <url> or --unlink is required.');
1446
+ process.exit(1);
1447
+ }
1448
+ try {
1449
+ const apiClient = new DeSciXApiClient();
1450
+ await requireAuth(apiClient);
1451
+ const result = await apiClient.invoke('set_app_repo', {
1452
+ app_id: options.app,
1453
+ repo_url: options.unlink ? null : options.repo,
1454
+ repo_branch: options.branch,
1455
+ repo_subfolder: options.subfolder
1456
+ });
1457
+ if (result.cleared) {
1458
+ console.log(result.message);
1459
+ } else {
1460
+ console.log('\nRepository linked successfully.\n');
1461
+ console.log(result.instructions);
1462
+ console.log(`\nFingerprint: ${result.fingerprint}\n`);
1463
+ }
1464
+ } catch (error) {
1465
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1466
+ process.exit(1);
1467
+ }
1468
+ });
1469
+
1470
+ appCommand
1471
+ .command('invite')
1472
+ .description('Generate invite links to share your app')
1473
+ .requiredOption('-a, --app <id>', 'App ID')
1474
+ .option('--type <type>', 'Invite type: try or install', 'install')
1475
+ .option('--hint <text>', 'Context for the AI agent (e.g., "Physics students, no coding background")')
1476
+ .option('--expires <duration>', 'Expiry duration (e.g., 30d, 7d, 24h)')
1477
+ .action(async (options) => {
1478
+ try {
1479
+ const apiClient = new DeSciXApiClient();
1480
+ await requireAuth(apiClient);
1481
+ const result = await apiClient.invoke('create_app_invite', {
1482
+ app_id: options.app,
1483
+ invite_type: options.type,
1484
+ agent_hint: options.hint,
1485
+ expires_in: options.expires
1486
+ });
1487
+ console.log(`\nInvite created for ${options.app}\n`);
1488
+ console.log(` Try (browser): ${result.try_url}`);
1489
+ console.log(` Install (VS Code): ${result.install_url}`);
1490
+ console.log(` Expires: ${result.expires_at}`);
1491
+ console.log(` Token: ${result.token}\n`);
1492
+ } catch (error) {
1493
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1494
+ process.exit(1);
1495
+ }
1496
+ });
1497
+
1498
+ // ============ Knowledge Base Commands ============
1499
+
1500
+ const kbCommand = program
1501
+ .command('kb')
1502
+ .description('Knowledge base operations');
1503
+
1504
+ kbCommand
1505
+ .command('create')
1506
+ .description('Create a new knowledge base in an app')
1507
+ .requiredOption('-c, --community <id>', 'Community ID')
1508
+ .requiredOption('-a, --app <id>', 'App ID')
1509
+ .requiredOption('-k, --kb <id>', 'Knowledge Base ID')
1510
+ .option('--drive-folder <id>', 'Google Drive folder ID (optional, will create if not provided)')
1511
+ .action(async (options) => {
1512
+ try {
1513
+ const apiClient = new DeSciXApiClient();
1514
+ await requireAuth(apiClient);
1515
+
1516
+ const response = await apiClient.invoke('create_skeleton_kb', {
1517
+ community_id: options.community,
1518
+ app_id: options.app,
1519
+ kb_name: options.kb,
1520
+ drive_folder_id: options.driveFolder || null
1521
+ });
1522
+ const result = response.message || response;
1523
+
1524
+ console.log(chalk.green('\nāœ… Knowledge Base created successfully!\n'));
1525
+ console.log(chalk.cyan(` KB ID: ${options.kb}`));
1526
+ console.log(chalk.gray(` App: ${options.community}/${options.app}`));
1527
+ if (result.drive_folder_id) {
1528
+ console.log(chalk.gray(` Drive Folder: ${result.drive_folder_id}\n`));
1529
+ }
1530
+ } catch (error) {
1531
+ console.error(chalk.red(error.message));
1532
+ process.exit(1);
1533
+ }
1534
+ });
1535
+
1536
+ // Phase 0 CLI-Centric KB Processing Commands
1537
+ kbCommand
1538
+ .command('pull')
1539
+ .description('Pull KB content from Drive and convert to local markdown')
1540
+ .option('-c, --community <id>', 'Community ID')
1541
+ .option('-a, --app <id>', 'App ID')
1542
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1543
+ .option('-v, --verbose', 'Show verbose output')
1544
+ .option('--merge-mode <mode>', 'Merge mode: merge|overwrite|force-overwrite (default: merge)')
1545
+ .option('--dry-run', 'Show what would happen without making changes')
1546
+ .action(async (options) => {
1547
+ try {
1548
+ let apiClient = null;
1549
+ try {
1550
+ apiClient = new DeSciXApiClient();
1551
+ await apiClient.loadCredentials();
1552
+ } catch {
1553
+ // Continue without API client
1554
+ }
1555
+
1556
+ await kbCommands.runKbPull(apiClient, options);
1557
+ } catch (error) {
1558
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1559
+ process.exit(1);
1560
+ }
1561
+ });
1562
+
1563
+ kbCommand
1564
+ .command('chunk')
1565
+ .description('Generate chunks from local markdown files')
1566
+ .option('-c, --community <id>', 'Community ID')
1567
+ .option('-a, --app <id>', 'App ID')
1568
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1569
+ .option('-s, --chunk-size <size>', 'Chunk size in characters (default: 512)')
1570
+ .option('-o, --overlap <size>', 'Overlap between chunks (default: 64)')
1571
+ .option('-v, --verbose', 'Show verbose output')
1572
+ .action(async (options) => {
1573
+ try {
1574
+ await kbCommands.runKbChunk(options);
1575
+ } catch (error) {
1576
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1577
+ process.exit(1);
1578
+ }
1579
+ });
1580
+
1581
+ kbCommand
1582
+ .command('sync')
1583
+ .description('Sync local chunks to Pinecone via service layer')
1584
+ .option('-c, --community <id>', 'Community ID')
1585
+ .option('-a, --app <id>', 'App ID')
1586
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1587
+ .option('-v, --verbose', 'Show verbose output')
1588
+ .action(async (options) => {
1589
+ try {
1590
+ const apiClient = new DeSciXApiClient();
1591
+ await requireAuth(apiClient);
1592
+
1593
+ await kbCommands.runKbSync(apiClient, options);
1594
+ } catch (error) {
1595
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1596
+ process.exit(1);
1597
+ }
1598
+ });
1599
+
1600
+ kbCommand
1601
+ .command('build')
1602
+ .description('Full KB pipeline: [push staging] → pull → chunk → sync')
1603
+ .option('-c, --community <id>', 'Community ID')
1604
+ .option('-a, --app <id>', 'App ID')
1605
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1606
+ .option('-s, --chunk-size <size>', 'Chunk size in characters (default: 2000)')
1607
+ .option('-o, --overlap <size>', 'Overlap between chunks (default: 500)')
1608
+ .option('-v, --verbose', 'Show verbose output')
1609
+ .option('-i, --interactive', 'Enable interactive prompts for conflicts')
1610
+ .option('--skip-staging', 'Skip staging push step')
1611
+ .option('--merge-mode <mode>', 'Pull merge mode: merge|overwrite|force-overwrite (default: merge)')
1612
+ .option('--dry-run', 'Show what would happen without making changes')
1613
+ .action(async (options) => {
1614
+ try {
1615
+ const apiClient = new DeSciXApiClient();
1616
+ await requireAuth(apiClient);
1617
+
1618
+ await kbCommands.runKbBuild(apiClient, options);
1619
+ } catch (error) {
1620
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1621
+ process.exit(1);
1622
+ }
1623
+ });
1624
+
1625
+ kbCommand
1626
+ .command('push')
1627
+ .description('Push staging files to Drive')
1628
+ .option('-c, --community <id>', 'Community ID')
1629
+ .option('-a, --app <id>', 'App ID')
1630
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1631
+ .option('-v, --verbose', 'Show verbose output')
1632
+ .option('-i, --interactive', 'Enable interactive prompts for conflicts')
1633
+ .option('--on-conflict <action>', 'Conflict handling: overwrite|skip (default: overwrite)')
1634
+ .option('--no-move', 'Do not move files to .processed after upload')
1635
+ .option('--dry-run', 'Show what would happen without making changes')
1636
+ .action(async (options) => {
1637
+ try {
1638
+ let apiClient = null;
1639
+ try {
1640
+ apiClient = new DeSciXApiClient();
1641
+ await apiClient.loadCredentials();
1642
+ } catch {
1643
+ // Continue without API client
1644
+ }
1645
+
1646
+ await kbCommands.runKbPush(apiClient, {
1647
+ ...options,
1648
+ moveToProcessed: options.move !== false
1649
+ });
1650
+ } catch (error) {
1651
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1652
+ process.exit(1);
1653
+ }
1654
+ });
1655
+
1656
+ kbCommand
1657
+ .command('status')
1658
+ .description('Show KB sync status (local vs Pinecone)')
1659
+ .option('-c, --community <id>', 'Community ID')
1660
+ .option('-a, --app <id>', 'App ID')
1661
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1662
+ .action(async (options) => {
1663
+ try {
1664
+ const apiClient = new DeSciXApiClient();
1665
+ await requireAuth(apiClient);
1666
+
1667
+ await kbCommands.runKbStatus(apiClient, options);
1668
+ } catch (error) {
1669
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1670
+ process.exit(1);
1671
+ }
1672
+ });
1673
+
1674
+ kbCommand
1675
+ .command('compare')
1676
+ .description('Show file-level deltas between local and Drive')
1677
+ .option('-c, --community <id>', 'Community ID')
1678
+ .option('-a, --app <id>', 'App ID')
1679
+ .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1680
+ .option('--staging', 'Only show staging → Drive delta')
1681
+ .option('--local', 'Only show local KB ↔ Drive delta')
1682
+ .action(async (options) => {
1683
+ try {
1684
+ await kbCommands.runKbCompare(null, options);
1685
+ } catch (error) {
1686
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1687
+ process.exit(1);
1688
+ }
1689
+ });
1690
+
1691
+ // ============ Site Commands ============
1692
+
1693
+ const siteCommand = program
1694
+ .command('site')
1695
+ .description('Site lifecycle management: init, upload, servelocal');
1696
+
1697
+ // site init - Copy scaffold to app
1698
+ siteCommand
1699
+ .command('init')
1700
+ .description('Initialize site scaffold in current app')
1701
+ .option('-f, --force', 'Overwrite existing site/ folder')
1702
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
1703
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
1704
+ .action(async (options) => {
1705
+ try {
1706
+ const workspaceConfig = await WorkspaceConfig.load();
1707
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
1708
+
1709
+ if (!ctx.communityId || !ctx.appId) {
1710
+ console.error(chalk.red('\nāŒ No app context found.'));
1711
+ console.log(chalk.gray(' cd into an app directory, or use -c and -a flags\n'));
1712
+ process.exit(1);
1713
+ }
1714
+
1715
+ const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
1716
+ if (!appConfig) {
1717
+ console.error(chalk.red('\nāŒ App not found in workspace.json.'));
1718
+ console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
1719
+ process.exit(1);
1720
+ }
1721
+
1722
+ const appPath = appConfig.absolutePath ||
1723
+ path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
1724
+
1725
+ console.log(chalk.cyan('\nšŸ“ Adding site scaffold...\n'));
1726
+
1727
+ const { copyScaffold } = await import('../lib/core/Hydrator.js');
1728
+ const stats = await copyScaffold('site', appPath, {
1729
+ verbose: true,
1730
+ force: options.force
1731
+ });
1732
+
1733
+ console.log(chalk.green(`\nāœ… Site scaffold added (${stats.copied} files)\n`));
1734
+ console.log(chalk.gray(' Next steps:'));
1735
+ console.log(chalk.gray(' - Edit site/index.html for your content'));
1736
+ console.log(chalk.gray(' - Run descix site upload to deploy\n'));
1737
+ } catch (error) {
1738
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
1739
+ process.exit(1);
1740
+ }
1741
+ });
1742
+
1743
+ // site upload - Deploy to GCS (context-aware, replaces deploy)
1744
+ siteCommand
1745
+ .command('upload')
1746
+ .description('Upload site to GCS and update app metadata')
1747
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
1748
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
1749
+ .option('-p, --path <localPath>', 'Local directory to deploy', './site')
1750
+ .option('--preview', 'Deploy to preview path')
1751
+ .option('--full', 'Force full upload (ignore delta)')
1752
+ .option('--dry-run', 'Show what would be deployed')
1753
+ .option('--no-cache', 'Set Cache-Control: no-cache')
1754
+ .action(async (options) => {
1755
+ try {
1756
+ const apiClient = new DeSciXApiClient();
1757
+ await requireAuth(apiClient);
1758
+
1759
+ // Load workspace context
1760
+ const workspaceConfig = await WorkspaceConfig.load();
1761
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
1762
+
1763
+ const communityId = ctx.communityId;
1764
+ const appId = ctx.appId;
1765
+
1766
+ if (!communityId || !appId) {
1767
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
1768
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1769
+ process.exit(1);
1770
+ }
1771
+
1772
+ // Import GitUtils for file hashing
1773
+ const { GitUtils } = await import('@descix/sdk/integrations/git');
1774
+ const mime = (await import('mime-types')).default;
1775
+
1776
+ const localPath = path.resolve(options.path);
1777
+
1778
+ // Check if directory exists
1779
+ try {
1780
+ const stat = await fs.stat(localPath);
1781
+ if (!stat.isDirectory()) {
1782
+ throw new Error(`Path is not a directory: ${localPath}`);
1783
+ }
1784
+ } catch (err) {
1785
+ if (err.code === 'ENOENT') {
1786
+ throw new Error(`Directory not found: ${localPath}`);
1787
+ }
1788
+ throw err;
1789
+ }
1790
+
1791
+ // Get Git status
1792
+ const gitUtils = new GitUtils(process.cwd());
1793
+ const gitStatus = await gitUtils.getStatus();
1794
+
1795
+ console.log(chalk.cyan(`\nšŸš€ Site Upload: ${localPath} → GCS\n`));
1796
+ console.log(chalk.gray(` Target: ${communityId}/${appId}`));
1797
+ if (options.preview) console.log(chalk.yellow(` Mode: PREVIEW`));
1798
+ if (gitStatus.isGitRepo) {
1799
+ console.log(chalk.gray(` Git: ${gitStatus.branch} @ ${gitStatus.shortHash}`));
1800
+ }
1801
+
1802
+ // 1. Compute local file hashes
1803
+ const localFiles = await gitUtils.getFileHashes(localPath);
1804
+ const fileList = Object.entries(localFiles).map(([filePath, info]) => ({
1805
+ path: filePath,
1806
+ hash: info.hash,
1807
+ size: info.size,
1808
+ content_type: mime.lookup(filePath) || 'application/octet-stream'
1809
+ }));
1810
+
1811
+ console.log(chalk.gray(`\n Found ${fileList.length} files\n`));
1812
+
1813
+ if (fileList.length === 0) {
1814
+ console.log(chalk.yellow(' No files found in directory. Nothing to deploy.\n'));
1815
+ return;
1816
+ }
1817
+
1818
+ // 2. Request deploy token with file list
1819
+ const tokenResponse = await apiClient.invoke('get_site_deploy_token', {
1820
+ community_id: communityId,
1821
+ app_id: appId,
1822
+ files: fileList,
1823
+ preview: options.preview || false
1824
+ });
1825
+
1826
+ const { signed_urls, existing_manifest, token_id, site_url } = tokenResponse.message;
1827
+
1828
+ // 3. Determine delta
1829
+ let filesToUpload = fileList;
1830
+ let filesToDelete = [];
1831
+
1832
+ if (!options.full && existing_manifest) {
1833
+ const delta = gitUtils.compareSyncState(localFiles, existing_manifest);
1834
+ filesToUpload = fileList.filter(f =>
1835
+ delta.added.includes(f.path) || delta.modified.includes(f.path)
1836
+ );
1837
+ filesToDelete = delta.deleted;
1838
+
1839
+ console.log(chalk.gray(` Delta: ${delta.added.length} added, ${delta.modified.length} modified, ${delta.unchanged.length} unchanged, ${delta.deleted.length} deleted`));
1840
+ }
1841
+
1842
+ if (options.dryRun) {
1843
+ console.log(chalk.yellow('\nšŸ“‹ Dry run - would upload:'));
1844
+ if (filesToUpload.length === 0) {
1845
+ console.log(chalk.gray(' (no files to upload)'));
1846
+ } else {
1847
+ filesToUpload.forEach(f => console.log(chalk.gray(` + ${f.path} (${(f.size / 1024).toFixed(1)}KB)`)));
1848
+ }
1849
+ if (filesToDelete.length > 0) {
1850
+ console.log(chalk.yellow('\nWould delete:'));
1851
+ filesToDelete.forEach(f => console.log(chalk.gray(` - ${f}`)));
1852
+ }
1853
+ console.log(chalk.gray(`\n Site URL: ${site_url}\n`));
1854
+ return;
1855
+ }
1856
+
1857
+ if (filesToUpload.length === 0) {
1858
+ console.log(chalk.green('\nāœ… Site already up to date on GCS.'));
1859
+ console.log(chalk.gray(' Ensuring app metadata is synchronized...'));
1860
+ // Fall through to confirm_site_deploy to ensure DB is updated with relative path
1861
+ } else {
1862
+ // 4. Upload files directly to GCS using signed URLs
1863
+ console.log(chalk.gray(`\n Uploading ${filesToUpload.length} files...`));
1864
+
1865
+ let uploadedCount = 0;
1866
+ let uploadErrors = [];
1867
+
1868
+ for (const file of filesToUpload) {
1869
+ const signedUrl = signed_urls[file.path];
1870
+ if (!signedUrl) {
1871
+ uploadErrors.push(`No signed URL for: ${file.path}`);
1872
+ continue;
1873
+ }
1874
+
1875
+ try {
1876
+ const content = await fs.readFile(path.join(localPath, file.path));
1877
+
1878
+ const response = await fetch(signedUrl, {
1879
+ method: 'PUT',
1880
+ headers: {
1881
+ 'Content-Type': file.content_type,
1882
+ ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
1883
+ },
1884
+ body: content
1885
+ });
1886
+
1887
+ if (!response.ok) {
1888
+ uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
1889
+ console.log(chalk.red(` āœ— ${file.path}`));
1890
+ } else {
1891
+ uploadedCount++;
1892
+ console.log(chalk.green(` āœ“ ${file.path}`));
1893
+ }
1894
+ } catch (err) {
1895
+ uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
1896
+ console.log(chalk.red(` āœ— ${file.path}`));
1897
+ }
1898
+ }
1899
+
1900
+ if (uploadErrors.length > 0) {
1901
+ console.log(chalk.yellow(`\nāš ļø ${uploadErrors.length} errors during upload:`));
1902
+ uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
1903
+ }
1904
+ }
1905
+
1906
+ // 5. Confirm deployment (ALWAYS call this to ensure DB is updated)
1907
+ const confirmResponse = await apiClient.invoke('confirm_site_deploy', {
1908
+ community_id: communityId,
1909
+ app_id: appId,
1910
+ token_id: token_id,
1911
+ manifest: {
1912
+ version: '1.0',
1913
+ app_id: appId,
1914
+ community_id: communityId,
1915
+ files: localFiles,
1916
+ source_path: options.path
1917
+ },
1918
+ git_info: gitStatus.isGitRepo ? {
1919
+ commit: gitStatus.shortHash,
1920
+ branch: gitStatus.branch
1921
+ } : null,
1922
+ preview: options.preview || false
1923
+ });
1924
+
1925
+ const result = confirmResponse.message;
1926
+
1927
+ console.log(chalk.green(`\nāœ… Site uploaded successfully!\n`));
1928
+ console.log(chalk.cyan(` URL: ${result.site_url}`));
1929
+ console.log(chalk.gray(` Files: ${result.files_count}`));
1930
+ console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
1931
+ if (result.preview) {
1932
+ console.log(chalk.yellow(` Mode: PREVIEW`));
1933
+ }
1934
+
1935
+ if (!options.preview) {
1936
+ console.log(chalk.gray(`\n For local development, run:`));
1937
+ console.log(chalk.cyan(` descix site servelocal <port>\n`));
1938
+ }
1939
+ console.log('');
1940
+
1941
+ } catch (error) {
1942
+ console.error(chalk.red(`\nāŒ Upload failed: ${error.message}\n`));
1943
+ process.exit(1);
1944
+ }
1945
+ });
1946
+
1947
+ // site servelocal - Register local dev server port
1948
+ siteCommand
1949
+ .command('servelocal [port]')
1950
+ .description('Register local dev server port (or "n" to disable)')
1951
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
1952
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
1953
+ .action(async (port, options) => {
1954
+ try {
1955
+ const workspaceConfig = await WorkspaceConfig.load();
1956
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
1957
+
1958
+ const communityId = ctx.communityId;
1959
+ const appId = ctx.appId;
1960
+
1961
+ if (!communityId || !appId) {
1962
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
1963
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1964
+ process.exit(1);
1965
+ }
1966
+
1967
+ const appConfig = workspaceConfig.getApp(communityId, appId);
1968
+ if (!appConfig) {
1969
+ console.error(chalk.red('\nāŒ App not found in workspace.json.'));
1970
+ console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
1971
+ process.exit(1);
1972
+ }
1973
+
1974
+ // Handle disable case
1975
+ if (port === 'n' || port === 'N') {
1976
+ if (appConfig.site) {
1977
+ delete appConfig.site.port;
1978
+ if (Object.keys(appConfig.site).length === 0) {
1979
+ delete appConfig.site;
1980
+ }
1981
+ }
1982
+ await workspaceConfig.save();
1983
+ console.log(chalk.green(`\nāœ… Local site server disabled for ${communityId}/${appId}\n`));
1984
+ return;
1985
+ }
1986
+
1987
+ // Handle port registration
1988
+ if (!port) {
1989
+ // Show current status
1990
+ const currentPort = appConfig.site?.port;
1991
+ if (currentPort) {
1992
+ console.log(chalk.cyan(`\nšŸ“ Local site server: port ${currentPort}`));
1993
+ console.log(chalk.gray(` App: ${communityId}/${appId}\n`));
1994
+ } else {
1995
+ console.log(chalk.yellow(`\nāš ļø No local site server configured.`));
1996
+ console.log(chalk.gray(` Usage: descix site servelocal <port>\n`));
1997
+ }
1998
+ return;
1999
+ }
2000
+
2001
+ const portNum = parseInt(port);
2002
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
2003
+ console.error(chalk.red('\nāŒ Invalid port number.\n'));
2004
+ process.exit(1);
2005
+ }
2006
+
2007
+ if (!appConfig.site) appConfig.site = {};
2008
+ appConfig.site.port = portNum;
2009
+ if (!appConfig.site.devCommand) {
2010
+ appConfig.site.devCommand = 'npm run dev';
2011
+ }
2012
+
2013
+ await workspaceConfig.save();
2014
+ console.log(chalk.green(`\nāœ… Local site server registered!`));
2015
+ console.log(chalk.cyan(` Port: ${portNum}`));
2016
+ console.log(chalk.gray(` App: ${communityId}/${appId}`));
2017
+ console.log(chalk.gray(`\n The PWA will proxy /apps/${communityId}/${appId}/* to localhost:${portNum}\n`));
2018
+
2019
+ } catch (error) {
2020
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
2021
+ process.exit(1);
2022
+ }
2023
+ });
2024
+
2025
+ // site list - List deployed files (context-aware)
2026
+ siteCommand
2027
+ .command('list')
2028
+ .description('List deployed site files')
2029
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2030
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2031
+ .option('--preview', 'List preview site')
2032
+ .action(async (options) => {
2033
+ try {
2034
+ const apiClient = new DeSciXApiClient();
2035
+ await requireAuth(apiClient);
2036
+
2037
+ const workspaceConfig = await WorkspaceConfig.load();
2038
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2039
+
2040
+ const communityId = ctx.communityId;
2041
+ const appId = ctx.appId;
2042
+
2043
+ if (!communityId || !appId) {
2044
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
2045
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
2046
+ process.exit(1);
2047
+ }
2048
+
2049
+ const response = await apiClient.invoke('list_site_files', {
2050
+ community_id: communityId,
2051
+ app_id: appId,
2052
+ preview: options.preview || false
2053
+ });
2054
+
2055
+ const result = response.message;
2056
+
2057
+ console.log(chalk.cyan(`\nšŸ“‚ Site Files: ${communityId}/${appId}${options.preview ? ' (preview)' : ''}\n`));
2058
+ console.log(chalk.gray(` URL: ${result.site_url}`));
2059
+ console.log(chalk.gray(` Files: ${result.files_count}\n`));
2060
+
2061
+ if (result.files.length === 0) {
2062
+ console.log(chalk.gray(' No files deployed.\n'));
2063
+ } else {
2064
+ result.files.forEach(f => {
2065
+ const sizeKB = (f.size / 1024).toFixed(1);
2066
+ console.log(chalk.gray(` ${f.path.padEnd(40)} ${sizeKB.padStart(8)}KB ${f.contentType}`));
2067
+ });
2068
+ console.log('');
2069
+ }
2070
+
2071
+ } catch (error) {
2072
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
2073
+ process.exit(1);
2074
+ }
2075
+ });
2076
+
2077
+ // site status - Get deployment status (context-aware)
2078
+ siteCommand
2079
+ .command('status')
2080
+ .description('Get deployment status and manifest')
2081
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2082
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2083
+ .option('--preview', 'Check preview site')
2084
+ .action(async (options) => {
2085
+ try {
2086
+ const apiClient = new DeSciXApiClient();
2087
+ await requireAuth(apiClient);
2088
+
2089
+ const workspaceConfig = await WorkspaceConfig.load();
2090
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2091
+
2092
+ const communityId = ctx.communityId;
2093
+ const appId = ctx.appId;
2094
+
2095
+ if (!communityId || !appId) {
2096
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
2097
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
2098
+ process.exit(1);
2099
+ }
2100
+
2101
+ const response = await apiClient.invoke('get_site_manifest', {
2102
+ community_id: communityId,
2103
+ app_id: appId,
2104
+ preview: options.preview || false
2105
+ });
2106
+
2107
+ const result = response.message;
2108
+
2109
+ console.log(chalk.cyan(`\nšŸ“Š Site Status: ${communityId}/${appId}${options.preview ? ' (preview)' : ''}\n`));
2110
+
2111
+ if (!result.exists) {
2112
+ console.log(chalk.yellow(' No site deployed.\n'));
2113
+ return;
2114
+ }
2115
+
2116
+ const manifest = result.manifest;
2117
+ const deploy = manifest.deploy || {};
2118
+
2119
+ console.log(chalk.gray(` URL: ${result.site_url}`));
2120
+ console.log(chalk.gray(` Deployed: ${deploy.deployed_at || 'unknown'}`));
2121
+ console.log(chalk.gray(` By: ${deploy.deployed_by || 'unknown'}`));
2122
+ if (deploy.git_commit) {
2123
+ console.log(chalk.gray(` Git: ${deploy.git_branch || 'unknown'} @ ${deploy.git_commit}`));
2124
+ }
2125
+ console.log(chalk.gray(` Files: ${Object.keys(manifest.files || {}).length}`));
2126
+ console.log('');
2127
+
2128
+ } catch (error) {
2129
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
2130
+ process.exit(1);
2131
+ }
2132
+ });
2133
+
2134
+ // site delete - Delete deployed site (context-aware)
2135
+ siteCommand
2136
+ .command('delete')
2137
+ .description('Delete deployed site')
2138
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2139
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2140
+ .option('--preview', 'Delete preview site only')
2141
+ .option('--confirm', 'Skip confirmation prompt')
2142
+ .action(async (options) => {
2143
+ try {
2144
+ const apiClient = new DeSciXApiClient();
2145
+ await requireAuth(apiClient);
2146
+
2147
+ const workspaceConfig = await WorkspaceConfig.load();
2148
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2149
+
2150
+ const communityId = ctx.communityId;
2151
+ const appId = ctx.appId;
2152
+
2153
+ if (!communityId || !appId) {
2154
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
2155
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
2156
+ process.exit(1);
2157
+ }
2158
+
2159
+ if (!options.confirm) {
2160
+ const readline = await import('readline');
2161
+ const rl = readline.createInterface({
2162
+ input: process.stdin,
2163
+ output: process.stdout
2164
+ });
2165
+
2166
+ const answer = await new Promise(resolve => {
2167
+ rl.question(chalk.yellow(`\nāš ļø Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `), resolve);
2168
+ });
2169
+ rl.close();
2170
+
2171
+ if (answer.toLowerCase() !== 'y') {
2172
+ console.log(chalk.gray('\n Cancelled.\n'));
2173
+ return;
2174
+ }
2175
+ }
2176
+
2177
+ const response = await apiClient.invoke('delete_site_files', {
2178
+ community_id: communityId,
2179
+ app_id: appId,
2180
+ preview: options.preview || false
2181
+ });
2182
+
2183
+ const result = response.message;
2184
+
2185
+ if (result.success) {
2186
+ console.log(chalk.green(`\nāœ… Site deleted successfully!`));
2187
+ console.log(chalk.gray(` Files removed: ${result.deleted_count}\n`));
2188
+ } else {
2189
+ console.log(chalk.yellow(`\nāš ļø Deletion completed with errors:`));
2190
+ result.errors.forEach(e => console.log(chalk.red(` - ${e}`)));
2191
+ console.log(chalk.gray(` Files removed: ${result.deleted_count}\n`));
2192
+ }
2193
+
2194
+ } catch (error) {
2195
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
2196
+ process.exit(1);
2197
+ }
2198
+ });
2199
+
2200
+ // ============ Microservice Commands ============
2201
+
2202
+ const microserviceCommand = program
2203
+ .command('microservice')
2204
+ .description('Microservice lifecycle management');
2205
+
2206
+ // microservice init - Copy scaffold to app
2207
+ microserviceCommand
2208
+ .command('init')
2209
+ .description('Initialize microservice scaffold in current app')
2210
+ .option('-f, --force', 'Overwrite existing microservice/ folder')
2211
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2212
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2213
+ .action(async (options) => {
2214
+ try {
2215
+ const workspaceConfig = await WorkspaceConfig.load();
2216
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2217
+
2218
+ if (!ctx.communityId || !ctx.appId) {
2219
+ console.error(chalk.red('\nāŒ No app context found.'));
2220
+ console.log(chalk.gray(' cd into an app directory, or use -c and -a flags\n'));
2221
+ process.exit(1);
2222
+ }
2223
+
2224
+ const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
2225
+ if (!appConfig) {
2226
+ console.error(chalk.red('\nāŒ App not found in workspace.json.'));
2227
+ console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2228
+ process.exit(1);
2229
+ }
2230
+
2231
+ const appPath = appConfig.absolutePath ||
2232
+ path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
2233
+
2234
+ console.log(chalk.cyan('\nšŸ“ Adding microservice scaffold...\n'));
2235
+
2236
+ const { copyScaffold } = await import('../lib/core/Hydrator.js');
2237
+ const stats = await copyScaffold('microservice', appPath, {
2238
+ verbose: true,
2239
+ force: options.force
2240
+ });
2241
+
2242
+ // Configuration Injection
2243
+ const microserviceDir = path.join(appPath, 'microservice');
2244
+ const defaultsPath = path.join(microserviceDir, 'defaults-config.json');
2245
+ const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
2246
+
2247
+ // 1. Inject Context into defaults-config.json
2248
+ try {
2249
+ const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
2250
+ const defaults = JSON.parse(defaultsContent);
2251
+ defaults.community_id = ctx.communityId;
2252
+ defaults.app_id = ctx.appId;
2253
+ await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
2254
+ console.log(chalk.gray(` āœ“ Injected context into defaults-config.json`));
2255
+ } catch (err) {
2256
+ console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
2257
+ }
2258
+
2259
+ // 2. Inject Credentials into dev-overrides.json
2260
+ try {
2261
+ const apiClient = new DeSciXApiClient();
2262
+ const credentials = await apiClient.loadCredentials();
2263
+
2264
+ if (credentials) {
2265
+ const overrides = {
2266
+ CORE_API_URL: apiClient.baseUrl ? `${apiClient.baseUrl}/apifront` : undefined,
2267
+ OWNER_WALLET_ADDRESS: credentials.walletAddress,
2268
+ OWNER_SIGNATURE: credentials.signature,
2269
+ OWNER_USER_ID: credentials.userId
2270
+ };
2271
+
2272
+ // Merge with existing overrides if any
2273
+ let existingOverrides = {};
2274
+ try {
2275
+ const content = await fs.readFile(overridesPath, 'utf-8');
2276
+ existingOverrides = JSON.parse(content);
2277
+ } catch {}
2278
+
2279
+ await fs.writeFile(overridesPath, JSON.stringify({ ...existingOverrides, ...overrides }, null, 2));
2280
+ console.log(chalk.gray(` āœ“ Injected owner credentials into dev-overrides.json`));
2281
+ }
2282
+ } catch (err) {
2283
+ console.warn(chalk.yellow(` ⚠ Could not update dev-overrides.json: ${err.message}`));
2284
+ }
2285
+
2286
+ console.log(chalk.green(`\nāœ… Microservice scaffold added (${stats.copied} files)\n`));
2287
+ console.log(chalk.gray(' Next steps:'));
2288
+ console.log(chalk.gray(' - Edit manifest.json to define your commands'));
2289
+ console.log(chalk.gray(' - Implement handlers in services/commandHandlers/'));
2290
+ console.log(chalk.gray(' - Run descix microservice register to register with gateway\n'));
2291
+ } catch (error) {
2292
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
2293
+ process.exit(1);
2294
+ }
2295
+ });
2296
+
2297
+ // microservice register - Register with gateway
2298
+ microserviceCommand
2299
+ .command('register')
2300
+ .description('Register microservice with gateway (requires SERVICE_README)')
2301
+ .option('-m, --manifest <path>', 'Path to manifest.json', './manifest.json')
2302
+ .option('-r, --readme <path>', 'Path to local SERVICE_README file')
2303
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2304
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2305
+ .option('--skip-readme-check', 'Skip SERVICE_README check (not recommended)')
2306
+ .action(async (options) => {
2307
+ try {
2308
+ const apiClient = new DeSciXApiClient();
2309
+ await requireAuth(apiClient);
2310
+
2311
+ // Auto-detect context
2312
+ const workspaceConfig = await WorkspaceConfig.load();
2313
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2314
+
2315
+ // Read manifest.json
2316
+ const manifestPath = path.resolve(options.manifest);
2317
+
2318
+ let manifest;
2319
+ try {
2320
+ const manifestContent = await fs.readFile(manifestPath, 'utf-8');
2321
+ manifest = JSON.parse(manifestContent);
2322
+ } catch (error) {
2323
+ if (error.code === 'ENOENT') {
2324
+ console.error(chalk.red(`āŒ manifest.json not found at ${manifestPath}`));
2325
+ console.error(chalk.gray('\nCreate a manifest.json with your service configuration.'));
2326
+ console.error(chalk.gray('Use descix microservice init to scaffold a template.\n'));
2327
+ process.exit(1);
2328
+ }
2329
+ throw new Error(`Failed to parse manifest.json: ${error.message}`);
2330
+ }
2331
+
2332
+ // Validate required fields
2333
+ if (!manifest.service?.name) {
2334
+ throw new Error('manifest.service.name is required');
2335
+ }
2336
+
2337
+ // Use detected context if manifest fields are missing
2338
+ if (!manifest.service?.app_id && ctx.appId) {
2339
+ if (!manifest.service) manifest.service = {};
2340
+ manifest.service.app_id = ctx.appId;
2341
+ }
2342
+ if (!manifest.service?.community_id && ctx.communityId) {
2343
+ if (!manifest.service) manifest.service = {};
2344
+ manifest.service.community_id = ctx.communityId;
2345
+ }
2346
+
2347
+ if (!manifest.service?.app_id) {
2348
+ throw new Error('manifest.service.app_id is required (or use -a flag)');
2349
+ }
2350
+ if (!manifest.service?.community_id) {
2351
+ throw new Error('manifest.service.community_id is required (or use -c flag)');
2352
+ }
2353
+
2354
+ const serviceName = manifest.service?.name;
2355
+
2356
+ if (!serviceName) {
2357
+ throw new Error('manifest.service.name is required');
2358
+ }
2359
+
2360
+ const appId = manifest.service?.app_id;
2361
+ const communityId = manifest.service?.community_id;
2362
+ const readmeFileName = `SERVICE_README_${serviceName}.md`;
2363
+
2364
+ console.log(chalk.cyan(`\nšŸ“¦ Registering microservice: ${serviceName}\n`));
2365
+ console.log(chalk.gray(` Community: ${communityId}`));
2366
+ console.log(chalk.gray(` App: ${appId}`));
2367
+ console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}\n`));
2368
+
2369
+ // Option 1: Local README file provided via --readme
2370
+ if (options.readme) {
2371
+ const readmePath = path.resolve(options.readme);
2372
+ console.log(chalk.cyan(`šŸ“„ Reading local README: ${readmePath}\n`));
2373
+
2374
+ let readmeContent;
2375
+ try {
2376
+ readmeContent = await fs.readFile(readmePath, 'utf-8');
2377
+ } catch (error) {
2378
+ if (error.code === 'ENOENT') {
2379
+ console.error(chalk.red(`āŒ README file not found: ${readmePath}`));
2380
+ process.exit(1);
2381
+ }
2382
+ throw new Error(`Failed to read README: ${error.message}`);
2383
+ }
2384
+
2385
+ // Register with local README content for vectorization
2386
+ const response = await apiClient.invoke('register_service', {
2387
+ manifest,
2388
+ readme_content: readmeContent
2389
+ });
2390
+
2391
+ console.log(chalk.green('āœ… Microservice registered successfully!\n'));
2392
+ console.log(chalk.cyan(` Service: ${serviceName}`));
2393
+ console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2394
+ console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
2395
+
2396
+ // Option 2: Check for SERVICE_README in Drive folder
2397
+ } else if (!options.skipReadmeCheck) {
2398
+ console.log(chalk.cyan(`šŸ” Checking for ${readmeFileName} in app Drive folder...\n`));
2399
+
2400
+ const checkResponse = await apiClient.invoke('check_service_readme_exists', {
2401
+ community_id: communityId,
2402
+ app_id: appId,
2403
+ service_name: serviceName
2404
+ });
2405
+
2406
+ const readmeExists = checkResponse.message?.exists || checkResponse.exists || false;
2407
+
2408
+ if (!readmeExists) {
2409
+ // Output AI-friendly instructions for creating the README
2410
+ outputServiceReadmeInstructions(serviceName, appId, communityId, manifest);
2411
+ process.exit(1);
2412
+ }
2413
+
2414
+ console.log(chalk.green(`āœ… Found ${readmeFileName}\n`));
2415
+
2416
+ // Read README content from Drive
2417
+ const readmeResponse = await apiClient.invoke('get_service_readme_content', {
2418
+ community_id: communityId,
2419
+ app_id: appId,
2420
+ service_name: serviceName
2421
+ });
2422
+
2423
+ const readmeContent = readmeResponse.message?.content || readmeResponse.content || '';
2424
+
2425
+ // Register with README content for vectorization
2426
+ const response = await apiClient.invoke('register_service', {
2427
+ manifest,
2428
+ readme_content: readmeContent
2429
+ });
2430
+
2431
+ console.log(chalk.green('āœ… Microservice registered successfully!\n'));
2432
+ console.log(chalk.cyan(` Service: ${serviceName}`));
2433
+ console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2434
+ console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
2435
+
2436
+ // Option 3: Skip README check
2437
+ } else {
2438
+ // Skip README check - register without vectorization
2439
+ console.log(chalk.yellow('āš ļø Skipping README check (tool discovery will be limited)\n'));
2440
+
2441
+ const response = await apiClient.invoke('register_service', { manifest });
2442
+
2443
+ console.log(chalk.green('āœ… Microservice registered successfully!\n'));
2444
+ console.log(chalk.cyan(` Service: ${serviceName}`));
2445
+ console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2446
+ console.log(chalk.yellow(` āš ļø No README - service won't appear in tell_me_how results\n`));
2447
+ }
2448
+
2449
+ } catch (error) {
2450
+ console.error(chalk.red(`\nāŒ Registration failed: ${error.message}\n`));
2451
+ process.exit(1);
2452
+ }
2453
+ });
2454
+
2455
+ // microservice vectorize - Vectorize SERVICE_README (context-aware)
2456
+ microserviceCommand
2457
+ .command('vectorize')
2458
+ .description('Vectorize SERVICE_README for tell_me_how discovery')
2459
+ .option('-n, --name <name>', 'Service name (reads from manifest.json if not provided)')
2460
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2461
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2462
+ .option('-r, --readme <path>', 'Path to SERVICE_README file')
2463
+ .action(async (options) => {
2464
+ try {
2465
+ const apiClient = new DeSciXApiClient();
2466
+ await requireAuth(apiClient);
2467
+
2468
+ // Auto-detect context
2469
+ const workspaceConfig = await WorkspaceConfig.load();
2470
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2471
+
2472
+ let communityId = ctx.communityId;
2473
+ let appId = ctx.appId;
2474
+ let serviceName = options.name;
2475
+
2476
+ // Try to read from manifest.json if not provided
2477
+ if (!serviceName || !communityId || !appId) {
2478
+ try {
2479
+ // Try to find manifest.json in current or parent directories
2480
+ let manifestPath = './manifest.json';
2481
+
2482
+ // Check current directory first
2483
+ try {
2484
+ await fs.access(manifestPath);
2485
+ } catch {
2486
+ // If not in current directory, try to find the microservice path for detected context
2487
+ if (ctx.communityId && ctx.appId && workspaceConfig.workspaceRoot) {
2488
+ const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
2489
+ if (appConfig) {
2490
+ const microservicePath = path.join(
2491
+ workspaceConfig.workspaceRoot,
2492
+ appConfig.localPath,
2493
+ 'microservice'
2494
+ );
2495
+ manifestPath = path.join(microservicePath, 'manifest.json');
2496
+ }
2497
+ }
2498
+ }
2499
+
2500
+ // console.log(chalk.gray(` Reading manifest from: ${manifestPath}`));
2501
+ const manifestContent = await fs.readFile(manifestPath, 'utf-8');
2502
+ const manifest = JSON.parse(manifestContent);
2503
+ if (!serviceName) serviceName = manifest.service?.name;
2504
+ if (!communityId) communityId = manifest.service?.community_id || ctx.communityId;
2505
+ if (!appId) appId = manifest.service?.app_id || ctx.appId;
2506
+ } catch (e) {
2507
+ // console.log(chalk.gray(` Manifest not found or invalid: ${e.message}`));
2508
+ // manifest.json not found, use detected context
2509
+ if (!communityId) communityId = ctx.communityId;
2510
+ if (!appId) appId = ctx.appId;
2511
+ }
2512
+ }
2513
+
2514
+ if (!serviceName) {
2515
+ console.error(chalk.red('\nāŒ Service name required.'));
2516
+ console.log(chalk.gray(' Provide -n flag or ensure manifest.json exists in current directory.\n'));
2517
+ process.exit(1);
2518
+ }
2519
+
2520
+ if (!communityId || !appId) {
2521
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
2522
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory.\n'));
2523
+ process.exit(1);
2524
+ }
2525
+
2526
+ // Find README file
2527
+ let readmePath = options.readme;
2528
+ if (!readmePath) {
2529
+ // Try common locations
2530
+ const possiblePaths = [
2531
+ `./SERVICE_README_${serviceName}.md`,
2532
+ `./microservice/SERVICE_README_${serviceName}.md`,
2533
+ `./SERVICE_README.md`
2534
+ ];
2535
+ for (const p of possiblePaths) {
2536
+ try {
2537
+ await fs.access(p);
2538
+ readmePath = p;
2539
+ break;
2540
+ } catch {
2541
+ // Try next
2542
+ }
2543
+ }
2544
+ }
2545
+
2546
+ if (!readmePath) {
2547
+ console.error(chalk.red(`\nāŒ README file not found.`));
2548
+ console.log(chalk.gray(` Expected: SERVICE_README_${serviceName}.md`));
2549
+ console.log(chalk.gray(` Provide path with -r flag.\n`));
2550
+ process.exit(1);
2551
+ }
2552
+
2553
+ readmePath = path.resolve(readmePath);
2554
+ console.log(chalk.cyan(`\nšŸ“„ Vectorizing README for service: ${serviceName}\n`));
2555
+ console.log(chalk.gray(` Community: ${communityId}`));
2556
+ console.log(chalk.gray(` App: ${appId}`));
2557
+ console.log(chalk.gray(` README: ${readmePath}\n`));
2558
+
2559
+ let readmeContent;
2560
+ try {
2561
+ readmeContent = await fs.readFile(readmePath, 'utf-8');
2562
+ } catch (error) {
2563
+ if (error.code === 'ENOENT') {
2564
+ console.error(chalk.red(`āŒ README file not found: ${readmePath}`));
2565
+ process.exit(1);
2566
+ }
2567
+ throw new Error(`Failed to read README: ${error.message}`);
2568
+ }
2569
+
2570
+ const response = await apiClient.invoke('vectorize_service_readme', {
2571
+ service_name: serviceName,
2572
+ community_id: communityId,
2573
+ app_id: appId,
2574
+ readme_content: readmeContent
2575
+ });
2576
+
2577
+ // Handle wrapped response
2578
+ const result = response.message || response;
2579
+
2580
+ const isSuccess = result.status === 'OK' ||
2581
+ (typeof result === 'string' && result.includes('vectorized')) ||
2582
+ (result.message && result.message.includes('vectorized')) ||
2583
+ result.chunks_created !== undefined;
2584
+
2585
+ if (isSuccess) {
2586
+ let chunksCreated = result.chunks_created;
2587
+ if (!chunksCreated) {
2588
+ const msgStr = typeof result === 'string' ? result : result.message;
2589
+ const match = msgStr?.match(/(\d+) chunks/);
2590
+ if (match) chunksCreated = match[1];
2591
+ }
2592
+
2593
+ console.log(chalk.green('āœ… README vectorized successfully!\n'));
2594
+ console.log(chalk.gray(` Chunks created: ${chunksCreated || 'unknown'}`));
2595
+ console.log(chalk.gray(` Service will now appear in tell_me_how results\n`));
2596
+ } else {
2597
+ const errorMsg = result.message || result.error || JSON.stringify(result);
2598
+ console.error(chalk.red(`āŒ Vectorization failed: ${errorMsg}\n`));
2599
+ process.exit(1);
2600
+ }
2601
+
2602
+ } catch (error) {
2603
+ console.error(chalk.red(`\nāŒ Vectorization failed: ${error.message}\n`));
2604
+ process.exit(1);
2605
+ }
2606
+ });
2607
+
2608
+ // microservice list - List registered services
2609
+ microserviceCommand
2610
+ .command('list')
2611
+ .description('List all registered microservices')
2612
+ .action(async () => {
2613
+ try {
2614
+ const apiClient = new DeSciXApiClient();
2615
+ // list_services is public, no auth required
2616
+
2617
+ const response = await apiClient.invoke('list_services', {}, { allowGuest: true });
2618
+ const services = response.message?.services || response.services || [];
2619
+
2620
+ console.log(chalk.green(`\nāœ… Found ${services.length} registered microservices\n`));
2621
+
2622
+ if (services.length === 0) {
2623
+ console.log(chalk.gray(' No microservices registered yet.\n'));
2624
+ return;
2625
+ }
2626
+
2627
+ console.log(chalk.cyan('Microservices:'));
2628
+ services.forEach((s, idx) => {
2629
+ console.log(chalk.yellow(` ${idx + 1}. ${s.name} (v${s.version || '?'})`));
2630
+ console.log(chalk.gray(` Domain: ${s.domain}`));
2631
+ console.log(chalk.gray(` Commands: ${s.commandCount || 0}`));
2632
+ console.log(chalk.gray(` Status: ${s.status || 'unknown'}`));
2633
+ console.log();
2634
+ });
2635
+ } catch (error) {
2636
+ console.error(chalk.red(error.message));
2637
+ process.exit(1);
2638
+ }
2639
+ });
2640
+
2641
+ // microservice health - Check service health
2642
+ microserviceCommand
2643
+ .command('health <name>')
2644
+ .description('Check health of a registered microservice')
2645
+ .action(async (serviceName) => {
2646
+ try {
2647
+ const apiClient = new DeSciXApiClient();
2648
+
2649
+ const response = await apiClient.invoke('service_health_check', { service_name: serviceName }, { allowGuest: true });
2650
+ const result = response.message || response;
2651
+
2652
+ if (result.healthy) {
2653
+ console.log(chalk.green(`\nāœ… Microservice '${serviceName}' is healthy\n`));
2654
+ console.log(chalk.gray(` Status: ${result.statusCode}`));
2655
+ if (result.healthData) {
2656
+ console.log(chalk.gray(` Data: ${JSON.stringify(result.healthData)}`));
2657
+ }
2658
+ } else {
2659
+ console.log(chalk.yellow(`\nāš ļø Microservice '${serviceName}' is unhealthy\n`));
2660
+ console.log(chalk.gray(` Error: ${result.error || 'Unknown'}`));
2661
+ }
2662
+ console.log();
2663
+ } catch (error) {
2664
+ console.error(chalk.red(error.message));
2665
+ process.exit(1);
2666
+ }
2667
+ });
2668
+
2669
+ // microservice reload - Reload manifests (admin)
2670
+ microserviceCommand
2671
+ .command('reload')
2672
+ .description('Reload microservice manifests (admin only)')
2673
+ .action(async () => {
2674
+ try {
2675
+ const apiClient = new DeSciXApiClient();
2676
+ await requireAuth(apiClient);
2677
+
2678
+ console.log(chalk.cyan('\nšŸ”„ Reloading microservice manifests...\n'));
2679
+
2680
+ const response = await apiClient.invoke('reload_service_manifests', {});
2681
+ const result = response.message || response;
2682
+
2683
+ console.log(chalk.green(`āœ… ${result}\n`));
2684
+
2685
+ if (response.services) {
2686
+ console.log(chalk.gray(` Services: ${response.services.join(', ')}\n`));
2687
+ }
2688
+ } catch (error) {
2689
+ console.error(chalk.red(error.message));
2690
+ process.exit(1);
2691
+ }
2692
+ });
2693
+
2694
+ // microservice register-delegate - Register delegate key
2695
+ microserviceCommand
2696
+ .command('register-delegate')
2697
+ .description('Register service delegate key for Intelligent Mesh')
2698
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2699
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
2700
+ .option('-s, --slot <id>', 'Service slot ID (uses first available if not provided)')
2701
+ .action(async (options) => {
2702
+ try {
2703
+ const apiClient = new DeSciXApiClient();
2704
+ await requireAuth(apiClient);
2705
+
2706
+ // Auto-detect context
2707
+ const workspaceConfig = await WorkspaceConfig.load();
2708
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
2709
+
2710
+ let communityId = ctx.communityId;
2711
+ let appId = ctx.appId;
2712
+
2713
+ // Try manifest.json if not detected
2714
+ if (!communityId || !appId) {
2715
+ try {
2716
+ const manifestContent = await fs.readFile('./manifest.json', 'utf-8');
2717
+ const manifest = JSON.parse(manifestContent);
2718
+ if (!communityId) communityId = manifest.service?.community_id;
2719
+ if (!appId) appId = manifest.service?.app_id;
2720
+ } catch {
2721
+ // Not found
2722
+ }
2723
+ }
2724
+
2725
+ if (!communityId || !appId) {
2726
+ console.error(chalk.red('\nāŒ Community and App ID required.'));
2727
+ console.log(chalk.gray(' Either provide -c and -a flags, cd into an app directory, or have manifest.json present.\n'));
2728
+ process.exit(1);
2729
+ }
2730
+
2731
+ console.log(chalk.cyan('\nšŸ”‘ Registering Service Delegate Key\n'));
2732
+
2733
+ // Fetch entitlements
2734
+ const entitlementsResponse = await apiClient.invoke('fetch_my_purchases', {});
2735
+ const entitlements = entitlementsResponse.message || {};
2736
+ const serviceSlots = entitlements.service_slots || [];
2737
+
2738
+ if (serviceSlots.length === 0) {
2739
+ console.error(chalk.red('āŒ No service slots available.'));
2740
+ console.log(chalk.white(' You need a "Runner NFT" or Subscription to create a service.'));
2741
+ process.exit(1);
2742
+ }
2743
+
2744
+ // Select slot
2745
+ let selectedSlot = serviceSlots[0];
2746
+ if (options.slot) {
2747
+ selectedSlot = serviceSlots.find(s => (s.nft_id || s.id) === options.slot);
2748
+ if (!selectedSlot) {
2749
+ console.error(chalk.red(`āŒ Slot ${options.slot} not found in your entitlements.`));
2750
+ process.exit(1);
2751
+ }
2752
+ }
2753
+
2754
+ console.log(chalk.gray(` Selected slot: ${selectedSlot.name} (${selectedSlot.type})\n`));
2755
+
2756
+ // Generate key pair
2757
+ const crypto = await import('crypto');
2758
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
2759
+ namedCurve: 'secp256k1',
2760
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
2761
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
2762
+ });
2763
+
2764
+ console.log(chalk.gray(' Generating key pair...'));
2765
+
2766
+ // Register with Virtual Registry
2767
+ const registerResponse = await apiClient.invoke('register_delegate', {
2768
+ slot_id: selectedSlot.nft_id || selectedSlot.id,
2769
+ slot_type: selectedSlot.type,
2770
+ public_key: publicKey,
2771
+ app_id: appId,
2772
+ community_id: communityId
2773
+ });
2774
+
2775
+ console.log(chalk.green(' āœ“ Delegate registered with Core\n'));
2776
+
2777
+ // Save to dev-overrides.json
2778
+ const overridesPath = path.resolve(process.cwd(), 'dev-overrides.json');
2779
+ let overrides = {};
2780
+
2781
+ try {
2782
+ const content = await fs.readFile(overridesPath, 'utf-8');
2783
+ overrides = JSON.parse(content);
2784
+ } catch (e) {
2785
+ // File doesn't exist, start fresh
2786
+ }
2787
+
2788
+ overrides.SERVICE_KEY = {
2789
+ privateKey,
2790
+ publicKey,
2791
+ slotId: selectedSlot.nft_id || selectedSlot.id,
2792
+ slotType: selectedSlot.type,
2793
+ appId: appId,
2794
+ communityId: communityId,
2795
+ createdAt: new Date().toISOString()
2796
+ };
2797
+
2798
+ await fs.writeFile(overridesPath, JSON.stringify(overrides, null, 2));
2799
+ console.log(chalk.green(`āœ… Key saved to dev-overrides.json\n`));
2800
+ console.log(chalk.gray(` Path: ${overridesPath}\n`));
2801
+
2802
+ } catch (error) {
2803
+ console.error(chalk.red(`\nāŒ Error: ${error.message}\n`));
2804
+ process.exit(1);
2805
+ }
2806
+ });
2807
+
2808
+ // ============ Role Commands ============
2809
+
2810
+ const roleCommand = program
2811
+ .command('role')
2812
+ .description('Role management operations');
2813
+
2814
+ roleCommand
2815
+ .command('create')
2816
+ .description('Create a new role')
2817
+ .requiredOption('--scope <scope>', 'Scope: PLATFORM, COMMUNITY, or APP')
2818
+ .requiredOption('--scope-id <id>', 'Scope ID (community_id or app_id)')
2819
+ .requiredOption('--role-id <id>', 'Role ID (e.g., "member", "admin")')
2820
+ .requiredOption('--name <name>', 'Role display name')
2821
+ .option('--description <text>', 'Role description')
2822
+ .option('--price-usdsci <amount>', 'Price in USDCX (default: 0 = free)')
2823
+ .action(async (options) => {
2824
+ try {
2825
+ const apiClient = new DeSciXApiClient();
2826
+ await requireAuth(apiClient);
2827
+
2828
+ const price = options.priceUsdsci ? parseFloat(options.priceUsdsci) : 0;
2829
+ if (isNaN(price) || price < 0) {
2830
+ throw new Error('Price must be a non-negative number');
2831
+ }
2832
+
2833
+ const response = await apiClient.invoke('create_role', {
2834
+ scope: options.scope,
2835
+ scope_id: options.scopeId,
2836
+ role_id: options.roleId,
2837
+ role_name: options.name,
2838
+ role_description: options.description || '',
2839
+ price_usdsci: price
2840
+ });
2841
+ const result = response.message || response;
2842
+
2843
+ console.log(chalk.green('\nāœ… Role created successfully!\n'));
2844
+ console.log(chalk.cyan(` Role ID: ${options.roleId}`));
2845
+ console.log(chalk.gray(` Scope: ${options.scope}/${options.scopeId}`));
2846
+ console.log(chalk.gray(` Price: ${price} USDCX\n`));
2847
+ } catch (error) {
2848
+ console.error(chalk.red(error.message));
2849
+ process.exit(1);
2850
+ }
2851
+ });
2852
+
2853
+ // ============ Incentives Commands ============
2854
+
2855
+ const referralCommand = program
2856
+ .command('referral')
2857
+ .description('Referral and incentive operations');
2858
+
2859
+ referralCommand
2860
+ .command('create')
2861
+ .description('Generate a referral code for a community/app')
2862
+ .requiredOption('-c, --community <id>', 'Community ID')
2863
+ .option('-a, --app <id>', 'App ID (optional)')
2864
+ .action(async (options) => {
2865
+ try {
2866
+ const apiClient = new DeSciXApiClient();
2867
+ await requireAuth(apiClient);
2868
+
2869
+ const response = await apiClient.invoke('generate_referral_code', {
2870
+ community_id: options.community,
2871
+ app_id: options.app || null
2872
+ });
2873
+ const result = response.message || response;
2874
+
2875
+ console.log(chalk.green('\nāœ… Referral code generated!\n'));
2876
+ console.log(chalk.cyan(` Code: ${result.referral_code || result.code}`));
2877
+ console.log(chalk.gray(` Community: ${options.community}`));
2878
+ if (options.app) {
2879
+ console.log(chalk.gray(` App: ${options.app}`));
2880
+ }
2881
+ console.log(chalk.gray(`\n Share this code to earn REF points!\n`));
2882
+ } catch (error) {
2883
+ console.error(chalk.red(error.message));
2884
+ process.exit(1);
2885
+ }
2886
+ });
2887
+
2888
+ program
2889
+ .command('stats')
2890
+ .description('Get user stats (REP/REF/DIP) for a community')
2891
+ .requiredOption('-c, --community <id>', 'Community ID')
2892
+ .action(async (options) => {
2893
+ try {
2894
+ const apiClient = new DeSciXApiClient();
2895
+ await requireAuth(apiClient);
2896
+
2897
+ const response = await apiClient.invoke('get_user_community_stats', {
2898
+ community_id: options.community
2899
+ });
2900
+ const result = response.message || response;
2901
+
2902
+ console.log(chalk.green('\nāœ… User Stats\n'));
2903
+ console.log(chalk.cyan(` Community: ${options.community}`));
2904
+ console.log(chalk.gray(` REP: ${result.rep || 0}`));
2905
+ console.log(chalk.gray(` REF: ${result.ref || 0}`));
2906
+ console.log(chalk.gray(` DIP: ${result.dip || 0}\n`));
2907
+ } catch (error) {
2908
+ console.error(chalk.red(error.message));
2909
+ process.exit(1);
2910
+ }
2911
+ });
2912
+
2913
+ const repCommand = program
2914
+ .command('rep')
2915
+ .description('Reputation operations');
2916
+
2917
+ repCommand
2918
+ .command('grant')
2919
+ .description('Grant REP points to a user (admin/dev only)')
2920
+ .requiredOption('-c, --community <id>', 'Community ID')
2921
+ .requiredOption('-u, --user <id>', 'User ID')
2922
+ .requiredOption('-a, --amount <amount>', 'REP amount to grant')
2923
+ .option('--reason <text>', 'Reason for granting REP')
2924
+ .action(async (options) => {
2925
+ try {
2926
+ const apiClient = new DeSciXApiClient();
2927
+ await requireAuth(apiClient);
2928
+
2929
+ const amount = parseInt(options.amount);
2930
+ if (isNaN(amount) || amount <= 0) {
2931
+ throw new Error('Amount must be a positive integer');
2932
+ }
2933
+
2934
+ const response = await apiClient.invoke('increment_rep', {
2935
+ community_id: options.community,
2936
+ user_id: options.user,
2937
+ amount: amount,
2938
+ reason: options.reason || 'CLI grant'
2939
+ });
2940
+ const result = response.message || response;
2941
+
2942
+ console.log(chalk.green('\nāœ… REP granted!\n'));
2943
+ console.log(chalk.cyan(` User: ${options.user}`));
2944
+ console.log(chalk.gray(` Community: ${options.community}`));
2945
+ console.log(chalk.gray(` Amount: +${amount} REP`));
2946
+ console.log(chalk.gray(` New Total: ${result.new_total || result.rep || 'N/A'}\n`));
2947
+ } catch (error) {
2948
+ console.error(chalk.red(error.message));
2949
+ process.exit(1);
2950
+ }
2951
+ });
2952
+
2953
+ // ============ Chat Session Management ============
2954
+
2955
+ /**
2956
+ * Get interaction_id from session file
2957
+ * @param {string} communityId
2958
+ * @param {string} appId
2959
+ * @returns {Promise<string|null>}
2960
+ */
2961
+ async function getSessionInteractionId(communityId, appId) {
2962
+ const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
2963
+ try {
2964
+ const data = JSON.parse(await fs.readFile(sessionPath, 'utf-8'));
2965
+ return data.interaction_id || null;
2966
+ } catch {
2967
+ return null;
2968
+ }
2969
+ }
2970
+
2971
+ /**
2972
+ * Save interaction_id to session file
2973
+ * @param {string} communityId
2974
+ * @param {string} appId
2975
+ * @param {string} interactionId
2976
+ */
2977
+ async function saveSessionInteractionId(communityId, appId, interactionId) {
2978
+ const sessionDir = path.join(os.homedir(), '.descix', 'sessions');
2979
+ await fs.mkdir(sessionDir, { recursive: true });
2980
+ const sessionPath = path.join(sessionDir, `${communityId}_${appId}.json`);
2981
+ await fs.writeFile(sessionPath, JSON.stringify({
2982
+ interaction_id: interactionId,
2983
+ updated: Date.now()
2984
+ }, null, 2));
2985
+ }
2986
+
2987
+ /**
2988
+ * Clear session file
2989
+ * @param {string} communityId
2990
+ * @param {string} appId
2991
+ */
2992
+ async function clearSession(communityId, appId) {
2993
+ const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
2994
+ try {
2995
+ await fs.unlink(sessionPath);
2996
+ } catch {
2997
+ // File doesn't exist, that's fine
2998
+ }
2999
+ }
3000
+
3001
+ // ============ Chat Commands ============
3002
+
3003
+ program
3004
+ .command('chat [question...]')
3005
+ .description('Chat with an app agent. Usage: descix chat "Your question" or descix chat -q "Your question"')
3006
+ .option('-c, --community <id>', 'Community ID (defaults to descix)')
3007
+ .option('-a, --app <id>', 'App ID (defaults to agent)')
3008
+ .option('-q, --question <text>', 'Question to ask (alternative to positional argument)')
3009
+ .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
3010
+ .option('--new', 'Start a new conversation (clear session)')
3011
+ .action(async (questionArgs, options) => {
3012
+ try {
3013
+ const apiClient = new DeSciXApiClient();
3014
+ await requireAuth(apiClient);
3015
+
3016
+ // Get question from positional args or -q option
3017
+ const question = options.question || (questionArgs && questionArgs.join(' '));
3018
+ if (!question) {
3019
+ console.error(chalk.red('Error: Question required. Usage: descix chat "Your question" or descix chat -q "Your question"'));
3020
+ process.exit(1);
3021
+ }
3022
+
3023
+ // Auto-detect app from current directory if not specified
3024
+ let communityId = options.community;
3025
+ let appId = options.app;
3026
+
3027
+ if (!communityId || !appId) {
3028
+ try {
3029
+ const { PathContext } = await import('../lib/core/PathContext.js');
3030
+ const ctx = await PathContext.tryLoad();
3031
+ if (ctx) {
3032
+ const detected = ctx.detectContext();
3033
+ if (detected) {
3034
+ communityId = communityId || detected.communityId;
3035
+ appId = appId || detected.appId;
3036
+ }
3037
+ }
3038
+ } catch {
3039
+ // Fall through to defaults
3040
+ }
3041
+ }
3042
+
3043
+ // Fall back to defaults if still not set
3044
+ communityId = communityId || 'descix';
3045
+ appId = appId || 'agent';
3046
+
3047
+ // Get session interaction_id unless --new flag
3048
+ let previousInteractionId = null;
3049
+ if (!options.new) {
3050
+ previousInteractionId = await getSessionInteractionId(communityId, appId);
3051
+ } else {
3052
+ // Clear session if --new flag
3053
+ await clearSession(communityId, appId);
3054
+ }
3055
+
3056
+ console.log(chalk.gray(`Asking ${communityId}/${appId}...`));
3057
+
3058
+ const response = await apiClient.invoke('ask_question_to_app', {
3059
+ app_id: appId,
3060
+ knowledgebase_name: options.kb,
3061
+ user_input: question,
3062
+ previous_interaction_id: previousInteractionId,
3063
+ streaming: false
3064
+ });
3065
+ const result = response.message || response;
3066
+
3067
+ // Save new interaction_id for next message
3068
+ if (result.interaction_id) {
3069
+ await saveSessionInteractionId(communityId, appId, result.interaction_id);
3070
+ }
3071
+
3072
+ console.log(chalk.green('\nāœ… Response:\n'));
3073
+ console.log(chalk.white(result.response || result.text || JSON.stringify(result, null, 2)));
3074
+
3075
+ const sources = result.sources || response.sources || response.message?.sources || [];
3076
+ if (sources && sources.length > 0) {
3077
+ console.log(chalk.cyan('\nšŸ“š Sources:'));
3078
+ sources.forEach((src, i) => {
3079
+ const fileName = src.fileName || src.file_path || src.source || src;
3080
+ const fileId = src.fileId || src.id || '';
3081
+ const score = src.score || src.similarity || 0;
3082
+ const scoreStr = score ? ` (score: ${score.toFixed(3)})` : '';
3083
+ const idStr = fileId ? ` [ID: ${fileId}]` : '';
3084
+ console.log(chalk.gray(` ${i + 1}. ${fileName}${idStr}${scoreStr}`));
3085
+ });
3086
+ }
3087
+ console.log('');
3088
+ } catch (error) {
3089
+ console.error(chalk.red(error.message));
3090
+ process.exit(1);
3091
+ }
3092
+ });
3093
+
3094
+ program
3095
+ .command('new-chat')
3096
+ .description('Clear chat session for an app (start fresh conversation)')
3097
+ .option('-c, --community <id>', 'Community ID (defaults to descix)')
3098
+ .option('-a, --app <id>', 'App ID (defaults to agent)')
3099
+ .action(async (options) => {
3100
+ try {
3101
+ const communityId = options.community || 'descix';
3102
+ const appId = options.app || 'agent';
3103
+ await clearSession(communityId, appId);
3104
+ console.log(chalk.green(`Chat session cleared for ${communityId}/${appId}. Next chat will start fresh.`));
3105
+ } catch (error) {
3106
+ console.error(chalk.red(error.message));
3107
+ process.exit(1);
3108
+ }
3109
+ });
3110
+
3111
+ // ============ Purchases Command ============
3112
+
3113
+ program
3114
+ .command('purchases')
3115
+ .description('List your purchased communities and apps')
3116
+ .action(async () => {
3117
+ try {
3118
+ const apiClient = new DeSciXApiClient();
3119
+ await requireAuth(apiClient);
3120
+
3121
+ const response = await apiClient.invoke('fetch_my_purchases', {});
3122
+ const result = response.message || response;
3123
+
3124
+ const communities = result.communities || [];
3125
+ const apps = result.apps || [];
3126
+
3127
+ console.log(chalk.green('\nāœ… Your Purchases:\n'));
3128
+ console.log(chalk.cyan(`Communities (${communities.length}):`));
3129
+ communities.forEach((c, idx) => {
3130
+ console.log(chalk.yellow(` ${idx + 1}. ${c.community_name} (${c.community_id})`));
3131
+ });
3132
+ console.log();
3133
+ console.log(chalk.cyan(`Apps (${apps.length}):`));
3134
+ apps.forEach((a, idx) => {
3135
+ console.log(chalk.yellow(` ${idx + 1}. ${a.app_name || a.app_id} (${a.community_id}/${a.app_id})`));
3136
+ });
3137
+ console.log();
3138
+ } catch (error) {
3139
+ console.error(chalk.red(error.message));
3140
+ process.exit(1);
3141
+ }
3142
+ });
3143
+
3144
+ // ============ Configuration Commands ============
3145
+
3146
+ const configCommand = program
3147
+ .command('config')
3148
+ .description('Manage CLI configuration');
3149
+
3150
+ configCommand
3151
+ .command('show')
3152
+ .description('Show current configuration')
3153
+ .action(async () => {
3154
+ try {
3155
+ await configCommands.show();
3156
+ } catch (error) {
3157
+ process.exit(1);
3158
+ }
3159
+ });
3160
+
3161
+ configCommand
3162
+ .command('set-url')
3163
+ .description('Set API URL')
3164
+ .argument('<url>', 'API URL (e.g., https://localhost:4000)')
3165
+ .option('-g, --global', 'Save to global config (~/.descixrc)')
3166
+ .action(async (url, options) => {
3167
+ try {
3168
+ await configCommands.setUrl(url, options);
3169
+ } catch (error) {
3170
+ process.exit(1);
3171
+ }
3172
+ });
3173
+
3174
+ configCommand
3175
+ .command('init')
3176
+ .description('Initialize configuration')
3177
+ .option('--dev', 'Initialize for development (https://localhost:4000)')
3178
+ .option('-g, --global', 'Save to global config (~/.descixrc)')
3179
+ .action(async (options) => {
3180
+ try {
3181
+ const env = options.dev ? 'dev' : 'prod';
3182
+ await configCommands.init(env, options);
3183
+ } catch (error) {
3184
+ process.exit(1);
3185
+ }
3186
+ });
3187
+
3188
+ configCommand
3189
+ .command('set-sync-mode')
3190
+ .description('Set KB sync mode for an app (git or drive)')
3191
+ .argument('<mode>', 'Sync mode: "git" (local-first) or "drive" (PWA-first)')
3192
+ .option('-c, --community <id>', 'Community ID')
3193
+ .option('-a, --app <id>', 'App ID')
3194
+ .action(async (mode, options) => {
3195
+ try {
3196
+ await configCommands.setSyncMode(mode, options);
3197
+ } catch (error) {
3198
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
3199
+ process.exit(1);
3200
+ }
3201
+ });
3202
+
3203
+ // ============ Documentation Commands ============
3204
+
3205
+ const docsCommand = program
3206
+ .command('docs')
3207
+ .description('Documentation operations');
3208
+
3209
+ docsCommand
3210
+ .command('sync')
3211
+ .description('Sync README/ folder to platform-docs KB for RAG search')
3212
+ .option('--path <path>', 'Path to README folder (default: ./README)', './README')
3213
+ .action(async (options) => {
3214
+ try {
3215
+ const apiClient = new DeSciXApiClient();
3216
+ await requireAuth(apiClient);
3217
+
3218
+ const fs = await import('fs/promises');
3219
+ const path = await import('path');
3220
+
3221
+ const readmePath = path.resolve(options.path || './README');
3222
+
3223
+ console.log(chalk.cyan(`\nšŸ“š Syncing documentation to platform-docs KB\n`));
3224
+ console.log(chalk.gray(`Reading from: ${readmePath}\n`));
3225
+
3226
+ // Check if README folder exists
3227
+ try {
3228
+ const stats = await fs.stat(readmePath);
3229
+ if (!stats.isDirectory()) {
3230
+ throw new Error(`Path is not a directory: ${readmePath}`);
3231
+ }
3232
+ } catch (error) {
3233
+ if (error.code === 'ENOENT') {
3234
+ throw new Error(`README folder not found: ${readmePath}`);
3235
+ }
3236
+ throw error;
3237
+ }
3238
+
3239
+ // Recursively read all .md files
3240
+ const files = [];
3241
+ async function readDir(dirPath, basePath = '') {
3242
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
3243
+ for (const entry of entries) {
3244
+ const fullPath = path.join(dirPath, entry.name);
3245
+ const relPath = path.join(basePath, entry.name);
3246
+
3247
+ if (entry.isDirectory()) {
3248
+ // Skip node_modules and .git
3249
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
3250
+ continue;
3251
+ }
3252
+ await readDir(fullPath, relPath);
3253
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
3254
+ const content = await fs.readFile(fullPath, 'utf-8');
3255
+ files.push({
3256
+ name: entry.name,
3257
+ path: relPath,
3258
+ content: Buffer.from(content).toString('base64') // Base64 encode
3259
+ });
3260
+ }
3261
+ }
3262
+ }
3263
+
3264
+ await readDir(readmePath);
3265
+
3266
+ if (files.length === 0) {
3267
+ throw new Error('No .md files found in README folder');
3268
+ }
3269
+
3270
+ console.log(chalk.gray(`Found ${files.length} markdown files. Uploading...\n`));
3271
+
3272
+ // Upload files to platform-docs KB
3273
+ const response = await apiClient.invoke('upload_files_to_kb', {
3274
+ community_id: 'descix',
3275
+ app_id: 'platform-docs',
3276
+ kb_id: 'General',
3277
+ files: files
3278
+ });
3279
+ const result = response.message || response;
3280
+
3281
+ console.log(chalk.green(`\nāœ… Uploaded ${result.uploaded || 0} files\n`));
3282
+
3283
+ // Sync to RAG
3284
+ console.log(chalk.gray('Syncing to RAG vectors...\n'));
3285
+ const syncResponse = await apiClient.invoke('sync_knowledge_base', {
3286
+ community_id: 'descix',
3287
+ app_id: 'platform-docs',
3288
+ kb_id: 'General'
3289
+ });
3290
+ const syncResult = syncResponse.message || syncResponse;
3291
+
3292
+ console.log(chalk.green('āœ… Documentation synced to RAG!\n'));
3293
+ console.log(chalk.cyan(` Total Vectors: ${syncResult.vectorCount || result.totalVectors || 0}\n`));
3294
+
3295
+ if (result.errors && result.errors.length > 0) {
3296
+ console.log(chalk.yellow(' Errors:'));
3297
+ result.errors.forEach(err => {
3298
+ console.log(chalk.red(` - ${err.file}: ${err.error}`));
3299
+ });
3300
+ }
3301
+
3302
+ console.log(chalk.gray('\nQuery documentation with: descix rag query -c descix -a platform-docs -k General -q "your question"\n'));
3303
+
3304
+ } catch (error) {
3305
+ console.error(chalk.red(error.message));
3306
+ process.exit(1);
3307
+ }
3308
+ });
3309
+
3310
+ // ============ Store Search Commands ============
3311
+
3312
+ const storeCommand = program
3313
+ .command('store')
3314
+ .description('Store search operations');
3315
+
3316
+ storeCommand
3317
+ .command('search')
3318
+ .description('Search store catalog (communities, apps, products, documents)')
3319
+ .option('-q, --query <text>', 'Search query')
3320
+ .option('-t, --type <types>', 'Entity types (comma-separated: COMMUNITY,APP,KB,IPDOC,ROLE)')
3321
+ .option('-c, --community <id>', 'Filter by community ID')
3322
+ .option('-a, --app <id>', 'Filter by app ID')
3323
+ .option('-k, --kb <id>', 'Filter by knowledge base ID')
3324
+ .option('--price-min <number>', 'Minimum price filter')
3325
+ .option('--price-max <number>', 'Maximum price filter')
3326
+ .option('--tags <tags>', 'Filter by tags (comma-separated)')
3327
+ .option('-l, --limit <number>', 'Number of results', '20')
3328
+ .option('--offset <number>', 'Pagination offset', '0')
3329
+ .action(async (options) => {
3330
+ try {
3331
+ const apiClient = new DeSciXApiClient();
3332
+ await requireAuth(apiClient);
3333
+
3334
+ const params = {
3335
+ query: options.query || '',
3336
+ entity_types: options.type ? options.type.split(',').map(t => t.trim()) : [],
3337
+ community_id: options.community || null,
3338
+ app_id: options.app || null,
3339
+ kb_id: options.kb || null,
3340
+ price_min: options.priceMin ? parseFloat(options.priceMin) : null,
3341
+ price_max: options.priceMax ? parseFloat(options.priceMax) : null,
3342
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : [],
3343
+ limit: parseInt(options.limit),
3344
+ offset: parseInt(options.offset)
3345
+ };
3346
+
3347
+ const response = await apiClient.invoke('search_store', params);
3348
+ const result = response.message || response;
3349
+
3350
+ console.log(chalk.green(`\nāœ… Found ${result.total || result.results?.length || 0} results\n`));
3351
+
3352
+ if (result.query) {
3353
+ console.log(chalk.cyan(`Query: "${result.query}"\n`));
3354
+ }
3355
+
3356
+ (result.results || []).forEach((r, idx) => {
3357
+ const num = idx + 1 + parseInt(options.offset);
3358
+ console.log(chalk.yellow(`${num}. [${r.entity_type}] ${r.name}`));
3359
+ if (r.description) {
3360
+ console.log(chalk.gray(` ${r.description.substring(0, 150)}...`));
3361
+ }
3362
+ if (r.price !== undefined && r.price > 0) {
3363
+ console.log(chalk.cyan(` Price: ${r.price} USDCX`));
3364
+ }
3365
+ if (r.community_id) {
3366
+ console.log(chalk.gray(` Community: ${r.community_id}`));
3367
+ }
3368
+ if (r.app_id) {
3369
+ console.log(chalk.gray(` App: ${r.community_id}/${r.app_id}`));
3370
+ }
3371
+ if (r.score !== undefined) {
3372
+ console.log(chalk.gray(` Score: ${(r.score * 100).toFixed(1)}%`));
3373
+ }
3374
+ console.log();
3375
+ });
3376
+ } catch (error) {
3377
+ console.error(chalk.red(error.message));
3378
+ process.exit(1);
3379
+ }
3380
+ });
3381
+
3382
+ storeCommand
3383
+ .command('generate-summary')
3384
+ .description('Generate AI summary for an entity')
3385
+ .requiredOption('-t, --type <type>', 'Entity type (COMMUNITY, APP, KB, IPDOC)')
3386
+ .requiredOption('-i, --id <id>', 'Entity ID')
3387
+ .option('-c, --community <id>', 'Community ID (required for APP, KB, IPDOC)')
3388
+ .option('-a, --app <id>', 'App ID (required for KB, IPDOC)')
3389
+ .option('-k, --kb <id>', 'KB ID (required for IPDOC)')
3390
+ .action(async (options) => {
3391
+ try {
3392
+ const apiClient = new DeSciXApiClient();
3393
+ await requireAuth(apiClient);
3394
+
3395
+ const params = {
3396
+ entity_type: options.type,
3397
+ entity_id: options.id,
3398
+ community_id: options.community || null,
3399
+ app_id: options.app || null,
3400
+ kb_id: options.kb || null
3401
+ };
3402
+
3403
+ console.log(chalk.gray(`Generating summary for ${options.type} ${options.id}...`));
3404
+
3405
+ const response = await apiClient.invoke('generate_entity_summary', params);
3406
+ const result = response.message || response;
3407
+
3408
+ console.log(chalk.green('\nāœ… Summary generated!\n'));
3409
+ console.log(chalk.cyan(`Entity: ${result.entity_name} (${result.entity_type})\n`));
3410
+ console.log(chalk.white(result.summary));
3411
+ console.log();
3412
+ } catch (error) {
3413
+ console.error(chalk.red(error.message));
3414
+ process.exit(1);
3415
+ }
3416
+ });
3417
+
3418
+ /**
3419
+ * Output AI-friendly instructions for creating SERVICE_README
3420
+ * Designed for both human developers and AI agents (like Cursor) to use directly
3421
+ */
3422
+ function outputServiceReadmeInstructions(serviceName, appId, communityId, manifest) {
3423
+ const readmeFileName = `SERVICE_README_${serviceName}.md`;
3424
+ const commands = Object.entries(manifest.commands || {});
3425
+
3426
+ // Generate command documentation from manifest
3427
+ let commandDocs = '';
3428
+ for (const [cmdName, cmdConfig] of commands) {
3429
+ const params = cmdConfig.inputSchema?.properties || {};
3430
+ const required = cmdConfig.inputSchema?.required || [];
3431
+
3432
+ commandDocs += `### ${cmdName}
3433
+ **Description:** ${cmdConfig.description || 'No description provided'}
3434
+ **Use when:** [Describe scenarios when this command should be used]
3435
+ **Prerequisites:** ${cmdConfig.guestAllowed ? 'None (guest allowed)' : 'User must be authenticated'}
3436
+ **Parameters:**
3437
+ `;
3438
+ for (const [paramName, paramConfig] of Object.entries(params)) {
3439
+ const isRequired = required.includes(paramName);
3440
+ commandDocs += `- \`${paramName}\` (${isRequired ? 'required' : 'optional'}, ${paramConfig.type || 'any'}): ${paramConfig.description || 'No description'}\n`;
3441
+ }
3442
+
3443
+ commandDocs += `
3444
+ **Example:**
3445
+ \`\`\`json
3446
+ {
3447
+ "command": "${cmdName}",
3448
+ "params": { ${required.map(r => `"${r}": "value"`).join(', ')} }
3449
+ }
3450
+ \`\`\`
3451
+
3452
+ `;
3453
+ }
3454
+
3455
+ console.log(chalk.red(`
3456
+ ╔════════════════════════════════════════════════════════════════════╗
3457
+ ā•‘ SERVICE_README Required ā•‘
3458
+ ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•
3459
+ `));
3460
+
3461
+ console.log(chalk.white(`Your microservice needs a ${readmeFileName} file for tool discovery.
3462
+
3463
+ šŸ“ Required file: ${readmeFileName}
3464
+ šŸ“ Location: Google Drive folder for app '${appId}' in community '${communityId}'
3465
+
3466
+ This README is vectorized and used by the \`tell_me_how\` command to help
3467
+ users and AI agents discover your service's capabilities.
3468
+
3469
+ To create this file, use the following template:
3470
+ `));
3471
+
3472
+ console.log(chalk.yellow(`---BEGIN TEMPLATE---`));
3473
+ console.log(chalk.white(`# ${serviceName} Service
3474
+
3475
+ ## Overview
3476
+ [One paragraph description of what this service does and when to use it.
3477
+ Include the key use cases and benefits.]
3478
+
3479
+ ## Available Commands
3480
+
3481
+ ${commandDocs || '### [command_name]\n**Description:** [what this command does]\n**Use when:** [scenarios]\n**Prerequisites:** [requirements]\n**Parameters:**\n- \\`param1\\` (required, string): [description]\n\n**Example:**\n\\`\\`\\`json\n{\n "command": "[command_name]",\n "params": { "param1": "value" }\n}\n\\`\\`\\`\n'}
3482
+ ## Common Workflows
3483
+
3484
+ [Describe how commands work together for common tasks]
3485
+
3486
+ ## Troubleshooting
3487
+
3488
+ [Common errors and how to resolve them]
3489
+ `));
3490
+ console.log(chalk.yellow(`---END TEMPLATE---`));
3491
+
3492
+ console.log(chalk.white(`
3493
+ After creating the file:
3494
+
3495
+ 1. Upload ${readmeFileName} to the app's Drive folder
3496
+ Location: Your Drive > [DeSciX folder] > ${communityId} > ${appId}/
3497
+
3498
+ 2. Re-run this command:
3499
+ descix microservice register -m ${manifest ? './manifest.json' : '<path-to-manifest>'}
3500
+
3501
+ šŸ’” Tip: Your AI coding assistant (like Cursor) can create this file for you
3502
+ using the template above. Just ask it to "create the SERVICE_README file".
3503
+ `));
3504
+ }
3505
+
3506
+ // ============ Clone Command ============
3507
+
3508
+ program
3509
+ .command('clone')
3510
+ .description('Clone a DeSciX app repository')
3511
+ .requiredOption('-a, --app <id>', 'App ID')
3512
+ .option('--path <dir>', 'Target directory')
3513
+ .action(async (options) => {
3514
+ try {
3515
+ const apiClient = new DeSciXApiClient();
3516
+ await requireAuth(apiClient);
3517
+ await runClone(apiClient, { app_id: options.app, targetPath: options.path });
3518
+ } catch (error) {
3519
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
3520
+ process.exit(1);
3521
+ }
3522
+ });
3523
+
3524
+ // ============ MCP Commands ============
3525
+
3526
+ import * as mcpCommands from '../lib/commands/mcp.js';
3527
+ import { runClone } from '../lib/commands/clone.js';
3528
+
3529
+ const mcpCommand = program
3530
+ .command('mcp')
3531
+ .description('MCP server operations');
3532
+
3533
+ mcpCommand
3534
+ .command('init')
3535
+ .description('Initialize MCP server for this workspace')
3536
+ .action(async () => {
3537
+ try {
3538
+ await mcpCommands.init();
3539
+ } catch (error) {
3540
+ process.exit(1);
3541
+ }
3542
+ });
3543
+
3544
+ mcpCommand
3545
+ .command('test')
3546
+ .description('Test MCP server with a sample query')
3547
+ .option('-q, --query <text>', 'Test query', 'What is this knowledge base about?')
3548
+ .action(async (options) => {
3549
+ try {
3550
+ await mcpCommands.test(options);
3551
+ } catch (error) {
3552
+ process.exit(1);
3553
+ }
3554
+ });
3555
+
3556
+ mcpCommand
3557
+ .command('config')
3558
+ .description('Show current workspace configuration')
3559
+ .action(async () => {
3560
+ try {
3561
+ await mcpCommands.config();
3562
+ } catch (error) {
3563
+ process.exit(1);
3564
+ }
3565
+ });
3566
+
3567
+ mcpCommand
3568
+ .command('quickstart')
3569
+ .description('One-command setup: authenticate, configure, and test')
3570
+ .action(async () => {
3571
+ try {
3572
+ await mcpCommands.quickstart();
3573
+ } catch (error) {
3574
+ process.exit(1);
3575
+ }
3576
+ });
3577
+
3578
+ // ============ Intelligent Tool Discovery ============
3579
+
3580
+ program
3581
+ .command('tell-me-how')
3582
+ .description('Discover platform tools using natural language (Intelligent MCP Mesh)')
3583
+ .argument('<question>', 'What do you want to accomplish? Use natural language.')
3584
+ .option('-s, --scope <scope>', 'Search scope: project, entitlements (default), discovery', 'entitlements')
3585
+ .option('-j, --json', 'Output raw JSON response')
3586
+ .action(async (question, options) => {
3587
+ try {
3588
+ const apiClient = new DeSciXApiClient();
3589
+ await requireAuth(apiClient);
3590
+
3591
+ const { scope, json: jsonOutput } = options;
3592
+
3593
+ // Validate scope
3594
+ const validScopes = ['project', 'entitlements', 'discovery'];
3595
+ if (!validScopes.includes(scope)) {
3596
+ console.error(chalk.red(`Invalid scope '${scope}'. Must be one of: ${validScopes.join(', ')}`));
3597
+ process.exit(1);
3598
+ }
3599
+
3600
+ // Get project context for 'project' scope
3601
+ let project_context = null;
3602
+ if (scope === 'project') {
3603
+ try {
3604
+ const { WorkspaceConfig } = await import('../lib/workspace-config.js');
3605
+ const wsConfig = await WorkspaceConfig.load(process.cwd());
3606
+ if (wsConfig) {
3607
+ // Extract community_ids from primaryCommunity and directoryMappings
3608
+ const communityIds = new Set();
3609
+ const appIds = new Set();
3610
+
3611
+ if (wsConfig.primaryCommunity) {
3612
+ communityIds.add(wsConfig.primaryCommunity);
3613
+ }
3614
+
3615
+ // Extract from directoryMappings
3616
+ for (const mapping of Object.values(wsConfig.directoryMappings || {})) {
3617
+ if (mapping.communityId) communityIds.add(mapping.communityId);
3618
+ if (mapping.appId) appIds.add(mapping.appId);
3619
+ }
3620
+
3621
+ // Extract from defaultContext
3622
+ if (wsConfig.defaultContext?.communityId) {
3623
+ communityIds.add(wsConfig.defaultContext.communityId);
3624
+ }
3625
+ if (wsConfig.defaultContext?.appId) {
3626
+ appIds.add(wsConfig.defaultContext.appId);
3627
+ }
3628
+
3629
+ project_context = {
3630
+ community_ids: Array.from(communityIds),
3631
+ app_ids: Array.from(appIds)
3632
+ };
3633
+ }
3634
+ } catch (e) {
3635
+ console.error(chalk.yellow(`āš ļø Could not read .descix/workspace.json for project scope`));
3636
+ console.error(chalk.gray(` Run 'descix init' first, or use --scope entitlements`));
3637
+ process.exit(1);
3638
+ }
3639
+ }
3640
+
3641
+ console.log(chalk.cyan(`\nšŸ” Searching for tools... (scope: ${scope})\n`));
3642
+ console.log(chalk.gray(` Question: "${question}"\n`));
3643
+
3644
+ const response = await apiClient.invoke('tell_me_how', {
3645
+ question,
3646
+ scope,
3647
+ project_context
3648
+ });
3649
+
3650
+ const result = response.message || response;
3651
+
3652
+ if (jsonOutput) {
3653
+ console.log(JSON.stringify(result, null, 2));
3654
+ return;
3655
+ }
3656
+
3657
+ // Format output for human consumption
3658
+ if (result.status === 'ERROR') {
3659
+ console.error(chalk.red(`\nāŒ ${result.message}\n`));
3660
+ process.exit(1);
3661
+ }
3662
+
3663
+ console.log(chalk.green(`\nšŸ’” ${result.explanation || 'Results found'}\n`));
3664
+
3665
+ const tools = result.recommended_tools || [];
3666
+ if (tools.length > 0) {
3667
+ console.log(chalk.cyan('Recommended Tools:'));
3668
+ console.log(chalk.cyan('─'.repeat(50)));
3669
+
3670
+ tools.forEach((tool, idx) => {
3671
+ console.log(chalk.yellow(`\n${idx + 1}. ${tool.command || tool.service}`));
3672
+ console.log(chalk.gray(` ${tool.description || 'No description'}`));
3673
+ if (tool.community_id && tool.app_id) {
3674
+ console.log(chalk.gray(` Source: ${tool.community_id}/${tool.app_id}`));
3675
+ }
3676
+ if (tool.relevance_score) {
3677
+ console.log(chalk.gray(` Relevance: ${(tool.relevance_score * 100).toFixed(1)}%`));
3678
+ }
3679
+ });
3680
+
3681
+ console.log(chalk.cyan('\n─'.repeat(50)));
3682
+ }
3683
+
3684
+ // Show purchase requirements for discovery scope
3685
+ if (scope === 'discovery' && result.requires_purchase?.length > 0) {
3686
+ console.log(chalk.yellow('\nāš ļø Some tools require purchase:\n'));
3687
+ result.requires_purchase.forEach(item => {
3688
+ console.log(chalk.gray(` • ${item.service} - ${item.reason}`));
3689
+ });
3690
+ }
3691
+
3692
+ // Show next steps
3693
+ if (tools.length > 0) {
3694
+ console.log(chalk.white('\nšŸ“ Next Steps:\n'));
3695
+ console.log(chalk.gray(' 1. Use the recommended tool via MCP or CLI'));
3696
+ console.log(chalk.gray(' 2. Example MCP call:'));
3697
+ console.log(chalk.gray(` execute_remote_command({ command: "${tools[0].command || tools[0].service}", params: {...} })`));
3698
+ console.log(chalk.gray(' 3. Or use the CLI:'));
3699
+ console.log(chalk.gray(` descix <command> [options]`));
3700
+ }
3701
+
3702
+ console.log();
3703
+
3704
+ } catch (error) {
3705
+ console.error(chalk.red(`\nāŒ ${error.message}\n`));
3706
+ process.exit(1);
3707
+ }
3708
+ });
3709
+
3710
+ // ============ Folder Commands ============
3711
+
3712
+ const folderCommand = program
3713
+ .command('folder')
3714
+ .description('Drive folder management');
3715
+
3716
+ folderCommand
3717
+ .command('set')
3718
+ .description('Set folder ID for an entity')
3719
+ .option('--user <folderId>', 'Set user base folder')
3720
+ .option('--community <communityId>', 'Set community folder (requires folderId positional)')
3721
+ .option('--app <communityApp>', 'Set app folder: "community app" (requires folderId positional)')
3722
+ .option('--kb <communityAppKb>', 'Set KB folder: "community app kb" (requires folderId positional)')
3723
+ .argument('[folderId]', 'Folder ID (for community/app/kb)')
3724
+ .action(async (folderId, options) => {
3725
+ try {
3726
+ await folderCommands.setFolder({ ...options, folderId });
3727
+ } catch (error) {
3728
+ process.exit(1);
3729
+ }
3730
+ });
3731
+
3732
+ folderCommand
3733
+ .command('get')
3734
+ .description('Get folder info for an entity')
3735
+ .option('--user', 'Get user base folder')
3736
+ .option('--community <communityId>', 'Get community folder')
3737
+ .option('--app <communityApp>', 'Get app folder: "community app"')
3738
+ .option('--kb <communityAppKb>', 'Get KB folder: "community app kb"')
3739
+ .action(async (options) => {
3740
+ try {
3741
+ await folderCommands.getFolder(options);
3742
+ } catch (error) {
3743
+ process.exit(1);
3744
+ }
3745
+ });
3746
+
3747
+ folderCommand
3748
+ .command('allocate')
3749
+ .description('Allocate a new folder for an entity')
3750
+ .option('--app <communityApp>', 'Allocate app folder: "community app"')
3751
+ .option('--kb <communityAppKb>', 'Allocate KB folder: "community app kb"')
3752
+ .option('-n, --name <name>', 'Custom folder name')
3753
+ .action(async (options) => {
3754
+ try {
3755
+ await folderCommands.allocateFolder(options);
3756
+ } catch (error) {
3757
+ process.exit(1);
3758
+ }
3759
+ });
3760
+
3761
+ folderCommand
3762
+ .command('validate <folderId>')
3763
+ .description('Validate folder access')
3764
+ .action(async (folderId, options) => {
3765
+ try {
3766
+ await folderCommands.validateFolder(folderId, options);
3767
+ } catch (error) {
3768
+ process.exit(1);
3769
+ }
3770
+ });
3771
+
3772
+ // ============ Update Commands (Context-Driven) ============
3773
+
3774
+ const updateCommand = program
3775
+ .command('update')
3776
+ .description('Context-driven resource sync (auto-detects from workspace)')
3777
+ .argument('[type]', 'Update type: app, kb, site, all (auto-detects if not specified)')
3778
+ .option('-c, --community <id>', 'Community ID (optional; app_id sufficient for Unified Registry)')
3779
+ .option('-a, --app <id>', 'App ID (globally unique; required if not in app directory)')
3780
+ .option('--kb <name>', 'Specific KB to update (for kb type)')
3781
+ .option('--stage1', 'KB: Local to Drive only (skip vectorization)')
3782
+ .option('--preview', 'Site: Deploy to preview URL')
3783
+ .option('--build <cmd>', 'Site: Run build command first')
3784
+ .option('--full', 'Force full sync (ignore delta)')
3785
+ .option('--port <number>', 'Site: Register local dev server port')
3786
+ .action(async (type, options) => {
3787
+ try {
3788
+ switch (type) {
3789
+ case 'app':
3790
+ await updateCommands.updateApp(options);
3791
+ break;
3792
+ case 'kb':
3793
+ await updateCommands.updateKB({ ...options, stage1Only: options.stage1 });
3794
+ break;
3795
+ case 'site':
3796
+ await updateCommands.updateSite({
3797
+ ...options,
3798
+ port: options.port ? parseInt(options.port) : undefined
3799
+ });
3800
+ break;
3801
+ case 'all':
3802
+ await updateCommands.updateAll(options);
3803
+ break;
3804
+ default:
3805
+ // Auto-detect
3806
+ await updateCommands.updateAuto(options);
3807
+ }
3808
+ } catch (error) {
3809
+ process.exit(1);
3810
+ }
3811
+ });
3812
+
3813
+ // ============ Local Gateway Command ============
3814
+
3815
+ program
3816
+ .command('serve')
3817
+ .description('Start the unified local gateway (reverse-proxy for PWA, Core, Powch, microservices)')
3818
+ .option('-p, --port <port>', 'Gateway port', '5173')
3819
+ .option('-w, --workspace <path>', 'Workspace root override')
3820
+ .action(async (options) => {
3821
+ try {
3822
+ const { runServe } = await import('../lib/commands/serve.js');
3823
+ await runServe({
3824
+ port: parseInt(options.port, 10),
3825
+ workspaceRoot: options.workspace || process.cwd(),
3826
+ });
3827
+ } catch (error) {
3828
+ console.error(chalk.red('Gateway error:', error.message));
3829
+ process.exit(1);
3830
+ }
3831
+ });
3832
+
3833
+ // ============ Quickstart Command ============
3834
+
3835
+ program
3836
+ .command('quickstart')
3837
+ .description('One-command setup: auth → workspace → agent files → MCP config')
3838
+ .option('-u, --url <url>', 'API URL override')
3839
+ .option('--dev', 'Use development server (https://localhost:4000)')
3840
+ .action(async (options) => {
3841
+ const { generateAgentFiles, generateMcpConfig } = await import('../lib/agent-files.js');
3842
+ const { WalletFileManager } = await import('../lib/wallet-file.js');
3843
+
3844
+ console.log(chalk.cyan('\nšŸš€ DeSciX Quickstart\n'));
3845
+
3846
+ const workspaceRoot = process.cwd();
3847
+
3848
+ // Step 1: Auth — login if no wallet.json
3849
+ const walletPath = WalletFileManager.getWalletPath(workspaceRoot);
3850
+ let needsLogin = true;
3851
+ try {
3852
+ const wallet = await WalletFileManager.loadWalletFile(walletPath);
3853
+ if (wallet && WalletFileManager.hasValidSession(wallet)) {
3854
+ console.log(chalk.green(`āœ“ Already authenticated as ${wallet.userId}`));
3855
+ needsLogin = false;
3856
+ }
3857
+ } catch { /* no wallet */ }
3858
+
3859
+ if (needsLogin) {
3860
+ const loginOptions = {};
3861
+ if (options.url) loginOptions.url = options.url;
3862
+ if (options.dev) loginOptions.dev = true;
3863
+ await authCommands.loginDevice(loginOptions);
3864
+ }
3865
+
3866
+ // Step 2: Workspace init — create workspace.json if missing
3867
+ const wsConfigPath = path.join(workspaceRoot, '.descix', 'workspace.json');
3868
+ let hasWorkspace = false;
3869
+ try {
3870
+ await fs.access(wsConfigPath);
3871
+ hasWorkspace = true;
3872
+ console.log(chalk.green('āœ“ Workspace already initialized'));
3873
+ } catch { /* missing */ }
3874
+
3875
+ if (!hasWorkspace) {
3876
+ console.log(chalk.cyan('\nšŸ“‹ Initialize Workspace\n'));
3877
+ await runInit({ path: workspaceRoot });
3878
+ }
3879
+
3880
+ // Step 3: Generate agent instruction files
3881
+ console.log(chalk.cyan('\nšŸ“‹ Generating Agent Instructions\n'));
3882
+ const written = await generateAgentFiles(workspaceRoot);
3883
+ for (const f of written) {
3884
+ console.log(chalk.green(` āœ“ ${f}`));
3885
+ }
3886
+
3887
+ // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
3888
+ const mcpWritten = await generateMcpConfig(workspaceRoot);
3889
+ if (mcpWritten) {
3890
+ console.log(chalk.green(' āœ“ .vscode/mcp.json'));
3891
+ } else {
3892
+ console.log(chalk.green(' āœ“ MCP handled by DeSciX extension (mcp.json skipped)'));
3893
+ }
3894
+
3895
+ // Step 5: Copy SDK assets
3896
+ try {
3897
+ const { pullSdkAssets } = await import('../lib/wizard/setup.js');
3898
+ const pulled = await pullSdkAssets(workspaceRoot);
3899
+ if (pulled) console.log(chalk.green(' āœ“ .descix/sdk-assets/'));
3900
+ } catch { /* setup.js pullSdkAssets may not be exported — skip */ }
3901
+
3902
+ // Done
3903
+ console.log(chalk.green('\nāœ… Quickstart complete!\n'));
3904
+ console.log(chalk.white('Open your editor — the AI knows about DeSciX.\n'));
3905
+ console.log(chalk.gray(' Copilot / Cline / Claude Code will have DeSciX MCP tools'));
3906
+ console.log(chalk.gray(' Ask: "What DeSciX tools do I have?"\n'));
3907
+ });
3908
+
3909
+ // ============ MCP Server Command (for npx usage) ============
3910
+
3911
+ program
3912
+ .command('mcp-serve')
3913
+ .description('Start MCP server for Cursor/VS Code integration (used by npx)')
3914
+ .action(async () => {
3915
+ try {
3916
+ await import('./mcp-server.js');
3917
+ } catch (error) {
3918
+ console.error(chalk.red('MCP server error:', error.message));
3919
+ process.exit(1);
3920
+ }
3921
+ });
3922
+
3923
+ // Parse arguments
3924
+ program.parse();
3925
+
3926
+ // Show help if no command provided
3927
+ if (!process.argv.slice(2).length) {
3928
+ program.outputHelp();
3929
+ }
3930
+