product-console-companion 0.1.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Fynch Consulting
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fynch Consulting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,96 +1,141 @@
1
- # product-console-companion
2
-
3
- A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
4
- your own machine, so:
5
-
6
- - code never leaves your machine — only a small structured verdict (matches/differs + a short
7
- explanation) is ever sent back;
8
- - there's no server-side truncation on what gets read (`server/drift.ts`'s
9
- `MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
10
- - AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
11
-
12
- Covers every AI call the hosted Console makes that reads your code — six task kinds as of this
13
- release (`verify_module` — "Check against code" on a module card; `generate_spec_batch` —
14
- writing full specs during setup; `propose_skeleton` and `propose_new_capabilities` — the two
15
- shallow whole-repo scans; `design_prompt` — the free-text export; `analyse_shipment` — the
16
- passive per-commit drift check behind the GitHub webhook and the "Check GitHub" button), two
17
- backends, chosen per connection at `connect` time:
18
-
19
- - **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
20
- (`api-key-caller.ts`), running its own two-pass file-select-then-judge, mirroring
21
- `server/drift.ts`'s `verifyModuleAgainstCode` but reading local files unbounded.
22
- - **`cli`** — shells out to an already-authenticated `claude` CLI (`claude-cli-caller.ts`) for
23
- full agentic Read/Grep/Glob depth in one call — the model does its own exploring instead of a
24
- separate file-select pass. Bills against whatever the CLI is authenticated with (a Claude
25
- subscription via `claude setup-token`, or `ANTHROPIC_API_KEY` if the CLI has that set). Runs
26
- against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
27
- `--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
28
- impossible on its own.
29
-
30
- ## Setup
31
-
32
- ```bash
33
- npx product-console-companion connect \
34
- --url https://console.fynchconsulting.com \
35
- --token <publicId>.<secret> \
36
- --connection <connectionId> \
37
- --path /path/to/your/local/checkout \
38
- --backend api-key # or: --backend cli
39
- ```
40
-
41
- Get `--token`/`--connection` from the Console's **Companion** button (generates a token and a
42
- ready-to-paste version of this command) — see `src/CompanionModal.tsx` in the parent repo.
43
- Running `connect` again for a different `--connection`/`--path` (same `--url`/`--token`) adds a
44
- second connection to the same local config; one companion process can serve several repos, each
45
- with its own backend choice.
46
-
47
- For `--backend api-key`: `export ANTHROPIC_API_KEY=sk-ant-...` (get one at console.anthropic.com).
48
- For `--backend cli`: install and authenticate the CLI first — `npm install -g
49
- @anthropic-ai/claude-code`, then `claude` (interactive login) or set `ANTHROPIC_API_KEY` for it.
50
- `connect` checks `claude --version` up front and fails fast if it's missing.
51
-
52
- Then, in the Console's **Manage connected repos**, switch that repo's execution to "On your
53
- machine, via companion". Finally, to actually serve tasks:
54
-
55
- ```bash
56
- npx product-console-companion run
57
- ```
58
-
59
- Polls each configured connection every few seconds until Ctrl+C. Nothing runs while this isn't
60
- running — see the parent plan's Section 7 for why that's deliberate (goal 4: no AI spend while
61
- you're not around to notice).
62
-
63
- ## What's NOT yet implemented (be aware before relying on this)
64
-
65
- - **Per-task budget is not enforced client-side for the `api-key` backend.** Every dispatched
66
- task carries a `maxBudgetUsd` (visible in the `run` log line); `api-key-caller.ts` does not
67
- track spend against it or abort a call that exceeds it. The `cli` backend passes it straight
68
- through as `claude`'s own `--max-budget-usd`, which the CLI does enforce (confirmed
69
- empirically: it stops and reports `terminal_reason: 'budget_exhausted'`).
70
- - **No wall-clock timeout for the `api-key` backend** — a hung request blocks that connection's
71
- polling loop. The `cli` backend has one (10 minutes, independent of the budget cap — a dollar
72
- ceiling doesn't stop a network-stall hang).
73
- - **Git-fetch-if-missing is `analyse_shipment`-only.** Every other task kind reads whatever HEAD
74
- currently is in `--path` (or, for `cli`, a worktree pinned to that HEAD) — correct for those,
75
- since they check the *current* state of your code, not a specific historical commit.
76
- `analyse_shipment` is different: it's about one specific commit a webhook or the hosted poller
77
- told the server about, which the server never fetches for you. It verifies that commit is
78
- actually present locally first, attempts one `git fetch` (your own git credentials — never
79
- anything from the hosted server) if it isn't, and reports back distinctly (`sha_not_local`)
80
- rather than silently analysing the wrong tree state if it still can't find it.
81
- - **`cli` backend's sandbox worktree isolates writes, not reads** — `--add-dir` points at the
82
- disposable worktree, so a read sees exactly what's committed at HEAD, including anything
83
- `.gitignore`d being invisible (same as `git ls-files`'s own behavior). Uncommitted local
84
- changes in your real checkout are never visible to the CLI backend — worth knowing if you're
85
- expecting a check to see work-in-progress edits. (`analyse_shipment`'s own diff read is the one
86
- exception: it happens against your real checkout, via `git show`, before the sandbox is even
87
- created — the model itself still only ever sees the sandboxed copy.)
88
-
89
- ## Local development (inside the parent repo)
90
-
91
- ```bash
92
- cd companion
93
- npm install
94
- npm run dev -- connect --url http://localhost:3101 --token <publicId>.<secret> --connection <connectionId> --path <repo path> --backend api-key
95
- npm run dev -- run
96
- ```
1
+ # product-console-companion
2
+
3
+ A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
4
+ your own machine, so:
5
+
6
+ - code never leaves your machine — only a small structured verdict (matches/differs + a short
7
+ explanation) is ever sent back;
8
+ - there's no server-side truncation on what gets read (`server/drift.ts`'s
9
+ `MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
10
+ - AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
11
+
12
+ Covers every AI call the hosted Console makes that reads your code — six task kinds as of this
13
+ release (`verify_module` — "Check against code" on a module card; `generate_spec_batch` —
14
+ writing full specs during setup; `propose_skeleton` and `propose_new_capabilities` — the two
15
+ shallow whole-repo scans; `design_prompt` — the free-text export; `analyse_shipment` — the
16
+ passive per-commit drift check behind the GitHub webhook and the "Check GitHub" button), two
17
+ backends, chosen per connection at `connect` time:
18
+
19
+ - **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
20
+ (`api-key-caller.ts`), running its own two-pass file-select-then-judge, mirroring
21
+ `server/drift.ts`'s `verifyModuleAgainstCode` but reading local files unbounded.
22
+ - **`cli`** — shells out to an already-authenticated `claude` CLI (`claude-cli-caller.ts`) for
23
+ full agentic Read/Grep/Glob depth in one call — the model does its own exploring instead of a
24
+ separate file-select pass. Bills against whatever the CLI is authenticated with (a Claude
25
+ subscription via `claude setup-token`, or `ANTHROPIC_API_KEY` if the CLI has that set). Runs
26
+ against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
27
+ `--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
28
+ impossible on its own.
29
+
30
+ ## Setup
31
+
32
+ The Console's **Companion** button generates a ready-to-paste command per connected project —
33
+ that's the intended way to get `--token`/`--connection`/`--path` right (see
34
+ `src/CompanionModal.tsx` in the parent repo, which also has an inline toggle to switch that
35
+ connection's execution to "on your machine" right next to the command it activates). Written out,
36
+ the shape is:
37
+
38
+ ```bash
39
+ npx product-console-companion connect \
40
+ --url https://console.fynchconsulting.com \
41
+ --token <publicId>.<secret> \
42
+ --connection <connectionId> \
43
+ --path /path/to/your/local/checkout \
44
+ --backend api-key \
45
+ --api-key sk-ant-... # optional — see below
46
+ ```
47
+
48
+ `--path` defaults to the current directory if omitted — running `connect` from inside your
49
+ project's own folder works with no `--path` at all. `connect` **pairs and immediately starts
50
+ serving tasks in the same command** (equivalent to also running `run`, below) — pass `--no-run`
51
+ if you're pairing a second/third project first and want to start one shared `run` for all of them
52
+ afterward instead.
53
+
54
+ Running `connect` again for a different `--connection`/`--path` (same `--url`/`--token`) adds a
55
+ second connection to the same local config; one companion process can serve several repos, each
56
+ with its own backend choice.
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
+
67
+ **`--backend api-key`** (default) needs an Anthropic API key (get one free at
68
+ console.anthropic.com) — either pass it inline with `--api-key sk-ant-...` (saved to this
69
+ package's own local config file, `~/.product-console-companion/config.json`, permissions locked
70
+ to your user only) or set `ANTHROPIC_API_KEY` in your environment yourself; the inline flag takes
71
+ priority if both are present.
72
+
73
+ **`--backend cli`** needs [Claude Code](https://claude.com/claude-code) installed and you logged
74
+ into it first — `npm install -g @anthropic-ai/claude-code`, then run `claude` and follow the login
75
+ prompt. `connect` checks `claude --version` up front and fails fast if it's missing. Bills against
76
+ your Claude subscription, not a separate API key.
77
+
78
+ Once it's running:
79
+
80
+ ```bash
81
+ npx product-console-companion run
82
+ ```
83
+
84
+ (this is what `connect` already ran for you, unless you passed `--no-run`) polls each configured
85
+ connection every few seconds until Ctrl+C. Nothing runs while this isn't running — see the parent
86
+ plan's Section 7 for why that's deliberate (goal 4: no AI spend while you're not around to
87
+ notice).
88
+
89
+ ## What's NOT yet implemented (be aware before relying on this)
90
+
91
+ - **Per-task budget is not enforced client-side for the `api-key` backend.** Every dispatched
92
+ task carries a `maxBudgetUsd` (visible in the `run` log line); `api-key-caller.ts` does not
93
+ track spend against it or abort a call that exceeds it. The `cli` backend passes it straight
94
+ through as `claude`'s own `--max-budget-usd`, which the CLI does enforce (confirmed
95
+ empirically: it stops and reports `terminal_reason: 'budget_exhausted'`).
96
+ - **No wall-clock timeout for the `api-key` backend** — a hung request blocks that connection's
97
+ polling loop. The `cli` backend has one (10 minutes, independent of the budget cap — a dollar
98
+ ceiling doesn't stop a network-stall hang).
99
+ - **Git-fetch-if-missing is `analyse_shipment`-only.** Every other task kind reads whatever HEAD
100
+ currently is in `--path` (or, for `cli`, a worktree pinned to that HEAD) — correct for those,
101
+ since they check the *current* state of your code, not a specific historical commit.
102
+ `analyse_shipment` is different: it's about one specific commit a webhook or the hosted poller
103
+ told the server about, which the server never fetches for you. It verifies that commit is
104
+ actually present locally first, attempts one `git fetch` (your own git credentials — never
105
+ anything from the hosted server) if it isn't, and reports back distinctly (`sha_not_local`)
106
+ rather than silently analysing the wrong tree state if it still can't find it.
107
+ - **`cli` backend's sandbox worktree isolates writes, not reads** — `--add-dir` points at the
108
+ disposable worktree, so a read sees exactly what's committed at HEAD, including anything
109
+ `.gitignore`d being invisible (same as `git ls-files`'s own behavior). Uncommitted local
110
+ changes in your real checkout are never visible to the CLI backend — worth knowing if you're
111
+ expecting a check to see work-in-progress edits. (`analyse_shipment`'s own diff read is the one
112
+ exception: it happens against your real checkout, via `git show`, before the sandbox is even
113
+ created — the model itself still only ever sees the sandboxed copy.)
114
+
115
+ ## Local development (inside the parent repo)
116
+
117
+ ```bash
118
+ cd companion
119
+ npm install
120
+ npm run dev -- connect --url http://localhost:3101 --token <publicId>.<secret> --connection <connectionId> --path <repo path> --backend api-key
121
+ npm run dev -- run
122
+ ```
123
+
124
+ ## Publishing a new version
125
+
126
+ Via GitHub Actions Trusted Publishing (`.github/workflows/publish-companion.yml`) — no npm token
127
+ generated, pasted, or stored for this, ever. One-time setup on npmjs.com (already done as of this
128
+ writing): on the `product-console-companion` package's own page, **Settings → Trusted
129
+ publishing → GitHub Actions**, pointing at this repo (`fynchconsulting/product-console`) and this
130
+ exact workflow filename (`publish-companion.yml`), no environment.
131
+
132
+ To actually publish once that's configured: bump the version in `package.json` in a normal PR,
133
+ merge it, then either
134
+
135
+ ```bash
136
+ git tag companion-v<the new version> && git push --tags
137
+ ```
138
+
139
+ or trigger the workflow manually (Actions tab, or `gh workflow run publish-companion.yml`). Either
140
+ way it publishes whatever's currently in `package.json` at that point — there's no separate
141
+ "which version" input to get wrong.
@@ -47,16 +47,16 @@ export async function runVerifyModuleTask(client, task, repoPath) {
47
47
  // Pass 1: which files implement this module? Reuses the module-spec text the server already
48
48
  // wrote into spec.volatileText (buildVerifyModuleTaskSpec) as context — it already contains
49
49
  // the full spec, so this doesn't need to re-derive it.
50
- const selectPrompt = `${spec.volatileText}
51
-
52
- # The repository file tree (${allFiles.length} files)
53
-
54
- ${allFiles.join('\n')}
55
-
56
- # Before judging
57
-
58
- First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
59
- likely to implement this module's behaviour. Prefer application source over tests, config, and
50
+ const selectPrompt = `${spec.volatileText}
51
+
52
+ # The repository file tree (${allFiles.length} files)
53
+
54
+ ${allFiles.join('\n')}
55
+
56
+ # Before judging
57
+
58
+ First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
59
+ likely to implement this module's behaviour. Prefer application source over tests, config, and
60
60
  generated files. Empty list if nothing plausibly implements it.`;
61
61
  const selectStream = client.messages.stream({
62
62
  model: spec.model,
@@ -160,19 +160,41 @@ 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?
166
- const selectPrompt = `${spec.volatileText}
167
-
168
- # The repository file tree (${allFiles.length} files)
169
-
170
- ${allFiles.join('\n')}
171
-
172
- # Before writing specs
173
-
174
- For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
175
- file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
188
+ const selectPrompt = `${spec.volatileText}
189
+
190
+ # The repository file tree (${allFiles.length} files)
191
+
192
+ ${allFiles.join('\n')}
193
+
194
+ # Before writing specs
195
+
196
+ For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
197
+ file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
176
198
  in the same order as given.`;
177
199
  const selectStream = client.messages.stream({
178
200
  model: spec.model,
@@ -236,31 +258,34 @@ 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 = `
243
- # Repository context (your local checkout)
244
-
245
- ## File tree (${repoCtx.fileList.length} files)
246
- ${repoCtx.fileList.join('\n')}
247
-
248
- ## README
249
- ${repoCtx.readme ?? '(none found)'}
250
-
251
- ## CHANGELOG
252
- ${repoCtx.changelog ?? '(none found)'}
253
-
254
- ## package.json
255
- ${repoCtx.packageJson ?? '(none found)'}
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 = `
267
+ # Repository context (your local checkout)
268
+
269
+ ## File tree (${repoCtx.fileList.length} files)
270
+ ${repoCtx.fileList.join('\n')}
271
+
272
+ ## README
273
+ ${repoCtx.readme ?? '(none found)'}
274
+
275
+ ## CHANGELOG
276
+ ${repoCtx.changelog ?? '(none found)'}
277
+
278
+ ## package.json
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,18 +2,27 @@
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';
9
9
  import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runVerifyModuleTaskViaCli } from './claude-cli-caller.js';
10
+ /** A flag with nothing after it (end of argv, or the next token is itself another "--flag") is
11
+ * treated as a bare boolean ("true") rather than swallowing the following flag as its value —
12
+ * needed for --no-run below. Every existing "--flag value" pair still parses exactly as before. */
10
13
  function parseFlags(argv) {
11
14
  const flags = {};
12
15
  for (let i = 0; i < argv.length; i++) {
13
16
  const a = argv[i];
14
17
  if (a?.startsWith('--')) {
15
- flags[a.slice(2)] = argv[i + 1] ?? '';
16
- i++;
18
+ const next = argv[i + 1];
19
+ if (next === undefined || next.startsWith('--')) {
20
+ flags[a.slice(2)] = 'true';
21
+ }
22
+ else {
23
+ flags[a.slice(2)] = next;
24
+ i++;
25
+ }
17
26
  }
18
27
  }
19
28
  return flags;
@@ -32,40 +41,84 @@ async function cmdConnect(argv) {
32
41
  const url = f.url?.replace(/\/+$/, '');
33
42
  const token = f.token;
34
43
  const connectionId = f.connection;
35
- const repoPath = f.path ? resolve(f.path) : undefined;
36
- const backend = (f.backend || 'api-key');
37
- if (!url || !token || !connectionId || !repoPath) {
38
- console.error('Usage: product-console-companion connect --url <origin> --token <publicId.secret> --connection <connectionId> --path <local repo path> [--backend api-key|cli]');
44
+ const apiKey = f['api-key'];
45
+ // Pairing on its own does nothing useful — see cmdRun below — so this defaults to starting
46
+ // right away, which is what someone following the app's own copy-pasted command wants: one
47
+ // paste, and it's working. --no-run is the escape hatch for pairing a second/third project
48
+ // before starting one shared `run` for all of them (see the README's own multi-connection note).
49
+ const shouldRunAfter = f['no-run'] !== 'true';
50
+ if (!url || !token) {
51
+ console.error('Usage: product-console-companion connect --url <your Console link> --token <publicId.secret> --connection <connectionId> ' +
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]');
39
54
  process.exitCode = 1;
40
55
  return;
41
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');
42
82
  if (backend !== 'api-key' && backend !== 'cli') {
43
83
  console.error(`--backend must be "api-key" or "cli", got "${backend}"`);
44
84
  process.exitCode = 1;
45
85
  return;
46
86
  }
47
- await assertIsRepo(repoPath);
87
+ try {
88
+ await assertIsRepo(repoPath);
89
+ }
90
+ catch (err) {
91
+ console.error((err instanceof Error ? err.message : String(err)) + ' Run this from inside your project\'s folder, or pass --path <folder>.');
92
+ process.exitCode = 1;
93
+ return;
94
+ }
48
95
  if (backend === 'cli') {
49
96
  const version = await checkClaudeCliAvailable();
50
97
  if (!version) {
51
- console.error('--backend cli needs the `claude` CLI on PATH and authenticated — install it first: npm install -g @anthropic-ai/claude-code');
98
+ 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.');
52
99
  process.exitCode = 1;
53
100
  return;
54
101
  }
55
- console.log(`Found claude CLI: ${version}`);
102
+ console.log(`Found Claude Code: ${version}`);
56
103
  }
57
104
  const client = new CompanionClient(url, token);
58
105
  try {
59
106
  await client.whoami();
60
107
  }
61
108
  catch (err) {
62
- console.error(`Could not validate that token against ${url}:`, err instanceof Error ? err.message : err);
109
+ console.error(`Hmm, that didn't work — could not verify your token against ${url}. Double check you copied the ` +
110
+ `WHOLE thing, including the part after the dot.`, err instanceof Error ? err.message : err);
63
111
  process.exitCode = 1;
64
112
  return;
65
113
  }
66
- addConnection({ url, token, connection: { connectionId, repoPath, backend } });
67
- console.log(`Paired (backend: ${backend}). Saved to ${configPath}.`);
68
- console.log(`Run "product-console-companion run" to start serving tasks for this (and any other configured) connection.`);
114
+ addConnection({ url, token, connection: { connectionId, repoPath, backend }, apiKey });
115
+ console.log(`Connected! This computer will now read this project's code and run AI calls for it. (Saved to ${configPath}.)`);
116
+ if (!shouldRunAfter) {
117
+ console.log('Run "product-console-companion run" whenever you\'re ready to start.');
118
+ return;
119
+ }
120
+ console.log('Starting now — Ctrl+C any time to stop.\n');
121
+ await cmdRun();
69
122
  }
70
123
  /** How often an idle connection is re-polled when nothing was claimed — a short client-side
71
124
  * poll, not a server-held long-poll (see the plan's own reasoning: this app has already been
@@ -78,23 +131,29 @@ const HEARTBEAT_MS = 30_000;
78
131
  * each backed by both backends. Grows by one entry per phase, same as the server's own registry
79
132
  * (server/companion.ts's TASK_RESULT_SCHEMAS) — the two lists should always name the same kinds. */
80
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
+ }
81
140
  if (task.kind === 'verify_module') {
82
- 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
83
142
  }
84
143
  if (task.kind === 'generate_spec_batch') {
85
- return backend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
144
+ return effectiveBackend === 'cli' ? runGenerateSpecBatchTaskViaCli(task, repoPath) : runGenerateSpecBatchTask(anthropic, task, repoPath);
86
145
  }
87
146
  if (task.kind === 'propose_skeleton') {
88
- return backend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
147
+ return effectiveBackend === 'cli' ? runProposeSkeletonTaskViaCli(task, repoPath) : runProposeSkeletonTask(anthropic, task, repoPath);
89
148
  }
90
149
  if (task.kind === 'propose_new_capabilities') {
91
- return backend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
150
+ return effectiveBackend === 'cli' ? runProposeNewCapabilitiesTaskViaCli(task, repoPath) : runProposeNewCapabilitiesTask(anthropic, task, repoPath);
92
151
  }
93
152
  if (task.kind === 'design_prompt') {
94
- return backend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
153
+ return effectiveBackend === 'cli' ? runDesignPromptTaskViaCli(task, repoPath) : runDesignPromptTask(anthropic, task, repoPath);
95
154
  }
96
155
  if (task.kind === 'analyse_shipment') {
97
- return backend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
156
+ return effectiveBackend === 'cli' ? runAnalyseShipmentTaskViaCli(task, repoPath) : runAnalyseShipmentTask(anthropic, task, repoPath);
98
157
  }
99
158
  throw new Error(`This companion doesn't know how to run task kind "${task.kind}" yet.`);
100
159
  }
@@ -124,43 +183,56 @@ async function runOneTask(anthropic, backend, client, repoPath, task) {
124
183
  }
125
184
  async function cmdRun() {
126
185
  const config = loadConfig();
127
- if (!config || config.connections.length === 0) {
128
- console.error(`No connections configured — ${configPath} is missing or empty. Run "connect" first.`);
186
+ if (!config || (config.connections.length === 0 && !config.pollWorkspaceTasks)) {
187
+ console.error(`Nothing set up yet — ${configPath} is missing or empty. Run "connect" first.`);
129
188
  process.exitCode = 1;
130
189
  return;
131
190
  }
132
- // Backends are per-connection (chosen at `connect` time) — only require what's actually
133
- // configured to run, not every prerequisite either backend could ever need.
134
- 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');
135
194
  const needsCli = config.connections.some((c) => c.backend === 'cli');
136
- const apiKey = process.env.ANTHROPIC_API_KEY;
195
+ // config.apiKey (set via `connect --api-key ...`) takes priority over the environment variable
196
+ // — the easier path for someone who'd rather paste their key once into a command than learn
197
+ // what an environment variable is and how to set one on their OS. Both are supported; neither
198
+ // is required unless a configured connection actually needs it.
199
+ const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
137
200
  if (needsApiKey && !apiKey) {
138
- console.error('ANTHROPIC_API_KEY is not set — the api-key backend calls the model directly with your own key. Get one at console.anthropic.com and set it in your environment.');
201
+ console.error('No Anthropic API key found. Get one free at console.anthropic.com, then either run ' +
202
+ '"connect" again adding --api-key <your key>, or set it yourself: ' +
203
+ '"set ANTHROPIC_API_KEY=<your key>" (Windows) / "export ANTHROPIC_API_KEY=<your key>" (Mac/Linux), then run "run" again.');
139
204
  process.exitCode = 1;
140
205
  return;
141
206
  }
142
207
  if (needsCli) {
143
208
  const version = await checkClaudeCliAvailable();
144
209
  if (!version) {
145
- console.error('The cli backend needs the `claude` CLI on PATH and authenticated — install it first: npm install -g @anthropic-ai/claude-code');
210
+ 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.');
146
211
  process.exitCode = 1;
147
212
  return;
148
213
  }
149
214
  }
150
215
  for (const c of config.connections) {
151
216
  await assertIsRepo(c.repoPath).catch((err) => {
152
- console.error(`Warning: ${c.connectionId} → ${c.repoPath}:`, err instanceof Error ? err.message : err);
217
+ console.error(`Warning: can't find ${c.connectionId}'s project folder (${c.repoPath}):`, err instanceof Error ? err.message : err);
153
218
  });
154
219
  }
155
220
  const anthropic = needsApiKey ? new Anthropic({ apiKey }) : null;
156
221
  const client = new CompanionClient(config.url, config.token);
157
222
  await client.whoami();
158
- console.log(`Connected to ${config.url}. Serving ${config.connections.length} connection${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}:`);
159
228
  for (const c of config.connections) {
160
229
  const sha = await currentCommit(c.repoPath).catch(() => '(unknown)');
161
230
  console.log(` - ${c.connectionId} → ${c.repoPath} @ ${sha.slice(0, 7)} (${c.backend})`);
162
231
  }
163
- console.log('Polling for tasks — Ctrl+C to stop.');
232
+ if (config.pollWorkspaceTasks) {
233
+ console.log(' - workspace-level (connection-less / PRD-only) tasks');
234
+ }
235
+ console.log('Waiting for work — leave this running. Ctrl+C any time to stop.');
164
236
  let stopping = false;
165
237
  process.on('SIGINT', () => {
166
238
  if (stopping)
@@ -190,6 +262,25 @@ async function cmdRun() {
190
262
  claimedAny = true;
191
263
  await runOneTask(anthropic, c.backend, client, c.repoPath, claim.task);
192
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
+ }
193
284
  if (!claimedAny && !stopping)
194
285
  await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
195
286
  }
package/dist/config.js CHANGED
@@ -20,12 +20,35 @@ function save(config) {
20
20
  }
21
21
  /** Adds (or replaces, if this exact connectionId is already configured) one connection —
22
22
  * url/token are shared across every connection this companion serves; only mismatched values
23
- * overwrite them, so re-running `connect` for a second repo with the SAME url/token is additive. */
23
+ * overwrite them, so re-running `connect` for a second repo with the SAME url/token is additive.
24
+ * apiKey works the same way: passing one saves/updates it; omitting it on a later `connect`
25
+ * call leaves whatever was already saved untouched, rather than blowing it away. */
24
26
  export function addConnection(args) {
25
27
  const existing = loadConfig();
26
28
  const connections = (existing?.connections ?? []).filter((c) => c.connectionId !== args.connection.connectionId);
27
29
  connections.push(args.connection);
28
- const config = { url: args.url, token: args.token, connections };
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
+ };
29
52
  save(config);
30
53
  return config;
31
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,46 +1,46 @@
1
- {
2
- "name": "product-console-companion",
3
- "version": "0.1.0",
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
- "type": "module",
6
- "license": "MIT",
7
- "bin": {
8
- "product-console-companion": "dist/cli.js"
9
- },
10
- "files": [
11
- "dist"
12
- ],
13
- "scripts": {
14
- "build": "tsc -b",
15
- "dev": "tsx src/cli.ts"
16
- },
17
- "keywords": [
18
- "cli",
19
- "ai",
20
- "anthropic",
21
- "claude",
22
- "companion",
23
- "byok",
24
- "byos",
25
- "product-console"
26
- ],
27
- "homepage": "https://console.fynchconsulting.com",
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/fynchconsulting/product-console.git",
31
- "directory": "companion"
32
- },
33
- "dependencies": {
34
- "@anthropic-ai/sdk": "^0.111.0",
35
- "cross-spawn": "^7.0.6"
36
- },
37
- "devDependencies": {
38
- "@types/cross-spawn": "^6.0.6",
39
- "@types/node": "^24.10.1",
40
- "tsx": "^4.20.6",
41
- "typescript": "^5.9.3"
42
- },
43
- "engines": {
44
- "node": ">=20"
45
- }
46
- }
1
+ {
2
+ "name": "product-console-companion",
3
+ "version": "0.3.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "product-console-companion": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -b",
15
+ "dev": "tsx src/cli.ts"
16
+ },
17
+ "keywords": [
18
+ "cli",
19
+ "ai",
20
+ "anthropic",
21
+ "claude",
22
+ "companion",
23
+ "byok",
24
+ "byos",
25
+ "product-console"
26
+ ],
27
+ "homepage": "https://console.fynchconsulting.com",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/fynchconsulting/product-console.git",
31
+ "directory": "companion"
32
+ },
33
+ "dependencies": {
34
+ "@anthropic-ai/sdk": "^0.111.0",
35
+ "cross-spawn": "^7.0.6"
36
+ },
37
+ "devDependencies": {
38
+ "@types/cross-spawn": "^6.0.6",
39
+ "@types/node": "^24.10.1",
40
+ "tsx": "^4.20.6",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ }
46
+ }