@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,362 @@
1
+ import { google } from 'googleapis';
2
+ import type { sheets_v4, drive_v3 } from 'googleapis';
3
+ import { CliError, httpStatusToErrorCode, type ErrorCode } from '../../utils/errors';
4
+ import type { ServiceClient, ValidationResult } from '../../types/service';
5
+ import { GOOGLE_OAUTH_CONFIG } from '../../config/credentials';
6
+ import type {
7
+ GSheetsCredentials,
8
+ GSheetsSpreadsheet,
9
+ GSheetsSheet,
10
+ GSheetsGetOptions,
11
+ GSheetsGetResult,
12
+ GSheetsUpdateOptions,
13
+ GSheetsUpdateResult,
14
+ GSheetsAppendOptions,
15
+ GSheetsAppendResult,
16
+ GSheetsClearResult,
17
+ GSheetsCreateResult,
18
+ GSheetsListOptions,
19
+ GSheetsListItem,
20
+ } from '../../types/gsheets';
21
+
22
+ export class GSheetsClient implements ServiceClient {
23
+ private credentials: GSheetsCredentials;
24
+ private sheets: sheets_v4.Sheets;
25
+ private drive: drive_v3.Drive;
26
+
27
+ constructor(credentials: GSheetsCredentials) {
28
+ this.credentials = credentials;
29
+ const auth = this.createOAuthClient();
30
+ this.sheets = google.sheets({ version: 'v4', auth });
31
+ this.drive = google.drive({ version: 'v3', auth });
32
+ }
33
+
34
+ async validate(): Promise<ValidationResult> {
35
+ try {
36
+ await this.drive.files.list({
37
+ pageSize: 1,
38
+ q: "mimeType='application/vnd.google-apps.spreadsheet'",
39
+ });
40
+ return { valid: true, info: this.credentials.email };
41
+ } catch (error) {
42
+ const message = error instanceof Error ? error.message : 'Unknown error';
43
+ if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
44
+ return { valid: false, error: 'refresh token expired, re-authenticate' };
45
+ }
46
+ return { valid: false, error: message };
47
+ }
48
+ }
49
+
50
+ async list(options: GSheetsListOptions = {}): Promise<GSheetsListItem[]> {
51
+ const { limit = 10, query } = options;
52
+
53
+ try {
54
+ let q = "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false";
55
+ if (query) {
56
+ q += ` and ${query}`;
57
+ }
58
+
59
+ const response = await this.drive.files.list({
60
+ pageSize: Math.min(limit, 100),
61
+ q,
62
+ fields: 'files(id,name,owners,createdTime,modifiedTime,webViewLink)',
63
+ orderBy: 'modifiedTime desc',
64
+ });
65
+
66
+ const files = response.data.files || [];
67
+ return files.map((file) => ({
68
+ id: file.id!,
69
+ title: file.name || 'Untitled',
70
+ owner: file.owners?.[0]?.displayName || file.owners?.[0]?.emailAddress || undefined,
71
+ createdTime: file.createdTime || undefined,
72
+ modifiedTime: file.modifiedTime || undefined,
73
+ webViewLink: file.webViewLink || `https://docs.google.com/spreadsheets/d/${file.id}`,
74
+ }));
75
+ } catch (err) {
76
+ this.throwApiError(err, 'list spreadsheets');
77
+ }
78
+ }
79
+
80
+ async get(spreadsheetIdOrUrl: string, range: string, options: GSheetsGetOptions = {}): Promise<GSheetsGetResult> {
81
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
82
+ const cleanedRange = this.cleanRange(range);
83
+
84
+ try {
85
+ const call = this.sheets.spreadsheets.values.get({
86
+ spreadsheetId,
87
+ range: cleanedRange,
88
+ majorDimension: options.majorDimension,
89
+ valueRenderOption: options.valueRenderOption,
90
+ });
91
+
92
+ const response = await call;
93
+
94
+ return {
95
+ range: response.data.range || cleanedRange,
96
+ values: (response.data.values as unknown[][]) || [],
97
+ };
98
+ } catch (err) {
99
+ this.throwApiError(err, 'get values');
100
+ }
101
+ }
102
+
103
+ async update(
104
+ spreadsheetIdOrUrl: string,
105
+ range: string,
106
+ values: unknown[][],
107
+ options: GSheetsUpdateOptions = {}
108
+ ): Promise<GSheetsUpdateResult> {
109
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
110
+ const cleanedRange = this.cleanRange(range);
111
+ const valueInputOption = options.valueInputOption || 'USER_ENTERED';
112
+
113
+ try {
114
+ const response = await this.sheets.spreadsheets.values.update({
115
+ spreadsheetId,
116
+ range: cleanedRange,
117
+ valueInputOption,
118
+ requestBody: {
119
+ values,
120
+ },
121
+ });
122
+
123
+ return {
124
+ updatedRange: response.data.updatedRange || cleanedRange,
125
+ updatedRows: response.data.updatedRows || 0,
126
+ updatedColumns: response.data.updatedColumns || 0,
127
+ updatedCells: response.data.updatedCells || 0,
128
+ };
129
+ } catch (err) {
130
+ this.throwApiError(err, 'update values');
131
+ }
132
+ }
133
+
134
+ async append(
135
+ spreadsheetIdOrUrl: string,
136
+ range: string,
137
+ values: unknown[][],
138
+ options: GSheetsAppendOptions = {}
139
+ ): Promise<GSheetsAppendResult> {
140
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
141
+ const cleanedRange = this.cleanRange(range);
142
+ const valueInputOption = options.valueInputOption || 'USER_ENTERED';
143
+
144
+ try {
145
+ const response = await this.sheets.spreadsheets.values.append({
146
+ spreadsheetId,
147
+ range: cleanedRange,
148
+ valueInputOption,
149
+ insertDataOption: options.insertDataOption,
150
+ requestBody: {
151
+ values,
152
+ },
153
+ });
154
+
155
+ const updates = response.data.updates;
156
+ return {
157
+ updatedRange: updates?.updatedRange || cleanedRange,
158
+ updatedRows: updates?.updatedRows || 0,
159
+ updatedColumns: updates?.updatedColumns || 0,
160
+ updatedCells: updates?.updatedCells || 0,
161
+ };
162
+ } catch (err) {
163
+ this.throwApiError(err, 'append values');
164
+ }
165
+ }
166
+
167
+ async clear(spreadsheetIdOrUrl: string, range: string): Promise<GSheetsClearResult> {
168
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
169
+ const cleanedRange = this.cleanRange(range);
170
+
171
+ try {
172
+ const response = await this.sheets.spreadsheets.values.clear({
173
+ spreadsheetId,
174
+ range: cleanedRange,
175
+ });
176
+
177
+ return {
178
+ clearedRange: response.data.clearedRange || cleanedRange,
179
+ };
180
+ } catch (err) {
181
+ this.throwApiError(err, 'clear values');
182
+ }
183
+ }
184
+
185
+ async metadata(spreadsheetIdOrUrl: string): Promise<GSheetsSpreadsheet> {
186
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
187
+
188
+ try {
189
+ const response = await this.sheets.spreadsheets.get({
190
+ spreadsheetId,
191
+ });
192
+
193
+ const props = response.data.properties!;
194
+ const sheets: GSheetsSheet[] = (response.data.sheets || []).map((sheet) => ({
195
+ id: sheet.properties?.sheetId || 0,
196
+ title: sheet.properties?.title || 'Untitled',
197
+ rowCount: sheet.properties?.gridProperties?.rowCount || 0,
198
+ columnCount: sheet.properties?.gridProperties?.columnCount || 0,
199
+ }));
200
+
201
+ return {
202
+ id: response.data.spreadsheetId!,
203
+ title: props.title || 'Untitled',
204
+ locale: props.locale || undefined,
205
+ timeZone: props.timeZone || undefined,
206
+ url: response.data.spreadsheetUrl || `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
207
+ sheets,
208
+ };
209
+ } catch (err) {
210
+ this.throwApiError(err, 'get metadata');
211
+ }
212
+ }
213
+
214
+ async create(title: string, sheetNames?: string[]): Promise<GSheetsCreateResult> {
215
+ try {
216
+ const sheets: sheets_v4.Schema$Sheet[] | undefined = sheetNames?.map((name) => ({
217
+ properties: {
218
+ title: name.trim(),
219
+ },
220
+ }));
221
+
222
+ const response = await this.sheets.spreadsheets.create({
223
+ requestBody: {
224
+ properties: {
225
+ title,
226
+ },
227
+ sheets,
228
+ },
229
+ });
230
+
231
+ return {
232
+ id: response.data.spreadsheetId!,
233
+ title: response.data.properties?.title || title,
234
+ url: response.data.spreadsheetUrl || `https://docs.google.com/spreadsheets/d/${response.data.spreadsheetId}`,
235
+ };
236
+ } catch (err) {
237
+ this.throwApiError(err, 'create spreadsheet');
238
+ }
239
+ }
240
+
241
+ async copy(spreadsheetIdOrUrl: string, newTitle: string, parentFolderId?: string): Promise<GSheetsCreateResult> {
242
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
243
+
244
+ try {
245
+ const response = await this.drive.files.copy({
246
+ fileId: spreadsheetId,
247
+ requestBody: {
248
+ name: newTitle,
249
+ parents: parentFolderId ? [parentFolderId] : undefined,
250
+ },
251
+ fields: 'id,name,webViewLink',
252
+ });
253
+
254
+ return {
255
+ id: response.data.id!,
256
+ title: response.data.name || newTitle,
257
+ url: response.data.webViewLink || `https://docs.google.com/spreadsheets/d/${response.data.id}`,
258
+ };
259
+ } catch (err) {
260
+ this.throwApiError(err, 'copy spreadsheet');
261
+ }
262
+ }
263
+
264
+ async export(
265
+ spreadsheetIdOrUrl: string,
266
+ format: 'xlsx' | 'pdf' | 'csv' | 'ods' | 'tsv'
267
+ ): Promise<{ data: Buffer; mimeType: string; extension: string }> {
268
+ const spreadsheetId = this.extractSpreadsheetId(spreadsheetIdOrUrl);
269
+
270
+ const formatMap: Record<string, { mimeType: string; extension: string }> = {
271
+ xlsx: {
272
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
273
+ extension: '.xlsx',
274
+ },
275
+ pdf: { mimeType: 'application/pdf', extension: '.pdf' },
276
+ csv: { mimeType: 'text/csv', extension: '.csv' },
277
+ ods: { mimeType: 'application/vnd.oasis.opendocument.spreadsheet', extension: '.ods' },
278
+ tsv: { mimeType: 'text/tab-separated-values', extension: '.tsv' },
279
+ };
280
+
281
+ const formatInfo = formatMap[format];
282
+ if (!formatInfo) {
283
+ throw new CliError('INVALID_PARAMS', `Unknown export format: ${format}`, 'Use xlsx, pdf, csv, ods, or tsv');
284
+ }
285
+
286
+ try {
287
+ const response = await this.drive.files.export(
288
+ { fileId: spreadsheetId, mimeType: formatInfo.mimeType },
289
+ { responseType: 'arraybuffer' }
290
+ );
291
+
292
+ return {
293
+ data: Buffer.from(response.data as ArrayBuffer),
294
+ mimeType: formatInfo.mimeType,
295
+ extension: formatInfo.extension,
296
+ };
297
+ } catch (err) {
298
+ this.throwApiError(err, 'export spreadsheet');
299
+ }
300
+ }
301
+
302
+ private extractSpreadsheetId(idOrUrl: string): string {
303
+ // Handle URLs like https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit
304
+ const urlMatch = idOrUrl.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
305
+ if (urlMatch) return urlMatch[1];
306
+
307
+ // Handle other URL formats
308
+ const idMatch = idOrUrl.match(/id=([a-zA-Z0-9_-]+)/);
309
+ if (idMatch) return idMatch[1];
310
+
311
+ // Assume it's already an ID
312
+ return idOrUrl;
313
+ }
314
+
315
+ private cleanRange(range: string): string {
316
+ // Some shells escape ! to \! (bash history expansion), which breaks API calls
317
+ return range.replace(/\\!/g, '!');
318
+ }
319
+
320
+ private createOAuthClient() {
321
+ const oauth2Client = new google.auth.OAuth2(
322
+ GOOGLE_OAUTH_CONFIG.clientId,
323
+ GOOGLE_OAUTH_CONFIG.clientSecret
324
+ );
325
+
326
+ oauth2Client.setCredentials({
327
+ access_token: this.credentials.accessToken,
328
+ refresh_token: this.credentials.refreshToken,
329
+ expiry_date: this.credentials.expiryDate,
330
+ });
331
+
332
+ return oauth2Client;
333
+ }
334
+
335
+ private throwApiError(err: unknown, operation: string): never {
336
+ const code = this.getErrorCode(err);
337
+ const message = this.getErrorMessage(err);
338
+ throw new CliError(code, `Failed to ${operation}: ${message}`);
339
+ }
340
+
341
+ private getErrorCode(err: unknown): ErrorCode {
342
+ if (err && typeof err === 'object') {
343
+ const error = err as Record<string, unknown>;
344
+ const code = error.code || error.status;
345
+ if (typeof code === 'number') return httpStatusToErrorCode(code);
346
+ }
347
+ return 'API_ERROR';
348
+ }
349
+
350
+ private getErrorMessage(err: unknown): string {
351
+ if (err && typeof err === 'object') {
352
+ const error = err as Record<string, unknown>;
353
+ const code = error.code || error.status;
354
+ if (code === 401) return 'OAuth token expired or invalid';
355
+ if (code === 403) return 'Insufficient permissions to access this spreadsheet';
356
+ if (code === 404) return 'Spreadsheet not found';
357
+ if (code === 429) return 'Rate limit exceeded, please try again later';
358
+ if (error.message && typeof error.message === 'string') return error.message;
359
+ }
360
+ return err instanceof Error ? err.message : String(err);
361
+ }
362
+ }
@@ -0,0 +1,301 @@
1
+ import { google, tasks_v1 } from 'googleapis';
2
+ import type { OAuth2Client } from 'google-auth-library';
3
+ import type {
4
+ GTaskList,
5
+ GTask,
6
+ GTasksListOptions,
7
+ GTasksCreateOptions,
8
+ GTasksUpdateOptions,
9
+ } from '../../types/gtasks';
10
+ import type { ServiceClient, ValidationResult } from '../../types/service';
11
+ import { CliError } from '../../utils/errors';
12
+
13
+ export class GTasksClient implements ServiceClient {
14
+ private tasks: tasks_v1.Tasks;
15
+ private userEmail: string | null = null;
16
+
17
+ constructor(auth: OAuth2Client) {
18
+ this.tasks = google.tasks({ version: 'v1', auth });
19
+ }
20
+
21
+ async validate(): Promise<ValidationResult> {
22
+ try {
23
+ // Try to list task lists to verify credentials work
24
+ await this.tasks.tasklists.list({ maxResults: 1 });
25
+ return { valid: true, info: 'tasks access ok' };
26
+ } catch (error) {
27
+ const message = error instanceof Error ? error.message : 'Unknown error';
28
+ if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
29
+ return { valid: false, error: 'refresh token expired, re-authenticate' };
30
+ }
31
+ return { valid: false, error: message };
32
+ }
33
+ }
34
+
35
+ async listTaskLists(maxResults: number = 100, pageToken?: string): Promise<{ taskLists: GTaskList[]; nextPageToken?: string }> {
36
+ try {
37
+ const response = await this.tasks.tasklists.list({
38
+ maxResults: Math.min(maxResults, 100),
39
+ pageToken,
40
+ });
41
+
42
+ const taskLists: GTaskList[] = (response.data.items || []).map((tl) => ({
43
+ id: tl.id!,
44
+ title: tl.title || '',
45
+ updated: tl.updated || undefined,
46
+ selfLink: tl.selfLink || undefined,
47
+ }));
48
+
49
+ return {
50
+ taskLists,
51
+ nextPageToken: response.data.nextPageToken || undefined,
52
+ };
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ throw new CliError('API_ERROR', `Tasks API error: ${message}`);
56
+ }
57
+ }
58
+
59
+ async createTaskList(title: string): Promise<GTaskList> {
60
+ try {
61
+ const response = await this.tasks.tasklists.insert({
62
+ requestBody: { title },
63
+ });
64
+
65
+ return {
66
+ id: response.data.id!,
67
+ title: response.data.title || '',
68
+ updated: response.data.updated || undefined,
69
+ selfLink: response.data.selfLink || undefined,
70
+ };
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ throw new CliError('API_ERROR', `Failed to create task list: ${message}`);
74
+ }
75
+ }
76
+
77
+ async deleteTaskList(tasklistId: string): Promise<void> {
78
+ try {
79
+ await this.tasks.tasklists.delete({ tasklist: tasklistId });
80
+ } catch (error) {
81
+ if (this.isNotFoundError(error)) {
82
+ throw new CliError('NOT_FOUND', `Task list not found: ${tasklistId}`);
83
+ }
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ throw new CliError('API_ERROR', `Failed to delete task list: ${message}`);
86
+ }
87
+ }
88
+
89
+ async listTasks(options: GTasksListOptions): Promise<{ tasks: GTask[]; nextPageToken?: string }> {
90
+ const {
91
+ tasklistId,
92
+ maxResults = 20,
93
+ pageToken,
94
+ showCompleted = true,
95
+ showDeleted = false,
96
+ showHidden = false,
97
+ dueMin,
98
+ dueMax,
99
+ completedMin,
100
+ completedMax,
101
+ updatedMin,
102
+ } = options;
103
+
104
+ try {
105
+ const params: tasks_v1.Params$Resource$Tasks$List = {
106
+ tasklist: tasklistId,
107
+ maxResults: Math.min(maxResults, 100),
108
+ showCompleted,
109
+ showDeleted,
110
+ showHidden,
111
+ };
112
+
113
+ if (pageToken) params.pageToken = pageToken;
114
+ if (dueMin) params.dueMin = dueMin;
115
+ if (dueMax) params.dueMax = dueMax;
116
+ if (completedMin) params.completedMin = completedMin;
117
+ if (completedMax) params.completedMax = completedMax;
118
+ if (updatedMin) params.updatedMin = updatedMin;
119
+
120
+ const response = await this.tasks.tasks.list(params);
121
+
122
+ const tasks: GTask[] = (response.data.items || []).map((t) => this.parseTask(t));
123
+
124
+ return {
125
+ tasks,
126
+ nextPageToken: response.data.nextPageToken || undefined,
127
+ };
128
+ } catch (error) {
129
+ if (this.isNotFoundError(error)) {
130
+ throw new CliError('NOT_FOUND', `Task list not found: ${tasklistId}`);
131
+ }
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new CliError('API_ERROR', `Tasks API error: ${message}`);
134
+ }
135
+ }
136
+
137
+ async getTask(tasklistId: string, taskId: string): Promise<GTask> {
138
+ try {
139
+ const response = await this.tasks.tasks.get({
140
+ tasklist: tasklistId,
141
+ task: taskId,
142
+ });
143
+
144
+ return this.parseTask(response.data);
145
+ } catch (error) {
146
+ if (this.isNotFoundError(error)) {
147
+ throw new CliError('NOT_FOUND', `Task not found: ${taskId}`);
148
+ }
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ throw new CliError('API_ERROR', `Tasks API error: ${message}`);
151
+ }
152
+ }
153
+
154
+ async createTask(options: GTasksCreateOptions): Promise<GTask> {
155
+ const { tasklistId, title, notes, due, parent, previous } = options;
156
+
157
+ try {
158
+ const task: tasks_v1.Schema$Task = {
159
+ title,
160
+ notes,
161
+ due: due ? this.normalizeDue(due) : undefined,
162
+ };
163
+
164
+ const params: tasks_v1.Params$Resource$Tasks$Insert = {
165
+ tasklist: tasklistId,
166
+ requestBody: task,
167
+ };
168
+
169
+ if (parent) params.parent = parent;
170
+ if (previous) params.previous = previous;
171
+
172
+ const response = await this.tasks.tasks.insert(params);
173
+
174
+ return this.parseTask(response.data);
175
+ } catch (error) {
176
+ if (this.isNotFoundError(error)) {
177
+ throw new CliError('NOT_FOUND', `Task list not found: ${tasklistId}`);
178
+ }
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ throw new CliError('API_ERROR', `Failed to create task: ${message}`);
181
+ }
182
+ }
183
+
184
+ async updateTask(options: GTasksUpdateOptions): Promise<GTask> {
185
+ const { tasklistId, taskId, title, notes, due, status } = options;
186
+
187
+ try {
188
+ const patch: tasks_v1.Schema$Task = {};
189
+
190
+ if (title !== undefined) patch.title = title;
191
+ if (notes !== undefined) patch.notes = notes;
192
+ if (due !== undefined) patch.due = due ? this.normalizeDue(due) : null;
193
+ if (status !== undefined) patch.status = status;
194
+
195
+ const response = await this.tasks.tasks.patch({
196
+ tasklist: tasklistId,
197
+ task: taskId,
198
+ requestBody: patch,
199
+ });
200
+
201
+ return this.parseTask(response.data);
202
+ } catch (error) {
203
+ if (this.isNotFoundError(error)) {
204
+ throw new CliError('NOT_FOUND', `Task not found: ${taskId}`);
205
+ }
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ throw new CliError('API_ERROR', `Failed to update task: ${message}`);
208
+ }
209
+ }
210
+
211
+ async completeTask(tasklistId: string, taskId: string): Promise<GTask> {
212
+ return this.updateTask({ tasklistId, taskId, status: 'completed' });
213
+ }
214
+
215
+ async uncompleteTask(tasklistId: string, taskId: string): Promise<GTask> {
216
+ return this.updateTask({ tasklistId, taskId, status: 'needsAction' });
217
+ }
218
+
219
+ async deleteTask(tasklistId: string, taskId: string): Promise<void> {
220
+ try {
221
+ await this.tasks.tasks.delete({
222
+ tasklist: tasklistId,
223
+ task: taskId,
224
+ });
225
+ } catch (error) {
226
+ if (this.isNotFoundError(error)) {
227
+ throw new CliError('NOT_FOUND', `Task not found: ${taskId}`);
228
+ }
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ throw new CliError('API_ERROR', `Failed to delete task: ${message}`);
231
+ }
232
+ }
233
+
234
+ async clearCompleted(tasklistId: string): Promise<void> {
235
+ try {
236
+ await this.tasks.tasks.clear({ tasklist: tasklistId });
237
+ } catch (error) {
238
+ if (this.isNotFoundError(error)) {
239
+ throw new CliError('NOT_FOUND', `Task list not found: ${tasklistId}`);
240
+ }
241
+ const message = error instanceof Error ? error.message : String(error);
242
+ throw new CliError('API_ERROR', `Failed to clear completed tasks: ${message}`);
243
+ }
244
+ }
245
+
246
+ async moveTask(tasklistId: string, taskId: string, parent?: string, previous?: string): Promise<GTask> {
247
+ try {
248
+ const params: tasks_v1.Params$Resource$Tasks$Move = {
249
+ tasklist: tasklistId,
250
+ task: taskId,
251
+ };
252
+
253
+ if (parent) params.parent = parent;
254
+ if (previous) params.previous = previous;
255
+
256
+ const response = await this.tasks.tasks.move(params);
257
+
258
+ return this.parseTask(response.data);
259
+ } catch (error) {
260
+ if (this.isNotFoundError(error)) {
261
+ throw new CliError('NOT_FOUND', `Task not found: ${taskId}`);
262
+ }
263
+ const message = error instanceof Error ? error.message : String(error);
264
+ throw new CliError('API_ERROR', `Failed to move task: ${message}`);
265
+ }
266
+ }
267
+
268
+ private normalizeDue(due: string): string {
269
+ // If it's already RFC3339, return as-is
270
+ if (due.includes('T')) {
271
+ return due;
272
+ }
273
+ // If it's just a date (YYYY-MM-DD), convert to RFC3339 with midnight UTC
274
+ return `${due}T00:00:00.000Z`;
275
+ }
276
+
277
+ private parseTask(task: tasks_v1.Schema$Task): GTask {
278
+ return {
279
+ id: task.id!,
280
+ title: task.title || '',
281
+ status: (task.status as 'needsAction' | 'completed') || 'needsAction',
282
+ notes: task.notes || undefined,
283
+ due: task.due || undefined,
284
+ completed: task.completed || undefined,
285
+ parent: task.parent || undefined,
286
+ position: task.position || undefined,
287
+ updated: task.updated || undefined,
288
+ selfLink: task.selfLink || undefined,
289
+ webViewLink: task.webViewLink || undefined,
290
+ hidden: task.hidden || undefined,
291
+ deleted: task.deleted || undefined,
292
+ };
293
+ }
294
+
295
+ private isNotFoundError(error: unknown): boolean {
296
+ if (error && typeof error === 'object' && 'code' in error) {
297
+ return (error as { code: unknown }).code === 404;
298
+ }
299
+ return false;
300
+ }
301
+ }