@agentuity/cli 2.0.5 → 2.0.7

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 (128) hide show
  1. package/README.md +11 -0
  2. package/dist/cmd/build/patch/otel-llm.js +2 -2
  3. package/dist/cmd/build/patch/otel-llm.js.map +1 -1
  4. package/dist/cmd/build/vite/bun-dev-server.d.ts.map +1 -1
  5. package/dist/cmd/build/vite/bun-dev-server.js +1 -0
  6. package/dist/cmd/build/vite/bun-dev-server.js.map +1 -1
  7. package/dist/cmd/build/vite/index.d.ts +0 -28
  8. package/dist/cmd/build/vite/index.d.ts.map +1 -1
  9. package/dist/cmd/build/vite/index.js +1 -104
  10. package/dist/cmd/build/vite/index.js.map +1 -1
  11. package/dist/cmd/build/vite/metadata-generator.d.ts.map +1 -1
  12. package/dist/cmd/build/vite/metadata-generator.js +8 -2
  13. package/dist/cmd/build/vite/metadata-generator.js.map +1 -1
  14. package/dist/cmd/build/vite/vite-asset-server-config.d.ts +2 -0
  15. package/dist/cmd/build/vite/vite-asset-server-config.d.ts.map +1 -1
  16. package/dist/cmd/build/vite/vite-asset-server-config.js +5 -1
  17. package/dist/cmd/build/vite/vite-asset-server-config.js.map +1 -1
  18. package/dist/cmd/build/vite/vite-asset-server.d.ts +2 -0
  19. package/dist/cmd/build/vite/vite-asset-server.d.ts.map +1 -1
  20. package/dist/cmd/build/vite/vite-asset-server.js +2 -1
  21. package/dist/cmd/build/vite/vite-asset-server.js.map +1 -1
  22. package/dist/cmd/build/vite/vite-builder.d.ts.map +1 -1
  23. package/dist/cmd/build/vite/vite-builder.js +143 -2
  24. package/dist/cmd/build/vite/vite-builder.js.map +1 -1
  25. package/dist/cmd/cloud/task/close.d.ts +3 -0
  26. package/dist/cmd/cloud/task/close.d.ts.map +1 -0
  27. package/dist/cmd/cloud/task/close.js +286 -0
  28. package/dist/cmd/cloud/task/close.js.map +1 -0
  29. package/dist/cmd/cloud/task/delete.d.ts +1 -5
  30. package/dist/cmd/cloud/task/delete.d.ts.map +1 -1
  31. package/dist/cmd/cloud/task/delete.js +15 -38
  32. package/dist/cmd/cloud/task/delete.js.map +1 -1
  33. package/dist/cmd/cloud/task/index.d.ts.map +1 -1
  34. package/dist/cmd/cloud/task/index.js +10 -0
  35. package/dist/cmd/cloud/task/index.js.map +1 -1
  36. package/dist/cmd/cloud/task/list.d.ts.map +1 -1
  37. package/dist/cmd/cloud/task/list.js +97 -3
  38. package/dist/cmd/cloud/task/list.js.map +1 -1
  39. package/dist/cmd/cloud/task/util.d.ts +10 -0
  40. package/dist/cmd/cloud/task/util.d.ts.map +1 -1
  41. package/dist/cmd/cloud/task/util.js +47 -3
  42. package/dist/cmd/cloud/task/util.js.map +1 -1
  43. package/dist/cmd/coder/config/index.d.ts +2 -0
  44. package/dist/cmd/coder/config/index.d.ts.map +1 -0
  45. package/dist/cmd/coder/config/index.js +20 -0
  46. package/dist/cmd/coder/config/index.js.map +1 -0
  47. package/dist/cmd/coder/config/set.d.ts +2 -0
  48. package/dist/cmd/coder/config/set.d.ts.map +1 -0
  49. package/dist/cmd/coder/config/set.js +100 -0
  50. package/dist/cmd/coder/config/set.js.map +1 -0
  51. package/dist/cmd/coder/hub-url.d.ts +21 -10
  52. package/dist/cmd/coder/hub-url.d.ts.map +1 -1
  53. package/dist/cmd/coder/hub-url.js +97 -55
  54. package/dist/cmd/coder/hub-url.js.map +1 -1
  55. package/dist/cmd/coder/index.d.ts.map +1 -1
  56. package/dist/cmd/coder/index.js +6 -1
  57. package/dist/cmd/coder/index.js.map +1 -1
  58. package/dist/cmd/coder/inspect.d.ts.map +1 -1
  59. package/dist/cmd/coder/inspect.js +15 -7
  60. package/dist/cmd/coder/inspect.js.map +1 -1
  61. package/dist/cmd/coder/list.d.ts.map +1 -1
  62. package/dist/cmd/coder/list.js +14 -7
  63. package/dist/cmd/coder/list.js.map +1 -1
  64. package/dist/cmd/coder/start.d.ts.map +1 -1
  65. package/dist/cmd/coder/start.js +38 -23
  66. package/dist/cmd/coder/start.js.map +1 -1
  67. package/dist/cmd/coder/tui-init.d.ts +4 -1
  68. package/dist/cmd/coder/tui-init.d.ts.map +1 -1
  69. package/dist/cmd/coder/tui-init.js +3 -2
  70. package/dist/cmd/coder/tui-init.js.map +1 -1
  71. package/dist/cmd/dev/index.d.ts.map +1 -1
  72. package/dist/cmd/dev/index.js +1 -0
  73. package/dist/cmd/dev/index.js.map +1 -1
  74. package/dist/cmd/dev/sync.js +5 -5
  75. package/dist/cmd/dev/sync.js.map +1 -1
  76. package/dist/coder-config.d.ts +14 -0
  77. package/dist/coder-config.d.ts.map +1 -0
  78. package/dist/coder-config.js +119 -0
  79. package/dist/coder-config.js.map +1 -0
  80. package/dist/coder-hub-url.d.ts +3 -0
  81. package/dist/coder-hub-url.d.ts.map +1 -0
  82. package/dist/coder-hub-url.js +32 -0
  83. package/dist/coder-hub-url.js.map +1 -0
  84. package/dist/config.d.ts +1 -0
  85. package/dist/config.d.ts.map +1 -1
  86. package/dist/config.js +11 -0
  87. package/dist/config.js.map +1 -1
  88. package/dist/internal-logger.d.ts +4 -0
  89. package/dist/internal-logger.d.ts.map +1 -1
  90. package/dist/internal-logger.js +64 -2
  91. package/dist/internal-logger.js.map +1 -1
  92. package/dist/keychain.d.ts +3 -0
  93. package/dist/keychain.d.ts.map +1 -1
  94. package/dist/keychain.js +47 -28
  95. package/dist/keychain.js.map +1 -1
  96. package/dist/types.d.ts +4 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/types.js +10 -0
  99. package/dist/types.js.map +1 -1
  100. package/package.json +6 -6
  101. package/src/cmd/build/patch/otel-llm.ts +2 -2
  102. package/src/cmd/build/vite/bun-dev-server.ts +1 -0
  103. package/src/cmd/build/vite/index.ts +1 -148
  104. package/src/cmd/build/vite/metadata-generator.ts +8 -2
  105. package/src/cmd/build/vite/vite-asset-server-config.ts +16 -1
  106. package/src/cmd/build/vite/vite-asset-server.ts +4 -0
  107. package/src/cmd/build/vite/vite-builder.ts +171 -9
  108. package/src/cmd/cloud/task/close.ts +319 -0
  109. package/src/cmd/cloud/task/delete.ts +15 -43
  110. package/src/cmd/cloud/task/index.ts +10 -0
  111. package/src/cmd/cloud/task/list.ts +111 -4
  112. package/src/cmd/cloud/task/util.ts +59 -5
  113. package/src/cmd/coder/config/index.ts +20 -0
  114. package/src/cmd/coder/config/set.ts +112 -0
  115. package/src/cmd/coder/hub-url.ts +147 -53
  116. package/src/cmd/coder/index.ts +6 -1
  117. package/src/cmd/coder/inspect.ts +33 -10
  118. package/src/cmd/coder/list.ts +33 -10
  119. package/src/cmd/coder/start.ts +62 -26
  120. package/src/cmd/coder/tui-init.ts +7 -2
  121. package/src/cmd/dev/index.ts +1 -0
  122. package/src/cmd/dev/sync.ts +5 -5
  123. package/src/coder-config.ts +141 -0
  124. package/src/coder-hub-url.ts +32 -0
  125. package/src/config.ts +13 -0
  126. package/src/internal-logger.ts +83 -2
  127. package/src/keychain.ts +68 -39
  128. package/src/types.ts +10 -0
