@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 +155 -94
- package/bin/mcp-server.js +15 -4
- package/lib/agent-files.js +24 -4
- package/lib/auth-guard.js +1 -1
- package/lib/commands/communityIdentity.js +45 -0
- package/lib/commands/init.js +28 -17
- package/lib/interactive.js +22 -0
- package/lib/invocation.js +26 -0
- package/lib/verb-invokes.generated.json +5 -1
- package/lib/workspace-config.js +2 -1
- package/package.json +1 -1
- package/templates/agent-claude.md +10 -3
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', '
|
|
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
|
-
|
|
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
|
|
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
|
|
2186
|
-
.option('--rebuild', '
|
|
2187
|
-
.option('--dry-run', '
|
|
2188
|
-
.option('--show-walk', 'Print the resolved ref
|
|
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
|
|
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('
|
|
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
|
-
|
|
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.,
|
|
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
|
-
|
|
4540
|
-
|
|
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
|
|
4551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
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
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4611
|
+
if (needsLogin) {
|
|
4612
|
+
const loginOptions = {};
|
|
4613
|
+
if (options.url) loginOptions.url = options.url;
|
|
4614
|
+
await authCommands.loginDevice(loginOptions);
|
|
4615
|
+
}
|
|
4620
4616
|
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
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
|
-
//
|
|
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});
|
|
414
|
-
return { 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
|
|
package/lib/agent-files.js
CHANGED
|
@@ -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('
|
|
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
|
+
}
|
package/lib/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
|
32
|
-
//
|
|
33
|
-
// pipe
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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('
|
|
168
|
-
const config = new WorkspaceConfig({ version: '2.
|
|
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'));
|
package/lib/interactive.js
CHANGED
|
@@ -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
|
],
|
package/lib/workspace-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
-
**`.
|
|
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
|
{
|