@venturewild/workspace 0.1.1 → 0.1.3

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,200 @@
1
+ // Agent readiness — "is the wrapped agent actually able to talk?"
2
+ //
3
+ // THE GAP THIS CLOSES: detectAgents() only proves the `claude` binary is on the
4
+ // PATH. It does NOT prove the user has signed in. A brand-new user who installed
5
+ // the CLI but never ran `claude auth login` has a binary that resolves but every
6
+ // `claude -p` turn fails with an auth error. Onboarding's folder-peek (step 2)
7
+ // would then surface a scary error bubble — the exact "makes them feel stupid"
8
+ // failure docs/user-experience.md §4 forbids, and the open question in §3.2.
9
+ //
10
+ // THE SECOND GAP (added 2026-06-01): being signed in is NOT the same as being
11
+ // ABLE to run turns. A free claude.ai account can complete `claude auth login`
12
+ // (so `loggedIn:true`) yet Claude Code still refuses every prompt — a paid plan
13
+ // (Pro/Max/Team/Enterprise) or API billing is required. If we only checked
14
+ // `loggedIn` we'd wave a free user straight into the folder-peek, where the
15
+ // FIRST `claude -p` blows up with a billing error = the same scary bubble. So we
16
+ // also read `subscriptionType` / `apiProvider` and raise a distinct `subscribe`
17
+ // verdict ("you're in — you just need an active plan") instead.
18
+ //
19
+ // THE PROBE: `claude auth status` — a built-in, ZERO-TOKEN, cross-platform check
20
+ // (verified on Claude Code 2.1.158). It reads whatever credential store the
21
+ // platform uses (macOS Keychain, Windows ~/.claude/.credentials.json, Linux
22
+ // config), so a naive credentials-file check (which would false-negative on a
23
+ // signed-in Mac user like Mel) is wrong — we ask the CLI itself. It prints JSON:
24
+ // - signed in → exit 0, {"loggedIn":true,"authMethod":"claude.ai",
25
+ // "apiProvider":"firstParty","email":"…",
26
+ // "subscriptionType":"max",…}
27
+ // - signed out → exit 1, {"loggedIn":false,"authMethod":"none",…}
28
+ // We parse the JSON (authoritative) and fall back to the exit code if the shape
29
+ // ever changes.
30
+ //
31
+ // API-KEY / TOKEN BILLING: if ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN /
32
+ // CLAUDE_CODE_OAUTH_TOKEN) is set, `claude -p` bills through that and turns run
33
+ // even without a claude.ai OAuth session — so we treat a configured key as
34
+ // `ready` regardless of `auth status` (the sponsored / managed-key path).
35
+ //
36
+ // Only `claude` has this contract today. Other agents (gemini/glm/codex) report
37
+ // `unknown` — we never block on a readiness we can't measure (fail-open), so a
38
+ // Codex user is never gated by a Claude-shaped check.
39
+
40
+ import { execFile as execFileCb } from 'node:child_process';
41
+ import { promisify } from 'node:util';
42
+
43
+ const execFile = promisify(execFileCb);
44
+
45
+ // `claude auth status` is local-only (no network), but give it room on a cold
46
+ // cache / slow disk. Comfortably under the 5s onboarding "feels instant" budget.
47
+ const AUTH_PROBE_TIMEOUT_MS = 8000;
48
+
49
+ // Subscription tiers that CAN run Claude Code turns vs. ones that cannot. Lower-
50
+ // cased for comparison. An unknown, non-empty tier on a firstParty account is
51
+ // treated leniently (assumed paid) so a future tier name never false-gates a
52
+ // paying user behind a non-skippable wall — the empty/known-free cases are the
53
+ // only ones we positively block.
54
+ const PAID_TIERS = new Set(['pro', 'max', 'team', 'enterprise']);
55
+ const UNPAID_TIERS = new Set(['free', 'none', 'inactive', 'expired', 'trial_expired']);
56
+
57
+ // An API key / long-lived token configures billing directly — turns will run
58
+ // even with no OAuth session. Checked before the auth probe.
59
+ function hasApiBillingKey(env) {
60
+ const keys = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
61
+ return keys.some((k) => typeof env?.[k] === 'string' && env[k].trim() !== '');
62
+ }
63
+
64
+ // Given a parsed `auth status` object for a signed-in user, can turns actually
65
+ // run? true = yes (paid plan or API billing), false = no (free / no plan),
66
+ // null = unknown shape → caller treats as ready (lenient, never false-gates).
67
+ export function canRunTurns(parsed) {
68
+ if (!parsed || typeof parsed !== 'object') return null;
69
+ const sub =
70
+ typeof parsed.subscriptionType === 'string' ? parsed.subscriptionType.toLowerCase().trim() : '';
71
+ const provider =
72
+ typeof parsed.apiProvider === 'string' ? parsed.apiProvider.toLowerCase().trim() : '';
73
+ // A non-firstParty provider (console / bedrock / vertex) bills via API → runs.
74
+ if (provider && provider !== 'firstparty') return true;
75
+ if (PAID_TIERS.has(sub)) return true;
76
+ if (UNPAID_TIERS.has(sub)) return false;
77
+ // Signed in via claude.ai with NO subscription field at all → no active plan
78
+ // (the current CLI emits subscriptionType for paid accounts, so absence here
79
+ // is the free-account fingerprint).
80
+ if (sub === '') return false;
81
+ // Unknown, non-empty tier on a firstParty account — lean paid (don't gate).
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Readiness verdict for one agent.
87
+ * status: 'ready' — installed AND able to run turns (signed-in + plan, or API key).
88
+ * 'subscribe' — installed AND signed in, but no active plan → the user
89
+ * needs a Claude Pro (or higher) plan before turns run.
90
+ * 'login' — installed but NOT signed in; the user must auth.
91
+ * 'missing' — binary not on PATH (detect step already knew).
92
+ * 'unknown' — installed, but this agent has no auth-status probe
93
+ * we understand → never gate on it (fail-open).
94
+ * email: parsed sign-in identity when known (claude only), else null.
95
+ * hint: a short, human, NON-technical line the UI can show as-is.
96
+ */
97
+ export async function probeClaudeAuth(agent, runner = defaultRunner, env = process.env) {
98
+ if (!agent || !agent.available) {
99
+ return {
100
+ status: 'missing',
101
+ email: null,
102
+ hint: 'Claude Code isn’t installed yet. Install it, then come back.',
103
+ };
104
+ }
105
+ // Sponsored / managed billing: a configured key runs turns regardless of the
106
+ // OAuth session state, so don't gate.
107
+ if (hasApiBillingKey(env)) {
108
+ return { status: 'ready', email: null, hint: null };
109
+ }
110
+ const command = agent.resolvedPath || agent.binary;
111
+ try {
112
+ const { code, stdout } = await runner(command, ['auth', 'status']);
113
+ const parsed = parseAuthStatus(stdout);
114
+ // Authoritative: the JSON `loggedIn` flag. Fall back to the exit code
115
+ // (0 = signed in) only if the JSON is unparseable / shape-changed.
116
+ const loggedIn = parsed ? parsed.loggedIn === true : code === 0;
117
+ if (loggedIn) {
118
+ // Signed in — but can they actually run a turn? A free account can't.
119
+ if (canRunTurns(parsed) === false) {
120
+ return {
121
+ status: 'subscribe',
122
+ email: parsed?.email || null,
123
+ hint: 'You’re signed in — you just need an active Claude plan (Pro or higher) so your agent can run.',
124
+ };
125
+ }
126
+ return { status: 'ready', email: parsed?.email || null, hint: null };
127
+ }
128
+ // Signed out (or any non-ready shape). Treat as "needs login" — safer than
129
+ // claiming ready and then failing the first real turn mid-onboarding.
130
+ return {
131
+ status: 'login',
132
+ email: null,
133
+ hint: 'One quick thing: sign in to Claude so your agent can think.',
134
+ };
135
+ } catch (err) {
136
+ // The probe itself failed to run (timeout, spawn error, unexpected CLI
137
+ // version with no `auth` subcommand). Don't hard-block onboarding on our
138
+ // own probe breaking — report unknown and let the turn-runner be the
139
+ // ground truth. Surfaced for logging, not shown as a scary error.
140
+ return {
141
+ status: 'unknown',
142
+ email: null,
143
+ hint: null,
144
+ error: String(err?.message || err),
145
+ };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Readiness for the active agent. Claude gets the real probe; every other agent
151
+ * is 'unknown' (we have no auth-status contract for them, so we never gate).
152
+ */
153
+ export async function probeAgentReadiness(agent, runner = defaultRunner, env = process.env) {
154
+ if (agent && agent.id === 'claude') {
155
+ return probeClaudeAuth(agent, runner, env);
156
+ }
157
+ return { status: 'unknown', email: null, hint: null };
158
+ }
159
+
160
+ // Parse `claude auth status` stdout. It prints a JSON object; tolerate leading/
161
+ // trailing noise by extracting the first {...} span. Returns null if nothing
162
+ // parseable is found (caller falls back to the exit code).
163
+ export function parseAuthStatus(stdout) {
164
+ const text = String(stdout || '');
165
+ const start = text.indexOf('{');
166
+ const end = text.lastIndexOf('}');
167
+ if (start === -1 || end === -1 || end <= start) return null;
168
+ try {
169
+ const obj = JSON.parse(text.slice(start, end + 1));
170
+ return obj && typeof obj === 'object' ? obj : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // execFile wrapper that resolves (never rejects) on a non-zero exit, so the
177
+ // caller can branch on { code, stdout } uniformly. Only a genuine spawn/timeout
178
+ // failure rejects — that's the 'unknown' path above.
179
+ function defaultRunner(command, args) {
180
+ return new Promise((resolve, reject) => {
181
+ execFile(
182
+ command,
183
+ args,
184
+ { timeout: AUTH_PROBE_TIMEOUT_MS, windowsHide: true },
185
+ (err, stdout, stderr) => {
186
+ if (err && typeof err.code !== 'number') {
187
+ // No numeric exit code => process never ran to completion
188
+ // (ENOENT, ETIMEDOUT, killed). That's a probe failure, not a verdict.
189
+ reject(err);
190
+ return;
191
+ }
192
+ resolve({
193
+ code: err ? err.code : 0,
194
+ stdout: stdout || '',
195
+ stderr: stderr || '',
196
+ });
197
+ },
198
+ );
199
+ });
200
+ }