@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,213 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+
5
+ export function createOrganizationListTool(client: ForLoopAPIClient) {
6
+ return tool({
7
+ description: 'List organizations you belong to',
8
+ args: {
9
+ ownedOnly: tool.schema.boolean()
10
+ .optional()
11
+ .default(false)
12
+ .describe('List only owned organizations'),
13
+ },
14
+ async execute(args, _context) {
15
+ const tokenResult = await validateToken();
16
+ if (!tokenResult.valid) {
17
+ return tokenResult.error;
18
+ }
19
+ client.setToken?.(tokenResult.token);
20
+
21
+ try {
22
+ const orgs = args.ownedOnly
23
+ ? await client.getOwnedOrganizations()
24
+ : await client.listOrganizations();
25
+
26
+ const lines: string[] = ['🏢 Organizations:', ''];
27
+
28
+ if (orgs.length === 0) {
29
+ return 'No organizations found.';
30
+ }
31
+
32
+ for (const org of orgs) {
33
+ const role = org.members?.[0]?.role || 'member';
34
+ const icon = org.type === 'system' ? '🌐' : role === 'owner' ? '👑' : '👤';
35
+ const tier = org.type === 'system' ? '' : ` (${org.type || 'user'})`;
36
+
37
+ lines.push(`${icon} #${org.id} **${org.name}**${tier}`);
38
+ lines.push(` Role: ${role}`);
39
+ if (org.description) {
40
+ lines.push(` Description: ${org.description}`);
41
+ }
42
+ lines.push('');
43
+ }
44
+
45
+ return lines.join('\n');
46
+ } catch (error: any) {
47
+ return `❌ Error: ${error.message}`;
48
+ }
49
+ },
50
+ });
51
+ }
52
+
53
+ export function createOrganizationGetTool(client: ForLoopAPIClient) {
54
+ return tool({
55
+ description: 'Get details of a specific organization',
56
+ args: {
57
+ organizationId: tool.schema.number()
58
+ .describe('Organization ID to retrieve'),
59
+ },
60
+ async execute(args, _context) {
61
+ const tokenResult = await validateToken();
62
+ if (!tokenResult.valid) {
63
+ return tokenResult.error;
64
+ }
65
+ client.setToken?.(tokenResult.token);
66
+
67
+ try {
68
+ const org = await client.getOrganization(args.organizationId);
69
+
70
+ const lines: string[] = [
71
+ `🏢 Organization #${org.id}`,
72
+ '',
73
+ `**Name**: ${org.name}`,
74
+ `**Type**: ${org.type || 'user'}`,
75
+ ];
76
+
77
+ if (org.description) {
78
+ lines.push(`**Description**: ${org.description}`);
79
+ }
80
+
81
+ if (org.createdAt) {
82
+ lines.push(`**Created**: ${new Date(org.createdAt).toLocaleDateString()}`);
83
+ }
84
+
85
+ return lines.join('\n');
86
+ } catch (error: any) {
87
+ return `❌ Error: ${error.message}`;
88
+ }
89
+ },
90
+ });
91
+ }
92
+
93
+ export function createOrganizationCreateTool(client: ForLoopAPIClient) {
94
+ return tool({
95
+ description: 'Create a new organization (requires Team/Enterprise tier)',
96
+ args: {
97
+ name: tool.schema.string()
98
+ .describe('Organization name'),
99
+ description: tool.schema.string()
100
+ .optional()
101
+ .describe('Organization description'),
102
+ },
103
+ async execute(args, _context) {
104
+ const tokenResult = await validateToken();
105
+ if (!tokenResult.valid) {
106
+ return tokenResult.error;
107
+ }
108
+ client.setToken?.(tokenResult.token);
109
+
110
+ try {
111
+ const profile = await client.getUserProfile();
112
+ if (!['team', 'enterprise'].includes(profile.tier)) {
113
+ return '❌ Creating organizations requires Team or Enterprise tier. Contact support to upgrade.';
114
+ }
115
+
116
+ const quotas = await client.getUserQuotas();
117
+ if (quotas.remaining?.ownedOrganizations <= 0) {
118
+ return `❌ Organization limit reached. Maximum: ${quotas.limits?.maxOwnedOrganizations}`;
119
+ }
120
+
121
+ const org = await client.createOrganization({
122
+ name: args.name,
123
+ description: args.description,
124
+ });
125
+
126
+ return [
127
+ `✅ Organization created successfully!`,
128
+ '',
129
+ `**#${org.id}**: ${org.name}`,
130
+ `**Type**: ${org.type || 'user'}`,
131
+ args.description ? `**Description**: ${args.description}` : null,
132
+ ].filter(Boolean).join('\n');
133
+ } catch (error: any) {
134
+ return `❌ Error: ${error.message}`;
135
+ }
136
+ },
137
+ });
138
+ }
139
+
140
+ export function createOrganizationUpdateTool(client: ForLoopAPIClient) {
141
+ return tool({
142
+ description: 'Update organization details (owner only)',
143
+ args: {
144
+ organizationId: tool.schema.number()
145
+ .describe('Organization ID to update'),
146
+ name: tool.schema.string()
147
+ .optional()
148
+ .describe('New name'),
149
+ description: tool.schema.string()
150
+ .optional()
151
+ .describe('New description'),
152
+ },
153
+ async execute(args, _context) {
154
+ const tokenResult = await validateToken();
155
+ if (!tokenResult.valid) {
156
+ return tokenResult.error;
157
+ }
158
+ client.setToken?.(tokenResult.token);
159
+
160
+ try {
161
+ const org = await client.updateOrganization(args.organizationId, {
162
+ name: args.name,
163
+ description: args.description,
164
+ });
165
+
166
+ return `✅ Organization #${org.id} updated successfully!`;
167
+ } catch (error: any) {
168
+ return `❌ Error: ${error.message}`;
169
+ }
170
+ },
171
+ });
172
+ }
173
+
174
+ export function createOrganizationDeleteTool(client: ForLoopAPIClient) {
175
+ return tool({
176
+ description: 'Delete organization permanently (owner only, irreversible)',
177
+ args: {
178
+ organizationId: tool.schema.number()
179
+ .describe('Organization ID to delete'),
180
+ confirm: tool.schema.boolean()
181
+ .default(false)
182
+ .describe('Type true to confirm deletion'),
183
+ },
184
+ async execute(args, _context) {
185
+ const tokenResult = await validateToken();
186
+ if (!tokenResult.valid) {
187
+ return tokenResult.error;
188
+ }
189
+ client.setToken?.(tokenResult.token);
190
+
191
+ if (!args.confirm) {
192
+ return [
193
+ '⚠️ **Warning**: This action is permanent and irreversible!',
194
+ '',
195
+ 'All organization data will be deleted:',
196
+ '- All sprints and stories',
197
+ '- All members will be removed',
198
+ '- All files and settings',
199
+ '',
200
+ 'Use `--confirm true` to proceed with deletion.',
201
+ ].join('\n');
202
+ }
203
+
204
+ try {
205
+ await client.deleteOrganization(args.organizationId);
206
+
207
+ return `✅ Organization #${args.organizationId} deleted successfully. This action cannot be undone.`;
208
+ } catch (error: any) {
209
+ return `❌ Error: ${error.message}`;
210
+ }
211
+ },
212
+ });
213
+ }
@@ -0,0 +1,173 @@
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 createScheduleTool(client: ForLoopAPIClient) {
7
+ return tool({
8
+ description: 'Schedule a meeting with attendees (uses specialized schedule story type)',
9
+ args: {
10
+ sprintId: tool.schema.number()
11
+ .optional()
12
+ .describe('Sprint ID (auto-detected from branch or env if not provided)'),
13
+ title: tool.schema.string()
14
+ .describe('Meeting title'),
15
+ description: tool.schema.string()
16
+ .optional()
17
+ .describe('Meeting description or agenda'),
18
+ startAt: tool.schema.string()
19
+ .describe('Meeting start time (ISO format: YYYY-MM-DDTHH:MM:SSZ)'),
20
+ endAt: tool.schema.string()
21
+ .describe('Meeting end time (ISO format: YYYY-MM-DDTHH:MM:SSZ)'),
22
+ timezone: tool.schema.string()
23
+ .optional()
24
+ .default('UTC')
25
+ .describe('Timezone (e.g., "America/Los_Angeles")'),
26
+ videoUrl: tool.schema.string()
27
+ .optional()
28
+ .describe('Video meeting URL (Zoom, Google Meet, etc.)'),
29
+ location: tool.schema.string()
30
+ .optional()
31
+ .describe('Physical meeting location'),
32
+ },
33
+ async execute(args, context) {
34
+ const tokenResult = await validateToken();
35
+ if (!tokenResult.valid) {
36
+ return tokenResult.error;
37
+ }
38
+ client.setToken?.(tokenResult.token);
39
+
40
+ try {
41
+ // Resolve sprint ID
42
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
43
+ if (!resolution.sprintId) {
44
+ return [
45
+ '❌ No sprint ID provided.',
46
+ '',
47
+ 'Please specify a sprint ID using one of these methods:',
48
+ ' 1. Pass --sprintId flag',
49
+ ' 2. Set FORLOOP_SPRINT_ID environment variable',
50
+ ' 3. Use a git branch named sprint-XXX',
51
+ ].join('\n');
52
+ }
53
+
54
+ // Build schedule metadata
55
+ const scheduleMetadata = {
56
+ title: args.title,
57
+ description: args.description,
58
+ startAt: args.startAt,
59
+ endAt: args.endAt,
60
+ timezone: args.timezone,
61
+ videoUrl: args.videoUrl,
62
+ location: args.location,
63
+ };
64
+
65
+ // Create schedule story
66
+ const story = await client.createStory({
67
+ title: args.title,
68
+ description: args.description,
69
+ sprintId: resolution.sprintId,
70
+ type: 'schedule',
71
+ metadata: JSON.stringify(scheduleMetadata),
72
+ status: 'todo',
73
+ });
74
+
75
+ const formatDateTime = (isoString: string) => {
76
+ try {
77
+ const date = new Date(isoString);
78
+ return date.toLocaleString('en-US', {
79
+ weekday: 'short',
80
+ year: 'numeric',
81
+ month: 'short',
82
+ day: 'numeric',
83
+ hour: 'numeric',
84
+ minute: '2-digit',
85
+ timeZoneName: 'short',
86
+ });
87
+ } catch {
88
+ return isoString;
89
+ }
90
+ };
91
+
92
+ const lines: string[] = [
93
+ `✅ Meeting scheduled successfully!`,
94
+ '',
95
+ `**#${story.id}**: ${story.title}`,
96
+ `**Sprint**: #${resolution.sprintId}`,
97
+ `**When**: ${formatDateTime(args.startAt)} - ${formatDateTime(args.endAt)}`,
98
+ ];
99
+ if (args.videoUrl) lines.push(`**Video**: ${args.videoUrl}`);
100
+ if (args.location) lines.push(`**Location**: ${args.location}`);
101
+
102
+ return lines.join('\n');
103
+ } catch (error: any) {
104
+ return `❌ Error: ${error.message}`;
105
+ }
106
+ },
107
+ });
108
+ }
109
+
110
+ export function createScheduleUpdateTool(client: ForLoopAPIClient) {
111
+ return tool({
112
+ description: 'Update an existing schedule/meeting',
113
+ args: {
114
+ storyId: tool.schema.number()
115
+ .describe('Schedule story ID to update'),
116
+ title: tool.schema.string()
117
+ .optional()
118
+ .describe('New title'),
119
+ description: tool.schema.string()
120
+ .optional()
121
+ .describe('New description'),
122
+ startAt: tool.schema.string()
123
+ .optional()
124
+ .describe('New start time'),
125
+ endAt: tool.schema.string()
126
+ .optional()
127
+ .describe('New end time'),
128
+ videoUrl: tool.schema.string()
129
+ .optional()
130
+ .describe('New video URL'),
131
+ },
132
+ async execute(args, _context) {
133
+ const tokenResult = await validateToken();
134
+ if (!tokenResult.valid) {
135
+ return tokenResult.error;
136
+ }
137
+ client.setToken?.(tokenResult.token);
138
+
139
+ try {
140
+ // Get current story to merge metadata
141
+ const story = await client.getStory(args.storyId);
142
+
143
+ if (story.type !== 'schedule') {
144
+ return `❌ Story #${args.storyId} is not a schedule meeting. Type: ${story.type}`;
145
+ }
146
+
147
+ // Parse existing metadata
148
+ const existingMetadata = story.metadata ? JSON.parse(story.metadata) : {};
149
+
150
+ // Update with new values
151
+ const updatedMetadata = {
152
+ ...existingMetadata,
153
+ title: args.title !== undefined ? args.title : existingMetadata.title,
154
+ description: args.description !== undefined ? args.description : existingMetadata.description,
155
+ startAt: args.startAt !== undefined ? args.startAt : existingMetadata.startAt,
156
+ endAt: args.endAt !== undefined ? args.endAt : existingMetadata.endAt,
157
+ videoUrl: args.videoUrl !== undefined ? args.videoUrl : existingMetadata.videoUrl,
158
+ };
159
+
160
+ // Update story
161
+ await client.updateStory(args.storyId, {
162
+ title: args.title,
163
+ description: args.description,
164
+ metadata: updatedMetadata,
165
+ });
166
+
167
+ return `✅ Schedule #${args.storyId} updated successfully!`;
168
+ } catch (error: any) {
169
+ return `❌ Error: ${error.message}`;
170
+ }
171
+ },
172
+ });
173
+ }
@@ -0,0 +1,287 @@
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
+ import { getToken } from '../capabilities/token-storage';
6
+
7
+ export function createSprintGetTool(client: ForLoopAPIClient) {
8
+ return tool({
9
+ description: 'Get sprint details including stories, files, and AI agents',
10
+ args: {
11
+ sprintId: tool.schema.number()
12
+ .optional()
13
+ .describe('Sprint ID (defaults to FORLOOP_SPRINT_ID env or current branch)'),
14
+ includeStories: tool.schema.boolean()
15
+ .default(true)
16
+ .describe('Include stories in response'),
17
+ includeFiles: tool.schema.boolean()
18
+ .default(true)
19
+ .describe('Include files in response'),
20
+ },
21
+ async execute(args, context) {
22
+ const tokenResult = await validateToken();
23
+ if (!tokenResult.valid) {
24
+ return tokenResult.error;
25
+ }
26
+ client.setToken?.(tokenResult.token);
27
+
28
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
29
+
30
+ if (!resolution.sprintId) {
31
+ return [
32
+ '❌ No sprint ID provided.',
33
+ '',
34
+ 'Please specify a sprint ID using one of these methods:',
35
+ ' 1. Pass --sprintId flag',
36
+ ' 2. Set FORLOOP_SPRINT_ID environment variable',
37
+ ' 3. Use a git branch named sprint-XXX (e.g., sprint-123)',
38
+ ].join('\n');
39
+ }
40
+
41
+ try {
42
+ const sprint = await client.getSprint(resolution.sprintId, {
43
+ includeStories: args.includeStories,
44
+ includeFiles: args.includeFiles,
45
+ });
46
+
47
+ const lines = [
48
+ `📋 Sprint #${sprint.id}: ${sprint.title}`,
49
+ '',
50
+ `**Status**: ${sprint.status}`,
51
+ `**Period**: ${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`,
52
+ `**Stories**: ${sprint.stories?.length || 0}`,
53
+ '',
54
+ ];
55
+
56
+ if (sprint.summary) {
57
+ lines.push(`**Summary**: ${sprint.summary}`);
58
+ lines.push('');
59
+ }
60
+
61
+ if (sprint.stories?.length) {
62
+ lines.push('**Stories**:', '');
63
+ for (const story of sprint.stories) {
64
+ const icon = getStoryStatusIcon(story.status);
65
+ lines.push(`- ${icon} #${story.id} ${story.title} (${story.status})`);
66
+ }
67
+ }
68
+
69
+ return lines.join('\n');
70
+ } catch (error: any) {
71
+ return `❌ Error: ${error.message}`;
72
+ }
73
+ },
74
+ });
75
+ }
76
+
77
+ export function createSprintListTool(client: ForLoopAPIClient) {
78
+ return tool({
79
+ description: 'List all accessible sprints',
80
+ args: {
81
+ organizationId: tool.schema.number()
82
+ .optional()
83
+ .describe('Filter by organization ID'),
84
+ includeSystemOrg: tool.schema.boolean()
85
+ .default(true)
86
+ .describe('Include sprints from system organization'),
87
+ },
88
+ async execute(args, _context) {
89
+ const tokenResult = await validateToken();
90
+ if (!tokenResult.valid) {
91
+ return tokenResult.error;
92
+ }
93
+ client.setToken?.(tokenResult.token);
94
+
95
+ const params: Record<string, string> = {};
96
+ if (args.organizationId) params.organizationId = String(args.organizationId);
97
+
98
+ try {
99
+ const sprints = await client.listSprints(params);
100
+
101
+ if (!sprints.length) {
102
+ return 'No sprints found.';
103
+ }
104
+
105
+ const lines = ['📋 Sprints:', ''];
106
+ for (const sprint of sprints) {
107
+ const statusIcon = getStatusIcon(sprint.status);
108
+ const orgInfo = sprint.organizationName ? ` (${sprint.organizationName})` : '';
109
+ lines.push(
110
+ `- ${statusIcon} #${sprint.id} ${sprint.title}${orgInfo} - ${sprint.status}`
111
+ );
112
+ }
113
+
114
+ return lines.join('\n');
115
+ } catch (error: any) {
116
+ return `❌ Error: ${error.message}`;
117
+ }
118
+ },
119
+ });
120
+ }
121
+
122
+ function formatDate(dateString: string): string {
123
+ return new Date(dateString).toLocaleDateString('en-US', {
124
+ year: 'numeric',
125
+ month: 'short',
126
+ day: 'numeric',
127
+ });
128
+ }
129
+
130
+ function getStoryStatusIcon(status: string): string {
131
+ const icons: Record<string, string> = {
132
+ todo: '📝',
133
+ in_progress: '🚧',
134
+ done: '✅',
135
+ blocked: '🚫',
136
+ };
137
+ return icons[status] || '📌';
138
+ }
139
+
140
+ function getStatusIcon(status: string): string {
141
+ const icons: Record<string, string> = {
142
+ planned: '📅',
143
+ 'in-progress': '🚧',
144
+ completed: '✅',
145
+ };
146
+ return icons[status] || '📌';
147
+ }
148
+
149
+ export function createSprintCreateTool(client: ForLoopAPIClient) {
150
+ return tool({
151
+ description: 'Create a new sprint with specified dates and settings',
152
+ args: {
153
+ title: tool.schema.string()
154
+ .describe('Sprint title'),
155
+ description: tool.schema.string()
156
+ .optional()
157
+ .describe('Sprint description'),
158
+ startDate: tool.schema.string()
159
+ .describe('Sprint start date (ISO format: YYYY-MM-DD)'),
160
+ endDate: tool.schema.string()
161
+ .describe('Sprint end date (ISO format: YYYY-MM-DD)'),
162
+ isPrivate: tool.schema.boolean()
163
+ .default(false)
164
+ .describe('Whether sprint is private'),
165
+ organizationId: tool.schema.number()
166
+ .optional()
167
+ .describe('Organization ID (defaults to user default)'),
168
+ },
169
+ async execute(args, _context) {
170
+ const tokenResult = await validateToken();
171
+ if (!tokenResult.valid) {
172
+ return tokenResult.error;
173
+ }
174
+ client.setToken?.(tokenResult.token);
175
+
176
+ try {
177
+ const sprint = await client.createSprint({
178
+ title: args.title,
179
+ description: args.description,
180
+ startDate: args.startDate,
181
+ endDate: args.endDate,
182
+ isPrivate: args.isPrivate,
183
+ organizationId: args.organizationId,
184
+ });
185
+
186
+ const lines = [
187
+ '✅ Sprint created successfully!',
188
+ '',
189
+ `**#${sprint.id}**: ${sprint.title}`,
190
+ `**Period**: ${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`,
191
+ `**Status**: ${sprint.status}`,
192
+ ];
193
+
194
+ if (sprint.isPrivate) {
195
+ lines.push('**Visibility**: Private');
196
+ }
197
+
198
+ return lines.join('\n');
199
+ } catch (error: any) {
200
+ return `❌ Error: ${error.message}`;
201
+ }
202
+ },
203
+ });
204
+ }
205
+
206
+ export function createSprintUpdateTool(client: ForLoopAPIClient) {
207
+ return tool({
208
+ description: 'Update sprint details',
209
+ args: {
210
+ sprintId: tool.schema.number()
211
+ .describe('Sprint ID to update'),
212
+ title: tool.schema.string()
213
+ .optional()
214
+ .describe('New title'),
215
+ description: tool.schema.string()
216
+ .optional()
217
+ .describe('New description'),
218
+ startDate: tool.schema.string()
219
+ .optional()
220
+ .describe('New start date (ISO format)'),
221
+ endDate: tool.schema.string()
222
+ .optional()
223
+ .describe('New end date (ISO format)'),
224
+ isPrivate: tool.schema.boolean()
225
+ .optional()
226
+ .describe('Update visibility setting'),
227
+ },
228
+ async execute(args, _context) {
229
+ const tokenResult = await validateToken();
230
+ if (!tokenResult.valid) {
231
+ return tokenResult.error;
232
+ }
233
+ client.setToken?.(tokenResult.token);
234
+
235
+ try {
236
+ const updateData: any = {};
237
+ if (args.title !== undefined) updateData.title = args.title;
238
+ if (args.description !== undefined) updateData.description = args.description;
239
+ if (args.startDate !== undefined) updateData.startDate = args.startDate;
240
+ if (args.endDate !== undefined) updateData.endDate = args.endDate;
241
+ if (args.isPrivate !== undefined) updateData.isPrivate = args.isPrivate;
242
+
243
+ const sprint = await client.updateSprint(args.sprintId, updateData);
244
+
245
+ return `✅ Sprint #${sprint.id} updated successfully!`;
246
+ } catch (error: any) {
247
+ return `❌ Error: ${error.message}`;
248
+ }
249
+ },
250
+ });
251
+ }
252
+
253
+ export function createSprintDeleteTool(client: ForLoopAPIClient) {
254
+ return tool({
255
+ description: 'Delete a sprint (use with caution)',
256
+ args: {
257
+ sprintId: tool.schema.number()
258
+ .describe('Sprint ID to delete'),
259
+ confirm: tool.schema.boolean()
260
+ .default(false)
261
+ .describe('Confirm deletion'),
262
+ },
263
+ async execute(args, _context) {
264
+ const tokenResult = await validateToken();
265
+ if (!tokenResult.valid) {
266
+ return tokenResult.error;
267
+ }
268
+ client.setToken?.(tokenResult.token);
269
+
270
+ if (!args.confirm) {
271
+ return [
272
+ '⚠️ Warning: This will permanently delete the sprint and all its stories.',
273
+ '',
274
+ 'Use --confirm true to proceed.',
275
+ ].join('\n');
276
+ }
277
+
278
+ try {
279
+ await client.deleteSprint(args.sprintId);
280
+
281
+ return `✅ Sprint #${args.sprintId} deleted successfully!`;
282
+ } catch (error: any) {
283
+ return `❌ Error: ${error.message}`;
284
+ }
285
+ },
286
+ });
287
+ }