@descix/cli 1.0.15 → 1.0.16

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,7 @@ 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';
36
37
  import { runDoctor } from '../lib/commands/doctor.js';
37
38
  import { runHealth } from '../lib/commands/health.js';
38
39
  import * as kbCommands from '../lib/commands/kb.js';
@@ -199,7 +200,8 @@ program
199
200
  .option('-c, --community <id>', 'Community ID')
200
201
  .option('-a, --app <name>', 'App name')
201
202
  .option('-p, --path <path>', 'Project path (defaults to current directory)')
202
- .option('-f, --force', 'Overwrite existing workspace.json')
203
+ .option('-f, --force', 'Restart the app registrations in an existing workspace (its environment settings are kept)')
204
+ .option('-y, --yes', 'Skip the confirmation — with -c and -a, init runs without a terminal')
203
205
  .option('--from-invite <token>', 'Resolve an invite token to pre-fill app context')
204
206
  .action(async (options) => {
205
207
  try {
@@ -212,7 +214,7 @@ program
212
214
  // Continue without API client
213
215
  }
214
216
 
215
- await runInit(apiClient, options);
217
+ await runInit(apiClient, { ...options, env: program.opts().env });
216
218
  } catch (error) {
217
219
  console.error(chalk.red(`\n❌ ${error.message}\n`));
218
220
  process.exit(1);
@@ -461,6 +463,22 @@ const communityCommand = program
461
463
  .command('community')
462
464
  .description('Community operations');
463
465
 
466
+ communityCommand
467
+ .command('refresh-identity')
468
+ .description('Mirror a community\'s token symbol and icon from the descix-chain registry into this environment (the icon only if it serves)')
469
+ .requiredOption('-c, --community <id>', 'Community ID')
470
+ .action(async (options) => {
471
+ try {
472
+ const apiClient = new DeSciXApiClient();
473
+ await requireAuth(apiClient);
474
+ printIdentityReceipt(options.community, await refreshCommunityIdentity(apiClient, options.community));
475
+ console.log();
476
+ } catch (error) {
477
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
478
+ process.exit(1);
479
+ }
480
+ });
481
+
464
482
  communityCommand
465
483
  .command('list')
466
484
  .description('List all communities')
@@ -1067,6 +1085,15 @@ appCommand
1067
1085
  let appId = options.app;
1068
1086
  const kbId = options.kb || 'General';
1069
1087
 
1088
+ // Refuse an unusable -p BEFORE anything is written — server-side included. The same
1089
+ // resolver the workspace loader applies on every read decides it, so a value accepted here
1090
+ // is one the next read accepts too. Refusing only at registration came AFTER a `-c` create,
1091
+ // leaving a platform app with no local registration (measured 2026-09-18, daita-docs).
1092
+ const workspaceConfig = await WorkspaceConfig.tryLoad();
1093
+ if (options.path) {
1094
+ resolveWorkspacePath(workspaceConfig?.workspaceRoot || process.cwd(), options.path, appId);
1095
+ }
1096
+
1070
1097
  // Resolve community_id from the Products registry to learn whether the app exists yet.
1071
1098
  let communityId;
1072
1099
  try {
@@ -1106,7 +1133,6 @@ appCommand
1106
1133
  }
1107
1134
 
1108
1135
  // 1. Workspace.json — register app if not already mapped
1109
- const workspaceConfig = await WorkspaceConfig.tryLoad();
1110
1136
  const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
1111
1137
  let appPath = alreadyMapped?.absolutePath;
1112
1138
 
@@ -1158,11 +1184,27 @@ appCommand
1158
1184
 
1159
1185
  console.log(chalk.green(`\n✓ ${appId} initialized`));
1160
1186
  console.log(chalk.gray(` Community: ${communityId}`));
1187
+
1188
+ // A community's OWN app (app_id == community_id) mirrors its token symbol and icon from the
1189
+ // env-invariant descix-chain registry into this environment (CEO 2026-09-19). The app is
1190
+ // already initialized; a refusal here (not an admin of the community, or the community not in
1191
+ // the registry) is reported with the command that retries it, not treated as an init failure.
1192
+ if (appId === communityId) {
1193
+ try {
1194
+ printIdentityReceipt(communityId, await refreshCommunityIdentity(apiClient, communityId));
1195
+ } catch (err) {
1196
+ console.log(chalk.yellow(`\n ⚠ Community identity not mirrored: ${err.message}`));
1197
+ console.log(chalk.gray(` Retry with: descix community refresh-identity -c ${communityId}`));
1198
+ }
1199
+ }
1161
1200
  console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
1162
1201
  console.log(chalk.gray(` (every app is guaranteed a default KB at creation; an empty one`));
1163
1202
  console.log(chalk.gray(` says so rather than answering from general knowledge)\n`));
1164
1203
  console.log(chalk.cyan('Next steps:'));
1165
- console.log(chalk.gray(` Create a corpus manifest at apps/${appId}/.descix/manifests/${kbId}.json`));
1204
+ // The manifest lives in THIS app's registered directory — never a guessed `apps/<id>/`,
1205
+ // which named a path that does not exist for any app registered elsewhere.
1206
+ const manifestPath = path.join(appPath, '.descix', 'manifests', `${kbId}.json`);
1207
+ console.log(chalk.gray(` Create a corpus manifest at ${path.relative(process.cwd(), manifestPath) || manifestPath}`));
1166
1208
  console.log(chalk.gray(` then run:`));
1167
1209
  console.log(chalk.white(` descix kb corpus sync -a ${appId}\n`));
1168
1210
  } catch (error) {
@@ -2177,17 +2219,17 @@ const corpusCommand = kbCommand
2177
2219
 
2178
2220
  corpusCommand
2179
2221
  .command('sync')
2180
- .description('Sync corpus files to Pinecone using manifest definitions')
2222
+ .description('Sync the files a corpus manifest names into the app\'s knowledge base')
2181
2223
  .requiredOption('-a, --app <id>', 'App ID')
2182
2224
  .option('-c, --community <community_id>', 'Community ID (optional; resolved from Products when omitted)')
2183
2225
  .option('-k, --kb <name>', 'KB name (syncs specific manifest; default: all)')
2184
2226
  .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.')
2227
+ .option('--ref <ref>', 'Override the git ref for ALL manifest sources (e.g., --ref my-feature-branch). Precedence: --ref > manifest source.ref > "main".')
2228
+ .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.')
2229
+ .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.')
2230
+ .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
2231
  .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.')
2232
+ .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
2233
  .action(async (options) => {
2192
2234
  try {
2193
2235
  const apiClient = new DeSciXApiClient();
@@ -2317,7 +2359,7 @@ driveCommand
2317
2359
 
2318
2360
  const siteCommand = program
2319
2361
  .command('site')
2320
- .description('Site lifecycle management: init, upload, status, list, delete');
2362
+ .description('Build and publish your app\'s website');
2321
2363
 
2322
2364
  // site init - Copy scaffold to app
2323
2365
  siteCommand
@@ -2432,7 +2474,13 @@ siteCommand
2432
2474
  // Import mime for content types
2433
2475
  const mime = (await import('mime-types')).default;
2434
2476
  const { GitUtils } = await import('@descix/sdk/integrations/git');
2435
- const gitUtils = new GitUtils(process.cwd());
2477
+ // Provenance is read from the directory the FILES come from — the app root when a site
2478
+ // manifest names the sources, else the -p directory — never the caller's cwd. Reading cwd
2479
+ // recorded the WRONG repository's commit on every deploy made from outside the app repo
2480
+ // (measured 2026-09-18: daita-docs, whose files live in DeSciX/daita, was stamped with the
2481
+ // Unkamon commit it happened to be run from).
2482
+ const provenanceDir = siteManifest ? appRoot : path.resolve(options.path);
2483
+ const gitUtils = new GitUtils(provenanceDir);
2436
2484
  const gitStatus = await gitUtils.getStatus();
2437
2485
 
2438
2486
  let fileList; // Array<{ path, hash, size, content_type }>
@@ -4271,7 +4319,7 @@ const mcpCommand = program
4271
4319
  mcpCommand
4272
4320
  .command('execute')
4273
4321
  .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)')
4322
+ .requiredOption('--tool <name>', 'Tool/command name (e.g., find_communities, ask_question_to_app)')
4275
4323
  .option('--params <json>', 'JSON parameters for the tool', '{}')
4276
4324
  .option('-a, --app <app_id>', 'App context (sets app_id in params if not already present)')
4277
4325
  .option('--json', 'Output raw JSON response')
@@ -4535,94 +4583,107 @@ program
4535
4583
  .command('quickstart')
4536
4584
  .description('One-command setup: auth → workspace → agent files → MCP config')
4537
4585
  .option('-u, --url <url>', 'API URL override')
4586
+ .option('-c, --community <id>', 'Community ID for the new workspace')
4587
+ .option('-a, --app <name>', 'App name for the new workspace')
4588
+ .option('-y, --yes', 'Skip the confirmation — with -c and -a, quickstart runs without a terminal')
4538
4589
  .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;
4590
+ // Every failure prints as a message and exits 1 — never a stack trace. A new developer's AI
4591
+ // assistant runs this non-interactively, and an uncaught refusal read as a crash (2026-09-19).
4549
4592
  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 */ }
4593
+ const { generateAgentFiles, generateMcpConfig, generateClaudeCodeMcpConfig } = await import('../lib/agent-files.js');
4594
+ const { WalletFileManager } = await import('../lib/wallet-file.js');
4556
4595
 
4557
- if (needsLogin) {
4558
- const loginOptions = {};
4559
- if (options.url) loginOptions.url = options.url;
4560
- await authCommands.loginDevice(loginOptions);
4561
- }
4596
+ console.log(chalk.cyan('\n🚀 DeSciX Quickstart\n'));
4562
4597
 
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
- }
4598
+ const workspaceRoot = process.cwd();
4593
4599
 
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
- }
4607
-
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
- }
4600
+ // Step 1: Auth — login if no wallet.json
4601
+ const walletPath = WalletFileManager.getWalletPath(workspaceRoot);
4602
+ let needsLogin = true;
4603
+ try {
4604
+ const wallet = await WalletFileManager.loadWalletFile(walletPath);
4605
+ if (wallet && WalletFileManager.hasValidSession(wallet)) {
4606
+ console.log(chalk.green(`✓ Already authenticated as ${wallet.userId}`));
4607
+ needsLogin = false;
4608
+ }
4609
+ } catch { /* no wallet */ }
4615
4610
 
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/'));
4611
+ if (needsLogin) {
4612
+ const loginOptions = {};
4613
+ if (options.url) loginOptions.url = options.url;
4614
+ await authCommands.loginDevice(loginOptions);
4615
+ }
4620
4616
 
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'));
4617
+ // Step 2: Workspace init — create workspace.json only when there is none ANYWHERE UP THE TREE.
4618
+ //
4619
+ // WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
4620
+ // exactly as load() does. This step used to derive it a THIRD time with fs.access on the target
4621
+ // path only (the wizard guard was the second). Run from a SUBDIRECTORY of an existing
4622
+ // workspace, that check saw nothing, runInit created a NESTED workspace.json whose
4623
+ // workspaceRoot pointed at the subdirectory, and it SHADOWED the parent for every later
4624
+ // resolution — silently, with "Quickstart complete!" and exit 0.
4625
+ //
4626
+ // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
4627
+ // SUCCEEDED, so this SKIPS and REPORTS and continues.
4628
+ const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
4629
+ if (existingRoot) {
4630
+ console.log(chalk.green('✓ Workspace already initialized'));
4631
+ if (path.resolve(existingRoot) !== path.resolve(workspaceRoot)) {
4632
+ console.log(chalk.gray(` Workspace root: ${existingRoot}`));
4633
+ console.log(chalk.gray(` You are in a subdirectory of it. Not creating a second workspace`));
4634
+ console.log(chalk.gray(` here — a nested one would shadow the root for every command run`));
4635
+ console.log(chalk.gray(` from this directory.`));
4636
+ }
4637
+ } else {
4638
+ console.log(chalk.cyan('\n📋 Initialize Workspace\n'));
4639
+ // runInit's signature is (apiClient, options). Passing a single object put it in the
4640
+ // apiClient slot and left options defaulting to {}, so `path` was silently discarded and
4641
+ // runInit fell back to process.cwd(). It was MASKED here only because workspaceRoot IS
4642
+ // process.cwd() in this action — the argument was in the wrong slot regardless, and would
4643
+ // have written the workspace to the wrong directory the moment that stopped being true.
4644
+ // This action has no apiClient of its own; null is passed explicitly rather than implied.
4645
+ await runInit(null, { path: workspaceRoot, community: options.community, app: options.app, yes: options.yes, env: program.opts().env });
4646
+ }
4647
+
4648
+ // EVERY REMAINING STEP TARGETS THE RESOLVED ROOT, not the directory the command was typed in.
4649
+ // They read the workspace to learn the app, community and origin they must state, so pointing
4650
+ // them at a subdirectory that deliberately has no workspace.json makes them hard-fail on the
4651
+ // absence this command just chose to preserve. Resolving the root once and ferrying it is the
4652
+ // same one-owner rule that fixed the check above.
4653
+ const targetRoot = existingRoot || workspaceRoot;
4654
+
4655
+ // Step 3: Generate agent instruction files
4656
+ console.log(chalk.cyan('\n📋 Generating Agent Instructions\n'));
4657
+ const written = await generateAgentFiles(targetRoot);
4658
+ for (const f of written) {
4659
+ console.log(chalk.green(` ✓ ${f}`));
4660
+ }
4661
+
4662
+ // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
4663
+ const mcpWritten = await generateMcpConfig(targetRoot);
4664
+ if (mcpWritten) {
4665
+ console.log(chalk.green(' ✓ .vscode/mcp.json'));
4666
+ } else {
4667
+ console.log(chalk.green(' ✓ MCP handled by DeSciX extension (mcp.json skipped)'));
4668
+ }
4669
+ // Claude Code reads project MCP servers from .mcp.json — written regardless of the VS Code case.
4670
+ await generateClaudeCodeMcpConfig(targetRoot);
4671
+ console.log(chalk.green(' ✓ .mcp.json (Claude Code)'));
4672
+
4673
+ // Step 5: Copy SDK assets
4674
+ const { pullSdkAssets } = await import('../lib/sdk-assets.js');
4675
+ await pullSdkAssets(targetRoot);
4676
+ console.log(chalk.green(' ✓ .descix/sdk-assets/'));
4677
+
4678
+ // Done
4679
+ console.log(chalk.green('\n✅ Quickstart complete!\n'));
4680
+ console.log(chalk.white('Open your editor — the AI knows about DeSciX.\n'));
4681
+ console.log(chalk.gray(' Copilot / Cline / Claude Code will have DeSciX MCP tools'));
4682
+ console.log(chalk.gray(' Ask: "What DeSciX tools do I have?"\n'));
4683
+ } catch (error) {
4684
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
4685
+ process.exit(1);
4686
+ }
4626
4687
  });
