jobarbiter 0.3.3 → 0.3.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.
package/dist/index.js CHANGED
@@ -743,9 +743,9 @@ program
743
743
  }
744
744
  });
745
745
  // ============================================================
746
- // observe (manage coding agent observers)
746
+ // observe (manage AI agent observers)
747
747
  // ============================================================
748
- const observe = program.command("observe").description("Manage coding agent proficiency observers");
748
+ const observe = program.command("observe").description("Manage AI agent proficiency observers");
749
749
  observe
750
750
  .command("status")
751
751
  .description("Show observer status and accumulated data")
@@ -754,7 +754,7 @@ observe
754
754
  const agents = detectAgents();
755
755
  const status = getObservationStatus();
756
756
  const detected = agents.filter((a) => a.installed);
757
- console.log("\nšŸ” Coding Agent Observers\n");
757
+ console.log("\nšŸ” AI Agent Observers\n");
758
758
  console.log(" Agents:");
759
759
  for (const agent of agents) {
760
760
  if (!agent.installed) {
@@ -795,7 +795,7 @@ observe
795
795
  });
796
796
  observe
797
797
  .command("install")
798
- .description("Install observers for detected coding agents")
798
+ .description("Install observers for detected AI agents")
799
799
  .option("--agent <id>", "Install for specific agent (claude-code, cursor, opencode, codex, gemini)")
800
800
  .option("--all", "Install for all detected agents")
801
801
  .action(async (opts) => {
@@ -803,7 +803,7 @@ observe
803
803
  const agents = detectAgents();
804
804
  const detected = agents.filter((a) => a.installed);
805
805
  if (detected.length === 0) {
806
- error("No coding agents detected on this system.");
806
+ error("No AI agents detected on this system.");
807
807
  console.log(" Supported: Claude Code, Cursor, OpenCode, Codex CLI, Gemini CLI");
808
808
  process.exit(1);
809
809
  }
@@ -841,7 +841,7 @@ observe
841
841
  });
842
842
  observe
843
843
  .command("remove")
844
- .description("Remove observers from coding agents")
844
+ .description("Remove observers from AI agents")
845
845
  .option("--agent <id>", "Remove from specific agent")
846
846
  .option("--all", "Remove from all agents")
