@descix/cli 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/descix.js CHANGED
@@ -33,6 +33,8 @@ import { runInit } from '../lib/commands/init.js';
33
33
  import { createPromptSession } from '../lib/interactive.js';
34
34
  import { registerAllRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
35
35
  import { runStatus } from '../lib/commands/status.js';
36
+ import { refreshCommunityIdentity, printIdentityReceipt } from '../lib/commands/communityIdentity.js';
37
+ import { runAppInit } from '../lib/commands/appInit.js';
36
38
  import { runDoctor } from '../lib/commands/doctor.js';
37
39
  import { runHealth } from '../lib/commands/health.js';
38
40
  import * as kbCommands from '../lib/commands/kb.js';
@@ -199,7 +201,8 @@ program
199
201
  .option('-c, --community <id>', 'Community ID')
200
202
  .option('-a, --app <name>', 'App name')
201
203
  .option('-p, --path <path>', 'Project path (defaults to current directory)')
202
- .option('-f, --force', 'Overwrite existing workspace.json')
204
+ .option('-f, --force', 'Restart the app registrations in an existing workspace (its environment settings are kept)')
205
+ .option('-y, --yes', 'Skip the confirmation — with -c and -a, init runs without a terminal')
203
206
  .option('--from-invite <token>', 'Resolve an invite token to pre-fill app context')
204
207
  .action(async (options) => {
205
208
  try {
@@ -212,7 +215,7 @@ program
212
215
  // Continue without API client
213
216
  }
214
217
 
215
- await runInit(apiClient, options);
218
+ await runInit(apiClient, { ...options, env: program.opts().env });
216
219
  } catch (error) {
217
220
  console.error(chalk.red(`\n❌ ${error.message}\n`));
218
221
  process.exit(1);
@@ -461,6 +464,22 @@ const communityCommand = program
461
464
  .command('community')
462
465
  .description('Community operations');
463
466
 
467
+ communityCommand
468
+ .command('refresh-identity')
469
+ .description('Mirror a community\'s token symbol and icon from the descix-chain registry into this environment (the icon only if it serves)')
470
+ .requiredOption('-c, --community <id>', 'Community ID')
471
+ .action(async (options) => {
472
+ try {
473
+ const apiClient = new DeSciXApiClient();
474
+ await requireAuth(apiClient);
475
+ printIdentityReceipt(options.community, await refreshCommunityIdentity(apiClient, options.community));
476
+ console.log();
477
+ } catch (error) {
478
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
479
+ process.exit(1);
480
+ }
481
+ });
482
+
464
483
  communityCommand
465
484
  .command('list')
466
485
  .description('List all communities')
@@ -1064,107 +1083,7 @@ appCommand
1064
1083
  try {
1065
1084
  const apiClient = new DeSciXApiClient();
1066
1085
  await requireAuth(apiClient);
1067
- let appId = options.app;
1068
- const kbId = options.kb || 'General';
1069
-
1070
- // Resolve community_id from the Products registry to learn whether the app exists yet.
1071
- let communityId;
1072
- try {
1073
- const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
1074
- communityId = (productCtx.message || productCtx).community_id;
1075
- } catch (e) {
1076
- // Not in Products — either we create it below (-c given) or we fail loud.
1077
- }
1078
-
1079
- if (!communityId) {
1080
- if (!options.community) {
1081
- throw new Error(
1082
- `App '${appId}' does not exist yet. Pass -c <community> to create it: ` +
1083
- `descix app init -a ${appId} -c <community> [-s <short>]`
1084
- );
1085
- }
1086
- // CREATE leg — the canonical server path. It composes the unique app_id
1087
- // ({community}-{short}) and is authoritative; we never compose it client-side.
1088
- const response = await apiClient.invoke('create_app_for_community', {
1089
- community_id: options.community,
1090
- app_name: appId,
1091
- short_name: options.short || undefined,
1092
- create_skeleton: false,
1093
- overwrite: options.overwrite,
1094
- });
1095
- const created = response.message || response;
1096
- appId = created.app_id || appId;
1097
- communityId = created.community_id || options.community;
1098
- console.log(chalk.green(`\n✅ App created: ${appId}`));
1099
- console.log(chalk.gray(` Products: Products/${appId}`));
1100
- console.log(chalk.gray(` Firestore: Community/${communityId}/Apps/${appId}`));
1101
- } else if (options.community && options.community !== communityId) {
1102
- throw new Error(
1103
- `App '${appId}' already exists in community '${communityId}', not '${options.community}'. ` +
1104
- `Omit -c to initialize the existing app.`
1105
- );
1106
- }
1107
-
1108
- // 1. Workspace.json — register app if not already mapped
1109
- const workspaceConfig = await WorkspaceConfig.tryLoad();
1110
- const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
1111
- let appPath = alreadyMapped?.absolutePath;
1112
-
1113
- if (alreadyMapped && options.path) {
1114
- throw new Error(
1115
- `App '${appId}' is already mapped to '${alreadyMapped.localPath}'. ` +
1116
- `Use 'descix app set-localpath -a ${appId} -p <new-path>' to update.`
1117
- );
1118
- }
1119
-
1120
- if (!alreadyMapped) {
1121
- const localPath = options.path || '.';
1122
- const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
1123
- const cfg = workspaceConfig || new WorkspaceConfig({}, wsRoot);
1124
- cfg.registerApp(communityId, appId, { localPath, kbId });
1125
- await cfg.save(wsRoot);
1126
- appPath = path.resolve(wsRoot, localPath);
1127
- console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
1128
- }
1129
-
1130
- // 2. Create app folder structure (site, microservice, assets)
1131
- if (appPath) {
1132
- const siteDir = path.join(appPath, 'site');
1133
- const msDir = path.join(appPath, 'microservice');
1134
- const assetsDir = path.join(appPath, 'assets');
1135
- await fs.mkdir(siteDir, { recursive: true });
1136
- await fs.mkdir(msDir, { recursive: true });
1137
- await fs.mkdir(assetsDir, { recursive: true });
1138
-
1139
- // Create template asset files if they don't exist
1140
- const siPath = path.join(assetsDir, 'system_instructions.md');
1141
- const descPath = path.join(assetsDir, 'app_description.md');
1142
- try {
1143
- await fs.access(siPath);
1144
- } catch {
1145
- await fs.writeFile(siPath, `# System Instructions for ${appId}\n\nYou are an AI assistant for the ${appId} application.\n`);
1146
- }
1147
- try {
1148
- await fs.access(descPath);
1149
- } catch {
1150
- await fs.writeFile(descPath, `# ${appId}\n\nApplication description goes here.\n`);
1151
- }
1152
- console.log(chalk.gray(` Created: site/, microservice/, assets/`));
1153
- }
1154
-
1155
- // 3. Create KnowledgeBase Firestore doc (Git Mode — no Drive required)
1156
- const kbResponse = await apiClient.invoke('init_git_mode_kb', { app_id: appId, kb_name: kbId });
1157
- const kbResult = kbResponse.message || kbResponse;
1158
-
1159
- console.log(chalk.green(`\n✓ ${appId} initialized`));
1160
- console.log(chalk.gray(` Community: ${communityId}`));
1161
- console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
1162
- console.log(chalk.gray(` (every app is guaranteed a default KB at creation; an empty one`));
1163
- console.log(chalk.gray(` says so rather than answering from general knowledge)\n`));
1164
- console.log(chalk.cyan('Next steps:'));
1165
- console.log(chalk.gray(` Create a corpus manifest at apps/${appId}/.descix/manifests/${kbId}.json`));
1166
- console.log(chalk.gray(` then run:`));
1167
- console.log(chalk.white(` descix kb corpus sync -a ${appId}\n`));
1086
+ await runAppInit(apiClient, options);
1168
1087
  } catch (error) {
1169
1088
  console.error(chalk.red(error.message));
1170
1089
  process.exit(1);
@@ -2177,17 +2096,17 @@ const corpusCommand = kbCommand
2177
2096
 
2178
2097
  corpusCommand
2179
2098
  .command('sync')
2180
- .description('Sync corpus files to Pinecone using manifest definitions')
2099
+ .description('Sync the files a corpus manifest names into the app\'s knowledge base')
2181
2100
  .requiredOption('-a, --app <id>', 'App ID')
2182
2101
  .option('-c, --community <community_id>', 'Community ID (optional; resolved from Products when omitted)')
2183
2102
  .option('-k, --kb <name>', 'KB name (syncs specific manifest; default: all)')
2184
2103
  .option('-v, --verbose', 'Show verbose output')
2185
- .option('--ref <ref>', 'Override the git ref for ALL manifest sources (e.g., --ref ws-admin-b1). Precedence: --ref > manifest source.ref > "main".')
2186
- .option('--rebuild', 'Reconcile Pinecone against REMOTE state: enumerate remote file_ids, purge any not in the current corpus, then re-sync from scratch. NOT needed to remove deleted files — an ordinary sync already purges them, diffing the walk against sync-state\'s last_sync_commit (measured: a plain sync purged 1962 chunks across 124 stale blob SHAs). Use --rebuild ONLY when that local sync-state cannot be trusted: it is lost, hand-edited, or vectors were orphaned outside the history it tracks. Prompts before deleting unless --yes is supplied.')
2187
- .option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
2188
- .option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
2104
+ .option('--ref <ref>', 'Override the git ref for ALL manifest sources (e.g., --ref my-feature-branch). Precedence: --ref > manifest source.ref > "main".')
2105
+ .option('--rebuild', 'Delete EVERYTHING in the knowledge base, then re-sync the whole corpus. Not needed to remove deleted or changed files — an ordinary sync already removes any corpus file that is live in the knowledge base but no longer in the corpus (see --dry-run, which names them). Use --rebuild only to clear content an ordinary sync cannot see. Prompts before deleting unless --yes is supplied.')
2106
+ .option('--dry-run', 'Show what a sync would add and remove — naming each file it would remove — without changing anything. Exit 0 if nothing would change, 1 if something would.')
2107
+ .option('--show-walk', 'Print the resolved ref and the first 50 files the manifest selects, before syncing anything. Useful for checking --ref and the manifest sources.')
2189
2108
  .option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
2190
- .option('--skip-retrieval-canary', 'Skip the post-sync retrieval canary that confirms a synced chunk actually retrieves before reporting success (measured 2026-09-17: presence in Pinecone is not the same as searchable — the index can lag ~20 minutes after a large upsert/purge). Skipping costs nothing but SAVES one credit-metered query_knowledge_base call and up to ~25s; the sync then reports success on presence only, same as before this check existed. Prefer leaving this ON for anything user-facing; use it for CI/cost-sensitive automation that will verify separately.')
2109
+ .option('--skip-retrieval-canary', 'Skip the post-sync check that a synced passage is actually searchable (a large sync can take several minutes to become searchable). Skipping saves one credit-metered query and up to ~25 s; the sync then reports success on upload alone. Leave the check on for anything user-facing.')
2191
2110
  .action(async (options) => {
2192
2111
  try {
2193
2112
  const apiClient = new DeSciXApiClient();
@@ -2317,7 +2236,7 @@ driveCommand
2317
2236
 
2318
2237
  const siteCommand = program
2319
2238
  .command('site')
2320
- .description('Site lifecycle management: init, upload, status, list, delete');
2239
+ .description('Build and publish your app\'s website');
2321
2240
 
2322
2241
  // site init - Copy scaffold to app
2323
2242
  siteCommand
@@ -2432,7 +2351,13 @@ siteCommand
2432
2351
  // Import mime for content types
2433
2352
  const mime = (await import('mime-types')).default;
2434
2353
  const { GitUtils } = await import('@descix/sdk/integrations/git');
2435
- const gitUtils = new GitUtils(process.cwd());
2354
+ // Provenance is read from the directory the FILES come from — the app root when a site
2355
+ // manifest names the sources, else the -p directory — never the caller's cwd. Reading cwd
2356
+ // recorded the WRONG repository's commit on every deploy made from outside the app repo
2357
+ // (measured 2026-09-18: daita-docs, whose files live in DeSciX/daita, was stamped with the
2358
+ // Unkamon commit it happened to be run from).
2359
+ const provenanceDir = siteManifest ? appRoot : path.resolve(options.path);
2360
+ const gitUtils = new GitUtils(provenanceDir);
2436
2361
  const gitStatus = await gitUtils.getStatus();
2437
2362
 
2438
2363
  let fileList; // Array<{ path, hash, size, content_type }>
@@ -4271,7 +4196,7 @@ const mcpCommand = program
4271
4196
  mcpCommand
4272
4197
  .command('execute')
4273
4198
  .description('Execute a registered MCP tool by name with JSON parameters')
4274
- .requiredOption('--tool <name>', 'Tool/command name (e.g., beast_get_initiatives, beast_update_stream)')
4199
+ .requiredOption('--tool <name>', 'Tool/command name (e.g., find_communities, ask_question_to_app)')
4275
4200
  .option('--params <json>', 'JSON parameters for the tool', '{}')
4276
4201
  .option('-a, --app <app_id>', 'App context (sets app_id in params if not already present)')
4277
4202
  .option('--json', 'Output raw JSON response')
@@ -4533,96 +4458,124 @@ devCertsCommand
4533
4458
 
4534
4459
  program
4535
4460
  .command('quickstart')
4536
- .description('One-command setup: auth → workspace → agent files → MCP config')
4461
+ .description('One-command setup: sign in → workspace → app (with -c and -a) → agent files → MCP config')
4537
4462
  .option('-u, --url <url>', 'API URL override')
4463
+ .option('-c, --community <id>', 'Community to create the app in')
4464
+ .option('-a, --app <name>', 'Name of the app to create (the platform issues its id; see app init)')
4465
+ .option('-y, --yes', 'Skip the confirmation — with -c and -a, quickstart runs without a terminal')
4538
4466
  .action(async (options) => {
4539
- const { generateAgentFiles, generateMcpConfig } = await import('../lib/agent-files.js');
4540
- const { WalletFileManager } = await import('../lib/wallet-file.js');
4541
-
4542
- console.log(chalk.cyan('\n🚀 DeSciX Quickstart\n'));
4543
-
4544
- const workspaceRoot = process.cwd();
4545
-
4546
- // Step 1: Auth — login if no wallet.json
4547
- const walletPath = WalletFileManager.getWalletPath(workspaceRoot);
4548
- let needsLogin = true;
4467
+ // Every failure prints as a message and exits 1 — never a stack trace. A new developer's AI
4468
+ // assistant runs this non-interactively, and an uncaught refusal read as a crash (2026-09-19).
4549
4469
  try {
4550
- const wallet = await WalletFileManager.loadWalletFile(walletPath);
4551
- if (wallet && WalletFileManager.hasValidSession(wallet)) {
4552
- console.log(chalk.green(`✓ Already authenticated as ${wallet.userId}`));
4553
- needsLogin = false;
4554
- }
4555
- } catch { /* no wallet */ }
4470
+ const { generateAgentFiles, generateMcpConfig, generateClaudeCodeMcpConfig } = await import('../lib/agent-files.js');
4471
+ const { WalletFileManager } = await import('../lib/wallet-file.js');
4556
4472
 
4557
- if (needsLogin) {
4558
- const loginOptions = {};
4559
- if (options.url) loginOptions.url = options.url;
4560
- await authCommands.loginDevice(loginOptions);
4561
- }
4473
+ console.log(chalk.cyan('\n🚀 DeSciX Quickstart\n'));
4562
4474
 
4563
- // Step 2: Workspace init — create workspace.json only when there is none ANYWHERE UP THE TREE.
4564
- //
4565
- // WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
4566
- // exactly as load() does. This step used to derive it a THIRD time with fs.access on the target
4567
- // path only (the wizard guard was the second). Run from a SUBDIRECTORY of an existing
4568
- // workspace, that check saw nothing, runInit created a NESTED workspace.json whose
4569
- // workspaceRoot pointed at the subdirectory, and it SHADOWED the parent for every later
4570
- // resolution — silently, with "Quickstart complete!" and exit 0.
4571
- //
4572
- // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
4573
- // SUCCEEDED, so this SKIPS and REPORTS and continues.
4574
- const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
4575
- if (existingRoot) {
4576
- console.log(chalk.green('✓ Workspace already initialized'));
4577
- if (path.resolve(existingRoot) !== path.resolve(workspaceRoot)) {
4578
- console.log(chalk.gray(` Workspace root: ${existingRoot}`));
4579
- console.log(chalk.gray(` You are in a subdirectory of it. Not creating a second workspace`));
4580
- console.log(chalk.gray(` here — a nested one would shadow the root for every command run`));
4581
- console.log(chalk.gray(` from this directory.`));
4582
- }
4583
- } else {
4584
- console.log(chalk.cyan('\n📋 Initialize Workspace\n'));
4585
- // runInit's signature is (apiClient, options). Passing a single object put it in the
4586
- // apiClient slot and left options defaulting to {}, so `path` was silently discarded and
4587
- // runInit fell back to process.cwd(). It was MASKED here only because workspaceRoot IS
4588
- // process.cwd() in this action — the argument was in the wrong slot regardless, and would
4589
- // have written the workspace to the wrong directory the moment that stopped being true.
4590
- // This action has no apiClient of its own; null is passed explicitly rather than implied.
4591
- await runInit(null, { path: workspaceRoot });
4592
- }
4475
+ const workspaceRoot = process.cwd();
4593
4476
 
4594
- // EVERY REMAINING STEP TARGETS THE RESOLVED ROOT, not the directory the command was typed in.
4595
- // They read the workspace to learn the app, community and origin they must state, so pointing
4596
- // them at a subdirectory that deliberately has no workspace.json makes them hard-fail on the
4597
- // absence this command just chose to preserve. Resolving the root once and ferrying it is the
4598
- // same one-owner rule that fixed the check above.
4599
- const targetRoot = existingRoot || workspaceRoot;
4600
-
4601
- // Step 3: Generate agent instruction files
4602
- console.log(chalk.cyan('\n📋 Generating Agent Instructions\n'));
4603
- const written = await generateAgentFiles(targetRoot);
4604
- for (const f of written) {
4605
- console.log(chalk.green(` ✓ ${f}`));
4606
- }
4477
+ // Step 1: Auth — login if no wallet.json
4478
+ const walletPath = WalletFileManager.getWalletPath(workspaceRoot);
4479
+ let needsLogin = true;
4480
+ try {
4481
+ const wallet = await WalletFileManager.loadWalletFile(walletPath);
4482
+ if (wallet && WalletFileManager.hasValidSession(wallet)) {
4483
+ console.log(chalk.green(`✓ Already authenticated as ${wallet.userId}`));
4484
+ needsLogin = false;
4485
+ }
4486
+ } catch { /* no wallet */ }
4607
4487
 
4608
- // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
4609
- const mcpWritten = await generateMcpConfig(targetRoot);
4610
- if (mcpWritten) {
4611
- console.log(chalk.green(' ✓ .vscode/mcp.json'));
4612
- } else {
4613
- console.log(chalk.green(' ✓ MCP handled by DeSciX extension (mcp.json skipped)'));
4614
- }
4488
+ if (needsLogin) {
4489
+ const loginOptions = {};
4490
+ if (options.url) loginOptions.url = options.url;
4491
+ await authCommands.loginDevice(loginOptions);
4492
+ }
4615
4493
 
4616
- // Step 5: Copy SDK assets
4617
- const { pullSdkAssets } = await import('../lib/sdk-assets.js');
4618
- await pullSdkAssets(targetRoot);
4619
- console.log(chalk.green(' ✓ .descix/sdk-assets/'));
4494
+ // Step 2: Workspace init — create workspace.json only when there is none ANYWHERE UP THE TREE.
4495
+ //
4496
+ // WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
4497
+ // exactly as load() does. This step used to derive it a THIRD time with fs.access on the target
4498
+ // path only (the wizard guard was the second). Run from a SUBDIRECTORY of an existing
4499
+ // workspace, that check saw nothing, runInit created a NESTED workspace.json whose
4500
+ // workspaceRoot pointed at the subdirectory, and it SHADOWED the parent for every later
4501
+ // resolution — silently, with "Quickstart complete!" and exit 0.
4502
+ //
4503
+ // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
4504
+ // SUCCEEDED, so this SKIPS and REPORTS and continues.
4505
+ const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
4506
+ if (existingRoot) {
4507
+ console.log(chalk.green('✓ Workspace already initialized'));
4508
+ if (path.resolve(existingRoot) !== path.resolve(workspaceRoot)) {
4509
+ console.log(chalk.gray(` Workspace root: ${existingRoot}`));
4510
+ console.log(chalk.gray(` You are in a subdirectory of it. Not creating a second workspace`));
4511
+ console.log(chalk.gray(` here — a nested one would shadow the root for every command run`));
4512
+ console.log(chalk.gray(` from this directory.`));
4513
+ }
4514
+ } else {
4515
+ console.log(chalk.cyan('\n📋 Initialize Workspace\n'));
4516
+ // runInit's signature is (apiClient, options). Passing a single object put it in the
4517
+ // apiClient slot and left options defaulting to {}, so `path` was silently discarded and
4518
+ // runInit fell back to process.cwd(). It was MASKED here only because workspaceRoot IS
4519
+ // process.cwd() in this action — the argument was in the wrong slot regardless, and would
4520
+ // have written the workspace to the wrong directory the moment that stopped being true.
4521
+ // This action has no apiClient of its own; null is passed explicitly rather than implied.
4522
+ await runInit(null, { path: workspaceRoot, community: options.community, app: options.app, yes: options.yes, env: program.opts().env });
4523
+ }
4524
+
4525
+ // EVERY REMAINING STEP TARGETS THE RESOLVED ROOT, not the directory the command was typed in.
4526
+ // They read the workspace to learn the app, community and origin they must state, so pointing
4527
+ // them at a subdirectory that deliberately has no workspace.json makes them hard-fail on the
4528
+ // absence this command just chose to preserve. Resolving the root once and ferrying it is the
4529
+ // same one-owner rule that fixed the check above.
4530
+ const targetRoot = existingRoot || workspaceRoot;
4531
+
4532
+ // Step 3: The app, and the agent instruction files that state its id. The id is the
4533
+ // platform's, so the files are written only once the workspace names one it issued: from the
4534
+ // workspace when an app is already registered, or by `app init` (the one owner, which writes
4535
+ // them itself) when -c and -a name the app to create.
4536
+ const { readIdentity, isIdentityNamed } = await import('../lib/workspace-identity.js');
4537
+ const wsRaw = JSON.parse(await fs.readFile(path.join(targetRoot, '.descix', 'workspace.json'), 'utf-8'));
4538
+ if (isIdentityNamed(readIdentity(wsRaw))) {
4539
+ console.log(chalk.cyan('\n📋 Generating Agent Instructions\n'));
4540
+ const written = await generateAgentFiles(targetRoot);
4541
+ for (const f of written) {
4542
+ console.log(chalk.green(` ✓ ${f}`));
4543
+ }
4544
+ } else if (options.community && options.app) {
4545
+ console.log(chalk.cyan('\n📋 Create and initialize the app\n'));
4546
+ const apiClient = new DeSciXApiClient();
4547
+ await requireAuth(apiClient);
4548
+ await runAppInit(apiClient, { app: options.app, community: options.community });
4549
+ } else {
4550
+ console.log(chalk.yellow('\n No app yet, so no agent instruction files (they state its id). Next:'));
4551
+ console.log(chalk.white(' descix app init -a <name> -c <community>'));
4552
+ }
4620
4553
 
4621
- // Done
4622
- console.log(chalk.green('\n✅ Quickstart complete!\n'));
4623
- console.log(chalk.white('Open your editor — the AI knows about DeSciX.\n'));
4624
- console.log(chalk.gray(' Copilot / Cline / Claude Code will have DeSciX MCP tools'));
4625
- console.log(chalk.gray(' Ask: "What DeSciX tools do I have?"\n'));
4554
+ // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
4555
+ const mcpWritten = await generateMcpConfig(targetRoot);
4556
+ if (mcpWritten) {
4557
+ console.log(chalk.green(' ✓ .vscode/mcp.json'));
4558
+ } else {
4559
+ console.log(chalk.green(' ✓ MCP handled by DeSciX extension (mcp.json skipped)'));
4560
+ }
4561
+ // Claude Code reads project MCP servers from .mcp.json — written regardless of the VS Code case.
4562
+ await generateClaudeCodeMcpConfig(targetRoot);
4563
+ console.log(chalk.green(' ✓ .mcp.json (Claude Code)'));
4564
+
4565
+ // Step 5: Copy SDK assets
4566
+ const { pullSdkAssets } = await import('../lib/sdk-assets.js');
4567
+ await pullSdkAssets(targetRoot);
4568
+ console.log(chalk.green(' ✓ .descix/sdk-assets/'));
4569
+
4570
+ // Done
4571
+ console.log(chalk.green('\n✅ Quickstart complete!\n'));
4572
+ console.log(chalk.white('Open your editor — the AI knows about DeSciX.\n'));
4573
+ console.log(chalk.gray(' Copilot / Cline / Claude Code will have DeSciX MCP tools'));
4574
+ console.log(chalk.gray(' Ask: "What DeSciX tools do I have?"\n'));
4575
+ } catch (error) {
4576
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
4577
+ process.exit(1);
4578
+ }
4626
4579
  });
4627
4580
 
4628
4581
  // ============ MCP Server Command (for npx usage) ============
package/bin/mcp-server.js CHANGED
@@ -31,7 +31,7 @@ import * as path from 'path';
31
31
  // This stdio server imports them and CONCATENATES its CLI-LOCAL diagnostics (descix_doctor,
32
32
  // platform_health) which are NOT /apifront commands and are intentionally stdio-only.
33
33
  // The previously hand-duplicated curated literal in this file is GONE.
34
- import { toMcpToolList, NATIVE_MCP_TOOLS as SHARED_NATIVE_TOOLS, MCP_HANDSHAKE_INSTRUCTIONS, validateToolParams, toolAcceptsParam, VALIDATION_PHASE } from '@descix/platform-api/mcp-tools';
34
+ import { toMcpToolList, NATIVE_MCP_TOOLS as SHARED_NATIVE_TOOLS, MCP_HANDSHAKE_INSTRUCTIONS, validateToolParams, toolAcceptsParam, VALIDATION_PHASE, isDiscoveryCoreTool } from '@descix/platform-api/mcp-tools';
35
35
 
36
36
  // ---------------------------------------------------------------------------
37
37
  // Tool definitions for the stdio MCP transport.
@@ -77,9 +77,20 @@ const CLI_LOCAL_TOOLS = [
77
77
  },
78
78
  ];
79
79
 
80
- // Final stdio tool list: CLI-local diagnostics + the shared curated core tools (protocol shape).
80
+ // Every tool this server can DESCRIBE: CLI-local diagnostics + the shared curated core. It seeds
81
+ // the schema-aware tools/call path below, and is NEVER advertised as a whole.
81
82
  const TOOLS = [...CLI_LOCAL_TOOLS, ...toMcpToolList(SHARED_NATIVE_TOOLS)];
82
83
 
84
+ // What tools/list ADVERTISES when the backend cannot supply the caller's permission-filtered
85
+ // list — which is every UNAUTHENTICATED session, i.e. an outside developer's first contact.
86
+ // It is the ratified DISCOVERY-CORE floor (platform-api isDiscoveryCoreTool, the same floor the
87
+ // server serves a caller with no grant), never the whole static catalogue: advertising all of
88
+ // TOOLS showed 37 tools, 18 of them the internal coordination fabric's (measured 2026-09-19).
89
+ const FALLBACK_ADVERTISED_TOOLS = [
90
+ ...CLI_LOCAL_TOOLS,
91
+ ...toMcpToolList(SHARED_NATIVE_TOOLS.filter((t) => isDiscoveryCoreTool(t.name))),
92
+ ];
93
+
83
94
  // Live tool surface as last returned by tools/list (permission-filtered by the backend).
84
95
  // Seeded with the static curated SSOT so the very first tools/call is schema-aware even if
85
96
  // no tools/list preceded it. Consumed by tools/call for schema-aware context injection and
@@ -410,8 +421,8 @@ try {
410
421
  console.error(`[MCP] tools/list — ${backendTools.length} permission-filtered backend tools + ${CLI_LOCAL_TOOLS.length} CLI-local = ${tools.length}`);
411
422
  return { tools };
412
423
  } catch (err) {
413
- console.error(`[MCP] tools/list — backend unreachable (${err.message}); falling back to static curated core (${TOOLS.length} tools)`);
414
- return { tools: TOOLS };
424
+ console.error(`[MCP] tools/list — backend unreachable (${err.message}); advertising the discovery-core floor (${FALLBACK_ADVERTISED_TOOLS.length} tools)`);
425
+ return { tools: FALLBACK_ADVERTISED_TOOLS };
415
426
  }
416
427
  });
417
428
 
@@ -11,6 +11,7 @@ import * as fs from 'fs/promises';
11
11
  import * as path from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { readIdentity, isIdentityNamed } from './workspace-identity.js';
14
+ import { mcpServerEntry } from './invocation.js';
14
15
 
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = path.dirname(__filename);
@@ -163,12 +164,31 @@ export async function generateMcpConfig(workspaceRoot) {
163
164
  }
164
165
 
165
166
  // Add/update DeSciX MCP server
166
- existing.servers['descix'] = {
167
- command: 'descix',
168
- args: ['mcp-serve'],
169
- };
167
+ existing.servers['descix'] = mcpServerEntry();
170
168
 
171
169
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
172
170
  await fs.writeFile(outputPath, JSON.stringify(existing, null, 2), 'utf-8');
173
171
  return true;
174
172
  }
173
+
174
+ /**
175
+ * Claude Code's PROJECT MCP config: `.mcp.json` at the project root, `{ mcpServers: { … } }`.
176
+ * Written independently of the VS Code file above (whose DeSciX-extension skip does not apply to
177
+ * Claude Code). Existing servers are preserved; only the `descix` entry is set.
178
+ *
179
+ * @param {string} workspaceRoot
180
+ * @returns {Promise<boolean>} true when written
181
+ */
182
+ export async function generateClaudeCodeMcpConfig(workspaceRoot) {
183
+ const outputPath = path.join(workspaceRoot, '.mcp.json');
184
+ let existing = { mcpServers: {} };
185
+ try {
186
+ existing = JSON.parse(await fs.readFile(outputPath, 'utf-8'));
187
+ if (!existing.mcpServers) existing.mcpServers = {};
188
+ } catch {
189
+ // No existing file — start fresh
190
+ }
191
+ existing.mcpServers.descix = mcpServerEntry();
192
+ await fs.writeFile(outputPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
193
+ return true;
194
+ }
package/lib/auth-guard.js CHANGED
@@ -29,7 +29,7 @@ export async function requireAuth(apiClient) {
29
29
 
30
30
  if (!apiClient.hasCredentials()) {
31
31
  console.error(chalk.red('\n Authentication required.\n'));
32
- console.error(chalk.white(' Please sign up with DeSciX to use this feature.\n'));
32
+ console.error(chalk.white(' Sign in to use this. On first use, signing in also creates your DeSciX account.\n'));
33
33
  console.error(chalk.cyan(' Run: descix login\n'));
34
34
  process.exit(1);
35
35
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `descix app init` — THE path to an app (CEO-D-2026-08-14): creates the app when it does not exist
3
+ * yet, then registers and scaffolds it locally. `descix quickstart` runs the same function, so the
4
+ * two cannot disagree about what an initialized app is.
5
+ *
6
+ * THE APP ID IS THE SERVER'S. With -c, the -a value is a NAME and create_app_for_community composes
7
+ * the id ({community}-{short}); this module registers whatever id the server returns and never
8
+ * composes one. `descix init` used to record the NAME as the app id, so the workspace and CLAUDE.md
9
+ * named `myapp` while the platform held `daita-myapp` (devx review 2026-09-19, D2). The agent
10
+ * instruction files are therefore written HERE, after the id exists, and nowhere earlier.
11
+ */
12
+ import chalk from 'chalk';
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
15
+ import { WorkspaceConfig, resolveWorkspacePath } from '../workspace-config.js';
16
+ import { refreshCommunityIdentity, printIdentityReceipt } from './communityIdentity.js';
17
+ import { generateAgentFiles } from '../agent-files.js';
18
+
19
+ /**
20
+ * @param {Object} apiClient an authenticated DeSciXApiClient
21
+ * @param {{app: string, community?: string, short?: string, overwrite?: boolean, kb?: string, path?: string}} options
22
+ * @returns {Promise<{appId: string, communityId: string, appPath: string, kbId: string}>}
23
+ */
24
+ export async function runAppInit(apiClient, options) {
25
+ let appId = options.app;
26
+ const kbId = options.kb || 'General';
27
+
28
+ // Refuse an unusable -p BEFORE anything is written — server-side included. The same resolver
29
+ // the workspace loader applies on every read decides it, so a value accepted here is one the
30
+ // next read accepts too. Refusing only at registration came AFTER a `-c` create, leaving a
31
+ // platform app with no local registration (measured 2026-09-18, daita-docs).
32
+ const workspaceConfig = await WorkspaceConfig.tryLoad();
33
+ if (options.path) {
34
+ resolveWorkspacePath(workspaceConfig?.workspaceRoot || process.cwd(), options.path, appId);
35
+ }
36
+
37
+ // Resolve community_id from the Products registry to learn whether the app exists yet.
38
+ let communityId;
39
+ try {
40
+ const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
41
+ communityId = (productCtx.message || productCtx).community_id;
42
+ } catch (e) {
43
+ // Not in Products — either we create it below (-c given) or we fail loud.
44
+ }
45
+
46
+ if (!communityId) {
47
+ if (!options.community) {
48
+ throw new Error(
49
+ `App '${appId}' does not exist yet. Pass -c <community> to create it: ` +
50
+ `descix app init -a ${appId} -c <community> [-s <short>]`
51
+ );
52
+ }
53
+ // CREATE leg — the canonical server path. It composes the unique app_id
54
+ // ({community}-{short}) and is authoritative; we never compose it client-side.
55
+ const response = await apiClient.invoke('create_app_for_community', {
56
+ community_id: options.community,
57
+ app_name: appId,
58
+ short_name: options.short || undefined,
59
+ create_skeleton: false,
60
+ overwrite: options.overwrite,
61
+ });
62
+ const created = response.message || response;
63
+ appId = created.app_id || appId;
64
+ communityId = created.community_id || options.community;
65
+ console.log(chalk.green(`\n✅ App created: ${appId}`));
66
+ if (appId !== options.app) {
67
+ console.log(chalk.gray(` The platform composed the id from the community and the name you gave.`));
68
+ console.log(chalk.gray(` Use -a ${appId} in every later command.`));
69
+ }
70
+ } else if (options.community && options.community !== communityId) {
71
+ throw new Error(
72
+ `App '${appId}' already exists in community '${communityId}', not '${options.community}'. ` +
73
+ `Omit -c to initialize the existing app.`
74
+ );
75
+ }
76
+
77
+ // 1. Workspace.json — register app if not already mapped
78
+ const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
79
+ let appPath = alreadyMapped?.absolutePath;
80
+
81
+ if (alreadyMapped && options.path) {
82
+ throw new Error(
83
+ `App '${appId}' is already mapped to '${alreadyMapped.localPath}'. ` +
84
+ `Use 'descix app set-localpath -a ${appId} -p <new-path>' to update.`
85
+ );
86
+ }
87
+
88
+ const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
89
+ if (!alreadyMapped) {
90
+ const localPath = options.path || '.';
91
+ const cfg = workspaceConfig || new WorkspaceConfig({}, wsRoot);
92
+ cfg.registerApp(communityId, appId, { localPath, kbId });
93
+ await cfg.save(wsRoot);
94
+ appPath = path.resolve(wsRoot, localPath);
95
+ console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
96
+ }
97
+
98
+ // 2. Create app folder structure (site, microservice, assets)
99
+ const assetsDir = path.join(appPath, 'assets');
100
+ await fs.mkdir(path.join(appPath, 'site'), { recursive: true });
101
+ await fs.mkdir(path.join(appPath, 'microservice'), { recursive: true });
102
+ await fs.mkdir(assetsDir, { recursive: true });
103
+ const siPath = path.join(assetsDir, 'system_instructions.md');
104
+ const descPath = path.join(assetsDir, 'app_description.md');
105
+ try {
106
+ await fs.access(siPath);
107
+ } catch {
108
+ await fs.writeFile(siPath, `# System Instructions for ${appId}\n\nYou are an AI assistant for the ${appId} application.\n`);
109
+ }
110
+ try {
111
+ await fs.access(descPath);
112
+ } catch {
113
+ await fs.writeFile(descPath, `# ${appId}\n\nApplication description goes here.\n`);
114
+ }
115
+ console.log(chalk.gray(` Created: site/, microservice/, assets/`));
116
+
117
+ // 3. Create KnowledgeBase Firestore doc (Git Mode — no Drive required)
118
+ const kbResponse = await apiClient.invoke('init_git_mode_kb', { app_id: appId, kb_name: kbId });
119
+ const kbResult = kbResponse.message || kbResponse;
120
+
121
+ console.log(chalk.green(`\n✓ ${appId} initialized`));
122
+ console.log(chalk.gray(` Community: ${communityId}`));
123
+
124
+ // A community's OWN app (app_id == community_id) mirrors its token symbol and icon from the
125
+ // env-invariant descix-chain registry into this environment (CEO 2026-09-19). The app is
126
+ // already initialized; a refusal here (not an admin of the community, or the community not in
127
+ // the registry) is reported with the command that retries it, not treated as an init failure.
128
+ if (appId === communityId) {
129
+ try {
130
+ printIdentityReceipt(communityId, await refreshCommunityIdentity(apiClient, communityId));
131
+ } catch (err) {
132
+ console.log(chalk.yellow(`\n ⚠ Community identity not mirrored: ${err.message}`));
133
+ console.log(chalk.gray(` Retry with: descix community refresh-identity -c ${communityId}`));
134
+ }
135
+ }
136
+ console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
137
+ console.log(chalk.gray(` (every app is guaranteed a default KB at creation; an empty one`));
138
+ console.log(chalk.gray(` says so rather than answering from general knowledge)\n`));
139
+
140
+ // 4. Agent instruction files, now that the workspace names an id the platform issued. Files the
141
+ // developer has customised (no BOOTSTRAP marker) are left alone by generateAgentFiles.
142
+ const written = await generateAgentFiles(wsRoot);
143
+ for (const f of written) console.log(chalk.green(` ✓ ${f}`));
144
+
145
+ console.log(chalk.cyan('\nNext steps:'));
146
+ // The manifest lives in THIS app's registered directory — never a guessed `apps/<id>/`,
147
+ // which named a path that does not exist for any app registered elsewhere.
148
+ const manifestPath = path.join(appPath, '.descix', 'manifests', `${kbId}.json`);
149
+ console.log(chalk.gray(` Create a corpus manifest at ${path.relative(process.cwd(), manifestPath) || manifestPath}`));
150
+ console.log(chalk.gray(` then run:`));
151
+ console.log(chalk.white(` descix kb corpus sync -a ${appId}\n`));
152
+
153
+ return { appId, communityId, appPath, kbId };
154
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Mirror a community's IDENTITY — token symbol and icon — from the env-invariant descix-chain
3
+ * registry into the current environment's records (CEO 2026-09-19).
4
+ *
5
+ * ONE owner for both callers: `descix app init` (run for a community's own app) and
6
+ * `descix community refresh-identity`. The server reads the registry and verifies the icon
7
+ * actually serves (update_community_metadata with from_registry); this module only asks and
8
+ * reports, so the receipt shows what the SERVER read and wrote, never what we hoped.
9
+ */
10
+ import chalk from 'chalk';
11
+
12
+ /**
13
+ * @param {Object} apiClient
14
+ * @param {string} communityId
15
+ * @returns {Promise<Object>} the server's `registry` block
16
+ * @throws {Error} when the server returns no registry block — an unreported mirror is not a mirror
17
+ */
18
+ export async function refreshCommunityIdentity(apiClient, communityId) {
19
+ const response = await apiClient.invoke('update_community_metadata', {
20
+ community_id: communityId,
21
+ from_registry: true,
22
+ });
23
+ const result = response.message || response;
24
+ if (!result || !result.registry) {
25
+ throw new Error(
26
+ `update_community_metadata returned no registry block for '${communityId}' — the server did ` +
27
+ `not report what it mirrored, so nothing can be claimed about it.`);
28
+ }
29
+ return result.registry;
30
+ }
31
+
32
+ /** Print what was read from the registry and what changed in this environment. */
33
+ export function printIdentityReceipt(communityId, registry) {
34
+ const { token_symbol, icon_url, icon_status, before, community_app_updated } = registry;
35
+ console.log(chalk.cyan(`\n Identity for ${communityId} (from the descix-chain registry):`));
36
+ const sym = before?.token_symbol === token_symbol ? 'unchanged' : `was ${before?.token_symbol ?? 'unset'}`;
37
+ console.log(chalk.gray(` Token symbol: ${token_symbol} (${sym})`));
38
+ if (icon_url) {
39
+ const ic = before?.icon_url === icon_url ? 'unchanged' : 'updated';
40
+ console.log(chalk.gray(` Icon: ${icon_url} (${ic}, ${icon_status})`));
41
+ } else {
42
+ console.log(chalk.yellow(` Icon: not written — the registry icon is ${icon_status}. The store shows a monogram.`));
43
+ }
44
+ console.log(chalk.gray(` Community app record: ${community_app_updated ? 'updated' : 'not found in this environment'}`));
45
+ }
@@ -11,13 +11,12 @@ import { fileURLToPath } from 'url';
11
11
  import { dirname, join } from 'path';
12
12
  // clone.js is imported dynamically inside the invite flow to avoid circular deps
13
13
  import { WorkspaceConfig } from '../workspace-config.js';
14
- import { generateAgentFiles } from '../agent-files.js';
15
14
  // The canonical KB-sync surface is owned by retired-kb-sync.js. Consume the constant: a literal
16
15
  // spelled here is a second derivation of the same fact, and the removed verb this replaced
17
16
  // reached a developer who had done everything right.
18
17
  import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
19
18
  // "May I prompt?" has ONE OWNER. init does not derive it, and holds no TTY check of its own.
20
- import { createPromptSession } from '../interactive.js';
19
+ import { createLazyPromptSession } from '../interactive.js';
21
20
 
22
21
  const __filename = fileURLToPath(import.meta.url);
23
22
  const __dirname = dirname(__filename);
@@ -28,13 +27,15 @@ const __dirname = dirname(__filename);
28
27
  * @param {Object} options - Command options
29
28
  */
30
29
  export async function runInit(apiClient, options = {}) {
31
- // The session is GATED AT CONSTRUCTION: under a non-TTY this throws NonInteractiveError,
32
- // which bin/descix.js's init action prints and exits 1 on. It can no longer hang on an open
33
- // pipe/FIFO, nor exit 0 writing nothing on /dev/null.
34
- const rl = createPromptSession({
30
+ // The session is built only when a question is actually asked, and is still gated then: under
31
+ // a non-TTY an unanswered question throws NonInteractiveError (bin/descix.js prints it and
32
+ // exits 1) — it can never hang on an open pipe, nor exit 0 having written nothing. With every
33
+ // answer given as a flag, nothing is asked, so an AI assistant or a script can run init.
34
+ const rl = createLazyPromptSession({
35
35
  what: 'descix init',
36
36
  nonInteractiveForm: [
37
- 'descix init has no non-interactive form today. Run it in a terminal.',
37
+ 'descix init -c <community> -a <app> --yes',
38
+ ' (add --force to replace an existing .descix/workspace.json)',
38
39
  '',
39
40
  'Nothing has been changed. An existing .descix/workspace.json is left as it is.'
40
41
  ]
@@ -105,14 +106,12 @@ export async function runInit(apiClient, options = {}) {
105
106
  // No existing config
106
107
  }
107
108
 
108
- if (hasExisting && !options.force) {
109
- const overwrite = await rl.askYesNo(chalk.yellow('Workspace already initialized. Overwrite?'), false);
110
- if (!overwrite) {
111
- console.log(chalk.gray('\nInitialization cancelled.\n'));
112
- rl.close();
113
- return {};
114
- }
115
- }
109
+ // An existing workspace is EXTENDED, never replaced. It may carry the environment the developer
110
+ // chose with `descix config init --env dev` — env.apiUrl / env.environment, owned by the config
111
+ // verbs — and init used to write a brand-new file over it, silently moving a DEV workspace to
112
+ // PROD (measured 2026-09-19: DEV before, `config show` said prod after). --force now restarts
113
+ // only the APP registrations; the environment is kept either way.
114
+ const existingConfig = hasExisting ? await WorkspaceConfig.load(projectPath) : null;
116
115
 
117
116
  let communityId = options.community;
118
117
  if (!communityId) {
@@ -151,42 +150,48 @@ export async function runInit(apiClient, options = {}) {
151
150
  }
152
151
  }
153
152
 
154
- const appId = appName.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
155
153
  console.log(chalk.cyan('\n─── Summary ───\n'));
156
154
  console.log(chalk.white(` Project: ${projectPath}`));
157
155
  console.log(chalk.white(` Community: ${communityId}`));
158
- console.log(chalk.white(` App: ${appName} (${appId})`));
156
+ console.log(chalk.white(` App name: ${appName}`));
159
157
 
160
- const proceed = await rl.askYesNo(chalk.white('\nProceed?'), true);
158
+ const proceed = options.yes ? true : await rl.askYesNo(chalk.white('\nProceed?'), true);
161
159
  if (!proceed) {
162
160
  console.log(chalk.gray('\nCancelled.\n'));
163
161
  rl.close();
164
162
  return {};
165
163
  }
166
164
 
167
- console.log(chalk.gray('\nWriting .descix/workspace.json...\n'));
168
- const config = new WorkspaceConfig({ version: '2.0', type: 'workspace', communities: {} }, projectPath);
169
- config.registerApp(communityId, appId, { localPath: '.', kbId: 'General' });
165
+ console.log(chalk.gray(`\n${existingConfig ? 'Updating' : 'Writing'} .descix/workspace.json...\n`));
166
+ const config = existingConfig || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, projectPath);
167
+ if (existingConfig && options.force && config.env) {
168
+ config.env.products = [];
169
+ delete config.env.platform;
170
+ }
171
+ // NO APP IS REGISTERED HERE. The app id is the platform's: `app init` creates the app and
172
+ // registers the id the server returns (with -c it is composed from the community and the
173
+ // name). Recording the NAME here as the id left the workspace and CLAUDE.md naming an app the
174
+ // platform does not have (devx review 2026-09-19, D2). The agent instruction files state that
175
+ // id, so `app init` writes them too, once it exists.
170
176
  await config.save(projectPath);
177
+ // A NEW workspace records the environment this run used (`descix --env dev init …`), through
178
+ // the same owner `config init` uses — otherwise the workspace carried no environment and the
179
+ // very next command fell back to PROD, although the developer had just chosen DEV. An EXISTING
180
+ // workspace keeps its own environment: that choice belongs to the config verbs.
181
+ if (!existingConfig && options.env) {
182
+ await config.setEnvironment(options.env);
183
+ }
171
184
 
172
- console.log(chalk.green('Created:'));
185
+ console.log(chalk.green(`${existingConfig ? 'Updated' : 'Created'}:`));
173
186
  console.log(chalk.green(' ✓ .descix/workspace.json'));
174
187
 
175
- // Generate agent instruction files with correct context
176
- try {
177
- const agentFilesWritten = await generateAgentFiles(projectPath);
178
- for (const f of agentFilesWritten) {
179
- console.log(chalk.green(` ✓ ${f}`));
180
- }
181
- } catch {
182
- // Agent file generation is best-effort
183
- }
184
-
185
188
  console.log(chalk.cyan('\n─── Next Steps ───\n'));
186
- console.log(chalk.white(` 1. descix app init -a ${appId} -c ${communityId} # register app on platform`));
187
- console.log(chalk.gray(` 2. Create a corpus manifest at .descix/manifests/General.json`));
188
- console.log(chalk.white(` 3. ${CANONICAL_KB_SYNC} -a ${appId} # sync KB to Pinecone`));
189
- console.log(chalk.white(` 4. descix chat -c ${communityId} -a ${appId} -q "test" # verify RAG\n`));
189
+ console.log(chalk.white(` 1. descix login # if you are not signed in`));
190
+ console.log(chalk.white(` 2. descix app init -a ${appName} -c ${communityId}`));
191
+ console.log(chalk.gray(` Creates the app and registers the id the platform issues for it (with -c the`));
192
+ console.log(chalk.gray(` id is ${communityId}-<name>), then writes CLAUDE.md and the other agent files.`));
193
+ console.log(chalk.gray(` 3. Create the corpus manifest app init names, then:`));
194
+ console.log(chalk.white(` ${CANONICAL_KB_SYNC} -a <the id app init printed>\n`));
190
195
  console.log(chalk.green('✅ Workspace initialized.\n'));
191
196
  rl.close();
192
197
  return { created: ['.descix/workspace.json'], skipped: [], warnings: [] };
@@ -197,38 +202,4 @@ export async function runInit(apiClient, options = {}) {
197
202
  }
198
203
  }
199
204
 
200
- /**
201
- * Non-interactive init: writes .descix/workspace.json only.
202
- * @param {Object} options - { path?, communityId, appName? }
203
- * @returns {Promise<Object>} { created, skipped, warnings }
204
- */
205
- export async function initWorkspace(options) {
206
- const projectPath = options.path ? path.resolve(options.path) : process.cwd();
207
- const communityId = options.communityId;
208
- const appName = options.appName || path.basename(projectPath).toLowerCase().replace(/[^a-z0-9]/g, '_');
209
- const appId = appName.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
210
- if (!communityId) {
211
- throw new Error('communityId is required');
212
- }
213
- const config = new WorkspaceConfig({ version: '2.0', type: 'workspace', communities: {} }, projectPath);
214
- config.registerApp(communityId, appId, { localPath: '.', kbId: 'General' });
215
- await config.save(projectPath);
216
-
217
- // Generate agent instruction files with workspace context
218
- const created = ['.descix/workspace.json'];
219
- try {
220
- const agentFilesWritten = await generateAgentFiles(projectPath);
221
- created.push(...agentFilesWritten);
222
- } catch {
223
- // Best-effort
224
- }
225
-
226
- return {
227
- created,
228
- skipped: [],
229
- warnings: [],
230
- nextSteps: [`descix app init -a ${appId} -c ${communityId}`, `${CANONICAL_KB_SYNC} -a ${appId}`]
231
- };
232
- }
233
-
234
- export default { runInit, initWorkspace };
205
+ export default { runInit };
@@ -155,4 +155,26 @@ export function createPromptSession({ what, nonInteractiveForm, terminal, destru
155
155
  return { ask, askYesNo, askRaw, close: () => rl.close() };
156
156
  }
157
157
 
158
+ /**
159
+ * A prompt session that is built only when a question is actually ASKED.
160
+ *
161
+ * `createPromptSession` gates at construction, which is right for a command that always
162
+ * prompts. A command whose every answer can arrive as a FLAG must not refuse a non-terminal
163
+ * before it knows it needs one: that made `descix init -c <c> -a <a>` impossible to run from an
164
+ * AI assistant or a script, although nothing was left to ask (measured 2026-09-19). The gate is
165
+ * unchanged, and still the one owner — this only defers WHEN it is consulted.
166
+ *
167
+ * @param {Object} o - exactly createPromptSession's options
168
+ * @returns {{ask: Function, askYesNo: Function, close: Function}}
169
+ */
170
+ export function createLazyPromptSession(o = {}) {
171
+ let session = null;
172
+ const open = () => (session ||= createPromptSession(o));
173
+ return {
174
+ ask: (...args) => open().ask(...args),
175
+ askYesNo: (...args) => open().askYesNo(...args),
176
+ close: () => { if (session) session.close(); },
177
+ };
178
+ }
179
+
158
180
  export default { stdinIsInteractive, requireInteractive, createPromptSession, NonInteractiveError };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * HOW A DEVELOPER (OR THEIR AI ASSISTANT) RUNS THIS CLI — the one owner.
3
+ *
4
+ * The npm package is `@descix/cli`; the binary it installs is `descix`. There is no npm package
5
+ * named `descix`, so `npx descix …` is a 404 — and it was typed, independently, into the
6
+ * "workspace not configured" remedy, the CLAUDE.md template and the docs (devx review 2026-09-19).
7
+ * The editor MCP config meanwhile used a bare `descix`, which exists only after a GLOBAL install,
8
+ * so an npx user's editor could not start the server.
9
+ *
10
+ * `npx -y -p @descix/cli descix …` works for both: with a global install npx finds the binary, and
11
+ * without one it fetches the package. `-y` stops npx from prompting, which an editor launching an
12
+ * MCP server (or an agent running a command) cannot answer.
13
+ */
14
+ export const CLI_PACKAGE = '@descix/cli';
15
+ export const CLI_BIN = 'descix';
16
+ const NPX_PREFIX = ['-y', '-p', CLI_PACKAGE, CLI_BIN];
17
+
18
+ /** `npx -y -p @descix/cli descix <args…>` as one string, for a message a person copies. */
19
+ export function npxCommand(...args) {
20
+ return ['npx', ...NPX_PREFIX, ...args].join(' ');
21
+ }
22
+
23
+ /** The MCP server entry every editor config uses: VS Code `.vscode/mcp.json`, Claude Code `.mcp.json`. */
24
+ export function mcpServerEntry() {
25
+ return { command: 'npx', args: [...NPX_PREFIX, 'mcp-serve'] };
26
+ }
@@ -11,7 +11,8 @@
11
11
  "app init": [
12
12
  "create_app_for_community",
13
13
  "get_product_context",
14
- "init_git_mode_kb"
14
+ "init_git_mode_kb",
15
+ "update_community_metadata"
15
16
  ],
16
17
  "app invite": [
17
18
  "create_app_invite"
@@ -90,6 +91,9 @@
90
91
  "community list": [
91
92
  "find_communities"
92
93
  ],
94
+ "community refresh-identity": [
95
+ "update_community_metadata"
96
+ ],
93
97
  "community rename": [
94
98
  "rename_community"
95
99
  ],
@@ -186,11 +190,15 @@
186
190
  "fetch_my_purchases"
187
191
  ],
188
192
  "quickstart": [
193
+ "create_app_for_community",
189
194
  "device_check_status",
190
195
  "device_request_login",
191
196
  "find_communities",
197
+ "get_product_context",
198
+ "init_git_mode_kb",
192
199
  "resolve_invite",
193
- "sync_auto_purchases"
200
+ "sync_auto_purchases",
201
+ "update_community_metadata"
194
202
  ],
195
203
  "reconnect": [
196
204
  "reconnect_by_wallet"
@@ -6,6 +6,7 @@ import { resolveOrigin } from './origin.js';
6
6
  // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
7
7
  // that can go stale where nobody is watching.
8
8
  import { CANONICAL_KB_SYNC } from './commands/retired-kb-sync.js';
9
+ import { npxCommand } from './invocation.js';
9
10
 
10
11
  /**
11
12
  * THE ONE OWNER of "this app is not mapped in workspace.json, here is how to fix it".
@@ -61,7 +62,7 @@ export function resolveWorkspacePath(workspaceRoot, localPath, label = 'app') {
61
62
  */
62
63
  const NOT_CONFIGURED_MESSAGE =
63
64
  'Workspace not configured.\n' +
64
- 'Run "npx descix init" first to initialize your workspace.';
65
+ `Run "${npxCommand('init')}" first to initialize your workspace.`;
65
66
 
66
67
  /**
67
68
  * THE ONE OWNER of "how do I set the API origin". Named by every refusal that turns a developer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -84,15 +84,18 @@ Use `find_communities` and `list_apps_for_community` to show options, then ask:
84
84
  > "Which community and app should this project target?"
85
85
 
86
86
  ### Checkpoint 3: Environment
87
- > "Should this run against local dev backend (localhost:4000) or hosted API?"
87
+ > "Should this app be built against DEV (https://dev.descix.net) or production?"
88
+
89
+ Pin the choice before anything else; every later command reads it from the workspace.
88
90
 
89
91
  ### Canonical Setup Commands (run in terminal after checkpoints)
90
92
  ```bash
91
- descix whoami # confirm identity
92
- descix app list # see available apps
93
- descix init -c <community> -a <app> -p . # create workspace.json
94
- descix app init -a <app> -c <community> # register app on platform
93
+ descix --env dev config init # pin the environment (omit --env dev for production)
94
+ descix login # sign in
95
+ descix app init -a <name> -c <community> # create the app; the platform issues its id
95
96
  ```
97
+ `app init` prints the app id the platform issued (with `-c` it is `<community>-<name>`). Use that
98
+ id, not the name, as `-a` in every later command.
96
99
 
97
100
  ### Checkpoint 4: Content Strategy
98
101
  > "Should I create a starter KB doc now, or wait for your domain docs?"
@@ -317,15 +320,22 @@ Follow Checkpoint 4 from the doctor's `next_steps`:
317
320
  ### If `claude_code_detected` is true (Claude Code environment)
318
321
  Also create these files for always-on dynamic alignment:
319
322
 
320
- **`.claude/settings.json`** (merge with existing if present):
323
+ **`.mcp.json`** at the project root — Claude Code reads PROJECT MCP servers from this file, not from
324
+ `.claude/settings.json` (`descix quickstart` writes it for you; merge with existing if present):
321
325
  ```json
322
326
  {
323
327
  "mcpServers": {
324
328
  "descix": {
325
329
  "command": "npx",
326
- "args": ["descix", "mcp-serve"]
330
+ "args": ["-y", "-p", "@descix/cli", "descix", "mcp-serve"]
327
331
  }
328
- },
332
+ }
333
+ }
334
+ ```
335
+
336
+ **`.claude/settings.json`** (merge with existing if present):
337
+ ```json
338
+ {
329
339
  "hooks": {
330
340
  "UserPromptSubmit": [
331
341
  {
@@ -56,15 +56,15 @@ Ask the user at each checkpoint — don't assume:
56
56
 
57
57
  1. **Objective:** "Explore existing apps or build something new?"
58
58
  2. **Community/App:** Use `find_communities` + `list_apps_for_community`, then ask which to target
59
- 3. **Environment:** "Local dev (localhost:4000) or hosted API?" (Skip if `app.json` has `api_url`)
59
+ 3. **Environment:** "Build against DEV (https://dev.descix.net) or production?" Pin it first.
60
60
 
61
61
  Canonical setup commands:
62
62
  ```bash
63
- descix whoami # confirm identity
64
- descix app list # see available apps
65
- descix init -c <community> -a <app> -p . # create workspace.json
66
- descix app init -a <app> -c <community> # register app on platform
63
+ descix --env dev config init # pin the environment (omit --env dev for production)
64
+ descix login # sign in
65
+ descix app init -a <name> -c <community> # create the app; the platform issues its id
67
66
  ```
67
+ `app init` prints the issued id (`<community>-<name>` with `-c`); use it as `-a` from then on.
68
68
 
69
69
  4. **Content strategy:** "Create starter KB now or wait for your docs?"
70
70
  5. **Frontend scope:** "Plain HTML or a framework (React, VitePress)?"
@@ -56,15 +56,15 @@ Ask the user at each checkpoint — don't assume:
56
56
 
57
57
  1. **Objective:** "Explore existing apps or build something new?"
58
58
  2. **Community/App:** Use `find_communities` + `list_apps_for_community`, then ask which to target
59
- 3. **Environment:** "Local dev (localhost:4000) or hosted API?" (Skip if `app.json` has `api_url`)
59
+ 3. **Environment:** "Build against DEV (https://dev.descix.net) or production?" Pin it first.
60
60
 
61
61
  Canonical setup commands:
62
62
  ```bash
63
- descix whoami # confirm identity
64
- descix app list # see available apps
65
- descix init -c <community> -a <app> -p . # create workspace.json
66
- descix app init -a <app> -c <community> # register app on platform
63
+ descix --env dev config init # pin the environment (omit --env dev for production)
64
+ descix login # sign in
65
+ descix app init -a <name> -c <community> # create the app; the platform issues its id
67
66
  ```
67
+ `app init` prints the issued id (`<community>-<name>` with `-c`); use it as `-a` from then on.
68
68
 
69
69
  4. **Content strategy:** "Create starter KB now or wait for your docs?"
70
70
  5. **Frontend scope:** "Plain HTML or a framework (React, VitePress)?"
@@ -56,15 +56,15 @@ Ask the user at each checkpoint — don't assume:
56
56
 
57
57
  1. **Objective:** "Explore existing apps or build something new?"
58
58
  2. **Community/App:** Use `find_communities` + `list_apps_for_community`, then ask which to target
59
- 3. **Environment:** "Local dev (localhost:4000) or hosted API?" (Skip if `app.json` has `api_url`)
59
+ 3. **Environment:** "Build against DEV (https://dev.descix.net) or production?" Pin it first.
60
60
 
61
61
  Canonical setup commands:
62
62
  ```bash
63
- descix whoami # confirm identity
64
- descix app list # see available apps
65
- descix init -c <community> -a <app> -p . # create workspace.json
66
- descix app init -a <app> -c <community> # register app on platform
63
+ descix --env dev config init # pin the environment (omit --env dev for production)
64
+ descix login # sign in
65
+ descix app init -a <name> -c <community> # create the app; the platform issues its id
67
66
  ```
67
+ `app init` prints the issued id (`<community>-<name>` with `-c`); use it as `-a` from then on.
68
68
 
69
69
  4. **Content strategy:** "Create starter KB now or wait for your docs?"
70
70
  5. **Frontend scope:** "Plain HTML or a framework (React, VitePress)?"