@aion0/forge 0.8.1 → 0.8.3
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/RELEASE_NOTES.md +6 -6
- package/app/api/connectors/[id]/settings/route.ts +31 -37
- package/app/api/connectors/[id]/test/route.ts +260 -0
- package/app/api/connectors/install-local/route.ts +211 -0
- package/app/api/connectors/marketplace/route.ts +79 -0
- package/app/api/connectors/route.ts +41 -46
- package/app/api/jobs/route.ts +4 -0
- package/app/api/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- package/components/SettingsModal.tsx +1 -0
- package/components/SkillsPanel.tsx +42 -1
- package/lib/agents/claude-adapter.ts +4 -0
- package/lib/agents/types.ts +6 -0
- package/lib/chat/agent-loop.ts +13 -22
- package/lib/chat/protocols/http.ts +1 -1
- package/lib/chat/protocols/shell.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +20 -20
- package/lib/connectors/migration.ts +110 -0
- package/lib/connectors/registry.ts +328 -0
- package/lib/connectors/sync.ts +305 -0
- package/lib/connectors/types.ts +253 -0
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/17-connectors.md +241 -189
- package/lib/help-docs/21-build-connector.md +314 -0
- package/lib/help-docs/CLAUDE.md +4 -2
- package/lib/init.ts +25 -0
- package/lib/jobs/dispatcher.ts +28 -8
- package/lib/jobs/scheduler.ts +66 -6
- package/lib/jobs/store.ts +51 -2
- package/lib/jobs/types.ts +32 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +137 -15
- package/lib/plugins/registry.ts +9 -42
- package/lib/plugins/types.ts +4 -129
- package/lib/settings.ts +7 -0
- package/lib/skills.ts +27 -1
- package/lib/task-manager.ts +62 -2
- package/package.json +4 -1
- package/src/core/db/database.ts +4 -0
- package/lib/builtin-plugins/github-api.yaml +0 -93
- package/lib/builtin-plugins/gitlab.yaml +0 -860
- package/lib/builtin-plugins/mantis.probe.js +0 -176
- package/lib/builtin-plugins/mantis.yaml +0 -964
- package/lib/builtin-plugins/pmdb.yaml +0 -178
- package/lib/builtin-plugins/teams.yaml +0 -913
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Connectors API — discovery surface for the Forge browser extension.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Reads from the standalone connector registry (lib/connectors/), no
|
|
5
|
+
* longer from the plugin registry. The extension hits this endpoint to
|
|
6
|
+
* learn what connectors are installed and what tools each exposes;
|
|
7
|
+
* `page` / `host_match` / `login_redirect` are expanded server-side
|
|
8
|
+
* with the user's saved settings, {args.*} stays literal for the
|
|
9
|
+
* extension runner.
|
|
10
|
+
*
|
|
11
|
+
* Response shape is preserved 1:1 from the old plugin-backed
|
|
12
|
+
* implementation so the extension and Forge chat keep working without
|
|
13
|
+
* any client change.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { NextResponse } from 'next/server';
|
|
14
17
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from '@/lib/plugins/registry';
|
|
18
|
+
listInstalledConnectors,
|
|
19
|
+
getInstalledConnector,
|
|
20
|
+
getConnectorEntries,
|
|
21
|
+
} from '@/lib/connectors/registry';
|
|
20
22
|
import { expandSettingsTokens } from '@/lib/plugins/templates';
|
|
21
|
-
import type {
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
import type {
|
|
24
|
+
ConnectorDefinition,
|
|
25
|
+
ConnectorEntry,
|
|
26
|
+
ConnectorTool,
|
|
27
|
+
} from '@/lib/connectors/types';
|
|
24
28
|
|
|
25
29
|
function expandTool(tool: ConnectorTool, settings: Record<string, any> | undefined): ConnectorTool {
|
|
26
30
|
if (!tool.page && !tool.script) return tool;
|
|
@@ -35,8 +39,8 @@ function expandTool(tool: ConnectorTool, settings: Record<string, any> | undefin
|
|
|
35
39
|
return { ...tool, ...(page ? { page } : {}) };
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
function
|
|
39
|
-
const out:
|
|
42
|
+
function expandEntry(entry: ConnectorEntry, settings: Record<string, any> | undefined): ConnectorEntry {
|
|
43
|
+
const out: ConnectorEntry = {
|
|
40
44
|
...entry,
|
|
41
45
|
tools: Object.fromEntries(
|
|
42
46
|
Object.entries(entry.tools).map(([name, t]) => [name, expandTool(t, settings)]),
|
|
@@ -47,36 +51,33 @@ function expandConnector(entry: Connector, settings: Record<string, any> | undef
|
|
|
47
51
|
return out;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
function toConnectorPayload(def:
|
|
51
|
-
const
|
|
52
|
-
const settings = inst?.config;
|
|
53
|
-
const entries = getConnectorsForPlugin(def).map(e => expandConnector(e, settings));
|
|
54
|
+
function toConnectorPayload(def: ConnectorDefinition, config: Record<string, any> | undefined, installed: boolean) {
|
|
55
|
+
const entries = getConnectorEntries(def).map(e => expandEntry(e, config));
|
|
54
56
|
|
|
55
|
-
// Hoist host_match / login_redirect to plugin level for 1:1 cases — the
|
|
56
|
-
// common case (and what the spec example shows). Pull from the first entry
|
|
57
|
-
// when those came from the top-level plugin fields.
|
|
58
57
|
const hostMatch = def.host_match
|
|
59
|
-
? expandSettingsTokens(def.host_match,
|
|
58
|
+
? expandSettingsTokens(def.host_match, config)
|
|
60
59
|
: entries[0]?.host_match;
|
|
61
60
|
const loginRedirect = def.login_redirect
|
|
62
|
-
? expandSettingsTokens(def.login_redirect,
|
|
61
|
+
? expandSettingsTokens(def.login_redirect, config)
|
|
63
62
|
: entries[0]?.login_redirect;
|
|
64
|
-
// Default to 'main' so existing connectors (Mantis, GitLab) keep their
|
|
65
|
-
// current execution path. Plugins on strict-CSP sites opt into 'isolated'.
|
|
66
63
|
const runner = def.runner || entries[0]?.runner || 'main';
|
|
67
64
|
|
|
68
65
|
return {
|
|
69
|
-
plugin_id: def.id,
|
|
66
|
+
plugin_id: def.id, // legacy field name preserved
|
|
70
67
|
name: def.name,
|
|
71
68
|
icon: def.icon,
|
|
72
69
|
version: def.version,
|
|
73
70
|
author: def.author || 'forge',
|
|
74
71
|
description: def.description || '',
|
|
75
|
-
mode:
|
|
76
|
-
installed
|
|
72
|
+
mode: 'browser-side', // every connector is browser-facing
|
|
73
|
+
installed,
|
|
77
74
|
...(hostMatch ? { host_match: hostMatch } : {}),
|
|
78
75
|
...(loginRedirect ? { login_redirect: loginRedirect } : {}),
|
|
79
76
|
runner,
|
|
77
|
+
/** Hint for the marketplace UI: a Test button is wired for this connector. */
|
|
78
|
+
has_test: !!def.test,
|
|
79
|
+
/** Optional human description of what the test does (no request shape exposed). */
|
|
80
|
+
test_description: def.test?.description,
|
|
80
81
|
entries,
|
|
81
82
|
};
|
|
82
83
|
}
|
|
@@ -85,24 +86,18 @@ export async function GET(req: Request) {
|
|
|
85
86
|
const url = new URL(req.url);
|
|
86
87
|
const id = url.searchParams.get('id');
|
|
87
88
|
|
|
88
|
-
// Single-connector detail
|
|
89
89
|
if (id) {
|
|
90
|
-
const
|
|
91
|
-
if (!
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
return NextResponse.json({ connector: toConnectorPayload(def) });
|
|
90
|
+
const inst = getInstalledConnector(id);
|
|
91
|
+
if (!inst) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
|
|
92
|
+
return NextResponse.json({ connector: toConnectorPayload(inst.definition, inst.config, true) });
|
|
95
93
|
}
|
|
96
94
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.map(
|
|
103
|
-
.filter((d): d is PluginDefinition => !!d)
|
|
104
|
-
.filter(d => !onlyInstalled || !!getInstalledPlugin(d.id))
|
|
105
|
-
.map(toConnectorPayload);
|
|
95
|
+
// The extension always wants the installed list — there is no "not
|
|
96
|
+
// installed but visible" path on this endpoint anymore (that lives
|
|
97
|
+
// in /api/connectors/marketplace, coming in P1.6).
|
|
98
|
+
const connectors = listInstalledConnectors()
|
|
99
|
+
.filter(c => c.enabled)
|
|
100
|
+
.map(c => toConnectorPayload(c.definition, c.config, true));
|
|
106
101
|
|
|
107
102
|
return NextResponse.json({ connectors });
|
|
108
103
|
}
|
package/app/api/jobs/route.ts
CHANGED
|
@@ -30,6 +30,10 @@ export async function POST(req: Request) {
|
|
|
30
30
|
dedup_field: String(body.dedup_field),
|
|
31
31
|
dispatch_type: body.dispatch_type,
|
|
32
32
|
dispatch_params: body.dispatch_params,
|
|
33
|
+
skills: Array.isArray(body.skills) ? body.skills.map((s: unknown) => String(s)) : [],
|
|
34
|
+
schedule_kind: ['once', 'cron', 'manual'].includes(body.schedule_kind) ? body.schedule_kind : 'period',
|
|
35
|
+
schedule_at: body.schedule_at ? String(body.schedule_at) : null,
|
|
36
|
+
schedule_cron: body.schedule_cron ? String(body.schedule_cron) : null,
|
|
33
37
|
mark_existing_as_seen: body.mark_existing_as_seen !== false,
|
|
34
38
|
});
|
|
35
39
|
return NextResponse.json({ job });
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/skills/install-local
|
|
3
|
+
*
|
|
4
|
+
* Install a Forge skill from a user-supplied bundle. Two flavours:
|
|
5
|
+
*
|
|
6
|
+
* multipart/form-data:
|
|
7
|
+
* file: .md → single-file skill, written as SKILL.md
|
|
8
|
+
* file: .zip → bundle. Must contain SKILL.md (either at the
|
|
9
|
+
* root or inside a single wrapping subdir).
|
|
10
|
+
* Any sibling files (scripts/, references/,
|
|
11
|
+
* assets/) are copied verbatim.
|
|
12
|
+
* name → optional, overrides the frontmatter / wrapper-dir
|
|
13
|
+
* project_path → optional, install into the project's .claude/skills/
|
|
14
|
+
* instead of the global ~/.claude/skills/
|
|
15
|
+
*
|
|
16
|
+
* application/json:
|
|
17
|
+
* { name, content } → quick single-file path for the
|
|
18
|
+
* { name, files: { path: content, ... } } AI / scripts
|
|
19
|
+
*
|
|
20
|
+
* The DB row is upserted with `source: 'local'` so the sync layer
|
|
21
|
+
* won't try to redownload it from the remote registry (and won't mark
|
|
22
|
+
* it deleted when it's not in registry.json).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { NextResponse } from 'next/server';
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
27
|
+
import { join, dirname, normalize, sep } from 'node:path';
|
|
28
|
+
import AdmZip from 'adm-zip';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { getDb } from '@/src/core/db/database';
|
|
31
|
+
import { getDbPath } from '@/src/config';
|
|
32
|
+
|
|
33
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
34
|
+
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file
|
|
35
|
+
const MAX_FILES = 50;
|
|
36
|
+
|
|
37
|
+
function db() { return getDb(getDbPath()); }
|
|
38
|
+
|
|
39
|
+
// ─── Helpers ──────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function getClaudeHome(): string {
|
|
42
|
+
return process.env.CLAUDE_HOME || join(homedir(), '.claude');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isValidName(s: string): boolean {
|
|
46
|
+
return /^[a-z0-9][a-z0-9_-]*$/.test(s);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeEntryPath(name: string): string | null {
|
|
50
|
+
if (!name || name.startsWith('/') || name.includes('..')) return null;
|
|
51
|
+
const normalized = normalize(name).replace(/^[/\\]+/, '');
|
|
52
|
+
if (normalized.split(sep).some((seg) => seg === '..' || seg === '')) return null;
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ParsedFrontmatter {
|
|
57
|
+
name?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
version?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract `name:` / `description:` / `version:` from a SKILL.md YAML frontmatter block. */
|
|
63
|
+
function parseSkillMd(md: string): ParsedFrontmatter {
|
|
64
|
+
const fm = md.match(/^---\n([\s\S]*?)\n---/);
|
|
65
|
+
if (!fm) return {};
|
|
66
|
+
const out: ParsedFrontmatter = {};
|
|
67
|
+
for (const line of fm[1].split('\n')) {
|
|
68
|
+
const m = line.match(/^([a-zA-Z_]+):\s*(.*?)\s*$/);
|
|
69
|
+
if (!m) continue;
|
|
70
|
+
const key = m[1].toLowerCase();
|
|
71
|
+
let val = m[2];
|
|
72
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
73
|
+
val = val.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
if (key === 'name') out.name = val;
|
|
76
|
+
else if (key === 'description') out.description = val;
|
|
77
|
+
else if (key === 'version') out.version = val;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureDir(p: string): void {
|
|
83
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface InstallPayload {
|
|
87
|
+
name: string;
|
|
88
|
+
files: Array<{ path: string; data: Buffer }>;
|
|
89
|
+
description?: string;
|
|
90
|
+
version?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pre-validate that the bundle has a SKILL.md and a usable name.
|
|
95
|
+
* Returns either a usable payload or an error.
|
|
96
|
+
*/
|
|
97
|
+
function buildPayload(
|
|
98
|
+
files: Array<{ path: string; data: Buffer }>,
|
|
99
|
+
nameOverride?: string,
|
|
100
|
+
): { ok: true; payload: InstallPayload } | { ok: false; error: string } {
|
|
101
|
+
// Detect wrapper-dir layout: <name>/SKILL.md
|
|
102
|
+
const skillMd = files.find((f) => f.path === 'SKILL.md' || f.path.toLowerCase() === 'skill.md');
|
|
103
|
+
let nestedRoot = '';
|
|
104
|
+
let actualSkill = skillMd;
|
|
105
|
+
if (!skillMd) {
|
|
106
|
+
// Look for <something>/SKILL.md as the only entry of its kind
|
|
107
|
+
const candidates = files.filter((f) => /(^|\/)SKILL\.md$/i.test(f.path));
|
|
108
|
+
if (candidates.length !== 1) {
|
|
109
|
+
return { ok: false, error: 'zip must contain exactly one SKILL.md (at root or inside one wrapper directory)' };
|
|
110
|
+
}
|
|
111
|
+
actualSkill = candidates[0];
|
|
112
|
+
nestedRoot = actualSkill.path.slice(0, -('SKILL.md'.length + 1));
|
|
113
|
+
}
|
|
114
|
+
const md = actualSkill!.data.toString('utf-8');
|
|
115
|
+
const fm = parseSkillMd(md);
|
|
116
|
+
const inferredName =
|
|
117
|
+
nameOverride ||
|
|
118
|
+
fm.name ||
|
|
119
|
+
(nestedRoot && nestedRoot.split('/')[0]) ||
|
|
120
|
+
'';
|
|
121
|
+
if (!inferredName) {
|
|
122
|
+
return { ok: false, error: 'cannot determine skill name — set frontmatter `name:` or wrap files in a <name>/ directory' };
|
|
123
|
+
}
|
|
124
|
+
if (!isValidName(inferredName)) {
|
|
125
|
+
return { ok: false, error: `skill name "${inferredName}" must be lowercase alphanumerics + hyphens/underscores` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Strip the wrapper-dir prefix from all file paths
|
|
129
|
+
const flatFiles = files
|
|
130
|
+
.map((f) => ({
|
|
131
|
+
path: nestedRoot && f.path.startsWith(nestedRoot) ? f.path.slice(nestedRoot.length) : f.path,
|
|
132
|
+
data: f.data,
|
|
133
|
+
}))
|
|
134
|
+
.filter((f) => f.path && f.path !== '');
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
payload: { name: inferredName, files: flatFiles, description: fm.description, version: fm.version },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeSkillToDisk(payload: InstallPayload, projectPath?: string): { targetDir: string } {
|
|
143
|
+
const root = projectPath
|
|
144
|
+
? join(projectPath, '.claude', 'skills', payload.name)
|
|
145
|
+
: join(getClaudeHome(), 'skills', payload.name);
|
|
146
|
+
// Clean any prior install so removed files don't linger.
|
|
147
|
+
if (existsSync(root)) {
|
|
148
|
+
try { rmSync(root, { recursive: true, force: true }); } catch {}
|
|
149
|
+
}
|
|
150
|
+
ensureDir(root);
|
|
151
|
+
for (const f of payload.files) {
|
|
152
|
+
const target = join(root, f.path);
|
|
153
|
+
ensureDir(dirname(target));
|
|
154
|
+
writeFileSync(target, f.data, { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
return { targetDir: root };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function upsertSkillRow(payload: InstallPayload, projectPath?: string): void {
|
|
160
|
+
const existing = db().prepare('SELECT installed_global, installed_projects FROM skills WHERE name = ?')
|
|
161
|
+
.get(payload.name) as any;
|
|
162
|
+
const installedProjects: string[] = existing
|
|
163
|
+
? (() => { try { return JSON.parse(existing.installed_projects || '[]'); } catch { return []; } })()
|
|
164
|
+
: [];
|
|
165
|
+
if (projectPath && !installedProjects.includes(projectPath)) installedProjects.push(projectPath);
|
|
166
|
+
const installedGlobal = projectPath ? (existing?.installed_global ? 1 : 0) : 1;
|
|
167
|
+
const version = payload.version || '0.1.0';
|
|
168
|
+
|
|
169
|
+
db().prepare(`
|
|
170
|
+
INSERT INTO skills (name, type, display_name, description, author, version, tags, score, rating,
|
|
171
|
+
source_url, archive, synced_at, source,
|
|
172
|
+
installed_global, installed_projects, installed_version)
|
|
173
|
+
VALUES (?, 'skill', ?, ?, '', ?, '[]', 0, 0, '', '', datetime('now'), 'local', ?, ?, ?)
|
|
174
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
175
|
+
display_name = excluded.display_name,
|
|
176
|
+
description = excluded.description,
|
|
177
|
+
version = excluded.version,
|
|
178
|
+
synced_at = datetime('now'),
|
|
179
|
+
source = 'local',
|
|
180
|
+
installed_global = excluded.installed_global,
|
|
181
|
+
installed_projects = excluded.installed_projects,
|
|
182
|
+
installed_version = excluded.installed_version
|
|
183
|
+
`).run(
|
|
184
|
+
payload.name,
|
|
185
|
+
payload.name,
|
|
186
|
+
payload.description || '',
|
|
187
|
+
version,
|
|
188
|
+
installedGlobal,
|
|
189
|
+
JSON.stringify(installedProjects),
|
|
190
|
+
version,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── POST ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export async function POST(req: Request) {
|
|
197
|
+
const ct = req.headers.get('content-type') || '';
|
|
198
|
+
|
|
199
|
+
// ── JSON path: { name, content } or { name, files: { path: content } }
|
|
200
|
+
if (ct.includes('application/json')) {
|
|
201
|
+
let body: any = {};
|
|
202
|
+
try { body = await req.json(); } catch {}
|
|
203
|
+
const nameRaw = String(body?.name || '').trim();
|
|
204
|
+
if (body?.content) {
|
|
205
|
+
// single SKILL.md
|
|
206
|
+
const built = buildPayload(
|
|
207
|
+
[{ path: 'SKILL.md', data: Buffer.from(String(body.content), 'utf-8') }],
|
|
208
|
+
nameRaw || undefined,
|
|
209
|
+
);
|
|
210
|
+
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
211
|
+
writeSkillToDisk(built.payload, body.project_path || undefined);
|
|
212
|
+
upsertSkillRow(built.payload, body.project_path || undefined);
|
|
213
|
+
return NextResponse.json({ ok: true, name: built.payload.name, version: built.payload.version || '0.1.0' });
|
|
214
|
+
}
|
|
215
|
+
if (body?.files && typeof body.files === 'object') {
|
|
216
|
+
const entries = Object.entries(body.files as Record<string, string>);
|
|
217
|
+
const built = buildPayload(
|
|
218
|
+
entries.map(([path, content]) => ({ path, data: Buffer.from(String(content), 'utf-8') })),
|
|
219
|
+
nameRaw || undefined,
|
|
220
|
+
);
|
|
221
|
+
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
222
|
+
writeSkillToDisk(built.payload, body.project_path || undefined);
|
|
223
|
+
upsertSkillRow(built.payload, body.project_path || undefined);
|
|
224
|
+
return NextResponse.json({ ok: true, name: built.payload.name, version: built.payload.version || '0.1.0' });
|
|
225
|
+
}
|
|
226
|
+
return NextResponse.json({ ok: false, error: 'JSON body must have { content } or { files }' }, { status: 400 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── multipart: file = .md | .zip
|
|
230
|
+
if (ct.includes('multipart/form-data')) {
|
|
231
|
+
let form: FormData;
|
|
232
|
+
try { form = await req.formData(); }
|
|
233
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid multipart body' }, { status: 400 }); }
|
|
234
|
+
const file = form.get('file');
|
|
235
|
+
if (!(file instanceof File)) {
|
|
236
|
+
return NextResponse.json({ ok: false, error: 'file field missing' }, { status: 400 });
|
|
237
|
+
}
|
|
238
|
+
if (file.size > MAX_BYTES) {
|
|
239
|
+
return NextResponse.json({ ok: false, error: `file too large (max ${MAX_BYTES} bytes)` }, { status: 413 });
|
|
240
|
+
}
|
|
241
|
+
const nameOverride = String(form.get('name') || '').trim() || undefined;
|
|
242
|
+
const projectPath = String(form.get('project_path') || '').trim() || undefined;
|
|
243
|
+
const filename = file.name.toLowerCase();
|
|
244
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
245
|
+
|
|
246
|
+
let files: Array<{ path: string; data: Buffer }> = [];
|
|
247
|
+
|
|
248
|
+
if (filename.endsWith('.md')) {
|
|
249
|
+
files.push({ path: 'SKILL.md', data: buf });
|
|
250
|
+
} else if (filename.endsWith('.zip')) {
|
|
251
|
+
let zip: AdmZip;
|
|
252
|
+
try { zip = new AdmZip(buf); }
|
|
253
|
+
catch (e) { return NextResponse.json({ ok: false, error: `invalid zip: ${(e as Error).message}` }, { status: 400 }); }
|
|
254
|
+
const entries = zip.getEntries().filter((e) => !e.isDirectory);
|
|
255
|
+
if (entries.length === 0) return NextResponse.json({ ok: false, error: 'zip is empty' }, { status: 400 });
|
|
256
|
+
if (entries.length > MAX_FILES) return NextResponse.json({ ok: false, error: `zip has too many files (max ${MAX_FILES})` }, { status: 400 });
|
|
257
|
+
for (const e of entries) {
|
|
258
|
+
const safe = safeEntryPath(e.entryName);
|
|
259
|
+
if (!safe) return NextResponse.json({ ok: false, error: `unsafe path in zip: ${e.entryName}` }, { status: 400 });
|
|
260
|
+
const data = e.getData();
|
|
261
|
+
if (data.length > MAX_FILE_BYTES) return NextResponse.json({ ok: false, error: `entry too large: ${safe}` }, { status: 413 });
|
|
262
|
+
files.push({ path: safe, data });
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
return NextResponse.json({ ok: false, error: 'unsupported file extension (expected .md or .zip)' }, { status: 400 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const built = buildPayload(files, nameOverride);
|
|
269
|
+
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
270
|
+
const { targetDir } = writeSkillToDisk(built.payload, projectPath);
|
|
271
|
+
upsertSkillRow(built.payload, projectPath);
|
|
272
|
+
return NextResponse.json({
|
|
273
|
+
ok: true,
|
|
274
|
+
name: built.payload.name,
|
|
275
|
+
version: built.payload.version || '0.1.0',
|
|
276
|
+
target: targetDir,
|
|
277
|
+
files_written: built.payload.files.length,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return NextResponse.json({ ok: false, error: 'expected JSON or multipart/form-data' }, { status: 400 });
|
|
282
|
+
}
|