@plosson/agentio 0.3.2 → 0.4.0
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 +1 -1
- package/src/auth/oauth.ts +24 -2
- package/src/auth/token-manager.ts +10 -8
- package/src/commands/discourse.ts +3 -3
- package/src/commands/gchat.ts +4 -4
- 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 +16 -14
- package/src/commands/slack.ts +1 -1
- package/src/commands/sql.ts +3 -2
- 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/output.ts +109 -0
package/src/commands/gmail.ts
CHANGED
|
@@ -68,7 +68,7 @@ function findChromePath(): string | null {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
async function getGmailClient(profileName
|
|
71
|
+
async function getGmailClient(profileName?: string): Promise<{ client: GmailClient; profile: string }> {
|
|
72
72
|
const { tokens, profile } = await getValidTokens('gmail', profileName);
|
|
73
73
|
const auth = createGoogleAuth(tokens);
|
|
74
74
|
return { client: new GmailClient(auth), profile };
|
|
@@ -82,7 +82,7 @@ export function registerGmailCommands(program: Command): void {
|
|
|
82
82
|
gmail
|
|
83
83
|
.command('list')
|
|
84
84
|
.description('List messages')
|
|
85
|
-
.
|
|
85
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
86
86
|
.option('--limit <n>', 'Number of messages', '10')
|
|
87
87
|
.option('--query <query>', 'Gmail search query (see "gmail search --help" for syntax)')
|
|
88
88
|
.option('--label <label>', 'Filter by label (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
@@ -103,7 +103,7 @@ export function registerGmailCommands(program: Command): void {
|
|
|
103
103
|
gmail
|
|
104
104
|
.command('get <message-id>')
|
|
105
105
|
.description('Get a message')
|
|
106
|
-
.
|
|
106
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
107
107
|
.option('--format <format>', 'Body format: text, html, or raw', 'text')
|
|
108
108
|
.option('--body-only', 'Output only the message body')
|
|
109
109
|
.action(async (messageId: string, options) => {
|
|
@@ -124,7 +124,7 @@ export function registerGmailCommands(program: Command): void {
|
|
|
124
124
|
.command('search')
|
|
125
125
|
.description('Search messages using Gmail query syntax')
|
|
126
126
|
.requiredOption('--query <query>', 'Search query')
|
|
127
|
-
.
|
|
127
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
128
128
|
.option('--limit <n>', 'Max results', '10')
|
|
129
129
|
.addHelpText('after', `
|
|
130
130
|
Query Syntax Examples:
|
|
@@ -178,7 +178,7 @@ Query Syntax Examples:
|
|
|
178
178
|
gmail
|
|
179
179
|
.command('send')
|
|
180
180
|
.description('Send an email')
|
|
181
|
-
.
|
|
181
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
182
182
|
.requiredOption('--to <email>', 'Recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
183
183
|
.option('--cc <email>', 'CC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
184
184
|
.option('--bcc <email>', 'BCC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
@@ -246,7 +246,7 @@ Query Syntax Examples:
|
|
|
246
246
|
gmail
|
|
247
247
|
.command('reply')
|
|
248
248
|
.description('Reply to a thread')
|
|
249
|
-
.
|
|
249
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
250
250
|
.requiredOption('--thread-id <id>', 'Thread ID')
|
|
251
251
|
.option('--body <body>', 'Reply body (or pipe via stdin)')
|
|
252
252
|
.option('--html', 'Treat body as HTML')
|
|
@@ -277,7 +277,7 @@ Query Syntax Examples:
|
|
|
277
277
|
gmail
|
|
278
278
|
.command('archive <message-id...>')
|
|
279
279
|
.description('Archive one or more messages')
|
|
280
|
-
.
|
|
280
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
281
281
|
.action(async (messageIds: string[], options) => {
|
|
282
282
|
try {
|
|
283
283
|
const { client } = await getGmailClient(options.profile);
|
|
@@ -293,7 +293,7 @@ Query Syntax Examples:
|
|
|
293
293
|
gmail
|
|
294
294
|
.command('mark <message-id...>')
|
|
295
295
|
.description('Mark one or more messages as read or unread')
|
|
296
|
-
.
|
|
296
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
297
297
|
.option('--read', 'Mark as read')
|
|
298
298
|
.option('--unread', 'Mark as unread')
|
|
299
299
|
.action(async (messageIds: string[], options) => {
|
|
@@ -318,7 +318,7 @@ Query Syntax Examples:
|
|
|
318
318
|
gmail
|
|
319
319
|
.command('attachment <message-id>')
|
|
320
320
|
.description('Download attachments from a message')
|
|
321
|
-
.
|
|
321
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
322
322
|
.option('--name <filename>', 'Download specific attachment by filename (downloads all if not specified)')
|
|
323
323
|
.option('--output <dir>', 'Output directory', '.')
|
|
324
324
|
.action(async (messageId: string, options) => {
|
|
@@ -361,7 +361,7 @@ Query Syntax Examples:
|
|
|
361
361
|
gmail
|
|
362
362
|
.command('export <message-id>')
|
|
363
363
|
.description('Export a message as PDF')
|
|
364
|
-
.
|
|
364
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
365
365
|
.option('--output <path>', 'Output file path', 'message.pdf')
|
|
366
366
|
.action(async (messageId: string, options) => {
|
|
367
367
|
try {
|
package/src/commands/jira.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { setCredentials, getCredentials } from '../auth/token-store';
|
|
3
|
-
import { setProfile,
|
|
3
|
+
import { setProfile, resolveProfile } from '../config/config-manager';
|
|
4
4
|
import { createProfileCommands } from '../utils/profile-commands';
|
|
5
5
|
import { performJiraOAuthFlow, refreshJiraToken, type AtlassianSite } from '../auth/jira-oauth';
|
|
6
6
|
import { JiraClient } from '../services/jira/client';
|
|
@@ -46,15 +46,17 @@ async function ensureValidToken(credentials: JiraCredentials, profile: string):
|
|
|
46
46
|
return credentials;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async function getJiraClient(profileName
|
|
50
|
-
const profile = await
|
|
49
|
+
async function getJiraClient(profileName?: string): Promise<{ client: JiraClient; profile: string }> {
|
|
50
|
+
const { profile, error } = await resolveProfile('jira', profileName);
|
|
51
51
|
|
|
52
52
|
if (!profile) {
|
|
53
|
-
|
|
54
|
-
'PROFILE_NOT_FOUND',
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
if (error === 'none') {
|
|
54
|
+
throw new CliError('PROFILE_NOT_FOUND', 'No jira profile configured', 'Run: agentio jira profile add');
|
|
55
|
+
}
|
|
56
|
+
if (error === 'multiple') {
|
|
57
|
+
throw new CliError('PROFILE_NOT_FOUND', 'Multiple jira profiles exist', 'Specify --profile <name>');
|
|
58
|
+
}
|
|
59
|
+
throw new CliError('PROFILE_NOT_FOUND', `Profile "${profileName}" not found for jira`, 'Run: agentio jira profile add');
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
let credentials = await getCredentials<JiraCredentials>('jira', profile);
|
|
@@ -85,7 +87,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
85
87
|
jira
|
|
86
88
|
.command('projects')
|
|
87
89
|
.description('List JIRA projects')
|
|
88
|
-
.
|
|
90
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
89
91
|
.option('--limit <number>', 'Maximum number of projects', '50')
|
|
90
92
|
.action(async (options) => {
|
|
91
93
|
try {
|
|
@@ -103,7 +105,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
103
105
|
jira
|
|
104
106
|
.command('search')
|
|
105
107
|
.description('Search JIRA issues')
|
|
106
|
-
.
|
|
108
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
107
109
|
.option('--jql <query>', 'JQL query')
|
|
108
110
|
.option('--project <key>', 'Project key')
|
|
109
111
|
.option('--status <status>', 'Issue status')
|
|
@@ -130,7 +132,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
130
132
|
.command('get')
|
|
131
133
|
.description('Get JIRA issue details')
|
|
132
134
|
.argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
|
|
133
|
-
.
|
|
135
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
134
136
|
.action(async (issueKey: string, options) => {
|
|
135
137
|
try {
|
|
136
138
|
const { client } = await getJiraClient(options.profile);
|
|
@@ -147,7 +149,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
147
149
|
.description('Add a comment to an issue')
|
|
148
150
|
.argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
|
|
149
151
|
.argument('[body]', 'Comment body (or pipe via stdin)')
|
|
150
|
-
.
|
|
152
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
151
153
|
.action(async (issueKey: string, body: string | undefined, options) => {
|
|
152
154
|
try {
|
|
153
155
|
let text = body;
|
|
@@ -173,7 +175,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
173
175
|
.command('transitions')
|
|
174
176
|
.description('List available transitions for an issue')
|
|
175
177
|
.argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
|
|
176
|
-
.
|
|
178
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
177
179
|
.action(async (issueKey: string, options) => {
|
|
178
180
|
try {
|
|
179
181
|
const { client } = await getJiraClient(options.profile);
|
|
@@ -190,7 +192,7 @@ export function registerJiraCommands(program: Command): void {
|
|
|
190
192
|
.description('Transition an issue to a new status')
|
|
191
193
|
.argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
|
|
192
194
|
.argument('<transition-id>', 'Transition ID (use "transitions" command to see available)')
|
|
193
|
-
.
|
|
195
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
194
196
|
.action(async (issueKey: string, transitionId: string, options) => {
|
|
195
197
|
try {
|
|
196
198
|
const { client } = await getJiraClient(options.profile);
|
package/src/commands/slack.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function registerSlackCommands(program: Command): void {
|
|
|
23
23
|
slack
|
|
24
24
|
.command('send')
|
|
25
25
|
.description('Send a message to Slack')
|
|
26
|
-
.
|
|
26
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
27
27
|
.option('--json [file]', 'Send Block Kit message from JSON file (or stdin if no file specified)')
|
|
28
28
|
.argument('[message]', 'Message text (or pipe via stdin)')
|
|
29
29
|
.action(async (message: string | undefined, options) => {
|
package/src/commands/sql.ts
CHANGED
|
@@ -16,9 +16,10 @@ const getSqlClient = createClientGetter<SqlCredentials, SqlClient>({
|
|
|
16
16
|
function extractDisplayName(url: string): string {
|
|
17
17
|
try {
|
|
18
18
|
const parsed = new URL(url);
|
|
19
|
+
const username = parsed.username ? decodeURIComponent(parsed.username) : '';
|
|
19
20
|
const host = parsed.hostname || 'localhost';
|
|
20
21
|
const db = parsed.pathname.replace(/^\//, '') || 'database';
|
|
21
|
-
return `${host}/${db}`;
|
|
22
|
+
return username ? `${username}@${host}/${db}` : `${host}/${db}`;
|
|
22
23
|
} catch {
|
|
23
24
|
return url.substring(0, 30);
|
|
24
25
|
}
|
|
@@ -32,7 +33,7 @@ export function registerSqlCommands(program: Command): void {
|
|
|
32
33
|
sql
|
|
33
34
|
.command('query')
|
|
34
35
|
.description('Execute a SQL query')
|
|
35
|
-
.
|
|
36
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
36
37
|
.option('--limit <n>', 'Maximum rows to return', '100')
|
|
37
38
|
.argument('[query]', 'SQL query (or pipe via stdin)')
|
|
38
39
|
.action(async (query: string | undefined, options) => {
|
package/src/commands/status.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { createGoogleAuth } from '../auth/token-manager';
|
|
|
5
5
|
import { refreshJiraToken } from '../auth/jira-oauth';
|
|
6
6
|
import { TelegramClient } from '../services/telegram/client';
|
|
7
7
|
import { GmailClient } from '../services/gmail/client';
|
|
8
|
+
import { GDocsClient } from '../services/gdocs/client';
|
|
9
|
+
import { GDriveClient } from '../services/gdrive/client';
|
|
8
10
|
import { GitHubClient } from '../services/github/client';
|
|
9
11
|
import { JiraClient } from '../services/jira/client';
|
|
10
12
|
import { GChatClient } from '../services/gchat/client';
|
|
@@ -17,6 +19,8 @@ import type { OAuthTokens } from '../types/tokens';
|
|
|
17
19
|
import type { TelegramCredentials } from '../types/telegram';
|
|
18
20
|
import type { GitHubCredentials } from '../types/github';
|
|
19
21
|
import type { JiraCredentials } from '../types/jira';
|
|
22
|
+
import type { GDocsCredentials } from '../types/gdocs';
|
|
23
|
+
import type { GDriveCredentials } from '../types/gdrive';
|
|
20
24
|
import type { GChatCredentials } from '../types/gchat';
|
|
21
25
|
import type { SlackCredentials } from '../types/slack';
|
|
22
26
|
import type { DiscourseCredentials } from '../types/discourse';
|
|
@@ -46,6 +50,16 @@ async function createServiceClient(
|
|
|
46
50
|
return new GmailClient(auth);
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
case 'gdocs': {
|
|
54
|
+
const creds = credentials as GDocsCredentials;
|
|
55
|
+
return new GDocsClient(creds);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'gdrive': {
|
|
59
|
+
const creds = credentials as GDriveCredentials;
|
|
60
|
+
return new GDriveClient(creds);
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
case 'telegram': {
|
|
50
64
|
const creds = credentials as TelegramCredentials;
|
|
51
65
|
return new TelegramClient(creds.botToken, creds.channelId);
|
package/src/commands/telegram.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
21
21
|
telegram
|
|
22
22
|
.command('send')
|
|
23
23
|
.description('Send a message to the channel')
|
|
24
|
-
.
|
|
24
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
25
25
|
.option('--parse-mode <mode>', 'Message format: html or markdown')
|
|
26
26
|
.option('--silent', 'Send without notification')
|
|
27
27
|
.argument('[message]', 'Message text (or pipe via stdin)')
|
|
@@ -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
|
+
}
|