@aion0/forge 0.8.4 → 0.8.6

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.6
2
2
 
3
- Released: 2026-05-20
3
+ Released: 2026-05-21
4
4
 
5
- ## Changes since v0.8.3
5
+ ## Changes since v0.8.5
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.5...v0.8.6
@@ -11,7 +11,17 @@ export async function GET(req: Request) {
11
11
  return NextResponse.json(info);
12
12
  }
13
13
 
14
- const agents = listAgents();
14
+ // ?include=cli|api|all (default: cli) — API profiles aren't useful in
15
+ // Terminal / Task / Pipeline pickers (no CLI binary to spawn), so the
16
+ // default hides them. WorkspaceView's API mode passes ?include=all
17
+ // (or ?include=api) to surface them when needed.
18
+ const include = (url.searchParams.get('include') || 'cli').toLowerCase();
19
+ const all = listAgents();
20
+ const agents = include === 'all'
21
+ ? all
22
+ : include === 'api'
23
+ ? all.filter((a: any) => a.backendType === 'api')
24
+ : all.filter((a: any) => a.backendType !== 'api');
15
25
  const defaultAgent = getDefaultAgentId();
16
26
  return NextResponse.json({ agents, defaultAgent });
17
27
  }
@@ -46,6 +46,48 @@ function pickPath(obj: unknown, path: string): unknown {
46
46
  return cur;
47
47
  }
48
48
 
