@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.
- package/CHANGELOG.md +225 -0
- package/CODE_OF_CONDUCT.md +77 -0
- package/CONTRIBUTING.md +176 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/SECURITY.md +69 -0
- package/docs/CONFIGURATION.md +283 -0
- package/docs/DEVELOPER-VS-USER.md +129 -0
- package/docs/IMPROVED-AUTHENTICATION.md +317 -0
- package/docs/INSTALLATION-SCRIPTS.md +304 -0
- package/docs/PLANNER-AGENT-IMPROVEMENTS.md +88 -0
- package/docs/PLUGIN-ARCHITECTURE.md +308 -0
- package/docs/QUICKSTART-NON-DEV.md +94 -0
- package/docs/README-FOR-HUMANS-AND-AI.md +109 -0
- package/docs/README.md +70 -0
- package/docs/SKILLS-GAP-ANALYSIS.md +157 -0
- package/docs/USER-INTERACTION-MODEL.md +106 -0
- package/docs/file-management/2026-04-12-forloop-aivy-documents-sync.md +395 -0
- package/docs/file-management/FILE_UPLOAD_ENHANCEMENT.md +431 -0
- package/docs/file-management/FILE_UPLOAD_FLOW.md +300 -0
- package/docs/images/forloop-opencode-flow.png +0 -0
- package/docs/images/forloop-opencode-header.png +0 -0
- package/docs/superpowers/plans/2026-05-03-sprint-isolated-directory-structure.md +1450 -0
- package/docs/superpowers/plans/2026-05-29-install-system.md +850 -0
- package/docs/superpowers/specs/2026-05-03-sprint-isolated-directory-structure-design.md +149 -0
- package/docs/superpowers/specs/2026-05-19-opencode-message-recording-design.md +468 -0
- package/docs/superpowers/specs/2026-05-29-install-system-design.md +135 -0
- package/install.sh +317 -0
- package/package.json +87 -0
- package/plugins/capabilities/api-client.ts +556 -0
- package/plugins/capabilities/auth.ts +120 -0
- package/plugins/capabilities/config.ts +195 -0
- package/plugins/capabilities/context-resolver.ts +106 -0
- package/plugins/capabilities/token-storage.ts +98 -0
- package/plugins/forloop-plugin.ts +186 -0
- package/plugins/hooks/message-recorder.ts +258 -0
- package/plugins/hooks/shell-env.ts +62 -0
- package/plugins/tools/agent-sprint-tools.ts +100 -0
- package/plugins/tools/agent-suggestion-tools.ts +275 -0
- package/plugins/tools/agent-tools.ts +114 -0
- package/plugins/tools/ai-trigger-tools.ts +79 -0
- package/plugins/tools/developer-status-tool.ts +97 -0
- package/plugins/tools/file-tools.ts +240 -0
- package/plugins/tools/folder-tools.ts +75 -0
- package/plugins/tools/organization-tools.ts +213 -0
- package/plugins/tools/schedule-tools.ts +173 -0
- package/plugins/tools/sprint-tools.ts +287 -0
- package/plugins/tools/story-tools.ts +320 -0
- package/plugins/tools/sync-tools.ts +509 -0
- package/plugins/tools/template-tools.ts +170 -0
- package/plugins/tools/user-tools.ts +146 -0
- package/scripts/migrate-tokens.js +90 -0
- package/scripts/use-prod.sh +11 -0
- package/tsconfig.json +17 -0
- 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
|
+
}
|