@plosson/agentio 0.3.2 → 0.4.1
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/package.json +2 -1
- package/src/auth/oauth.ts +24 -2
- package/src/auth/token-manager.ts +10 -8
- package/src/commands/config.ts +90 -3
- package/src/commands/discourse.ts +3 -3
- package/src/commands/gchat.ts +14 -9
- package/src/commands/gdocs.ts +186 -0
- package/src/commands/gdrive.ts +285 -0
- package/src/commands/github.ts +2 -2
- package/src/commands/gmail.ts +10 -10
- package/src/commands/jira.ts +25 -28
- package/src/commands/slack.ts +1 -1
- package/src/commands/sql.ts +14 -27
- package/src/commands/status.ts +14 -0
- package/src/commands/telegram.ts +1 -1
- package/src/config/config-manager.ts +35 -1
- package/src/index.ts +4 -0
- package/src/services/gdocs/client.ts +165 -0
- package/src/services/gdrive/client.ts +452 -0
- package/src/types/config.ts +3 -1
- package/src/types/gdocs.ts +28 -0
- package/src/types/gdrive.ts +81 -0
- package/src/utils/client-factory.ts +12 -9
- package/src/utils/interactive.ts +145 -0
- package/src/utils/output.ts +109 -0
|
@@ -7,7 +7,7 @@ import type { Config, ServiceName } from '../types/config';
|
|
|
7
7
|
const CONFIG_DIR = join(homedir(), '.config', 'agentio');
|
|
8
8
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
9
9
|
|
|
10
|
-
const ALL_SERVICES: ServiceName[] = ['gmail', 'gchat', 'github', 'jira', 'slack', 'telegram', 'discourse', 'sql'];
|
|
10
|
+
const ALL_SERVICES: ServiceName[] = ['gdocs', 'gdrive', 'gmail', 'gchat', 'github', 'jira', 'slack', 'telegram', 'discourse', 'sql'];
|
|
11
11
|
|
|
12
12
|
const DEFAULT_CONFIG: Config = {
|
|
13
13
|
profiles: {},
|
|
@@ -61,6 +61,40 @@ export async function getProfile(
|
|
|
61
61
|
return profileName;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolve profile name for a service.
|
|
66
|
+
* - If profileName is provided, validates it exists
|
|
67
|
+
* - If not provided and exactly 1 profile exists, returns that profile
|
|
68
|
+
* - Returns null if no profiles exist or if multiple profiles exist without explicit selection
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveProfile(
|
|
71
|
+
service: ServiceName,
|
|
72
|
+
profileName?: string
|
|
73
|
+
): Promise<{ profile: string | null; error?: 'none' | 'multiple' }> {
|
|
74
|
+
const config = await loadConfig();
|
|
75
|
+
const serviceProfiles = config.profiles[service] || [];
|
|
76
|
+
|
|
77
|
+
if (profileName) {
|
|
78
|
+
// Explicit profile requested - validate it exists
|
|
79
|
+
if (!serviceProfiles.includes(profileName)) {
|
|
80
|
+
return { profile: null };
|
|
81
|
+
}
|
|
82
|
+
return { profile: profileName };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No profile specified - check if we can auto-select
|
|
86
|
+
if (serviceProfiles.length === 0) {
|
|
87
|
+
return { profile: null, error: 'none' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (serviceProfiles.length === 1) {
|
|
91
|
+
return { profile: serviceProfiles[0] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Multiple profiles exist - user must specify
|
|
95
|
+
return { profile: null, error: 'multiple' };
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
export async function setProfile(
|
|
65
99
|
service: ServiceName,
|
|
66
100
|
profileName: string
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { registerGmailCommands } from './commands/gmail';
|
|
4
|
+
import { registerGDocsCommands } from './commands/gdocs';
|
|
5
|
+
import { registerGDriveCommands } from './commands/gdrive';
|
|
4
6
|
import { registerTelegramCommands } from './commands/telegram';
|
|
5
7
|
import { registerGChatCommands } from './commands/gchat';
|
|
6
8
|
import { registerGitHubCommands } from './commands/github';
|
|
@@ -32,6 +34,8 @@ program
|
|
|
32
34
|
.version(getVersion());
|
|
33
35
|
|
|
34
36
|
registerGmailCommands(program);
|
|
37
|
+
registerGDocsCommands(program);
|
|
38
|
+
registerGDriveCommands(program);
|
|
35
39
|
registerTelegramCommands(program);
|
|
36
40
|
registerGChatCommands(program);
|
|
37
41
|
registerGitHubCommands(program);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
import type { drive_v3 } from 'googleapis';
|
|
4
|
+
import { CliError, httpStatusToErrorCode, type ErrorCode } from '../../utils/errors';
|
|
5
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
6
|
+
import { GOOGLE_OAUTH_CONFIG } from '../../config/credentials';
|
|
7
|
+
import type { GDocsCredentials, GDocsDocument, GDocsListOptions, GDocsCreateResult } from '../../types/gdocs';
|
|
8
|
+
|
|
9
|
+
export class GDocsClient implements ServiceClient {
|
|
10
|
+
private credentials: GDocsCredentials;
|
|
11
|
+
private drive: drive_v3.Drive;
|
|
12
|
+
|
|
13
|
+
constructor(credentials: GDocsCredentials) {
|
|
14
|
+
this.credentials = credentials;
|
|
15
|
+
const auth = this.createOAuthClient();
|
|
16
|
+
this.drive = google.drive({ version: 'v3', auth });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async validate(): Promise<ValidationResult> {
|
|
20
|
+
try {
|
|
21
|
+
await this.drive.files.list({ pageSize: 1, q: "mimeType='application/vnd.google-apps.document'" });
|
|
22
|
+
return { valid: true, info: this.credentials.email };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
25
|
+
if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
|
|
26
|
+
return { valid: false, error: 'refresh token expired, re-authenticate' };
|
|
27
|
+
}
|
|
28
|
+
return { valid: false, error: message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getAsMarkdown(docIdOrUrl: string): Promise<string> {
|
|
33
|
+
const docId = this.extractDocId(docIdOrUrl);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await this.drive.files.export({
|
|
37
|
+
fileId: docId,
|
|
38
|
+
mimeType: 'text/markdown',
|
|
39
|
+
});
|
|
40
|
+
return response.data as string;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
this.throwApiError(err, 'export document as markdown');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getAsDocx(docIdOrUrl: string): Promise<Buffer> {
|
|
47
|
+
const docId = this.extractDocId(docIdOrUrl);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const response = await this.drive.files.export(
|
|
51
|
+
{ fileId: docId, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
|
52
|
+
{ responseType: 'arraybuffer' }
|
|
53
|
+
);
|
|
54
|
+
return Buffer.from(response.data as ArrayBuffer);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
this.throwApiError(err, 'export document as docx');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async create(title: string, markdown: string, folderId?: string): Promise<GDocsCreateResult> {
|
|
61
|
+
try {
|
|
62
|
+
const stream = Readable.from(Buffer.from(markdown, 'utf-8'));
|
|
63
|
+
|
|
64
|
+
const response = await this.drive.files.create({
|
|
65
|
+
requestBody: {
|
|
66
|
+
name: title,
|
|
67
|
+
mimeType: 'application/vnd.google-apps.document',
|
|
68
|
+
parents: folderId ? [folderId] : undefined,
|
|
69
|
+
},
|
|
70
|
+
media: {
|
|
71
|
+
mimeType: 'text/markdown',
|
|
72
|
+
body: stream,
|
|
73
|
+
},
|
|
74
|
+
fields: 'id,name,webViewLink',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id: response.data.id!,
|
|
79
|
+
title: response.data.name || title,
|
|
80
|
+
webViewLink: response.data.webViewLink || `https://docs.google.com/document/d/${response.data.id}`,
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.throwApiError(err, 'create document');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async list(options: GDocsListOptions = {}): Promise<GDocsDocument[]> {
|
|
88
|
+
const { limit = 10, query } = options;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let q = "mimeType='application/vnd.google-apps.document' and trashed=false";
|
|
92
|
+
if (query) {
|
|
93
|
+
q += ` and ${query}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const response = await this.drive.files.list({
|
|
97
|
+
pageSize: Math.min(limit, 100),
|
|
98
|
+
q,
|
|
99
|
+
fields: 'files(id,name,owners,createdTime,modifiedTime,webViewLink)',
|
|
100
|
+
orderBy: 'modifiedTime desc',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const files = response.data.files || [];
|
|
104
|
+
return files.map((file) => ({
|
|
105
|
+
id: file.id!,
|
|
106
|
+
title: file.name || 'Untitled',
|
|
107
|
+
owner: file.owners?.[0]?.displayName || file.owners?.[0]?.emailAddress || undefined,
|
|
108
|
+
createdTime: file.createdTime || undefined,
|
|
109
|
+
modifiedTime: file.modifiedTime || undefined,
|
|
110
|
+
webViewLink: file.webViewLink || `https://docs.google.com/document/d/${file.id}`,
|
|
111
|
+
}));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
this.throwApiError(err, 'list documents');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private extractDocId(docIdOrUrl: string): string {
|
|
118
|
+
// Matches both full URLs and docs.google.com URLs
|
|
119
|
+
const urlMatch = docIdOrUrl.match(/\/document\/d\/([a-zA-Z0-9_-]+)/);
|
|
120
|
+
return urlMatch ? urlMatch[1] : docIdOrUrl;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private createOAuthClient() {
|
|
124
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
125
|
+
GOOGLE_OAUTH_CONFIG.clientId,
|
|
126
|
+
GOOGLE_OAUTH_CONFIG.clientSecret
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
oauth2Client.setCredentials({
|
|
130
|
+
access_token: this.credentials.accessToken,
|
|
131
|
+
refresh_token: this.credentials.refreshToken,
|
|
132
|
+
expiry_date: this.credentials.expiryDate,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return oauth2Client;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private throwApiError(err: unknown, operation: string): never {
|
|
139
|
+
const code = this.getErrorCode(err);
|
|
140
|
+
const message = this.getErrorMessage(err);
|
|
141
|
+
throw new CliError(code, `Failed to ${operation}: ${message}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private getErrorCode(err: unknown): ErrorCode {
|
|
145
|
+
if (err && typeof err === 'object') {
|
|
146
|
+
const error = err as Record<string, unknown>;
|
|
147
|
+
const code = error.code || error.status;
|
|
148
|
+
if (typeof code === 'number') return httpStatusToErrorCode(code);
|
|
149
|
+
}
|
|
150
|
+
return 'API_ERROR';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private getErrorMessage(err: unknown): string {
|
|
154
|
+
if (err && typeof err === 'object') {
|
|
155
|
+
const error = err as Record<string, unknown>;
|
|
156
|
+
const code = error.code || error.status;
|
|
157
|
+
if (code === 401) return 'OAuth token expired or invalid';
|
|
158
|
+
if (code === 403) return 'Insufficient permissions to access this document';
|
|
159
|
+
if (code === 404) return 'Document not found';
|
|
160
|
+
if (code === 429) return 'Rate limit exceeded, please try again later';
|
|
161
|
+
if (error.message && typeof error.message === 'string') return error.message;
|
|
162
|
+
}
|
|
163
|
+
return err instanceof Error ? err.message : String(err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from 'fs/promises';
|
|
2
|
+
import { basename } from 'path';
|
|
3
|
+
import { google } from 'googleapis';
|
|
4
|
+
import type { drive_v3 } from 'googleapis';
|
|
5
|
+
import { CliError, httpStatusToErrorCode, type ErrorCode } from '../../utils/errors';
|
|
6
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
7
|
+
import { GOOGLE_OAUTH_CONFIG } from '../../config/credentials';
|
|
8
|
+
import type {
|
|
9
|
+
GDriveCredentials,
|
|
10
|
+
GDriveFile,
|
|
11
|
+
GDriveListOptions,
|
|
12
|
+
GDriveSearchOptions,
|
|
13
|
+
GDriveFolderListOptions,
|
|
14
|
+
GDriveDownloadOptions,
|
|
15
|
+
GDriveDownloadResult,
|
|
16
|
+
GDriveUploadResult,
|
|
17
|
+
GDriveUploadOptions,
|
|
18
|
+
GDriveExportFormat,
|
|
19
|
+
} from '../../types/gdrive';
|
|
20
|
+
|
|
21
|
+
// Export MIME types for Google Workspace files
|
|
22
|
+
const EXPORT_MIME_TYPES: Record<string, Record<GDriveExportFormat, string | undefined>> = {
|
|
23
|
+
'application/vnd.google-apps.document': {
|
|
24
|
+
pdf: 'application/pdf',
|
|
25
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
26
|
+
odt: 'application/vnd.oasis.opendocument.text',
|
|
27
|
+
txt: 'text/plain',
|
|
28
|
+
html: 'text/html',
|
|
29
|
+
rtf: 'application/rtf',
|
|
30
|
+
xlsx: undefined, csv: undefined, pptx: undefined, ods: undefined, odp: undefined, tsv: undefined, png: undefined, jpeg: undefined, svg: undefined,
|
|
31
|
+
},
|
|
32
|
+
'application/vnd.google-apps.spreadsheet': {
|
|
33
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
34
|
+
csv: 'text/csv',
|
|
35
|
+
pdf: 'application/pdf',
|
|
36
|
+
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
|
37
|
+
tsv: 'text/tab-separated-values',
|
|
38
|
+
docx: undefined, odt: undefined, txt: undefined, html: undefined, rtf: undefined, pptx: undefined, odp: undefined, png: undefined, jpeg: undefined, svg: undefined,
|
|
39
|
+
},
|
|
40
|
+
'application/vnd.google-apps.presentation': {
|
|
41
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
42
|
+
pdf: 'application/pdf',
|
|
43
|
+
odp: 'application/vnd.oasis.opendocument.presentation',
|
|
44
|
+
txt: 'text/plain',
|
|
45
|
+
docx: undefined, odt: undefined, html: undefined, rtf: undefined, xlsx: undefined, csv: undefined, ods: undefined, tsv: undefined, png: undefined, jpeg: undefined, svg: undefined,
|
|
46
|
+
},
|
|
47
|
+
'application/vnd.google-apps.drawing': {
|
|
48
|
+
pdf: 'application/pdf',
|
|
49
|
+
png: 'image/png',
|
|
50
|
+
jpeg: 'image/jpeg',
|
|
51
|
+
svg: 'image/svg+xml',
|
|
52
|
+
docx: undefined, odt: undefined, txt: undefined, html: undefined, rtf: undefined, xlsx: undefined, csv: undefined, pptx: undefined, ods: undefined, odp: undefined, tsv: undefined,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// MIME types that trigger conversion to Google Workspace format
|
|
57
|
+
const CONVERTIBLE_MIME_TYPES: Record<string, string> = {
|
|
58
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'application/vnd.google-apps.document',
|
|
59
|
+
'application/msword': 'application/vnd.google-apps.document',
|
|
60
|
+
'application/vnd.oasis.opendocument.text': 'application/vnd.google-apps.document',
|
|
61
|
+
'text/plain': 'application/vnd.google-apps.document',
|
|
62
|
+
'text/html': 'application/vnd.google-apps.document',
|
|
63
|
+
'application/rtf': 'application/vnd.google-apps.document',
|
|
64
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'application/vnd.google-apps.spreadsheet',
|
|
65
|
+
'application/vnd.ms-excel': 'application/vnd.google-apps.spreadsheet',
|
|
66
|
+
'application/vnd.oasis.opendocument.spreadsheet': 'application/vnd.google-apps.spreadsheet',
|
|
67
|
+
'text/csv': 'application/vnd.google-apps.spreadsheet',
|
|
68
|
+
'text/tab-separated-values': 'application/vnd.google-apps.spreadsheet',
|
|
69
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'application/vnd.google-apps.presentation',
|
|
70
|
+
'application/vnd.ms-powerpoint': 'application/vnd.google-apps.presentation',
|
|
71
|
+
'application/vnd.oasis.opendocument.presentation': 'application/vnd.google-apps.presentation',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// File extension to MIME type mapping for conversion
|
|
75
|
+
const EXT_TO_MIME: Record<string, string> = {
|
|
76
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
77
|
+
'.doc': 'application/msword',
|
|
78
|
+
'.odt': 'application/vnd.oasis.opendocument.text',
|
|
79
|
+
'.txt': 'text/plain',
|
|
80
|
+
'.html': 'text/html',
|
|
81
|
+
'.htm': 'text/html',
|
|
82
|
+
'.rtf': 'application/rtf',
|
|
83
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
84
|
+
'.xls': 'application/vnd.ms-excel',
|
|
85
|
+
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
|
86
|
+
'.csv': 'text/csv',
|
|
87
|
+
'.tsv': 'text/tab-separated-values',
|
|
88
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
89
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
90
|
+
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export class GDriveClient implements ServiceClient {
|
|
94
|
+
private credentials: GDriveCredentials;
|
|
95
|
+
private drive: drive_v3.Drive;
|
|
96
|
+
|
|
97
|
+
constructor(credentials: GDriveCredentials) {
|
|
98
|
+
this.credentials = credentials;
|
|
99
|
+
const auth = this.createOAuthClient();
|
|
100
|
+
this.drive = google.drive({ version: 'v3', auth });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async validate(): Promise<ValidationResult> {
|
|
104
|
+
try {
|
|
105
|
+
await this.drive.files.list({ pageSize: 1 });
|
|
106
|
+
return { valid: true, info: this.credentials.email };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
109
|
+
if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
|
|
110
|
+
return { valid: false, error: 'refresh token expired, re-authenticate' };
|
|
111
|
+
}
|
|
112
|
+
return { valid: false, error: message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async list(options: GDriveListOptions = {}): Promise<GDriveFile[]> {
|
|
117
|
+
const { limit = 20, folderId, query, orderBy = 'modifiedTime desc', includeTrash = false } = options;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const queryParts: string[] = [];
|
|
121
|
+
|
|
122
|
+
if (!includeTrash) {
|
|
123
|
+
queryParts.push('trashed = false');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (folderId) {
|
|
127
|
+
queryParts.push(`'${folderId}' in parents`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (query) {
|
|
131
|
+
queryParts.push(query);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const q = queryParts.join(' and ') || undefined;
|
|
135
|
+
|
|
136
|
+
const response = await this.drive.files.list({
|
|
137
|
+
pageSize: Math.min(limit, 100),
|
|
138
|
+
q,
|
|
139
|
+
fields: 'files(id,name,mimeType,size,createdTime,modifiedTime,owners,parents,webViewLink,webContentLink,starred,trashed,shared,description)',
|
|
140
|
+
orderBy,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return (response.data.files || []).map(this.parseFile);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
this.throwApiError(err, 'list files');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async listFolders(options: GDriveFolderListOptions = {}): Promise<GDriveFile[]> {
|
|
150
|
+
const { limit = 20, parentId, query } = options;
|
|
151
|
+
|
|
152
|
+
const queryParts: string[] = [
|
|
153
|
+
"mimeType = 'application/vnd.google-apps.folder'",
|
|
154
|
+
'trashed = false',
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
if (parentId) {
|
|
158
|
+
queryParts.push(`'${parentId}' in parents`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (query) {
|
|
162
|
+
queryParts.push(query);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return this.list({
|
|
166
|
+
limit,
|
|
167
|
+
query: queryParts.join(' and '),
|
|
168
|
+
orderBy: 'name',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async get(fileIdOrUrl: string): Promise<GDriveFile> {
|
|
173
|
+
const fileId = this.extractFileId(fileIdOrUrl);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const response = await this.drive.files.get({
|
|
177
|
+
fileId,
|
|
178
|
+
fields: 'id,name,mimeType,size,createdTime,modifiedTime,owners,parents,webViewLink,webContentLink,starred,trashed,shared,description',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return this.parseFile(response.data);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.throwApiError(err, 'get file');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async search(options: GDriveSearchOptions): Promise<GDriveFile[]> {
|
|
188
|
+
const { query, mimeType, limit = 20, folderId } = options;
|
|
189
|
+
|
|
190
|
+
const queryParts: string[] = [
|
|
191
|
+
'trashed = false',
|
|
192
|
+
`fullText contains '${query.replace(/'/g, "\\'")}'`,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
if (mimeType) {
|
|
196
|
+
queryParts.push(`mimeType = '${mimeType}'`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (folderId) {
|
|
200
|
+
queryParts.push(`'${folderId}' in parents`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return this.list({
|
|
204
|
+
limit,
|
|
205
|
+
query: queryParts.join(' and '),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async download(options: GDriveDownloadOptions): Promise<GDriveDownloadResult> {
|
|
210
|
+
const { fileIdOrUrl, outputPath, exportFormat } = options;
|
|
211
|
+
const fileId = this.extractFileId(fileIdOrUrl);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const file = await this.get(fileId);
|
|
215
|
+
const isWorkspaceFile = file.mimeType.startsWith('application/vnd.google-apps.');
|
|
216
|
+
|
|
217
|
+
// Handle Google Workspace files
|
|
218
|
+
if (isWorkspaceFile) {
|
|
219
|
+
if (!exportFormat) {
|
|
220
|
+
const supportedFormats = this.getSupportedExportFormats(file.mimeType);
|
|
221
|
+
throw new CliError(
|
|
222
|
+
'INVALID_PARAMS',
|
|
223
|
+
`Cannot download Google ${this.getWorkspaceTypeName(file.mimeType)} directly`,
|
|
224
|
+
`Use --export with one of: ${supportedFormats.join(', ')}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const exportMimeType = this.getExportMimeType(file.mimeType, exportFormat);
|
|
229
|
+
if (!exportMimeType) {
|
|
230
|
+
const supportedFormats = this.getSupportedExportFormats(file.mimeType);
|
|
231
|
+
throw new CliError(
|
|
232
|
+
'INVALID_PARAMS',
|
|
233
|
+
`Cannot export Google ${this.getWorkspaceTypeName(file.mimeType)} to ${exportFormat}`,
|
|
234
|
+
`Supported formats: ${supportedFormats.join(', ')}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const response = await this.drive.files.export(
|
|
239
|
+
{ fileId, mimeType: exportMimeType },
|
|
240
|
+
{ responseType: 'arraybuffer' }
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const buffer = Buffer.from(response.data as ArrayBuffer);
|
|
244
|
+
await writeFile(outputPath, buffer);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
filename: file.name,
|
|
248
|
+
path: outputPath,
|
|
249
|
+
size: buffer.length,
|
|
250
|
+
mimeType: exportMimeType,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Handle regular files
|
|
255
|
+
if (exportFormat) {
|
|
256
|
+
throw new CliError(
|
|
257
|
+
'INVALID_PARAMS',
|
|
258
|
+
'Export format is only for Google Workspace files',
|
|
259
|
+
'Remove --export flag for regular files'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const response = await this.drive.files.get(
|
|
264
|
+
{ fileId, alt: 'media' },
|
|
265
|
+
{ responseType: 'arraybuffer' }
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const buffer = Buffer.from(response.data as ArrayBuffer);
|
|
269
|
+
await writeFile(outputPath, buffer);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
filename: file.name,
|
|
273
|
+
path: outputPath,
|
|
274
|
+
size: buffer.length,
|
|
275
|
+
mimeType: file.mimeType,
|
|
276
|
+
};
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof CliError) throw err;
|
|
279
|
+
this.throwApiError(err, 'download file');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async upload(options: GDriveUploadOptions): Promise<GDriveUploadResult> {
|
|
284
|
+
// Treat missing accessLevel as readonly for backward compatibility
|
|
285
|
+
if (!this.credentials.accessLevel || this.credentials.accessLevel === 'readonly') {
|
|
286
|
+
throw new CliError(
|
|
287
|
+
'PERMISSION_DENIED',
|
|
288
|
+
'This profile has read-only access',
|
|
289
|
+
'Create a new profile with full access: agentio gdrive profile add --full'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { filePath, name, folderId, mimeType, convert } = options;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const { extname } = await import('path');
|
|
297
|
+
const fileStats = await stat(filePath);
|
|
298
|
+
const content = await readFile(filePath);
|
|
299
|
+
const fileName = name || basename(filePath);
|
|
300
|
+
const ext = extname(filePath).toLowerCase();
|
|
301
|
+
|
|
302
|
+
// Determine source MIME type
|
|
303
|
+
const sourceMimeType = mimeType || EXT_TO_MIME[ext] || 'application/octet-stream';
|
|
304
|
+
|
|
305
|
+
// Check if conversion is requested
|
|
306
|
+
let targetMimeType: string | undefined;
|
|
307
|
+
if (convert) {
|
|
308
|
+
targetMimeType = CONVERTIBLE_MIME_TYPES[sourceMimeType];
|
|
309
|
+
if (!targetMimeType) {
|
|
310
|
+
throw new CliError(
|
|
311
|
+
'INVALID_PARAMS',
|
|
312
|
+
`Cannot convert ${ext || 'this file type'} to Google Workspace format`,
|
|
313
|
+
'Supported: docx, doc, odt, txt, html, rtf, xlsx, xls, ods, csv, tsv, pptx, ppt, odp'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const fileMetadata: { name: string; parents?: string[]; mimeType?: string } = {
|
|
319
|
+
name: convert ? fileName.replace(/\.[^.]+$/, '') : fileName, // Remove extension when converting
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (folderId) {
|
|
323
|
+
fileMetadata.parents = [folderId];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (targetMimeType) {
|
|
327
|
+
fileMetadata.mimeType = targetMimeType;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const response = await this.drive.files.create({
|
|
331
|
+
requestBody: fileMetadata,
|
|
332
|
+
media: {
|
|
333
|
+
mimeType: sourceMimeType,
|
|
334
|
+
body: content,
|
|
335
|
+
},
|
|
336
|
+
fields: 'id,name,mimeType,size,webViewLink',
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
id: response.data.id!,
|
|
341
|
+
name: response.data.name || fileName,
|
|
342
|
+
mimeType: response.data.mimeType || sourceMimeType,
|
|
343
|
+
size: fileStats.size,
|
|
344
|
+
webViewLink: response.data.webViewLink || undefined,
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err instanceof CliError) throw err;
|
|
348
|
+
this.throwApiError(err, 'upload file');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private extractFileId(fileIdOrUrl: string): string {
|
|
353
|
+
const patterns = [
|
|
354
|
+
/\/file\/d\/([a-zA-Z0-9_-]+)/,
|
|
355
|
+
/\/folders\/([a-zA-Z0-9_-]+)/,
|
|
356
|
+
/id=([a-zA-Z0-9_-]+)/,
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
for (const pattern of patterns) {
|
|
360
|
+
const match = fileIdOrUrl.match(pattern);
|
|
361
|
+
if (match) return match[1];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return fileIdOrUrl;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private createOAuthClient() {
|
|
368
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
369
|
+
GOOGLE_OAUTH_CONFIG.clientId,
|
|
370
|
+
GOOGLE_OAUTH_CONFIG.clientSecret
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
oauth2Client.setCredentials({
|
|
374
|
+
access_token: this.credentials.accessToken,
|
|
375
|
+
refresh_token: this.credentials.refreshToken,
|
|
376
|
+
expiry_date: this.credentials.expiryDate,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return oauth2Client;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private parseFile(file: drive_v3.Schema$File): GDriveFile {
|
|
383
|
+
return {
|
|
384
|
+
id: file.id!,
|
|
385
|
+
name: file.name || 'Untitled',
|
|
386
|
+
mimeType: file.mimeType || 'application/octet-stream',
|
|
387
|
+
size: file.size ? parseInt(file.size, 10) : undefined,
|
|
388
|
+
createdTime: file.createdTime || undefined,
|
|
389
|
+
modifiedTime: file.modifiedTime || undefined,
|
|
390
|
+
owners: file.owners?.map((o) => o.displayName || o.emailAddress || 'Unknown'),
|
|
391
|
+
parents: file.parents || undefined,
|
|
392
|
+
webViewLink: file.webViewLink || undefined,
|
|
393
|
+
webContentLink: file.webContentLink || undefined,
|
|
394
|
+
starred: file.starred || false,
|
|
395
|
+
trashed: file.trashed || false,
|
|
396
|
+
shared: file.shared || false,
|
|
397
|
+
description: file.description || undefined,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private getWorkspaceTypeName(mimeType: string): string {
|
|
402
|
+
const types: Record<string, string> = {
|
|
403
|
+
'application/vnd.google-apps.document': 'Doc',
|
|
404
|
+
'application/vnd.google-apps.spreadsheet': 'Sheet',
|
|
405
|
+
'application/vnd.google-apps.presentation': 'Slide',
|
|
406
|
+
'application/vnd.google-apps.drawing': 'Drawing',
|
|
407
|
+
'application/vnd.google-apps.form': 'Form',
|
|
408
|
+
};
|
|
409
|
+
return types[mimeType] || 'Workspace file';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private getExportMimeType(sourceMimeType: string, format: GDriveExportFormat): string | undefined {
|
|
413
|
+
const formatMap = EXPORT_MIME_TYPES[sourceMimeType];
|
|
414
|
+
return formatMap?.[format];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private getSupportedExportFormats(mimeType: string): string[] {
|
|
418
|
+
const formatMap = EXPORT_MIME_TYPES[mimeType];
|
|
419
|
+
if (!formatMap) return [];
|
|
420
|
+
return Object.entries(formatMap)
|
|
421
|
+
.filter(([, mime]) => mime !== undefined)
|
|
422
|
+
.map(([format]) => format);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private throwApiError(err: unknown, operation: string): never {
|
|
426
|
+
const code = this.getErrorCode(err);
|
|
427
|
+
const message = this.getErrorMessage(err);
|
|
428
|
+
throw new CliError(code, `Failed to ${operation}: ${message}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private getErrorCode(err: unknown): ErrorCode {
|
|
432
|
+
if (err && typeof err === 'object') {
|
|
433
|
+
const error = err as Record<string, unknown>;
|
|
434
|
+
const code = error.code || error.status;
|
|
435
|
+
if (typeof code === 'number') return httpStatusToErrorCode(code);
|
|
436
|
+
}
|
|
437
|
+
return 'API_ERROR';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private getErrorMessage(err: unknown): string {
|
|
441
|
+
if (err && typeof err === 'object') {
|
|
442
|
+
const error = err as Record<string, unknown>;
|
|
443
|
+
const code = error.code || error.status;
|
|
444
|
+
if (code === 401) return 'OAuth token expired or invalid';
|
|
445
|
+
if (code === 403) return 'Insufficient permissions to access this file';
|
|
446
|
+
if (code === 404) return 'File not found';
|
|
447
|
+
if (code === 429) return 'Rate limit exceeded, please try again later';
|
|
448
|
+
if (error.message && typeof error.message === 'string') return error.message;
|
|
449
|
+
}
|
|
450
|
+
return err instanceof Error ? err.message : String(err);
|
|
451
|
+
}
|
|
452
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface Config {
|
|
2
2
|
profiles: {
|
|
3
|
+
gdocs?: string[];
|
|
4
|
+
gdrive?: string[];
|
|
3
5
|
gmail?: string[];
|
|
4
6
|
gchat?: string[];
|
|
5
7
|
github?: string[];
|
|
@@ -12,4 +14,4 @@ export interface Config {
|
|
|
12
14
|
env?: Record<string, string>;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export type ServiceName = 'gmail' | 'gchat' | 'github' | 'jira' | 'slack' | 'telegram' | 'discourse' | 'sql';
|
|
17
|
+
export type ServiceName = 'gdocs' | 'gdrive' | 'gmail' | 'gchat' | 'github' | 'jira' | 'slack' | 'telegram' | 'discourse' | 'sql';
|