@agentuity/cli 1.0.29 → 1.0.31

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.
Files changed (61) hide show
  1. package/dist/agent-detection.d.ts.map +1 -1
  2. package/dist/agent-detection.js +23 -3
  3. package/dist/agent-detection.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +2 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cmd/cloud/keyvalue/repl.js +1 -1
  8. package/dist/cmd/cloud/keyvalue/repl.js.map +1 -1
  9. package/dist/cmd/cloud/keyvalue/search.js +2 -2
  10. package/dist/cmd/cloud/keyvalue/search.js.map +1 -1
  11. package/dist/cmd/cloud/storage/config.d.ts +2 -0
  12. package/dist/cmd/cloud/storage/config.d.ts.map +1 -0
  13. package/dist/cmd/cloud/storage/config.js +202 -0
  14. package/dist/cmd/cloud/storage/config.js.map +1 -0
  15. package/dist/cmd/cloud/storage/index.d.ts.map +1 -1
  16. package/dist/cmd/cloud/storage/index.js +2 -0
  17. package/dist/cmd/cloud/storage/index.js.map +1 -1
  18. package/dist/cmd/cloud/storage/list.d.ts.map +1 -1
  19. package/dist/cmd/cloud/storage/list.js +19 -0
  20. package/dist/cmd/cloud/storage/list.js.map +1 -1
  21. package/dist/cmd/cloud/task/create.d.ts.map +1 -1
  22. package/dist/cmd/cloud/task/create.js +15 -6
  23. package/dist/cmd/cloud/task/create.js.map +1 -1
  24. package/dist/cmd/cloud/task/delete.d.ts +8 -0
  25. package/dist/cmd/cloud/task/delete.d.ts.map +1 -0
  26. package/dist/cmd/cloud/task/delete.js +281 -0
  27. package/dist/cmd/cloud/task/delete.js.map +1 -0
  28. package/dist/cmd/cloud/task/get.d.ts.map +1 -1
  29. package/dist/cmd/cloud/task/get.js +10 -3
  30. package/dist/cmd/cloud/task/get.js.map +1 -1
  31. package/dist/cmd/cloud/task/index.d.ts.map +1 -1
  32. package/dist/cmd/cloud/task/index.js +10 -0
  33. package/dist/cmd/cloud/task/index.js.map +1 -1
  34. package/dist/cmd/cloud/task/list.d.ts.map +1 -1
  35. package/dist/cmd/cloud/task/list.js +2 -0
  36. package/dist/cmd/cloud/task/list.js.map +1 -1
  37. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  38. package/dist/cmd/project/template-flow.js +30 -2
  39. package/dist/cmd/project/template-flow.js.map +1 -1
  40. package/dist/domain.d.ts.map +1 -1
  41. package/dist/domain.js +48 -0
  42. package/dist/domain.js.map +1 -1
  43. package/dist/tui.d.ts.map +1 -1
  44. package/dist/tui.js +3 -1
  45. package/dist/tui.js.map +1 -1
  46. package/package.json +6 -6
  47. package/src/agent-detection.ts +23 -3
  48. package/src/cli.ts +1 -0
  49. package/src/cmd/cloud/keyvalue/repl.ts +1 -1
  50. package/src/cmd/cloud/keyvalue/search.ts +2 -2
  51. package/src/cmd/cloud/storage/config.ts +238 -0
  52. package/src/cmd/cloud/storage/index.ts +2 -0
  53. package/src/cmd/cloud/storage/list.ts +18 -0
  54. package/src/cmd/cloud/task/create.ts +17 -8
  55. package/src/cmd/cloud/task/delete.ts +331 -0
  56. package/src/cmd/cloud/task/get.ts +11 -3
  57. package/src/cmd/cloud/task/index.ts +10 -0
  58. package/src/cmd/cloud/task/list.ts +2 -0
  59. package/src/cmd/project/template-flow.ts +31 -1
  60. package/src/domain.ts +52 -4
  61. package/src/tui.ts +2 -1
