@aion0/forge 0.8.4 → 0.8.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/RELEASE_NOTES.md
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
# Forge v0.8.
|
|
1
|
+
# Forge v0.8.5
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-20
|
|
4
4
|
|
|
5
|
-
## Changes since v0.8.
|
|
5
|
+
## Changes since v0.8.4
|
|
6
6
|
|
|
7
|
-
### Other
|
|
8
|
-
- fix(http-protocol): apply manifest parameter defaults before template expansion
|
|
9
|
-
- fix(http-protocol): drop unsubstituted {args.*} query params
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.3...v0.8.4
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.4...v0.8.5
|
|
@@ -30,6 +30,19 @@ import { homedir } from 'node:os';
|
|
|
30
30
|
import { getDb } from '@/src/core/db/database';
|
|
31
31
|
import { getDbPath } from '@/src/config';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Refuse to clobber an existing skill (registry-synced or previously
|
|
35
|
+
* uploaded). The wrapper-dir / frontmatter inside a zip can easily
|
|
36
|
+
* collide with an existing name without the user noticing — better to
|
|
37
|
+
* fail loudly than silently overwrite. To re-install, the user must
|
|
38
|
+
* uninstall the existing one first.
|
|
39
|
+
*/
|
|
40
|
+
function nameConflict(name: string): { conflict: true; source: 'registry' | 'local' } | { conflict: false } {
|
|
41
|
+
const row = db().prepare('SELECT source FROM skills WHERE name = ?').get(name) as any;
|
|
42
|
+
if (!row) return { conflict: false };
|
|
43
|
+
return { conflict: true, source: row.source === 'local' ? 'local' : 'registry' };
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
34
47
|
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file
|
|
35
48
|
const MAX_FILES = 50;
|
|
@@ -153,11 +166,30 @@ function writeSkillToDisk(payload: InstallPayload, projectPath?: string): { targ
|
|
|
153
166
|
ensureDir(dirname(target));
|
|
154
167
|
writeFileSync(target, f.data, { mode: 0o600 });
|
|
155
168
|
}
|
|
169
|
+
// Synthesize info.json if the upload didn't include one. The sync /
|
|
170
|
+
// refresh layer reads version + description from here; without it,
|
|
171
|
+
// a hand-uploaded skill shows up with blank metadata. `source: "local"`
|
|
172
|
+
// (plus uploaded_at) marks it as a manual upload — distinguishable
|
|
173
|
+
// from anything pulled from the remote registry.
|
|
174
|
+
const hasInfoJson = payload.files.some((f) => f.path === 'info.json');
|
|
175
|
+
if (!hasInfoJson) {
|
|
176
|
+
const info = {
|
|
177
|
+
name: payload.name,
|
|
178
|
+
version: payload.version || '0.1.0',
|
|
179
|
+
description: payload.description || '',
|
|
180
|
+
tags: [],
|
|
181
|
+
score: 0,
|
|
182
|
+
rating: 0,
|
|
183
|
+
source: 'local',
|
|
184
|
+
uploaded_at: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
writeFileSync(join(root, 'info.json'), JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
187
|
+
}
|
|
156
188
|
return { targetDir: root };
|
|
157
189
|
}
|
|
158
190
|
|
|
159
191
|
function upsertSkillRow(payload: InstallPayload, projectPath?: string): void {
|
|
160
|
-
const existing = db().prepare('SELECT installed_global, installed_projects FROM skills WHERE name = ?')
|
|
192
|
+
const existing = db().prepare('SELECT installed_global, installed_projects, source FROM skills WHERE name = ?')
|
|
161
193
|
.get(payload.name) as any;
|
|
162
194
|
const installedProjects: string[] = existing
|
|
163
195
|
? (() => { try { return JSON.parse(existing.installed_projects || '[]'); } catch { return []; } })()
|
|
@@ -208,6 +240,13 @@ export async function POST(req: Request) {
|
|
|
208
240
|
nameRaw || undefined,
|
|
209
241
|
);
|
|
210
242
|
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
243
|
+
{
|
|
244
|
+
const c = nameConflict(built.payload.name);
|
|
245
|
+
if (c.conflict) {
|
|
246
|
+
const which = c.source === 'registry' ? 'synced from the marketplace registry' : 'already uploaded';
|
|
247
|
+
return NextResponse.json({ ok: false, error: `A skill named "${built.payload.name}" is ${which}. Uninstall it first, or rename your upload (or the SKILL.md frontmatter \`name:\`) before re-uploading.` }, { status: 409 });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
211
250
|
writeSkillToDisk(built.payload, body.project_path || undefined);
|
|
212
251
|
upsertSkillRow(built.payload, body.project_path || undefined);
|
|
213
252
|
return NextResponse.json({ ok: true, name: built.payload.name, version: built.payload.version || '0.1.0' });
|
|
@@ -219,6 +258,13 @@ export async function POST(req: Request) {
|
|
|
219
258
|
nameRaw || undefined,
|
|
220
259
|
);
|
|
221
260
|
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
261
|
+
{
|
|
262
|
+
const c = nameConflict(built.payload.name);
|
|
263
|
+
if (c.conflict) {
|
|
264
|
+
const which = c.source === 'registry' ? 'synced from the marketplace registry' : 'already uploaded';
|
|
265
|
+
return NextResponse.json({ ok: false, error: `A skill named "${built.payload.name}" is ${which}. Uninstall it first, or rename your upload (or the SKILL.md frontmatter \`name:\`) before re-uploading.` }, { status: 409 });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
222
268
|
writeSkillToDisk(built.payload, body.project_path || undefined);
|
|
223
269
|
upsertSkillRow(built.payload, body.project_path || undefined);
|
|
224
270
|
return NextResponse.json({ ok: true, name: built.payload.name, version: built.payload.version || '0.1.0' });
|
|
@@ -267,6 +313,13 @@ export async function POST(req: Request) {
|
|
|
267
313
|
|
|
268
314
|
const built = buildPayload(files, nameOverride);
|
|
269
315
|
if (!built.ok) return NextResponse.json({ ok: false, error: built.error }, { status: 400 });
|
|
316
|
+
{
|
|
317
|
+
const c = nameConflict(built.payload.name);
|
|
318
|
+
if (c.conflict) {
|
|
319
|
+
const which = c.source === 'registry' ? 'synced from the marketplace registry' : 'already uploaded';
|
|
320
|
+
return NextResponse.json({ ok: false, error: `A skill named "${built.payload.name}" is ${which}. Uninstall it first, or rename your upload (or the SKILL.md frontmatter \`name:\`) before re-uploading.` }, { status: 409 });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
270
323
|
const { targetDir } = writeSkillToDisk(built.payload, projectPath);
|
|
271
324
|
upsertSkillRow(built.payload, projectPath);
|
|
272
325
|
return NextResponse.json({
|
|
@@ -174,8 +174,15 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
174
174
|
setSkills(data.skills || []);
|
|
175
175
|
setProjects(data.projects || []);
|
|
176
176
|
const localData = await localRes.json();
|
|
177
|
-
//
|
|
178
|
-
|
|
177
|
+
// Hide filesystem items that match a *synced* registry row (those
|
|
178
|
+
// belong to the registry list). Uploaded items (source='local')
|
|
179
|
+
// are intentionally not in this set so they appear under "Local"
|
|
180
|
+
// — that's where users expect to find what they just uploaded.
|
|
181
|
+
const registryNames = new Set(
|
|
182
|
+
(data.skills || [])
|
|
183
|
+
.filter((s: any) => s.source !== 'local')
|
|
184
|
+
.map((s: any) => s.name)
|
|
185
|
+
);
|
|
179
186
|
setLocalItems((localData.items || []).filter((i: any) => !registryNames.has(i.name)));
|
|
180
187
|
} catch {}
|
|
181
188
|
setLoading(false);
|
|
@@ -247,6 +254,17 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
247
254
|
try {
|
|
248
255
|
const fd = new FormData();
|
|
249
256
|
fd.append('file', file);
|
|
257
|
+
// Use the upload filename as the default install name. Without
|
|
258
|
+
// this, the server falls back to frontmatter / wrapper-dir name,
|
|
259
|
+
// which can silently clobber an unrelated skill (e.g. uploading
|
|
260
|
+
// prelude-nac.zip whose wrapper dir is `prelude/` would overwrite
|
|
261
|
+
// the registry-synced `prelude`).
|
|
262
|
+
const baseName = file.name
|
|
263
|
+
.replace(/\.(md|zip)$/i, '')
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
266
|
+
.replace(/^-+|-+$/g, '');
|
|
267
|
+
if (/^[a-z0-9][a-z0-9_-]*$/.test(baseName)) fd.append('name', baseName);
|
|
250
268
|
const r = await fetch('/api/skills/install-local', { method: 'POST', body: fd });
|
|
251
269
|
const j = await r.json();
|
|
252
270
|
if (!r.ok || j.ok === false) {
|
|
@@ -254,6 +272,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
254
272
|
return;
|
|
255
273
|
}
|
|
256
274
|
await fetchSkills();
|
|
275
|
+
alert(`Uploaded as "${j.name}" → ${j.target}`);
|
|
257
276
|
} catch (e) {
|
|
258
277
|
alert(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
259
278
|
} finally { setUploading(false); }
|
|
@@ -352,6 +371,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
352
371
|
// Filter by project, type, and search
|
|
353
372
|
const q = searchQuery.toLowerCase();
|
|
354
373
|
const filtered = (typeFilter === 'local' ? [] : skills
|
|
374
|
+
.filter(s => s.source !== 'local') // local-uploaded skills render under the Local section instead
|
|
355
375
|
.filter(s => projectFilter ? (s.installedGlobal || s.installedProjects.includes(projectFilter)) : true)
|
|
356
376
|
.filter(s => typeFilter === 'all' ? true : s.type === typeFilter)
|
|
357
377
|
.filter(s => !q || s.name.toLowerCase().includes(q) || s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
|
@@ -426,7 +446,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
426
446
|
type="button"
|
|
427
447
|
onClick={() => skillUploadRef.current?.click()}
|
|
428
448
|
disabled={uploading}
|
|
429
|
-
title="Upload
|
|
449
|
+
title="Upload your own skill — SKILL.md or a .zip bundle. Stored locally, not synced from the marketplace."
|
|
430
450
|
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
431
451
|
>
|
|
432
452
|
{uploading ? 'Uploading…' : '+ Upload'}
|
|
@@ -501,8 +521,10 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
501
521
|
</div>
|
|
502
522
|
);
|
|
503
523
|
})}
|
|
504
|
-
{/* Local items — collapsible by scope group
|
|
505
|
-
|
|
524
|
+
{/* Local items — collapsible by scope group. Visible on
|
|
525
|
+
All/Local AND on Skills/Commands so uploaded items show
|
|
526
|
+
up under their own type tab too. */}
|
|
527
|
+
{(typeFilter === 'all' || typeFilter === 'local' || typeFilter === 'skill' || typeFilter === 'command') && filteredLocal.length > 0 && (
|
|
506
528
|
<>
|
|
507
529
|
{/* Local section header — collapsible */}
|
|
508
530
|
{typeFilter !== 'local' && (
|