@plosson/agentio 0.4.2 → 0.4.4

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.
@@ -0,0 +1,365 @@
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 { GSheetsClient } from '../services/gsheets/client';
11
+ import {
12
+ printGSheetsList,
13
+ printGSheetsMetadata,
14
+ printGSheetsValues,
15
+ printGSheetsUpdateResult,
16
+ printGSheetsAppendResult,
17
+ printGSheetsClearResult,
18
+ printGSheetsCreated,
19
+ } from '../utils/output';
20
+ import { CliError, handleError } from '../utils/errors';
21
+ import type { GSheetsCredentials } from '../types/gsheets';
22
+
23
+ const getGSheetsClient = createClientGetter<GSheetsCredentials, GSheetsClient>({
24
+ service: 'gsheets',
25
+ createClient: (credentials) => new GSheetsClient(credentials),
26
+ });
27
+
28
+ /**
29
+ * Parse values from CLI arguments or JSON
30
+ * Supports two formats:
31
+ * - Simple: comma-separated rows, pipe-separated cells (e.g., "a|b|c,d|e|f")
32
+ * - JSON: 2D array (e.g., '[["a","b"],["c","d"]]')
33
+ */
34
+ function parseValues(valueArgs: string[], valuesJson?: string): unknown[][] {
35
+ if (valuesJson) {
36
+ try {
37
+ const parsed = JSON.parse(valuesJson);
38
+ if (!Array.isArray(parsed)) {
39
+ throw new Error('JSON must be a 2D array');
40
+ }
41
+ return parsed;
42
+ } catch (err) {
43
+ throw new CliError('INVALID_PARAMS', `Invalid JSON values: ${err instanceof Error ? err.message : err}`);
44
+ }
45
+ }
46
+
47
+ if (valueArgs.length === 0) {
48
+ throw new CliError('INVALID_PARAMS', 'No values provided', 'Provide values as args or via --values-json');
49
+ }
50
+
51
+ // Parse simple format: comma-separated rows, pipe-separated cells
52
+ const rawValues = valueArgs.join(' ');
53
+ const rows = rawValues.split(',');
54
+ return rows.map((row) => {
55
+ const cells = row.trim().split('|');
56
+ return cells.map((cell) => cell.trim());
57
+ });
58
+ }
59
+
60
+ export function registerGSheetsCommands(program: Command): void {
61
+ const gsheets = program.command('gsheets').description('Google Sheets operations');
62
+
63
+ gsheets
64
+ .command('list')
65
+ .description('List recent spreadsheets')
66
+ .option('--profile <name>', 'Profile name')
67
+ .option('--limit <n>', 'Number of spreadsheets', '10')
68
+ .option('--query <query>', 'Drive search query filter')
69
+ .addHelpText(
70
+ 'after',
71
+ `
72
+ Query Syntax Examples:
73
+
74
+ Name search:
75
+ --query "name contains 'budget'" Spreadsheets with "budget" in name
76
+ --query "name = 'Q1 Report'" Exact name match
77
+
78
+ Ownership:
79
+ --query "'me' in owners" Spreadsheets you own
80
+ --query "'user@example.com' in owners"
81
+
82
+ Date filters:
83
+ --query "modifiedTime > '2024-01-01'" Modified after date
84
+ --query "createdTime > '2024-01-01'" Created after date
85
+
86
+ Combined:
87
+ --query "name contains 'sales' and modifiedTime > '2024-01-01'"
88
+ `
89
+ )
90
+ .action(async (options) => {
91
+ try {
92
+ const { client } = await getGSheetsClient(options.profile);
93
+ const spreadsheets = await client.list({
94
+ limit: parseInt(options.limit, 10),
95
+ query: options.query,
96
+ });
97
+ printGSheetsList(spreadsheets);
98
+ } catch (error) {
99
+ handleError(error);
100
+ }
101
+ });
102
+
103
+ gsheets
104
+ .command('get')
105
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
106
+ .argument('<range>', 'Range in A1 notation (e.g., Sheet1!A1:B10)')
107
+ .description('Get values from a range')
108
+ .option('--profile <name>', 'Profile name')
109
+ .option('--dimension <dim>', 'Major dimension: ROWS or COLUMNS')
110
+ .option('--render <opt>', 'Value render: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA')
111
+ .action(async (spreadsheetId: string, range: string, options) => {
112
+ try {
113
+ const { client } = await getGSheetsClient(options.profile);
114
+ const result = await client.get(spreadsheetId, range, {
115
+ majorDimension: options.dimension,
116
+ valueRenderOption: options.render,
117
+ });
118
+ printGSheetsValues(result);
119
+ } catch (error) {
120
+ handleError(error);
121
+ }
122
+ });
123
+
124
+ gsheets
125
+ .command('update')
126
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
127
+ .argument('<range>', 'Range in A1 notation (e.g., Sheet1!A1:B2)')
128
+ .argument('[values...]', 'Values (comma-separated rows, pipe-separated cells)')
129
+ .description('Update values in a range')
130
+ .option('--profile <name>', 'Profile name')
131
+ .option('--values-json <json>', 'Values as JSON 2D array')
132
+ .option('--input <opt>', 'Value input option: RAW or USER_ENTERED', 'USER_ENTERED')
133
+ .addHelpText(
134
+ 'after',
135
+ `
136
+ Value Formats:
137
+
138
+ Simple format (comma = row separator, pipe = cell separator):
139
+ agentio gsheets update <id> Sheet1!A1:B2 "a|b,c|d"
140
+ Results in:
141
+ A1=a B1=b
142
+ A2=c B2=d
143
+
144
+ JSON format:
145
+ agentio gsheets update <id> Sheet1!A1:B2 --values-json '[["a","b"],["c","d"]]'
146
+
147
+ Input Options:
148
+ RAW - Values are stored exactly as entered (no parsing)
149
+ USER_ENTERED - Values are parsed as if typed in the UI (formulas, dates, etc.)
150
+ `
151
+ )
152
+ .action(async (spreadsheetId: string, range: string, valueArgs: string[], options) => {
153
+ try {
154
+ const values = parseValues(valueArgs, options.valuesJson);
155
+ const { client } = await getGSheetsClient(options.profile);
156
+ const result = await client.update(spreadsheetId, range, values, {
157
+ valueInputOption: options.input,
158
+ });
159
+ printGSheetsUpdateResult(result);
160
+ } catch (error) {
161
+ handleError(error);
162
+ }
163
+ });
164
+
165
+ gsheets
166
+ .command('append')
167
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
168
+ .argument('<range>', 'Range in A1 notation (e.g., Sheet1!A:C)')
169
+ .argument('[values...]', 'Values (comma-separated rows, pipe-separated cells)')
170
+ .description('Append values to a range')
171
+ .option('--profile <name>', 'Profile name')
172
+ .option('--values-json <json>', 'Values as JSON 2D array')
173
+ .option('--input <opt>', 'Value input option: RAW or USER_ENTERED', 'USER_ENTERED')
174
+ .option('--insert <opt>', 'Insert data option: OVERWRITE or INSERT_ROWS')
175
+ .addHelpText(
176
+ 'after',
177
+ `
178
+ Value Formats:
179
+
180
+ Simple format (comma = row separator, pipe = cell separator):
181
+ agentio gsheets append <id> Sheet1!A:C "a|b|c,d|e|f"
182
+ Appends two rows to columns A, B, C
183
+
184
+ JSON format:
185
+ agentio gsheets append <id> Sheet1!A:C --values-json '[["a","b","c"],["d","e","f"]]'
186
+
187
+ Insert Options:
188
+ OVERWRITE - New data overwrites existing data in the areas it is written
189
+ INSERT_ROWS - Rows are inserted for the new data
190
+ `
191
+ )
192
+ .action(async (spreadsheetId: string, range: string, valueArgs: string[], options) => {
193
+ try {
194
+ const values = parseValues(valueArgs, options.valuesJson);
195
+ const { client } = await getGSheetsClient(options.profile);
196
+ const result = await client.append(spreadsheetId, range, values, {
197
+ valueInputOption: options.input,
198
+ insertDataOption: options.insert,
199
+ });
200
+ printGSheetsAppendResult(result);
201
+ } catch (error) {
202
+ handleError(error);
203
+ }
204
+ });
205
+
206
+ gsheets
207
+ .command('clear')
208
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
209
+ .argument('<range>', 'Range in A1 notation (e.g., Sheet1!A1:B10)')
210
+ .description('Clear values in a range')
211
+ .option('--profile <name>', 'Profile name')
212
+ .action(async (spreadsheetId: string, range: string, options) => {
213
+ try {
214
+ const { client } = await getGSheetsClient(options.profile);
215
+ const result = await client.clear(spreadsheetId, range);
216
+ printGSheetsClearResult(result);
217
+ } catch (error) {
218
+ handleError(error);
219
+ }
220
+ });
221
+
222
+ gsheets
223
+ .command('metadata')
224
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
225
+ .description('Get spreadsheet metadata')
226
+ .option('--profile <name>', 'Profile name')
227
+ .action(async (spreadsheetId: string, options) => {
228
+ try {
229
+ const { client } = await getGSheetsClient(options.profile);
230
+ const metadata = await client.metadata(spreadsheetId);
231
+ printGSheetsMetadata(metadata);
232
+ } catch (error) {
233
+ handleError(error);
234
+ }
235
+ });
236
+
237
+ gsheets
238
+ .command('create')
239
+ .argument('<title>', 'Spreadsheet title')
240
+ .description('Create a new spreadsheet')
241
+ .option('--profile <name>', 'Profile name')
242
+ .option('--sheets <names>', 'Comma-separated sheet names to create')
243
+ .action(async (title: string, options) => {
244
+ try {
245
+ const { client } = await getGSheetsClient(options.profile);
246
+ const sheetNames = options.sheets ? options.sheets.split(',').map((n: string) => n.trim()) : undefined;
247
+ const result = await client.create(title, sheetNames);
248
+ printGSheetsCreated(result);
249
+ } catch (error) {
250
+ handleError(error);
251
+ }
252
+ });
253
+
254
+ gsheets
255
+ .command('copy')
256
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
257
+ .argument('<title>', 'New spreadsheet title')
258
+ .description('Copy a spreadsheet')
259
+ .option('--profile <name>', 'Profile name')
260
+ .option('--parent <folder-id>', 'Destination folder ID')
261
+ .action(async (spreadsheetId: string, title: string, options) => {
262
+ try {
263
+ const { client } = await getGSheetsClient(options.profile);
264
+ const result = await client.copy(spreadsheetId, title, options.parent);
265
+ printGSheetsCreated(result);
266
+ } catch (error) {
267
+ handleError(error);
268
+ }
269
+ });
270
+
271
+ gsheets
272
+ .command('export')
273
+ .argument('<spreadsheet-id-or-url>', 'Spreadsheet ID or URL')
274
+ .description('Export a spreadsheet to a file')
275
+ .option('--profile <name>', 'Profile name')
276
+ .requiredOption('--output <path>', 'Output file path')
277
+ .option('--format <fmt>', 'Export format: xlsx, pdf, csv, ods, tsv', 'xlsx')
278
+ .addHelpText(
279
+ 'after',
280
+ `
281
+ Export Formats:
282
+ xlsx - Microsoft Excel (default)
283
+ pdf - PDF document
284
+ csv - Comma-separated values (first sheet only)
285
+ ods - OpenDocument Spreadsheet
286
+ tsv - Tab-separated values (first sheet only)
287
+
288
+ Examples:
289
+ agentio gsheets export <id> --output report.xlsx
290
+ agentio gsheets export <id> --output data.csv --format csv
291
+ `
292
+ )
293
+ .action(async (spreadsheetId: string, options) => {
294
+ try {
295
+ const { client } = await getGSheetsClient(options.profile);
296
+ const format = options.format.toLowerCase();
297
+
298
+ if (!['xlsx', 'pdf', 'csv', 'ods', 'tsv'].includes(format)) {
299
+ throw new CliError('INVALID_PARAMS', `Unknown format: ${format}`, 'Use xlsx, pdf, csv, ods, or tsv');
300
+ }
301
+
302
+ const result = await client.export(spreadsheetId, format as 'xlsx' | 'pdf' | 'csv' | 'ods' | 'tsv');
303
+ await writeFile(options.output, result.data);
304
+ console.log(`Exported to ${options.output}`);
305
+ console.log(` Format: ${format}`);
306
+ console.log(` Size: ${result.data.length} bytes`);
307
+ } catch (error) {
308
+ handleError(error);
309
+ }
310
+ });
311
+
312
+ // Profile management
313
+ const profile = createProfileCommands<GSheetsCredentials>(gsheets, {
314
+ service: 'gsheets',
315
+ displayName: 'Google Sheets',
316
+ getExtraInfo: (credentials) => (credentials?.email ? ` - ${credentials.email}` : ''),
317
+ });
318
+
319
+ profile
320
+ .command('add')
321
+ .description('Add a new Google Sheets profile')
322
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
323
+ .action(async (options) => {
324
+ try {
325
+ console.error('Starting OAuth flow for Google Sheets...\n');
326
+
327
+ const tokens = await performOAuthFlow('gsheets');
328
+ const auth = createGoogleAuth(tokens);
329
+
330
+ // Fetch user email for profile naming
331
+ let userEmail: string;
332
+ try {
333
+ const oauth2 = google.oauth2({ version: 'v2', auth });
334
+ const userInfo = await oauth2.userinfo.get();
335
+ userEmail = userInfo.data.email || '';
336
+ if (!userEmail) {
337
+ throw new Error('No email returned');
338
+ }
339
+ } catch (error) {
340
+ const errorMessage = error instanceof Error ? error.message : String(error);
341
+ throw new CliError('AUTH_FAILED', `Failed to fetch user email: ${errorMessage}`, 'Ensure the account has an email address');
342
+ }
343
+
344
+ const profileName = options.profile || userEmail;
345
+
346
+ const credentials: GSheetsCredentials = {
347
+ accessToken: tokens.access_token,
348
+ refreshToken: tokens.refresh_token,
349
+ expiryDate: tokens.expiry_date,
350
+ tokenType: tokens.token_type,
351
+ scope: tokens.scope,
352
+ email: userEmail,
353
+ };
354
+
355
+ await setProfile('gsheets', profileName);
356
+ await setCredentials('gsheets', profileName, credentials);
357
+
358
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
359
+ console.log(` Email: ${userEmail}`);
360
+ console.log(` Test with: agentio gsheets list --profile ${profileName}`);
361
+ } catch (error) {
362
+ handleError(error);
363
+ }
364
+ });
365
+ }
@@ -0,0 +1,326 @@
1
+ import { Command } from 'commander';
2
+ import { google } from 'googleapis';
3
+ import { getValidTokens, 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 { performOAuthFlow } from '../auth/oauth';
8
+ import { GTasksClient } from '../services/gtasks/client';
9
+ import {
10
+ printGTasksList,
11
+ printGTaskList,
12
+ printGTaskListCreated,
13
+ printGTaskListDeleted,
14
+ printGTasks,
15
+ printGTask,
16
+ printGTaskCreated,
17
+ printGTaskDeleted,
18
+ printGTasksCleared,
19
+ } from '../utils/output';
20
+ import { CliError, handleError } from '../utils/errors';
21
+ import { readStdin } from '../utils/stdin';
22
+
23
+ async function getGTasksClient(profileName?: string): Promise<{ client: GTasksClient; profile: string }> {
24
+ const { tokens, profile } = await getValidTokens('gtasks', profileName);
25
+ const auth = createGoogleAuth(tokens);
26
+ return { client: new GTasksClient(auth), profile };
27
+ }
28
+
29
+ export function registerGTasksCommands(program: Command): void {
30
+ const gtasks = program
31
+ .command('gtasks')
32
+ .description('Google Tasks operations');
33
+
34
+ // === Task Lists Commands ===
35
+
36
+ const lists = gtasks
37
+ .command('lists')
38
+ .description('Manage task lists');
39
+
40
+ // List task lists (default subcommand)
41
+ lists
42
+ .command('list', { isDefault: true })
43
+ .description('List all task lists')
44
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
45
+ .option('--limit <n>', 'Max results', '100')
46
+ .action(async (options) => {
47
+ try {
48
+ const { client } = await getGTasksClient(options.profile);
49
+ const result = await client.listTaskLists(parseInt(options.limit, 10));
50
+ printGTasksList(result.taskLists, result.nextPageToken);
51
+ } catch (error) {
52
+ handleError(error);
53
+ }
54
+ });
55
+
56
+ // Create task list
57
+ lists
58
+ .command('create <title>')
59
+ .description('Create a new task list')
60
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
61
+ .action(async (title: string, options) => {
62
+ try {
63
+ const { client } = await getGTasksClient(options.profile);
64
+ const taskList = await client.createTaskList(title);
65
+ printGTaskListCreated(taskList);
66
+ } catch (error) {
67
+ handleError(error);
68
+ }
69
+ });
70
+
71
+ // Delete task list
72
+ lists
73
+ .command('delete <tasklist-id>')
74
+ .description('Delete a task list')
75
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
76
+ .action(async (tasklistId: string, options) => {
77
+ try {
78
+ const { client } = await getGTasksClient(options.profile);
79
+ await client.deleteTaskList(tasklistId);
80
+ printGTaskListDeleted(tasklistId);
81
+ } catch (error) {
82
+ handleError(error);
83
+ }
84
+ });
85
+
86
+ // === Task Commands ===
87
+
88
+ // List tasks in a task list
89
+ gtasks
90
+ .command('list <tasklist-id>')
91
+ .description('List tasks in a task list')
92
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
93
+ .option('--limit <n>', 'Max results', '20')
94
+ .option('--show-completed', 'Include completed tasks', true)
95
+ .option('--no-show-completed', 'Exclude completed tasks')
96
+ .option('--show-hidden', 'Include hidden tasks')
97
+ .option('--due-min <datetime>', 'Filter: due date minimum (RFC3339)')
98
+ .option('--due-max <datetime>', 'Filter: due date maximum (RFC3339)')
99
+ .action(async (tasklistId: string, options) => {
100
+ try {
101
+ const { client } = await getGTasksClient(options.profile);
102
+ const result = await client.listTasks({
103
+ tasklistId,
104
+ maxResults: parseInt(options.limit, 10),
105
+ showCompleted: options.showCompleted,
106
+ showHidden: options.showHidden,
107
+ dueMin: options.dueMin,
108
+ dueMax: options.dueMax,
109
+ });
110
+ printGTasks(result.tasks, result.nextPageToken);
111
+ } catch (error) {
112
+ handleError(error);
113
+ }
114
+ });
115
+
116
+ // Get a specific task
117
+ gtasks
118
+ .command('get <tasklist-id> <task-id>')
119
+ .description('Get a specific task')
120
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
121
+ .action(async (tasklistId: string, taskId: string, options) => {
122
+ try {
123
+ const { client } = await getGTasksClient(options.profile);
124
+ const task = await client.getTask(tasklistId, taskId);
125
+ printGTask(task);
126
+ } catch (error) {
127
+ handleError(error);
128
+ }
129
+ });
130
+
131
+ // Add a new task
132
+ gtasks
133
+ .command('add <tasklist-id>')
134
+ .description('Add a new task')
135
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
136
+ .requiredOption('--title <title>', 'Task title')
137
+ .option('--notes <text>', 'Task notes/description (or pipe via stdin)')
138
+ .option('--due <date>', 'Due date (RFC3339 or YYYY-MM-DD)')
139
+ .option('--parent <task-id>', 'Parent task ID (create as subtask)')
140
+ .option('--previous <task-id>', 'Previous sibling task ID (controls ordering)')
141
+ .action(async (tasklistId: string, options) => {
142
+ try {
143
+ let notes = options.notes;
144
+ if (!notes) {
145
+ const stdin = await readStdin();
146
+ if (stdin) notes = stdin;
147
+ }
148
+
149
+ const { client } = await getGTasksClient(options.profile);
150
+ const task = await client.createTask({
151
+ tasklistId,
152
+ title: options.title,
153
+ notes,
154
+ due: options.due,
155
+ parent: options.parent,
156
+ previous: options.previous,
157
+ });
158
+ printGTaskCreated(task);
159
+ } catch (error) {
160
+ handleError(error);
161
+ }
162
+ });
163
+
164
+ // Update a task
165
+ gtasks
166
+ .command('update <tasklist-id> <task-id>')
167
+ .description('Update an existing task')
168
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
169
+ .option('--title <title>', 'New title')
170
+ .option('--notes <text>', 'New notes (or pipe via stdin)')
171
+ .option('--due <date>', 'New due date (RFC3339 or YYYY-MM-DD, empty to clear)')
172
+ .option('--status <status>', 'New status: needsAction or completed')
173
+ .action(async (tasklistId: string, taskId: string, options) => {
174
+ try {
175
+ let notes = options.notes;
176
+ if (notes === undefined && !process.stdin.isTTY) {
177
+ const stdin = await readStdin();
178
+ if (stdin) notes = stdin;
179
+ }
180
+
181
+ if (options.status && !['needsAction', 'completed'].includes(options.status)) {
182
+ throw new CliError('INVALID_PARAMS', `Invalid status: ${options.status}`, 'Use: needsAction or completed');
183
+ }
184
+
185
+ const { client } = await getGTasksClient(options.profile);
186
+ const task = await client.updateTask({
187
+ tasklistId,
188
+ taskId,
189
+ title: options.title,
190
+ notes,
191
+ due: options.due,
192
+ status: options.status,
193
+ });
194
+ printGTask(task);
195
+ } catch (error) {
196
+ handleError(error);
197
+ }
198
+ });
199
+
200
+ // Mark task as done
201
+ gtasks
202
+ .command('done <tasklist-id> <task-id>')
203
+ .alias('complete')
204
+ .description('Mark a task as completed')
205
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
206
+ .action(async (tasklistId: string, taskId: string, options) => {
207
+ try {
208
+ const { client } = await getGTasksClient(options.profile);
209
+ const task = await client.completeTask(tasklistId, taskId);
210
+ console.log(`Task completed: ${task.title}`);
211
+ console.log(`ID: ${task.id}`);
212
+ console.log(`Status: ${task.status}`);
213
+ } catch (error) {
214
+ handleError(error);
215
+ }
216
+ });
217
+
218
+ // Mark task as not done
219
+ gtasks
220
+ .command('undo <tasklist-id> <task-id>')
221
+ .alias('uncomplete')
222
+ .description('Mark a task as needs action (not completed)')
223
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
224
+ .action(async (tasklistId: string, taskId: string, options) => {
225
+ try {
226
+ const { client } = await getGTasksClient(options.profile);
227
+ const task = await client.uncompleteTask(tasklistId, taskId);
228
+ console.log(`Task uncompleted: ${task.title}`);
229
+ console.log(`ID: ${task.id}`);
230
+ console.log(`Status: ${task.status}`);
231
+ } catch (error) {
232
+ handleError(error);
233
+ }
234
+ });
235
+
236
+ // Delete a task
237
+ gtasks
238
+ .command('delete <tasklist-id> <task-id>')
239
+ .description('Delete a task')
240
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
241
+ .action(async (tasklistId: string, taskId: string, options) => {
242
+ try {
243
+ const { client } = await getGTasksClient(options.profile);
244
+ await client.deleteTask(tasklistId, taskId);
245
+ printGTaskDeleted(tasklistId, taskId);
246
+ } catch (error) {
247
+ handleError(error);
248
+ }
249
+ });
250
+
251
+ // Clear completed tasks
252
+ gtasks
253
+ .command('clear <tasklist-id>')
254
+ .description('Clear all completed tasks from a task list')
255
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
256
+ .action(async (tasklistId: string, options) => {
257
+ try {
258
+ const { client } = await getGTasksClient(options.profile);
259
+ await client.clearCompleted(tasklistId);
260
+ printGTasksCleared(tasklistId);
261
+ } catch (error) {
262
+ handleError(error);
263
+ }
264
+ });
265
+
266
+ // Move a task
267
+ gtasks
268
+ .command('move <tasklist-id> <task-id>')
269
+ .description('Move a task (change parent or position)')
270
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
271
+ .option('--parent <task-id>', 'New parent task ID (make subtask)')
272
+ .option('--previous <task-id>', 'Previous sibling task ID (change position)')
273
+ .action(async (tasklistId: string, taskId: string, options) => {
274
+ try {
275
+ if (!options.parent && !options.previous) {
276
+ throw new CliError('INVALID_PARAMS', 'At least one of --parent or --previous is required');
277
+ }
278
+ const { client } = await getGTasksClient(options.profile);
279
+ const task = await client.moveTask(tasklistId, taskId, options.parent, options.previous);
280
+ console.log(`Task moved: ${task.title}`);
281
+ console.log(`ID: ${task.id}`);
282
+ if (task.parent) console.log(`Parent: ${task.parent}`);
283
+ } catch (error) {
284
+ handleError(error);
285
+ }
286
+ });
287
+
288
+ // Profile management
289
+ const profile = createProfileCommands<{ email?: string }>(gtasks, {
290
+ service: 'gtasks',
291
+ displayName: 'Google Tasks',
292
+ getExtraInfo: (credentials) => credentials?.email ? ` - ${credentials.email}` : '',
293
+ });
294
+
295
+ profile
296
+ .command('add')
297
+ .description('Add a new Google Tasks profile')
298
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
299
+ .action(async (options) => {
300
+ try {
301
+ console.error('Starting OAuth flow for Google Tasks...\n');
302
+
303
+ const tokens = await performOAuthFlow('gtasks');
304
+
305
+ // Fetch user email via oauth2 userinfo
306
+ const auth = createGoogleAuth(tokens);
307
+ const oauth2 = google.oauth2({ version: 'v2', auth });
308
+ const userInfo = await oauth2.userinfo.get();
309
+ const email = userInfo.data.email;
310
+
311
+ if (!email) {
312
+ throw new CliError('AUTH_FAILED', 'Could not fetch email', 'Try again or specify --profile manually');
313
+ }
314
+
315
+ const profileName = options.profile || email;
316
+
317
+ await setProfile('gtasks', profileName);
318
+ await setCredentials('gtasks', profileName, { ...tokens, email });
319
+
320
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
321
+ console.log(` Email: ${email}`);
322
+ } catch (error) {
323
+ handleError(error);
324
+ }
325
+ });
326
+ }