@bobfrankston/gcards 0.1.36 → 0.1.38

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.
@@ -34,7 +34,20 @@
34
34
  "Bash(npm link:*)",
35
35
  "Bash(cmd /c \"dir /AL\")",
36
36
  "Bash(node gcards.ts:*)",
37
- "Bash(npm run release:*)"
37
+ "Bash(npm run release:*)",
38
+ "Bash(npm update:*)",
39
+ "Bash(npm ls:*)",
40
+ "Bash(cmd.exe /c \"cd /d Y:\\\\dev\\\\projects\\\\oauth\\\\oauthsupport && npm run release\")",
41
+ "Bash(powershell.exe -Command \"Push-Location ''Y:\\\\dev\\\\projects\\\\oauth\\\\oauthsupport''; npm run release\")",
42
+ "WebSearch",
43
+ "WebFetch(domain:www.npmjs.com)",
44
+ "WebFetch(domain:github.com)",
45
+ "Bash(npm run check:*)",
46
+ "Bash(powershell.exe -Command \"if \\(Test-Path \"\"$env:APPDATA\\\\gcards\\\\keys.env\"\"\\) { Get-Content \"\"$env:APPDATA\\\\gcards\\\\keys.env\"\" | Select-String ''ANTHROPIC'' | ForEach-Object { $_Line -replace ''=.*'', ''=***'' } } else { Write-Host ''Not found: %APPDATA%\\\\gcards\\\\keys.env'' }\")",
47
+ "Bash(powershell.exe -Command \"if \\(Test-Path \"\"$env:USERPROFILE\\\\.gcards\\\\keys.env\"\"\\) { Write-Host ''Found: ~/.gcards/keys.env''; Get-Content \"\"$env:USERPROFILE\\\\.gcards\\\\keys.env\"\" | Select-String ''ANTHROPIC'' | ForEach-Object { $_Line -replace ''=.*'', ''=***'' } } else { Write-Host ''Not found: ~/.gcards/keys.env'' }\")",
48
+ "Bash(powershell.exe -Command \"if \\($env:ANTHROPIC_API_KEY\\) { Write-Host ''ANTHROPIC_API_KEY is set in environment'' } else { Write-Host ''ANTHROPIC_API_KEY NOT set in environment'' }\")",
49
+ "Bash(powershell.exe -Command \"Set-Clipboard -Value ''John Smith, CEO, Acme Corporation, john.smith@acme.com, +1-555-123-4567''\")",
50
+ "Bash(node:*)"
38
51
  ],
39
52
  "deny": [],
40
53
  "ask": []
@@ -5,6 +5,9 @@
5
5
  },
6
6
  {
7
7
  "path": "../../../projects/OAuth/OauthSupport"
8
+ },
9
+ {
10
+ "path": "../cardserve"
8
11
  }
9
12
  ],
10
13
  "settings": {
package/gcards.ts CHANGED
@@ -15,7 +15,8 @@ import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
15
15
  import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
16
16
  import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
17
17
  import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger, processProblemsFile } from './glib/gutils.ts';
18
- import { explainApiError, isAIAvailable, showAISetupHint } from './glib/aihelper.ts';
18
+ import { explainApiError, isAIAvailable, showAISetupHint, extractContactFromText, extractContactFromImage } from './glib/aihelper.ts';
19
+ import Clipboard from '@crosscopy/clipboard';
19
20
 
20
21
  const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
21
22
  const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
@@ -42,7 +43,7 @@ let abortController: AbortController | null = null;
42
43
  function setupEscapeHandler(): void {
43
44
  // Create new abort controller for this operation
44
45
  abortController = new AbortController();
45
-
46
+
46
47
  // Handle Ctrl+C via SIGINT (works without raw mode)
47
48
  process.on('SIGINT', () => {
48
49
  escapePressed = true;
@@ -160,7 +161,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
160
161
  sortedContacts[key] = value;
161
162
  }
162
163
  index.contacts = sortedContacts;
163
-
164
+
164
165
  // Add helpful comments to the file
165
166
  const comment = `// index.json - Active contacts index
166
167
  // This file tracks all contacts synced from Google.
@@ -214,7 +215,7 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
214
215
  sorted[key] = value;
215
216
  }
216
217
  deletedIndex.deleted = sorted;
217
-
218
+
218
219
  // Add helpful comments
219
220
  const comment = `// deleted.json - Tombstone index of deleted contacts
220
221
  // This file tracks contacts that have been deleted from Google.
@@ -315,14 +316,14 @@ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
315
316
  <table>
316
317
  <tr><th>Photo</th><th>Name</th></tr>
317
318
  ${entries.map(([resourceName, entry]) => {
318
- const id = resourceName.replace('people/', '');
319
- const contactUrl = `https://contacts.google.com/person/${id}`;
320
- const photoUrl = entry.photos[0]?.url || '';
321
- return ` <tr>
319
+ const id = resourceName.replace('people/', '');
320
+ const contactUrl = `https://contacts.google.com/person/${id}`;
321
+ const photoUrl = entry.photos[0]?.url || '';
322
+ return ` <tr>
322
323
  <td>${photoUrl ? `<img src="${photoUrl}" alt="${entry.displayName}" loading="lazy">` : '(no photo)'}</td>
323
324
  <td><a href="${contactUrl}" target="_blank">${entry.displayName}</a><br><span class="deleted">Deleted: ${entry.deletedAt?.split('T')[0] || 'unknown'}</span></td>
324
325
  </tr>`;
325
- }).join('\n')}
326
+ }).join('\n')}
326
327
  </table>
327
328
  </body>
328
329
  </html>`;
@@ -342,7 +343,7 @@ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
342
343
 
343
344
  function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
344
345
  queue.updatedAt = new Date().toISOString();
345
-
346
+
346
347
  // Add helpful comments
347
348
  const comment = `// _delete.json - Pending deletion queue
348
349
  // This file contains contacts marked for deletion that require review.