49
+ /**
50
+ * Guess the right items_path purely from the response shape, so the UI
51
+ * can offer it even when the user left the field blank (or set it wrong).
52
+ * - top-level array → '' (scheduler iterates directly)
53
+ * - object with exactly one array-valued key → that key
54
+ * - object with several array-valued keys → prefer common names
55
+ * - top-level single object → '' (scheduler wraps as 1-item)
56
+ */
57
+ function suggestItemsPath(parsed: unknown): string | null {
58
+ if (Array.isArray(parsed)) return '';
59
+ if (parsed && typeof parsed === 'object') {
60
+ const obj = parsed as Record<string, unknown>;
61
+ const arrayKeys = Object.entries(obj).filter(([, v]) => Array.isArray(v)).map(([k]) => k);
62
+ if (arrayKeys.length === 0) return '';
63
+ // Only suggest a non-empty items_path when one of the keys looks
64
+ // like a *list payload* name. Otherwise this is probably a detail
65
+ // object (e.g. mantis.get_bug returns {id, summary, ..., history,
66
+ // notes}) — `history` is an internal array, not the items list.
67
+ // For detail responses, empty items_path → scheduler wraps the
68
+ // single object as a 1-item list.
69
+ const common = ['items', 'data', 'results', 'records', 'bugs', 'issues', 'merge_requests', 'mrs', 'projects', 'pipelines'];
70
+ for (const name of common) if (arrayKeys.includes(name)) return name;
71
+ return '';
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Pick the most-likely unique-identifier key from the sample item.
78
+ * Order matches what users typically expect, with `iid` (GitLab project-scoped
79
+ * issue/MR id) preferred over the global `id` when both exist.
80
+ */
81
+ function suggestDedupField(itemKeys: string[]): string | null {
82
+ // Prefer the globally-unique `id` over GitLab's project-scoped `iid`
83
+ // so dedup remains safe if a job ever spans multiple projects.
84
+ const priority = ['id', 'iid', 'key', 'uuid', 'global_id', 'guid', 'sha', 'commit_id'];
85
+ for (const p of priority) if (itemKeys.includes(p)) return p;
86
+ const trailingId = itemKeys.find((k) => /_id$/i.test(k));
87
+ if (trailingId) return trailingId;
88
+ return null;
89
+ }
90
+
49
91
  /**
50
92
  * Best-effort match a workflow input name to a key on the sample item.
51
93
  * The mantis case (bug_id ← id, base_branch ← nothing) drove this list:
@@ -109,11 +151,10 @@ export async function POST(req: Request) {
109
151
  const callName = `${source_connector}.${source_tool}`;
110
152
  let toolResult;
111
153
  try {
112
- toolResult = await dispatchTool({
113
- id: `jobs-preview-${Date.now()}`,
114
- name: callName,
115
- input: source_input || {},
116
- });
154
+ toolResult = await dispatchTool(
155
+ { id: `jobs-preview-${Date.now()}`, name: callName, input: source_input || {} },
156
+ { noTruncation: true },
157
+ );
117
158
  } catch (e) {
118
159
  return NextResponse.json({ ok: false, error: `connector call threw: ${(e as Error).message}` }, { status: 500 });
119
160
  }
@@ -136,6 +177,11 @@ export async function POST(req: Request) {
136
177
  }, { status: 200 });
137
178
  }
138
179
 
180
+ // Always compute the shape-based items_path suggestion — UI offers it
181
+ // both on success ("you may also want this") and on failure ("apply
182
+ // this and retry"), so the user never has to guess.
183
+ const suggestedItemsPath = suggestItemsPath(parsed);
184
+
139
185
  let items = pickPath(parsed, items_path || '');
140
186
  // Same single-object-as-1-item-list logic as the scheduler, so the
141
187
  // preview matches what runtime will see.
@@ -152,6 +198,7 @@ export async function POST(req: Request) {
152
198
  ? `items_path resolved to an empty array — no sample item available`
153
199
  : `items_path "${items_path || '(empty)'}" did not resolve to an array or object`,
154
200
  top_level_keys: topKeys,
201
+ suggested_items_path: suggestedItemsPath,
155
202
  }, { status: 200 });
156
203
  }
157
204
 
@@ -188,6 +235,8 @@ export async function POST(req: Request) {
188
235
  sample_item: sampleItem,
189
236
  item_keys: itemKeys,
190
237
  suggested_template: suggestedTemplate,
238
+ suggested_items_path: suggestedItemsPath,
239
+ suggested_dedup_field: suggestDedupField(itemKeys),
191
240
  total_items: items.length,
192
241
  });
193
242
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * /api/jobs/recipes
3
+ *
4
+ * GET — list installed recipes
5
+ * GET ?name=<name> — fetch a single recipe (UI form metadata)
6
+ * POST { action: 'instantiate', name, params } — create Job from recipe
7
+ * POST { action: 'upload', yaml } — save a user-supplied recipe yaml
8
+ * POST { action: 'delete', name } — delete a recipe file
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import {
13
+ listRecipes,
14
+ getRecipe,
15
+ saveRecipeYaml,
16
+ deleteRecipe,
17
+ instantiateRecipe,
18
+ } from '@/lib/jobs/recipes';
19
+
20
+ export async function GET(req: Request) {
21
+ const { searchParams } = new URL(req.url);
22
+ const name = searchParams.get('name');
23
+ if (name) {
24
+ const r = getRecipe(name);
25
+ if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
26
+ return NextResponse.json({ recipe: r });
27
+ }
28
+ return NextResponse.json({ recipes: listRecipes() });
29
+ }
30
+
31
+ export async function POST(req: Request) {
32
+ let body: any;
33
+ try { body = await req.json(); }
34
+ catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
35
+ const action = body?.action;
36
+
37
+ if (action === 'instantiate') {
38
+ if (!body.name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
39
+ const r = instantiateRecipe(body.name, body.params || {});
40
+ if (!r.ok) return NextResponse.json(r, { status: 400 });
41
+ return NextResponse.json(r);
42
+ }
43
+
44
+ if (action === 'upload') {
45
+ if (typeof body.yaml !== 'string' || !body.yaml.trim()) {
46
+ return NextResponse.json({ ok: false, error: 'yaml required' }, { status: 400 });
47
+ }
48
+ const r = saveRecipeYaml(body.yaml);
49
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
50
+ }
51
+
52
+ if (action === 'delete') {
53
+ if (!body.name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
54
+ const ok = deleteRecipe(body.name);
55
+ return NextResponse.json({ ok }, { status: ok ? 200 : 404 });
56
+ }
57
+
58
+ return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
59
+ }
@@ -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({
@@ -0,0 +1,52 @@
1
+ /**
2
+ * /api/workflows/marketplace
3
+ *
4
+ * GET — list registry + installed recipes/pipelines (merged)
5
+ * POST { action: 'sync' } — pull registry.json from forge-workflow
6
+ * POST { action: 'install', kind, name } — download yaml → save to local dir
7
+ * POST { action: 'uninstall', kind, name } — remove the local yaml file
8
+ *
9
+ * `kind` is "recipe" | "pipeline". Built-in pipelines are protected
10
+ * elsewhere (lib/workflow-marketplace.ts excludes them from `installed`,
11
+ * and the pipelines delete-workflow route rejects them).
12
+ */
13
+
14
+ import { NextResponse } from 'next/server';
15
+ import {
16
+ syncMarketplace,
17
+ listMarketplace,
18
+ installFromMarketplace,
19
+ uninstallFromMarketplace,
20
+ type WorkflowKind,
21
+ } from '@/lib/workflow-marketplace';
22
+
23
+ export async function GET() {
24
+ return NextResponse.json(listMarketplace());
25
+ }
26
+
27
+ export async function POST(req: Request) {
28
+ let body: any;
29
+ try { body = await req.json(); }
30
+ catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
31
+ const action = body?.action;
32
+
33
+ if (action === 'sync') {
34
+ const r = await syncMarketplace();
35
+ return NextResponse.json(r, { status: r.ok ? 200 : 502 });
36
+ }
37
+
38
+ if (action === 'install' || action === 'uninstall') {
39
+ const kind = body.kind as WorkflowKind;
40
+ const name = body.name;
41
+ if (kind !== 'recipe' && kind !== 'pipeline') {
42
+ return NextResponse.json({ ok: false, error: 'kind must be "recipe" or "pipeline"' }, { status: 400 });
43
+ }
44
+ if (!name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
45
+ const r = action === 'install'
46
+ ? await installFromMarketplace(kind, name, { target_name: body.target_name, overwrite: !!body.overwrite })
47
+ : uninstallFromMarketplace(kind, name);
48
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
49
+ }
50
+
51
+ return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
52
+ }
@@ -145,6 +145,27 @@ if (!isStop) {
145
145
  console.warn('[forge] Install Codex: https://github.com/openai/codex#installation');
146
146
  console.warn('[forge] Or configure API-only profiles in Settings after login.');
147
147
  }
148
+
149
+ // ── Optional helpers used by built-in workflows (mr-review-fix etc.).
150
+ // Warn-only: Forge starts fine without them, but specific Jobs/Pipelines
151
+ // will fail at the shell node if they're missing — better to flag here.
152
+ const optional = [
153
+ { bin: 'jq', used: 'shell pipelines that parse JSON output' },
154
+ { bin: 'glab', used: 'GitLab MR review / mr-review-fix workflow' },
155
+ ];
156
+ const missing = optional.filter((o) => !has(o.bin));
157
+ if (missing.length) {
158
+ console.warn(`[forge] ⚠️ Optional CLIs missing: ${missing.map((m) => m.bin).join(', ')}`);
159
+ for (const m of missing) console.warn(`[forge] - ${m.bin}: used by ${m.used}`);
160
+ if (process.platform === 'darwin') {
161
+ console.warn(`[forge] Install with: brew install ${missing.map((m) => m.bin).join(' ')}`);
162
+ } else if (process.platform === 'linux') {
163
+ console.warn('[forge] Install via your distro\'s package manager.');
164
+ if (missing.some((m) => m.bin === 'glab')) {
165
+ console.warn('[forge] glab: see https://gitlab.com/gitlab-org/cli#installation');
166
+ }
167
+ }
168
+ }
148
169
  }
149
170
 
150
171
  // ── Load <data-dir>/.env.local ──