@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,556 @@
|
|
|
1
|
+
import { detectTokenType, type TokenType } from './auth';
|
|
2
|
+
import { setToken as persistToken } from './token-storage';
|
|
3
|
+
|
|
4
|
+
export interface MessageRecord {
|
|
5
|
+
sprintId: number;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
conversationId: string;
|
|
9
|
+
role: 'user' | 'assistant';
|
|
10
|
+
content: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
model?: { providerID: string; modelID: string };
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ClientConfig {
|
|
17
|
+
token: string;
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
retryCount?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SprintOptions {
|
|
24
|
+
includeStories?: boolean;
|
|
25
|
+
includeFiles?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ListSprintsParams {
|
|
29
|
+
organizationId?: number;
|
|
30
|
+
includeSystemOrg?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CreateSprintRequest {
|
|
34
|
+
title: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
startDate: string;
|
|
37
|
+
endDate: string;
|
|
38
|
+
isPrivate?: boolean;
|
|
39
|
+
organizationId?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateStoryRequest {
|
|
43
|
+
title: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
sprintId: number;
|
|
46
|
+
type?: 'story' | 'bug' | 'task' | 'doc_folder' | 'schedule';
|
|
47
|
+
priority?: 'low' | 'medium' | 'high' | 'critical';
|
|
48
|
+
points?: number;
|
|
49
|
+
assigneeId?: number;
|
|
50
|
+
assigneeType?: 'user' | 'agent';
|
|
51
|
+
assigneeAgentKey?: string;
|
|
52
|
+
status?: 'todo' | 'in_progress' | 'done' | 'blocked';
|
|
53
|
+
x?: number;
|
|
54
|
+
y?: number;
|
|
55
|
+
zIndex?: number;
|
|
56
|
+
templateId?: number;
|
|
57
|
+
metadata?: any;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Template {
|
|
61
|
+
id: number;
|
|
62
|
+
name: string;
|
|
63
|
+
slug: string | null;
|
|
64
|
+
description: string;
|
|
65
|
+
fields: string;
|
|
66
|
+
storyType: 'story' | 'bug' | 'task' | 'doc_folder' | 'schedule' | null;
|
|
67
|
+
createdAt: string;
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
deletedAt: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ChatRequest {
|
|
73
|
+
message: string;
|
|
74
|
+
sprintId?: number;
|
|
75
|
+
selectedAgentKey?: string;
|
|
76
|
+
type?: 'chat' | 'summary' | 'suggestion' | 'analysis' | 'translation' | 'feature' | 'planner.chat' | 'developer.sprint';
|
|
77
|
+
conversationId?: string;
|
|
78
|
+
metadata?: any;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class APIError extends Error {
|
|
82
|
+
constructor(
|
|
83
|
+
public code: string,
|
|
84
|
+
message: string,
|
|
85
|
+
public status: number
|
|
86
|
+
) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = 'APIError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class ForLoopAPIClient {
|
|
93
|
+
private config: Required<ClientConfig>;
|
|
94
|
+
private defaultHeaders: HeadersInit;
|
|
95
|
+
private tokenType: TokenType | null;
|
|
96
|
+
private jwtExchangePromise: Promise<string> | null;
|
|
97
|
+
|
|
98
|
+
constructor(config: ClientConfig) {
|
|
99
|
+
this.config = {
|
|
100
|
+
timeout: 30000,
|
|
101
|
+
retryCount: 3,
|
|
102
|
+
...config,
|
|
103
|
+
};
|
|
104
|
+
this.defaultHeaders = {
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
};
|
|
107
|
+
// Detect token type once at initialization
|
|
108
|
+
this.tokenType = detectTokenType(config.token);
|
|
109
|
+
this.jwtExchangePromise = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public setToken(token: string) {
|
|
113
|
+
this.config.token = token;
|
|
114
|
+
this.tokenType = detectTokenType(token);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async ensureOpencodeApiToken(endpoint: string): Promise<void> {
|
|
118
|
+
if (this.tokenType !== 'jwt') return;
|
|
119
|
+
if (!endpoint.startsWith('/api/opencode/')) return;
|
|
120
|
+
if (endpoint.startsWith('/api/opencode/auth/tokens')) return;
|
|
121
|
+
|
|
122
|
+
if (!this.jwtExchangePromise) {
|
|
123
|
+
this.jwtExchangePromise = this.exchangeJwtForApiToken();
|
|
124
|
+
}
|
|
125
|
+
const token = await this.jwtExchangePromise;
|
|
126
|
+
this.setToken(token);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async exchangeJwtForApiToken(): Promise<string> {
|
|
130
|
+
const url = `${this.config.baseUrl}/api/opencode/auth/tokens`;
|
|
131
|
+
const jwt = this.config.token;
|
|
132
|
+
|
|
133
|
+
const scopes = [
|
|
134
|
+
'sprint:read',
|
|
135
|
+
'sprint:write',
|
|
136
|
+
'story:read',
|
|
137
|
+
'story:write',
|
|
138
|
+
'story:delete',
|
|
139
|
+
'agent:read',
|
|
140
|
+
'agent:query',
|
|
141
|
+
'profile:read',
|
|
142
|
+
'organization:read',
|
|
143
|
+
'organization:write',
|
|
144
|
+
'organization:delete',
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const name = `lambda-planner-${Date.now()}`;
|
|
148
|
+
|
|
149
|
+
const response = await fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
...this.defaultHeaders,
|
|
153
|
+
Authorization: `Bearer ${jwt}`,
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
name,
|
|
157
|
+
expiresIn: '7d',
|
|
158
|
+
scopes,
|
|
159
|
+
}),
|
|
160
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
throw await this.parseError(response);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const payload = await response.json();
|
|
168
|
+
const token = typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
169
|
+
if (!token) {
|
|
170
|
+
throw new APIError('invalid_token_exchange', 'Token exchange returned empty token', 500);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await persistToken(token);
|
|
174
|
+
return token;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get auth headers based on token type
|
|
179
|
+
* - JWT tokens: Authorization: Bearer <token>
|
|
180
|
+
* - API keys: X-API-Token: <token>
|
|
181
|
+
*/
|
|
182
|
+
private getAuthHeaders(): HeadersInit {
|
|
183
|
+
const headers: HeadersInit = {};
|
|
184
|
+
|
|
185
|
+
if (this.tokenType === 'jwt') {
|
|
186
|
+
// JWT token - use Authorization header
|
|
187
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
188
|
+
} else {
|
|
189
|
+
// API key or unknown - use X-API-Token header (backward compatible)
|
|
190
|
+
headers['X-API-Token'] = this.config.token;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return headers;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public async request<T>(
|
|
197
|
+
endpoint: string,
|
|
198
|
+
options: RequestInit = {}
|
|
199
|
+
): Promise<T> {
|
|
200
|
+
await this.ensureOpencodeApiToken(endpoint);
|
|
201
|
+
const url = `${this.config.baseUrl}${endpoint}`;
|
|
202
|
+
|
|
203
|
+
let lastError: Error | null = null;
|
|
204
|
+
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(url, {
|
|
207
|
+
...options,
|
|
208
|
+
headers: {
|
|
209
|
+
...this.defaultHeaders,
|
|
210
|
+
...this.getAuthHeaders(), // Dynamic auth headers based on token type
|
|
211
|
+
...(options.headers || {}),
|
|
212
|
+
},
|
|
213
|
+
signal: AbortSignal.timeout(this.config.timeout),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
const error = await this.parseError(response);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return response.json();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
lastError = error as Error;
|
|
224
|
+
|
|
225
|
+
if (error instanceof APIError && error.status >= 400 && error.status < 500) {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (attempt < this.config.retryCount) {
|
|
230
|
+
await this.sleep(Math.pow(2, attempt) * 100);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw lastError || new Error('Request failed');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async parseError(response: Response): Promise<APIError> {
|
|
239
|
+
try {
|
|
240
|
+
const body = await response.json();
|
|
241
|
+
return new APIError(body.error || 'UnknownError', body.message || response.statusText, response.status);
|
|
242
|
+
} catch {
|
|
243
|
+
return new APIError('UnknownError', response.statusText, response.status);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private sleep(ms: number): Promise<void> {
|
|
248
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async listSprints(params?: ListSprintsParams): Promise<any[]> {
|
|
252
|
+
const query = new URLSearchParams(params as Record<string, string>);
|
|
253
|
+
const response = await this.request<{ data: any[] }>(`/api/opencode/sprints?${query}`);
|
|
254
|
+
return Array.isArray(response) ? response : (response.data || []);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getSprint(id: number, options?: SprintOptions): Promise<any> {
|
|
258
|
+
const query = new URLSearchParams({
|
|
259
|
+
includeStories: options?.includeStories !== false ? 'true' : 'false',
|
|
260
|
+
includeFiles: options?.includeFiles !== false ? 'true' : 'false',
|
|
261
|
+
});
|
|
262
|
+
return this.request(`/api/opencode/sprints/${id}?${query}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async createSprint(data: CreateSprintRequest): Promise<any> {
|
|
266
|
+
return this.request('/api/opencode/sprints', {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
body: JSON.stringify(data),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async updateSprint(id: number, data: Partial<CreateSprintRequest>): Promise<any> {
|
|
273
|
+
return this.request(`/api/opencode/sprints/${id}`, {
|
|
274
|
+
method: 'PUT',
|
|
275
|
+
body: JSON.stringify(data),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async deleteSprint(id: number): Promise<void> {
|
|
280
|
+
await this.request(`/api/opencode/sprints/${id}`, {
|
|
281
|
+
method: 'DELETE',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async createStory(data: CreateStoryRequest): Promise<any> {
|
|
286
|
+
return this.request('/api/opencode/stories', {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
body: JSON.stringify(data),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async getStory(id: number, options?: { includeComments?: boolean }): Promise<any> {
|
|
293
|
+
const params = new URLSearchParams();
|
|
294
|
+
if (options?.includeComments) params.set('includeComments', 'true');
|
|
295
|
+
const query = params.toString() ? `?${params.toString()}` : '';
|
|
296
|
+
return this.request(`/api/opencode/stories/${id}${query}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async updateStory(id: number, data: Partial<any>): Promise<any> {
|
|
300
|
+
return this.request(`/api/opencode/stories/${id}`, {
|
|
301
|
+
method: 'PUT',
|
|
302
|
+
body: JSON.stringify(data),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async deleteStory(id: number): Promise<void> {
|
|
307
|
+
await this.request(`/api/opencode/stories/${id}`, {
|
|
308
|
+
method: 'DELETE',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async listTemplates(): Promise<Template[]> {
|
|
313
|
+
return this.request('/api/opencode/templates');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async getUserProfile(): Promise<any> {
|
|
317
|
+
return this.request('/api/opencode/user/profile');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async getUserQuotas(): Promise<any> {
|
|
321
|
+
return this.request('/api/opencode/user/quotas');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async getOrganizationQuotas(orgId: number): Promise<any> {
|
|
325
|
+
return this.request(`/api/opencode/organization/${orgId}/quotas`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async listOrganizations(): Promise<any[]> {
|
|
329
|
+
return this.request('/api/opencode/organizations');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async getOwnedOrganizations(): Promise<any[]> {
|
|
333
|
+
return this.request('/api/opencode/organizations/owned');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async getOrganization(id: number): Promise<any> {
|
|
337
|
+
return this.request(`/api/opencode/organizations/${id}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async createOrganization(data: { name: string; description?: string }): Promise<any> {
|
|
341
|
+
return this.request('/api/opencode/organizations', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
body: JSON.stringify(data),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async updateOrganization(id: number, data: { name?: string; description?: string }): Promise<any> {
|
|
348
|
+
return this.request(`/api/opencode/organizations/${id}`, {
|
|
349
|
+
method: 'PUT',
|
|
350
|
+
body: JSON.stringify(data),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async deleteOrganization(id: number): Promise<void> {
|
|
355
|
+
await this.request(`/api/opencode/organizations/${id}`, {
|
|
356
|
+
method: 'DELETE',
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async chatWithAI(request: ChatRequest): Promise<any> {
|
|
361
|
+
return this.request('/api/opencode/agents/chat', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
body: JSON.stringify(request),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async getDeveloperStatus(sprintId: number): Promise<any> {
|
|
368
|
+
return this.request(`/api/opencode/sprints/${sprintId}/developer-status`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async getStorySuggestions(sprintId: number, storyId?: number): Promise<any> {
|
|
372
|
+
return this.request('/api/opencode/agents/suggestions', {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
body: JSON.stringify({ sprintId, storyId }),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async getAITasks(sprintId?: number): Promise<any> {
|
|
379
|
+
const query = sprintId ? new URLSearchParams({ sprintId: String(sprintId) }) : new URLSearchParams();
|
|
380
|
+
return this.request(`/api/opencode/agents/tasks?${query}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async getSprintSummaries(sprintId: number): Promise<any> {
|
|
384
|
+
return this.request(`/api/opencode/agents/summary?sprintId=${sprintId}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async getAgentSuggestions(request: AgentSuggestionRequest): Promise<any> {
|
|
388
|
+
return this.request('/api/agents/suggestions', {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
body: JSON.stringify(request),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async getStoryBreakdown(storyId: number): Promise<any> {
|
|
395
|
+
return this.request(`/api/agents/stories/${storyId}/breakdown`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getStoryEstimate(storyId: number): Promise<any> {
|
|
399
|
+
return this.request(`/api/agents/stories/${storyId}/estimate`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async getConversationHistory(params?: ConversationHistoryParams): Promise<any> {
|
|
403
|
+
const query = new URLSearchParams();
|
|
404
|
+
if (params?.sprintId) query.append('sprintId', String(params.sprintId));
|
|
405
|
+
if (params?.limit) query.append('limit', String(params.limit));
|
|
406
|
+
return this.request(`/api/opencode/messages?${query}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async clearConversationHistory(params?: ClearHistoryParams): Promise<any> {
|
|
410
|
+
const query = new URLSearchParams();
|
|
411
|
+
if (params?.sprintId) query.append('sprintId', String(params.sprintId));
|
|
412
|
+
query.append('confirm', 'true');
|
|
413
|
+
return this.request(`/api/opencode/messages?${query}`, {
|
|
414
|
+
method: 'DELETE',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async createPresignedUpload(data: {
|
|
419
|
+
sprintId: number;
|
|
420
|
+
contentType: string;
|
|
421
|
+
originalName: string;
|
|
422
|
+
folder?: string;
|
|
423
|
+
}): Promise<any> {
|
|
424
|
+
return this.request('/api/opencode/files/presign', {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
body: JSON.stringify(data),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async completeUpload(data: {
|
|
431
|
+
sprintId: number;
|
|
432
|
+
fileName: string;
|
|
433
|
+
originalName: string;
|
|
434
|
+
fileType: string;
|
|
435
|
+
size: number;
|
|
436
|
+
folder?: string;
|
|
437
|
+
storyId?: number;
|
|
438
|
+
x?: number;
|
|
439
|
+
y?: number;
|
|
440
|
+
}): Promise<any> {
|
|
441
|
+
return this.request('/api/opencode/files/complete', {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
body: JSON.stringify(data),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async getSprintFiles(sprintId: number): Promise<any[]> {
|
|
448
|
+
return this.request(`/api/opencode/files/sprint/${sprintId}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async deleteFile(fileId: number): Promise<void> {
|
|
452
|
+
await this.request(`/api/opencode/files/${fileId}`, {
|
|
453
|
+
method: 'DELETE',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async logFileRead(fileId: number): Promise<void> {
|
|
458
|
+
await this.request(`/api/opencode/files/${fileId}/read`, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async getFileDownloadUrl(fileId: number): Promise<any> {
|
|
464
|
+
return this.request(`/api/opencode/files/download/${fileId}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async getFileOpenUrl(fileId: number): Promise<any> {
|
|
468
|
+
return this.request(`/api/opencode/files/open-url/${fileId}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async updateFilePosition(fileId: number, data: {
|
|
472
|
+
x?: number;
|
|
473
|
+
y?: number;
|
|
474
|
+
zIndex?: number;
|
|
475
|
+
}): Promise<any> {
|
|
476
|
+
return this.request(`/api/opencode/files/${fileId}/position`, {
|
|
477
|
+
method: 'PUT',
|
|
478
|
+
body: JSON.stringify(data),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async listAiAgents(): Promise<AiAgentDefinition[]> {
|
|
483
|
+
const response = await this.request('/api/opencode/ai/agents') as any;
|
|
484
|
+
return response.agents || [];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async updateSprintAiAgents(sprintId: number, enabledAgentKeys: string[]): Promise<SprintAiAgent[]> {
|
|
488
|
+
const response = await this.request(`/api/opencode/sprints/${sprintId}/ai-agents`, {
|
|
489
|
+
method: 'PUT',
|
|
490
|
+
body: JSON.stringify({ enabledAgentKeys }),
|
|
491
|
+
}) as any;
|
|
492
|
+
return response.sprintAiAgents || [];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async updateSchedule(storyId: number, data: {
|
|
496
|
+
title?: string;
|
|
497
|
+
description?: string;
|
|
498
|
+
startAt?: string;
|
|
499
|
+
endAt?: string;
|
|
500
|
+
videoUrl?: string;
|
|
501
|
+
}): Promise<any> {
|
|
502
|
+
return this.updateStory(storyId, {
|
|
503
|
+
title: data.title,
|
|
504
|
+
description: data.description,
|
|
505
|
+
metadata: data,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async recordMessage(data: MessageRecord): Promise<any> {
|
|
510
|
+
return this.request('/api/opencode/messages', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
body: JSON.stringify(data),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async removeMessage(messageId: string, sprintId: number, sessionId: string): Promise<void> {
|
|
517
|
+
await this.request(`/api/opencode/messages/${encodeURIComponent(messageId)}`, {
|
|
518
|
+
method: 'DELETE',
|
|
519
|
+
body: JSON.stringify({ sprintId, sessionId }),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface AgentSuggestionRequest {
|
|
525
|
+
sprintId?: number;
|
|
526
|
+
storyId?: number;
|
|
527
|
+
type: 'breakdown' | 'estimate' | 'related' | 'acceptance_criteria' | 'test_cases' | 'sprint_planning';
|
|
528
|
+
query?: string;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export interface ConversationHistoryParams {
|
|
532
|
+
sprintId?: number;
|
|
533
|
+
limit?: number;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export interface ClearHistoryParams {
|
|
537
|
+
sprintId?: number;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export interface AiAgentDefinition {
|
|
541
|
+
key: string;
|
|
542
|
+
name: string;
|
|
543
|
+
description: string;
|
|
544
|
+
icon: string | null;
|
|
545
|
+
category: string | null;
|
|
546
|
+
capabilities: string;
|
|
547
|
+
defaultEnabled: boolean;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface SprintAiAgent {
|
|
551
|
+
sprintId: number;
|
|
552
|
+
agentKey: string;
|
|
553
|
+
enabled: boolean;
|
|
554
|
+
position: number;
|
|
555
|
+
agent: AiAgentDefinition;
|
|
556
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getToken } from './token-storage';
|
|
2
|
+
import { getTokensUrl, isLambdaExecution } from './config';
|
|
3
|
+
|
|
4
|
+
const AVAILABLE_SCOPES = [
|
|
5
|
+
'sprint:read',
|
|
6
|
+
'sprint:write',
|
|
7
|
+
'story:read',
|
|
8
|
+
'story:write',
|
|
9
|
+
'story:delete',
|
|
10
|
+
'agent:query',
|
|
11
|
+
'profile:read',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type Scope = (typeof AVAILABLE_SCOPES)[number];
|
|
15
|
+
|
|
16
|
+
export type TokenType = 'api_key' | 'jwt';
|
|
17
|
+
|
|
18
|
+
export type TokenValidationResult =
|
|
19
|
+
| {
|
|
20
|
+
valid: true;
|
|
21
|
+
token: string;
|
|
22
|
+
tokenType: TokenType;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
valid: false;
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect token type based on format
|
|
31
|
+
* - API Key: starts with 'floop_'
|
|
32
|
+
* - JWT: contains two dots (header.payload.signature format)
|
|
33
|
+
*/
|
|
34
|
+
export function detectTokenType(token: string): TokenType | null {
|
|
35
|
+
if (!token) return null;
|
|
36
|
+
|
|
37
|
+
// API Key format: floop_xxx
|
|
38
|
+
if (token.startsWith('floop_')) {
|
|
39
|
+
return 'api_key';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// JWT format: xxx.yyy.zzz (base64.base64.base64 or base64url.base64url.base64url)
|
|
43
|
+
// Must have exactly 2 dots and at least 3 parts
|
|
44
|
+
const parts = token.split('.');
|
|
45
|
+
if (parts.length === 3 && parts.every(p => p.length > 0)) {
|
|
46
|
+
// Additional check: JWT parts should be base64/base64url encoded
|
|
47
|
+
// Base64url uses - and _ instead of + and /
|
|
48
|
+
const base64urlRegex = /^[A-Za-z0-9_-]+$/;
|
|
49
|
+
if (parts.every(p => base64urlRegex.test(p))) {
|
|
50
|
+
return 'jwt';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate token based on execution context
|
|
59
|
+
*
|
|
60
|
+
* Local workspace: Requires 'floop_*' API key token
|
|
61
|
+
* AWS Lambda: Accepts JWT tokens (passed via EventBridge) or API keys
|
|
62
|
+
*/
|
|
63
|
+
export async function validateToken(providedToken?: string): Promise<TokenValidationResult> {
|
|
64
|
+
const token = providedToken || (await getToken());
|
|
65
|
+
|
|
66
|
+
if (!token) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
error: 'No API token configured. Please create a token at ' + getTokensUrl(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tokenType = detectTokenType(token);
|
|
74
|
+
|
|
75
|
+
if (!tokenType) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
error: 'Unrecognized token format. Use either a ForLoop API key (floop_*) or a valid JWT token.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// In Lambda execution, we accept JWT tokens from EventBridge
|
|
83
|
+
// In local execution, we only accept API keys
|
|
84
|
+
if (tokenType === 'jwt') {
|
|
85
|
+
const isLambda = isLambdaExecution();
|
|
86
|
+
if (!isLambda) {
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: 'JWT tokens are only supported in Lambda execution. Please use a ForLoop API key (floop_*) for local execution.',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// JWT token accepted in Lambda
|
|
93
|
+
return {
|
|
94
|
+
valid: true,
|
|
95
|
+
token,
|
|
96
|
+
tokenType: 'jwt',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// API key token - accepted in both local and Lambda
|
|
101
|
+
return {
|
|
102
|
+
valid: true,
|
|
103
|
+
token,
|
|
104
|
+
tokenType: 'api_key',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function hasRequiredScope(tokenScopes: string[], requiredScope: Scope): boolean {
|
|
109
|
+
return tokenScopes.includes(requiredScope);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatScopesError(missingScopes: string[]): string {
|
|
113
|
+
return [
|
|
114
|
+
'❌ Insufficient permissions',
|
|
115
|
+
'',
|
|
116
|
+
`Missing required scope${missingScopes.length > 1 ? 's' : ''}: ${missingScopes.join(', ')}`,
|
|
117
|
+
'',
|
|
118
|
+
'Please create a new token with the required scope(s).',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|