4627
4688
 
4628
4689
  // ============ 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,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
+ }
@@ -17,7 +17,7 @@ import { generateAgentFiles } from '../agent-files.js';
17
17
  // reached a developer who had done everything right.
18
18
  import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
19
19
  // "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';
20
+ import { createLazyPromptSession } from '../interactive.js';
21
21
 
22
22
  const __filename = fileURLToPath(import.meta.url);
23
23
  const __dirname = dirname(__filename);
@@ -28,13 +28,15 @@ const __dirname = dirname(__filename);
28
28
  * @param {Object} options - Command options
29
29
  */
30
30
  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({
31
+ // The session is built only when a question is actually asked, and is still gated then: under
32
+ // a non-TTY an unanswered question throws NonInteractiveError (bin/descix.js prints it and
33
+ // exits 1) — it can never hang on an open pipe, nor exit 0 having written nothing. With every
34
+ // answer given as a flag, nothing is asked, so an AI assistant or a script can run init.
35
+ const rl = createLazyPromptSession({
35
36
  what: 'descix init',
36
37
  nonInteractiveForm: [
37
- 'descix init has no non-interactive form today. Run it in a terminal.',
38
+ 'descix init -c <community> -a <app> --yes',
39
+ ' (add --force to replace an existing .descix/workspace.json)',
38
40
  '',
39
41
  'Nothing has been changed. An existing .descix/workspace.json is left as it is.'
40
42
  ]
@@ -105,14 +107,12 @@ export async function runInit(apiClient, options = {}) {
105
107
  // No existing config
106
108
  }
107
109
 
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
- }
110
+ // An existing workspace is EXTENDED, never replaced. It may carry the environment the developer
111
+ // chose with `descix config init --env dev` — env.apiUrl / env.environment, owned by the config
112
+ // verbs — and init used to write a brand-new file over it, silently moving a DEV workspace to
113
+ // PROD (measured 2026-09-19: DEV before, `config show` said prod after). --force now restarts
114
+ // only the APP registrations; the environment is kept either way.
115
+ const existingConfig = hasExisting ? await WorkspaceConfig.load(projectPath) : null;
116
116
 
117
117
  let communityId = options.community;
118
118
  if (!communityId) {
@@ -157,17 +157,28 @@ export async function runInit(apiClient, options = {}) {
157
157
  console.log(chalk.white(` Community: ${communityId}`));
158
158
  console.log(chalk.white(` App: ${appName} (${appId})`));
159
159
 
160
- const proceed = await rl.askYesNo(chalk.white('\nProceed?'), true);
160
+ const proceed = options.yes ? true : await rl.askYesNo(chalk.white('\nProceed?'), true);
161
161
  if (!proceed) {
162
162
  console.log(chalk.gray('\nCancelled.\n'));
163
163
  rl.close();
164
164
  return {};
165
165
  }
166
166
 
167
- console.log(chalk.gray('\nWriting .descix/workspace.json...\n'));
168
- const config = new WorkspaceConfig({ version: '2.0', type: 'workspace', communities: {} }, projectPath);
167
+ console.log(chalk.gray(`\n${existingConfig ? 'Updating' : 'Writing'} .descix/workspace.json...\n`));
168
+ const config = existingConfig || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, projectPath);
169
+ if (existingConfig && options.force && config.env) {
170
+ config.env.products = [];
171
+ delete config.env.platform;
172
+ }
169
173
  config.registerApp(communityId, appId, { localPath: '.', kbId: 'General' });
170
174
  await config.save(projectPath);
175
+ // A NEW workspace records the environment this run used (`descix --env dev init …`), through
176
+ // the same owner `config init` uses — otherwise the workspace carried no environment and the
177
+ // very next command fell back to PROD, although the developer had just chosen DEV. An EXISTING
178
+ // workspace keeps its own environment: that choice belongs to the config verbs.
179
+ if (!existingConfig && options.env) {
180
+ await config.setEnvironment(options.env);
181
+ }
171
182
 
172
183
  console.log(chalk.green('Created:'));
173
184
  console.log(chalk.green(' ✓ .descix/workspace.json'));
@@ -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
  ],
@@ -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.16",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -317,15 +317,22 @@ Follow Checkpoint 4 from the doctor's `next_steps`:
317
317
  ### If `claude_code_detected` is true (Claude Code environment)
318
318
  Also create these files for always-on dynamic alignment:
319
319
 
320
- **`.claude/settings.json`** (merge with existing if present):
320
+ **`.mcp.json`** at the project root — Claude Code reads PROJECT MCP servers from this file, not from
321
+ `.claude/settings.json` (`descix quickstart` writes it for you; merge with existing if present):
321
322
  ```json
322
323
  {
323
324
  "mcpServers": {
324
325
  "descix": {
325
326
  "command": "npx",
326
- "args": ["descix", "mcp-serve"]
327
+ "args": ["-y", "-p", "@descix/cli", "descix", "mcp-serve"]
327
328
  }
328
- },
329
+ }
330
+ }
331
+ ```
332
+
333
+ **`.claude/settings.json`** (merge with existing if present):
334
+ ```json
335
+ {
329
336
  "hooks": {
330
337
  "UserPromptSubmit": [
331
338
  {