@@ -482,7 +483,7 @@ function saveContact(paths: UserPaths, person: GooglePerson): void {
482
483
 
483
484
  const id = person.resourceName.replace('people/', '');
484
485
  const filePath = path.join(paths.contactsDir, `${id}.json`);
485
-
486
+
486
487
  // Add helpful comment for contact files
487
488
  const comment = `// Contact file from Google People API
488
489
  // To delete this contact: Add "_delete": "force" field at root level, then run 'gcards push'
@@ -723,7 +724,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
723
724
  const indexedResourceNames = new Set(
724
725
  Object.keys(index.contacts).map(rn => rn.replace('people/', '') + '.json')
725
726
  );
726
-
727
+
727
728
  for (const file of contactFiles) {
728
729
  if (!indexedResourceNames.has(file)) {
729
730
  if (!fs.existsSync(paths.orphansDir)) {
@@ -1048,6 +1049,138 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
1048
1049
  return await response.json();
1049
1050
  }
1050
1051
 
1052
+ /**
1053
+ * Add a contact from text, clipboard, or image file using AI extraction
1054
+ */
1055
+ async function addContact(user: string, options: { clip: boolean; file: string; text: string; verbose: boolean }): Promise<void> {
1056
+ const paths = getUserPaths(user);
1057
+ ensureUserDir(user);
1058
+
1059
+ // Ensure _add directory exists
1060
+ if (!fs.existsSync(paths.toAddDir)) {
1061
+ fs.mkdirSync(paths.toAddDir, { recursive: true });
1062
+ }
1063
+
1064
+ let extracted;
1065
+ let sourceDesc: string;
1066
+
1067
+ if (options.file) {
1068
+ // Read from image file
1069
+ if (!fs.existsSync(options.file)) {
1070
+ throw new Error(`File not found: ${options.file}`);
1071
+ }
1072
+ console.log(`Reading image from: ${options.file}`);
1073
+ const imageBuffer = fs.readFileSync(options.file);
1074
+ const base64 = imageBuffer.toString('base64');
1075
+ const ext = path.extname(options.file).toLowerCase();
1076
+ const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
1077
+
1078
+ console.log('Extracting contact info from image...');
1079
+ extracted = await extractContactFromImage(base64, mediaType);
1080
+ sourceDesc = `image:${path.basename(options.file)}`;
1081
+
1082
+ } else if (options.clip) {
1083
+ // Read from clipboard
1084
+ console.log('Reading from clipboard...');
1085
+
1086
+ try {
1087
+ // Try image first
1088
+ let hasImage = false;
1089
+ try {
1090
+ hasImage = await Clipboard.hasImage();
1091
+ } catch {
1092
+ // hasImage check failed, assume text
1093
+ }
1094
+
1095
+ if (hasImage) {
1096
+ console.log('Found image in clipboard');
1097
+ const base64 = await Clipboard.getImageBase64();
1098
+ if (!base64) {
1099
+ throw new Error('Failed to read image from clipboard');
1100
+ }
1101
+ console.log('Extracting contact info from image...');
1102
+ extracted = await extractContactFromImage(base64, 'image/png');
1103
+ sourceDesc = 'clipboard:image';
1104
+ } else {
1105
+ const text = await Clipboard.getText();
1106
+ if (!text || text.trim().length === 0) {
1107
+ throw new Error('Clipboard is empty or contains unsupported content (try copying text or an image)');
1108
+ }
1109
+ console.log(`Found text in clipboard (${text.length} chars)`);
1110
+ if (options.verbose) {
1111
+ console.log(`Text: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
1112
+ }
1113
+ console.log('Extracting contact info from text...');
1114
+ extracted = await extractContactFromText(text);
1115
+ sourceDesc = 'clipboard:text';
1116
+ }
1117
+ } catch (clipError: any) {
1118
+ throw new Error(`Clipboard error: ${clipError.message || clipError}. Make sure clipboard contains text or an image.`);
1119
+ }
1120
+
1121
+ } else if (options.text) {
1122
+ // Parse from command line text
1123
+ console.log('Extracting contact info from text...');
1124
+ if (options.verbose) {
1125
+ console.log(`Text: ${options.text}`);
1126
+ }
1127
+ extracted = await extractContactFromText(options.text);
1128
+ sourceDesc = 'text';
1129
+
1130
+ } else {
1131
+ throw new Error('No input specified. Use -clip, --file, or provide text. See --help.');
1132
+ }
1133
+
1134
+ if (!extracted) {
1135
+ throw new Error('Failed to extract contact information');
1136
+ }
1137
+
1138
+ // Build display name for the contact
1139
+ let displayName = 'Unknown';
1140
+ if (extracted.names?.[0]) {
1141
+ const name = extracted.names[0];
1142
+ if (name.givenName || name.familyName) {
1143
+ displayName = [name.givenName, name.familyName].filter(Boolean).join(' ');
1144
+ } else if (name.displayName) {
1145
+ displayName = name.displayName;
1146
+ }
1147
+ } else if (extracted.organizations?.[0]?.name) {
1148
+ displayName = extracted.organizations[0].name;
1149
+ }
1150
+
1151
+ // Generate a unique filename
1152
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
1153
+ const safeName = displayName.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 30);
1154
+ const filename = `${timestamp}_${safeName}.json`;
1155
+ const filePath = path.join(paths.toAddDir, filename);
1156
+
1157
+ // Add metadata about source
1158
+ const contactData = {
1159
+ ...extracted,
1160
+ userDefined: [
1161
+ { key: 'addedVia', value: `gcards-add:${sourceDesc}` },
1162
+ { key: 'addedAt', value: new Date().toISOString() }
1163
+ ]
1164
+ };
1165
+
1166
+ // Save to _add folder
1167
+ fs.writeFileSync(filePath, JSON.stringify(contactData, null, 2));
1168
+
1169
+ console.log(`\nExtracted contact: ${displayName}`);
1170
+ if (extracted.emailAddresses?.length) {
1171
+ console.log(` Email: ${extracted.emailAddresses.map(e => e.value).join(', ')}`);
1172
+ }
1173
+ if (extracted.phoneNumbers?.length) {
1174
+ console.log(` Phone: ${extracted.phoneNumbers.map(p => p.value).join(', ')}`);
1175
+ }
1176
+ if (extracted.organizations?.length) {
1177
+ const org = extracted.organizations[0];
1178
+ console.log(` Org: ${[org.title, org.name].filter(Boolean).join(' @ ')}`);
1179
+ }
1180
+ console.log(`\nSaved to: ${filePath}`);
1181
+ console.log(`Run 'gcards push' to create the contact on Google.`);
1182
+ }
1183
+
1051
1184
  async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
1052
1185
  const paths = getUserPaths(user);
1053
1186
  ensureUserDir(user);
@@ -1470,6 +1603,9 @@ async function main(): Promise<void> {
1470
1603
  case 'push':
1471
1604
  await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
1472
1605
  break;
1606
+ case 'add':
1607
+ await addContact(user, { clip: options.clip, file: options.file, text: options.text, verbose: options.verbose });
1608
+ break;
1473
1609
  default:
1474
1610
  console.error(`Unknown command: ${options.command}`);
1475
1611
  showHelp();
@@ -1492,6 +1628,26 @@ async function main(): Promise<void> {
1492
1628
  }
1493
1629
  }
1494
1630
 
1631
+ /**
1632
+ * Patch process.exit to throw an exception for easier debugging and testing.
1633
+ * This allows catching accidental exits and inspecting the call stack.
1634
+ */
1635
+ // const originalExit = process.exit;
1636
+ // process.exit = function patchedExit(code?: number): never {
1637
+ // const err = new Error(`process.exit(${code}) called`);
1638
+ // // Print stack for debugging
1639
+ // console.error(err.stack);
1640
+ // debugger;
1641
+ // // Optionally, throw to allow catching in tests or debugging
1642
+ // throw err;
1643
+ // };
1644
+
1495
1645
  if (import.meta.main) {
1496
- await main();
1646
+ try {
1647
+ await main();
1648
+ }
1649
+ catch (e: any) {
1650
+ console.log(`Fatal error: ${e.message}\n${e.stack}`);
1651
+ debugger;
1652
+ }
1497
1653
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",
@@ -29,12 +29,16 @@
29
29
  "author": "Bob Frankston",
30
30
  "license": "MIT",
31
31
  "devDependencies": {
32
- "@types/node": "^25.0.3",
32
+ "@types/node": "^25.0.9",
33
33
  "tsx": "^4.21.0"
34
34
  },
35
35
  "dependencies": {
36
- "@bobfrankston/miscassists": "^1.0.36",
37
- "@bobfrankston/oauthsupport": "^1.0.1",
36
+ "@bobfrankston/miscassists": "^1.0.41",
37
+ "@bobfrankston/oauthsupport": "^1.0.3",
38
+ "@crosscopy/clipboard": "^0.2.8",
38
39
  "jsonc-parser": "^3.3.1"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
39
43
  }
40
44
  }
@@ -0,0 +1 @@
1
+ /y/dev/utils/cardx/gcards
package/.gitattributes DELETED
@@ -1 +0,0 @@
1
- * text=auto eol=lf
@@ -1,21 +0,0 @@
1
- {
2
- "version": "0.2.0",
3
- "configurations": [
4
- {
5
- "type": "node",
6
- "request": "launch",
7
- "name": "gcards sync",
8
- "program": "${workspaceFolder}/gcards.ts",
9
- "args": ["sync"],
10
- "skipFiles": ["<node_internals>/**"]
11
- },
12
- {
13
- "type": "node",
14
- "request": "launch",
15
- "name": "gcards sync --full",
16
- "program": "${workspaceFolder}/gcards.ts",
17
- "args": ["sync", "--full"],
18
- "skipFiles": ["<node_internals>/**"]
19
- }
20
- ]
21
- }
@@ -1,23 +0,0 @@
1
- {
2
- "workbench.colorTheme": "Visual Studio 2019 Dark",
3
- "workbench.colorCustomizations": {
4
- "activityBar.activeBackground": "#0ae04a",
5
- "activityBar.background": "#0ae04a",
6
- "activityBar.foreground": "#15202b",
7
- "activityBar.inactiveForeground": "#15202b99",
8
- "activityBarBadge.background": "#9266f8",
9
- "activityBarBadge.foreground": "#15202b",
10
- "commandCenter.border": "#e7e7e799",
11
- "sash.hoverBorder": "#0ae04a",
12
- "statusBar.background": "#08af3a",
13
- "statusBar.foreground": "#e7e7e7",
14
- "statusBarItem.hoverBackground": "#0ae04a",
15
- "statusBarItem.remoteBackground": "#08af3a",
16
- "statusBarItem.remoteForeground": "#e7e7e7",
17
- "titleBar.activeBackground": "#08af3a",
18
- "titleBar.activeForeground": "#e7e7e7",
19
- "titleBar.inactiveBackground": "#08af3a99",
20
- "titleBar.inactiveForeground": "#e7e7e799"
21
- },
22
- "peacock.color": "#08af3a"
23
- }
@@ -1,20 +0,0 @@
1
- {
2
- "version": "2.0.0",
3
- "tasks": [
4
- {
5
- "label": "tsc: check",
6
- "type": "shell",
7
- "command": "tsc",
8
- "args": ["--watch"],
9
- "runOptions": {
10
- "runOn": "folderOpen"
11
- },
12
- "problemMatcher": "$tsc-watch",
13
- "isBackground": true,
14
- "group": {
15
- "kind": "build",
16
- "isDefault": true
17
- }
18
- }
19
- ]
20
- }
package/glib/aihelper.ts DELETED
@@ -1,215 +0,0 @@
1
- /**
2
- * AI-powered error explanation helper for Google People API errors
3
- * Uses Anthropic Claude (primary) or OpenAI (fallback)
4
- */
5
-
6
- import fs from 'fs';
7
- import path from 'path';
8
-
9
- // Load keys from standard locations (AppData for Windows, ~/.config for Unix)
10
- function loadEnvFile(): void {
11
- const home = process.env.HOME || process.env.USERPROFILE || '';
12
- const appData = process.env.APPDATA || path.join(home, '.config');
13
-
14
- const locations = [
15
- path.join(appData, 'gcards', 'keys.env'), // Standard app config location
16
- path.join(home, '.gcards', 'keys.env'), // Unix dotfile style
17
- path.join(process.cwd(), 'keys.env'), // Local override (dev only)
18
- path.join(process.cwd(), '.env'),
19
- ];
20
-
21
- for (const loc of locations) {
22
- if (fs.existsSync(loc)) {
23
- const content = fs.readFileSync(loc, 'utf-8');
24
- for (const line of content.split(/\r?\n/)) {
25
- const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
26
- if (match && !process.env[match[1]]) {
27
- process.env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
28
- }
29
- }
30
- break;
31
- }
32
- }
33
- }
34
-
35
- loadEnvFile();
36
-
37
- export interface AIErrorExplanation {
38
- explanation: string;
39
- suggestedFix: string;
40
- correctedJson?: string;
41
- }
42
-
43
- const SYSTEM_PROMPT = `You are an expert on the Google People API for contacts. When given an API error and the JSON payload that caused it, provide:
44
-
45
- 1. A brief explanation of what went wrong
46
- 2. The specific fix needed
47
- 3. If possible, the corrected JSON snippet
48
-
49
- Be extremely concise. No preamble. Format:
50
- PROBLEM: <one line>
51
- FIX: <one line>
52
- CORRECTED: <json if applicable, or "N/A">
53
-
54
- Common issues:
55
- - nicknames is a top-level field, NOT nested in names[]
56
- - phoneNumbers[].type values: "mobile", "home", "work", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"
57
- - emailAddresses[].type values: "home", "work", "other"
58
- - addresses[].type values: "home", "work", "other"
59
- - birthdays[].date needs {year?, month, day} not a string
60
- - organizations[].type values: "work", "school"
61
- - Field names are camelCase not snake_case`;
62
-
63
- async function callAnthropic(errorMsg: string, payload: string): Promise<string | null> {
64
- const apiKey = process.env.ANTHROPIC_API_KEY;
65
- if (!apiKey) return null;
66
-
67
- try {
68
- const response = await fetch('https://api.anthropic.com/v1/messages', {
69
- method: 'POST',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- 'x-api-key': apiKey,
73
- 'anthropic-version': '2023-06-01'
74
- },
75
- body: JSON.stringify({
76
- model: 'claude-3-haiku-20240307',
77
- max_tokens: 500,
78
- system: SYSTEM_PROMPT,
79
- messages: [{
80
- role: 'user',
81
- content: `Error: ${errorMsg}\n\nPayload:\n${payload}`
82
- }]
83
- })
84
- });
85
-
86
- if (!response.ok) return null;
87
-
88
- const data = await response.json() as any;
89
- return data.content?.[0]?.text || null;
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- async function callOpenAI(errorMsg: string, payload: string): Promise<string | null> {
96
- const apiKey = process.env.OPENAI_API_KEY;
97
- if (!apiKey) return null;
98
-
99
- try {
100
- const response = await fetch('https://api.openai.com/v1/chat/completions', {
101
- method: 'POST',
102
- headers: {
103
- 'Content-Type': 'application/json',
104
- 'Authorization': `Bearer ${apiKey}`
105
- },
106
- body: JSON.stringify({
107
- model: 'gpt-4o-mini',
108
- max_tokens: 500,
109
- messages: [
110
- { role: 'system', content: SYSTEM_PROMPT },
111
- { role: 'user', content: `Error: ${errorMsg}\n\nPayload:\n${payload}` }
112
- ]
113
- })
114
- });
115
-
116
- if (!response.ok) return null;
117
-
118
- const data = await response.json() as any;
119
- return data.choices?.[0]?.message?.content || null;
120
- } catch {
121
- return null;
122
- }
123
- }
124
-
125
- function parseAIResponse(response: string): AIErrorExplanation {
126
- const lines = response.split('\n');
127
- let explanation = '';
128
- let suggestedFix = '';
129
- let correctedJson: string | undefined;
130
- let inCorrected = false;
131
- const correctedLines: string[] = [];
132
-
133
- for (const line of lines) {
134
- if (line.startsWith('PROBLEM:')) {
135
- explanation = line.substring(8).trim();
136
- } else if (line.startsWith('FIX:')) {
137
- suggestedFix = line.substring(4).trim();
138
- } else if (line.startsWith('CORRECTED:')) {
139
- const rest = line.substring(10).trim();
140
- if (rest && rest !== 'N/A') {
141
- inCorrected = true;
142
- if (rest !== '{' && rest !== '[') {
143
- correctedLines.push(rest);
144
- } else {
145
- correctedLines.push(rest);
146
- }
147
- }
148
- } else if (inCorrected) {
149
- correctedLines.push(line);
150
- }
151
- }
152
-
153
- if (correctedLines.length > 0) {
154
- const joined = correctedLines.join('\n').trim();
155
- // Try to extract just the JSON part
156
- const jsonMatch = joined.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
157
- if (jsonMatch) {
158
- correctedJson = jsonMatch[1];
159
- } else if (joined !== 'N/A') {
160
- correctedJson = joined;
161
- }
162
- }
163
-
164
- return { explanation, suggestedFix, correctedJson };
165
- }
166
-
167
- /**
168
- * Get AI-powered explanation for a Google People API error
169
- * @param errorMessage The error message from the API
170
- * @param payload The JSON payload that caused the error (will be truncated if too long)
171
- * @returns Explanation and fix suggestion, or null if AI unavailable
172
- */
173
- export async function explainApiError(errorMessage: string, payload: unknown): Promise<AIErrorExplanation | null> {
174
- // Truncate payload to avoid huge API calls
175
- let payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
176
- if (payloadStr.length > 2000) {
177
- payloadStr = payloadStr.substring(0, 2000) + '\n... (truncated)';
178
- }
179
-
180
- // Try Anthropic first, then OpenAI
181
- let response = await callAnthropic(errorMessage, payloadStr);
182
- if (!response) {
183
- response = await callOpenAI(errorMessage, payloadStr);
184
- }
185
-
186
- if (!response) return null;
187
-
188
- return parseAIResponse(response);
189
- }
190
-
191
- /**
192
- * Check if AI error explanation is available (any API key configured)
193
- */
194
- export function isAIAvailable(): boolean {
195
- return !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
196
- }
197
-
198
- /**
199
- * Get the expected keys.env path for the current platform
200
- */
201
- export function getKeysEnvPath(): string {
202
- const home = process.env.HOME || process.env.USERPROFILE || '';
203
- const appData = process.env.APPDATA || path.join(home, '.config');
204
- return path.join(appData, 'gcards', 'keys.env');
205
- }
206
-
207
- /**
208
- * Show instructions for setting up AI keys (call when AI could have helped but wasn't available)
209
- */
210
- export function showAISetupHint(): void {
211
- const keysPath = getKeysEnvPath();
212
- console.log(`\n Tip: Enable AI error explanations by creating ${keysPath}`);
213
- console.log(` Add: ANTHROPIC_API_KEY=sk-ant-... or OPENAI_API_KEY=sk-...`);
214
- console.log(` See README.md for details.`);
215
- }