@forloop-cc/forloop-opencode-plugin-planner 2.2.1

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 (55) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +77 -0
  3. package/CONTRIBUTING.md +176 -0
  4. package/LICENSE +21 -0
  5. package/README.md +211 -0
  6. package/SECURITY.md +69 -0
  7. package/docs/CONFIGURATION.md +283 -0
  8. package/docs/DEVELOPER-VS-USER.md +129 -0
  9. package/docs/IMPROVED-AUTHENTICATION.md +317 -0
  10. package/docs/INSTALLATION-SCRIPTS.md +304 -0
  11. package/docs/PLANNER-AGENT-IMPROVEMENTS.md +88 -0
  12. package/docs/PLUGIN-ARCHITECTURE.md +308 -0
  13. package/docs/QUICKSTART-NON-DEV.md +94 -0
  14. package/docs/README-FOR-HUMANS-AND-AI.md +109 -0
  15. package/docs/README.md +70 -0
  16. package/docs/SKILLS-GAP-ANALYSIS.md +157 -0
  17. package/docs/USER-INTERACTION-MODEL.md +106 -0
  18. package/docs/file-management/2026-04-12-forloop-aivy-documents-sync.md +395 -0
  19. package/docs/file-management/FILE_UPLOAD_ENHANCEMENT.md +431 -0
  20. package/docs/file-management/FILE_UPLOAD_FLOW.md +300 -0
  21. package/docs/images/forloop-opencode-flow.png +0 -0
  22. package/docs/images/forloop-opencode-header.png +0 -0
  23. package/docs/superpowers/plans/2026-05-03-sprint-isolated-directory-structure.md +1450 -0
  24. package/docs/superpowers/plans/2026-05-29-install-system.md +850 -0
  25. package/docs/superpowers/specs/2026-05-03-sprint-isolated-directory-structure-design.md +149 -0
  26. package/docs/superpowers/specs/2026-05-19-opencode-message-recording-design.md +468 -0
  27. package/docs/superpowers/specs/2026-05-29-install-system-design.md +135 -0
  28. package/install.sh +317 -0
  29. package/package.json +87 -0
  30. package/plugins/capabilities/api-client.ts +556 -0
  31. package/plugins/capabilities/auth.ts +120 -0
  32. package/plugins/capabilities/config.ts +195 -0
  33. package/plugins/capabilities/context-resolver.ts +106 -0
  34. package/plugins/capabilities/token-storage.ts +98 -0
  35. package/plugins/forloop-plugin.ts +186 -0
  36. package/plugins/hooks/message-recorder.ts +258 -0
  37. package/plugins/hooks/shell-env.ts +62 -0
  38. package/plugins/tools/agent-sprint-tools.ts +100 -0
  39. package/plugins/tools/agent-suggestion-tools.ts +275 -0
  40. package/plugins/tools/agent-tools.ts +114 -0
  41. package/plugins/tools/ai-trigger-tools.ts +79 -0
  42. package/plugins/tools/developer-status-tool.ts +97 -0
  43. package/plugins/tools/file-tools.ts +240 -0
  44. package/plugins/tools/folder-tools.ts +75 -0
  45. package/plugins/tools/organization-tools.ts +213 -0
  46. package/plugins/tools/schedule-tools.ts +173 -0
  47. package/plugins/tools/sprint-tools.ts +287 -0
  48. package/plugins/tools/story-tools.ts +320 -0
  49. package/plugins/tools/sync-tools.ts +509 -0
  50. package/plugins/tools/template-tools.ts +170 -0
  51. package/plugins/tools/user-tools.ts +146 -0
  52. package/scripts/migrate-tokens.js +90 -0
  53. package/scripts/use-prod.sh +11 -0
  54. package/tsconfig.json +17 -0
  55. package/update.sh +172 -0