@@ -0,0 +1,238 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ getBucketConfig,
4
+ updateBucketConfig,
5
+ deleteBucketConfig,
6
+ BucketConfigResponseError,
7
+ listOrgResources,
8
+ type BucketConfigUpdate,
9
+ type BucketConfig,
10
+ StorageTierSchema,
11
+ } from '@agentuity/server';
12
+ import { createSubcommand } from '../../../types';
13
+ import * as tui from '../../../tui';
14
+ import { getCatalystAPIClient, getGlobalCatalystAPIClient } from '../../../config';
15
+ import { getCommand } from '../../../command-prefix';
16
+ import { getResourceInfo, setResourceInfo } from '../../../cache';
17
+
18
+ function displayConfig(config: BucketConfig) {
19
+ tui.newline();
20
+ console.log(tui.bold('Bucket: ') + config.bucket_name);
21
+ console.log(
22
+ tui.bold('Storage Tier: ') + (config.storage_tier ?? tui.muted('default'))
23
+ );
24
+ console.log(
25
+ tui.bold('TTL: ') +
26
+ (config.ttl != null ? `${config.ttl}s` : tui.muted('default'))
27
+ );
28
+ console.log(
29
+ tui.bold('Public: ') +
30
+ (config.public != null ? String(config.public) : tui.muted('default'))
31
+ );
32
+ console.log(
33
+ tui.bold('Cache Control: ') + (config.cache_control ?? tui.muted('default'))
34
+ );
35
+
36
+ if (config.cors) {
37
+ console.log(tui.bold('CORS:'));
38
+ if (config.cors.allowed_origins?.length) {
39
+ console.log(' Origins: ' + config.cors.allowed_origins.join(', '));
40
+ }
41
+ if (config.cors.allowed_methods?.length) {
42
+ console.log(' Methods: ' + config.cors.allowed_methods.join(', '));
43
+ }
44
+ if (config.cors.allowed_headers?.length) {
45
+ console.log(' Headers: ' + config.cors.allowed_headers.join(', '));
46
+ }
47
+ if (config.cors.expose_headers?.length) {
48
+ console.log(' Expose: ' + config.cors.expose_headers.join(', '));
49
+ }
50
+ if (config.cors.max_age_seconds != null) {
51
+ console.log(' Max Age: ' + config.cors.max_age_seconds + 's');
52
+ }
53
+ } else {
54
+ console.log(tui.bold('CORS: ') + tui.muted('default'));
55
+ }
56
+
57
+ if (config.additional_headers && Object.keys(config.additional_headers).length > 0) {
58
+ console.log(tui.bold('Headers:'));
59
+ for (const [key, value] of Object.entries(config.additional_headers)) {
60
+ console.log(` ${key}: ${value}`);
61
+ }
62
+ } else {
63
+ console.log(tui.bold('Headers: ') + tui.muted('default'));
64
+ }
65
+ tui.newline();
66
+ }
67
+
68
+ export const configSubcommand = createSubcommand({
69
+ name: 'config',
70
+ description: 'View or update bucket configuration',
71
+ tags: ['slow', 'requires-auth'],
72
+ requires: { auth: true },
73
+ optional: { org: true },
74
+ idempotent: true,
75
+ examples: [
76
+ {
77
+ command: `${getCommand('cloud storage config')} my-bucket`,
78
+ description: 'View bucket configuration',
79
+ },
80
+ {
81
+ command: `${getCommand('cloud storage config')} my-bucket --ttl 3600 --public`,
82
+ description: 'Update bucket TTL and make it public',
83
+ },
84
+ {
85
+ command: `${getCommand('cloud storage config')} my-bucket --storage-tier ARCHIVE`,
86
+ description: 'Change the storage tier',
87
+ },
88
+ {
89
+ command: `${getCommand('cloud storage config')} my-bucket --reset`,
90
+ description: 'Reset all configuration to system defaults',
91
+ },
92
+ ],
93
+ schema: {
94
+ args: z.object({
95
+ name: z.string().describe('The name of the storage bucket'),
96
+ }),
97
+ options: z.object({
98
+ reset: z.boolean().optional().describe('Reset all configuration to system defaults'),
99
+ storageTier: StorageTierSchema.optional().describe('Storage tier'),
100
+ ttl: z.coerce.number().optional().describe('Object TTL in seconds (0 to clear)'),
101
+ public: z.boolean().optional().describe('Make bucket publicly accessible'),
102
+ cacheControl: z.string().optional().describe('Cache-Control header value'),
103
+ cors: z.string().optional().describe('CORS configuration as JSON string'),
104
+ additionalHeaders: z
105
+ .string()
106
+ .optional()
107
+ .describe('Additional headers as JSON key-value pairs'),
108
+ }),
109
+ response: z.object({
110
+ bucket_name: z.string(),
111
+ storage_tier: z.string().nullable().optional(),
112
+ ttl: z.number().nullable().optional(),
113
+ public: z.boolean().nullable().optional(),
114
+ cache_control: z.string().nullable().optional(),
115
+ cors: z.any().nullable().optional(),
116
+ additional_headers: z.record(z.string(), z.string()).nullable().optional(),
117
+ }),
118
+ },
119
+
120
+ async handler(ctx) {
121
+ const { logger, args, opts, options, auth, config } = ctx;
122
+ const { name: bucketName } = args;
123
+
124
+ const profileName = config?.name ?? 'production';
125
+ const catalystClient = await getGlobalCatalystAPIClient(logger, auth, profileName);
126
+
127
+ // Look up bucket to get cloud_region
128
+ const cachedInfo = await getResourceInfo('bucket', profileName, bucketName);
129
+ const orgId = ctx.orgId ?? cachedInfo?.orgId;
130
+
131
+ const resources = await tui.spinner({
132
+ message: 'Looking up bucket...',
133
+ clearOnSuccess: true,
134
+ callback: () => listOrgResources(catalystClient, { type: 's3', orgId }),
135
+ });
136
+
137
+ const bucket = resources.s3.find((s3) => s3.bucket_name === bucketName);
138
+ if (!bucket) {
139
+ throw new BucketConfigResponseError({ message: `Bucket "${bucketName}" not found` });
140
+ }
141
+
142
+ // Cache the bucket info for future lookups
143
+ if (bucket.cloud_region && bucket.org_id) {
144
+ await setResourceInfo(
145
+ 'bucket',
146
+ profileName,
147
+ bucket.bucket_name,
148
+ bucket.cloud_region,
149
+ bucket.org_id
150
+ );
151
+ }
152
+
153
+ if (!bucket.cloud_region) {
154
+ throw new BucketConfigResponseError({
155
+ message: `Bucket "${bucketName}" is missing region information`,
156
+ });
157
+ }
158
+
159
+ // Create regional client for bucket config operations (orgId required for CLI auth)
160
+ const regionalClient = getCatalystAPIClient(logger, auth, bucket.cloud_region, bucket.org_id);
161
+
162
+ // Handle --reset flag (DELETE)
163
+ if (opts.reset) {
164
+ await tui.spinner({
165
+ message: 'Resetting bucket configuration...',
166
+ clearOnSuccess: true,
167
+ callback: () => deleteBucketConfig(regionalClient, bucketName),
168
+ });
169
+ if (!options.json) {
170
+ tui.success(`Configuration reset to defaults for bucket "${bucketName}"`);
171
+ }
172
+ return { bucket_name: bucketName };
173
+ }
174
+
175
+ // Check if any update flags are present
176
+ const hasUpdateFlags =
177
+ opts.storageTier !== undefined ||
178
+ opts.ttl !== undefined ||
179
+ opts.public !== undefined ||
180
+ opts.cacheControl !== undefined ||
181
+ opts.cors !== undefined ||
182
+ opts.additionalHeaders !== undefined;
183
+
184
+ if (hasUpdateFlags) {
185
+ // Build update payload
186
+ const update: BucketConfigUpdate = {};
187
+
188
+ if (opts.storageTier !== undefined) update.storage_tier = opts.storageTier;
189
+ if (opts.ttl !== undefined) update.ttl = opts.ttl === 0 ? null : opts.ttl;
190
+ if (opts.public !== undefined) update.public = opts.public;
191
+ if (opts.cacheControl !== undefined) update.cache_control = opts.cacheControl;
192
+
193
+ // Parse JSON flags
194
+ if (opts.cors !== undefined) {
195
+ try {
196
+ update.cors = JSON.parse(opts.cors);
197
+ } catch {
198
+ throw new BucketConfigResponseError({
199
+ message: 'Invalid JSON for --cors flag',
200
+ });
201
+ }
202
+ }
203
+ if (opts.additionalHeaders !== undefined) {
204
+ try {
205
+ update.additional_headers = JSON.parse(opts.additionalHeaders);
206
+ } catch {
207
+ throw new BucketConfigResponseError({
208
+ message: 'Invalid JSON for --additionalHeaders flag',
209
+ });
210
+ }
211
+ }
212
+
213
+ const result = await tui.spinner({
214
+ message: 'Updating bucket configuration...',
215
+ clearOnSuccess: true,
216
+ callback: () => updateBucketConfig(regionalClient, bucketName, update),
217
+ });
218
+
219
+ if (!options.json) {
220
+ displayConfig(result);
221
+ tui.success(`Configuration updated for bucket "${bucketName}"`);
222
+ }
223
+ return result;
224
+ }
225
+
226
+ // No update flags — GET and display
227
+ const getResult = await tui.spinner({
228
+ message: 'Fetching bucket configuration...',
229
+ clearOnSuccess: true,
230
+ callback: () => getBucketConfig(regionalClient, bucketName),
231
+ });
232
+
233
+ if (!options.json) {
234
+ displayConfig(getResult);
235
+ }
236
+ return getResult;
237
+ },
238
+ });
@@ -1,4 +1,5 @@
1
1
  import { createCommand } from '../../../types';
2
+ import { configSubcommand } from './config';
2
3
  import { createSubcommand } from './create';
3
4
  import { listSubcommand } from './list';
4
5
  import { deleteSubcommand } from './delete';
@@ -20,6 +21,7 @@ export const storageCommand = createCommand({
20
21
  },
21
22
  ],
22
23
  subcommands: [
24
+ configSubcommand,
23
25
  createSubcommand,
24
26
  listSubcommand,
25
27
  getSubcommand,
@@ -23,6 +23,9 @@ const StorageListResponseSchema = z.object({
23
23
  bucket_type: z.string().optional().describe('Bucket type (user or snapshots)'),
24
24
  internal: z.boolean().optional().describe('Whether this is a system-managed bucket'),
25
25
  description: z.string().optional().describe('Optional description of the bucket'),
26
+ object_count: z.number().int().optional().describe('Number of objects in this bucket'),
27
+ total_size: z.number().int().optional().describe('Total size of objects in bytes'),
28
+ last_event_at: z.string().optional().describe('Last activity timestamp'),
26
29
  })
27
30
  )
28
31
  .optional()
@@ -256,6 +259,18 @@ export const listSubcommand = createSubcommand({
256
259
  }
257
260
  if (s3.region) console.log(` Region: ${tui.muted(s3.region)}`);
258
261
  if (s3.endpoint) console.log(` Endpoint: ${tui.muted(s3.endpoint)}`);
262
+ if (s3.object_count != null) {
263
+ const sizeStr = s3.total_size != null ? tui.formatBytes(s3.total_size) : 'unknown';
264
+ console.log(` Objects: ${tui.muted(`${s3.object_count.toLocaleString()} (${sizeStr})`)}`);
265
+ }
266
+ if (s3.last_event_at) {
267
+ const date = new Date(s3.last_event_at);
268
+ if (Number.isNaN(date.getTime())) {
269
+ console.log(` Activity: ${tui.muted('unknown')}`);
270
+ } else {
271
+ console.log(` Activity: ${tui.muted(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }))}`);
272
+ }
273
+ }
259
274
  tui.newline();
260
275
  }
