@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.4
1
+ # Forge v0.8.5
2
2
 
3
3
  Released: 2026-05-20
4
4
 
5
- ## Changes since v0.8.3
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
- // Filter out items already in registry
178
- const registryNames = new Set((data.skills || []).map((s: any) => s.name));
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 a local skill — SKILL.md or a .zip bundle. Falls outside the forge-skills registry."
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
- {(typeFilter === 'all' || typeFilter === 'local') && filteredLocal.length > 0 && (
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' && (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {