@bobfrankston/gcards 0.1.10 → 0.1.12
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/gcards.ts +33 -2
- package/gfix.ts +2 -2
- package/glib/aihelper.ts +196 -0
- package/glib/gutils.ts +17 -3
- package/package.json +1 -1
package/gcards.ts
CHANGED
|
@@ -14,11 +14,22 @@ import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
|
14
14
|
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
15
15
|
import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
16
16
|
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
|
|
17
|
+
import { explainApiError, isAIAvailable } from './glib/aihelper.ts';
|
|
17
18
|
|
|
18
19
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
19
20
|
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
20
21
|
const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
|
|
21
22
|
|
|
23
|
+
/** Custom error that includes the payload for AI analysis */
|
|
24
|
+
class ApiError extends Error {
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
constructor(message: string, payload?: unknown) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ApiError';
|
|
29
|
+
this.payload = payload;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
function ts(): string {
|
|
23
34
|
const now = new Date();
|
|
24
35
|
return `[${now.toTimeString().slice(0, 8)}]`;
|
|
@@ -698,7 +709,7 @@ async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, token
|
|
|
698
709
|
}
|
|
699
710
|
|
|
700
711
|
if (!response.ok) {
|
|
701
|
-
throw new
|
|
712
|
+
throw new ApiError(`${response.status} ${await response.text()}`, person);
|
|
702
713
|
}
|
|
703
714
|
}
|
|
704
715
|
|
|
@@ -778,7 +789,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
|
|
|
778
789
|
}
|
|
779
790
|
|
|
780
791
|
if (!response.ok) {
|
|
781
|
-
throw new
|
|
792
|
+
throw new ApiError(`${response.status} ${await response.text()}`, person);
|
|
782
793
|
}
|
|
783
794
|
|
|
784
795
|
return await response.json();
|
|
@@ -1023,6 +1034,26 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1023
1034
|
console.log(` ERROR: ${error.message}`);
|
|
1024
1035
|
problems.push(errorMsg);
|
|
1025
1036
|
errorCount++;
|
|
1037
|
+
|
|
1038
|
+
// Get AI explanation if available and error is from API
|
|
1039
|
+
if (error instanceof ApiError && error.payload && isAIAvailable()) {
|
|
1040
|
+
try {
|
|
1041
|
+
const explanation = await explainApiError(error.message, error.payload);
|
|
1042
|
+
if (explanation) {
|
|
1043
|
+
console.log(`\n AI Analysis:`);
|
|
1044
|
+
if (explanation.explanation) {
|
|
1045
|
+
console.log(` Problem: ${explanation.explanation}`);
|
|
1046
|
+
}
|
|
1047
|
+
if (explanation.suggestedFix) {
|
|
1048
|
+
console.log(` Fix: ${explanation.suggestedFix}`);
|
|
1049
|
+
}
|
|
1050
|
+
if (explanation.correctedJson) {
|
|
1051
|
+
console.log(` Corrected JSON:\n${explanation.correctedJson.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
1052
|
+
}
|
|
1053
|
+
problems.push(` AI: ${explanation.explanation || ''} Fix: ${explanation.suggestedFix || ''}`);
|
|
1054
|
+
}
|
|
1055
|
+
} catch { /* ignore AI errors */ }
|
|
1056
|
+
}
|
|
1026
1057
|
}
|
|
1027
1058
|
}
|
|
1028
1059
|
|
package/gfix.ts
CHANGED
|
@@ -1898,12 +1898,12 @@ async function main(): Promise<void> {
|
|
|
1898
1898
|
console.error('Usage: gfix export -u <source> -to <target> <pattern>');
|
|
1899
1899
|
process.exit(1);
|
|
1900
1900
|
}
|
|
1901
|
-
const resolvedSource = resolveUser(user);
|
|
1901
|
+
const resolvedSource = resolveUser(user, true);
|
|
1902
1902
|
await runExport(resolvedSource, targetUser, positionalArgs[0]);
|
|
1903
1903
|
return;
|
|
1904
1904
|
}
|
|
1905
1905
|
|
|
1906
|
-
const resolvedUser = resolveUser(user);
|
|
1906
|
+
const resolvedUser = resolveUser(user, true);
|
|
1907
1907
|
|
|
1908
1908
|
if (processLimit > 0) {
|
|
1909
1909
|
console.log(`[DEBUG] Processing limited to first ${processLimit} contacts\n`);
|
package/glib/aihelper.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
}
|
package/glib/gutils.ts
CHANGED
|
@@ -125,9 +125,23 @@ export function resolveUser(cliUser: string, setAsDefault = false): string {
|
|
|
125
125
|
return config.lastUser;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// 3.
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
// 3. Auto-detect if only one user exists
|
|
129
|
+
const allUsers = getAllUsers();
|
|
130
|
+
if (allUsers.length === 1) {
|
|
131
|
+
const onlyUser = allUsers[0];
|
|
132
|
+
console.log(`Auto-selected only user: ${onlyUser}`);
|
|
133
|
+
config.lastUser = onlyUser;
|
|
134
|
+
saveConfig(config);
|
|
135
|
+
return onlyUser;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. No user - require --user on first use
|
|
139
|
+
if (allUsers.length > 1) {
|
|
140
|
+
console.error(`Multiple users exist: ${allUsers.join(', ')}`);
|
|
141
|
+
console.error('Use -u <name> to select one.');
|
|
142
|
+
} else {
|
|
143
|
+
console.error('No user specified. Use -u <email> on first run (e.g., your Gmail address).');
|
|
144
|
+
}
|
|
131
145
|
process.exit(1);
|
|
132
146
|
}
|
|
133
147
|
|