@@ -5,11 +5,132 @@
5
5
  */
6
6
 
7
7
  import { join } from 'node:path';
8
- import { existsSync, rmSync } from 'node:fs';
8
+ import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
9
+ import { createHash } from 'node:crypto';
10
+ import { createRequire } from 'node:module';
9
11
  import type { InlineConfig } from 'vite';
10
12
  import type { Logger, DeployOptions } from '../../../types';
11
13
  import type { BuildReportCollector } from '../../../build-report';
12
14
 
15
+ /**
16
+ * Read the pre-built beacon script from @agentuity/frontend package.
17
+ * Tries multiple resolution strategies for workspace/installed/symlink scenarios.
18
+ */
19
+ async function readBeaconScript(projectRoot: string): Promise<string> {
20
+ let frontendPath: string | null = null;
21
+
22
+ try {
23
+ frontendPath = await Bun.resolve('@agentuity/frontend', projectRoot);
24
+ } catch {
25
+ // Not found from project root
26
+ }
27
+
28
+ if (!frontendPath) {
29
+ try {
30
+ const thisDir = new URL('.', import.meta.url).pathname;
31
+ frontendPath = await Bun.resolve('@agentuity/frontend', thisDir);
32
+ } catch {
33
+ // Not found from CLI directory
34
+ }
35
+ }
36
+
37
+ if (!frontendPath) {
38
+ try {
39
+ const projectRequire = createRequire(join(projectRoot, 'package.json'));
40
+ frontendPath = projectRequire.resolve('@agentuity/frontend');
41
+ } catch {
42
+ // Not found via createRequire
43
+ }
44
+ }
45
+
46
+ if (!frontendPath) {
47
+ throw new Error(
48
+ 'Could not resolve @agentuity/frontend. Ensure the package is installed and built.'
49
+ );
50
+ }
51
+
52
+ const packageDir = join(frontendPath, '..');
53
+ const beaconPath = join(packageDir, 'beacon.js');
54
+
55
+ const beaconFile = Bun.file(beaconPath);
56
+ if (!(await beaconFile.exists())) {
57
+ throw new Error(
58
+ `Beacon script not found at ${beaconPath}. Run "bun run build" in @agentuity/frontend first.`
59
+ );
60
+ }
61
+
62
+ return beaconFile.text();
63
+ }
64
+
65
+ /**
66
+ * Post-build step: inject the analytics beacon into the built index.html.
67
+ *
68
+ * 1. Reads the beacon script from @agentuity/frontend
69
+ * 2. Writes it as a content-hashed asset file
70
+ * 3. Injects a <script data-agentuity-beacon> tag into the HTML
71
+ *
72
+ * This runs after `vite build` completes so it works regardless of the
73
+ * user's vite.config.ts — no Vite plugin required.
74
+ */
75
+ async function injectBeacon(rootDir: string, cdnBaseUrl: string, logger: Logger): Promise<void> {
76
+ const clientDir = join(rootDir, '.agentuity/client');
77
+ const indexHtmlPath = join(clientDir, 'index.html');
78
+
79
+ if (!existsSync(indexHtmlPath)) {
80
+ logger.debug('No index.html found, skipping beacon injection');
81
+ return;
82
+ }
83
+
84
+ let beaconCode: string;
85
+ try {
86
+ beaconCode = await readBeaconScript(rootDir);
87
+ } catch (error) {
88
+ logger.warn(
89
+ 'Failed to read beacon script, skipping injection: %s',
90
+ error instanceof Error ? error.message : String(error)
91
+ );
92
+ return;
93
+ }
94
+
95
+ // Write beacon as a content-hashed asset (matches Vite's naming convention)
96
+ const hash = createHash('sha256').update(beaconCode).digest('hex').slice(0, 8);
97
+ const beaconFileName = `agentuity-beacon-${hash}.js`;
98
+ const assetsDir = join(clientDir, 'assets');
99
+ mkdirSync(assetsDir, { recursive: true });
100
+ writeFileSync(join(assetsDir, beaconFileName), beaconCode);
101
+
102
+ // If a Vite manifest exists, add the beacon so the metadata generator
103
+ // includes it in the asset list. When no manifest exists, the directory
104
+ // scanner in metadata-generator.ts picks up assets/ directly.
105
+ const manifestPath = join(clientDir, '.vite', 'manifest.json');
106
+ if (existsSync(manifestPath)) {
107
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
108
+ manifest['agentuity-beacon'] = { file: `assets/${beaconFileName}` };
109
+ writeFileSync(manifestPath, JSON.stringify(manifest));
110
+ }
111
+
112
+ // Build the beacon URL using the CDN base
113
+ const normalizedBase = cdnBaseUrl.endsWith('/') ? cdnBaseUrl : `${cdnBaseUrl}/`;
114
+ const beaconUrl = `${normalizedBase}assets/${beaconFileName}`;
115
+
116
+ // Inject the script tag into index.html
117
+ // The script must be sync (no async/defer) to patch history API before router loads.
118
+ // The data-agentuity-beacon attribute is the marker the runtime looks for.
119
+ const beaconScript = `<script data-agentuity-beacon src="${beaconUrl}"></script>`;
120
+
121
+ let html = readFileSync(indexHtmlPath, 'utf-8');
122
+ if (html.includes('</head>')) {
123
+ html = html.replace('</head>', `${beaconScript}</head>`);
124
+ } else if (html.includes('<body')) {
125
+ html = html.replace(/<body([^>]*)>/, `<body$1>${beaconScript}`);
126
+ } else {
127
+ html = beaconScript + html;
128
+ }
129
+
130
+ writeFileSync(indexHtmlPath, html);
131
+ logger.debug('Injected analytics beacon: %s', beaconUrl);
132
+ }
133
+
13
134
  export interface ViteBuildOptions {
14
135
  rootDir: string;
15
136
  mode: 'client' | 'server' | 'workbench';
@@ -116,18 +237,43 @@ export default defineConfig({
116
237
  await Bun.write(viteConfigPath, fallbackConfig);
117
238
  }
118
239
 
240
+ // Construct CDN base URL for production builds so Vite prefixes all
241
+ // asset URLs (CSS, JS chunks) with the CDN origin instead of "/".
242
+ const cdnBaseUrl =
243
+ !dev && options.deploymentId
244
+ ? `https://${options.region === 'local' ? 'localstack-static-assets.t3.storageapi.dev' : 'cdn.agentuity.com'}/${options.deploymentId}/client/`
245
+ : undefined;
246
+
247
+ const args = [
248
+ 'bun',
249
+ 'x',
250
+ 'vite',
251
+ 'build',
252
+ '--mode',
253
+ buildMode,
254
+ '--outDir',
255
+ clientOutDir,
256
+ '--logLevel',
257
+ 'error',
258
+ '--clearScreen',
259
+ 'false',
260
+ ];
261
+ if (cdnBaseUrl) {
262
+ args.push('--base', cdnBaseUrl);
263
+ }
264
+
119
265
  logger.debug('Spawning vite build for client (subprocess mode)');
120
266
  logger.debug(' outDir: %s', clientOutDir);
121
267
  logger.debug(' mode: %s', buildMode);
268
+ if (cdnBaseUrl) {
269
+ logger.debug(' base (CDN): %s', cdnBaseUrl);
270
+ }
122
271
 
123
- const viteProcess = Bun.spawn(
124
- ['bun', 'x', 'vite', 'build', '--mode', buildMode, '--outDir', clientOutDir],
125
- {
126
- cwd: rootDir,
127
- stdout: 'inherit',
128
- stderr: 'inherit',
129
- }
130
- );
272
+ const viteProcess = Bun.spawn(args, {
273
+ cwd: rootDir,
274
+ stdout: 'inherit',
275
+ stderr: 'inherit',
276
+ });
131
277
 
132
278
  const exitCode = await viteProcess.exited;
133
279
 
@@ -264,6 +410,22 @@ export async function runAllBuilds(options: Omit<ViteBuildOptions, 'mode'>): Pro
264
410
  logger.debug('Moved index.html from src/web/ to client root');
265
411
  }
266
412
 
413
+ // Post-build: inject analytics beacon into the built HTML.
414
+ // Must run AFTER the index.html normalization above (Vite may
415
+ // output to src/web/index.html which gets moved to the client root).
416
+ const isLocalRegion = options.region === 'local';
417
+ const cdnDomain = isLocalRegion
418
+ ? 'localstack-static-assets.t3.storageapi.dev'
419
+ : 'cdn.agentuity.com';
420
+ const cdnBaseUrl =
421
+ !dev && options.deploymentId
422
+ ? `https://${cdnDomain}/${options.deploymentId}/client/`
423
+ : undefined;
424
+
425
+ if (cdnBaseUrl && analyticsEnabled) {
426
+ await injectBeacon(rootDir, cdnBaseUrl, logger);
427
+ }
428
+
267
429
  result.client.included = true;
268
430
  result.client.duration = Date.now() - started;
269
431
  endClientDiagnostic?.();
@@ -0,0 +1,319 @@
1
+ import { z } from 'zod';
2
+ import { createCommand } from '../../../types';
3
+ import * as tui from '../../../tui';
4
+ import { createStorageAdapter, resolveMeId, parseDuration, truncate } from './util';
5
+ import { getCommand } from '../../../command-prefix';
6
+ import { isDryRunMode, outputDryRun } from '../../../explain';
7
+ import type { TaskPriority, TaskStatus, TaskType, BatchClosedTask } from '@agentuity/core';
8
+
9
+ const TaskCloseResponseSchema = z.object({
10
+ success: z.boolean().describe('Whether the operation succeeded'),
11
+ closed: z
12
+ .array(
13
+ z.object({
14
+ id: z.string().describe('Closed task ID'),
15
+ title: z.string().describe('Closed task title'),
16
+ status: z.string().describe('Task status'),
17
+ closed_date: z.string().optional().describe('ISO 8601 closed date'),
18
+ })
19
+ )
20
+ .describe('List of closed tasks'),
21
+ count: z.number().describe('Number of tasks closed'),
22
+ durationMs: z.number().describe('Operation duration in milliseconds'),
23
+ dryRun: z.boolean().optional().describe('Whether this was a dry run'),
24
+ message: z.string().optional().describe('Status message'),
25
+ });
26
+
27
+ export const closeSubcommand = createCommand({
28
+ name: 'close',
29
+ aliases: ['done', 'complete'],
30
+ description: 'Close a task by ID or batch-close tasks by filter',
31
+ tags: ['mutating', 'slow', 'requires-auth'],
32
+ requires: { auth: true },
33
+ examples: [
34
+ {
35
+ command: getCommand('cloud task close task_abc123'),
36
+ description: 'Close a single task by ID',
37
+ },
38
+ {
39
+ command: getCommand('cloud task close --status in_progress --older-than 7d'),
40
+ description: 'Close in-progress tasks older than 7 days',
41
+ },
42
+ {
43
+ command: getCommand('cloud task close --status open --limit 10 --dry-run'),
44
+ description: 'Preview which open tasks would be closed (dry run)',
45
+ },
46
+ {
47
+ command: getCommand('cloud task close --created-id me --confirm'),
48
+ description: 'Close all tasks created by me without confirmation prompt',
49
+ },
50
+ ],
51
+ schema: {
52
+ args: z.object({
53
+ id: z.string().optional().describe('Task ID to close (for single close)'),
54
+ }),
55
+ options: z.object({
56
+ status: z
57
+ .enum(['open', 'in_progress', 'started', 'done', 'completed', 'closed', 'cancelled'])
58
+ .optional()
59
+ .describe('filter batch close by status'),
60
+ type: z
61
+ .enum(['epic', 'feature', 'enhancement', 'bug', 'task'])
62
+ .optional()
63
+ .describe('filter batch close by type'),
64
+ priority: z
65
+ .enum(['high', 'medium', 'low', 'none'])
66
+ .optional()
67
+ .describe('filter batch close by priority'),
68
+ olderThan: z
69
+ .string()
70
+ .optional()
71
+ .describe('filter batch close by age (e.g. 30s, 7d, 24h, 2w)'),
72
+ parentId: z.string().optional().describe('filter batch close by parent task ID'),
73
+ createdId: z
74
+ .string()
75
+ .optional()
76
+ .describe('filter batch close by creator ID (use "me" for current user)'),
77
+ assignedId: z.string().optional().describe('filter batch close by assigned user ID'),
78
+ projectId: z.string().optional().describe('filter batch close by project ID'),
79
+ tagId: z.string().optional().describe('filter batch close by tag ID'),
80
+ idsFile: z.string().optional().describe('path to JSON file containing task IDs to close'),
81
+ orgId: z.string().optional().describe('organization ID (uses default if not specified)'),
82
+ dryRun: z
83
+ .boolean()
84
+ .optional()
85
+ .default(false)
86
+ .describe('preview changes without executing'),
87
+ limit: z.coerce
88
+ .number()
89
+ .int()
90
+ .min(1)
91
+ .max(200)
92
+ .default(50)
93
+ .describe('max tasks to close in batch mode (default: 50, max: 200)'),
94
+ confirm: z.boolean().optional().default(false).describe('skip confirmation prompt'),
95
+ }),
96
+ response: TaskCloseResponseSchema,
97
+ },
98
+
99
+ async handler(ctx) {
100
+ const { args, opts, options } = ctx;
101
+ const started = Date.now();
102
+ const storage = await createStorageAdapter(ctx);
103
+
104
+ const isSingleClose = !!args.id;
105
+ const hasFilters =
106
+ opts.status ||
107
+ opts.type ||
108
+ opts.priority ||
109
+ opts.olderThan ||
110
+ opts.parentId ||
111
+ opts.createdId ||
112
+ opts.assignedId ||
113
+ opts.projectId ||
114
+ opts.tagId ||
115
+ opts.idsFile;
116
+
117
+ if (!isSingleClose && !hasFilters) {
118
+ tui.fatal(
119
+ 'Provide a task ID for single close, or use --status, --type, --priority, --older-than, --parent-id, --created-id, --assigned-id, --project-id, --tag-id, or --ids-file for batch close.'
120
+ );
121
+ }
122
+
123
+ if (isSingleClose && hasFilters) {
124
+ tui.fatal(
125
+ 'Cannot combine task ID with filter options. Use either single close (by ID) or batch close (by filters).'
126
+ );
127
+ }
128
+
129
+ if (isSingleClose) {
130
+ if (isDryRunMode(options)) {
131
+ outputDryRun(`Would close task: ${args.id}`, options);
132
+ return {
133
+ success: true,
134
+ closed: [{ id: args.id!, title: '(dry run)', status: 'done' }],
135
+ count: 1,
136
+ durationMs: Date.now() - started,
137
+ dryRun: true,
138
+ message: 'Dry run — no tasks were closed',
139
+ };
140
+ }
141
+
142
+ if (!opts.confirm) {
143
+ const confirmed = await tui.confirm(`Close task "${args.id}"?`, false);
144
+ if (!confirmed) {
145
+ if (!options.json) tui.info('Cancelled');
146
+ return {
147
+ success: false,
148
+ closed: [],
149
+ count: 0,
150
+ durationMs: Date.now() - started,
151
+ message: 'Cancelled',
152
+ };
153
+ }
154
+ }
155
+
156
+ const task = await storage.close(args.id!);
157
+ const durationMs = Date.now() - started;
158
+
159
+ if (!options.json) {
160
+ tui.success(`Closed task ${tui.bold(task.id)} (${task.title}) in ${durationMs}ms`);
161
+ }
162
+
163
+ return {
164
+ success: true,
165
+ closed: [
166
+ {
167
+ id: task.id,
168
+ title: task.title,
169
+ status: task.status,
170
+ closed_date: task.closed_date,
171
+ },
172
+ ],
173
+ count: 1,
174
+ durationMs,
175
+ };
176
+ }
177
+
178
+ // Batch close mode
179
+ if (opts.olderThan) {
180
+ parseDuration(opts.olderThan);
181
+ }
182
+
183
+ const createdId = resolveMeId(opts.createdId, ctx);
184
+ const assignedId = resolveMeId(opts.assignedId, ctx);
185
+
186
+ // Handle IDs file
187
+ let explicitIds: string[] | undefined;
188
+ if (opts.idsFile) {
189
+ const file = Bun.file(opts.idsFile);
190
+ if (!(await file.exists())) {
191
+ tui.fatal(`IDs file not found: ${opts.idsFile}`);
192
+ }
193
+ try {
194
+ const content = await file.json();
195
+ if (Array.isArray(content)) {
196
+ explicitIds = content.map((id) => String(id));
197
+ } else if (content && Array.isArray((content as { ids?: string[] }).ids)) {
198
+ explicitIds = (content as { ids: string[] }).ids;
199
+ } else {
200
+ tui.fatal(`Invalid IDs file format. Expected array of IDs or { ids: [...] }`);
201
+ }
202
+ } catch (err) {
203
+ tui.fatal(`Failed to parse IDs file: ${err}`);
204
+ }
205
+ }
206
+
207
+ const batchParams = {
208
+ status: opts.status as TaskStatus | undefined,
209
+ type: opts.type as TaskType | undefined,
210
+ priority: opts.priority as TaskPriority | undefined,
211
+ parent_id: opts.parentId,
212
+ created_id: createdId,
213
+ assigned_id: assignedId,
214
+ project_id: opts.projectId,
215
+ tag_id: opts.tagId,
216
+ older_than: opts.olderThan,
217
+ ids: explicitIds,
218
+ limit: opts.limit,
219
+ closed_id: ctx.auth.userId,
220
+ dry_run: isDryRunMode(options),
221
+ };
222
+
223
+ // For confirmation, run a dry-run first to preview
224
+ if (!isDryRunMode(options) && !opts.confirm) {
225
+ const preview = await storage.batchClose({ ...batchParams, dry_run: true });
226
+
227
+ if (preview.count === 0) {
228
+ if (!options.json) tui.info('No tasks match the given filters');
229
+ return {
230
+ success: true,
231
+ closed: [],
232
+ count: 0,
233
+ durationMs: Date.now() - started,
234
+ message: 'No matching tasks found',
235
+ };
236
+ }
237
+
238
+ if (!options.json) {
239
+ tui.warning(
240
+ `Found ${preview.count} ${tui.plural(preview.count, 'task', 'tasks')} to close:`
241
+ );
242
+ tui.newline();
243
+
244
+ const tableData = preview.closed.map((task: BatchClosedTask) => ({
245
+ ID: tui.muted(truncate(task.id, 28)),
246
+ Title: truncate(task.title, 40),
247
+ Status: task.status,
248
+ }));
249
+
250
+ tui.table(tableData, [
251
+ { name: 'ID', alignment: 'left' },
252
+ { name: 'Title', alignment: 'left' },
253
+ { name: 'Status', alignment: 'left' },
254
+ ]);
255
+ tui.newline();
256
+ }
257
+
258
+ const confirmed = await tui.confirm(
259
+ `Close ${preview.count} ${tui.plural(preview.count, 'task', 'tasks')}?`,
260
+ false
261
+ );
262
+ if (!confirmed) {
263
+ if (!options.json) tui.info('Cancelled');
264
+ return {
265
+ success: false,
266
+ closed: [],
267
+ count: 0,
268
+ durationMs: Date.now() - started,
269
+ message: 'Cancelled',
270
+ };
271
+ }
272
+ }
273
+
274
+ // Execute batch close
275
+ const result = await storage.batchClose(batchParams);
276
+ const durationMs = Date.now() - started;
277
+
278
+ if (!options.json) {
279
+ if (result.dry_run) {
280
+ if (result.count > 0) {
281
+ tui.info(
282
+ `Dry run: would close ${result.count} ${tui.plural(result.count, 'task', 'tasks')}`
283
+ );
284
+ } else {
285
+ tui.info('No tasks match the given filters');
286
+ }
287
+ } else if (result.count > 0) {
288
+ tui.success(
289
+ `Closed ${result.count} ${tui.plural(result.count, 'task', 'tasks')} in ${durationMs}ms`
290
+ );
291
+
292
+ // Show which tasks were closed
293
+ if (result.closed.length > 0) {
294
+ tui.newline();
295
+ const closedTable = result.closed.map((task) => ({
296
+ ID: tui.muted(truncate(task.id, 28)),
297
+ Title: truncate(task.title, 40),
298
+ }));
299
+ tui.table(closedTable, [
300
+ { name: 'ID', alignment: 'left' },
301
+ { name: 'Title', alignment: 'left' },
302
+ ]);
303
+ }
304
+ } else {
305
+ tui.info('No tasks matched the given filters');
306
+ }
307
+ }
308
+
309
+ return {
310
+ success: true,
311
+ closed: result.closed,
312
+ count: result.count,
313
+ durationMs,
314
+ dryRun: result.dry_run,
315
+ };
316
+ },
317
+ });
318
+
319
+ export default closeSubcommand;
@@ -1,46 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import { createCommand } from '../../../types';
3
3
  import * as tui from '../../../tui';
4
- import { createStorageAdapter } from './util';
4
+ import { createStorageAdapter, resolveMeId, parseDuration, truncate } from './util';
5
5
  import { getCommand } from '../../../command-prefix';
6
6
  import { isDryRunMode, outputDryRun } from '../../../explain';
7
7
  import type { TaskPriority, TaskStatus, TaskType, BatchDeletedTask } from '@agentuity/core';
8
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
- }
9
+ // Re-export for testing
10
+ export { parseDuration } from './util';
44
11
 
