wtf-p 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +18 -11
  2. package/bin/lib/adapter-compiler.js +28 -10
  3. package/bin/lib/citation-fetcher.js +4 -1
  4. package/bin/lib/cite-nexus-client.js +120 -0
  5. package/package.json +2 -2
  6. package/vendors/antigravity/.wtfp-generated.json +9 -5
  7. package/vendors/antigravity/plugin.json +1 -1
  8. package/vendors/antigravity/tools/README.md +5 -2
  9. package/vendors/antigravity/tools/citation/fetch.js +4 -1
  10. package/vendors/antigravity/tools/support/cite-nexus-client.js +120 -0
  11. package/vendors/antigravity/tools/wtfp-tool.js +18 -7
  12. package/vendors/claude/.claude-plugin/marketplace.json +2 -2
  13. package/vendors/claude/.claude-plugin/plugin.json +1 -1
  14. package/vendors/claude/.wtfp-generated.json +12 -8
  15. package/vendors/claude/ai.iowarp.clio/prompts/wtfp/help.md +1 -1
  16. package/vendors/claude/plugin.json +1 -1
  17. package/vendors/claude/tools/README.md +5 -2
  18. package/vendors/claude/tools/citation/fetch.js +4 -1
  19. package/vendors/claude/tools/support/cite-nexus-client.js +120 -0
  20. package/vendors/claude/tools/wtfp-tool.js +18 -7
  21. package/vendors/codex/plugins/wtfp/.codex-plugin/plugin.json +1 -1
  22. package/vendors/codex/plugins/wtfp/.wtfp-generated.json +10 -6
  23. package/vendors/codex/plugins/wtfp/plugin.json +1 -1
  24. package/vendors/codex/plugins/wtfp/tools/README.md +5 -2
  25. package/vendors/codex/plugins/wtfp/tools/citation/fetch.js +4 -1
  26. package/vendors/codex/plugins/wtfp/tools/support/cite-nexus-client.js +120 -0
  27. package/vendors/codex/plugins/wtfp/tools/wtfp-tool.js +18 -7
  28. package/vendors/copilot/.wtfp-generated.json +2 -2
  29. package/vendors/copilot/marketplace.json +2 -2
  30. package/vendors/copilot/plugins/wtfp/.claude-plugin/plugin.json +1 -1
  31. package/vendors/copilot/plugins/wtfp/.wtfp-generated.json +9 -5
  32. package/vendors/copilot/plugins/wtfp/tools/README.md +5 -2
  33. package/vendors/copilot/plugins/wtfp/tools/citation/fetch.js +4 -1
  34. package/vendors/copilot/plugins/wtfp/tools/support/cite-nexus-client.js +120 -0
  35. package/vendors/copilot/plugins/wtfp/tools/wtfp-tool.js +18 -7
  36. package/vendors/gemini/.wtfp-generated.json +9 -5
  37. package/vendors/gemini/gemini-extension.json +1 -1
  38. package/vendors/gemini/tools/README.md +5 -2
  39. package/vendors/gemini/tools/citation/fetch.js +4 -1
  40. package/vendors/gemini/tools/support/cite-nexus-client.js +120 -0
  41. package/vendors/gemini/tools/wtfp-tool.js +18 -7
  42. package/vendors/opencode/.wtfp-generated.json +8 -4
  43. package/vendors/opencode/tools/README.md +5 -2
  44. package/vendors/opencode/tools/citation/fetch.js +4 -1
  45. package/vendors/opencode/tools/support/cite-nexus-client.js +120 -0
  46. package/vendors/opencode/tools/wtfp-tool.js +18 -7
  47. package/vendors/plugin/.wtfp-generated.json +10 -6
  48. package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/help.md +1 -1
  49. package/vendors/plugin/plugin.json +1 -1
  50. package/vendors/plugin/tools/README.md +5 -2
  51. package/vendors/plugin/tools/citation/fetch.js +4 -1
  52. package/vendors/plugin/tools/support/cite-nexus-client.js +120 -0
  53. package/vendors/plugin/tools/wtfp-tool.js +18 -7
package/README.md CHANGED
@@ -19,9 +19,16 @@ the scientific judgment. The agent keeps the bookkeeping and does bounded jobs.
19
19
 
