@aion0/forge 0.10.85 → 0.10.87
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 +8 -5
- package/app/api/connectors/import-config-template/route.ts +28 -0
- package/app/api/schedules/[id]/route.ts +27 -20
- package/app/api/schedules/extract/route.ts +23 -16
- package/components/SchedulesView.tsx +7 -1
- package/components/SkillsPanel.tsx +11 -0
- package/lib/pipeline.ts +63 -4
- package/lib/projects.ts +154 -1
- package/lib/skills.ts +193 -65
- package/lib/task-manager.ts +15 -2
- package/package.json +1 -1
- package/src/core/db/database.ts +7 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.87
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-17
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.86
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
- feat(
|
|
8
|
+
- feat(pipeline): log attached skills in task init line
|
|
9
|
+
- feat(skills): per-skill source badge in marketplace (#354)
|
|
10
|
+
- feat(skills): multi-source sync + install (enterprise + public) — #354
|
|
11
|
+
- feat(pipeline): workflow-level skills: field + template _skills auto-install
|
|
9
12
|
|
|
10
13
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
14
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.86...v0.10.87
|
|
@@ -219,6 +219,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
219
219
|
fields_preserved: Array<{ connector: string; field: string; reason: string }>;
|
|
220
220
|
fields_left_empty: Array<{ connector: string; field: string }>;
|
|
221
221
|
agents_applied?: string[];
|
|
222
|
+
skills_installed?: string[];
|
|
223
|
+
skills_failed?: Array<{ name: string; error: string }>;
|
|
222
224
|
}> {
|
|
223
225
|
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
224
226
|
// can use {user_name} / {user_email} without prompting the user again.
|
|
@@ -359,6 +361,30 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
359
361
|
}
|
|
360
362
|
}
|
|
361
363
|
|
|
364
|
+
// ─── _skills block — auto-install marketplace skills to global ─────
|
|
365
|
+
//
|
|
366
|
+
// Template declares `"_skills": ["prelude", ...]`. On apply we sync the
|
|
367
|
+
// marketplace registry once, then installGlobal each — so e.g. the fortinet
|
|
368
|
+
// template pulls prelude into ~/.claude/skills/ at setup time. installGlobal
|
|
369
|
+
// always resolves the registry's latest version, so "0.2.0 and later" tracks
|
|
370
|
+
// automatically. Failures are collected, never abort the rest of the apply.
|
|
371
|
+
const skillsInstalled: string[] = [];
|
|
372
|
+
const skillsFailed: Array<{ name: string; error: string }> = [];
|
|
373
|
+
const skillsList = Array.isArray((template as any)._skills) ? (template as any)._skills : [];
|
|
374
|
+
if (skillsList.length > 0) {
|
|
375
|
+
const { syncSkills, installGlobal } = await import('@/lib/skills');
|
|
376
|
+
// syncSkills populates the local skills DB that installGlobal reads from.
|
|
377
|
+
// If it fails (offline), installGlobal will likely fail too (it downloads
|
|
378
|
+
// files from GitHub) — that surfaces per-skill in skills_failed, never aborts.
|
|
379
|
+
try { await syncSkills(); } catch { /* non-fatal — per-skill install still attempted */ }
|
|
380
|
+
for (const raw of skillsList) {
|
|
381
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
382
|
+
if (!name) continue;
|
|
383
|
+
try { await installGlobal(name); skillsInstalled.push(name); }
|
|
384
|
+
catch (e: any) { skillsFailed.push({ name, error: e?.message || String(e) }); }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
362
388
|
return {
|
|
363
389
|
applied,
|
|
364
390
|
installed_from_registry: installedFromRegistry,
|
|
@@ -367,6 +393,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
367
393
|
fields_preserved: preserved,
|
|
368
394
|
fields_left_empty: leftEmpty,
|
|
369
395
|
agents_applied: agentsApplied,
|
|
396
|
+
skills_installed: skillsInstalled,
|
|
397
|
+
skills_failed: skillsFailed,
|
|
370
398
|
};
|
|
371
399
|
}
|
|
372
400
|
|
|
@@ -116,29 +116,36 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|
|
116
116
|
}
|
|
117
117
|
patch.action_config = body.action_config;
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
119
|
+
// Cross-field validation runs ONLY for the section being patched.
|
|
120
|
+
// Toggling unrelated fields (e.g. `enabled` from the Pause/Resume button)
|
|
121
|
+
// must not re-validate sections the user isn't touching — a schedule with a
|
|
122
|
+
// pre-existing quirk in its action_config would otherwise refuse to pause.
|
|
123
|
+
if (patch.action_kind !== undefined || patch.action_config !== undefined) {
|
|
124
|
+
const effectiveActionKind = patch.action_kind ?? existing.action_kind;
|
|
125
|
+
const effectiveActionConfig = patch.action_config ?? existing.action_config ?? {};
|
|
126
|
+
if (effectiveActionKind === 'chat') {
|
|
127
|
+
const sid = typeof effectiveActionConfig.session_id === 'string' ? effectiveActionConfig.session_id.trim() : '';
|
|
128
|
+
if (!sid) return NextResponse.json({ error: 'action_kind=chat requires action_config.session_id' }, { status: 400 });
|
|
129
|
+
}
|
|
130
|
+
if (effectiveActionKind === 'email') {
|
|
131
|
+
const toRaw = effectiveActionConfig.to;
|
|
132
|
+
const to = Array.isArray(toRaw)
|
|
133
|
+
? toRaw.filter((x) => typeof x === 'string' && x.trim())
|
|
134
|
+
: (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
|
|
135
|
+
if (to.length === 0) {
|
|
136
|
+
return NextResponse.json({ error: 'action_kind=email requires action_config.to (recipient address or array)' }, { status: 400 });
|
|
137
|
+
}
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
if (patch.schedule_kind !== undefined || patch.schedule_cron !== undefined || patch.schedule_at !== undefined) {
|
|
142
|
+
const effectiveKind = patch.schedule_kind ?? existing.schedule_kind;
|
|
143
|
+
if (effectiveKind === 'cron' && !(patch.schedule_cron ?? existing.schedule_cron)) {
|
|
144
|
+
return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
|
|
145
|
+
}
|
|
146
|
+
if (effectiveKind === 'once' && !(patch.schedule_at ?? existing.schedule_at)) {
|
|
147
|
+
return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
|
|
148
|
+
}
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
updateSchedule(id, patch);
|
|
@@ -19,6 +19,8 @@ import { inferAdapter, pickApiKey, pickBaseUrl } from '@/lib/chat/agent-loop';
|
|
|
19
19
|
import { makeAnthropicClient } from '@/lib/chat/llm/anthropic';
|
|
20
20
|
import { listAgents } from '@/lib/agents';
|
|
21
21
|
import { scanProjects } from '@/lib/projects';
|
|
22
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
23
|
+
import { generateText } from 'ai';
|
|
22
24
|
|
|
23
25
|
interface Extracted {
|
|
24
26
|
name: string;
|
|
@@ -116,15 +118,9 @@ export async function POST(req: Request) {
|
|
|
116
118
|
? profiles[preferredId]
|
|
117
119
|
: candidates[0]![1];
|
|
118
120
|
const adapter = inferAdapter(profile.provider);
|
|
119
|
-
if (adapter !== 'anthropic') {
|
|
120
|
-
return NextResponse.json({
|
|
121
|
-
ok: false,
|
|
122
|
-
error: 'extractor currently supports anthropic profiles only (openai is a follow-up)',
|
|
123
|
-
}, { status: 400 });
|
|
124
|
-
}
|
|
125
121
|
const apiKey = pickApiKey(profile, adapter);
|
|
126
122
|
const baseUrl = pickBaseUrl(profile, adapter);
|
|
127
|
-
const model = profile.model || 'claude-sonnet-4-6';
|
|
123
|
+
const model = profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
|
|
128
124
|
|
|
129
125
|
const cliAgents = listAgents().filter((a: any) => a.backendType !== 'api');
|
|
130
126
|
const availableAgentIds = cliAgents.map((a: any) => a.id);
|
|
@@ -134,15 +130,26 @@ export async function POST(req: Request) {
|
|
|
134
130
|
|
|
135
131
|
let raw = '';
|
|
136
132
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
133
|
+
if (adapter === 'anthropic') {
|
|
134
|
+
const client = makeAnthropicClient(apiKey, baseUrl);
|
|
135
|
+
const resp = await client.messages.create({
|
|
136
|
+
model,
|
|
137
|
+
max_tokens: 1500,
|
|
138
|
+
system,
|
|
139
|
+
messages: [{ role: 'user', content: text }],
|
|
140
|
+
});
|
|
141
|
+
for (const block of resp.content) {
|
|
142
|
+
if (block.type === 'text') raw += block.text;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// OpenAI / OpenAI-compatible — non-streaming one-shot via @ai-sdk/openai.
|
|
146
|
+
const provider = createOpenAI({ apiKey, baseURL: baseUrl });
|
|
147
|
+
const resp = await generateText({
|
|
148
|
+
model: provider.chat(model),
|
|
149
|
+
system,
|
|
150
|
+
messages: [{ role: 'user', content: text }],
|
|
151
|
+
});
|
|
152
|
+
raw = resp.text;
|
|
146
153
|
}
|
|
147
154
|
} catch (e: any) {
|
|
148
155
|
return NextResponse.json({
|
|
@@ -145,11 +145,17 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
145
145
|
|
|
146
146
|
async function togglePaused(s: Schedule) {
|
|
147
147
|
try {
|
|
148
|
-
await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
|
148
|
+
const res = await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
|
149
149
|
method: 'PATCH',
|
|
150
150
|
headers: { 'Content-Type': 'application/json' },
|
|
151
151
|
body: JSON.stringify({ enabled: !s.enabled }),
|
|
152
152
|
});
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
// Surface backend validation errors — otherwise the button looks dead.
|
|
155
|
+
const body = await res.json().catch(() => ({}));
|
|
156
|
+
setErr(`Toggle failed (${res.status}): ${body?.error || res.statusText}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
153
159
|
void refresh();
|
|
154
160
|
} catch (e) { setErr(`Toggle failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
155
161
|
}
|
|
@@ -26,6 +26,8 @@ interface Skill {
|
|
|
26
26
|
installedProjects: string[];
|
|
27
27
|
deletedRemotely: boolean;
|
|
28
28
|
source?: 'registry' | 'local';
|
|
29
|
+
sourceId?: string;
|
|
30
|
+
isEnterprise?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface ProjectInfo {
|
|
@@ -584,6 +586,15 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
584
586
|
>
|
|
585
587
|
<div className="flex items-center gap-2">
|
|
586
588
|
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
|
|
589
|
+
{/* Source badge: enterprise skills (synced from an enterprise
|
|
590
|
+
repo, winning over a same-name public skill) get a 🔒 chip
|
|
591
|
+
with the tenant id; public skills stay unmarked. */}
|
|
592
|
+
{skill.isEnterprise && (
|
|
593
|
+
<span
|
|
594
|
+
className="text-[8px] px-1 rounded font-medium bg-amber-500/20 text-amber-500 shrink-0"
|
|
595
|
+
title={`Enterprise source: ${skill.sourceId?.replace(/^enterprise-/, '') || ''}`}
|
|
596
|
+
>🔒 {skill.sourceId?.replace(/^enterprise-/, '') || 'enterprise'}</span>
|
|
597
|
+
)}
|
|
587
598
|
<span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
|
|
588
599
|
{skill.rating > 0 && (
|
|
589
600
|
<span className="text-[8px] text-[var(--yellow)] shrink-0" title={`Rating: ${skill.rating}/5`}>
|
package/lib/pipeline.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSy
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
|
-
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
13
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, taskSkillsOverrides, cancelTask } from './task-manager';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
@@ -172,6 +172,23 @@ export interface Workflow {
|
|
|
172
172
|
/** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
|
|
173
173
|
* so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
|
|
174
174
|
git_push?: boolean;
|
|
175
|
+
/** Declares the pipeline needs a real git checkout to operate (e.g. it will
|
|
176
|
+
* cd into the project and run `git`, `glab`, build/test commands). When
|
|
177
|
+
* true, startPipeline runs the strict resolver (resolveProjectStrict) and
|
|
178
|
+
* aborts the whole run with a clear error if no project can be resolved —
|
|
179
|
+
* instead of silently falling back to scratch, which causes downstream
|
|
180
|
+
* shell nodes to crash with "fatal: not a git repository".
|
|
181
|
+
*
|
|
182
|
+
* Default: false (preserves the scratch fallback for connector-only / chat
|
|
183
|
+
* pipelines that don't touch a repo). */
|
|
184
|
+
requires_real_project?: boolean;
|
|
185
|
+
/** Skills auto-attached to every task this workflow spawns, declared in the
|
|
186
|
+
* workflow YAML. Merged (deduped) with any skills the dispatching
|
|
187
|
+
* Job/Schedule/chat passes at runtime — so a pipeline can carry a default
|
|
188
|
+
* skill (e.g. prelude) regardless of how it's triggered, including manual
|
|
189
|
+
* Fire which passes no opts.skills. Composed into each task's
|
|
190
|
+
* --append-system-prompt via renderSkillsAppendPrompt + auto-installed. */
|
|
191
|
+
skills?: string[];
|
|
175
192
|
/** Default execution backend for every node's task. 'tmux' runs interactive
|
|
176
193
|
* claude in a per-node tmux session (subscription billing); 'headless' /
|
|
177
194
|
* omitted uses default `claude -p`. Per-node `backend:` overrides this. */
|
|
@@ -517,6 +534,17 @@ export function parseWorkflow(raw: string): Workflow {
|
|
|
517
534
|
for_each,
|
|
518
535
|
conversation,
|
|
519
536
|
backend: parsed.backend === 'tmux' || parsed.backend === 'headless' ? parsed.backend : undefined,
|
|
537
|
+
// Top-level boolean flag — must be explicitly passed through; the return is
|
|
538
|
+
// a literal, so any field omitted here is silently dropped from the parsed
|
|
539
|
+
// workflow. NOTE: `git_push` has the SAME latent issue (it's declared in
|
|
540
|
+
// the interface + read at startPipeline but never parsed here, so its OTP
|
|
541
|
+
// preflight has never actually fired). Left as-is on purpose: wiring it up
|
|
542
|
+
// would newly enable the preflight on existing fortinet pipelines, an
|
|
543
|
+
// unrelated behavior change that belongs in its own commit.
|
|
544
|
+
requires_real_project: parsed.requires_real_project === true,
|
|
545
|
+
skills: Array.isArray(parsed.skills)
|
|
546
|
+
? parsed.skills.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
|
|
547
|
+
: undefined,
|
|
520
548
|
};
|
|
521
549
|
}
|
|
522
550
|
|
|
@@ -1075,12 +1103,39 @@ export function startPipeline(
|
|
|
1075
1103
|
nodes,
|
|
1076
1104
|
nodeOrder,
|
|
1077
1105
|
createdAt: new Date().toISOString(),
|
|
1078
|
-
skills:
|
|
1106
|
+
skills: (() => {
|
|
1107
|
+
const merged = [...(workflow.skills || []), ...(opts.skills || [])];
|
|
1108
|
+
return merged.length ? [...new Set(merged)] : undefined;
|
|
1109
|
+
})(),
|
|
1079
1110
|
// Runtime override (e.g. chat "use tmux") wins over the workflow's declared default.
|
|
1080
1111
|
backend: opts.backend ?? workflow.backend,
|
|
1081
1112
|
forEach: forEachState,
|
|
1082
1113
|
};
|
|
1083
1114
|
|
|
1115
|
+
// Strict project resolution — only for pipelines that declared they need a
|
|
1116
|
+
// real git checkout. MUST run BEFORE ensurePipelineTmpDir: the lenient
|
|
1117
|
+
// resolver inside that helper falls back to scratch (building tmp_dir in the
|
|
1118
|
+
// wrong place) and downstream `git` / `glab` commands then crash with
|
|
1119
|
+
// "fatal: not a git repository". The strict resolver instead clones/locates
|
|
1120
|
+
// the real repo (or returns a structured error we surface here), and seeds
|
|
1121
|
+
// input.project so the tmp_dir lands inside that repo.
|
|
1122
|
+
if (workflow.requires_real_project) {
|
|
1123
|
+
const r = resolveProjectStrict(input.project || '');
|
|
1124
|
+
if ('error' in r) {
|
|
1125
|
+
pipeline.status = 'failed';
|
|
1126
|
+
pipeline.error = r.message;
|
|
1127
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1128
|
+
savePipeline(pipeline);
|
|
1129
|
+
finalizePipeline(pipeline);
|
|
1130
|
+
return pipeline;
|
|
1131
|
+
}
|
|
1132
|
+
// Seed the resolved project name into input so ensurePipelineTmpDir and
|
|
1133
|
+
// every node's lenient resolveOrCloneProject hit it immediately as
|
|
1134
|
+
// 'existing' (no redundant scan/clone). pipeline.input === input here
|
|
1135
|
+
// (same ref from construction), so an in-place mutation updates both.
|
|
1136
|
+
if (r.project.name) input.project = r.project.name;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1084
1139
|
ensurePipelineTmpDir(pipeline);
|
|
1085
1140
|
|
|
1086
1141
|
// Git OTP/2FA preflight — abort the whole run up front so a push wall never
|
|
@@ -1232,7 +1287,10 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
|
|
|
1232
1287
|
pipelineTaskIds.add(task.id);
|
|
1233
1288
|
{
|
|
1234
1289
|
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
1235
|
-
if (skillsAppend)
|
|
1290
|
+
if (skillsAppend) {
|
|
1291
|
+
taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
1292
|
+
taskSkillsOverrides.set(task.id, pipeline.skills!);
|
|
1293
|
+
}
|
|
1236
1294
|
}
|
|
1237
1295
|
|
|
1238
1296
|
// Add pending message
|
|
@@ -2115,6 +2173,7 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
2115
2173
|
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
2116
2174
|
if (skillsAppend) {
|
|
2117
2175
|
taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
2176
|
+
taskSkillsOverrides.set(task.id, pipeline.skills!);
|
|
2118
2177
|
}
|
|
2119
2178
|
// Pipeline tasks use the same model selection as normal tasks
|
|
2120
2179
|
// (per-task override > agent scene model > agent default).
|
package/lib/projects.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync, accessSync, constants as fsConstants } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join, resolve, isAbsolute, parse } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -231,6 +231,159 @@ export interface ResolveResult {
|
|
|
231
231
|
clone_url?: string;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ─── Container mode + auto-root ───────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const CONTAINER_DEFAULT_ROOT = '/data/project';
|
|
237
|
+
|
|
238
|
+
/** True when Forge is running inside a Docker/container deployment.
|
|
239
|
+
* Two-channel detection (per user choice C):
|
|
240
|
+
* 1. FORGE_CONTAINER=1 env var (set by entrypoint)
|
|
241
|
+
* 2. /data/project exists AND is writable (probe; covers ad-hoc mounts)
|
|
242
|
+
* Either signal is enough. */
|
|
243
|
+
export function detectContainerMode(): boolean {
|
|
244
|
+
if (process.env.FORGE_CONTAINER === '1') return true;
|
|
245
|
+
try {
|
|
246
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
247
|
+
return true;
|
|
248
|
+
} catch { return false; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** In container mode, register `/data/project` as a project root if no roots
|
|
252
|
+
* are configured yet. Idempotent: returns the path that was added (or
|
|
253
|
+
* already present), or null when we're not in container mode / the path
|
|
254
|
+
* isn't writable. Auto-add (user choice 3) so subsequent runs use it without
|
|
255
|
+
* re-triggering this path.
|
|
256
|
+
*
|
|
257
|
+
* Persists to settings so the path also appears in Settings → Projects. */
|
|
258
|
+
export function ensureContainerRoot(): string | null {
|
|
259
|
+
if (!detectContainerMode()) return null;
|
|
260
|
+
try {
|
|
261
|
+
mkdirSync(CONTAINER_DEFAULT_ROOT, { recursive: true });
|
|
262
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
263
|
+
} catch { return null; }
|
|
264
|
+
const settings = loadSettings();
|
|
265
|
+
const roots = settings.projectRoots || [];
|
|
266
|
+
if (roots.includes(CONTAINER_DEFAULT_ROOT)) return CONTAINER_DEFAULT_ROOT;
|
|
267
|
+
settings.projectRoots = [...roots, CONTAINER_DEFAULT_ROOT];
|
|
268
|
+
saveSettings(settings);
|
|
269
|
+
invalidateProjectScan();
|
|
270
|
+
return CONTAINER_DEFAULT_ROOT;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Strict resolver (for pipelines that declare requires_real_project) ───
|
|
274
|
+
|
|
275
|
+
export type StrictResolveError =
|
|
276
|
+
| 'no_repo_specified'
|
|
277
|
+
| 'clone_failed';
|
|
278
|
+
|
|
279
|
+
export interface StrictResolveResult {
|
|
280
|
+
project: LocalProject;
|
|
281
|
+
source: 'existing' | 'gitlab-cloned';
|
|
282
|
+
clone_url?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Strict project resolver — used by pipelines that declare
|
|
287
|
+
* `requires_real_project: true`. Differs from resolveOrCloneProject in that
|
|
288
|
+
* it NEVER falls back to scratch; on any failure it returns a structured
|
|
289
|
+
* error so the caller (startPipeline) can abort the run with a clear message
|
|
290
|
+
* instead of letting git commands inside the task crash with
|
|
291
|
+
* "fatal: not a git repository".
|
|
292
|
+
*
|
|
293
|
+
* Flow (matches the user's spec):
|
|
294
|
+
* 1. Existing project by name / owner-repo / fuzzy → use it
|
|
295
|
+
* 2. Otherwise figure out the target repo URL:
|
|
296
|
+
* - input name contains '/' → treat as owner/repo
|
|
297
|
+
* - else gitlab connector default_project_path
|
|
298
|
+
* - else → error('no_repo_specified')
|
|
299
|
+
* 3. Re-scan locally for a checkout whose origin matches the target → use it
|
|
300
|
+
* 4. Clone via tryGitlabClone — writes to the Forge-managed cache
|
|
301
|
+
* (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
|
|
302
|
+
* need a configured projectRoot. We best-effort register the container
|
|
303
|
+
* root (/data/project) so manually-placed checkouts there get scanned,
|
|
304
|
+
* but never block the clone on it.
|
|
305
|
+
* - gitlab connector missing → error('no_repo_specified')
|
|
306
|
+
* - clone failed → error('clone_failed')
|
|
307
|
+
*/
|
|
308
|
+
export function resolveProjectStrict(name: string | undefined): StrictResolveResult | { error: StrictResolveError; message: string } {
|
|
309
|
+
const trimmed = (name || '').trim();
|
|
310
|
+
|
|
311
|
+
// Step 1: existing match — reuse the lenient resolver's match logic by
|
|
312
|
+
// delegating, but discard its scratch fallback.
|
|
313
|
+
if (trimmed) {
|
|
314
|
+
const hit = getProjectInfo(trimmed);
|
|
315
|
+
if (hit) return { project: hit, source: 'existing' };
|
|
316
|
+
|
|
317
|
+
const projects = scanProjects();
|
|
318
|
+
if (trimmed.includes('/')) {
|
|
319
|
+
const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
320
|
+
const wantBase = want.split('/').pop()!;
|
|
321
|
+
const byRepo = projects.filter((p) => p.repo && p.repo === want);
|
|
322
|
+
if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
|
|
323
|
+
const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
|
|
324
|
+
if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
|
|
325
|
+
}
|
|
326
|
+
const base = trimmed.split('/').pop()!.trim();
|
|
327
|
+
const ownerRepo = trimmed.replace(/\//g, '-');
|
|
328
|
+
const cands = projects.filter((p) =>
|
|
329
|
+
p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
|
|
330
|
+
if (cands.length === 1) return { project: cands[0], source: 'existing' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Step 2: derive target repo path
|
|
334
|
+
const gl = readGitlabConnector();
|
|
335
|
+
const targetPath = trimmed && trimmed.includes('/')
|
|
336
|
+
? trimmed
|
|
337
|
+
: (gl?.default_project_path || '').trim();
|
|
338
|
+
if (!targetPath) {
|
|
339
|
+
return {
|
|
340
|
+
error: 'no_repo_specified',
|
|
341
|
+
message: trimmed
|
|
342
|
+
? `Could not resolve project "${trimmed}". Pass an owner/repo path, or set GitLab connector's default_project_path.`
|
|
343
|
+
: 'No repository specified. Set `project: owner/repo` in pipeline input, or set GitLab connector\'s default_project_path.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Step 3: scan locally by origin remote
|
|
348
|
+
if (gl) {
|
|
349
|
+
const projects = scanProjects();
|
|
350
|
+
const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
351
|
+
const wantBase = want.split('/').pop()!;
|
|
352
|
+
const localHit =
|
|
353
|
+
projects.find((p) => p.repo && p.repo === want) ||
|
|
354
|
+
projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
|
|
355
|
+
projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
|
|
356
|
+
if (localHit) return { project: localHit, source: 'existing' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Step 4: clone. tryGitlabClone writes to <dataDir>/cloned-projects (always
|
|
360
|
+
// writable) — it does NOT use projectRoots, so there's no "no project root"
|
|
361
|
+
// failure here: as long as the gitlab connector is configured, a clone has
|
|
362
|
+
// somewhere to land. Best-effort register the container root so manual
|
|
363
|
+
// checkouts under /data/project are discoverable on the next scan; ignore
|
|
364
|
+
// its result — it must never block the clone.
|
|
365
|
+
ensureContainerRoot();
|
|
366
|
+
|
|
367
|
+
if (!gl) {
|
|
368
|
+
return {
|
|
369
|
+
error: 'no_repo_specified',
|
|
370
|
+
message: `GitLab connector is not configured; cannot clone "${targetPath}". Install + configure the GitLab connector first.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const cloned = tryGitlabClone(targetPath);
|
|
374
|
+
if (!cloned) {
|
|
375
|
+
return {
|
|
376
|
+
error: 'clone_failed',
|
|
377
|
+
message: `Failed to clone ${gl.base_url}/${targetPath}. Check GitLab connectivity, credentials, and that the path exists.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
project: cloned,
|
|
382
|
+
source: 'gitlab-cloned',
|
|
383
|
+
clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
234
387
|
/**
|
|
235
388
|
* Pick a writable directory to clone into, in priority order:
|
|
236
389
|
* 1. settings.projectRoots[0] — what the user already trusts
|
package/lib/skills.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { getDb } from '../src/core/db/database';
|
|
|
9
9
|
import { getDbPath } from '../src/config';
|
|
10
10
|
import { loadSettings } from './settings';
|
|
11
11
|
import { getClaudeDir } from './dirs';
|
|
12
|
+
import { listSources, fetchSourceFile, type SourceMeta } from './connectors/sync';
|
|
12
13
|
|
|
13
14
|
type ItemType = 'skill' | 'command';
|
|
14
15
|
|
|
@@ -30,6 +31,11 @@ interface SkillItem {
|
|
|
30
31
|
deletedRemotely: boolean;
|
|
31
32
|
/** 'registry' (synced from forge-skills) or 'local' (uploaded). */
|
|
32
33
|
source: 'registry' | 'local';
|
|
34
|
+
/** Which marketplace source the registry row came from: 'public' or an
|
|
35
|
+
* 'enterprise-<tenant>' id. Lets the UI show a per-skill source badge. */
|
|
36
|
+
sourceId: string;
|
|
37
|
+
/** True when sourceId is an enterprise source (for the 🔒 badge). */
|
|
38
|
+
isEnterprise: boolean;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
function db() {
|
|
@@ -49,6 +55,40 @@ function getRepoInfo(): { owner: string; repo: string; branch: string } {
|
|
|
49
55
|
return { owner: 'aiwatching', repo: 'forge-skills', branch: 'main' };
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// ─── Multi-source (#354) ──────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Skills sync from the same source set as connectors: enterprise sources
|
|
61
|
+
// (by priority) layered OVER the public marketplace. We reuse the connector
|
|
62
|
+
// module's SourceMeta + fetchSourceFile (it already handles raw vs github_api
|
|
63
|
+
// + private-repo PAT auth), but the PUBLIC source must point at forge-skills,
|
|
64
|
+
// not forge-connectors — so we take the enterprise entries from listSources()
|
|
65
|
+
// and swap in the skills public base.
|
|
66
|
+
|
|
67
|
+
/** Ordered skill sources, highest priority first, public last. */
|
|
68
|
+
function listSkillSources(): SourceMeta[] {
|
|
69
|
+
const enterprise = listSources().filter((s) => s.is_enterprise);
|
|
70
|
+
const publicSrc: SourceMeta = {
|
|
71
|
+
id: 'public',
|
|
72
|
+
display_name: 'Public',
|
|
73
|
+
is_enterprise: false,
|
|
74
|
+
priority: enterprise.length,
|
|
75
|
+
fetch_mode: 'raw',
|
|
76
|
+
base_url: getBaseUrl(),
|
|
77
|
+
};
|
|
78
|
+
return [...enterprise, publicSrc];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceById(id: string): SourceMeta | undefined {
|
|
82
|
+
return listSkillSources().find((s) => s.id === id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "owner/repo" for a source — for the source_repo DB column. */
|
|
86
|
+
function sourceRepoOf(s: SourceMeta): string {
|
|
87
|
+
if (s.is_enterprise) return (s.repo_url || '').replace(/^github\.com\//, '');
|
|
88
|
+
const r = getRepoInfo();
|
|
89
|
+
return `${r.owner}/${r.repo}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
function compareVersions(a: string, b: string): number {
|
|
53
93
|
const pa = (a || '0.0.0').split('.').map(Number);
|
|
54
94
|
const pb = (b || '0.0.0').split('.').map(Number);
|
|
@@ -64,85 +104,112 @@ function compareVersions(a: string, b: string): number {
|
|
|
64
104
|
/** Max info.json enrichments per sync (incremental) */
|
|
65
105
|
const ENRICH_BATCH_SIZE = 10;
|
|
66
106
|
|
|
107
|
+
function parseRegistryItems(data: any): any[] {
|
|
108
|
+
if (data && data.version === 2) {
|
|
109
|
+
return [
|
|
110
|
+
...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
|
|
111
|
+
...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
return (data?.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' }));
|
|
115
|
+
}
|
|
116
|
+
|
|
67
117
|
export async function syncSkills(): Promise<{ synced: number; enriched: number; total?: number; remaining?: number; error?: string }> {
|
|
68
118
|
console.log('[skills] Syncing from registry...');
|
|
69
119
|
const baseUrl = getBaseUrl();
|
|
120
|
+
const cacheBust = `_t=${Date.now()}`;
|
|
70
121
|
|
|
71
122
|
try {
|
|
72
|
-
// Step 1:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
|
|
79
|
-
});
|
|
80
|
-
clearTimeout(timeout);
|
|
81
|
-
|
|
82
|
-
if (!res.ok) return { synced: 0, enriched: 0, error: `Registry fetch failed: ${res.status}` };
|
|
123
|
+
// Step 1+2: fetch + upsert from EVERY source. Process lowest-priority
|
|
124
|
+
// first (public), then enterprise — so an enterprise skill of the same
|
|
125
|
+
// name overwrites the public one on INSERT OR REPLACE (enterprise wins,
|
|
126
|
+
// matching connector/pipeline precedence). Each row records which source
|
|
127
|
+
// it came from so installGlobal knows where (and with what auth) to pull.
|
|
128
|
+
const ordered = [...listSkillSources()].reverse(); // public → … → highest enterprise
|
|
83
129
|
|
|
84
|
-
const data = await res.json();
|
|
85
|
-
|
|
86
|
-
// Parse registry items (v1 + v2 support)
|
|
87
|
-
let rawItems: any[] = [];
|
|
88
|
-
if (data.version === 2) {
|
|
89
|
-
rawItems = [
|
|
90
|
-
...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
|
|
91
|
-
...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
|
|
92
|
-
];
|
|
93
|
-
} else {
|
|
94
|
-
rawItems = (data.skills || []).map((s: any) => ({ ...s, type: s.type || 'command' }));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Step 2: Upsert all items from registry.json directly (fast, no extra fetch)
|
|
98
130
|
const upsertStmt = db().prepare(`
|
|
99
|
-
INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive,
|
|
131
|
+
INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive,
|
|
132
|
+
source_id, source_repo, source_branch, synced_at,
|
|
100
133
|
installed_global, installed_projects, installed_version)
|
|
101
134
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
135
|
+
?, ?, ?,
|
|
102
136
|
COALESCE((SELECT synced_at FROM skills WHERE name = ?), datetime('now')),
|
|
103
137
|
COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
|
|
104
138
|
COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
|
|
105
139
|
COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
|
|
106
140
|
`);
|
|
107
141
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
142
|
+
const registryNames = new Set<string>();
|
|
143
|
+
const sourceErrors: string[] = [];
|
|
144
|
+
let syncedTotal = 0;
|
|
145
|
+
let anyOk = false;
|
|
146
|
+
|
|
147
|
+
for (const source of ordered) {
|
|
148
|
+
let items: any[];
|
|
149
|
+
try {
|
|
150
|
+
// fetchSourceFile busts cache internally (raw appends ?_t, github_api
|
|
151
|
+
// appends &_t) — pass the bare path so we don't double the query string.
|
|
152
|
+
const text = await fetchSourceFile(source, 'registry.json');
|
|
153
|
+
items = parseRegistryItems(JSON.parse(text));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// One source down (offline / PAT issue) must not wipe the others.
|
|
156
|
+
sourceErrors.push(`${source.id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
157
|
+
continue;
|
|
119
158
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
anyOk = true;
|
|
160
|
+
const repo = sourceRepoOf(source);
|
|
161
|
+
const branch = source.is_enterprise ? 'main' : getRepoInfo().branch;
|
|
162
|
+
const tx = db().transaction(() => {
|
|
163
|
+
for (const s of items) {
|
|
164
|
+
if (!s.name) continue;
|
|
165
|
+
upsertStmt.run(
|
|
166
|
+
s.name, s.type || 'skill',
|
|
167
|
+
s.display_name || '', s.description || '',
|
|
168
|
+
(s.author?.name || s.author || '').toString(), s.version || '',
|
|
169
|
+
JSON.stringify(s.tags || []),
|
|
170
|
+
s.score ?? 0, s.rating ?? 0, s.source?.url || s.source_url || '',
|
|
171
|
+
'', // archive
|
|
172
|
+
source.id, repo, branch,
|
|
173
|
+
s.name, s.name, s.name, s.name
|
|
174
|
+
);
|
|
175
|
+
registryNames.add(s.name);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
tx();
|
|
179
|
+
syncedTotal += items.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!anyOk) {
|
|
183
|
+
// Every source failed (typically offline) — leave the DB untouched.
|
|
184
|
+
return { synced: 0, enriched: 0, error: sourceErrors.join('; ') || 'all sources unreachable' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Step 3: prune items no longer in ANY registry — but ONLY when every
|
|
188
|
+
// source synced cleanly. If an enterprise source was transiently down,
|
|
189
|
+
// skipping this avoids wrongly deleting its (still-valid) skills.
|
|
190
|
+
// 'local'-source rows are never pruned (user uploads, no upstream).
|
|
191
|
+
if (sourceErrors.length === 0) {
|
|
192
|
+
const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
|
|
193
|
+
for (const row of dbItems) {
|
|
194
|
+
if (row.source === 'local') continue;
|
|
195
|
+
if (!registryNames.has(row.name)) {
|
|
196
|
+
const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
|
|
197
|
+
if (hasLocal) {
|
|
198
|
+
db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
|
|
199
|
+
} else {
|
|
200
|
+
db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
|
|
201
|
+
}
|
|
137
202
|
}
|
|
138
203
|
}
|
|
139
204
|
}
|
|
140
205
|
|
|
141
|
-
// Step 4: Incremental enrichment
|
|
142
|
-
//
|
|
206
|
+
// Step 4: Incremental enrichment from info.json — PUBLIC source only.
|
|
207
|
+
// Enterprise registry.json entries are authored in full (version, tags,
|
|
208
|
+
// description), so they need no enrichment; and their info.json lives in a
|
|
209
|
+
// private repo that the plain fetch() below can't read anyway.
|
|
143
210
|
const staleItems = db().prepare(`
|
|
144
211
|
SELECT name, type, version FROM skills
|
|
145
|
-
WHERE deleted_remotely = 0
|
|
212
|
+
WHERE deleted_remotely = 0 AND source_id = 'public'
|
|
146
213
|
ORDER BY synced_at ASC
|
|
147
214
|
LIMIT ?
|
|
148
215
|
`).all(ENRICH_BATCH_SIZE) as any[];
|
|
@@ -190,8 +257,16 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
|
|
|
190
257
|
|
|
191
258
|
const totalCount = (db().prepare('SELECT count(*) as c FROM skills WHERE deleted_remotely = 0').get() as any).c;
|
|
192
259
|
const remaining = totalCount - ENRICH_BATCH_SIZE; // approximate items not yet enriched this round
|
|
193
|
-
console.log(`[skills] Synced ${
|
|
194
|
-
return {
|
|
260
|
+
console.log(`[skills] Synced ${syncedTotal} items across ${ordered.length} source(s), enriched ${enriched}/${staleItems.length} from info.json`);
|
|
261
|
+
return {
|
|
262
|
+
synced: syncedTotal,
|
|
263
|
+
enriched,
|
|
264
|
+
total: totalCount,
|
|
265
|
+
remaining: Math.max(0, remaining),
|
|
266
|
+
// Surface a partial-failure note (e.g. one enterprise source down) while
|
|
267
|
+
// still reporting success for the sources that did sync.
|
|
268
|
+
error: sourceErrors.length ? `partial: ${sourceErrors.join('; ')}` : undefined,
|
|
269
|
+
};
|
|
195
270
|
} catch (e) {
|
|
196
271
|
const msg = e instanceof Error ? e.message : String(e);
|
|
197
272
|
// Corp networks routinely can't reach GitHub raw — this is expected
|
|
@@ -226,6 +301,8 @@ export function listSkills(): SkillItem[] {
|
|
|
226
301
|
hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
|
|
227
302
|
deletedRemotely: !!r.deleted_remotely,
|
|
228
303
|
source: r.source === 'local' ? 'local' : 'registry',
|
|
304
|
+
sourceId: r.source_id || 'public',
|
|
305
|
+
isEnterprise: typeof r.source_id === 'string' && r.source_id.startsWith('enterprise-'),
|
|
229
306
|
};
|
|
230
307
|
});
|
|
231
308
|
}
|
|
@@ -269,6 +346,59 @@ async function downloadFile(url: string): Promise<string> {
|
|
|
269
346
|
return res.text();
|
|
270
347
|
}
|
|
271
348
|
|
|
349
|
+
/** Recursively list a directory in a private enterprise repo via the GitHub
|
|
350
|
+
* contents API (PAT-authenticated). Returns repo-relative file paths. */
|
|
351
|
+
async function listEnterpriseDir(source: SourceMeta, dirPath: string): Promise<string[]> {
|
|
352
|
+
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
353
|
+
const out: string[] = [];
|
|
354
|
+
async function recurse(path: string): Promise<void> {
|
|
355
|
+
const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=main`;
|
|
356
|
+
const res = await fetch(url, {
|
|
357
|
+
headers: {
|
|
358
|
+
Authorization: `Bearer ${source.github_pat}`,
|
|
359
|
+
Accept: 'application/vnd.github.v3+json',
|
|
360
|
+
'User-Agent': 'forge-skills-sync/1.0',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
if (!res.ok) return;
|
|
364
|
+
const items = await res.json();
|
|
365
|
+
if (!Array.isArray(items)) return;
|
|
366
|
+
for (const it of items) {
|
|
367
|
+
if (it.type === 'file') out.push(it.path);
|
|
368
|
+
else if (it.type === 'dir') await recurse(it.path);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
await recurse(dirPath);
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Source-aware fetch of all files for a skill/command. Public skills use the
|
|
376
|
+
* unauthenticated GitHub contents API; enterprise skills list + download via
|
|
377
|
+
* the recorded source's PAT (private repos). Returns local-relative paths. */
|
|
378
|
+
async function fetchSkillFiles(name: string, type: ItemType, sourceId: string): Promise<{ path: string; content: string }[]> {
|
|
379
|
+
const source = sourceById(sourceId);
|
|
380
|
+
// Public (or a source no longer configured) → legacy unauthenticated path.
|
|
381
|
+
if (!source || !source.is_enterprise) {
|
|
382
|
+
const files = await listRepoFiles(name, type);
|
|
383
|
+
const out: { path: string; content: string }[] = [];
|
|
384
|
+
for (const f of files) out.push({ path: f.path, content: await downloadFile(f.download_url) });
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
// Enterprise → private repo: list with PAT, fetch each via fetchSourceFile.
|
|
388
|
+
const dirs = type === 'skill' ? ['skills', 'commands'] : ['commands', 'skills'];
|
|
389
|
+
for (const dir of dirs) {
|
|
390
|
+
const repoPaths = await listEnterpriseDir(source, `${dir}/${name}`);
|
|
391
|
+
if (repoPaths.length === 0) continue;
|
|
392
|
+
const stripRe = new RegExp(`^${dir}/${name}/`);
|
|
393
|
+
const out: { path: string; content: string }[] = [];
|
|
394
|
+
for (const p of repoPaths) {
|
|
395
|
+
out.push({ path: p.replace(stripRe, ''), content: await fetchSourceFile(source, p) });
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
|
|
272
402
|
// ─── Install ─────────────────────────────────────────────────
|
|
273
403
|
|
|
274
404
|
function getClaudeHome(): string {
|
|
@@ -290,15 +420,14 @@ export async function installGlobal(name: string): Promise<void> {
|
|
|
290
420
|
const subdir = type === 'skill' ? 'skills' : 'commands';
|
|
291
421
|
const targetDir = join(claudeHome, subdir, name);
|
|
292
422
|
|
|
293
|
-
const files = await
|
|
423
|
+
const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
|
|
294
424
|
if (files.length === 0) throw new Error(`No files found for ${name}`);
|
|
295
425
|
|
|
296
426
|
mkdirSync(targetDir, { recursive: true });
|
|
297
427
|
for (const f of files) {
|
|
298
|
-
const content = await downloadFile(f.download_url);
|
|
299
428
|
const targetPath = join(targetDir, f.path);
|
|
300
429
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
301
|
-
writeFileSync(targetPath, content);
|
|
430
|
+
writeFileSync(targetPath, f.content);
|
|
302
431
|
}
|
|
303
432
|
|
|
304
433
|
// Update installed state
|
|
@@ -333,15 +462,14 @@ export async function installProject(name: string, projectPath: string): Promise
|
|
|
333
462
|
const subdir = type === 'skill' ? 'skills' : 'commands';
|
|
334
463
|
const targetDir = join(projectPath, '.claude', subdir, name);
|
|
335
464
|
|
|
336
|
-
const files = await
|
|
465
|
+
const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
|
|
337
466
|
if (files.length === 0) throw new Error(`No files found for ${name}`);
|
|
338
467
|
|
|
339
468
|
mkdirSync(targetDir, { recursive: true });
|
|
340
469
|
for (const f of files) {
|
|
341
|
-
const content = await downloadFile(f.download_url);
|
|
342
470
|
const targetPath = join(targetDir, f.path);
|
|
343
471
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
344
|
-
writeFileSync(targetPath, content);
|
|
472
|
+
writeFileSync(targetPath, f.content);
|
|
345
473
|
}
|
|
346
474
|
|
|
347
475
|
// Update installed state
|
package/lib/task-manager.ts
CHANGED
|
@@ -69,6 +69,19 @@ export const taskModelOverrides = new Map<string, string>();
|
|
|
69
69
|
*/
|
|
70
70
|
export const taskAppendSystemPromptOverrides = new Map<string, string>();
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Skill names attached to a task (parallel to the append-system-prompt
|
|
74
|
+
* override above). Purely for observability — surfaced in the task's
|
|
75
|
+
* init log so a pipeline run shows which skills it loaded.
|
|
76
|
+
*/
|
|
77
|
+
export const taskSkillsOverrides = new Map<string, string[]>();
|
|
78
|
+
|
|
79
|
+
/** ` | Skills: a, b` for the init log, or '' when none attached. */
|
|
80
|
+
function skillsLogSuffix(taskId: string): string {
|
|
81
|
+
const s = taskSkillsOverrides.get(taskId);
|
|
82
|
+
return s && s.length ? ` | Skills: ${s.join(', ')}` : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
// ─── CRUD ────────────────────────────────────────────────────
|
|
73
86
|
|
|
74
87
|
export function createTask(opts: {
|
|
@@ -538,7 +551,7 @@ function executeTmuxBackendTask(task: Task): Promise<void> {
|
|
|
538
551
|
|
|
539
552
|
updateTaskStatus(task.id, 'running');
|
|
540
553
|
db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
|
|
541
|
-
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux`, timestamp: new Date().toISOString() });
|
|
554
|
+
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
|
|
542
555
|
|
|
543
556
|
return executeTmuxTask(task, {
|
|
544
557
|
appendLog: (entry) => appendLog(task.id, entry),
|
|
@@ -670,7 +683,7 @@ function executeTask(task: Task): Promise<void> {
|
|
|
670
683
|
console.log(`[task] ${task.projectName} [${agentName}${supportsModel && model ? '/' + model : ''}]: "${task.prompt.slice(0, 60)}..."`);
|
|
671
684
|
|
|
672
685
|
// Log agent info as first entry
|
|
673
|
-
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}`, timestamp: new Date().toISOString() });
|
|
686
|
+
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
|
|
674
687
|
|
|
675
688
|
const needsTTY = adapter.config.capabilities?.requiresTTY;
|
|
676
689
|
let child: any;
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -246,6 +246,13 @@ function initSchema(db: Database.Database) {
|
|
|
246
246
|
// Local skills are kept across syncs (they're not in the remote registry,
|
|
247
247
|
// so the deleted_remotely housekeeping would otherwise wipe them).
|
|
248
248
|
migrate("ALTER TABLE skills ADD COLUMN source TEXT NOT NULL DEFAULT 'registry'");
|
|
249
|
+
// Multi-source skill sync (#354): which marketplace source a registry skill
|
|
250
|
+
// came from, so installGlobal knows where (and with what auth) to fetch its
|
|
251
|
+
// files. 'public' = the public forge-skills repo; otherwise an enterprise
|
|
252
|
+
// tenant_id. source_repo is "owner/repo", source_branch the ref.
|
|
253
|
+
migrate("ALTER TABLE skills ADD COLUMN source_id TEXT NOT NULL DEFAULT 'public'");
|
|
254
|
+
migrate("ALTER TABLE skills ADD COLUMN source_repo TEXT NOT NULL DEFAULT ''");
|
|
255
|
+
migrate("ALTER TABLE skills ADD COLUMN source_branch TEXT NOT NULL DEFAULT 'main'");
|
|
249
256
|
migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
|
|
250
257
|
migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
|
|
251
258
|
migrate("ALTER TABLE tasks ADD COLUMN agent TEXT DEFAULT 'claude'");
|