@forloop-cc/forloop-opencode-plugin-planner 2.2.1 → 2.2.5
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/README.md +12 -0
- package/docs/superpowers/specs/2026-06-08-plugin-test-docker-design.md +110 -0
- package/package.json +1 -1
- package/plugins/capabilities/mime-types.ts +25 -0
- package/plugins/tools/file-tools.ts +5 -34
- package/plugins/tools/schedule-tools.ts +5 -5
- package/plugins/tools/sprint-tools.ts +4 -1
- package/plugins/tools/sync-tools.ts +1 -17
- package/plugins/tools/template-tools.ts +8 -2
- package/update.sh +0 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
**AI-powered sprint planning, story management, and task automation for opencode**
|
|
9
9
|
|
|
10
|
+
[English](README.md) · [中文](README_zh.md) · [日本語](README_ja.md)
|
|
11
|
+
|
|
10
12
|
[Quick Install](#quick-install) · [How It Works](#how-it-works) · [Capabilities](#capabilities) · [FAQ](#faq)
|
|
11
13
|
|
|
12
14
|
</div>
|
|
@@ -64,6 +66,16 @@ curl -fsSL https://raw.githubusercontent.com/forloop-cc/forloop-opencode-plugin-
|
|
|
64
66
|
- A [ForLoop](https://forloop.cc) account with an API token ([create one here](https://forloop.cc/profile?tab=api-tokens))
|
|
65
67
|
- Token scopes: `sprint:read`, `sprint:write`, `story:read`, `story:write`, `agent:query`, `profile:read`
|
|
66
68
|
|
|
69
|
+
### Quick Start
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
opencode --agent forLoopPlanner
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or launch opencode normally and press **Tab** to switch to the **ForLoop Planner** agent from the agent picker.
|
|
76
|
+
|
|
77
|
+
The `forLoopPlanner` agent uses the plugin's built-in tools for full sprint, story, and file management. For environments without the opencode plugin installed, use `forLoopPlannerCLI` which works with the standalone `forloop` CLI binary.
|
|
78
|
+
|
|
67
79
|
### Set Your Token
|
|
68
80
|
|
|
69
81
|
**Easiest — ask the agent:**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Plugin Docker Integration Test Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-08
|
|
4
|
+
**Status:** Approved
|
|
5
|
+
**Context:** end-to-end verification of the ForLoop plugin install flow in a clean environment
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
Verify that the one-line install command (`curl -fsSL .../install.sh | bash`) produces a working plugin installation with correct agent/skill discovery, and that the planner agent loads correctly and is self-aware of its tools and skills.
|
|
10
|
+
|
|
11
|
+
## Architecture
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
┌──────────────────────────────────────────┐
|
|
15
|
+
│ Docker Container │
|
|
16
|
+
│ Ubuntu 22.04 (clean, no mounts) │
|
|
17
|
+
│ │
|
|
18
|
+
│ 1. Install deps (curl, git, jq, node) │
|
|
19
|
+
│ 2. Install opencode (official script) │
|
|
20
|
+
│ 3. Install plugin (official script) │
|
|
21
|
+
│ 4. Verify installation structure │
|
|
22
|
+
│ 5. Verify planner agent self-awareness │
|
|
23
|
+
│ │
|
|
24
|
+
│ Entrypoint: test.sh │
|
|
25
|
+
│ Exit code: 0 = pass, non-zero = fail │
|
|
26
|
+
└──────────────────────────────────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Key constraint:** no volume mounts, no pre-installed dependencies. Everything installed from the internet inside the container, matching the real user experience.
|
|
30
|
+
|
|
31
|
+
## Files
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
forloop-opencode-plugin-planner/
|
|
35
|
+
test/
|
|
36
|
+
Dockerfile # Builds container with Ubuntu + all deps
|
|
37
|
+
test.sh # Verification script (entrypoint)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install Flow
|
|
41
|
+
|
|
42
|
+
The Docker container installs in this order:
|
|
43
|
+
|
|
44
|
+
1. **System deps:** `apt-get install -y curl git jq ca-certificates gnupg`
|
|
45
|
+
2. **Node.js 20:** via NodeSource setup script
|
|
46
|
+
3. **OpenCode:** `curl -fsSL https://opencode.ai/install.sh | bash`
|
|
47
|
+
4. **ForLoop plugin:** `curl -fsSL https://raw.githubusercontent.com/forloop-cc/forloop-opencode-plugin-planner/main/install.sh | bash`
|
|
48
|
+
5. **API token:** NOT configured (test runs without token)
|
|
49
|
+
|
|
50
|
+
## Verification Stages
|
|
51
|
+
|
|
52
|
+
### Stage A: Plugin Registration
|
|
53
|
+
**Assert:** `~/.config/opencode/opencode.json` exists and contains the plugin entry in the `plugin` array.
|
|
54
|
+
|
|
55
|
+
### Stage B: Agents Installed
|
|
56
|
+
**Assert:** 3 agent `.md` files are symlinked in `~/.config/opencode/agents/`:
|
|
57
|
+
- `forLoopPlanner.md`
|
|
58
|
+
- `forLoopPlannerCLI.md`
|
|
59
|
+
- `forLoopStoryEvaluator.md`
|
|
60
|
+
|
|
61
|
+
### Stage C: Skills Installed
|
|
62
|
+
**Assert:** 16 skill directories are symlinked in `~/.config/opencode/skills/`, each containing a `SKILL.md`:
|
|
63
|
+
- agent-auto-assignment, aivy-documents-sync, file-management, forloop-cli, forloop-context, forloop-skill, knowledge-management, plan-documentation, sprint-planning, story-creation, story-points, task-tracking, tech-stack-default, template-based-tasks, user-management, verification-before-completion
|
|
64
|
+
|
|
65
|
+
### Stage D: Planner Self-Awareness (Workflow)
|
|
66
|
+
**Assert:** `opencode run --agent forLoopPlanner "who are you and what is your workflow"` produces output that:
|
|
67
|
+
- Identifies itself as the ForLoop Planner agent
|
|
68
|
+
- Mentions its 7-phase workflow (or at minimum: context loading, sprint review, planning, execution)
|
|
69
|
+
- References ForLoop platform (sprints, stories, agents)
|
|
70
|
+
|
|
71
|
+
### Stage E: Tool Discovery
|
|
72
|
+
**Assert:** `opencode run --agent forLoopPlanner "what tools and skills do you have"` produces output that:
|
|
73
|
+
- Lists or references ForLoop plugin tools (forloopSprintList, forloopStoryCreate, forloopAgentQuery, etc.)
|
|
74
|
+
- References skills loaded from the plugin (sprint-planning, task-tracking, file-management, forloop-skill)
|
|
75
|
+
|
|
76
|
+
## No Token Behavior
|
|
77
|
+
|
|
78
|
+
Since no API token is configured, the planner will note this and offer to help the user set one. This is expected and acceptable — the test verifies installation correctness and agent self-awareness, not actual API calls.
|
|
79
|
+
|
|
80
|
+
## Test Script Output
|
|
81
|
+
|
|
82
|
+
The test script prints pass/fail for each stage:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
=== Stage A: Plugin Registration ===
|
|
86
|
+
PASS: Plugin registered in opencode.json
|
|
87
|
+
|
|
88
|
+
=== Stage B: Agents ===
|
|
89
|
+
PASS: 3 agents installed (forLoopPlanner, forLoopPlannerCLI, forLoopStoryEvaluator)
|
|
90
|
+
|
|
91
|
+
=== Stage C: Skills ===
|
|
92
|
+
PASS: 16 skills installed
|
|
93
|
+
|
|
94
|
+
=== Stage D: Planner Self-Awareness ===
|
|
95
|
+
PASS: Planner identifies itself and workflow
|
|
96
|
+
|
|
97
|
+
=== Stage E: Tool Discovery ===
|
|
98
|
+
PASS: Tools and skills discovered
|
|
99
|
+
|
|
100
|
+
=== ALL TESTS PASSED ===
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
On failure, prints the actual vs expected and exits with code 1.
|
|
104
|
+
|
|
105
|
+
## Build and Run
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
docker build -t forloop-plugin-test -f test/Dockerfile .
|
|
109
|
+
docker run --rm forloop-plugin-test
|
|
110
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@forloop-cc/forloop-opencode-plugin-planner",
|
|
4
|
-
"version": "2.2.
|
|
4
|
+
"version": "2.2.5",
|
|
5
5
|
"description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MIME_TYPES: Record<string, string> = {
|
|
2
|
+
'.png': 'image/png',
|
|
3
|
+
'.jpg': 'image/jpeg',
|
|
4
|
+
'.jpeg': 'image/jpeg',
|
|
5
|
+
'.gif': 'image/gif',
|
|
6
|
+
'.pdf': 'application/pdf',
|
|
7
|
+
'.txt': 'text/plain',
|
|
8
|
+
'.md': 'text/markdown',
|
|
9
|
+
'.json': 'application/json',
|
|
10
|
+
'.csv': 'text/csv',
|
|
11
|
+
'.mp4': 'video/mp4',
|
|
12
|
+
'.mp3': 'audio/mpeg',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function contentTypeFromExtension(ext: string): string {
|
|
16
|
+
return MIME_TYPES[String(ext || '').toLowerCase()] || 'application/octet-stream';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatFileSize(bytes: number): string {
|
|
20
|
+
if (bytes === 0) return '0 Bytes';
|
|
21
|
+
const k = 1024;
|
|
22
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
23
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
24
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
25
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tool } from '@opencode-ai/plugin';
|
|
2
2
|
import { ForLoopAPIClient } from '../capabilities/api-client';
|
|
3
3
|
import { validateToken } from '../capabilities/auth';
|
|
4
|
+
import { contentTypeFromExtension, formatFileSize } from '../capabilities/mime-types';
|
|
4
5
|
import * as fs from 'fs';
|
|
5
6
|
import * as path from 'path';
|
|
6
7
|
|
|
@@ -41,21 +42,7 @@ export function createFileUploadTool(client: ForLoopAPIClient) {
|
|
|
41
42
|
const ext = path.extname(fileName);
|
|
42
43
|
const fileSize = fileContent.length;
|
|
43
44
|
|
|
44
|
-
|
|
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';
|
|
45
|
+
const contentType = contentTypeFromExtension(ext);
|
|
59
46
|
|
|
60
47
|
// Get presigned URL
|
|
61
48
|
const presign = await client.createPresignedUpload({
|
|
@@ -91,19 +78,11 @@ export function createFileUploadTool(client: ForLoopAPIClient) {
|
|
|
91
78
|
storyId: args.storyId,
|
|
92
79
|
});
|
|
93
80
|
|
|
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
81
|
return [
|
|
103
82
|
`✅ File uploaded successfully!`,
|
|
104
83
|
'',
|
|
105
84
|
`**File**: ${fileName}`,
|
|
106
|
-
`**Size**: ${
|
|
85
|
+
`**Size**: ${formatFileSize(fileSize)}`,
|
|
107
86
|
`**URL**: ${complete.url}`,
|
|
108
87
|
args.description ? `**Description**: ${args.description}` : null,
|
|
109
88
|
args.folder ? `**Folder**: ${args.folder}` : null,
|
|
@@ -137,18 +116,10 @@ export function createFileListTool(client: ForLoopAPIClient) {
|
|
|
137
116
|
return '🗂️ No files found in this sprint.';
|
|
138
117
|
}
|
|
139
118
|
|
|
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
119
|
const lines: string[] = [`🗂️ Files in Sprint #${args.sprintId}:`, ''];
|
|
149
|
-
|
|
120
|
+
|
|
150
121
|
for (const file of files) {
|
|
151
|
-
const size =
|
|
122
|
+
const size = formatFileSize(file.size);
|
|
152
123
|
const uploadedBy = file.uploader?.name || file.uploaderId ? `by ${file.uploader?.name || `User #${file.uploaderId}`}` : '';
|
|
153
124
|
const date = file.createdAt ? new Date(file.createdAt).toLocaleDateString() : '';
|
|
154
125
|
|
|
@@ -52,7 +52,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Build schedule metadata
|
|
55
|
-
const scheduleMetadata = {
|
|
55
|
+
const scheduleMetadata = JSON.stringify({
|
|
56
56
|
title: args.title,
|
|
57
57
|
description: args.description,
|
|
58
58
|
startAt: args.startAt,
|
|
@@ -60,7 +60,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
|
|
|
60
60
|
timezone: args.timezone,
|
|
61
61
|
videoUrl: args.videoUrl,
|
|
62
62
|
location: args.location,
|
|
63
|
-
};
|
|
63
|
+
});
|
|
64
64
|
|
|
65
65
|
// Create schedule story
|
|
66
66
|
const story = await client.createStory({
|
|
@@ -68,7 +68,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
|
|
|
68
68
|
description: args.description,
|
|
69
69
|
sprintId: resolution.sprintId,
|
|
70
70
|
type: 'schedule',
|
|
71
|
-
metadata:
|
|
71
|
+
metadata: scheduleMetadata,
|
|
72
72
|
status: 'todo',
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -148,14 +148,14 @@ export function createScheduleUpdateTool(client: ForLoopAPIClient) {
|
|
|
148
148
|
const existingMetadata = story.metadata ? JSON.parse(story.metadata) : {};
|
|
149
149
|
|
|
150
150
|
// Update with new values
|
|
151
|
-
const updatedMetadata = {
|
|
151
|
+
const updatedMetadata = JSON.stringify({
|
|
152
152
|
...existingMetadata,
|
|
153
153
|
title: args.title !== undefined ? args.title : existingMetadata.title,
|
|
154
154
|
description: args.description !== undefined ? args.description : existingMetadata.description,
|
|
155
155
|
startAt: args.startAt !== undefined ? args.startAt : existingMetadata.startAt,
|
|
156
156
|
endAt: args.endAt !== undefined ? args.endAt : existingMetadata.endAt,
|
|
157
157
|
videoUrl: args.videoUrl !== undefined ? args.videoUrl : existingMetadata.videoUrl,
|
|
158
|
-
};
|
|
158
|
+
});
|
|
159
159
|
|
|
160
160
|
// Update story
|
|
161
161
|
await client.updateStory(args.storyId, {
|
|
@@ -96,7 +96,10 @@ export function createSprintListTool(client: ForLoopAPIClient) {
|
|
|
96
96
|
if (args.organizationId) params.organizationId = String(args.organizationId);
|
|
97
97
|
|
|
98
98
|
try {
|
|
99
|
-
const sprints = await client.listSprints(
|
|
99
|
+
const sprints = await client.listSprints({
|
|
100
|
+
organizationId: args.organizationId,
|
|
101
|
+
includeSystemOrg: args.includeSystemOrg ?? true,
|
|
102
|
+
});
|
|
100
103
|
|
|
101
104
|
if (!sprints.length) {
|
|
102
105
|
return 'No sprints found.';
|
|
@@ -2,6 +2,7 @@ import { tool } from '@opencode-ai/plugin';
|
|
|
2
2
|
import { ForLoopAPIClient } from '../capabilities/api-client';
|
|
3
3
|
import { validateToken } from '../capabilities/auth';
|
|
4
4
|
import { resolveSprintId, getForloopRoot as getForloopRootPath } from '../capabilities/context-resolver';
|
|
5
|
+
import { contentTypeFromExtension } from '../capabilities/mime-types';
|
|
5
6
|
import * as fs from 'fs';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
|
|
@@ -363,23 +364,6 @@ export function createLocalToS3SyncTool(client: ForLoopAPIClient) {
|
|
|
363
364
|
});
|
|
364
365
|
}
|
|
365
366
|
|
|
366
|
-
function contentTypeFromExtension(ext: string): string {
|
|
367
|
-
const map: Record<string, string> = {
|
|
368
|
-
'.png': 'image/png',
|
|
369
|
-
'.jpg': 'image/jpeg',
|
|
370
|
-
'.jpeg': 'image/jpeg',
|
|
371
|
-
'.gif': 'image/gif',
|
|
372
|
-
'.pdf': 'application/pdf',
|
|
373
|
-
'.txt': 'text/plain',
|
|
374
|
-
'.md': 'text/markdown',
|
|
375
|
-
'.json': 'application/json',
|
|
376
|
-
'.csv': 'text/csv',
|
|
377
|
-
'.mp4': 'video/mp4',
|
|
378
|
-
'.mp3': 'audio/mpeg',
|
|
379
|
-
};
|
|
380
|
-
return map[String(ext || '').toLowerCase()] || 'application/octet-stream';
|
|
381
|
-
}
|
|
382
|
-
|
|
383
367
|
function resolveForloopRoot(contextDir?: string, sprintId?: number): string {
|
|
384
368
|
if (sprintId !== undefined) {
|
|
385
369
|
return getForloopRootPath(sprintId);
|
|
@@ -22,8 +22,14 @@ export function createTemplateListTool(client: ForLoopAPIClient) {
|
|
|
22
22
|
for (const t of templates) {
|
|
23
23
|
if (t.deletedAt) continue; // Skip deleted templates
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const fieldNames = (() => {
|
|
26
|
+
try {
|
|
27
|
+
const fields = JSON.parse(t.fields);
|
|
28
|
+
return fields.map((f: any) => f.label).join(', ');
|
|
29
|
+
} catch {
|
|
30
|
+
return 'N/A';
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
27
33
|
|
|
28
34
|
lines.push(`📝 **${t.name}** (\`${t.slug}\`)`);
|
|
29
35
|
lines.push(` ${t.description}`);
|
package/update.sh
CHANGED
|
@@ -129,7 +129,6 @@ echo ""
|
|
|
129
129
|
|
|
130
130
|
check_jq || exit 1
|
|
131
131
|
|
|
132
|
-
local config_file
|
|
133
132
|
config_file="$(find_config)" || {
|
|
134
133
|
print_error "No opencode.json found"
|
|
135
134
|
echo ""
|
|
@@ -141,7 +140,6 @@ config_file="$(find_config)" || {
|
|
|
141
140
|
exit 1
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
local plugin_entry
|
|
145
143
|
plugin_entry="$(find_plugin_entry "$config_file")"
|
|
146
144
|
|
|
147
145
|
if [ -z "$plugin_entry" ]; then
|