product-console-companion 0.3.0 → 0.4.0

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/README.md CHANGED
@@ -63,6 +63,10 @@ npx product-console-companion connect --url https://console.fynchconsulting.com
63
63
 
64
64
  This runs your workspace's PRD-only setup AI calls when there is no repo yet. `run` then polls
65
65
  for workspace-level tasks in addition to any per-repo connections configured on this machine.
66
+ Pass `--backend cli` to use your Claude subscription for these repo-less tasks too. This mode
67
+ runs with zero filesystem access (stricter than the repo-scoped `cli` backend, because there is
68
+ nothing to read). PDF-format PRDs cannot be delivered to the `cli` backend yet; use `api-key`,
69
+ or re-upload the PRD as `.md`, `.txt`, or `.docx`.
66
70
 
67
71
  **`--backend api-key`** (default) needs an Anthropic API key (get one free at
68
72
  console.anthropic.com) — either pass it inline with `--api-key sk-ant-...` (saved to this
@@ -11,6 +11,7 @@
11
11
  * during development, not guessed from --help text alone — see the Phase 2 PR description.
12
12
  */
13
13
  import spawn from 'cross-spawn';
14
+ import { tmpdir } from 'node:os';
14
15
  import { createInterface } from 'node:readline';
15
16
  import { createSandbox } from './sandbox.js';
16
17
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
@@ -26,13 +27,18 @@ function normalizeToRepoRelative(filePath, root) {
26
27
  const r = root.replace(/\\/g, '/');
27
28
  return (p.startsWith(r) ? p.slice(r.length) : p).replace(/^\/+/, '');
28
29
  }
29
- /** The actual invocation — task-kind-agnostic. Runs `spec` against a disposable sandbox
30
- * worktree of `repoPath`, returns whatever structured_output the model produced plus every
31
- * file it actually read via the Read tool. Every exported per-kind function below is a thin
32
- * wrapper over this: extract a ModelTaskSpec from that kind's own payload shape, call this,
33
- * return what it returns. */
34
- async function runViaCli(spec, maxBudgetUsd, repoPath) {
35
- const sandbox = await createSandbox(repoPath);
30
+ /** The actual invocation — task-kind-agnostic. Repo-scoped calls run against a disposable
31
+ * sandbox worktree of `repoPath`; connection-less calls grant no tools and need no sandbox.
32
+ * Returns whatever structured_output the model produced plus every file it actually read via
33
+ * the Read tool. Every exported per-kind function below is a thin wrapper over this. */
34
+ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
35
+ if (spec.stableDocumentBase64) {
36
+ throw new Error("This task includes a PDF PRD, which the cli backend can't read yet. Pair with " +
37
+ '--backend api-key instead, or re-upload the PRD as .md/.txt/.docx.');
38
+ }
39
+ const sandbox = connectionless
40
+ ? { path: tmpdir(), cleanup: async () => { } }
41
+ : await createSandbox(repoPath);
36
42
  try {
37
43
  const args = [
38
44
  '-p',
@@ -43,10 +49,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath) {
43
49
  '--verbose',
44
50
  ...(spec.schema ? ['--json-schema', JSON.stringify(spec.schema)] : []),
45
51
  '--restricted',
46
- '--tools',
47
- 'Read,Grep,Glob',
48
- '--add-dir',
49
- sandbox.path,
52
+ ...(connectionless ? ['--tools', ''] : ['--tools', 'Read,Grep,Glob', '--add-dir', sandbox.path]),
50
53
  '--model',
51
54
  spec.model,
52
55
  '--effort',
@@ -66,6 +69,8 @@ async function runViaCli(spec, maxBudgetUsd, repoPath) {
66
69
  let stderrTail = '';
67
70
  let timedOut = false;
68
71
  const child = spawn('claude', args, { cwd: sandbox.path });
72
+ if (spec.stableText)
73
+ child.stdin.write(spec.stableText + '\n\n');
69
74
  child.stdin.write(spec.volatileText);
70
75
  child.stdin.end();
71
76
  // stdout/stderr are only null when stdio is explicitly configured away from 'pipe' — the
@@ -135,19 +140,17 @@ export async function runVerifyModuleTaskViaCli(task, repoPath) {
135
140
  return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
136
141
  }
137
142
  /** moduleIds isn't needed here the way api-key-caller.ts's batch path needs it (to structure its
138
- * own file-select pass) — the CLI does its own exploring in one shot off spec.volatileText
139
- * alone, which already names every module in the batch. */
143
+ * own file-select pass) — spec.volatileText already names every module in the batch. */
140
144
  export async function runGenerateSpecBatchTaskViaCli(task, repoPath) {
141
145
  const payload = task.payload;
142
- return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
146
+ return runViaCli(payload.spec, task.maxBudgetUsd, repoPath, payload.connectionless);
143
147
  }
144
148
  /** propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks (see
145
- * api-key-caller.ts's own comment on the pair) — for the CLI backend this needs no special
146
- * handling at all: --add-dir plus Read/Grep/Glob already lets the model explore the repo
147
- * itself, same as verify_module/generate_spec_batch, so these are exactly as thin as those. */
149
+ * api-key-caller.ts's own comment on the pair). Repo-scoped calls let the model explore via
150
+ * --add-dir plus Read/Grep/Glob; a connection-less skeleton call has no repo to explore. */
148
151
  export async function runProposeSkeletonTaskViaCli(task, repoPath) {
149
152
  const payload = task.payload;
150
- return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
153
+ return runViaCli(payload.spec, task.maxBudgetUsd, repoPath, payload.connectionless);
151
154
  }
152
155
  export async function runProposeNewCapabilitiesTaskViaCli(task, repoPath) {
153
156
  const payload = task.payload;
package/dist/cli.js CHANGED
@@ -42,6 +42,7 @@ async function cmdConnect(argv) {
42
42
  const token = f.token;
43
43
  const connectionId = f.connection;
44
44
  const apiKey = f['api-key'];
45
+ const backend = (f.backend || 'api-key');
45
46
  // Pairing on its own does nothing useful — see cmdRun below — so this defaults to starting
46
47
  // right away, which is what someone following the app's own copy-pasted command wants: one
47
48
  // paste, and it's working. --no-run is the escape hatch for pairing a second/third project
@@ -50,11 +51,25 @@ async function cmdConnect(argv) {
50
51
  if (!url || !token) {
51
52
  console.error('Usage: product-console-companion connect --url <your Console link> --token <publicId.secret> --connection <connectionId> ' +
52
53
  '[--path <folder — defaults to the folder you\'re in right now>] [--backend api-key|cli] [--api-key <your Anthropic key>] [--no-run]\n' +
53
- ' or: product-console-companion connect --url <your Console link> --token <publicId.secret> [--api-key <your Anthropic key>] [--no-run]');
54
+ ' or: product-console-companion connect --url <your Console link> --token <publicId.secret> [--backend api-key|cli] [--api-key <your Anthropic key>] [--no-run]');
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ if (backend !== 'api-key' && backend !== 'cli') {
59
+ console.error(`--backend must be "api-key" or "cli", got "${backend}"`);
54
60
  process.exitCode = 1;
55
61
  return;
56
62
  }
57
63
  if (!connectionId) {
64
+ if (backend === 'cli') {
65
+ const version = await checkClaudeCliAvailable();
66
+ if (!version) {
67
+ console.error('This needs Claude Code installed and you logged into it first: npm install -g @anthropic-ai/claude-code, then run `claude` and follow the login prompt.');
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ console.log(`Found Claude Code: ${version}`);
72
+ }
58
73
  const client = new CompanionClient(url, token);
59
74
  try {
60
75
  await client.whoami();
@@ -65,7 +80,7 @@ async function cmdConnect(argv) {
65
80
  process.exitCode = 1;
66
81
  return;
67
82
  }
68
- enableWorkspacePolling({ url, token, apiKey });
83
+ enableWorkspacePolling({ url, token, apiKey, backend });
69
84
  console.log(`Connected! This computer will now run AI calls for this workspace's setup (PRD-only) tasks. (Saved to ${configPath}.)`);
70
85
  if (!shouldRunAfter) {
71
86
  console.log('Run "product-console-companion run" whenever you\'re ready to start.');
@@ -78,12 +93,6 @@ async function cmdConnect(argv) {
78
93
  // --path is optional — defaults to wherever this command is actually run from, so "open a
79
94
  // terminal in your project's folder, paste this" just works without typing a path at all.
80
95
  const repoPath = resolve(f.path || '.');
81
- const backend = (f.backend || 'api-key');
82
- if (backend !== 'api-key' && backend !== 'cli') {
83
- console.error(`--backend must be "api-key" or "cli", got "${backend}"`);
84
- process.exitCode = 1;
85
- return;
86
- }
87
96
  try {
88
97
  await assertIsRepo(repoPath);
89
98
  }
@@ -131,29 +140,23 @@ const HEARTBEAT_MS = 30_000;
131
140
  * each backed by both backends. Grows by one entry per phase, same as the server's own registry
132
141
  * (server/companion.ts's TASK_RESULT_SCHEMAS) — the two lists should always name the same kinds. */
133
142
  async function runTask(task, backend, anthropic, repoPath) {
134
- const connectionless = task.payload.connectionless === true;
135
- const effectiveBackend = connectionless ? 'api-key' : backend;
136
- if (connectionless && !anthropic) {
137
- throw new Error('This is a connection-less (PRD-only) task, which needs an Anthropic API key. Re-run ' +
138
- '`connect` with --api-key <key>, or set ANTHROPIC_API_KEY.');
139
- }
140
143
  if (task.kind === 'verify_module') {
141
- return effectiveBackend === 'cli' ? runVerifyModuleTaskViaCli(task, repoPath) : runVerifyModuleTask(anthropic, task, repoPath); // anthropic is always set for backend 'api-key' — see cmdRun
144
+ return backend === 'cli' ? runVerifyModuleTaskViaCli(task, repoPath) : runVerifyModuleTask(anthropic, task, repoPath); // anthropic is always set for backend 'api-key' — see cmdRun
142
145
  }
143
146
  if (task.kind === 'generate_spec_batch') {
144
- return effectiveBackend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
147
+ return backend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
145
148
  }
146
149
  if (task.kind === 'propose_skeleton') {
147
- return effectiveBackend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
150
+ return backend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
148
151
  }
149
152
  if (task.kind === 'propose_new_capabilities') {
150
- return effectiveBackend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
153
+ return backend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
151
154
  }
152
155
  if (task.kind === 'design_prompt') {
153
- return effectiveBackend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
156
+ return backend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
154
157
  }
155
158
  if (task.kind === 'analyse_shipment') {
156
- return effectiveBackend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
159
+ return backend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
157
160
  }
158
161
  throw new Error(`This companion doesn't know how to run task kind "${task.kind}" yet.`);
159
162
  }
@@ -188,10 +191,11 @@ async function cmdRun() {
188
191
  process.exitCode = 1;
189
192
  return;
190
193
  }
191
- // Repo backends are chosen per connection; workspace-level tasks always use api-key. Only
194
+ // Repo backends are chosen per connection; workspace-level tasks have their own choice. Only
192
195
  // require what's actually configured to run, not every prerequisite either backend could need.
193
- const needsApiKey = config.pollWorkspaceTasks || config.connections.some((c) => c.backend !== 'cli');
194
- const needsCli = config.connections.some((c) => c.backend === 'cli');
196
+ const workspaceBackend = config.workspaceBackend ?? 'api-key';
197
+ const needsApiKey = (config.pollWorkspaceTasks && workspaceBackend !== 'cli') || config.connections.some((c) => c.backend !== 'cli');
198
+ const needsCli = (config.pollWorkspaceTasks && workspaceBackend === 'cli') || config.connections.some((c) => c.backend === 'cli');
195
199
  // config.apiKey (set via `connect --api-key ...`) takes priority over the environment variable
196
200
  // — the easier path for someone who'd rather paste their key once into a command than learn
197
201
  // what an environment variable is and how to set one on their OS. Both are supported; neither
@@ -230,7 +234,7 @@ async function cmdRun() {
230
234
  console.log(` - ${c.connectionId} → ${c.repoPath} @ ${sha.slice(0, 7)} (${c.backend})`);
231
235
  }
232
236
  if (config.pollWorkspaceTasks) {
233
- console.log(' - workspace-level (connection-less / PRD-only) tasks');
237
+ console.log(` - workspace-level (connection-less / PRD-only) tasks (${workspaceBackend})`);
234
238
  }
235
239
  console.log('Waiting for work — leave this running. Ctrl+C any time to stop.');
236
240
  let stopping = false;
@@ -278,7 +282,7 @@ async function cmdRun() {
278
282
  if (claim.task) {
279
283
  claimedAny = true;
280
284
  // Connection-less task kinds do not read a repo; cwd is a harmless placeholder.
281
- await runOneTask(anthropic, 'api-key', client, process.cwd(), claim.task);
285
+ await runOneTask(anthropic, workspaceBackend, client, process.cwd(), claim.task);
282
286
  }
283
287
  }
284
288
  if (!claimedAny && !stopping)
package/dist/config.js CHANGED
@@ -33,6 +33,7 @@ export function addConnection(args) {
33
33
  connections,
34
34
  apiKey: args.apiKey || existing?.apiKey,
35
35
  pollWorkspaceTasks: existing?.pollWorkspaceTasks,
36
+ workspaceBackend: existing?.workspaceBackend,
36
37
  };
37
38
  save(config);
38
39
  return config;
@@ -48,6 +49,7 @@ export function enableWorkspacePolling(args) {
48
49
  connections: existing?.connections ?? [],
49
50
  apiKey: args.apiKey || existing?.apiKey,
50
51
  pollWorkspaceTasks: true,
52
+ workspaceBackend: args.backend || existing?.workspaceBackend,
51
53
  };
52
54
  save(config);
53
55
  return config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",