@@ -0,0 +1,114 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { getToken, setToken } from '../capabilities/token-storage';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import { ForLoopAPIClient } from '../capabilities/api-client';
5
+ import { getTokensUrl } from '../capabilities/config';
6
+
7
+ export function createTokenSetTool(client?: ForLoopAPIClient) {
8
+ return tool({
9
+ description: 'Set or update the ForLoop API token',
10
+ args: {
11
+ token: tool.schema.string()
12
+ .describe('API token (starts with "floop_")'),
13
+ profile: tool.schema.string()
14
+ .optional()
15
+ .describe('Profile name (e.g., "dev", "prod")'),
16
+ },
17
+ async execute(args, _context) {
18
+ if (!args.token || !args.token.startsWith('floop_')) {
19
+ return '❌ Invalid token format. ForLoop tokens should start with "floop_"';
20
+ }
21
+
22
+ try {
23
+ await setToken(args.token, args.profile);
24
+ client?.setToken(args.token);
25
+
26
+ const message = args.profile
27
+ ? `✅ Token saved to profile "${args.profile}"`
28
+ : '✅ Default token saved successfully';
29
+
30
+ return message;
31
+ } catch (error: any) {
32
+ return `❌ Error saving token: ${error.message}`;
33
+ }
34
+ },
35
+ });
36
+ }
37
+
38
+ export function createTokenGetTool() {
39
+ return tool({
40
+ description: 'Check if ForLoop API token is configured',
41
+ args: {
42
+ profile: tool.schema.string()
43
+ .optional()
44
+ .describe('Profile name to check'),
45
+ },
46
+ async execute(args, _context) {
47
+ const token = await getToken(args.profile);
48
+
49
+ if (!token) {
50
+ return [
51
+ '❌ No ForLoop API token configured',
52
+ '',
53
+ 'Token location: ~/.config/forloop/tokens.json',
54
+ '',
55
+ 'To create a token:',
56
+ ` 1. Go to ${getTokensUrl()}`,
57
+ ' 2. Click "Create New Token"',
58
+ ' 3. Copy the token',
59
+ ' 4. Run: forloopTokenSet --token <your-token>',
60
+ ].join('\n');
61
+ }
62
+
63
+ const masked = token.slice(0, 10) + '...' + token.slice(-4);
64
+ return `✅ ForLoop token configured: ${masked}`;
65
+ },
66
+ });
67
+ }
68
+
69
+ export function createAgentQueryTool(client: ForLoopAPIClient) {
70
+ return tool({
71
+ description: 'Query ForLoop AI agents for analysis, suggestions, or execution',
72
+ args: {
73
+ query: tool.schema.string()
74
+ .describe('Natural language query or command'),
75
+ agentKey: tool.schema.enum(['forLoopTaskSupervisor'])
76
+ .optional()
77
+ .default('forLoopTaskSupervisor'),
78
+ sprintId: tool.schema.number()
79
+ .optional()
80
+ .describe('Sprint context'),
81
+ enableMutations: tool.schema.boolean()
82
+ .default(false)
83
+ .describe('Allow agent to make changes'),
84
+ },
85
+ async execute(args, context) {
86
+ const tokenResult = await validateToken();
87
+ if (!tokenResult.valid) {
88
+ return tokenResult.error;
89
+ }
90
+ client.setToken?.(tokenResult.token);
91
+
92
+ try {
93
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
94
+
95
+ const response = await client.chatWithAI({
96
+ message: args.query,
97
+ sprintId: resolution.sprintId,
98
+ selectedAgentKey: args.agentKey,
99
+ type: args.enableMutations ? 'feature' : 'analysis',
100
+ });
101
+
102
+ return [
103
+ `Query submitted to ${args.agentKey} agent.`,
104
+ '',
105
+ response.message || 'Processing your request...',
106
+ ].join('\n');
107
+ } catch (error: any) {
108
+ return `❌ Error: ${error.message}`;
109
+ }
110
+ },
111
+ });
112
+ }
113
+
114
+ import { resolveSprintId } from '../capabilities/context-resolver';
@@ -0,0 +1,79 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+
5
+ /**
6
+ * Create tools for triggering AI agent execution via EventBridge
7
+ */
8
+ export function createAITriggerTools(client: ForLoopAPIClient) {
9
+ const tools: Record<string, ReturnType<typeof tool>> = {};
10
+
11
+ tools['forloopAiDeveloperSprint'] = createDeveloperSprintTool(client);
12
+
13
+ return tools;
14
+ }
15
+
16
+ /**
17
+ * forloopAiDeveloperSprint - Trigger developer agent for sprint work
18
+ * Triggers the forLoopTaskSupervisor agent via EventBridge → Step Functions → ECS
19
+ */
20
+ function createDeveloperSprintTool(client: ForLoopAPIClient) {
21
+ return tool({
22
+ description: 'Trigger forLoopTaskSupervisor agent to execute sprint work. This sends stories to the developer agent for implementation via EventBridge → Step Functions → ECS Fargate.',
23
+ args: {
24
+ sprintId: tool.schema.number()
25
+ .describe('Sprint ID to execute'),
26
+ message: tool.schema.string()
27
+ .optional()
28
+ .describe('Optional instructions for the developer agent')
29
+ .default('Start implementing the sprint stories assigned to the developer agent'),
30
+ },
31
+ async execute(args, _context) {
32
+ const tokenResult = await validateToken();
33
+ if (!tokenResult.valid) {
34
+ return tokenResult.error;
35
+ }
36
+
37
+ try {
38
+ console.log('[forloopAiDeveloperSprint] Triggering developer agent', {
39
+ sprintId: args.sprintId,
40
+ });
41
+
42
+ const response = await client.chatWithAI({
43
+ sprintId: args.sprintId,
44
+ message: args.message || 'Start implementing the sprint stories assigned to the developer agent',
45
+ selectedAgentKey: 'forLoopTaskSupervisor',
46
+ type: 'developer.sprint',
47
+ metadata: { channel: 'developer_sprint' },
48
+ });
49
+
50
+ const taskId = response.taskId || response.id || 'unknown';
51
+ const trackingId = response.trackingId || 'unknown';
52
+
53
+ return `🚀 Developer sprint triggered for execution
54
+
55
+ **Execution Details:**
56
+ - Task ID: \`${taskId}\`
57
+ - Tracking ID: \`${trackingId}\`
58
+ - Sprint: #${args.sprintId}
59
+ - Agent: forLoopTaskSupervisor
60
+
61
+ **What will happen:**
62
+ 1. Sprint lock is acquired
63
+ 2. ECS Fargate task starts with forLoopTaskSupervisor agent
64
+ 3. Agent fetches sprint stories and processes them
65
+ 4. Sub-agents are dispatched: forLoopDeveloper (code), forLoopTester (validation), forLoopDevops (deploy)
66
+ 5. Sprint knowledge (knowledge-application.md) is updated
67
+ 6. PR is created and auto-merged to develop
68
+ 7. You'll be notified by email when complete
69
+
70
+ **Monitor Progress:**
71
+ - Check the ForLoop dashboard for story status updates
72
+ - Wait for email notification upon completion`
73
+ } catch (error: any) {
74
+ console.error('[forloopAiDeveloperSprint] Failed to trigger developer', error)
75
+ return `❌ Failed to trigger forLoopTaskSupervisor: ${error.message}`
76
+ }
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,97 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { resolveSprintId } from '../capabilities/context-resolver';
4
+ import { validateToken } from '../capabilities/auth';
5
+
6
+ const SFN_ICONS: Record<string, string> = {
7
+ RUNNING: '🚀',
8
+ SUCCEEDED: '✅',
9
+ FAILED: '❌',
10
+ TIMED_OUT: '⏰',
11
+ ABORTED: '🛑',
12
+ NOT_FOUND: '❓',
13
+ ERROR: '⚠️',
14
+ };
15
+
16
+ export function createDeveloperStatusTool(client: ForLoopAPIClient) {
17
+ return tool({
18
+ description: 'Check the status of a running developer task (Step Functions execution + story progress). Use when you want to know if a dispatched developer sprint is still running, completed, or failed.',
19
+ args: {
20
+ sprintId: tool.schema.number()
21
+ .optional()
22
+ .describe('Target sprint ID (auto-detected via env/branch/manifest)'),
23
+ },
24
+ async execute(args, context) {
25
+ const tokenResult = await validateToken();
26
+ if (!tokenResult.valid) {
27
+ return tokenResult.error;
28
+ }
29
+ client.setToken?.(tokenResult.token);
30
+
31
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
32
+ if (!resolution.sprintId) {
33
+ return '❌ No sprint ID available. Provide sprintId or set FORLOOP_SPRINT_ID or ensure ~/.forloop/manifest.json has activeSprintId.';
34
+ }
35
+
36
+ try {
37
+ const status = await client.getDeveloperStatus(resolution.sprintId);
38
+
39
+ if (!status.hasActiveTask) {
40
+ const sprint = status.sprint || {};
41
+ return [
42
+ '❌ No active developer task',
43
+ '',
44
+ `Sprint #${resolution.sprintId}: ${sprint.storiesDone || 0}/${sprint.storiesTotal || 0} stories done`,
45
+ ].join('\n');
46
+ }
47
+
48
+ const task = status.task || {};
49
+ const exec = status.execution || {};
50
+ const sprint = status.sprint || {};
51
+ const execIcon = SFN_ICONS[exec.status] || '❓';
52
+
53
+ const lines = [
54
+ `${execIcon} Developer Task: ${exec.status}`,
55
+ '',
56
+ `**Sprint**: #${resolution.sprintId}`,
57
+ `**Task Status**: ${task.status} (${task.id ? task.id.substring(0, 8) : '?'})`,
58
+ ];
59
+
60
+ if (exec.startedAt) {
61
+ lines.push(`**Started**: ${new Date(exec.startedAt).toLocaleString()}`);
62
+ }
63
+
64
+ if (exec.status === 'RUNNING') {
65
+ const started = new Date(exec.startedAt).getTime();
66
+ const elapsed = Math.floor((Date.now() - started) / 1000);
67
+ const minutes = Math.floor(elapsed / 60);
68
+ const seconds = elapsed % 60;
69
+ lines.push(`**Elapsed**: ${minutes}m ${seconds}s`);
70
+ }
71
+
72
+ if (exec.stoppedAt) {
73
+ lines.push(`**Stopped**: ${new Date(exec.stoppedAt).toLocaleString()}`);
74
+ }
75
+
76
+ if (task.branchName) {
77
+ lines.push(`**Branch**: ${task.branchName}`);
78
+ }
79
+
80
+ if (task.errorMessage) {
81
+ lines.push(`**Error**: ${task.errorMessage}`);
82
+ }
83
+
84
+ if (exec.error) {
85
+ lines.push(`**SFN Error**: ${exec.error}`);
86
+ }
87
+
88
+ lines.push('');
89
+ lines.push(`**Sprint Progress**: ${sprint.storiesDone || 0}/${sprint.storiesTotal || 0} done, ${sprint.storiesInProgress || 0} in progress`);
90
+
91
+ return lines.join('\n');
92
+ } catch (error: any) {
93
+ return `❌ Error checking developer status: ${error.message}`;
94
+ }
95
+ },
96
+ });
97
+ }
@@ -0,0 +1,240 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+
7
+ export function createFileUploadTool(client: ForLoopAPIClient) {
8
+ return tool({
9
+ description: 'Upload a file to a sprint (supports presigned S3 upload)',
10
+ args: {
11
+ filePath: tool.schema.string()
12
+ .describe('Local file path to upload'),
13
+ sprintId: tool.schema.number()
14
+ .describe('Target sprint ID'),
15
+ description: tool.schema.string()
16
+ .optional()
17
+ .describe('File description'),
18
+ folder: tool.schema.string()
19
+ .optional()
20
+ .describe('Folder path within sprint for S3 organization (e.g., "project", "docs", "meetings")'),
21
+ storyId: tool.schema.number()
22
+ .optional()
23
+ .describe('Link file to a story (e.g., doc_folder story for logical grouping)'),
24
+ },
25
+ async execute(args, _context) {
26
+ const tokenResult = await validateToken();
27
+ if (!tokenResult.valid) {
28
+ return tokenResult.error ?? '❌ No API token configured.';
29
+ }
30
+ client.setToken?.(tokenResult.token);
31
+
32
+ try {
33
+ // Check if file exists
34
+ if (!fs.existsSync(args.filePath)) {
35
+ return `❌ File not found: ${args.filePath}`;
36
+ }
37
+
38
+ // Read file
39
+ const fileContent = fs.readFileSync(args.filePath);
40
+ const fileName = path.basename(args.filePath);
41
+ const ext = path.extname(fileName);
42
+ const fileSize = fileContent.length;
43
+
44
+ // Determine content type
45
+ const mimeTypes: Record<string, string> = {
46
+ '.png': 'image/png',
47
+ '.jpg': 'image/jpeg',
48
+ '.jpeg': 'image/jpeg',
49
+ '.gif': 'image/gif',
50
+ '.pdf': 'application/pdf',
51
+ '.txt': 'text/plain',
52
+ '.md': 'text/markdown',
53
+ '.json': 'application/json',
54
+ '.csv': 'text/csv',
55
+ '.mp4': 'video/mp4',
56
+ '.mp3': 'audio/mpeg',
57
+ };
58
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
59
+
60
+ // Get presigned URL
61
+ const presign = await client.createPresignedUpload({
62
+ sprintId: args.sprintId,
63
+ contentType,
64
+ originalName: fileName,
65
+ folder: args.folder,
66
+ });
67
+
68
+ const uploadResponse = await fetch(presign.uploadUrl, {
69
+ method: 'PUT',
70
+ headers: {
71
+ 'Content-Type': contentType,
72
+ 'Content-Length': String(fileSize),
73
+ },
74
+ body: fileContent,
75
+ });
76
+ if (!uploadResponse.ok) {
77
+ const text = await uploadResponse.text().catch(() => '');
78
+ return `❌ Upload failed (${uploadResponse.status}): ${text || uploadResponse.statusText}`;
79
+ }
80
+
81
+ // Complete upload
82
+ const complete = await client.completeUpload({
83
+ sprintId: args.sprintId,
84
+ fileName: presign.fileName,
85
+ originalName: fileName,
86
+ fileType: contentType,
87
+ size: fileSize,
88
+ x: 0,
89
+ y: 0,
90
+ folder: args.folder,
91
+ storyId: args.storyId,
92
+ });
93
+
94
+ const formatSize = (bytes: number) => {
95
+ if (bytes === 0) return '0 Bytes';
96
+ const k = 1024;
97
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
98
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
99
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
100
+ };
101
+
102
+ return [
103
+ `✅ File uploaded successfully!`,
104
+ '',
105
+ `**File**: ${fileName}`,
106
+ `**Size**: ${formatSize(fileSize)}`,
107
+ `**URL**: ${complete.url}`,
108
+ args.description ? `**Description**: ${args.description}` : null,
109
+ args.folder ? `**Folder**: ${args.folder}` : null,
110
+ args.storyId ? `**Linked to Story**: #${args.storyId}` : null,
111
+ ].filter(Boolean).join('\n');
112
+ } catch (error: any) {
113
+ return `❌ Error: ${error.message}`;
114
+ }
115
+ },
116
+ });
117
+ }
118
+
119
+ export function createFileListTool(client: ForLoopAPIClient) {
120
+ return tool({
121
+ description: 'List files attached to a sprint',
122
+ args: {
123
+ sprintId: tool.schema.number()
124
+ .describe('Sprint ID to list files from'),
125
+ },
126
+ async execute(args, _context) {
127
+ const tokenResult = await validateToken();
128
+ if (!tokenResult.valid) {
129
+ return tokenResult.error ?? '❌ No API token configured.';
130
+ }
131
+ client.setToken?.(tokenResult.token);
132
+
133
+ try {
134
+ const files = await client.getSprintFiles(args.sprintId);
135
+
136
+ if (files.length === 0) {
137
+ return '🗂️ No files found in this sprint.';
138
+ }
139
+
140
+ const formatSize = (bytes: number) => {
141
+ if (bytes === 0) return '0 Bytes';
142
+ const k = 1024;
143
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
144
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
145
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
146
+ };
147
+
148
+ const lines: string[] = [`🗂️ Files in Sprint #${args.sprintId}:`, ''];
149
+
150
+ for (const file of files) {
151
+ const size = formatSize(file.size);
152
+ const uploadedBy = file.uploader?.name || file.uploaderId ? `by ${file.uploader?.name || `User #${file.uploaderId}`}` : '';
153
+ const date = file.createdAt ? new Date(file.createdAt).toLocaleDateString() : '';
154
+
155
+ lines.push(`📎 **${file.originalName || file.filename}**`);
156
+ lines.push(` Size: ${size} | Type: ${file.fileType}`);
157
+ lines.push(` Uploaded: ${date} ${uploadedBy}`);
158
+ lines.push(` URL: ${file.url}`);
159
+ lines.push('');
160
+ }
161
+
162
+ return lines.join('\n');
163
+ } catch (error: any) {
164
+ return `❌ Error: ${error.message}`;
165
+ }
166
+ },
167
+ });
168
+ }
169
+
170
+ export function createFileDeleteTool(client: ForLoopAPIClient) {
171
+ return tool({
172
+ description: 'Delete a file from a sprint (permanent action)',
173
+ args: {
174
+ fileId: tool.schema.number()
175
+ .describe('File ID to delete'),
176
+ confirm: tool.schema.boolean()
177
+ .default(false)
178
+ .describe('Confirm deletion'),
179
+ },
180
+ async execute(args, _context) {
181
+ const tokenResult = await validateToken();
182
+ if (!tokenResult.valid) {
183
+ return tokenResult.error ?? '❌ No API token configured.';
184
+ }
185
+ client.setToken?.(tokenResult.token);
186
+
187
+ if (!args.confirm) {
188
+ return [
189
+ '⚠️ **Warning**: This action is permanent and cannot be undone!',
190
+ '',
191
+ 'Use `--confirm true` to proceed with deletion.',
192
+ ].join('\n');
193
+ }
194
+
195
+ try {
196
+ await client.deleteFile(args.fileId);
197
+
198
+ return `✅ File #${args.fileId} deleted successfully.`;
199
+ } catch (error: any) {
200
+ return `❌ Error: ${error.message}`;
201
+ }
202
+ },
203
+ });
204
+ }
205
+
206
+ export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
207
+ return tool({
208
+ description: 'Get a download URL for a file',
209
+ args: {
210
+ fileId: tool.schema.number()
211
+ .describe('File ID to download'),
212
+ },
213
+ async execute(args, _context) {
214
+ const tokenResult = await validateToken();
215
+ if (!tokenResult.valid) {
216
+ return tokenResult.error ?? '❌ No API token configured.';
217
+ }
218
+ client.setToken?.(tokenResult.token);
219
+
220
+ try {
221
+ // Log file read (tracks access)
222
+ await client.logFileRead(args.fileId);
223
+
224
+ // Get download URL
225
+ const download = await client.getFileDownloadUrl(args.fileId);
226
+
227
+ return [
228
+ `📥 Download URL`,
229
+ '',
230
+ `**File ID**: ${args.fileId}`,
231
+ `**URL**: ${download.url}`,
232
+ '',
233
+ '⚠️ This URL may expire. Download the file soon.',
234
+ ].join('\n');
235
+ } catch (error: any) {
236
+ return `❌ Error: ${error.message}`;
237
+ }
238
+ },
239
+ });
240
+ }
@@ -0,0 +1,75 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import { resolveSprintId } from '../capabilities/context-resolver';
5
+
6
+ export function createDocFolderTool(client: ForLoopAPIClient) {
7
+ return tool({
8
+ description: 'Create a document folder for storing files (S3 storage)',
9
+ args: {
10
+ sprintId: tool.schema.number()
11
+ .optional()
12
+ .describe('Target sprint ID (auto-detected from branch or env)'),
13
+ title: tool.schema.string()
14
+ .describe('Folder name'),
15
+ description: tool.schema.string()
16
+ .optional()
17
+ .describe('Folder description'),
18
+ permissions: tool.schema.enum(['public', 'team', 'private'])
19
+ .optional()
20
+ .default('team')
21
+ .describe('Folder access permissions'),
22
+ },
23
+ async execute(args, context) {
24
+ const tokenResult = await validateToken();
25
+ if (!tokenResult.valid) {
26
+ return tokenResult.error;
27
+ }
28
+ client.setToken?.(tokenResult.token);
29
+
30
+ try {
31
+ // Resolve sprint ID
32
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
33
+ if (!resolution.sprintId) {
34
+ return [
35
+ '❌ No sprint ID provided.',
36
+ '',
37
+ 'Please specify a sprint ID using one of these methods:',
38
+ ' 1. Pass --sprintId flag',
39
+ ' 2. Set FORLOOP_SPRINT_ID environment variable',
40
+ ' 3. Use a git branch named sprint-XXX',
41
+ ].join('\n');
42
+ }
43
+
44
+ // Create doc folder story
45
+ const story = await client.createStory({
46
+ title: args.title,
47
+ description: args.description,
48
+ sprintId: resolution.sprintId,
49
+ type: 'doc_folder',
50
+ metadata: JSON.stringify({
51
+ permissions: args.permissions,
52
+ }),
53
+ status: 'todo',
54
+ });
55
+
56
+ const lines: string[] = [
57
+ `✅ Document folder created!`,
58
+ '',
59
+ `**#${story.id}**: ${story.title}`,
60
+ `**Sprint**: #${resolution.sprintId}`,
61
+ `**Type**: doc_folder`,
62
+ `**Permissions**: ${args.permissions}`,
63
+ '',
64
+ `📁 **Next Steps:**`,
65
+ ` 1. Upload files with: forloopFileUpload --sprintId ${resolution.sprintId}`,
66
+ ` 2. List files with: forloopFileList --sprintId ${resolution.sprintId}`,
67
+ ];
68
+
69
+ return lines.join('\n');
70
+ } catch (error: any) {
71
+ return `❌ Error: ${error.message}`;
72
+ }
73
+ },
74
+ });
75
+ }