20
20
  It ships as one canonical [Agent Plugins 1.0.0](https://agent-plugins.org)
21
21
  bundle that a deterministic compiler projects into a native package for seven
22
- coding-agent hosts. Version `0.6.0` is the first stable release of that
23
- design and is what npm `latest` resolves to; v0.5 remains installable by
24
- explicit version.
22
+ coding-agent hosts. Version `0.7.1` is the current stable release and is
23
+ what npm `latest` resolves to; v0.5 remains installable by explicit version.
24
+
25
+ **Optional CiteNexus research backend (since 0.7.0):** `citation-search --backend=cite-nexus`
26
+ queries Crossref, DataCite and Europe PMC by default, plus explicitly selected academic
27
+ APIs, through the [CiteNexus](docs/CITE_NEXUS.md) MCP server. Results keep their source
28
+ evidence and stay candidates. CiteNexus is a separate Python package
29
+ (`pip install cite-nexus-mcp==0.2.0`) that WTF-P never installs for you. Research
30
+ actions reach it on Clio Coder and Claude Code; the other hosts keep their existing
31
+ capability blockers.
25
32
 
26
33
  ## Install on your agent
27
34
 
@@ -49,13 +56,13 @@ registers it with the host's native plugin lifecycle where one exists.
49
56
 
50
57
  | Host | Install | Native registration the installer performs | Verified with |
51
58
  | --- | --- | --- | --- |
52
- | Clio Coder (0.4.7 or newer) | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install clio` | `clio-coder library install <staged-bundle> --user`, then `clio-coder library inspect wtfp --user --json` | 0.4.8 |
53
- | Claude Code | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install claude` | `claude plugin marketplace add <root>/marketplaces/wtfp --scope user`, then `claude plugin install wtfp@wtfp --scope user -y` | 2.1.271 |
54
- | Codex | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install codex` | `codex plugin marketplace add <root>/marketplaces/wtfp`, then `codex plugin add wtfp@wtfp --json`; agents copied to `$CODEX_HOME/agents/` | 0.153.3 |
55
- | GitHub Copilot CLI | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install copilot` | `copilot plugin marketplace add <root>/marketplaces/wtfp`, then `copilot plugin install wtfp@wtfp` | 1.0.83 |
56
- | OpenCode | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install opencode` | Files under the OpenCode config root; OpenCode discovers them by directory | 1.18.31 |
57
- | Antigravity CLI | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install antigravity` | `agy plugin install <root>/sources/wtfp` | 1.2.2 |
58
- | Gemini CLI | `npx --yes --package=wtf-p@0.6.0 -- wtf-p install gemini` | Files under `<root>/extensions/wtfp`; Gemini discovers the extension by directory | 0.59.0 |
59
+ | Clio Coder (0.4.7 or newer) | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install clio` | `clio-coder library install <staged-bundle> --user`, then `clio-coder library inspect wtfp --user --json` | 0.4.9 (local build) |
60
+ | Claude Code | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install claude` | `claude plugin marketplace add <root>/marketplaces/wtfp --scope user`, then `claude plugin install wtfp@wtfp --scope user -y` | 2.1.274 |
61
+ | Codex | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install codex` | `codex plugin marketplace add <root>/marketplaces/wtfp`, then `codex plugin add wtfp@wtfp --json`; agents copied to `$CODEX_HOME/agents/` | 0.153.3 |
62
+ | GitHub Copilot CLI | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install copilot` | `copilot plugin marketplace add <root>/marketplaces/wtfp`, then `copilot plugin install wtfp@wtfp` | 1.0.85 |
63
+ | OpenCode | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install opencode` | Files under the OpenCode config root; OpenCode discovers them by directory | 1.18.31 |
64
+ | Antigravity CLI | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install antigravity` | `agy plugin install <root>/sources/wtfp` | 1.2.5 |
65
+ | Gemini CLI | `npx --yes --package=wtf-p@0.7.1 -- wtf-p install gemini` | Files under `<root>/extensions/wtfp`; Gemini discovers the extension by directory | 0.60.0 |
59
66
 
60
67
  "Verified" means native discovery in a disposable profile on this exact
61
68
  envelope, with the commands recorded in
@@ -69,7 +76,7 @@ the old executable instead.
69
76
  ## Sixty-second start
70
77
 
71
78
  ```bash
72
- npx --yes --package=wtf-p@0.6.0 -- wtf-p install clio
79
+ npx --yes --package=wtf-p@0.7.1 -- wtf-p install clio
73
80
  cd /path/to/your-paper-or-proposal
74
81
  clio-coder --autonomy suggest
75
82
  ```
@@ -1410,13 +1410,13 @@ function toolOutputPath(tool) {
1410
1410
 
1411
1411
  // The one command a host is ever asked to run. It resolves a logical tool id
1412
1412
  // through the packaged module map, bounds every argument, and prints JSON on
1413
- // stdout. Nothing here shells out, writes a file, or reads a path the caller
1414
- // did not name.
1413
+ // stdout. The explicit CiteNexus backend launches only its installed companion
1414
+ // without a shell. No tool writes a file or reads a path the caller did not name.
1415
1415
  const TOOL_COMMANDS = Object.freeze([
1416
1416
  { command: 'bib-index', tool: 'bibliography.index', usage: 'bib-index <bib-file> [--key=<citation-key>] [--query=<text>]' },
1417
1417
  { command: 'bib-format', tool: 'bibliography.format', usage: 'bib-format <bib-file> --key=<citation-key> [--style=<bibtex|al-folio>]' },
1418
1418
  { command: 'bib-impact', tool: 'bibliography.analyze-impact', usage: 'bib-impact <bib-file> [--timeout=<seconds>]' },
1419
- { command: 'citation-search', tool: 'citation.fetch', usage: 'citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]' },
1419
+ { command: 'citation-search', tool: 'citation.fetch', usage: 'citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]' },
1420
1420
  { command: 'scholar-search', tool: 'citation.scholar-lookup', usage: 'scholar-search --query=<text> [--limit=<1-25>] [--timeout=<seconds>]' },
1421
1421
  { command: 's2-search', tool: 'citation.semantic-scholar', usage: 's2-search --query=<text> [--limit=<1-25>] [--year=<yyyy>] [--timeout=<seconds>]' },
1422
1422
  { command: 'rank', tool: 'citation.rank', usage: 'rank <papers.json> [--intent=<seminal|recent|balanced>]' }
@@ -1463,9 +1463,9 @@ function offlineRequested(argv) {
1463
1463
  return argv.includes('--offline') || flag === '1' || flag === 'true';
1464
1464
  }
1465
1465
 
1466
- function fail(message) {
1466
+ function fail(message, status = 1) {
1467
1467
  process.stderr.write(\`\${JSON.stringify({ error: message })}\\n\`);
1468
- process.exit(1);
1468
+ process.exit(status);
1469
1469
  }
1470
1470
 
1471
1471
  function emit(value) {
@@ -1520,7 +1520,7 @@ function requirePath(candidate) {
1520
1520
 
1521
1521
  function timeoutOf(flags) {
1522
1522
  if (!flags.has('timeout')) return DEFAULT_TIMEOUT_SECONDS;
1523
- const seconds = Number.parseInt(flags.get('timeout'), 10);
1523
+ const seconds = /^\\d+$/.test(flags.get('timeout')) ? Number(flags.get('timeout')) : NaN;
1524
1524
  if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
1525
1525
  fail(\`--timeout must be an integer number of seconds between 1 and \${MAX_TIMEOUT_SECONDS}\`);
1526
1526
  }
@@ -1551,7 +1551,7 @@ function uniqueEntry(bib, content, key) {
1551
1551
 
1552
1552
  function limitOf(flags) {
1553
1553
  if (!flags.has('limit')) return 10;
1554
- const limit = Number.parseInt(flags.get('limit'), 10);
1554
+ const limit = /^\\d+$/.test(flags.get('limit')) ? Number(flags.get('limit')) : NaN;
1555
1555
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail(\`--limit must be an integer between 1 and \${MAX_LIMIT}\`);
1556
1556
  return limit;
1557
1557
  }
@@ -1588,6 +1588,13 @@ async function main(rawArgv) {
1588
1588
  fail(\`\${command} is refused in offline mode: it declares \${networkEffects.join(', ')}\`);
1589
1589
  }
1590
1590
  const { flags, positional } = parseArguments(argv.slice(1));
1591
+ const allowedFlags = {
1592
+ 'bib-index': ['key', 'query'], 'bib-format': ['key', 'style'],
1593
+ 'bib-impact': ['timeout'], 'citation-search': ['query', 'backend', 'providers', 'limit', 'intent', 'year', 'timeout'],
1594
+ 'scholar-search': ['query', 'limit', 'timeout'], 's2-search': ['query', 'limit', 'year', 'timeout'],
1595
+ 'rank': ['intent']
1596
+ };
1597
+ for (const key of flags.keys()) if (!allowedFlags[command].includes(key)) fail(\`unknown --\${key} for \${command}\`);
1591
1598
  if (positional.length > 1) fail(\`\${command} accepts at most one positional argument: \${declared.usage}\`);
1592
1599
  const load = () => require(MODULES[declared.tool]);
1593
1600
 
@@ -1622,7 +1629,11 @@ async function main(rawArgv) {
1622
1629
  const query = requireText(flags.get('query'), '--query');
1623
1630
  const year = yearOf(flags);
1624
1631
  const seconds = timeoutOf(flags);
1625
- return emit(await withTimeout(load().search(query, { limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
1632
+ const backend = flags.get('backend') || 'legacy';
1633
+ if (!['legacy', 'cite-nexus'].includes(backend)) fail('--backend must be legacy or cite-nexus');
1634
+ const providers = flags.has('providers') ? flags.get('providers').split(',').map((p) => p.trim()) : undefined;
1635
+ if (providers && backend !== 'cite-nexus') fail('--providers requires --backend=cite-nexus');
1636
+ return emit(await withTimeout(load().search(query, { backend, providers, timeoutSeconds: seconds, limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
1626
1637
  }
1627
1638
  if (command === 'scholar-search') {
1628
1639
  if (positional.length > 0) fail(\`\${command} takes its query through --query: \${declared.usage}\`);
@@ -1649,7 +1660,7 @@ async function main(rawArgv) {
1649
1660
  // config root as a custom-tool module; an unconditional main() printed a
1650
1661
  // dispatcher error and exited the host process at session start.
1651
1662
  if (require.main === module) {
1652
- main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error)));
1663
+ main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error), error && error.code === 'WTFP_TIMEOUT' ? EXIT_TIMEOUT : 1));
1653
1664
  }
1654
1665
  `;
1655
1666
  }
@@ -1657,6 +1668,10 @@ if (require.main === module) {
1657
1668
  function addToolBundle(plan) {
1658
1669
  const registry = readJson(path.join(PROTOCOL_ROOT, 'tools.json'));
1659
1670
  const byLegacyName = new Map(registry.tools.map((tool) => [tool.legacyName, toolOutputPath(tool)]));
1671
+ // One private companion dependency, owned and hashed with every envelope.
1672
+ // It is not a logical tool and receives no independent execution grant.
1673
+ byLegacyName.set('cite-nexus-client', 'tools/support/cite-nexus-client.js');
1674
+ addFile(plan, 'tools/support/cite-nexus-client.js', fs.readFileSync(path.join(ROOT, 'bin', 'lib', 'cite-nexus-client.js'), 'utf8'));
1660
1675
  const byToolId = new Map(registry.tools.map((tool) => [tool.id, toolOutputPath(tool)]));
1661
1676
  const effectsByToolId = new Map(registry.tools.map((tool) => [tool.id, [...tool.effects]]));
1662
1677
  for (const entry of TOOL_COMMANDS) {
@@ -1689,9 +1704,10 @@ function addToolBundle(plan) {
1689
1704
  '',
1690
1705
  generatedBanner('protocol', 'tools.json'),
1691
1706
  '',
1692
- 'Only implementations declared by `tools.json` are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.',
1707
+ 'Only implementations declared by `tools.json`, the dispatcher, and its private CiteNexus companion dependency are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.',
1693
1708
  '',
1694
1709
  ...rows,
1710
+ '- Private dependency: `tools/support/cite-nexus-client.js`; reached only through `citation.fetch`, never executed directly.',
1695
1711
  '',
1696
1712
  '## Executing a bundled tool',
1697
1713
  '',
@@ -1705,6 +1721,8 @@ function addToolBundle(plan) {
1705
1721
  '',
1706
1722
  ...TOOL_COMMANDS.map((entry) => `- \`${entry.usage}\` → \`${entry.tool}\` (${effectsByToolId.get(entry.tool).length > 0 ? effectsByToolId.get(entry.tool).join(', ') : 'no declared effects'})`),
1707
1723
  '',
1724
+ 'For approved scholarly discovery, `citation-search --backend=cite-nexus --query="<topic>"` uses the separately installed `cite-nexus-wtfp` companion and its real MCP stdio server. Defaults are Crossref, DataCite and Europe PMC. Select optional vendors explicitly with `--providers`; include selected providers and query scope in the action approval. CiteNexus supports only balanced provider ordering, so omit `--intent` or use `--intent=balanced`. Results remain candidates: retain `citeNexus.sources`, field attribution, metrics, warnings and `metadata.errors`; do not infer verification or combine citation counts. The result limit is a displayed total; `metadata.total` counts the fetched deduplicated page, not the full corpus. Unavailable enrichment fails explicitly without falling back to another vendor. `WTFP_CITE_NEXUS_COMMAND` may name an absolute installed companion executable; it is operator configuration, never source content. No package is installed, server registered, or user profile changed by a tool call. Offline mode refuses this backend before process launch. Host capability blockers still apply.',
1725
+ '',
1708
1726
  'Every command prints one JSON document on stdout and reports failures as `{"error": "..."}` on stderr with exit status 1. Queries are capped at 512 characters, file paths at 4096, and result limits at 25. A symlinked file is accepted and read through its resolved target, which must be a regular file. Commands whose effects include `network.*` perform outbound requests to the declared scholarly indexes; pass `--offline` or set `WTFP_TOOL_OFFLINE=1` to refuse them, which is the mechanical form of "do not invoke a network-capable bibliography tool through a filesystem-only permission path". Each network command has a hard wall clock, `--timeout=<seconds>` (default 20, maximum 600); on expiry it reports `{"error": "<command> timed out after N s"}` on stderr and exits 124. `bib-impact` reports batch progress on stderr. `bib-index` flags repeated keys with `duplicate: true` and lists them under `duplicates`; `--key` refuses an ambiguous key. `bib-format` emits a standard BibTeX entry (`@article`, `@inproceedings`, ...) by default; `--style=al-folio` selects the Jekyll al-folio projection, which is not valid BibTeX. Do not execute any other module in this package directly, and do not pass a logical `project://` or `wtfp://` URI as a shell argument.',
1709
1727
  ''
1710
1728
  ].join('\n'));
@@ -160,6 +160,9 @@ function deduplicatePapers(papers) {
160
160
  // --- Main Search Logic ---
161
161
 
162
162
  async function search(query, options = {}) {
163
+ if (options.backend === 'cite-nexus') return require('./cite-nexus-client').search(query, options);
164
+ if (options.backend && options.backend !== 'legacy') throw new Error('Unknown citation backend');
165
+ if (options.providers) throw new Error('Provider selection requires --backend=cite-nexus');
163
166
  const limit = options.limit || 10;
164
167
  const intent = options.intent || 'balanced';
165
168
 
@@ -299,4 +302,4 @@ if (require.main === module) {
299
302
  });
300
303
  }
301
304
 
302
- module.exports = { search };
305
+ module.exports = { search };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ // Optional companion to citation.fetch, never a separately executable tool.
4
+ const { spawn } = require('child_process');
5
+ const path = require('path');
6
+ const SCHEMA = 'cite-nexus.wtfp/v1';
7
+ const MAX_BYTES = 2 * 1024 * 1024;
8
+ const PROVIDERS = Object.freeze({
9
+ crossref: ['CITE_NEXUS_CONTACT_EMAIL'], datacite: [], europe_pmc: [], arxiv: [],
10
+ semantic_scholar: ['SEMANTIC_SCHOLAR_API_KEY'], openalex: ['OPENALEX_API_KEY'],
11
+ serpapi: ['SERPAPI_API_KEY'], scopus: ['SCOPUS_API_KEY', 'SCOPUS_INSTTOKEN'], wos: ['WOS_API_KEY']
12
+ });
13
+ const RUNTIME_ENV = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP'];
14
+
15
+ function timedOut() {
16
+ return Object.assign(new Error('CiteNexus request timed out'), { code: 'WTFP_TIMEOUT' });
17
+ }
18
+
19
+ function requestFor(query, options) {
20
+ if (process.env.WTFP_TOOL_OFFLINE === '1' || process.env.WTFP_TOOL_OFFLINE === 'true') {
21
+ throw new Error('CiteNexus is unavailable in offline mode');
22
+ }
23
+ if (typeof query !== 'string' || !query.trim() || query.length > 512) throw new Error('CiteNexus query must contain 1-512 characters');
24
+ const limit = options.limit === undefined ? 10 : options.limit;
25
+ if (!Number.isInteger(limit) || limit < 1 || limit > 25) throw new Error('CiteNexus limit must be an integer between 1 and 25');
26
+ const providers = options.providers || ['crossref', 'datacite', 'europe_pmc'];
27
+ if (!Array.isArray(providers) || providers.length < 1 || providers.length > 9 ||
28
+ providers.some((p) => typeof p !== 'string' || !Object.hasOwn(PROVIDERS, p))) {
29
+ throw new Error('CiteNexus providers must be a nonempty list of supported provider IDs');
30
+ }
31
+ const timeout = options.timeoutSeconds === undefined ? 20 : options.timeoutSeconds;
32
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new Error('CiteNexus timeout must be an integer between 1 and 600 seconds');
33
+ const request = { schema_version: SCHEMA, operation: 'search', query, limit, providers: [...new Set(providers)], timeout_seconds: timeout };
34
+ if (options.year != null) {
35
+ if (!/^\d{4}$/.test(String(options.year)) || Number(options.year) < 1000 || Number(options.year) > 2200) throw new Error('CiteNexus year must be between 1000 and 2200');
36
+ request.year = Number(options.year);
37
+ }
38
+ if (options.intent && options.intent !== 'balanced') throw new Error('CiteNexus preserves provider ordering; use --intent=balanced');
39
+ return request;
40
+ }
41
+
42
+ function validateResponse(text, limit) {
43
+ let data;
44
+ try { data = JSON.parse(text); } catch { throw new Error('CiteNexus returned malformed JSON'); }
45
+ const stack = [[data, 0]];
46
+ while (stack.length) {
47
+ const [value, depth] = stack.pop();
48
+ if (depth > 32) throw new Error('CiteNexus response nesting exceeds 32 levels');
49
+ if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push([child, depth + 1]);
50
+ }
51
+ if (!data || data.schema_version !== SCHEMA || !Array.isArray(data.results) || data.results.length > limit ||
52
+ !data.metadata || data.metadata.backend !== 'cite-nexus' || !Array.isArray(data.metadata.errors) ||
53
+ data.metadata.returned !== data.results.length ||
54
+ data.results.some((p) => !p || p.verification !== 'candidate' || typeof p.title !== 'string' ||
55
+ typeof p.bibtex !== 'string' || !p.citeNexus || !Array.isArray(p.citeNexus.sources) || !Array.isArray(p.citeNexus.metrics))) {
56
+ throw new Error('CiteNexus returned an incompatible response; update both companions');
57
+ }
58
+ return data;
59
+ }
60
+
61
+ async function search(query, options = {}) {
62
+ const request = requestFor(query, options);
63
+ if (options.signal && options.signal.aborted) throw new Error('CiteNexus request cancelled');
64
+ const command = process.env.WTFP_CITE_NEXUS_COMMAND || 'cite-nexus-wtfp';
65
+ if (command !== 'cite-nexus-wtfp' && !path.isAbsolute(command)) {
66
+ throw new Error('WTFP_CITE_NEXUS_COMMAND must name an absolute installed executable');
67
+ }
68
+ const allowed = [...RUNTIME_ENV, ...request.providers.flatMap((p) => PROVIDERS[p])];
69
+ const env = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]));
70
+ return new Promise((resolve, reject) => {
71
+ // No shell, command arguments, URL or executable can arrive from source data.
72
+ const child = spawn(command, [], { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
73
+ let settled = false, bytes = 0;
74
+ const chunks = [];
75
+ const stop = () => { if (child.exitCode === null) child.kill('SIGTERM'); };
76
+ const onExit = () => stop();
77
+ const onSignal = () => { stop(); process.exit(130); };
78
+ const finish = (error, data) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ process.removeListener('exit', onExit);
83
+ process.removeListener('SIGTERM', onSignal);
84
+ process.removeListener('SIGINT', onSignal);
85
+ if (options.signal) options.signal.removeEventListener('abort', cancel);
86
+ if (error) {
87
+ stop();
88
+ // The Python bridge cancels MCP and closes the SDK-owned server group.
89
+ const kill = setTimeout(() => child.kill('SIGKILL'), 7000);
90
+ kill.unref();
91
+ child.once('close', () => clearTimeout(kill));
92
+ reject(error);
93
+ } else resolve(data);
94
+ };
95
+ const cancel = () => finish(new Error('CiteNexus request cancelled'));
96
+ const timer = setTimeout(() => finish(timedOut()), request.timeout_seconds * 1000);
97
+ process.once('exit', onExit);
98
+ process.once('SIGTERM', onSignal);
99
+ process.once('SIGINT', onSignal);
100
+ if (options.signal) options.signal.addEventListener('abort', cancel, { once: true });
101
+ child.on('error', () => finish(new Error('CiteNexus companion unavailable; install cite-nexus-mcp 0.2.0 separately and run cite-nexus-wtfp --check')));
102
+ child.stdin.on('error', () => finish(new Error('CiteNexus companion closed its input')));
103
+ child.stdout.on('data', (chunk) => {
104
+ bytes += chunk.length;
105
+ if (bytes > MAX_BYTES) return finish(new Error('CiteNexus response exceeds 2 MiB'));
106
+ if (!settled) chunks.push(chunk);
107
+ });
108
+ // Drain, but never relay child stderr (it may contain sensitive diagnostics).
109
+ child.stderr.on('data', () => {});
110
+ child.on('close', (code) => {
111
+ if (settled) return;
112
+ if (code !== 0) return finish(code === 124 ? timedOut() : new Error('CiteNexus companion failed; run cite-nexus-wtfp --check'));
113
+ try { finish(null, validateResponse(Buffer.concat(chunks).toString('utf8'), request.limit)); }
114
+ catch (error) { finish(error); }
115
+ });
116
+ child.stdin.end(JSON.stringify(request));
117
+ });
118
+ }
119
+
120
+ module.exports = { search, requestFor, validateResponse };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtf-p",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Portable, evidence-grounded academic writing workflows (papers, proposals, posters, presentations) for Clio Coder, Claude Code, Codex, GitHub Copilot CLI, OpenCode, Antigravity CLI, and Gemini CLI.",
5
5
  "bin": {
6
6
  "wtfp": "bin/install.js",
@@ -11,7 +11,7 @@
11
11
  "build:adapters": "node scripts/build-adapters.js",
12
12
  "check:adapters": "node scripts/build-adapters.js --check",
13
13
  "test:evaluation": "node evaluation/tools/lint.js && node test/compiler-v4-evidence.test.js && node test/dynamo-lifecycle-evidence.test.js && node test/evaluation-comparison.test.js && node test/evaluation.test.js && node test/evaluation-routing-runner.test.js && node test/evaluation-lifecycle.test.js && node test/evaluation-fleets.test.js && node test/nsf25-531-proposal-uat.test.js",
14
- "test:tools": "node test/lib/bib-index.test.js && node test/lib/bib-format.test.js && node test/lib/citation-ranker.test.js && node test/lib/analyze-impact.test.js && node test/lib/scholar-lookup.test.js && node test/lib/semantic-scholar.test.js && node test/lib/citation-fetcher.test.js",
14
+ "test:tools": "node test/lib/bib-index.test.js && node test/lib/bib-format.test.js && node test/lib/citation-ranker.test.js && node test/lib/analyze-impact.test.js && node test/lib/scholar-lookup.test.js && node test/lib/semantic-scholar.test.js && node test/lib/citation-fetcher.test.js && node test/lib/cite-nexus-client.test.js",
15
15
  "test:protocol": "node test/protocol-catalog.test.js && node test/canonical-workflows.test.js && node test/portable-roles.test.js && node test/project-protocol.test.js && node test/test-skills.test.js && npm run test:tools && npm run test:evaluation && node test/adapter-compiler.test.js && node test/tool-dispatcher.test.js",
16
16
  "test:installer": "node test/import-safety.test.js && node test/ownership.test.js && node test/ownership-races.test.js && node test/status-partial.test.js && node test/paths.js && node test/modern-targets.test.js && node test/clio-installer.test.js && node test/installer.test.js",
17
17
  "test:compatibility": "node test/sanity.js && node test/linter.js && node test/dry-run.js && node test/feature-tests.js",
@@ -2,7 +2,7 @@
2
2
  "schema": "wtfp.generated-adapter/v1",
3
3
  "generatorVersion": 5,
4
4
  "target": "antigravity",
5
- "sourceHash": "a189e7094e5c62db7ff8da9c4c6146c6c131ea5cbd9345ff3c4031d3a9e9a371",
5
+ "sourceHash": "fd300ebe88a80d19a91efd0d8e22700782eceec0728584e5d1004a54794804a7",
6
6
  "files": [
7
7
  {
8
8
  "path": "actions/add-todo.json",
@@ -362,7 +362,7 @@
362
362
  },
363
363
  {
364
364
  "path": "plugin.json",
365
- "sha256": "c025a56dccbfb22881924e0b776161423b030944bea4ada3b3cf473e25a97035"
365
+ "sha256": "1017fe85464c00f4d1f2ad8ac23c1aace4f2edfccccd264b7ba6580568dc42bf"
366
366
  },
367
367
  {
368
368
  "path": "project/README.md",
@@ -650,7 +650,7 @@
650
650
  },
651
651
  {
652
652
  "path": "tools/citation/fetch.js",
653
- "sha256": "56c92d67709d7fc21b8b6e00492ba7e22e60b402c36b4f1d0780a9cf45c56d7b"
653
+ "sha256": "fa24cc676bdad1699c1ef3ca56ac358268ec516a1aa1305ffe0760941ec8d756"
654
654
  },
655
655
  {
656
656
  "path": "tools/citation/rank.js",
@@ -666,11 +666,15 @@
666
666
  },
667
667
  {
668
668
  "path": "tools/README.md",
669
- "sha256": "92e77815dd04a1b1df4606daefd76de5dba88a6351a3c8cd7810a94d70db8e2b"
669
+ "sha256": "997889703a61599a3d6267b1ddbd571ac354b55e7fe3a9dc2ebbf00b6c6d2106"
670
+ },
671
+ {
672
+ "path": "tools/support/cite-nexus-client.js",
673
+ "sha256": "9a7a9a548477b14cadda2be40ed95e579d809956e63ccf19092edde8de46d2dc"
670
674
  },
671
675
  {
672
676
  "path": "tools/wtfp-tool.js",
673
- "sha256": "4eb713f525eb0b2c13ccc5b1126611039654927fdecfd76c5aa62dc232fcb8c6"
677
+ "sha256": "d7fe8278161d5a18f33be87a41c374e2a9c90657ac2315c24a1697793f750e19"
674
678
  },
675
679
  {
676
680
  "path": "workflows/add-todo.md",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "wtfp",
4
- "description": "Portable academic research and writing workflows (WTF-P 0.6.0)."
4
+ "description": "Portable academic research and writing workflows (WTF-P 0.7.1)."
5
5
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- Generated by WTF-P adapter compiler v5 from protocol/tools.json; do not edit. -->
4
4
 
5
- Only implementations declared by `tools.json` are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.
5
+ Only implementations declared by `tools.json`, the dispatcher, and its private CiteNexus companion dependency are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.
6
6
 
7
7
  - `wtfp://tools/bibliography/analyze-impact` → `tools/bibliography/analyze-impact.js` (legacy module `analyze-impact.js`)
8
8
  - `wtfp://tools/bibliography/format` → `tools/bibliography/format.js` (legacy module `bib-format.js`)
@@ -11,6 +11,7 @@ Only implementations declared by `tools.json` are packaged here. Resolve each lo
11
11
  - `wtfp://tools/citation/rank` → `tools/citation/rank.js` (legacy module `citation-ranker.js`)
12
12
  - `wtfp://tools/citation/scholar-lookup` → `tools/citation/scholar-lookup.js` (legacy module `scholar-lookup.js`)
13
13
  - `wtfp://tools/citation/semantic-scholar` → `tools/citation/semantic-scholar.js` (legacy module `semantic-scholar.js`)
14
+ - Private dependency: `tools/support/cite-nexus-client.js`; reached only through `citation.fetch`, never executed directly.
14
15
 
15
16
  ## Executing a bundled tool
16
17
 
@@ -25,9 +26,11 @@ Run it with no argument, or with `list`, to print the declared command set as JS
25
26
  - `bib-index <bib-file> [--key=<citation-key>] [--query=<text>]` → `bibliography.index` (filesystem.read)
26
27
  - `bib-format <bib-file> --key=<citation-key> [--style=<bibtex|al-folio>]` → `bibliography.format` (filesystem.read)
27
28
  - `bib-impact <bib-file> [--timeout=<seconds>]` → `bibliography.analyze-impact` (filesystem.read, network.search)
28
- - `citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
29
+ - `citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
29
30
  - `scholar-search --query=<text> [--limit=<1-25>] [--timeout=<seconds>]` → `citation.scholar-lookup` (network.search)
30
31
  - `s2-search --query=<text> [--limit=<1-25>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.semantic-scholar` (network.fetch, network.search)
31
32
  - `rank <papers.json> [--intent=<seminal|recent|balanced>]` → `citation.rank` (no declared effects)
32
33
 
34
+ For approved scholarly discovery, `citation-search --backend=cite-nexus --query="<topic>"` uses the separately installed `cite-nexus-wtfp` companion and its real MCP stdio server. Defaults are Crossref, DataCite and Europe PMC. Select optional vendors explicitly with `--providers`; include selected providers and query scope in the action approval. CiteNexus supports only balanced provider ordering, so omit `--intent` or use `--intent=balanced`. Results remain candidates: retain `citeNexus.sources`, field attribution, metrics, warnings and `metadata.errors`; do not infer verification or combine citation counts. The result limit is a displayed total; `metadata.total` counts the fetched deduplicated page, not the full corpus. Unavailable enrichment fails explicitly without falling back to another vendor. `WTFP_CITE_NEXUS_COMMAND` may name an absolute installed companion executable; it is operator configuration, never source content. No package is installed, server registered, or user profile changed by a tool call. Offline mode refuses this backend before process launch. Host capability blockers still apply.
35
+
33
36
  Every command prints one JSON document on stdout and reports failures as `{"error": "..."}` on stderr with exit status 1. Queries are capped at 512 characters, file paths at 4096, and result limits at 25. A symlinked file is accepted and read through its resolved target, which must be a regular file. Commands whose effects include `network.*` perform outbound requests to the declared scholarly indexes; pass `--offline` or set `WTFP_TOOL_OFFLINE=1` to refuse them, which is the mechanical form of "do not invoke a network-capable bibliography tool through a filesystem-only permission path". Each network command has a hard wall clock, `--timeout=<seconds>` (default 20, maximum 600); on expiry it reports `{"error": "<command> timed out after N s"}` on stderr and exits 124. `bib-impact` reports batch progress on stderr. `bib-index` flags repeated keys with `duplicate: true` and lists them under `duplicates`; `--key` refuses an ambiguous key. `bib-format` emits a standard BibTeX entry (`@article`, `@inproceedings`, ...) by default; `--style=al-folio` selects the Jekyll al-folio projection, which is not valid BibTeX. Do not execute any other module in this package directly, and do not pass a logical `project://` or `wtfp://` URI as a shell argument.
@@ -160,6 +160,9 @@ function deduplicatePapers(papers) {
160
160
  // --- Main Search Logic ---
161
161
 
162
162
  async function search(query, options = {}) {
163
+ if (options.backend === 'cite-nexus') return require('../support/cite-nexus-client.js').search(query, options);
164
+ if (options.backend && options.backend !== 'legacy') throw new Error('Unknown citation backend');
165
+ if (options.providers) throw new Error('Provider selection requires --backend=cite-nexus');
163
166
  const limit = options.limit || 10;
164
167
  const intent = options.intent || 'balanced';
165
168
 
@@ -299,4 +302,4 @@ if (require.main === module) {
299
302
  });
300
303
  }
301
304
 
302
- module.exports = { search };
305
+ module.exports = { search };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ // Optional companion to citation.fetch, never a separately executable tool.
4
+ const { spawn } = require('child_process');
5
+ const path = require('path');
6
+ const SCHEMA = 'cite-nexus.wtfp/v1';
7
+ const MAX_BYTES = 2 * 1024 * 1024;
8
+ const PROVIDERS = Object.freeze({
9
+ crossref: ['CITE_NEXUS_CONTACT_EMAIL'], datacite: [], europe_pmc: [], arxiv: [],
10
+ semantic_scholar: ['SEMANTIC_SCHOLAR_API_KEY'], openalex: ['OPENALEX_API_KEY'],
11
+ serpapi: ['SERPAPI_API_KEY'], scopus: ['SCOPUS_API_KEY', 'SCOPUS_INSTTOKEN'], wos: ['WOS_API_KEY']
12
+ });
13
+ const RUNTIME_ENV = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP'];
14
+
15
+ function timedOut() {
16
+ return Object.assign(new Error('CiteNexus request timed out'), { code: 'WTFP_TIMEOUT' });
17
+ }
18
+
19
+ function requestFor(query, options) {
20
+ if (process.env.WTFP_TOOL_OFFLINE === '1' || process.env.WTFP_TOOL_OFFLINE === 'true') {
21
+ throw new Error('CiteNexus is unavailable in offline mode');
22
+ }
23
+ if (typeof query !== 'string' || !query.trim() || query.length > 512) throw new Error('CiteNexus query must contain 1-512 characters');
24
+ const limit = options.limit === undefined ? 10 : options.limit;
25
+ if (!Number.isInteger(limit) || limit < 1 || limit > 25) throw new Error('CiteNexus limit must be an integer between 1 and 25');
26
+ const providers = options.providers || ['crossref', 'datacite', 'europe_pmc'];
27
+ if (!Array.isArray(providers) || providers.length < 1 || providers.length > 9 ||
28
+ providers.some((p) => typeof p !== 'string' || !Object.hasOwn(PROVIDERS, p))) {
29
+ throw new Error('CiteNexus providers must be a nonempty list of supported provider IDs');
30
+ }
31
+ const timeout = options.timeoutSeconds === undefined ? 20 : options.timeoutSeconds;
32
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new Error('CiteNexus timeout must be an integer between 1 and 600 seconds');
33
+ const request = { schema_version: SCHEMA, operation: 'search', query, limit, providers: [...new Set(providers)], timeout_seconds: timeout };
34
+ if (options.year != null) {
35
+ if (!/^\d{4}$/.test(String(options.year)) || Number(options.year) < 1000 || Number(options.year) > 2200) throw new Error('CiteNexus year must be between 1000 and 2200');
36
+ request.year = Number(options.year);
37
+ }
38
+ if (options.intent && options.intent !== 'balanced') throw new Error('CiteNexus preserves provider ordering; use --intent=balanced');
39
+ return request;
40
+ }
41
+
42
+ function validateResponse(text, limit) {
43
+ let data;
44
+ try { data = JSON.parse(text); } catch { throw new Error('CiteNexus returned malformed JSON'); }
45
+ const stack = [[data, 0]];
46
+ while (stack.length) {
47
+ const [value, depth] = stack.pop();
48
+ if (depth > 32) throw new Error('CiteNexus response nesting exceeds 32 levels');
49
+ if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push([child, depth + 1]);
50
+ }
51
+ if (!data || data.schema_version !== SCHEMA || !Array.isArray(data.results) || data.results.length > limit ||
52
+ !data.metadata || data.metadata.backend !== 'cite-nexus' || !Array.isArray(data.metadata.errors) ||
53
+ data.metadata.returned !== data.results.length ||
54
+ data.results.some((p) => !p || p.verification !== 'candidate' || typeof p.title !== 'string' ||
55
+ typeof p.bibtex !== 'string' || !p.citeNexus || !Array.isArray(p.citeNexus.sources) || !Array.isArray(p.citeNexus.metrics))) {
56
+ throw new Error('CiteNexus returned an incompatible response; update both companions');
57
+ }
58
+ return data;
59
+ }
60
+
61
+ async function search(query, options = {}) {
62
+ const request = requestFor(query, options);
63
+ if (options.signal && options.signal.aborted) throw new Error('CiteNexus request cancelled');
64
+ const command = process.env.WTFP_CITE_NEXUS_COMMAND || 'cite-nexus-wtfp';
65
+ if (command !== 'cite-nexus-wtfp' && !path.isAbsolute(command)) {
66
+ throw new Error('WTFP_CITE_NEXUS_COMMAND must name an absolute installed executable');
67
+ }
68
+ const allowed = [...RUNTIME_ENV, ...request.providers.flatMap((p) => PROVIDERS[p])];
69
+ const env = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]));
70
+ return new Promise((resolve, reject) => {
71
+ // No shell, command arguments, URL or executable can arrive from source data.
72
+ const child = spawn(command, [], { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
73
+ let settled = false, bytes = 0;
74
+ const chunks = [];
75
+ const stop = () => { if (child.exitCode === null) child.kill('SIGTERM'); };
76
+ const onExit = () => stop();
77
+ const onSignal = () => { stop(); process.exit(130); };
78
+ const finish = (error, data) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ process.removeListener('exit', onExit);
83
+ process.removeListener('SIGTERM', onSignal);
84
+ process.removeListener('SIGINT', onSignal);
85
+ if (options.signal) options.signal.removeEventListener('abort', cancel);
86
+ if (error) {
87
+ stop();
88
+ // The Python bridge cancels MCP and closes the SDK-owned server group.
89
+ const kill = setTimeout(() => child.kill('SIGKILL'), 7000);
90
+ kill.unref();
91
+ child.once('close', () => clearTimeout(kill));
92
+ reject(error);
93
+ } else resolve(data);
94
+ };
95
+ const cancel = () => finish(new Error('CiteNexus request cancelled'));
96
+ const timer = setTimeout(() => finish(timedOut()), request.timeout_seconds * 1000);
97
+ process.once('exit', onExit);
98
+ process.once('SIGTERM', onSignal);
99
+ process.once('SIGINT', onSignal);
100
+ if (options.signal) options.signal.addEventListener('abort', cancel, { once: true });
101
+ child.on('error', () => finish(new Error('CiteNexus companion unavailable; install cite-nexus-mcp 0.2.0 separately and run cite-nexus-wtfp --check')));
102
+ child.stdin.on('error', () => finish(new Error('CiteNexus companion closed its input')));
103
+ child.stdout.on('data', (chunk) => {
104
+ bytes += chunk.length;
105
+ if (bytes > MAX_BYTES) return finish(new Error('CiteNexus response exceeds 2 MiB'));
106
+ if (!settled) chunks.push(chunk);
107
+ });
108
+ // Drain, but never relay child stderr (it may contain sensitive diagnostics).
109
+ child.stderr.on('data', () => {});
110
+ child.on('close', (code) => {
111
+ if (settled) return;
112
+ if (code !== 0) return finish(code === 124 ? timedOut() : new Error('CiteNexus companion failed; run cite-nexus-wtfp --check'));
113
+ try { finish(null, validateResponse(Buffer.concat(chunks).toString('utf8'), request.limit)); }
114
+ catch (error) { finish(error); }
115
+ });
116
+ child.stdin.end(JSON.stringify(request));
117
+ });
118
+ }
119
+
120
+ module.exports = { search, requestFor, validateResponse };
@@ -54,7 +54,7 @@ const COMMANDS = [
54
54
  {
55
55
  "command": "citation-search",
56
56
  "tool": "citation.fetch",
57
- "usage": "citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
57
+ "usage": "citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
58
58
  "effects": [
59
59
  "network.search"
60
60
  ]
@@ -96,9 +96,9 @@ function offlineRequested(argv) {
96
96
  return argv.includes('--offline') || flag === '1' || flag === 'true';
97
97
  }
98
98
 
99
- function fail(message) {
99
+ function fail(message, status = 1) {
100
100
  process.stderr.write(`${JSON.stringify({ error: message })}\n`);
101
- process.exit(1);
101
+ process.exit(status);
102
102
  }
103
103
 
104
104
  function emit(value) {
@@ -153,7 +153,7 @@ function requirePath(candidate) {
153
153
 
154
154
  function timeoutOf(flags) {
155
155
  if (!flags.has('timeout')) return DEFAULT_TIMEOUT_SECONDS;
156
- const seconds = Number.parseInt(flags.get('timeout'), 10);
156
+ const seconds = /^\d+$/.test(flags.get('timeout')) ? Number(flags.get('timeout')) : NaN;
157
157
  if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
158
158
  fail(`--timeout must be an integer number of seconds between 1 and ${MAX_TIMEOUT_SECONDS}`);
159
159
  }
@@ -184,7 +184,7 @@ function uniqueEntry(bib, content, key) {
184
184
 
185
185
  function limitOf(flags) {
186
186
  if (!flags.has('limit')) return 10;
187
- const limit = Number.parseInt(flags.get('limit'), 10);
187
+ const limit = /^\d+$/.test(flags.get('limit')) ? Number(flags.get('limit')) : NaN;
188
188
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
189
189
  return limit;
190
190
  }
@@ -221,6 +221,13 @@ async function main(rawArgv) {
221
221
  fail(`${command} is refused in offline mode: it declares ${networkEffects.join(', ')}`);
222
222
  }
223
223
  const { flags, positional } = parseArguments(argv.slice(1));
224
+ const allowedFlags = {
225
+ 'bib-index': ['key', 'query'], 'bib-format': ['key', 'style'],
226
+ 'bib-impact': ['timeout'], 'citation-search': ['query', 'backend', 'providers', 'limit', 'intent', 'year', 'timeout'],
227
+ 'scholar-search': ['query', 'limit', 'timeout'], 's2-search': ['query', 'limit', 'year', 'timeout'],
228
+ 'rank': ['intent']
229
+ };
230
+ for (const key of flags.keys()) if (!allowedFlags[command].includes(key)) fail(`unknown --${key} for ${command}`);
224
231
  if (positional.length > 1) fail(`${command} accepts at most one positional argument: ${declared.usage}`);
225
232
  const load = () => require(MODULES[declared.tool]);
226
233
 
@@ -255,7 +262,11 @@ async function main(rawArgv) {
255
262
  const query = requireText(flags.get('query'), '--query');
256
263
  const year = yearOf(flags);
257
264
  const seconds = timeoutOf(flags);
258
- return emit(await withTimeout(load().search(query, { limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
265
+ const backend = flags.get('backend') || 'legacy';
266
+ if (!['legacy', 'cite-nexus'].includes(backend)) fail('--backend must be legacy or cite-nexus');
267
+ const providers = flags.has('providers') ? flags.get('providers').split(',').map((p) => p.trim()) : undefined;
268
+ if (providers && backend !== 'cite-nexus') fail('--providers requires --backend=cite-nexus');
269
+ return emit(await withTimeout(load().search(query, { backend, providers, timeoutSeconds: seconds, limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
259
270
  }
260
271
  if (command === 'scholar-search') {
261
272
  if (positional.length > 0) fail(`${command} takes its query through --query: ${declared.usage}`);
@@ -282,5 +293,5 @@ async function main(rawArgv) {
282
293
  // config root as a custom-tool module; an unconditional main() printed a
283
294
  // dispatcher error and exited the host process at session start.
284
295
  if (require.main === module) {
285
- main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error)));
296
+ main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error), error && error.code === 'WTFP_TIMEOUT' ? EXIT_TIMEOUT : 1));
286
297
  }