45
12
  const TaskDeleteResponseSchema = z.object({
46
13
  success: z.boolean().describe('Whether the operation succeeded'),
@@ -104,7 +71,16 @@ export const deleteSubcommand = createCommand({
104
71
  .optional()
105
72
  .describe('filter batch delete by age (e.g. 30s, 7d, 24h, 2w)'),
106
73
  parentId: z.string().optional().describe('filter batch delete by parent task ID'),
107
- createdId: z.string().optional().describe('filter batch delete by creator ID'),
74
+ createdId: z
75
+ .string()
76
+ .optional()
77
+ .describe('filter batch delete by creator ID (use "me" for current user)'),
78
+ orgId: z.string().optional().describe('organization ID (uses default if not specified)'),
79
+ dryRun: z
80
+ .boolean()
81
+ .optional()
82
+ .default(false)
83
+ .describe('preview changes without executing'),
108
84
  limit: z.coerce
109
85
  .number()
110
86
  .int()
@@ -198,7 +174,7 @@ export const deleteSubcommand = createCommand({
198
174
  type: opts.type as TaskType | undefined,
199
175
  priority: opts.priority as TaskPriority | undefined,
200
176
  parent_id: opts.parentId,
201
- created_id: opts.createdId,
177
+ created_id: resolveMeId(opts.createdId, ctx),
202
178
  older_than: opts.olderThan,
203
179
  limit: opts.limit,
204
180
  };
@@ -212,6 +188,7 @@ export const deleteSubcommand = createCommand({
212
188
  type: batchParams.type,
213
189
  priority: batchParams.priority,
214
190
  parent_id: batchParams.parent_id,
191
+ created_id: batchParams.created_id,
215
192
  limit: batchParams.limit,
216
193
  sort: 'created_at',
217
194
  order: 'asc',
@@ -219,11 +196,6 @@ export const deleteSubcommand = createCommand({
219
196
 
220
197
  // Client-side filters for preview (server will apply these on actual delete)
221
198
  let candidates = preview.tasks;
222
- if (batchParams.created_id) {
223
- candidates = candidates.filter(
224
- (t: { created_id: string }) => t.created_id === batchParams.created_id
225
- );
226
- }
227
199
  if (opts.olderThan) {
228
200
  const durationMs = parseDuration(opts.olderThan);
229
201
  const cutoff = new Date(Date.now() - durationMs);
@@ -4,6 +4,7 @@ import { createSubcommand } from './create';
4
4
  import { updateSubcommand } from './update';
5
5
  import { listSubcommand } from './list';
6
6
  import { deleteSubcommand } from './delete';
7
+ import { closeSubcommand } from './close';
7
8
  import { statsSubcommand } from './stats';
8
9
  import { attachmentSubcommand } from './attachment';
9
10
  import { userSubcommand } from './user';
@@ -36,6 +37,14 @@ export const taskCommand = createCommand({
36
37
  command: getCommand('cloud task delete task_abc123'),
37
38
  description: 'Delete a task by ID',
38
39
  },
40
+ {
41
+ command: getCommand('cloud task close task_abc123'),
42
+ description: 'Close a task by ID',
43
+ },
44
+ {
45
+ command: getCommand('cloud task close --status done --older-than 7d'),
46
+ description: 'Batch close done tasks older than 7 days',
47
+ },
39
48
  {
40
49
  command: getCommand('cloud task delete --status done --older-than 7d'),
41
50
  description: 'Batch delete done tasks older than 7 days',
@@ -59,6 +68,7 @@ export const taskCommand = createCommand({
59
68
  updateSubcommand,
60
69
  listSubcommand,
61
70
  deleteSubcommand,
71
+ closeSubcommand,
62
72
  statsSubcommand,
63
73
  attachmentSubcommand,
64
74
  userSubcommand,