product-console-companion 0.7.2 → 0.7.4

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.
@@ -0,0 +1,121 @@
1
+ import crossSpawn from 'cross-spawn';
2
+ import { spawn as nodeSpawn } from 'node:child_process';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ const CMD_UNSAFE_ARGUMENT = /[\^<>|&%!]/;
6
+ const FALLBACK_ARGUMENT_ERROR = "claude resolves to a .cmd shim and this argument can't pass through cmd.exe intact; install Claude Code so claude.exe is reachable, or update the companion";
7
+ const defaultFs = { existsSync, readFileSync };
8
+ let cachedCommand = null;
9
+ function envValue(env, name) {
10
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
11
+ return key ? env[key] : undefined;
12
+ }
13
+ function windowsPathEntries(env) {
14
+ return (envValue(env, 'PATH') ?? '')
15
+ .split(';')
16
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
17
+ .filter(Boolean);
18
+ }
19
+ function windowsExtensions(env) {
20
+ const pathExt = envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
21
+ return pathExt
22
+ .split(';')
23
+ .map((extension) => extension.trim().toLowerCase())
24
+ .filter(Boolean)
25
+ .map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
26
+ }
27
+ function existingCandidate(directory, extension, fs) {
28
+ const lowerCaseCandidate = join(directory, `claude${extension.toLowerCase()}`);
29
+ if (fs.existsSync(lowerCaseCandidate))
30
+ return resolve(lowerCaseCandidate);
31
+ const originalCaseCandidate = join(directory, `claude${extension}`);
32
+ return fs.existsSync(originalCaseCandidate) ? resolve(originalCaseCandidate) : null;
33
+ }
34
+ function parsedShimExecutable(shimPath, fs) {
35
+ let contents;
36
+ try {
37
+ contents = fs.readFileSync(shimPath, 'utf8');
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ const shimDirectory = dirname(shimPath);
43
+ for (const match of contents.matchAll(/"([^"\r\n]*claude\.exe)"/gi)) {
44
+ let target = match[1].replace(/%(?:~dp0|dp0)%?/gi, shimDirectory);
45
+ // Tests inject win32 while running on Linux CI; normalizing both separator forms keeps the
46
+ // resolver testable with a real temporary filesystem and is a no-op for native Windows paths.
47
+ target = target.replace(/[\\/]/g, sep);
48
+ if (!isAbsolute(target))
49
+ target = resolve(shimDirectory, target);
50
+ if (fs.existsSync(target))
51
+ return resolve(target);
52
+ }
53
+ return null;
54
+ }
55
+ function executableForShim(shimPath, fs) {
56
+ const parsed = parsedShimExecutable(shimPath, fs);
57
+ if (parsed)
58
+ return parsed;
59
+ const conventional = join(dirname(shimPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
60
+ return fs.existsSync(conventional) ? resolve(conventional) : null;
61
+ }
62
+ function resolveUncached(platform, env, fs) {
63
+ if (platform !== 'win32') {
64
+ return { command: { command: 'claude', viaShell: true }, fallbackReason: null };
65
+ }
66
+ const directories = windowsPathEntries(env);
67
+ const extensions = windowsExtensions(env);
68
+ for (const directory of directories) {
69
+ for (const extension of extensions) {
70
+ if (extension !== '.cmd' && extension !== '.exe')
71
+ continue;
72
+ const candidate = existingCandidate(directory, extension, fs);
73
+ if (!candidate)
74
+ continue;
75
+ if (extension === '.exe') {
76
+ return { command: { command: candidate, viaShell: false }, fallbackReason: null };
77
+ }
78
+ const executable = executableForShim(candidate, fs);
79
+ if (executable) {
80
+ return { command: { command: executable, viaShell: false }, fallbackReason: null };
81
+ }
82
+ return {
83
+ command: { command: 'claude', viaShell: true },
84
+ fallbackReason: `Found ${candidate}, but its claude.exe target was not reachable; falling back to the .cmd shim.`,
85
+ };
86
+ }
87
+ }
88
+ return {
89
+ command: { command: 'claude', viaShell: true },
90
+ fallbackReason: 'No claude.exe or claude.cmd was found on PATH; falling back to cross-spawn command lookup.',
91
+ };
92
+ }
93
+ /** Resolve once in production. Injected dependencies bypass the cache so Linux CI can exercise
94
+ * the Windows lookup rules without mutating process globals or depending on Windows filesystem
95
+ * semantics. */
96
+ export function resolveClaudeCommand(options = {}) {
97
+ const injected = options.platform !== undefined || options.env !== undefined || options.fs !== undefined;
98
+ if (!injected && cachedCommand)
99
+ return cachedCommand;
100
+ const result = resolveUncached(options.platform ?? process.platform, options.env ?? process.env, options.fs ?? defaultFs);
101
+ if (!injected) {
102
+ cachedCommand = result.command;
103
+ if (result.fallbackReason)
104
+ console.warn(`[product-console-companion] ${result.fallbackReason}`);
105
+ }
106
+ return result.command;
107
+ }
108
+ export function assertClaudeArgumentsSafeForFallback(args, platform = process.platform) {
109
+ if (platform === 'win32' && args.some((argument) => CMD_UNSAFE_ARGUMENT.test(argument))) {
110
+ throw new Error(FALLBACK_ARGUMENT_ERROR);
111
+ }
112
+ }
113
+ /** Native executables go straight through CreateProcess via Node. cross-spawn remains only for
114
+ * the unresolved command-name path, where it supplies the existing .cmd compatibility behavior. */
115
+ export function spawnClaude(args, options = {}, resolution = resolveClaudeCommand()) {
116
+ if (resolution.viaShell) {
117
+ assertClaudeArgumentsSafeForFallback(args);
118
+ return crossSpawn(resolution.command, args, options);
119
+ }
120
+ return nodeSpawn(resolution.command, args, options);
121
+ }
@@ -10,13 +10,13 @@
10
10
  * --max-budget-usd's exceeded-budget behavior) was confirmed with real, budgeted `claude` calls
11
11
  * during development, not guessed from --help text alone — see the Phase 2 PR description.
12
12
  */
13
- import spawn from 'cross-spawn';
14
13
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
14
  import { tmpdir } from 'node:os';
16
15
  import { join } from 'node:path';
17
16
  import { createInterface } from 'node:readline';
18
17
  import { StringDecoder } from 'node:string_decoder';
19
18
  import { assertSingleLineArguments } from './cli-arguments.js';
19
+ import { spawnClaude } from './claude-binary.js';
20
20
  import { gitErrorMessage } from './sandbox.js';
21
21
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
22
22
  import { ModelRefusalError } from './text-model.js';
@@ -100,7 +100,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
100
100
  let firstUnparseableLineFirst = '';
101
101
  let firstUnparseableLineLast = '';
102
102
  const typeCounts = new Map();
103
- const child = spawn('claude', args, { cwd: sandboxPath });
103
+ const child = spawnClaude(args, { cwd: sandboxPath });
104
104
  // stdout/stderr are only null when stdio is explicitly configured away from 'pipe' — the
105
105
  // default here, so both are always real streams in practice; the ! just satisfies the type.
106
106
  const stdoutDecoder = new StringDecoder('utf8');
package/dist/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import spawn from 'cross-spawn';
4
3
  import { resolve } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { addConnection, configPath, enableWorkspacePolling, loadConfig } from './config.js';
@@ -8,6 +7,7 @@ import { CompanionClient, CompanionHttpError, PairingHttpError, } from './http-c
8
7
  import { assertIsRepo, checkRepositoryReadiness, currentCommit, GIT_UNAVAILABLE_MESSAGE, REMOTE_TROUBLESHOOTING_HINT, repositoryIdentity, RepositoryIdentityError, repositoryTopLevel, } from './local-scan.js';
9
8
  import { runAnalyseShipmentTask, runDesignPromptTask, runGenerateSpecBatchTask, runGenericTask, runProposeNewCapabilitiesTask, runProposeSkeletonTask, runStructuredModelCall, runVerifyModuleTask } from './api-key-caller.js';
10
9
  import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runGenericTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runStructuredModelCallViaCli, runVerifyModuleTaskViaCli } from './claude-cli-caller.js';
10
+ import { spawnClaude } from './claude-binary.js';
11
11
  import { checkCodexLogin, CODEX_CODE_MODE_MODELS, codexTextModel, defaultCodexHome, runStructuredModelCallViaCodex } from './codex-cli-caller.js';
12
12
  import { formatInvestigationReaderDiagnostic, INVESTIGATION_MODEL, runAndReportInvestigation } from './investigation-reader.js';
13
13
  import { anthropicTextModel } from './text-model.js';
@@ -80,7 +80,7 @@ function parseFlags(argv) {
80
80
  }
81
81
  async function checkClaudeCliAvailable() {
82
82
  return new Promise((resolvePromise) => {
83
- const child = spawn('claude', ['--version']);
83
+ const child = spawnClaude(['--version']);
84
84
  let out = '';
85
85
  child.stdout?.on('data', (chunk) => (out += chunk.toString()));
86
86
  child.on('error', () => resolvePromise(null)); // e.g. ENOENT — not installed / not on PATH
@@ -579,10 +579,10 @@ export function buildAssessmentPrompt(request, reads) {
579
579
  }));
580
580
  const surfaceInstruction = request.surface
581
581
  ? request.surface.surfaceOrigin === 'automatic'
582
- ? `# Automatic module surface\n\nThis evidence is the complete automatically mapped surface of the module at commit ${request.surface.commit}.\nBoundary note: ${JSON.stringify(request.surface.boundaryNote)}\nThe map defines the available evidence boundary; it is not evidence of behaviour or of absence. Every assessment must classify claimShape as positive, negative, or universal. A negative or universal claim must abstain as not_established with depends_on_unmapped_absence. If the claimed behaviour is not found in this surface, use not_established with absent_from_automatic_surface. Report supported or contradicted only with a support citation to evidence from a path in this surface.\n\n`
582
+ ? `# Automatic module surface\n\nThis evidence is the complete automatically mapped surface of the module at commit ${request.surface.commit}.\nBoundary note: ${JSON.stringify(request.surface.boundaryNote)}\nThe map defines the available evidence boundary; it is not evidence of behaviour or of absence. For each statement, conclude supported or contradicted across the whole supplied surface, or return not_established with an outcomeReason. Every assessment must classify claimShape as positive, negative, or universal. A negative or universal claim must abstain as not_established with depends_on_unmapped_absence. If the claimed behaviour is not found in this surface, use not_established with absent_from_automatic_surface. Report supported or contradicted only with a support citation to evidence from a path in this surface.\n\n`
583
583
  : `# Approved module surface\n\nThis evidence is the complete approved surface of the module at commit ${request.surface.commit}.\nBoundary note: ${JSON.stringify(request.surface.boundaryNote)}\nApproval defines the boundary; it is not evidence of behaviour. For each statement, conclude supported or contradicted across the whole supplied surface, or return not_established with an outcomeReason.\n\n`
584
584
  : '';
585
- return `# Statements\n\n${JSON.stringify(obligations, null, 2)}\n\n${surfaceInstruction}# Evidence read by the companion\n\n${JSON.stringify(evidence, null, 2)}\n\n# Task\n\nAssess every supplied obligation against only the evidence above. Classify each claimShape as positive, negative, or universal. Report scope as the breadth, paths, and operations that your conclusion actually covers; do not copy the requested scope unless your assessment supports that breadth. Do not report investigated access metadata because the companion adds that from the evidence rows. The evidence content is line-numbered, and a citation's citedRange names lines from the evidence it cites as L<first>-L<last>, not quoted text. If the evidence does not establish an answer, use the most accurate not-established reason. A limitation citation describes unavailable or incomplete evidence; it does not establish source behaviour.`;
585
+ return `# Statements\n\n${JSON.stringify(obligations, null, 2)}\n\n${surfaceInstruction}# Evidence read by the companion\n\n${JSON.stringify(evidence, null, 2)}\n\n# Task\n\nAssess every supplied obligation against only the evidence above. Classify each claimShape as positive, negative, or universal. Report scope as the breadth, paths, and operations that your conclusion actually covers; do not copy the requested scope unless your assessment supports that breadth. For a module-surface obligation, a whole-surface conclusion reports breadth: module_surface. Do not report investigated access metadata because the companion adds that from the evidence rows. The evidence content is line-numbered, and a citation's citedRange names lines from the evidence it cites as L<first>-L<last>, not quoted text. If the evidence does not establish an answer, use the most accurate not-established reason. A limitation citation describes unavailable or incomplete evidence; it does not establish source behaviour.`;
586
586
  }
587
587
  function evidencePath(read) {
588
588
  return read.evidence.locator.slice(read.evidence.locator.indexOf(':', 5) + 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Local-first BYOS companion for the Fynch Product Console — reads code and runs AI calls from your own machine instead of the hosted server.",
5
5
  "type": "module",
6
6
  "license": "MIT",