product-console-companion 0.2.0 → 0.3.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
@@ -55,6 +55,15 @@ Running `connect` again for a different `--connection`/`--path` (same `--url`/`-
55
55
  second connection to the same local config; one companion process can serve several repos, each
56
56
  with its own backend choice.
57
57
 
58
+ To pair this machine before any repo is connected, omit `--connection` and `--path`:
59
+
60
+ ```bash
61
+ npx product-console-companion connect --url https://console.fynchconsulting.com --token <publicId>.<secret> [--api-key sk-ant-...]
62
+ ```
63
+
64
+ This runs your workspace's PRD-only setup AI calls when there is no repo yet. `run` then polls
65
+ for workspace-level tasks in addition to any per-repo connections configured on this machine.
66
+
58
67
  **`--backend api-key`** (default) needs an Anthropic API key (get one free at
59
68
  console.anthropic.com) — either pass it inline with `--api-key sk-ant-...` (saved to this
60
69
  package's own local config file, `~/.product-console-companion/config.json`, permissions locked
@@ -160,6 +160,28 @@ export async function runGenerateSpecBatchTask(client, task, repoPath) {
160
160
  const payload = task.payload;
161
161
  const spec = payload.spec;
162
162
  const moduleIds = payload.moduleIds;
163
+ if (payload.connectionless) {
164
+ // The PRD is already in spec.stableText (stableTextForLocalDispatch); there is nothing local to read.
165
+ const content = [];
166
+ if (spec.stableText)
167
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
168
+ content.push({ type: 'text', text: spec.volatileText });
169
+ const writeStream = client.messages.stream({
170
+ model: spec.model,
171
+ max_tokens: spec.maxTokens,
172
+ thinking: { type: 'adaptive' },
173
+ output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
174
+ system: spec.system,
175
+ messages: [{ role: 'user', content }],
176
+ });
177
+ const writeMsg = await writeStream.finalMessage();
178
+ if (writeMsg.stop_reason === 'refusal')
179
+ throw new Error('The model declined to write specs for this batch.');
180
+ const writeText = writeMsg.content.find((b) => b.type === 'text');
181
+ if (!writeText || writeText.type !== 'text')
182
+ throw new Error('No specs returned.');
183
+ return { result: JSON.parse(writeText.text), filesRead: [] };
184
+ }
163
185
  const allFiles = await listRepoFiles(repoPath);
164
186
  console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
165
187
  // Pass 1: which files relate to each module in this batch?
@@ -236,10 +258,12 @@ in the same order as given.`;
236
258
  * server/index.ts's completeAiJob payloads for /api/setup/skeleton and
237
259
  * /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
238
260
  */
239
- async function runShallowRepoTask(client, spec, repoPath) {
240
- const repoCtx = await buildLocalRepoContext(repoPath);
241
- console.log(` scanning ${repoCtx.fileList.length} files, README ${repoCtx.readme ? 'found' : 'not found'}, CHANGELOG ${repoCtx.changelog ? 'found' : 'not found'}, package.json ${repoCtx.packageJson ? 'found' : 'not found'}`);
242
- const repoSection = `
261
+ async function runShallowRepoTask(client, spec, repoPath, connectionless = false) {
262
+ let repoSection = '';
263
+ if (!connectionless) {
264
+ const repoCtx = await buildLocalRepoContext(repoPath);
265
+ console.log(` scanning ${repoCtx.fileList.length} files, README ${repoCtx.readme ? 'found' : 'not found'}, CHANGELOG ${repoCtx.changelog ? 'found' : 'not found'}, package.json ${repoCtx.packageJson ? 'found' : 'not found'}`);
266
+ repoSection = `
243
267
  # Repository context (your local checkout)
244
268
 
245
269
  ## File tree (${repoCtx.fileList.length} files)
@@ -254,13 +278,14 @@ ${repoCtx.changelog ?? '(none found)'}
254
278
  ## package.json
255
279
  ${repoCtx.packageJson ?? '(none found)'}
256
280
  `.trim();
281
+ }
257
282
  const content = [];
258
283
  if (spec.stableDocumentBase64) {
259
284
  content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: spec.stableDocumentBase64 } });
260
285
  }
261
286
  if (spec.stableText)
262
287
  content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
263
- content.push({ type: 'text', text: `${spec.volatileText}\n\n${repoSection}` });
288
+ content.push({ type: 'text', text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}` });
264
289
  const stream = client.messages.stream({
265
290
  model: spec.model,
266
291
  max_tokens: spec.maxTokens,
@@ -279,7 +304,7 @@ ${repoCtx.packageJson ?? '(none found)'}
279
304
  }
280
305
  export async function runProposeSkeletonTask(client, task, repoPath) {
281
306
  const payload = task.payload;
282
- return runShallowRepoTask(client, payload.spec, repoPath);
307
+ return runShallowRepoTask(client, payload.spec, repoPath, payload.connectionless);
283
308
  }
284
309
  export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
285
310
  const payload = task.payload;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
3
  import spawn from 'cross-spawn';
4
4
  import { resolve } from 'node:path';
5
- import { addConnection, configPath, loadConfig } from './config.js';
5
+ import { addConnection, configPath, enableWorkspacePolling, loadConfig } from './config.js';
6
6
  import { CompanionClient, CompanionHttpError } from './http-client.js';
7
7
  import { assertIsRepo, currentCommit } from './local-scan.js';
8
8
  import { runAnalyseShipmentTask, runDesignPromptTask, runGenerateSpecBatchTask, runProposeNewCapabilitiesTask, runProposeSkeletonTask, runVerifyModuleTask } from './api-key-caller.js';
@@ -41,22 +41,44 @@ async function cmdConnect(argv) {
41
41
  const url = f.url?.replace(/\/+$/, '');
42
42
  const token = f.token;
43
43
  const connectionId = f.connection;
44
- // --path is optional — defaults to wherever this command is actually run from, so "open a
45
- // terminal in your project's folder, paste this" just works without typing a path at all.
46
- const repoPath = resolve(f.path || '.');
47
- const backend = (f.backend || 'api-key');
48
44
  const apiKey = f['api-key'];
49
45
  // Pairing on its own does nothing useful — see cmdRun below — so this defaults to starting
50
46
  // right away, which is what someone following the app's own copy-pasted command wants: one
51
47
  // paste, and it's working. --no-run is the escape hatch for pairing a second/third project
52
48
  // before starting one shared `run` for all of them (see the README's own multi-connection note).
53
49
  const shouldRunAfter = f['no-run'] !== 'true';
54
- if (!url || !token || !connectionId) {
50
+ if (!url || !token) {
55
51
  console.error('Usage: product-console-companion connect --url <your Console link> --token <publicId.secret> --connection <connectionId> ' +
56
- '[--path <folder — defaults to the folder you\'re in right now>] [--backend api-key|cli] [--api-key <your Anthropic key>] [--no-run]');
52
+ '[--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]');
57
54
  process.exitCode = 1;
58
55
  return;
59
56
  }
57
+ if (!connectionId) {
58
+ const client = new CompanionClient(url, token);
59
+ try {
60
+ await client.whoami();
61
+ }
62
+ catch (err) {
63
+ console.error(`Hmm, that didn't work — could not verify your token against ${url}. Double check you copied the ` +
64
+ `WHOLE thing, including the part after the dot.`, err instanceof Error ? err.message : err);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ enableWorkspacePolling({ url, token, apiKey });
69
+ console.log(`Connected! This computer will now run AI calls for this workspace's setup (PRD-only) tasks. (Saved to ${configPath}.)`);
70
+ if (!shouldRunAfter) {
71
+ console.log('Run "product-console-companion run" whenever you\'re ready to start.');
72
+ return;
73
+ }
74
+ console.log('Starting now — Ctrl+C any time to stop.\n');
75
+ await cmdRun();
76
+ return;
77
+ }
78
+ // --path is optional — defaults to wherever this command is actually run from, so "open a
79
+ // terminal in your project's folder, paste this" just works without typing a path at all.
80
+ const repoPath = resolve(f.path || '.');
81
+ const backend = (f.backend || 'api-key');
60
82
  if (backend !== 'api-key' && backend !== 'cli') {
61
83
  console.error(`--backend must be "api-key" or "cli", got "${backend}"`);
62
84
  process.exitCode = 1;
@@ -109,23 +131,29 @@ const HEARTBEAT_MS = 30_000;
109
131
  * each backed by both backends. Grows by one entry per phase, same as the server's own registry
110
132
  * (server/companion.ts's TASK_RESULT_SCHEMAS) — the two lists should always name the same kinds. */
111
133
  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
+ }
112
140
  if (task.kind === 'verify_module') {
113
- return backend === 'cli' ? runVerifyModuleTaskViaCli(task, repoPath) : runVerifyModuleTask(anthropic, task, repoPath); // anthropic is always set for backend 'api-key' — see cmdRun
141
+ return effectiveBackend === 'cli' ? runVerifyModuleTaskViaCli(task, repoPath) : runVerifyModuleTask(anthropic, task, repoPath); // anthropic is always set for backend 'api-key' — see cmdRun
114
142
  }
115
143
  if (task.kind === 'generate_spec_batch') {
116
- return backend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
144
+ return effectiveBackend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
117
145
  }
118
146
  if (task.kind === 'propose_skeleton') {
119
- return backend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
147
+ return effectiveBackend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
120
148
  }
121
149
  if (task.kind === 'propose_new_capabilities') {
122
- return backend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
150
+ return effectiveBackend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
123
151
  }
124
152
  if (task.kind === 'design_prompt') {
125
- return backend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
153
+ return effectiveBackend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
126
154
  }
127
155
  if (task.kind === 'analyse_shipment') {
128
- return backend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
156
+ return effectiveBackend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
129
157
  }
130
158
  throw new Error(`This companion doesn't know how to run task kind "${task.kind}" yet.`);
131
159
  }
@@ -155,14 +183,14 @@ async function runOneTask(anthropic, backend, client, repoPath, task) {
155
183
  }
156
184
  async function cmdRun() {
157
185
  const config = loadConfig();
158
- if (!config || config.connections.length === 0) {
186
+ if (!config || (config.connections.length === 0 && !config.pollWorkspaceTasks)) {
159
187
  console.error(`Nothing set up yet — ${configPath} is missing or empty. Run "connect" first.`);
160
188
  process.exitCode = 1;
161
189
  return;
162
190
  }
163
- // Backends are per-connection (chosen at `connect` time) — only require what's actually
164
- // configured to run, not every prerequisite either backend could ever need.
165
- const needsApiKey = config.connections.some((c) => c.backend !== 'cli');
191
+ // Repo backends are chosen per connection; workspace-level tasks always use api-key. Only
192
+ // 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');
166
194
  const needsCli = config.connections.some((c) => c.backend === 'cli');
167
195
  // config.apiKey (set via `connect --api-key ...`) takes priority over the environment variable
168
196
  // — the easier path for someone who'd rather paste their key once into a command than learn
@@ -192,11 +220,18 @@ async function cmdRun() {
192
220
  const anthropic = needsApiKey ? new Anthropic({ apiKey }) : null;
193
221
  const client = new CompanionClient(config.url, config.token);
194
222
  await client.whoami();
195
- console.log(`Connected to ${config.url}. Watching ${config.connections.length} project${config.connections.length === 1 ? '' : 's'}:`);
223
+ const projectCount = config.connections.length;
224
+ const watching = config.pollWorkspaceTasks
225
+ ? `${projectCount > 0 ? `${projectCount} project${projectCount === 1 ? '' : 's'} and ` : ''}workspace-level tasks`
226
+ : `${projectCount} project${projectCount === 1 ? '' : 's'}`;
227
+ console.log(`Connected to ${config.url}. Watching ${watching}:`);
196
228
  for (const c of config.connections) {
197
229
  const sha = await currentCommit(c.repoPath).catch(() => '(unknown)');
198
230
  console.log(` - ${c.connectionId} → ${c.repoPath} @ ${sha.slice(0, 7)} (${c.backend})`);
199
231
  }
232
+ if (config.pollWorkspaceTasks) {
233
+ console.log(' - workspace-level (connection-less / PRD-only) tasks');
234
+ }
200
235
  console.log('Waiting for work — leave this running. Ctrl+C any time to stop.');
201
236
  let stopping = false;
202
237
  process.on('SIGINT', () => {
@@ -227,6 +262,25 @@ async function cmdRun() {
227
262
  claimedAny = true;
228
263
  await runOneTask(anthropic, c.backend, client, c.repoPath, claim.task);
229
264
  }
265
+ if (!stopping && config.pollWorkspaceTasks) {
266
+ let claim;
267
+ try {
268
+ claim = await client.claimNext();
269
+ }
270
+ catch (err) {
271
+ if (err instanceof CompanionHttpError && err.status === 401) {
272
+ console.error('Token rejected (revoked?) — stopping. Pair a new token and reconnect.');
273
+ return;
274
+ }
275
+ console.error('Workspace-level poll failed:', err instanceof Error ? err.message : err);
276
+ claim = { task: null };
277
+ }
278
+ if (claim.task) {
279
+ claimedAny = true;
280
+ // 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);
282
+ }
283
+ }
230
284
  if (!claimedAny && !stopping)
231
285
  await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
232
286
  }
package/dist/config.js CHANGED
@@ -27,7 +27,28 @@ export function addConnection(args) {
27
27
  const existing = loadConfig();
28
28
  const connections = (existing?.connections ?? []).filter((c) => c.connectionId !== args.connection.connectionId);
29
29
  connections.push(args.connection);
30
- const config = { url: args.url, token: args.token, connections, apiKey: args.apiKey || existing?.apiKey };
30
+ const config = {
31
+ url: args.url,
32
+ token: args.token,
33
+ connections,
34
+ apiKey: args.apiKey || existing?.apiKey,
35
+ pollWorkspaceTasks: existing?.pollWorkspaceTasks,
36
+ };
37
+ save(config);
38
+ return config;
39
+ }
40
+ /** Records that this companion should also poll for workspace-level (connection-less) tasks.
41
+ * Additive: keeps any existing connections and apiKey; only sets url/token if not already set
42
+ * (or if they differ — same rule as addConnection). */
43
+ export function enableWorkspacePolling(args) {
44
+ const existing = loadConfig();
45
+ const config = {
46
+ url: args.url,
47
+ token: args.token,
48
+ connections: existing?.connections ?? [],
49
+ apiKey: args.apiKey || existing?.apiKey,
50
+ pollWorkspaceTasks: true,
51
+ };
31
52
  save(config);
32
53
  return config;
33
54
  }
@@ -26,7 +26,7 @@ export class CompanionClient {
26
26
  return this.call('/api/companion/whoami');
27
27
  }
28
28
  claimNext(connectionId) {
29
- return this.call(`/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}`);
29
+ return this.call(connectionId ? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}` : '/api/companion/tasks/next');
30
30
  }
31
31
  heartbeat(taskId) {
32
32
  return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, { method: 'POST' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",