261
276
  }
@@ -274,6 +289,9 @@ export const listSubcommand = createSubcommand({
274
289
  bucket_type: s3.bucket_type,
275
290
  internal: s3.internal,
276
291
  description: s3.description ?? undefined,
292
+ object_count: s3.object_count ?? undefined,
293
+ total_size: s3.total_size ?? undefined,
294
+ last_event_at: s3.last_event_at ?? undefined,
277
295
  })),
278
296
  };
279
297
  },
@@ -5,7 +5,7 @@ import * as tui from '../../../tui';
5
5
  import { createStorageAdapter, parseMetadataFlag, cacheTaskId } from './util';
6
6
  import { getCommand } from '../../../command-prefix';
7
7
  import { whoami } from '@agentuity/server';
8
- import type { TaskPriority, TaskStatus, TaskType } from '@agentuity/core';
8
+ import type { TaskPriority, TaskStatus, TaskType, UserType } from '@agentuity/core';
9
9
  import { getCachedUserInfo, setCachedUserInfo } from '../../../cache';
10
10
  import { defaultProfileName } from '../../../config';
11
11
 
@@ -70,6 +70,10 @@ export const createSubcommand = createCommand({
70
70
  .min(1)
71
71
  .optional()
72
72
  .describe('the display name of the creator (used with --created-id)'),
73
+ createdType: z
74
+ .enum(['human', 'agent'])
75
+ .optional()
76
+ .describe('the type of the creator - human user or AI agent (default: human)'),
73
77
  projectId: z.string().optional().describe('project ID to associate with the task'),
74
78
  projectName: z
75
79
  .string()
@@ -101,18 +105,23 @@ export const createSubcommand = createCommand({
101
105
 
102
106
  // Resolve creator info
103
107
  const createdId = opts.createdId ?? ctx.auth.userId;
104
- let creator: { id: string; name: string } | undefined;
105
- if (opts.createdId && opts.createdName) {
106
- // Explicit creator with name
107
- creator = { id: opts.createdId, name: opts.createdName };
108
- } else if (!opts.createdId) {
108
+ const createdType = (opts.createdType as UserType) ?? 'human';
109
+ let creator: { id: string; name: string; type?: UserType } | undefined;
110
+ if (opts.createdId) {
111
+ // Explicit creator use createdId as name fallback (like project pattern)
112
+ creator = {
113
+ id: opts.createdId,
114
+ name: opts.createdName ?? opts.createdId,
115
+ type: createdType,
116
+ };
117
+ } else {
109
118
  // Using auth userId — check cache first, then fall back to whoami API call
110
119
  const profileName = ctx.config?.name ?? defaultProfileName;
111
120
  const cached = getCachedUserInfo(profileName);
112
121
  if (cached) {
113
122
  const name = [cached.firstName, cached.lastName].filter(Boolean).join(' ');
114
123
  if (name) {
115
- creator = { id: createdId, name };
124
+ creator = { id: createdId, name, type: createdType };
116
125
  }
117
126
  } else {
118
127
  // Fetch from API and cache
@@ -120,7 +129,7 @@ export const createSubcommand = createCommand({
120
129
  const user = await whoami(ctx.apiClient);
121
130
  const name = [user.firstName, user.lastName].filter(Boolean).join(' ');
122
131
  if (name) {
123
- creator = { id: createdId, name };
132
+ creator = { id: createdId, name, type: createdType };
124
133
  }
125
134
  setCachedUserInfo(profileName, createdId, user.firstName, user.lastName);
126
135
  } catch {
@@ -0,0 +1,331 @@
1
+ import { z } from 'zod';
2
+ import { createCommand } from '../../../types';
3
+ import * as tui from '../../../tui';
4
+ import { createStorageAdapter } from './util';
5
+ import { getCommand } from '../../../command-prefix';
6
+ import { isDryRunMode, outputDryRun } from '../../../explain';
7
+ import type { TaskPriority, TaskStatus, TaskType, BatchDeletedTask } from '@agentuity/core';
8
+
9
+ const DURATION_UNITS: Record<string, number> = {
10
+ s: 1000,
11
+ m: 60 * 1000,
12
+ h: 60 * 60 * 1000,
13
+ d: 24 * 60 * 60 * 1000,
14
+ w: 7 * 24 * 60 * 60 * 1000,
15
+ };
16
+
17
+ /**
18
+ * Parse a human-friendly duration string (e.g. "30s", "7d", "24h", "30m", "2w")
19
+ * into milliseconds. Exported for testing.
20
+ */
21
+ export function parseDuration(duration: string): number {
22
+ const match = duration.match(/^(\d+)([smhdw])$/);
23
+ if (!match) {
24
+ tui.fatal(
25
+ `Invalid duration format: "${duration}". Use a number followed by s (seconds), m (minutes), h (hours), d (days), or w (weeks). Examples: 30s, 30m, 24h, 7d, 2w`
26
+ );
27
+ // tui.fatal exits, but TypeScript doesn't know that
28
+ throw new Error('unreachable');
29
+ }
30
+ const value = parseInt(match[1]!, 10);
31
+ const unit = match[2]!;
32
+ const ms = DURATION_UNITS[unit];
33
+ if (!ms) {
34
+ tui.fatal(`Unknown duration unit: "${unit}"`);
35
+ throw new Error('unreachable');
36
+ }
37
+ return value * ms;
38
+ }
39
+
40
+ function truncate(s: string, max: number): string {
41
+ if (s.length <= max) return s;
42
+ return `${s.slice(0, max - 1)}…`;
43
+ }
44
+
45
+ const TaskDeleteResponseSchema = z.object({
46
+ success: z.boolean().describe('Whether the operation succeeded'),
47
+ deleted: z
48
+ .array(
49
+ z.object({
50
+ id: z.string().describe('Deleted task ID'),
51
+ title: z.string().describe('Deleted task title'),
52
+ })
53
+ )
54
+ .describe('List of deleted tasks'),
55
+ count: z.number().describe('Number of tasks deleted'),
56
+ durationMs: z.number().describe('Operation duration in milliseconds'),
57
+ dryRun: z.boolean().optional().describe('Whether this was a dry run'),
58
+ message: z.string().optional().describe('Status message'),
59
+ });
60
+
61
+ export const deleteSubcommand = createCommand({
62
+ name: 'delete',
63
+ aliases: ['del', 'rm'],
64
+ description: 'Soft-delete a task by ID or batch-delete tasks by filter',
65
+ tags: ['destructive', 'deletes-resource', 'slow', 'requires-auth'],
66
+ requires: { auth: true },
67
+ examples: [
68
+ {
69
+ command: getCommand('cloud task delete task_abc123'),
70
+ description: 'Delete a single task by ID',
71
+ },
72
+ {
73
+ command: getCommand('cloud task delete --status closed --older-than 7d'),
74
+ description: 'Delete closed tasks older than 7 days',
75
+ },
76
+ {
77
+ command: getCommand('cloud task delete --status done --limit 10 --dry-run'),
78
+ description: 'Preview which done tasks would be deleted (dry run)',
79
+ },
80
+ {
81
+ command: getCommand('cloud task delete --status cancelled --confirm'),
82
+ description: 'Delete all cancelled tasks without confirmation prompt',
83
+ },
84
+ ],
85
+ schema: {
86
+ args: z.object({
87
+ id: z.string().optional().describe('Task ID to delete (for single delete)'),
88
+ }),
89
+ options: z.object({
90
+ status: z
91
+ .enum(['open', 'in_progress', 'done', 'closed', 'cancelled'])
92
+ .optional()
93
+ .describe('filter batch delete by status'),
94
+ type: z
95
+ .enum(['epic', 'feature', 'enhancement', 'bug', 'task'])
96
+ .optional()
97
+ .describe('filter batch delete by type'),
98
+ priority: z
99
+ .enum(['high', 'medium', 'low', 'none'])
100
+ .optional()
101
+ .describe('filter batch delete by priority'),
102
+ olderThan: z
103
+ .string()
104
+ .optional()
105
+ .describe('filter batch delete by age (e.g. 30s, 7d, 24h, 2w)'),
106
+ parentId: z.string().optional().describe('filter batch delete by parent task ID'),
107
+ createdId: z.string().optional().describe('filter batch delete by creator ID'),
108
+ limit: z.coerce
109
+ .number()
110
+ .int()
111
+ .min(1)
112
+ .max(200)
113
+ .default(50)
114
+ .describe('max tasks to delete in batch mode (default: 50, max: 200)'),
115
+ confirm: z.boolean().optional().default(false).describe('skip confirmation prompt'),
116
+ }),
117
+ response: TaskDeleteResponseSchema,
118
+ },
119
+
120
+ async handler(ctx) {
121
+ const { args, opts, options } = ctx;
122
+ const started = Date.now();
123
+ const storage = await createStorageAdapter(ctx);
124
+
125
+ // Determine mode: single delete or batch delete
126
+ const isSingleDelete = !!args.id;
127
+ const hasFilters =
128
+ opts.status || opts.type || opts.priority || opts.olderThan || opts.parentId || opts.createdId;
129
+
130
+ if (!isSingleDelete && !hasFilters) {
131
+ tui.fatal(
132
+ 'Provide a task ID for single delete, or use --status, --type, --priority, --older-than, --parent-id, or --created-id for batch delete.'
133
+ );
134
+ }
135
+
136
+ if (isSingleDelete && hasFilters) {
137
+ tui.fatal(
138
+ 'Cannot combine task ID with filter options. Use either single delete (by ID) or batch delete (by filters).'
139
+ );
140
+ }
141
+
142
+ // ── Single delete mode ──────────────────────────────────────────────
143
+ if (isSingleDelete) {
144
+ if (isDryRunMode(options)) {
145
+ outputDryRun(`Would soft-delete task: ${args.id}`, options);
146
+ return {
147
+ success: true,
148
+ deleted: [{ id: args.id!, title: '(dry run)' }],
149
+ count: 1,
150
+ durationMs: Date.now() - started,
151
+ dryRun: true,
152
+ message: 'Dry run — no tasks were deleted',
153
+ };
154
+ }
155
+
156
+ if (!opts.confirm) {
157
+ const confirmed = await tui.confirm(`Delete task "${args.id}"?`, false);
158
+ if (!confirmed) {
159
+ if (!options.json) tui.info('Cancelled');
160
+ return {
161
+ success: false,
162
+ deleted: [],
163
+ count: 0,
164
+ durationMs: Date.now() - started,
165
+ message: 'Cancelled',
166
+ };
167
+ }
168
+ }
169
+
170
+ const task = await storage.softDelete(args.id!);
171
+ const durationMs = Date.now() - started;
172
+
173
+ if (!options.json) {
174
+ tui.success(`Deleted task ${tui.bold(task.id)} (${task.title}) in ${durationMs}ms`);
175
+ }
176
+
177
+ return {
178
+ success: true,
179
+ deleted: [{ id: task.id, title: task.title }],
180
+ count: 1,
181
+ durationMs,
182
+ };
183
+ }
184
+
185
+ // ── Batch delete mode ───────────────────────────────────────────────
186
+ // Validate older-than format early (before calling the API)
187
+ if (opts.olderThan) {
188
+ parseDuration(opts.olderThan); // will fatal on invalid format
189
+ }
190
+
191
+ const batchParams = {
192
+ status: opts.status as TaskStatus | undefined,
193
+ type: opts.type as TaskType | undefined,
194
+ priority: opts.priority as TaskPriority | undefined,
195
+ parent_id: opts.parentId,
196
+ created_id: opts.createdId,
197
+ older_than: opts.olderThan,
198
+ limit: opts.limit,
199
+ };
200
+
201
+ // For dry-run and preview, first list what would be matched
202
+ // (we call batchDelete only when actually executing)
203
+ if (isDryRunMode(options) || !opts.confirm) {
204
+ // Use list() to preview matching tasks
205
+ const preview = await storage.list({
206
+ status: batchParams.status,
207
+ type: batchParams.type,
208
+ priority: batchParams.priority,
209
+ parent_id: batchParams.parent_id,
210
+ limit: batchParams.limit,
211
+ sort: 'created_at',
212
+ order: 'asc',
213
+ });
214
+
215
+ // Client-side filters for preview (server will apply these on actual delete)
216
+ let candidates = preview.tasks;
217
+ if (batchParams.created_id) {
218
+ candidates = candidates.filter(
219
+ (t: { created_id: string }) => t.created_id === batchParams.created_id
220
+ );
221
+ }
222
+ if (opts.olderThan) {
223
+ const durationMs = parseDuration(opts.olderThan);
224
+ const cutoff = new Date(Date.now() - durationMs);
225
+ candidates = candidates.filter(
226
+ (t: { created_at: string }) => new Date(t.created_at) < cutoff
227
+ );
228
+ }
229
+
230
+ if (candidates.length === 0) {
231
+ if (!options.json) tui.info('No tasks match the given filters');
232
+ return {
233
+ success: true,
234
+ deleted: [],
235
+ count: 0,
236
+ durationMs: Date.now() - started,
237
+ message: 'No matching tasks found',
238
+ };
239
+ }
240
+
241
+ // Show preview table
242
+ if (!options.json) {
243
+ tui.warning(
244
+ `Found ${candidates.length} ${tui.plural(candidates.length, 'task', 'tasks')} to delete:`
245
+ );
246
+ tui.newline();
247
+
248
+ const tableData = candidates.map(
249
+ (task: { id: string; title: string; status: string; type: string; created_at: string }) => ({
250
+ ID: tui.muted(truncate(task.id, 28)),
251
+ Title: truncate(task.title, 40),
252
+ Status: task.status,
253
+ Type: task.type,
254
+ Created: new Date(task.created_at).toLocaleDateString(),
255
+ })
256
+ );
257
+
258
+ tui.table(tableData, [
259
+ { name: 'ID', alignment: 'left' },
260
+ { name: 'Title', alignment: 'left' },
261
+ { name: 'Status', alignment: 'left' },
262
+ { name: 'Type', alignment: 'left' },
263
+ { name: 'Created', alignment: 'left' },
264
+ ]);
265
+ tui.newline();
266
+ }
267
+
268
+ // Dry-run: return preview without executing
269
+ if (isDryRunMode(options)) {
270
+ outputDryRun(
271
+ `Would soft-delete ${candidates.length} ${tui.plural(candidates.length, 'task', 'tasks')}`,
272
+ options
273
+ );
274
+ return {
275
+ success: true,
276
+ deleted: candidates.map(
277
+ (t: { id: string; title: string }): BatchDeletedTask => ({
278
+ id: t.id,
279
+ title: t.title,
280
+ })
281
+ ),
282
+ count: candidates.length,
283
+ durationMs: Date.now() - started,
284
+ dryRun: true,
285
+ message: 'Dry run — no tasks were deleted',
286
+ };
287
+ }
288
+
289
+ // Confirmation prompt
290
+ if (!opts.confirm) {
291
+ const confirmed = await tui.confirm(
292
+ `Delete ${candidates.length} ${tui.plural(candidates.length, 'task', 'tasks')}?`,
293
+ false
294
+ );
295
+ if (!confirmed) {
296
+ if (!options.json) tui.info('Cancelled');
297
+ return {
298
+ success: false,
299
+ deleted: [],
300
+ count: 0,
301
+ durationMs: Date.now() - started,
302
+ message: 'Cancelled',
303
+ };
304
+ }
305
+ }
306
+ }
307
+
308
+ // Execute batch delete via server-side API
309
+ const result = await storage.batchDelete(batchParams);
310
+ const durationMs = Date.now() - started;
311
+
312
+ if (!options.json) {
313
+ if (result.count > 0) {
314
+ tui.success(
315
+ `Deleted ${result.count} ${tui.plural(result.count, 'task', 'tasks')} in ${durationMs}ms`
316
+ );
317
+ } else {
318
+ tui.info('No tasks matched the given filters');
319
+ }
320
+ }
321
+
322
+ return {
323
+ success: true,
324
+ deleted: result.deleted,
325
+ count: result.count,
326
+ durationMs,
327
+ };
328
+ },
329
+ });
330
+
331
+ export default deleteSubcommand;