@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/auth/oauth.ts CHANGED
@@ -17,13 +17,35 @@ const GCHAT_SCOPES = [
17
17
  'https://www.googleapis.com/auth/userinfo.email', // get user email for profile naming
18
18
  ];
19
19
 
20
- const SCOPES: Record<'gmail' | 'gchat', string[]> = {
20
+ const GDOCS_SCOPES = [
21
+ 'https://www.googleapis.com/auth/documents', // read/write docs
22
+ 'https://www.googleapis.com/auth/drive.file', // create/access files created by this app
23
+ 'https://www.googleapis.com/auth/drive.readonly', // read all drive files (list, metadata, export)
24
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
25
+ ];
26
+
27
+ const GDRIVE_READONLY_SCOPES = [
28
+ 'https://www.googleapis.com/auth/drive.readonly', // read all drive files (list, metadata, download)
29
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
30
+ ];
31
+
32
+ const GDRIVE_FULL_SCOPES = [
33
+ 'https://www.googleapis.com/auth/drive', // full access to all drive files
34
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
35
+ ];
36
+
37
+ const SCOPES: Record<'gmail' | 'gchat' | 'gdocs' | 'gdrive-readonly' | 'gdrive-full', string[]> = {
21
38
  gmail: GMAIL_SCOPES,
22
39
  gchat: GCHAT_SCOPES,
40
+ gdocs: GDOCS_SCOPES,
41
+ 'gdrive-readonly': GDRIVE_READONLY_SCOPES,
42
+ 'gdrive-full': GDRIVE_FULL_SCOPES,
23
43
  };
24
44
 
45
+ export type OAuthService = 'gmail' | 'gchat' | 'gdocs' | 'gdrive-readonly' | 'gdrive-full';
46
+
25
47
  export async function performOAuthFlow(
26
- service: 'gmail' | 'gchat'
48
+ service: OAuthService
27
49
  ): Promise<OAuthTokens> {
28
50
  const port = await findAvailablePort();
29
51
  const redirectUri = `http://localhost:${port}/callback`;
@@ -1,6 +1,6 @@
1
1
  import { google } from 'googleapis';
2
2
  import { getCredentials, setCredentials } from './token-store';
3
- import { getProfile } from '../config/config-manager';
3
+ import { resolveProfile } from '../config/config-manager';
4
4
  import { GOOGLE_OAUTH_CONFIG } from '../config/credentials';
5
5
  import { CliError } from '../utils/errors';
6
6
  import type { ServiceName } from '../types/config';
@@ -10,16 +10,18 @@ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
10
10
 
11
11
  export async function getValidTokens(
12
12
  service: ServiceName,
13
- profileName: string
13
+ profileName?: string
14
14
  ): Promise<{ tokens: OAuthTokens; profile: string }> {
15
- const profile = await getProfile(service, profileName);
15
+ const { profile, error } = await resolveProfile(service, profileName);
16
16
 
17
17
  if (!profile) {
18
- throw new CliError(
19
- 'PROFILE_NOT_FOUND',
20
- `Profile "${profileName}" not found for ${service}`,
21
- `Run: agentio ${service} profile add`
22
- );
18
+ if (error === 'none') {
19
+ throw new CliError('PROFILE_NOT_FOUND', `No ${service} profile configured`, `Run: agentio ${service} profile add`);
20
+ }
21
+ if (error === 'multiple') {
22
+ throw new CliError('PROFILE_NOT_FOUND', `Multiple ${service} profiles exist`, 'Specify --profile <name>');
23
+ }
24
+ throw new CliError('PROFILE_NOT_FOUND', `Profile "${profileName}" not found for ${service}`, `Run: agentio ${service} profile add`);
23
25
  }
24
26
 
25
27
  const tokens = await getCredentials<OAuthTokens>(service, profile);
@@ -25,7 +25,7 @@ export function registerDiscourseCommands(program: Command): void {
25
25
  discourse
26
26
  .command('list')
27
27
  .description('List latest topics')
28
- .requiredOption('--profile <name>', 'Profile name')
28
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
29
29
  .option('--category <slug>', 'Filter by category slug or name')
30
30
  .option('--page <number>', 'Page number (0-indexed)', '0')
31
31
  .action(async (options) => {
@@ -46,7 +46,7 @@ export function registerDiscourseCommands(program: Command): void {
46
46
  .command('get')
47
47
  .description('Get a topic with its posts')
48
48
  .argument('<topic-id>', 'Topic ID')
49
- .requiredOption('--profile <name>', 'Profile name')
49
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
50
50
  .action(async (topicId: string, options) => {
51
51
  try {
52
52
  const id = parseInt(topicId, 10);
@@ -66,7 +66,7 @@ export function registerDiscourseCommands(program: Command): void {
66
66
  discourse
67
67
  .command('categories')
68
68
  .description('List all categories')
69
- .requiredOption('--profile <name>', 'Profile name')
69
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
70
70
  .action(async (options) => {
71
71
  try {
72
72
  const { client } = await getDiscourseClient(options.profile);
@@ -26,7 +26,7 @@ export function registerGChatCommands(program: Command): void {
26
26
  gchat
27
27
  .command('send')
28
28
  .description('Send a message to Google Chat')
29
- .requiredOption('--profile <name>', 'Profile name')
29
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
30
30
  .option('--space <id>', 'Space ID (required for OAuth profiles)')
31
31
  .option('--thread <id>', 'Thread ID (optional)')
32
32
  .option('--json [file]', 'Send rich message from JSON file (or stdin if no file specified)')
@@ -111,7 +111,7 @@ export function registerGChatCommands(program: Command): void {
111
111
  gchat
112
112
  .command('list')
113
113
  .description('List messages from a Google Chat space (OAuth profiles only)')
114
- .requiredOption('--profile <name>', 'Profile name')
114
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
115
115
  .requiredOption('--space <id>', 'Space ID')
116
116
  .option('--limit <n>', 'Number of messages', '10')
117
117
  .option('--thread <id>', 'Filter by thread ID')
@@ -135,7 +135,7 @@ export function registerGChatCommands(program: Command): void {
135
135
  gchat
136
136
  .command('get <message-id>')
137
137
  .description('Get a message from a Google Chat space (OAuth profiles only)')
138
- .requiredOption('--profile <name>', 'Profile name')
138
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
139
139
  .requiredOption('--space <id>', 'Space ID')
140
140
  .action(async (messageId: string, options) => {
141
141
  try {
@@ -154,7 +154,7 @@ export function registerGChatCommands(program: Command): void {
154
154
  gchat
155
155
  .command('spaces')
156
156
  .description('List available Google Chat spaces (OAuth profiles only)')
157
- .requiredOption('--profile <name>', 'Profile name')
157
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
158
158
  .option('--filter <text>', 'Filter spaces by name (case-insensitive)')
159
159
  .action(async (options) => {
160
160
  try {
@@ -0,0 +1,186 @@
1
+ import { Command } from 'commander';
2
+ import { writeFile } from 'fs/promises';
3
+ import { google } from 'googleapis';
4
+ import { createGoogleAuth } from '../auth/token-manager';
5
+ import { setCredentials } from '../auth/token-store';
6
+ import { setProfile } from '../config/config-manager';
7
+ import { createProfileCommands } from '../utils/profile-commands';
8
+ import { createClientGetter } from '../utils/client-factory';
9
+ import { performOAuthFlow } from '../auth/oauth';
10
+ import { GDocsClient } from '../services/gdocs/client';
11
+ import { printGDocsList, printGDocCreated, raw } from '../utils/output';
12
+ import { CliError, handleError } from '../utils/errors';
13
+ import { readStdin } from '../utils/stdin';
14
+ import type { GDocsCredentials } from '../types/gdocs';
15
+
16
+ const getGDocsClient = createClientGetter<GDocsCredentials, GDocsClient>({
17
+ service: 'gdocs',
18
+ createClient: (credentials) => new GDocsClient(credentials),
19
+ });
20
+
21
+ export function registerGDocsCommands(program: Command): void {
22
+ const gdocs = program
23
+ .command('gdocs')
24
+ .description('Google Docs operations');
25
+
26
+ gdocs
27
+ .command('get <doc-id-or-url>')
28
+ .description('Export a document')
29
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
30
+ .option('--format <format>', 'Export format: markdown or docx', 'markdown')
31
+ .option('--output <file>', 'Output file path (required for docx, optional for markdown)')
32
+ .action(async (docIdOrUrl: string, options) => {
33
+ try {
34
+ const { client } = await getGDocsClient(options.profile);
35
+ const format = options.format.toLowerCase();
36
+
37
+ if (format !== 'markdown' && format !== 'docx') {
38
+ throw new CliError('INVALID_PARAMS', `Unknown format: ${format}`, 'Use --format markdown or --format docx');
39
+ }
40
+
41
+ if (format === 'docx' && !options.output) {
42
+ throw new CliError('INVALID_PARAMS', 'Output file required for docx format', 'Use --output <file.docx>');
43
+ }
44
+
45
+ const content = format === 'docx'
46
+ ? await client.getAsDocx(docIdOrUrl)
47
+ : await client.getAsMarkdown(docIdOrUrl);
48
+
49
+ if (options.output) {
50
+ await writeFile(options.output, content);
51
+ console.log(`Exported to ${options.output}`);
52
+ } else {
53
+ raw(content as string);
54
+ }
55
+ } catch (error) {
56
+ handleError(error);
57
+ }
58
+ });
59
+
60
+ gdocs
61
+ .command('create')
62
+ .description('Create a new document from Markdown')
63
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
64
+ .requiredOption('--title <title>', 'Document title')
65
+ .option('--content <text>', 'Markdown content (or pipe via stdin)')
66
+ .option('--folder <folder-id>', 'Folder ID to create the document in')
67
+ .action(async (options) => {
68
+ try {
69
+ let content = options.content;
70
+
71
+ if (!content) {
72
+ content = await readStdin();
73
+ }
74
+
75
+ if (!content) {
76
+ throw new CliError('INVALID_PARAMS', 'No content provided', 'Provide --content or pipe markdown via stdin');
77
+ }
78
+
79
+ const { client } = await getGDocsClient(options.profile);
80
+ const result = await client.create(options.title, content, options.folder);
81
+
82
+ printGDocCreated(result);
83
+ } catch (error) {
84
+ handleError(error);
85
+ }
86
+ });
87
+
88
+ gdocs
89
+ .command('list')
90
+ .description('List recent documents')
91
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
92
+ .option('--limit <n>', 'Number of documents', '10')
93
+ .option('--query <query>', 'Drive search query filter')
94
+ .addHelpText('after', `
95
+ Query Syntax Examples:
96
+
97
+ Name search:
98
+ --query "name contains 'project'" Documents with "project" in name
99
+ --query "name = 'Meeting Notes'" Exact name match
100
+
101
+ Ownership:
102
+ --query "'me' in owners" Documents you own
103
+ --query "'user@example.com' in owners" Documents owned by specific user
104
+
105
+ Date filters:
106
+ --query "modifiedTime > '2024-01-01'" Modified after date
107
+ --query "createdTime > '2024-01-01'" Created after date
108
+
109
+ Starred/Trashed:
110
+ --query "starred = true" Starred documents
111
+ --query "trashed = false" Not in trash (default)
112
+
113
+ Combined:
114
+ --query "name contains 'report' and modifiedTime > '2024-01-01'"
115
+ `)
116
+ .action(async (options) => {
117
+ try {
118
+ const { client } = await getGDocsClient(options.profile);
119
+ const docs = await client.list({
120
+ limit: parseInt(options.limit, 10),
121
+ query: options.query,
122
+ });
123
+ printGDocsList(docs);
124
+ } catch (error) {
125
+ handleError(error);
126
+ }
127
+ });
128
+
129
+ // Profile management
130
+ const profile = createProfileCommands<GDocsCredentials>(gdocs, {
131
+ service: 'gdocs',
132
+ displayName: 'Google Docs',
133
+ getExtraInfo: (credentials) => credentials?.email ? ` - ${credentials.email}` : '',
134
+ });
135
+
136
+ profile
137
+ .command('add')
138
+ .description('Add a new Google Docs profile')
139
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
140
+ .action(async (options) => {
141
+ try {
142
+ console.error('Starting OAuth flow for Google Docs...\n');
143
+
144
+ const tokens = await performOAuthFlow('gdocs');
145
+ const auth = createGoogleAuth(tokens);
146
+
147
+ // Fetch user email for profile naming
148
+ let userEmail: string;
149
+ try {
150
+ const oauth2 = google.oauth2({ version: 'v2', auth });
151
+ const userInfo = await oauth2.userinfo.get();
152
+ userEmail = userInfo.data.email || '';
153
+ if (!userEmail) {
154
+ throw new Error('No email returned');
155
+ }
156
+ } catch (error) {
157
+ const errorMessage = error instanceof Error ? error.message : String(error);
158
+ throw new CliError(
159
+ 'AUTH_FAILED',
160
+ `Failed to fetch user email: ${errorMessage}`,
161
+ 'Ensure the account has an email address'
162
+ );
163
+ }
164
+
165
+ const profileName = options.profile || userEmail;
166
+
167
+ const credentials: GDocsCredentials = {
168
+ accessToken: tokens.access_token,
169
+ refreshToken: tokens.refresh_token,
170
+ expiryDate: tokens.expiry_date,
171
+ tokenType: tokens.token_type,
172
+ scope: tokens.scope,
173
+ email: userEmail,
174
+ };
175
+
176
+ await setProfile('gdocs', profileName);
177
+ await setCredentials('gdocs', profileName, credentials);
178
+
179
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
180
+ console.log(` Email: ${userEmail}`);
181
+ console.log(` Test with: agentio gdocs list --profile ${profileName}`);
182
+ } catch (error) {
183
+ handleError(error);
184
+ }
185
+ });
186
+ }
@@ -0,0 +1,285 @@
1
+ import { Command } from 'commander';
2
+ import { google } from 'googleapis';
3
+ import { createGoogleAuth } from '../auth/token-manager';
4
+ import { setCredentials } from '../auth/token-store';
5
+ import { setProfile } from '../config/config-manager';
6
+ import { createProfileCommands } from '../utils/profile-commands';
7
+ import { createClientGetter } from '../utils/client-factory';
8
+ import { performOAuthFlow } from '../auth/oauth';
9
+ import { GDriveClient } from '../services/gdrive/client';
10
+ import { printGDriveFileList, printGDriveFile, printGDriveDownloaded, printGDriveUploaded } from '../utils/output';
11
+ import { CliError, handleError } from '../utils/errors';
12
+ import { prompt } from '../utils/stdin';
13
+ import type { GDriveCredentials, GDriveAccessLevel } from '../types/gdrive';
14
+
15
+ const getGDriveClient = createClientGetter<GDriveCredentials, GDriveClient>({
16
+ service: 'gdrive',
17
+ createClient: (credentials) => new GDriveClient(credentials),
18
+ });
19
+
20
+ export function registerGDriveCommands(program: Command): void {
21
+ const gdrive = program
22
+ .command('gdrive')
23
+ .description('Google Drive operations');
24
+
25
+ gdrive
26
+ .command('list')
27
+ .description('List files')
28
+ .option('--profile <name>', 'Profile name')
29
+ .option('--limit <n>', 'Number of files', '20')
30
+ .option('--folder <id>', 'Folder ID to list (use "root" for root folder)')
31
+ .option('--query <query>', 'Drive API query filter')
32
+ .option('--order <field>', 'Order by field', 'modifiedTime desc')
33
+ .option('--trash', 'Include trashed files')
34
+ .addHelpText('after', `
35
+ Query Syntax Examples:
36
+
37
+ Name search:
38
+ --query "name contains 'report'" Files with "report" in name
39
+ --query "name = 'Budget 2024.xlsx'" Exact name match
40
+
41
+ File type:
42
+ --query "mimeType = 'application/pdf'"
43
+ --query "mimeType contains 'image/'"
44
+
45
+ Ownership:
46
+ --query "'me' in owners" Files you own
47
+ --query "not 'me' in owners" Shared with you
48
+
49
+ Date filters:
50
+ --query "modifiedTime > '2024-01-01'"
51
+ --query "createdTime > '2024-01-01'"
52
+
53
+ Properties:
54
+ --query "starred = true" Starred files
55
+ --query "shared = true" Shared files
56
+
57
+ Combined:
58
+ --query "name contains 'report' and modifiedTime > '2024-01-01'"
59
+ `)
60
+ .action(async (options) => {
61
+ try {
62
+ const { client } = await getGDriveClient(options.profile);
63
+ const files = await client.list({
64
+ limit: parseInt(options.limit, 10),
65
+ folderId: options.folder,
66
+ query: options.query,
67
+ orderBy: options.order,
68
+ includeTrash: options.trash,
69
+ });
70
+ printGDriveFileList(files);
71
+ } catch (error) {
72
+ handleError(error);
73
+ }
74
+ });
75
+
76
+ gdrive
77
+ .command('folders')
78
+ .description('List folders')
79
+ .option('--profile <name>', 'Profile name')
80
+ .option('--limit <n>', 'Number of folders', '20')
81
+ .option('--parent <id>', 'Parent folder ID (use "root" for root folder)')
82
+ .option('--query <query>', 'Additional query filter')
83
+ .action(async (options) => {
84
+ try {
85
+ const { client } = await getGDriveClient(options.profile);
86
+ const folders = await client.listFolders({
87
+ limit: parseInt(options.limit, 10),
88
+ parentId: options.parent,
89
+ query: options.query,
90
+ });
91
+ printGDriveFileList(folders, 'Folders');
92
+ } catch (error) {
93
+ handleError(error);
94
+ }
95
+ });
96
+
97
+ gdrive
98
+ .command('get <file-id-or-url>')
99
+ .description('Get file metadata')
100
+ .option('--profile <name>', 'Profile name')
101
+ .action(async (fileIdOrUrl: string, options) => {
102
+ try {
103
+ const { client } = await getGDriveClient(options.profile);
104
+ const file = await client.get(fileIdOrUrl);
105
+ printGDriveFile(file);
106
+ } catch (error) {
107
+ handleError(error);
108
+ }
109
+ });
110
+
111
+ gdrive
112
+ .command('search')
113
+ .description('Search for files')
114
+ .requiredOption('--query <text>', 'Search text (searches name and content)')
115
+ .option('--profile <name>', 'Profile name')
116
+ .option('--limit <n>', 'Number of results', '20')
117
+ .option('--type <mime>', 'Filter by MIME type')
118
+ .option('--folder <id>', 'Search within folder')
119
+ .action(async (options) => {
120
+ try {
121
+ const { client } = await getGDriveClient(options.profile);
122
+ const files = await client.search({
123
+ query: options.query,
124
+ mimeType: options.type,
125
+ limit: parseInt(options.limit, 10),
126
+ folderId: options.folder,
127
+ });
128
+ printGDriveFileList(files, 'Search Results');
129
+ } catch (error) {
130
+ handleError(error);
131
+ }
132
+ });
133
+
134
+ gdrive
135
+ .command('download <file-id-or-url>')
136
+ .description('Download a file (or export Google Workspace files)')
137
+ .option('--profile <name>', 'Profile name')
138
+ .requiredOption('--output <path>', 'Output file path')
139
+ .option('--export <format>', 'Export format for Google Workspace files (pdf, docx, xlsx, csv, pptx, txt, etc.)')
140
+ .addHelpText('after', `
141
+ Export Formats:
142
+
143
+ Google Docs: pdf, docx, odt, txt, html, rtf
144
+ Google Sheets: xlsx, csv, pdf, ods, tsv
145
+ Google Slides: pptx, pdf, odp, txt
146
+ Google Drawing: pdf, png, jpeg, svg
147
+
148
+ Examples:
149
+ agentio gdrive download <doc-id> --output report.pdf --export pdf
150
+ agentio gdrive download <sheet-id> --output data.csv --export csv
151
+ `)
152
+ .action(async (fileIdOrUrl: string, options) => {
153
+ try {
154
+ const { client } = await getGDriveClient(options.profile);
155
+ const result = await client.download({
156
+ fileIdOrUrl,
157
+ outputPath: options.output,
158
+ exportFormat: options.export,
159
+ });
160
+ printGDriveDownloaded(result);
161
+ } catch (error) {
162
+ handleError(error);
163
+ }
164
+ });
165
+
166
+ gdrive
167
+ .command('put <file-path>')
168
+ .description('Upload a file to Google Drive')
169
+ .option('--profile <name>', 'Profile name')
170
+ .option('--name <name>', 'Name for the file in Drive (defaults to local filename)')
171
+ .option('--folder <id>', 'Folder ID to upload to')
172
+ .option('--type <mime>', 'MIME type (auto-detected if not specified)')
173
+ .option('--convert', 'Convert to Google Workspace format (Doc, Sheet, or Slides)')
174
+ .addHelpText('after', `
175
+ Conversion:
176
+
177
+ With --convert, supported files are converted to Google Workspace format:
178
+ docx, doc, odt, txt, html, rtf → Google Doc
179
+ xlsx, xls, ods, csv, tsv → Google Sheet
180
+ pptx, ppt, odp → Google Slides
181
+
182
+ Examples:
183
+ agentio gdrive put report.docx --convert # Creates Google Doc
184
+ agentio gdrive put data.xlsx --convert # Creates Google Sheet
185
+ agentio gdrive put slides.pptx --convert # Creates Google Slides
186
+ `)
187
+ .action(async (filePath: string, options) => {
188
+ try {
189
+ const { client } = await getGDriveClient(options.profile);
190
+ const result = await client.upload({
191
+ filePath,
192
+ name: options.name,
193
+ folderId: options.folder,
194
+ mimeType: options.type,
195
+ convert: options.convert,
196
+ });
197
+ printGDriveUploaded(result);
198
+ } catch (error) {
199
+ handleError(error);
200
+ }
201
+ });
202
+
203
+ // Profile management
204
+ const profile = createProfileCommands<GDriveCredentials>(gdrive, {
205
+ service: 'gdrive',
206
+ displayName: 'Google Drive',
207
+ getExtraInfo: (credentials) => {
208
+ if (!credentials) return '';
209
+ const access = credentials.accessLevel === 'full' ? 'full' : 'read-only';
210
+ return ` - ${credentials.email} (${access})`;
211
+ },
212
+ });
213
+
214
+ profile
215
+ .command('add')
216
+ .description('Add a new Google Drive profile')
217
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
218
+ .option('--readonly', 'Create a read-only profile (skip access level prompt)')
219
+ .option('--full', 'Create a full access profile (skip access level prompt)')
220
+ .action(async (options) => {
221
+ try {
222
+ console.error('Google Drive Setup\n');
223
+
224
+ let accessLevel: GDriveAccessLevel;
225
+
226
+ if (options.readonly) {
227
+ accessLevel = 'readonly';
228
+ } else if (options.full) {
229
+ accessLevel = 'full';
230
+ } else {
231
+ console.error('Access level options:');
232
+ console.error(' 1. Read-only - List, search, download files');
233
+ console.error(' 2. Full - Read-only + upload, create folders, modify files\n');
234
+
235
+ const choice = await prompt('? Select access level (1 or 2): ');
236
+ accessLevel = choice.trim() === '2' ? 'full' : 'readonly';
237
+ }
238
+
239
+ const oauthService = accessLevel === 'full' ? 'gdrive-full' : 'gdrive-readonly';
240
+ console.error(`\nStarting OAuth flow (${accessLevel} access)...\n`);
241
+
242
+ const tokens = await performOAuthFlow(oauthService);
243
+ const auth = createGoogleAuth(tokens);
244
+
245
+ let userEmail: string;
246
+ try {
247
+ const oauth2 = google.oauth2({ version: 'v2', auth });
248
+ const userInfo = await oauth2.userinfo.get();
249
+ userEmail = userInfo.data.email || '';
250
+ if (!userEmail) {
251
+ throw new Error('No email returned');
252
+ }
253
+ } catch (error) {
254
+ const errorMessage = error instanceof Error ? error.message : String(error);
255
+ throw new CliError(
256
+ 'AUTH_FAILED',
257
+ `Failed to fetch user email: ${errorMessage}`,
258
+ 'Ensure the account has an email address'
259
+ );
260
+ }
261
+
262
+ const profileName = options.profile || userEmail;
263
+
264
+ const credentials: GDriveCredentials = {
265
+ accessToken: tokens.access_token,
266
+ refreshToken: tokens.refresh_token,
267
+ expiryDate: tokens.expiry_date,
268
+ tokenType: tokens.token_type,
269
+ scope: tokens.scope,
270
+ email: userEmail,
271
+ accessLevel,
272
+ };
273
+
274
+ await setProfile('gdrive', profileName);
275
+ await setCredentials('gdrive', profileName, credentials);
276
+
277
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
278
+ console.log(` Email: ${userEmail}`);
279
+ console.log(` Access: ${accessLevel === 'full' ? 'Full (read & write)' : 'Read-only'}`);
280
+ console.log(` Test with: agentio gdrive list --profile ${profileName}`);
281
+ } catch (error) {
282
+ handleError(error);
283
+ }
284
+ });
285
+ }
@@ -35,7 +35,7 @@ export function registerGitHubCommands(program: Command): void {
35
35
  .command('install')
36
36
  .description('Install AGENTIO_KEY and AGENTIO_CONFIG as GitHub Actions secrets')
37
37
  .argument('<repo>', 'Repository in owner/repo format')
38
- .requiredOption('--profile <name>', 'Profile name')
38
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
39
39
  .action(async (repo: string, options) => {
40
40
  try {
41
41
  // Validate repo format
@@ -70,7 +70,7 @@ export function registerGitHubCommands(program: Command): void {
70
70
  .command('uninstall')
71
71
  .description('Remove AGENTIO_KEY and AGENTIO_CONFIG secrets from a repository')
72
72
  .argument('<repo>', 'Repository in owner/repo format')
73
- .requiredOption('--profile <name>', 'Profile name')
73
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
74
74
  .action(async (repo: string, options) => {
75
75
  try {
76
76
  // Validate repo format