847
847
  .action(async (opts) => {
@@ -880,7 +880,7 @@ observe
880
880
  const status = getObservationStatus();
881
881
  if (!status.hasData) {
882
882
  console.log("\nNo observation data collected yet.");
883
- console.log("Use your coding agents normally — data accumulates automatically.\n");
883
+ console.log("Use your AI agents normally — data accumulates automatically.\n");
884
884
  process.exit(0);
885
885
  }
886
886
  console.log("\nšŸ“Š Observation Data Review\n");
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Never logs or stores API key values — only checks for existence.
9
9
  */
10
- export type ToolCategory = "coding-agent" | "chat" | "orchestration" | "api-provider";
10
+ export type ToolCategory = "ai-agent" | "chat" | "orchestration" | "api-provider";
11
11
  export interface DetectedTool {
12
12
  id: string;
13
13
  name: string;
@@ -13,11 +13,11 @@ import { homedir, platform } from "node:os";
13
13
  import { execSync } from "node:child_process";
14
14
  // ── Tool Definitions ───────────────────────────────────────────────────
15
15
  const TOOL_DEFINITIONS = [
16
- // AI Coding Agents
16
+ // AI AI Agents
17
17
  {
18
18
  id: "claude-code",
19
19
  name: "Claude Code",
20
- category: "coding-agent",
20
+ category: "ai-agent",
21
21
  binary: "claude",
22
22
  configDir: join(homedir(), ".claude"),
23
23
  observerAvailable: true,
@@ -25,7 +25,7 @@ const TOOL_DEFINITIONS = [
25
25
  {
26
26
  id: "cursor",
27
27
  name: "Cursor",
28
- category: "coding-agent",
28
+ category: "ai-agent",
29
29
  binary: "cursor",
30
30
  configDir: join(homedir(), ".cursor"),
31
31
  macApp: "/Applications/Cursor.app",
@@ -34,7 +34,7 @@ const TOOL_DEFINITIONS = [
34
34
  {
35
35
  id: "github-copilot",
36
36
  name: "GitHub Copilot",
37
- category: "coding-agent",
37
+ category: "ai-agent",
38
38
  configDir: join(homedir(), ".config", "github-copilot"),
39
39
  vscodeExtension: "github.copilot",
40
40
  cursorExtension: "github.copilot",
@@ -43,7 +43,7 @@ const TOOL_DEFINITIONS = [
43
43
  {
44
44
  id: "codex",
45
45
  name: "Codex CLI",
46
- category: "coding-agent",
46
+ category: "ai-agent",
47
47
  binary: "codex",
48
48
  configDir: join(homedir(), ".codex"),
49
49
  observerAvailable: true,
@@ -51,7 +51,7 @@ const TOOL_DEFINITIONS = [
51
51
  {
52
52
  id: "opencode",
53
53
  name: "OpenCode",
54
- category: "coding-agent",
54
+ category: "ai-agent",
55
55
  binary: "opencode",
56
56
  configDir: join(homedir(), ".config", "opencode"),
57
57
  observerAvailable: true,
@@ -59,7 +59,7 @@ const TOOL_DEFINITIONS = [
59
59
  {
60
60
  id: "aider",
61
61
  name: "Aider",
62
- category: "coding-agent",
62
+ category: "ai-agent",
63
63
  binary: "aider",
64
64
  configDir: join(homedir(), ".aider"),
65
65
  pipPackage: "aider-chat",
@@ -68,7 +68,7 @@ const TOOL_DEFINITIONS = [
68
68
  {
69
69
  id: "continue",
70
70
  name: "Continue",
71
- category: "coding-agent",
71
+ category: "ai-agent",
72
72
  vscodeExtension: "continue.continue",
73
73
  cursorExtension: "continue.continue",
74
74
  observerAvailable: false,
@@ -76,7 +76,7 @@ const TOOL_DEFINITIONS = [
76
76
  {
77
77
  id: "cline",
78
78
  name: "Cline",
79
- category: "coding-agent",
79
+ category: "ai-agent",
80
80
  vscodeExtension: "saoudrizwan.claude-dev",
81
81
  cursorExtension: "saoudrizwan.claude-dev",
82
82
  observerAvailable: false,
@@ -84,7 +84,7 @@ const TOOL_DEFINITIONS = [
84
84
  {
85
85
  id: "windsurf",
86
86
  name: "Windsurf",
87
- category: "coding-agent",
87
+ category: "ai-agent",
88
88
  binary: "windsurf",
89
89
  macApp: "/Applications/Windsurf.app",
90
90
  observerAvailable: false,
@@ -92,7 +92,7 @@ const TOOL_DEFINITIONS = [
92
92
  {
93
93
  id: "letta",
94
94
  name: "Letta Code",
95
- category: "coding-agent",
95
+ category: "ai-agent",
96
96
  binary: "letta",
97
97
  configDir: join(homedir(), ".letta"),
98
98
  pipPackage: "letta",
@@ -101,7 +101,7 @@ const TOOL_DEFINITIONS = [
101
101
  {
102
102
  id: "gemini",
103
103
  name: "Gemini CLI",
104
- category: "coding-agent",
104
+ category: "ai-agent",
105
105
  binary: "gemini",
106
106
  configDir: join(homedir(), ".gemini"),
107
107
  observerAvailable: true,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JobArbiter Observer — Hook installer for coding agent CLIs
2
+ * JobArbiter Observer — Hook installer for AI agent CLIs
3
3
  *
4
4
  * Installs observation hooks that extract proficiency signals from
5
5
  * session transcripts. Uses detect-tools.ts for agent detection.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JobArbiter Observer — Hook installer for coding agent CLIs
2
+ * JobArbiter Observer — Hook installer for AI agent CLIs
3
3
  *
4
4
  * Installs observation hooks that extract proficiency signals from
5
5
  * session transcripts. Uses detect-tools.ts for agent detection.
@@ -64,7 +64,7 @@ function ensureObserverDirs() {
64
64
  }
65
65
  // ── Core Observer Script ───────────────────────────────────────────────
66
66
  /**
67
- * The universal observer script. Runs as a hook in any coding agent.
67
+ * The universal observer script. Runs as a hook in any AI agent.
68
68
  * Reads session transcript data from stdin (JSON), extracts proficiency
69
69
  * signals, and appends them to the local observations file.
70
70
  *
@@ -75,7 +75,7 @@ function getObserverScript() {
75
75
  return `#!/usr/bin/env node
76
76
  /**
77
77
  * JobArbiter Observer Hook
78
- * Extracts proficiency signals from coding agent sessions.
78
+ * Extracts proficiency signals from AI agent sessions.
79
79
  *
80
80
  * Reads JSON from stdin, writes observations to:
81
81
  * ~/.config/jobarbiter/observer/observations.json
@@ -105,7 +105,7 @@ process.stdin.on("end", () => {
105
105
  const observation = extractSignals(data);
106
106
  if (observation) appendObservation(observation);
107
107
  } catch (err) {
108
- // Silent failure — never block the coding agent
108
+ // Silent failure — never block the AI agent
109
109
  fs.appendFileSync(
110
110
  path.join(os.homedir(), ".config", "jobarbiter", "observer", "errors.log"),
111
111
  \`[\${new Date().toISOString()}] \${err.message}\\n\`
@@ -12,6 +12,7 @@ import { loadConfig, saveConfig, getConfigPath } from "./config.js";
12
12
  import { apiUnauthenticated, api, ApiError } from "./api.js";
13
13
  import { installObservers } from "./observe.js";
14
14
  import { detectAllTools, formatToolDisplay, } from "./detect-tools.js";
15
+ import { loadProviderKeys, saveProviderKey, validateProviderKey, } from "./providers.js";
15
16
  // ── ANSI Colors ────────────────────────────────────────────────────────
16
17
  const colors = {
17
18
  reset: "\x1b[0m",
@@ -103,7 +104,7 @@ export async function runOnboardWizard(opts) {
103
104
  }
104
105
  // Onboarding incomplete — resume
105
106
  const resumeStep = (existingConfig.onboardingStep ?? 1) + 1;
106
- console.log(`\n${sym.rocket} ${c.bold("Resuming onboarding")} from step ${resumeStep}/6\n`);
107
+ console.log(`\n${sym.rocket} ${c.bold("Resuming onboarding")} from step ${resumeStep}/7\n`);
107
108
  console.log(c.dim(` Account: ${existingConfig.userType} | API key configured`));
108
109
  console.log(c.dim(` Run ${c.highlight("jobarbiter onboard --force")} to start over.\n`));
109
110
  const prompt = new Prompt();
@@ -199,9 +200,9 @@ async function selectUserType(prompt) {
199
200
  }
200
201
  // ── Email & Verification ───────────────────────────────────────────────
201
202
  async function handleEmailVerification(prompt, baseUrl, userType) {
202
- // Workers: 1) Account, 2) Tool Detection, 3) Domains, 4) GitHub, 5) LinkedIn, 6) Done
203
- // Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done
204
- const totalSteps = 6;
203
+ // Workers: 1) Account, 2) Tool Detection, 3) AI Accounts, 4) Domains, 5) GitHub, 6) LinkedIn, 7) Done
204
+ // Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done (stays at 6)
205
+ const totalSteps = userType === "employer" ? 6 : 7;
205
206
  console.log(`\n${sym.email} ${c.bold(`Step 1/${totalSteps} — Create Your Account`)}\n`);
206
207
  // Get email
207
208
  let email;
@@ -286,9 +287,14 @@ async function runWorkerFlow(prompt, state, startStep = 2) {
286
287
  state.tools = detectedToolsResult.tools;
287
288
  saveProgress(2);
288
289
  }
289
- // Step 3: Domains
290
+ // Step 3: Connect AI Accounts (optional)
290
291
  if (startStep <= 3) {
291
- console.log(`${sym.target} ${c.bold("Step 3/6 — Your Domains")}\n`);
292
+ await runConnectAIAccountsStep(prompt);
293
+ saveProgress(3);
294
+ }
295
+ // Step 4: Domains
296
+ if (startStep <= 4) {
297
+ console.log(`${sym.target} ${c.bold("Step 4/7 — Your Domains")}\n`);
292
298
  console.log(`What domains do you work in? ${c.dim("(comma-separated)")}`);
293
299
  console.log(c.dim("Examples: full-stack dev, data engineering, trading, content creation\n"));
294
300
  const domainsInput = await prompt.question(`${sym.arrow} `);
@@ -308,11 +314,11 @@ async function runWorkerFlow(prompt, state, startStep = 2) {
308
314
  catch (err) {
309
315
  console.log(`${sym.warning} ${c.warning("Could not save profile details — you can update later with 'jobarbiter profile create'")}\n`);
310
316
  }
311
- saveProgress(3);
317
+ saveProgress(4);
312
318
  }
313
- // Step 4: Connect GitHub (optional)
314
- if (startStep <= 4) {
315
- console.log(`${sym.link} ${c.bold("Step 4/6 — Connect GitHub")} ${c.dim("(optional)")}\n`);
319
+ // Step 5: Connect GitHub (optional)
320
+ if (startStep <= 5) {
321
+ console.log(`${sym.link} ${c.bold("Step 5/7 — Connect GitHub")} ${c.dim("(optional)")}\n`);
316
322
  console.log(`Connecting your GitHub lets us analyze your AI-assisted work patterns.`);
317
323
  console.log(`This significantly boosts your proficiency score.\n`);
318
324
  const githubUsername = await prompt.question(`GitHub username ${c.dim("(press Enter to skip)")}: `);
@@ -333,11 +339,11 @@ async function runWorkerFlow(prompt, state, startStep = 2) {
333
339
  else {
334
340
  console.log(`${c.dim("Skipped — you can connect later with 'jobarbiter git connect'")}\n`);
335
341
  }
336
- saveProgress(4);
342
+ saveProgress(5);
337
343
  }
338
- // Step 5: Connect LinkedIn (optional)
339
- if (startStep <= 5) {
340
- console.log(`${sym.link} ${c.bold("Step 5/6 — Connect LinkedIn")} ${c.dim("(optional)")}\n`);
344
+ // Step 6: Connect LinkedIn (optional)
345
+ if (startStep <= 6) {
346
+ console.log(`${sym.link} ${c.bold("Step 6/7 — Connect LinkedIn")} ${c.dim("(optional)")}\n`);
341
347
  console.log(`Your LinkedIn profile strengthens identity verification.`);
342
348
  console.log(c.dim("We never post on your behalf or access your connections.\n"));
343
349
  const linkedinUrl = await prompt.question(`LinkedIn URL ${c.dim("(press Enter to skip)")}: `);
@@ -356,21 +362,21 @@ async function runWorkerFlow(prompt, state, startStep = 2) {
356
362
  else {
357
363
  console.log(`${c.dim("Skipped — you can connect later with 'jobarbiter identity linkedin <url>'")}\n`);
358
364
  }
359
- saveProgress(5);
365
+ saveProgress(6);
360
366
  }
361
- // Step 6: Done!
362
- saveConfig({ ...config, onboardingComplete: true, onboardingStep: 6 });
367
+ // Step 7: Done!
368
+ saveConfig({ ...config, onboardingComplete: true, onboardingStep: 7 });
363
369
  showWorkerCompletion(state);
364
370
  }
365
371
  // ── Tool Detection Step ────────────────────────────────────────────────
366
372
  async function runToolDetectionStep(prompt, config) {
367
- console.log(`šŸ” ${c.bold("Step 2/6 — Detecting AI Tools")}\n`);
373
+ console.log(`šŸ” ${c.bold("Step 2/7 — Detecting AI Tools")}\n`);
368
374
  console.log(c.dim(" Scanning your machine...\n"));
369
375
  const allTools = detectAllTools();
370
376
  const installed = allTools.filter((t) => t.installed);
371
- const notInstalled = allTools.filter((t) => !t.installed && t.category === "coding-agent");
377
+ const notInstalled = allTools.filter((t) => !t.installed && t.category === "ai-agent");
372
378
  // Group by category
373
- const codingAgents = installed.filter((t) => t.category === "coding-agent");
379
+ const codingAgents = installed.filter((t) => t.category === "ai-agent");
374
380
  const chatTools = installed.filter((t) => t.category === "chat");
375
381
  const orchestration = installed.filter((t) => t.category === "orchestration");
376
382
  const apiProviders = installed.filter((t) => t.category === "api-provider");
@@ -381,7 +387,7 @@ async function runToolDetectionStep(prompt, config) {
381
387
  return { tools: [] };
382
388
  }
383
389
  console.log(` ${c.bold("Found:")}`);
384
- // Show coding agents with observer status
390
+ // Show AI agents with observer status
385
391
  for (const tool of codingAgents) {
386
392
  const display = formatToolDisplay(tool);
387
393
  if (tool.observerAvailable) {
@@ -406,7 +412,7 @@ async function runToolDetectionStep(prompt, config) {
406
412
  for (const tool of apiProviders) {
407
413
  console.log(` ${sym.check} ${tool.name} ${c.dim("configured")}`);
408
414
  }
409
- // Show not-detected coding agents
415
+ // Show not-detected AI agents
410
416
  if (notInstalled.length > 0) {
411
417
  console.log(`\n ${c.dim("Not detected (install to track):")}`);
412
418
  for (const tool of notInstalled.slice(0, 5)) {
@@ -418,11 +424,11 @@ async function runToolDetectionStep(prompt, config) {
418
424
  }
419
425
  // Collect tool names for profile
420
426
  const toolNames = installed.map((t) => t.name);
421
- // Observer installation for coding agents
427
+ // Observer installation for AI agents
422
428
  const needsObserver = codingAgents.filter((t) => t.observerAvailable && !t.observerActive);
423
429
  if (needsObserver.length > 0) {
424
430
  console.log(`\n ${c.bold("Observers")}`);
425
- console.log(` JobArbiter observes your coding sessions to build your`);
431
+ console.log(` JobArbiter observes your AI sessions to build your`);
426
432
  console.log(` proficiency profile. ${c.bold("No code or prompts leave your machine")} —`);
427
433
  console.log(` only aggregate scores (tool usage, session counts, token volume).\n`);
428
434
  console.log(c.dim(` Data stored locally: ~/.config/jobarbiter/observer/observations.json`));
@@ -473,8 +479,87 @@ async function runToolDetectionStep(prompt, config) {
473
479
  }
474
480
  return { tools: toolNames };
475
481
  }
482
+ // ── Connect AI Accounts Step ───────────────────────────────────────────
483
+ async function runConnectAIAccountsStep(prompt) {
484
+ console.log(`${sym.link} ${c.bold("Step 3/7 — Connect AI Accounts")} ${c.dim("(optional)")}\n`);
485
+ console.log(` Connecting your AI provider accounts lets us analyze usage depth,`);
486
+ console.log(` not just tool presence. The more we can see, the stronger your`);
487
+ console.log(` verified proficiency profile.\n`);
488
+ console.log(` ${c.bold("What we can pull:")}`);
489
+ console.log(` ${sym.bullet} Token usage volume and patterns ${c.dim("(how much you use AI)")}`);
490
+ console.log(` ${sym.bullet} Model preferences ${c.dim("(which models you reach for)")}`);
491
+ console.log(` ${sym.bullet} Session frequency and consistency over time`);
492
+ console.log(` ${sym.bullet} API spend patterns ${c.dim("(demonstrates serious usage)")}\n`);
493
+ console.log(` ${c.bold("What we NEVER access:")}`);
494
+ console.log(` ${sym.bullet} Your conversation content`);
495
+ console.log(` ${sym.bullet} Prompts or responses`);
496
+ console.log(` ${sym.bullet} Any proprietary or sensitive data\n`);
497
+ // Check for already connected providers
498
+ const existingProviders = loadProviderKeys();
499
+ if (existingProviders.length > 0) {
500
+ console.log(` ${c.bold("Already connected:")}`);
501
+ for (const p of existingProviders) {
502
+ console.log(` ${sym.check} ${p.provider}`);
503
+ }
504
+ console.log();
505
+ }
506
+ // Show available connections
507
+ let continueConnecting = true;
508
+ while (continueConnecting) {
509
+ console.log(` ${c.bold("Available connections:")}\n`);
510
+ console.log(` ${c.highlight("1.")} Anthropic API key — Pull Claude usage stats`);
511
+ console.log(` ${c.highlight("2.")} OpenAI API key — Pull GPT/ChatGPT usage stats`);
512
+ console.log(` ${c.highlight("3.")} Skip for now\n`);
513
+ console.log(c.dim(` You can connect accounts later with 'jobarbiter tokens connect'\n`));
514
+ const choice = await prompt.question(` Your choice ${c.dim("[1/2/3]")}: `);
515
+ if (choice === "3" || choice.toLowerCase() === "skip" || choice === "") {
516
+ console.log(`\n${c.dim(" Skipped — you can connect providers later with 'jobarbiter tokens connect'")}\n`);
517
+ continueConnecting = false;
518
+ }
519
+ else if (choice === "1") {
520
+ await connectProvider(prompt, "anthropic", "Anthropic");
521
+ // Ask if they want to connect another
522
+ continueConnecting = await prompt.confirm(`\n Connect another provider?`, false);
523
+ console.log();
524
+ }
525
+ else if (choice === "2") {
526
+ await connectProvider(prompt, "openai", "OpenAI");
527
+ // Ask if they want to connect another
528
+ continueConnecting = await prompt.confirm(`\n Connect another provider?`, false);
529
+ console.log();
530
+ }
531
+ else {
532
+ console.log(c.error(" Please enter 1, 2, or 3"));
533
+ }
534
+ }
535
+ }
536
+ async function connectProvider(prompt, providerId, providerName) {
537
+ console.log(`\n ${sym.lock} ${c.bold("Privacy Notice")}`);
538
+ console.log(` ${c.dim("─".repeat(50))}`);
539
+ console.log(` Your API key is stored ${c.bold("locally only")} at:`);
540
+ console.log(` ${c.dim("~/.config/jobarbiter/providers.json")}\n`);
541
+ console.log(` ${sym.bullet} Keys are ${c.bold("NEVER")} sent to JobArbiter's servers`);
542
+ console.log(` ${sym.bullet} Only aggregate stats (token counts, model usage) are submitted`);
543
+ console.log(` ${sym.bullet} Revoke anytime: ${c.highlight(`jobarbiter tokens disconnect ${providerId}`)}`);
544
+ console.log(` ${c.dim("─".repeat(50))}\n`);
545
+ const apiKey = await prompt.question(` ${providerName} API key: `);
546
+ if (!apiKey || apiKey.trim() === "") {
547
+ console.log(` ${c.dim("Skipped")}`);
548
+ return;
549
+ }
550
+ console.log(c.dim("\n Validating API key..."));
551
+ const result = await validateProviderKey(providerId, apiKey.trim());
552
+ if (result.valid) {
553
+ saveProviderKey(providerId, apiKey.trim());
554
+ console.log(` ${sym.check} ${c.success(`${providerName} connected`)} — ${result.summary}`);
555
+ }
556
+ else {
557
+ console.log(` ${sym.cross} ${c.error(`Invalid key: ${result.error}`)}`);
558
+ console.log(` ${c.dim("You can try again later with 'jobarbiter tokens connect'")}`);
559
+ }
560
+ }
476
561
  function showWorkerCompletion(state) {
477
- console.log(`${sym.done} ${c.bold("Step 6/6 — You're In!")}\n`);
562
+ console.log(`${sym.done} ${c.bold("Step 7/7 — You're In!")}\n`);
478
563
  console.log(`Your profile is live. Here's what happens next:\n`);
479
564
  console.log(` šŸ“Š Your proficiency score builds automatically from:`);
480
565
  console.log(` ${sym.bullet} Coding agent observation ${c.dim("(biggest factor — 35%)")}`);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Provider Key Management Module
3
+ *
4
+ * Securely manages API keys for AI providers (Anthropic, OpenAI, etc.)
5
+ * Keys are stored locally ONLY and never sent to JobArbiter's servers.
6
+ * Only aggregate usage stats are submitted for proficiency scoring.
7
+ */
8
+ export interface ProviderConfig {
9
+ provider: string;
10
+ apiKey: string;
11
+ connectedAt: string;
12
+ lastSync?: string;
13
+ }
14
+ export interface ProvidersFile {
15
+ version: number;
16
+ providers: ProviderConfig[];
17
+ }
18
+ export interface ValidationResult {
19
+ valid: boolean;
20
+ error?: string;
21
+ summary?: string;
22
+ }
23
+ export declare function getProvidersPath(): string;
24
+ export declare function loadProviderKeys(): ProviderConfig[];
25
+ export declare function saveProviderKey(provider: string, apiKey: string): void;
26
+ export declare function removeProviderKey(provider: string): boolean;
27
+ export declare function getProviderKey(provider: string): ProviderConfig | null;
28
+ /**
29
+ * Validate an Anthropic API key by making a test API call.
30
+ * Returns validation result with usage summary if available.
31
+ */
32
+ export declare function validateAnthropicKey(apiKey: string): Promise<ValidationResult>;
33
+ /**
34
+ * Validate an OpenAI API key by making a test API call.
35
+ * Returns validation result with usage summary if available.
36
+ */
37
+ export declare function validateOpenAIKey(apiKey: string): Promise<ValidationResult>;
38
+ /**
39
+ * Get list of supported providers.
40
+ */
41
+ export declare function getSupportedProviders(): Array<{
42
+ id: string;
43
+ name: string;
44
+ }>;
45
+ /**
46
+ * Validate a key for any supported provider.
47
+ */
48
+ export declare function validateProviderKey(provider: string, apiKey: string): Promise<ValidationResult>;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Provider Key Management Module
3
+ *
4
+ * Securely manages API keys for AI providers (Anthropic, OpenAI, etc.)
5
+ * Keys are stored locally ONLY and never sent to JobArbiter's servers.
6
+ * Only aggregate usage stats are submitted for proficiency scoring.
7
+ */
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ // ── Paths ──────────────────────────────────────────────────────────────
12
+ const CONFIG_DIR = join(homedir(), ".config", "jobarbiter");
13
+ const PROVIDERS_FILE = join(CONFIG_DIR, "providers.json");
14
+ export function getProvidersPath() {
15
+ return PROVIDERS_FILE;
16
+ }
17
+ // ── Load / Save ────────────────────────────────────────────────────────
18
+ export function loadProviderKeys() {
19
+ if (!existsSync(PROVIDERS_FILE)) {
20
+ return [];
21
+ }
22
+ try {
23
+ const raw = readFileSync(PROVIDERS_FILE, "utf-8");
24
+ const data = JSON.parse(raw);
25
+ return data.providers || [];
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ }
31
+ export function saveProviderKey(provider, apiKey) {
32
+ mkdirSync(CONFIG_DIR, { recursive: true });
33
+ const existing = loadProviderKeys();
34
+ // Remove any existing entry for this provider
35
+ const filtered = existing.filter((p) => p.provider !== provider);
36
+ // Add new entry
37
+ filtered.push({
38
+ provider,
39
+ apiKey,
40
+ connectedAt: new Date().toISOString(),
41
+ });
42
+ const data = {
43
+ version: 1,
44
+ providers: filtered,
45
+ };
46
+ writeFileSync(PROVIDERS_FILE, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
47
+ }
48
+ export function removeProviderKey(provider) {
49
+ const existing = loadProviderKeys();
50
+ const filtered = existing.filter((p) => p.provider !== provider);
51
+ if (filtered.length === existing.length) {
52
+ return false; // Provider not found
53
+ }
54
+ const data = {
55
+ version: 1,
56
+ providers: filtered,
57
+ };
58
+ writeFileSync(PROVIDERS_FILE, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
59
+ return true;
60
+ }
61
+ export function getProviderKey(provider) {
62
+ const providers = loadProviderKeys();
63
+ return providers.find((p) => p.provider === provider) || null;
64
+ }
65
+ // ── Validation ─────────────────────────────────────────────────────────
66
+ /**
67
+ * Validate an Anthropic API key by making a test API call.
68
+ * Returns validation result with usage summary if available.
69
+ */
70
+ export async function validateAnthropicKey(apiKey) {
71
+ try {
72
+ const response = await fetch("https://api.anthropic.com/v1/models", {
73
+ method: "GET",
74
+ headers: {
75
+ "x-api-key": apiKey,
76
+ "anthropic-version": "2023-06-01",
77
+ },
78
+ });
79
+ if (response.status === 401) {
80
+ return { valid: false, error: "Invalid API key" };
81
+ }
82
+ if (response.status === 403) {
83
+ return { valid: false, error: "API key doesn't have required permissions" };
84
+ }
85
+ if (!response.ok) {
86
+ return { valid: false, error: `API error: ${response.status}` };
87
+ }
88
+ // Key is valid - we can't get usage from this endpoint,
89
+ // but we can confirm the key works
90
+ return {
91
+ valid: true,
92
+ summary: "API key validated",
93
+ };
94
+ }
95
+ catch (err) {
96
+ if (err instanceof Error) {
97
+ return { valid: false, error: `Connection error: ${err.message}` };
98
+ }
99
+ return { valid: false, error: "Unknown error" };
100
+ }
101
+ }
102
+ /**
103
+ * Validate an OpenAI API key by making a test API call.
104
+ * Returns validation result with usage summary if available.
105
+ */
106
+ export async function validateOpenAIKey(apiKey) {
107
+ try {
108
+ const response = await fetch("https://api.openai.com/v1/models", {
109
+ method: "GET",
110
+ headers: {
111
+ "Authorization": `Bearer ${apiKey}`,
112
+ },
113
+ });
114
+ if (response.status === 401) {
115
+ return { valid: false, error: "Invalid API key" };
116
+ }
117
+ if (response.status === 403) {
118
+ return { valid: false, error: "API key doesn't have required permissions" };
119
+ }
120
+ if (!response.ok) {
121
+ return { valid: false, error: `API error: ${response.status}` };
122
+ }
123
+ // Key is valid
124
+ return {
125
+ valid: true,
126
+ summary: "API key validated",
127
+ };
128
+ }
129
+ catch (err) {
130
+ if (err instanceof Error) {
131
+ return { valid: false, error: `Connection error: ${err.message}` };
132
+ }
133
+ return { valid: false, error: "Unknown error" };
134
+ }
135
+ }
136
+ /**
137
+ * Get list of supported providers.
138
+ */
139
+ export function getSupportedProviders() {
140
+ return [
141
+ { id: "anthropic", name: "Anthropic" },
142
+ { id: "openai", name: "OpenAI" },
143
+ ];
144
+ }
145
+ /**
146
+ * Validate a key for any supported provider.
147
+ */
148
+ export async function validateProviderKey(provider, apiKey) {
149
+ switch (provider) {
150
+ case "anthropic":
151
+ return validateAnthropicKey(apiKey);
152
+ case "openai":
153
+ return validateOpenAIKey(apiKey);
154
+ default:
155
+ return { valid: false, error: `Unknown provider: ${provider}` };
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobarbiter",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "CLI for JobArbiter — the first AI Proficiency Marketplace",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -781,10 +781,10 @@ program
781
781
  });
782
782
 
783
783
  // ============================================================
784
- // observe (manage coding agent observers)
784
+ // observe (manage AI agent observers)
785
785
  // ============================================================
786
786
 
787
- const observe = program.command("observe").description("Manage coding agent proficiency observers");
787
+ const observe = program.command("observe").description("Manage AI tool proficiency observers");
788
788
 
789
789
  observe
790
790
  .command("status")
@@ -796,7 +796,7 @@ observe
796
796
 
797
797
  const detected = agents.filter((a) => a.installed);
798
798
 
799
- console.log("\nšŸ” Coding Agent Observers\n");
799
+ console.log("\nšŸ” AI Agent Observers\n");
800
800
  console.log(" Agents:");
801
801
  for (const agent of agents) {
802
802
  if (!agent.installed) {
@@ -839,7 +839,7 @@ observe
839
839
 
840
840
  observe
841
841
  .command("install")
842
- .description("Install observers for detected coding agents")
842
+ .description("Install observers for detected AI agents")
843
843
  .option("--agent <id>", "Install for specific agent (claude-code, cursor, opencode, codex, gemini)")
844
844
  .option("--all", "Install for all detected agents")
845
845
  .action(async (opts) => {
@@ -848,7 +848,7 @@ observe
848
848
  const detected = agents.filter((a) => a.installed);
849
849
 
850
850
  if (detected.length === 0) {
851
- error("No coding agents detected on this system.");
851
+ error("No AI agents detected on this system.");
852
852
  console.log(" Supported: Claude Code, Cursor, OpenCode, Codex CLI, Gemini CLI");
853
853
  process.exit(1);
854
854
  }
@@ -890,7 +890,7 @@ observe
890
890
 
891
891
  observe
892
892
  .command("remove")
893
- .description("Remove observers from coding agents")
893
+ .description("Remove observers from AI agents")
894
894
  .option("--agent <id>", "Remove from specific agent")
895
895
  .option("--all", "Remove from all agents")
896
896
  .action(async (opts) => {
@@ -935,7 +935,7 @@ observe
935
935
 
936
936
  if (!status.hasData) {
937
937
  console.log("\nNo observation data collected yet.");
938
- console.log("Use your coding agents normally — data accumulates automatically.\n");
938
+ console.log("Use your AI agents normally — data accumulates automatically.\n");
939
939
  process.exit(0);
940
940
  }
941
941
 
package/src/lib/config.ts CHANGED
@@ -7,7 +7,7 @@ export interface Config {
7
7
  baseUrl: string;
8
8
  userType: "worker" | "employer" | "seeker" | "poster";
9
9
  onboardingComplete?: boolean;
10
- onboardingStep?: number; // last completed step (1-6)
10
+ onboardingStep?: number; // last completed step (1-7)
11
11
  }
12
12
 
13
13
  const CONFIG_DIR = join(homedir(), ".config", "jobarbiter");
@@ -15,7 +15,7 @@ import { execSync } from "node:child_process";
15
15
 
16
16
  // ── Types ──────────────────────────────────────────────────────────────
17
17
 
18
- export type ToolCategory = "coding-agent" | "chat" | "orchestration" | "api-provider";
18
+ export type ToolCategory = "ai-agent" | "chat" | "orchestration" | "api-provider";
19
19
 
20
20
  export interface DetectedTool {
21
21
  id: string;
@@ -46,11 +46,11 @@ interface ToolDefinition {
46
46
  // ── Tool Definitions ───────────────────────────────────────────────────
47
47
 
48
48
  const TOOL_DEFINITIONS: ToolDefinition[] = [
49
- // AI Coding Agents
49
+ // AI AI Agents
50
50
  {
51
51
  id: "claude-code",
52
52
  name: "Claude Code",
53
- category: "coding-agent",
53
+ category: "ai-agent",
54
54
  binary: "claude",
55
55
  configDir: join(homedir(), ".claude"),
56
56
  observerAvailable: true,
@@ -58,7 +58,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
58
58
  {
59
59
  id: "cursor",
60
60
  name: "Cursor",
61
- category: "coding-agent",
61
+ category: "ai-agent",
62
62
  binary: "cursor",
63
63
  configDir: join(homedir(), ".cursor"),
64
64
  macApp: "/Applications/Cursor.app",
@@ -67,7 +67,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
67
67
  {
68
68
  id: "github-copilot",
69
69
  name: "GitHub Copilot",
70
- category: "coding-agent",
70
+ category: "ai-agent",
71
71
  configDir: join(homedir(), ".config", "github-copilot"),
72
72
  vscodeExtension: "github.copilot",
73
73
  cursorExtension: "github.copilot",
@@ -76,7 +76,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
76
76
  {
77
77
  id: "codex",
78
78
  name: "Codex CLI",
79
- category: "coding-agent",
79
+ category: "ai-agent",
80
80
  binary: "codex",
81
81
  configDir: join(homedir(), ".codex"),
82
82
  observerAvailable: true,
@@ -84,7 +84,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
84
84
  {
85
85
  id: "opencode",
86
86
  name: "OpenCode",
87
- category: "coding-agent",
87
+ category: "ai-agent",
88
88
  binary: "opencode",
89
89
  configDir: join(homedir(), ".config", "opencode"),
90
90
  observerAvailable: true,
@@ -92,7 +92,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
92
92
  {
93
93
  id: "aider",
94
94
  name: "Aider",
95
- category: "coding-agent",
95
+ category: "ai-agent",
96
96
  binary: "aider",
97
97
  configDir: join(homedir(), ".aider"),
98
98
  pipPackage: "aider-chat",
@@ -101,7 +101,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
101
101
  {
102
102
  id: "continue",
103
103
  name: "Continue",
104
- category: "coding-agent",
104
+ category: "ai-agent",
105
105
  vscodeExtension: "continue.continue",
106
106
  cursorExtension: "continue.continue",
107
107
  observerAvailable: false,
@@ -109,7 +109,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
109
109
  {
110
110
  id: "cline",
111
111
  name: "Cline",
112
- category: "coding-agent",
112
+ category: "ai-agent",
113
113
  vscodeExtension: "saoudrizwan.claude-dev",
114
114
  cursorExtension: "saoudrizwan.claude-dev",
115
115
  observerAvailable: false,
@@ -117,7 +117,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
117
117
  {
118
118
  id: "windsurf",
119
119
  name: "Windsurf",
120
- category: "coding-agent",
120
+ category: "ai-agent",
121
121
  binary: "windsurf",
122
122
  macApp: "/Applications/Windsurf.app",
123
123
  observerAvailable: false,
@@ -125,7 +125,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
125
125
  {
126
126
  id: "letta",
127
127
  name: "Letta Code",
128
- category: "coding-agent",
128
+ category: "ai-agent",
129
129
  binary: "letta",
130
130
  configDir: join(homedir(), ".letta"),
131
131
  pipPackage: "letta",
@@ -134,7 +134,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
134
134
  {
135
135
  id: "gemini",
136
136
  name: "Gemini CLI",
137
- category: "coding-agent",
137
+ category: "ai-agent",
138
138
  binary: "gemini",
139
139
  configDir: join(homedir(), ".gemini"),
140
140
  observerAvailable: true,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JobArbiter Observer — Hook installer for coding agent CLIs
2
+ * JobArbiter Observer — Hook installer for AI agent CLIs
3
3
  *
4
4
  * Installs observation hooks that extract proficiency signals from
5
5
  * session transcripts. Uses detect-tools.ts for agent detection.
@@ -98,7 +98,7 @@ function ensureObserverDirs(): void {
98
98
  // ── Core Observer Script ───────────────────────────────────────────────
99
99
 
100
100
  /**
101
- * The universal observer script. Runs as a hook in any coding agent.
101
+ * The universal observer script. Runs as a hook in any AI agent.
102
102
  * Reads session transcript data from stdin (JSON), extracts proficiency
103
103
  * signals, and appends them to the local observations file.
104
104
  *
@@ -109,7 +109,7 @@ function getObserverScript(): string {
109
109
  return `#!/usr/bin/env node
110
110
  /**
111
111
  * JobArbiter Observer Hook
112
- * Extracts proficiency signals from coding agent sessions.
112
+ * Extracts proficiency signals from AI agent sessions.
113
113
  *
114
114
  * Reads JSON from stdin, writes observations to:
115
115
  * ~/.config/jobarbiter/observer/observations.json
@@ -139,7 +139,7 @@ process.stdin.on("end", () => {
139
139
  const observation = extractSignals(data);
140
140
  if (observation) appendObservation(observation);
141
141
  } catch (err) {
142
- // Silent failure — never block the coding agent
142
+ // Silent failure — never block the AI agent
143
143
  fs.appendFileSync(
144
144
  path.join(os.homedir(), ".config", "jobarbiter", "observer", "errors.log"),
145
145
  \`[\${new Date().toISOString()}] \${err.message}\\n\`
@@ -20,6 +20,12 @@ import {
20
20
  type DetectedTool,
21
21
  type ToolCategory,
22
22
  } from "./detect-tools.js";
23
+ import {
24
+ loadProviderKeys,
25
+ saveProviderKey,
26
+ validateProviderKey,
27
+ getSupportedProviders,
28
+ } from "./providers.js";
23
29
 
24
30
  // ── ANSI Colors ────────────────────────────────────────────────────────
25
31
 
@@ -150,7 +156,7 @@ export async function runOnboardWizard(opts: { force?: boolean; baseUrl?: string
150
156
  }
151
157
  // Onboarding incomplete — resume
152
158
  const resumeStep = (existingConfig.onboardingStep ?? 1) + 1;
153
- console.log(`\n${sym.rocket} ${c.bold("Resuming onboarding")} from step ${resumeStep}/6\n`);
159
+ console.log(`\n${sym.rocket} ${c.bold("Resuming onboarding")} from step ${resumeStep}/7\n`);
154
160
  console.log(c.dim(` Account: ${existingConfig.userType} | API key configured`));
155
161
  console.log(c.dim(` Run ${c.highlight("jobarbiter onboard --force")} to start over.\n`));
156
162
 
@@ -257,9 +263,9 @@ async function handleEmailVerification(
257
263
  baseUrl: string,
258
264
  userType: "worker" | "employer"
259
265
  ): Promise<{ email: string; apiKey: string; userId: string }> {
260
- // Workers: 1) Account, 2) Tool Detection, 3) Domains, 4) GitHub, 5) LinkedIn, 6) Done
261
- // Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done
262
- const totalSteps = 6;
266
+ // Workers: 1) Account, 2) Tool Detection, 3) AI Accounts, 4) Domains, 5) GitHub, 6) LinkedIn, 7) Done
267
+ // Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done (stays at 6)
268
+ const totalSteps = userType === "employer" ? 6 : 7;
263
269
 
264
270
  console.log(`\n${sym.email} ${c.bold(`Step 1/${totalSteps} — Create Your Account`)}\n`);
265
271
 
@@ -357,9 +363,15 @@ async function runWorkerFlow(prompt: Prompt, state: OnboardState, startStep = 2)
357
363
  saveProgress(2);
358
364
  }
359
365
 
360
- // Step 3: Domains
366
+ // Step 3: Connect AI Accounts (optional)
361
367
  if (startStep <= 3) {
362
- console.log(`${sym.target} ${c.bold("Step 3/6 — Your Domains")}\n`);
368
+ await runConnectAIAccountsStep(prompt);
369
+ saveProgress(3);
370
+ }
371
+
372
+ // Step 4: Domains
373
+ if (startStep <= 4) {
374
+ console.log(`${sym.target} ${c.bold("Step 4/7 — Your Domains")}\n`);
363
375
  console.log(`What domains do you work in? ${c.dim("(comma-separated)")}`);
364
376
  console.log(c.dim("Examples: full-stack dev, data engineering, trading, content creation\n"));
365
377
  const domainsInput = await prompt.question(`${sym.arrow} `);
@@ -380,12 +392,12 @@ async function runWorkerFlow(prompt: Prompt, state: OnboardState, startStep = 2)
380
392
  } catch (err) {
381
393
  console.log(`${sym.warning} ${c.warning("Could not save profile details — you can update later with 'jobarbiter profile create'")}\n`);
382
394
  }
383
- saveProgress(3);
395
+ saveProgress(4);
384
396
  }
385
397
 
386
- // Step 4: Connect GitHub (optional)
387
- if (startStep <= 4) {
388
- console.log(`${sym.link} ${c.bold("Step 4/6 — Connect GitHub")} ${c.dim("(optional)")}\n`);
398
+ // Step 5: Connect GitHub (optional)
399
+ if (startStep <= 5) {
400
+ console.log(`${sym.link} ${c.bold("Step 5/7 — Connect GitHub")} ${c.dim("(optional)")}\n`);
389
401
  console.log(`Connecting your GitHub lets us analyze your AI-assisted work patterns.`);
390
402
  console.log(`This significantly boosts your proficiency score.\n`);
391
403
 
@@ -406,12 +418,12 @@ async function runWorkerFlow(prompt: Prompt, state: OnboardState, startStep = 2)
406
418
  } else {
407
419
  console.log(`${c.dim("Skipped — you can connect later with 'jobarbiter git connect'")}\n`);
408
420
  }
409
- saveProgress(4);
421
+ saveProgress(5);
410
422
  }
411
423
 
412
- // Step 5: Connect LinkedIn (optional)
413
- if (startStep <= 5) {
414
- console.log(`${sym.link} ${c.bold("Step 5/6 — Connect LinkedIn")} ${c.dim("(optional)")}\n`);
424
+ // Step 6: Connect LinkedIn (optional)
425
+ if (startStep <= 6) {
426
+ console.log(`${sym.link} ${c.bold("Step 6/7 — Connect LinkedIn")} ${c.dim("(optional)")}\n`);
415
427
  console.log(`Your LinkedIn profile strengthens identity verification.`);
416
428
  console.log(c.dim("We never post on your behalf or access your connections.\n"));
417
429
 
@@ -430,11 +442,11 @@ async function runWorkerFlow(prompt: Prompt, state: OnboardState, startStep = 2)
430
442
  } else {
431
443
  console.log(`${c.dim("Skipped — you can connect later with 'jobarbiter identity linkedin <url>'")}\n`);
432
444
  }
433
- saveProgress(5);
445
+ saveProgress(6);
434
446
  }
435
447
 
436
- // Step 6: Done!
437
- saveConfig({ ...config, onboardingComplete: true, onboardingStep: 6 });
448
+ // Step 7: Done!
449
+ saveConfig({ ...config, onboardingComplete: true, onboardingStep: 7 });
438
450
  showWorkerCompletion(state);
439
451
  }
440
452
 
@@ -444,15 +456,15 @@ async function runToolDetectionStep(
444
456
  prompt: Prompt,
445
457
  config: Config,
446
458
  ): Promise<{ tools: string[] }> {
447
- console.log(`šŸ” ${c.bold("Step 2/6 — Detecting AI Tools")}\n`);
459
+ console.log(`šŸ” ${c.bold("Step 2/7 — Detecting AI Tools")}\n`);
448
460
  console.log(c.dim(" Scanning your machine...\n"));
449
461
 
450
462
  const allTools = detectAllTools();
451
463
  const installed = allTools.filter((t) => t.installed);
452
- const notInstalled = allTools.filter((t) => !t.installed && t.category === "coding-agent");
464
+ const notInstalled = allTools.filter((t) => !t.installed && t.category === "ai-agent");
453
465
 
454
466
  // Group by category
455
- const codingAgents = installed.filter((t) => t.category === "coding-agent");
467
+ const codingAgents = installed.filter((t) => t.category === "ai-agent");
456
468
  const chatTools = installed.filter((t) => t.category === "chat");
457
469
  const orchestration = installed.filter((t) => t.category === "orchestration");
458
470
  const apiProviders = installed.filter((t) => t.category === "api-provider");
@@ -466,7 +478,7 @@ async function runToolDetectionStep(
466
478
 
467
479
  console.log(` ${c.bold("Found:")}`);
468
480
 
469
- // Show coding agents with observer status
481
+ // Show AI agents with observer status
470
482
  for (const tool of codingAgents) {
471
483
  const display = formatToolDisplay(tool);
472
484
  if (tool.observerAvailable) {
@@ -491,7 +503,7 @@ async function runToolDetectionStep(
491
503
  console.log(` ${sym.check} ${tool.name} ${c.dim("configured")}`);
492
504
  }
493
505
 
494
- // Show not-detected coding agents
506
+ // Show not-detected AI agents
495
507
  if (notInstalled.length > 0) {
496
508
  console.log(`\n ${c.dim("Not detected (install to track):")}`);
497
509
  for (const tool of notInstalled.slice(0, 5)) {
@@ -505,12 +517,12 @@ async function runToolDetectionStep(
505
517
  // Collect tool names for profile
506
518
  const toolNames = installed.map((t) => t.name);
507
519
 
508
- // Observer installation for coding agents
520
+ // Observer installation for AI agents
509
521
  const needsObserver = codingAgents.filter((t) => t.observerAvailable && !t.observerActive);
510
522
 
511
523
  if (needsObserver.length > 0) {
512
524
  console.log(`\n ${c.bold("Observers")}`);
513
- console.log(` JobArbiter observes your coding sessions to build your`);
525
+ console.log(` JobArbiter observes your AI sessions to build your`);
514
526
  console.log(` proficiency profile. ${c.bold("No code or prompts leave your machine")} —`);
515
527
  console.log(` only aggregate scores (tool usage, session counts, token volume).\n`);
516
528
  console.log(c.dim(` Data stored locally: ~/.config/jobarbiter/observer/observations.json`));
@@ -567,8 +579,99 @@ async function runToolDetectionStep(
567
579
  return { tools: toolNames };
568
580
  }
569
581
 
582
+ // ── Connect AI Accounts Step ───────────────────────────────────────────
583
+
584
+ async function runConnectAIAccountsStep(prompt: Prompt): Promise<void> {
585
+ console.log(`${sym.link} ${c.bold("Step 3/7 — Connect AI Accounts")} ${c.dim("(optional)")}\n`);
586
+
587
+ console.log(` Connecting your AI provider accounts lets us analyze usage depth,`);
588
+ console.log(` not just tool presence. The more we can see, the stronger your`);
589
+ console.log(` verified proficiency profile.\n`);
590
+
591
+ console.log(` ${c.bold("What we can pull:")}`);
592
+ console.log(` ${sym.bullet} Token usage volume and patterns ${c.dim("(how much you use AI)")}`);
593
+ console.log(` ${sym.bullet} Model preferences ${c.dim("(which models you reach for)")}`);
594
+ console.log(` ${sym.bullet} Session frequency and consistency over time`);
595
+ console.log(` ${sym.bullet} API spend patterns ${c.dim("(demonstrates serious usage)")}\n`);
596
+
597
+ console.log(` ${c.bold("What we NEVER access:")}`);
598
+ console.log(` ${sym.bullet} Your conversation content`);
599
+ console.log(` ${sym.bullet} Prompts or responses`);
600
+ console.log(` ${sym.bullet} Any proprietary or sensitive data\n`);
601
+
602
+ // Check for already connected providers
603
+ const existingProviders = loadProviderKeys();
604
+ if (existingProviders.length > 0) {
605
+ console.log(` ${c.bold("Already connected:")}`);
606
+ for (const p of existingProviders) {
607
+ console.log(` ${sym.check} ${p.provider}`);
608
+ }
609
+ console.log();
610
+ }
611
+
612
+ // Show available connections
613
+ let continueConnecting = true;
614
+
615
+ while (continueConnecting) {
616
+ console.log(` ${c.bold("Available connections:")}\n`);
617
+ console.log(` ${c.highlight("1.")} Anthropic API key — Pull Claude usage stats`);
618
+ console.log(` ${c.highlight("2.")} OpenAI API key — Pull GPT/ChatGPT usage stats`);
619
+ console.log(` ${c.highlight("3.")} Skip for now\n`);
620
+ console.log(c.dim(` You can connect accounts later with 'jobarbiter tokens connect'\n`));
621
+
622
+ const choice = await prompt.question(` Your choice ${c.dim("[1/2/3]")}: `);
623
+
624
+ if (choice === "3" || choice.toLowerCase() === "skip" || choice === "") {
625
+ console.log(`\n${c.dim(" Skipped — you can connect providers later with 'jobarbiter tokens connect'")}\n`);
626
+ continueConnecting = false;
627
+ } else if (choice === "1") {
628
+ await connectProvider(prompt, "anthropic", "Anthropic");
629
+ // Ask if they want to connect another
630
+ continueConnecting = await prompt.confirm(`\n Connect another provider?`, false);
631
+ console.log();
632
+ } else if (choice === "2") {
633
+ await connectProvider(prompt, "openai", "OpenAI");
634
+ // Ask if they want to connect another
635
+ continueConnecting = await prompt.confirm(`\n Connect another provider?`, false);
636
+ console.log();
637
+ } else {
638
+ console.log(c.error(" Please enter 1, 2, or 3"));
639
+ }
640
+ }
641
+ }
642
+
643
+ async function connectProvider(prompt: Prompt, providerId: string, providerName: string): Promise<void> {
644
+ console.log(`\n ${sym.lock} ${c.bold("Privacy Notice")}`);
645
+ console.log(` ${c.dim("─".repeat(50))}`);
646
+ console.log(` Your API key is stored ${c.bold("locally only")} at:`);
647
+ console.log(` ${c.dim("~/.config/jobarbiter/providers.json")}\n`);
648
+ console.log(` ${sym.bullet} Keys are ${c.bold("NEVER")} sent to JobArbiter's servers`);
649
+ console.log(` ${sym.bullet} Only aggregate stats (token counts, model usage) are submitted`);
650
+ console.log(` ${sym.bullet} Revoke anytime: ${c.highlight(`jobarbiter tokens disconnect ${providerId}`)}`);
651
+ console.log(` ${c.dim("─".repeat(50))}\n`);
652
+
653
+ const apiKey = await prompt.question(` ${providerName} API key: `);
654
+
655
+ if (!apiKey || apiKey.trim() === "") {
656
+ console.log(` ${c.dim("Skipped")}`);
657
+ return;
658
+ }
659
+
660
+ console.log(c.dim("\n Validating API key..."));
661
+
662
+ const result = await validateProviderKey(providerId, apiKey.trim());
663
+
664
+ if (result.valid) {
665
+ saveProviderKey(providerId, apiKey.trim());
666
+ console.log(` ${sym.check} ${c.success(`${providerName} connected`)} — ${result.summary}`);
667
+ } else {
668
+ console.log(` ${sym.cross} ${c.error(`Invalid key: ${result.error}`)}`);
669
+ console.log(` ${c.dim("You can try again later with 'jobarbiter tokens connect'")}`);
670
+ }
671
+ }
672
+
570
673
  function showWorkerCompletion(state: OnboardState): void {
571
- console.log(`${sym.done} ${c.bold("Step 6/6 — You're In!")}\n`);
674
+ console.log(`${sym.done} ${c.bold("Step 7/7 — You're In!")}\n`);
572
675
  console.log(`Your profile is live. Here's what happens next:\n`);
573
676
 
574
677
  console.log(` šŸ“Š Your proficiency score builds automatically from:`);
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Provider Key Management Module
3
+ *
4
+ * Securely manages API keys for AI providers (Anthropic, OpenAI, etc.)
5
+ * Keys are stored locally ONLY and never sent to JobArbiter's servers.
6
+ * Only aggregate usage stats are submitted for proficiency scoring.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ export interface ProviderConfig {
16
+ provider: string; // "anthropic" | "openai" | "google"
17
+ apiKey: string; // stored locally only
18
+ connectedAt: string; // ISO timestamp
19
+ lastSync?: string; // last time usage data was pulled
20
+ }
21
+
22
+ export interface ProvidersFile {
23
+ version: number;
24
+ providers: ProviderConfig[];
25
+ }
26
+
27
+ export interface ValidationResult {
28
+ valid: boolean;
29
+ error?: string;
30
+ summary?: string; // e.g., "1.2M tokens used this month"
31
+ }
32
+
33
+ // ── Paths ──────────────────────────────────────────────────────────────
34
+
35
+ const CONFIG_DIR = join(homedir(), ".config", "jobarbiter");
36
+ const PROVIDERS_FILE = join(CONFIG_DIR, "providers.json");
37
+
38
+ export function getProvidersPath(): string {
39
+ return PROVIDERS_FILE;
40
+ }
41
+
42
+ // ── Load / Save ────────────────────────────────────────────────────────
43
+
44
+ export function loadProviderKeys(): ProviderConfig[] {
45
+ if (!existsSync(PROVIDERS_FILE)) {
46
+ return [];
47
+ }
48
+
49
+ try {
50
+ const raw = readFileSync(PROVIDERS_FILE, "utf-8");
51
+ const data = JSON.parse(raw) as ProvidersFile;
52
+ return data.providers || [];
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export function saveProviderKey(provider: string, apiKey: string): void {
59
+ mkdirSync(CONFIG_DIR, { recursive: true });
60
+
61
+ const existing = loadProviderKeys();
62
+
63
+ // Remove any existing entry for this provider
64
+ const filtered = existing.filter((p) => p.provider !== provider);
65
+
66
+ // Add new entry
67
+ filtered.push({
68
+ provider,
69
+ apiKey,
70
+ connectedAt: new Date().toISOString(),
71
+ });
72
+
73
+ const data: ProvidersFile = {
74
+ version: 1,
75
+ providers: filtered,
76
+ };
77
+
78
+ writeFileSync(PROVIDERS_FILE, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
79
+ }
80
+
81
+ export function removeProviderKey(provider: string): boolean {
82
+ const existing = loadProviderKeys();
83
+ const filtered = existing.filter((p) => p.provider !== provider);
84
+
85
+ if (filtered.length === existing.length) {
86
+ return false; // Provider not found
87
+ }
88
+
89
+ const data: ProvidersFile = {
90
+ version: 1,
91
+ providers: filtered,
92
+ };
93
+
94
+ writeFileSync(PROVIDERS_FILE, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
95
+ return true;
96
+ }
97
+
98
+ export function getProviderKey(provider: string): ProviderConfig | null {
99
+ const providers = loadProviderKeys();
100
+ return providers.find((p) => p.provider === provider) || null;
101
+ }
102
+
103
+ // ── Validation ─────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Validate an Anthropic API key by making a test API call.
107
+ * Returns validation result with usage summary if available.
108
+ */
109
+ export async function validateAnthropicKey(apiKey: string): Promise<ValidationResult> {
110
+ try {
111
+ const response = await fetch("https://api.anthropic.com/v1/models", {
112
+ method: "GET",
113
+ headers: {
114
+ "x-api-key": apiKey,
115
+ "anthropic-version": "2023-06-01",
116
+ },
117
+ });
118
+
119
+ if (response.status === 401) {
120
+ return { valid: false, error: "Invalid API key" };
121
+ }
122
+
123
+ if (response.status === 403) {
124
+ return { valid: false, error: "API key doesn't have required permissions" };
125
+ }
126
+
127
+ if (!response.ok) {
128
+ return { valid: false, error: `API error: ${response.status}` };
129
+ }
130
+
131
+ // Key is valid - we can't get usage from this endpoint,
132
+ // but we can confirm the key works
133
+ return {
134
+ valid: true,
135
+ summary: "API key validated",
136
+ };
137
+ } catch (err) {
138
+ if (err instanceof Error) {
139
+ return { valid: false, error: `Connection error: ${err.message}` };
140
+ }
141
+ return { valid: false, error: "Unknown error" };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Validate an OpenAI API key by making a test API call.
147
+ * Returns validation result with usage summary if available.
148
+ */
149
+ export async function validateOpenAIKey(apiKey: string): Promise<ValidationResult> {
150
+ try {
151
+ const response = await fetch("https://api.openai.com/v1/models", {
152
+ method: "GET",
153
+ headers: {
154
+ "Authorization": `Bearer ${apiKey}`,
155
+ },
156
+ });
157
+
158
+ if (response.status === 401) {
159
+ return { valid: false, error: "Invalid API key" };
160
+ }
161
+
162
+ if (response.status === 403) {
163
+ return { valid: false, error: "API key doesn't have required permissions" };
164
+ }
165
+
166
+ if (!response.ok) {
167
+ return { valid: false, error: `API error: ${response.status}` };
168
+ }
169
+
170
+ // Key is valid
171
+ return {
172
+ valid: true,
173
+ summary: "API key validated",
174
+ };
175
+ } catch (err) {
176
+ if (err instanceof Error) {
177
+ return { valid: false, error: `Connection error: ${err.message}` };
178
+ }
179
+ return { valid: false, error: "Unknown error" };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Get list of supported providers.
185
+ */
186
+ export function getSupportedProviders(): Array<{ id: string; name: string }> {
187
+ return [
188
+ { id: "anthropic", name: "Anthropic" },
189
+ { id: "openai", name: "OpenAI" },
190
+ ];
191
+ }
192
+
193
+ /**
194
+ * Validate a key for any supported provider.
195
+ */
196
+ export async function validateProviderKey(provider: string, apiKey: string): Promise<ValidationResult> {
197
+ switch (provider) {
198
+ case "anthropic":
199
+ return validateAnthropicKey(apiKey);
200
+ case "openai":
201
+ return validateOpenAIKey(apiKey);
202
+ default:
203
+ return { valid: false, error: `Unknown provider: ${provider}` };
204
+ }
205
+ }