@cat-factory/app 0.123.1 → 0.124.0

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.
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ // Account-tier repo-sourced Claude Skills library (docs/initiatives/repo-skills.md): a team
3
+ // authors skills in a repo (`<skill>/SKILL.md` + resources), the account syncs them into a
4
+ // catalog shared across its workspaces, and a pipeline `skill` step runs one. A body-only
5
+ // section rendered in the "Skills" tab of AccountSettingsPanel; available for ALL account types.
6
+ import SkillLibraryManager from '~/components/skills/SkillLibraryManager.vue'
7
+
8
+ const props = defineProps<{ accountId: string }>()
9
+ const { t } = useI18n()
10
+ </script>
11
+
12
+ <template>
13
+ <div class="space-y-6 text-sm">
14
+ <section>
15
+ <p class="mb-3 text-[11px] text-slate-400">
16
+ {{ t('layout.accountSkills.intro') }}
17
+ </p>
18
+ <SkillLibraryManager :account-id="props.accountId" />
19
+ </section>
20
+ </div>
21
+ </template>
@@ -42,6 +42,26 @@ function toggleGating(i: number) {
42
42
  const agents = useAgentsStore()
43
43
  const ui = useUiStore()
44
44
  const releaseHealth = useReleaseHealthStore()
45
+ const skills = useSkillsStore()
46
+
47
+ // The account's skill catalog (from the workspace snapshot) as USelect items for the per-step
48
+ // skill picker. A `skill` step is parametrized by the picked skill (`stepOptions.skillId`).
49
+ const skillSelectItems = computed(() => skills.catalog.map((s) => ({ label: s.name, value: s.id })))
50
+
51
+ // An enabled `skill` step with no picked skill — mirrors the backend save/start rejection
52
+ // (`assertValidSkillSteps`), surfaced as an inline hint so the user fixes it before saving.
53
+ const skillStepNeedsPick = computed(() =>
54
+ pipelines.draft.some(
55
+ (k, i) => k === 'skill' && pipelines.draftEnabled[i] !== false && !pipelines.draftSkillId(i),
56
+ ),
57
+ )
58
+
59
+ // A step's picked skill id is no longer in the account catalog (the source dir was renamed or
60
+ // unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
61
+ function skillMissing(index: number): boolean {
62
+ const id = pipelines.draftSkillId(index)
63
+ return !!id && !skills.catalog.some((s) => s.id === id)
64
+ }
45
65
 
46
66
  const open = computed({
47
67
  get: () => ui.builderOpen,
@@ -322,6 +342,14 @@ async function clone(p: Pipeline) {
322
342
  {{ t('pipeline.builder.gatingNeedsEstimator') }}
323
343
  </p>
324
344
 
345
+ <p
346
+ v-if="skillStepNeedsPick"
347
+ class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
348
+ >
349
+ <UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
350
+ {{ t('pipeline.builder.skillNeedsPick') }}
351
+ </p>
352
+
325
353
  <div
326
354
  v-if="pipelines.draft.length === 0"
327
355
  class="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500"
@@ -517,6 +545,26 @@ async function clone(p: Pipeline) {
517
545
  </div>
518
546
  </div>
519
547
 
548
+ <!-- Skill picker: the one generic `skill` kind is parametrized per step by the
549
+ picked account skill (its `stepOptions.skillId`). Bind directly to the store. -->
550
+ <div v-if="unit.kind === 'skill'" class="ms-6 flex flex-col gap-1">
551
+ <USelect
552
+ :model-value="pipelines.draftSkillId(unit.index)"
553
+ :items="skillSelectItems"
554
+ value-key="value"
555
+ size="xs"
556
+ :placeholder="t('pipeline.builder.skillPlaceholder')"
557
+ :disabled="!skillSelectItems.length"
558
+ @update:model-value="pipelines.setDraftSkillId(unit.index, $event)"
559
+ />
560
+ <p v-if="!skillSelectItems.length" class="text-[10px] text-slate-500">
561
+ {{ t('pipeline.builder.skillNoneAvailable') }}
562
+ </p>
563
+ <p v-else-if="skillMissing(unit.index)" class="text-[10px] text-amber-400">
564
+ {{ t('pipeline.builder.skillMissing') }}
565
+ </p>
566
+ </div>
567
+
520
568
  <!-- Attached companion: a dependent reviewer for this producer, optionally
521
569
  gated on the task estimate. -->
522
570
  <div
@@ -7,6 +7,7 @@
7
7
  // bar; bound to the `ui` store so any surface can open it and deep-link to a tab.
8
8
  import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
9
9
  import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
10
+ import AccountSkillSettings from '~/components/layout/AccountSkillSettings.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -32,6 +33,12 @@ const tabs = computed(() => [
32
33
  icon: 'i-lucide-book-marked',
33
34
  slot: 'fragments',
34
35
  },
36
+ {
37
+ value: 'skills',
38
+ label: t('settings.account.tabs.skills'),
39
+ icon: 'i-lucide-book-open-check',
40
+ slot: 'skills',
41
+ },
35
42
  ])
36
43
  </script>
37
44
 
@@ -54,6 +61,14 @@ const tabs = computed(() => [
54
61
  <template #fragments>
55
62
  <AccountFragmentSettings :account-id="accounts.activeAccountId" />
56
63
  </template>
64
+ <template #skills>
65
+ <!-- Key on the account so a mid-modal account switch remounts against a fresh
66
+ account-keyed skill-library store rather than the stale initial one. -->
67
+ <AccountSkillSettings
68
+ :key="accounts.activeAccountId ?? undefined"
69
+ :account-id="accounts.activeAccountId"
70
+ />
71
+ </template>
57
72
  </UTabs>
58
73
  </template>
59
74
  </UModal>
@@ -0,0 +1,337 @@
1
+ <script setup lang="ts">
2
+ // Repo-sourced Claude Skills library manager (docs/initiatives/repo-skills.md), for the account
3
+ // tier (skills are a single tier — shared across the account's workspaces). Link repo directories
4
+ // of `<skill>/SKILL.md` folders, resync them (with a "changes available" badge), and review the
5
+ // synced skill catalog a pipeline `skill` step picks from. Mirrors the fragment library's
6
+ // repo-sources UI; when the GitHub App is connected the user searches a repo + browses to the
7
+ // skills directory, otherwise the manual owner/name/dir fields are the fallback.
8
+ import { computed, reactive, ref, watch } from 'vue'
9
+ import type { GitHubAvailableRepo } from '~/types/domain'
10
+ import { useSkillLibrary } from '~/stores/skillLibrary'
11
+ import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
12
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
13
+
14
+ const props = defineProps<{ accountId: string }>()
15
+
16
+ const library = useSkillLibrary(props.accountId)
17
+ const github = useGitHubStore()
18
+ const toast = useToast()
19
+ const { t, d } = useI18n()
20
+ const { confirm } = useConfirm()
21
+
22
+ watch(
23
+ () => props.accountId,
24
+ () => {
25
+ void library.probe()
26
+ // The GitHub pickers need the active board's installation state; probe once so they light up.
27
+ void github.probe()
28
+ },
29
+ { immediate: true },
30
+ )
31
+
32
+ // The rich GitHub pickers reuse the active board's App installation. When it isn't connected (or
33
+ // the integration is off) the form falls back to manual text entry.
34
+ const githubReady = computed(() => github.available === true && github.connected)
35
+
36
+ function notifyError(title: string, e: unknown) {
37
+ toast.add({
38
+ title,
39
+ description: e instanceof Error ? e.message : String(e),
40
+ icon: 'i-lucide-triangle-alert',
41
+ color: 'error',
42
+ })
43
+ }
44
+
45
+ // Per-row in-flight tracking so only the control that triggered an action spins.
46
+ const busyRows = reactive(new Set<string>())
47
+ const rowBusy = (key: string) => busyRows.has(key)
48
+ async function withRow(key: string, fn: () => Promise<void>) {
49
+ if (busyRows.has(key)) return
50
+ busyRows.add(key)
51
+ try {
52
+ await fn()
53
+ } finally {
54
+ busyRows.delete(key)
55
+ }
56
+ }
57
+
58
+ // ---- link a repo source ----------------------------------------------------
59
+ const sourceRepoId = ref<number | undefined>(undefined)
60
+ const sourceRepo = ref<GitHubAvailableRepo | undefined>(undefined)
61
+ const sourceDir = ref<string | undefined>(undefined)
62
+ const sourceRef = ref('')
63
+ const manualSource = ref({ repoOwner: '', repoName: '', dirPath: '' })
64
+ const linkingSource = ref(false)
65
+
66
+ // A new repo selection clears the previously-browsed directory.
67
+ watch(sourceRepoId, () => {
68
+ sourceDir.value = undefined
69
+ })
70
+
71
+ const sourceOwnerName = computed<{ owner: string; name: string } | null>(() => {
72
+ if (githubReady.value) {
73
+ return sourceRepo.value ? { owner: sourceRepo.value.owner, name: sourceRepo.value.name } : null
74
+ }
75
+ const owner = manualSource.value.repoOwner.trim()
76
+ const name = manualSource.value.repoName.trim()
77
+ return owner && name ? { owner, name } : null
78
+ })
79
+ const sourceValid = computed(() => sourceOwnerName.value !== null)
80
+
81
+ function resetSourceDraft() {
82
+ sourceRepoId.value = undefined
83
+ sourceRepo.value = undefined
84
+ sourceDir.value = undefined
85
+ sourceRef.value = ''
86
+ manualSource.value = { repoOwner: '', repoName: '', dirPath: '' }
87
+ }
88
+
89
+ async function linkSource() {
90
+ const ownerName = sourceOwnerName.value
91
+ if (!ownerName) return
92
+ const dirPath =
93
+ (githubReady.value ? sourceDir.value : manualSource.value.dirPath.trim()) || undefined
94
+ linkingSource.value = true
95
+ try {
96
+ await library.linkSource({
97
+ repoOwner: ownerName.owner,
98
+ repoName: ownerName.name,
99
+ dirPath,
100
+ gitRef: sourceRef.value.trim() || undefined,
101
+ })
102
+ resetSourceDraft()
103
+ toast.add({ title: t('skills.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
104
+ } catch (e) {
105
+ notifyError(t('skills.toast.linkSourceFailed'), e)
106
+ } finally {
107
+ linkingSource.value = false
108
+ }
109
+ }
110
+
111
+ async function syncSource(id: string) {
112
+ await withRow(`sync:${id}`, async () => {
113
+ try {
114
+ const result = await library.syncSource(id)
115
+ toast.add({
116
+ title: t('skills.toast.synced', {
117
+ updated: result.upserted,
118
+ removed: result.tombstoned,
119
+ }),
120
+ icon: 'i-lucide-refresh-cw',
121
+ color: 'info',
122
+ })
123
+ } catch (e) {
124
+ notifyError(t('skills.toast.syncFailed'), e)
125
+ }
126
+ })
127
+ }
128
+
129
+ async function checkSource(id: string) {
130
+ await withRow(`check:${id}`, async () => {
131
+ try {
132
+ const status = await library.checkSource(id)
133
+ toast.add({
134
+ title: status.changed ? t('skills.toast.changesAvailable') : t('skills.toast.upToDate'),
135
+ icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
136
+ })
137
+ } catch (e) {
138
+ notifyError(t('skills.toast.checkSourceFailed'), e)
139
+ }
140
+ })
141
+ }
142
+
143
+ async function unlinkSource(id: string) {
144
+ const source = library.sources.find((s) => s.id === id)
145
+ const repo = source ? `${source.repoOwner}/${source.repoName}` : ''
146
+ const ok = await confirm({
147
+ title: t('skills.confirmUnlinkSource.title'),
148
+ description: t('skills.confirmUnlinkSource.body', { repo }),
149
+ variant: 'destructive',
150
+ confirmLabel: t('skills.confirmUnlinkSource.confirm'),
151
+ icon: 'i-lucide-unplug',
152
+ })
153
+ if (!ok) return
154
+ await withRow(`unlink:${id}`, async () => {
155
+ try {
156
+ await library.unlinkSource(id)
157
+ toast.add({ title: t('skills.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
158
+ } catch (e) {
159
+ notifyError(t('skills.toast.unlinkSourceFailed'), e)
160
+ }
161
+ })
162
+ }
163
+ </script>
164
+
165
+ <template>
166
+ <div class="flex flex-col gap-4">
167
+ <!-- The library is opt-in; if a deployment disabled it, don't offer forms that would fail
168
+ with a raw 503 — say so instead. -->
169
+ <div
170
+ v-if="library.available === false"
171
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-400"
172
+ >
173
+ {{ t('skills.unavailable') }}
174
+ </div>
175
+
176
+ <template v-else>
177
+ <!-- Synced skill catalog -->
178
+ <div class="flex flex-col gap-2">
179
+ <p class="text-sm font-medium">{{ t('skills.catalog.title') }}</p>
180
+ <div
181
+ v-for="s in library.catalog"
182
+ :key="s.id"
183
+ class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
184
+ >
185
+ <UIcon name="i-lucide-book-open-check" class="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
186
+ <div class="min-w-0 flex-1">
187
+ <p class="truncate text-sm font-medium text-slate-100">{{ s.name }}</p>
188
+ <p class="text-xs text-slate-400">{{ s.description }}</p>
189
+ <p class="mt-1 flex flex-wrap gap-x-3 text-[11px] text-slate-500">
190
+ <span v-if="s.resources.length">
191
+ {{ t('skills.catalog.resources', { count: s.resources.length }) }}
192
+ </span>
193
+ <span v-if="s.pinnedCommit" class="font-mono">
194
+ {{ t('skills.catalog.pinned', { commit: s.pinnedCommit.slice(0, 7) }) }}
195
+ </span>
196
+ </p>
197
+ </div>
198
+ </div>
199
+ <p v-if="!library.catalog.length" class="text-sm text-slate-500">
200
+ {{ t('skills.catalog.empty') }}
201
+ </p>
202
+ </div>
203
+
204
+ <!-- Repo sources -->
205
+ <div class="flex flex-col gap-3">
206
+ <p class="text-sm font-medium">{{ t('skills.sources.title') }}</p>
207
+ <div
208
+ v-for="s in library.sources"
209
+ :key="s.id"
210
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
211
+ >
212
+ <UIcon name="i-lucide-git-branch" class="h-4 w-4 text-slate-400" />
213
+ <div class="min-w-0">
214
+ <span class="font-mono text-sm text-slate-100">
215
+ {{ s.repoOwner }}/{{ s.repoName
216
+ }}<span class="text-slate-500">/{{ s.dirPath || '' }}</span>
217
+ </span>
218
+ <p class="text-xs text-slate-500">
219
+ {{
220
+ s.lastSyncedAt
221
+ ? t('skills.sources.metaSynced', {
222
+ ref: s.gitRef,
223
+ date: d(new Date(s.lastSyncedAt), 'short'),
224
+ })
225
+ : t('skills.sources.metaNever', { ref: s.gitRef })
226
+ }}
227
+ </p>
228
+ </div>
229
+ <UBadge
230
+ v-if="library.sourceChanges[s.id]"
231
+ size="xs"
232
+ color="warning"
233
+ variant="subtle"
234
+ class="ms-auto"
235
+ >
236
+ {{ t('skills.sources.changes') }}
237
+ </UBadge>
238
+ <div class="ms-auto flex gap-1">
239
+ <UButton
240
+ icon="i-lucide-search-check"
241
+ size="xs"
242
+ variant="ghost"
243
+ :loading="rowBusy(`check:${s.id}`)"
244
+ :title="t('skills.sources.check')"
245
+ @click="checkSource(s.id)"
246
+ />
247
+ <UButton
248
+ icon="i-lucide-refresh-cw"
249
+ size="xs"
250
+ variant="ghost"
251
+ :loading="rowBusy(`sync:${s.id}`)"
252
+ :title="t('skills.sources.sync')"
253
+ @click="syncSource(s.id)"
254
+ />
255
+ <UButton
256
+ icon="i-lucide-unplug"
257
+ size="xs"
258
+ color="error"
259
+ variant="ghost"
260
+ :loading="rowBusy(`unlink:${s.id}`)"
261
+ :title="t('skills.sources.unlink')"
262
+ @click="unlinkSource(s.id)"
263
+ />
264
+ </div>
265
+ </div>
266
+ <p v-if="!library.sources.length" class="text-sm text-slate-500">
267
+ {{ t('skills.sources.empty') }}
268
+ </p>
269
+
270
+ <!-- Link a new source. Needs the GitHub integration; hide the form when it's off. -->
271
+ <div
272
+ v-if="!library.sourcesAvailable"
273
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-500"
274
+ >
275
+ {{ t('skills.sources.githubRequired') }}
276
+ </div>
277
+ <div v-else class="rounded-md border border-slate-800 p-3">
278
+ <p class="mb-2 text-sm font-medium">{{ t('skills.sources.linkTitle') }}</p>
279
+ <div class="flex flex-col gap-2">
280
+ <!-- Connected: search a repo + browse to the skills directory -->
281
+ <template v-if="githubReady">
282
+ <GitHubRepoSearchSelect v-model="sourceRepoId" @update:repo="sourceRepo = $event" />
283
+ <div
284
+ v-if="sourceRepoId !== undefined"
285
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
286
+ >
287
+ <p class="mb-2 text-xs text-slate-400">
288
+ {{ t('skills.sources.browseHint') }}
289
+ </p>
290
+ <RepoTreeBrowser v-model="sourceDir" :repo-github-id="sourceRepoId" mode="dir" />
291
+ <p class="mt-2 truncate text-xs text-slate-400">
292
+ <template v-if="sourceDir">
293
+ {{ t('skills.sources.selectedDir') }}
294
+ <code class="text-slate-200">{{ sourceDir }}</code>
295
+ </template>
296
+ <template v-else>{{ t('skills.sources.wholeRepo') }}</template>
297
+ </p>
298
+ </div>
299
+ </template>
300
+
301
+ <!-- Not connected to the App: manual owner/name/dir fallback -->
302
+ <template v-else>
303
+ <div class="flex gap-2">
304
+ <UInput
305
+ v-model="manualSource.repoOwner"
306
+ :placeholder="t('skills.sources.ownerPlaceholder')"
307
+ class="flex-1"
308
+ />
309
+ <UInput
310
+ v-model="manualSource.repoName"
311
+ :placeholder="t('skills.sources.repoPlaceholder')"
312
+ class="flex-1"
313
+ />
314
+ </div>
315
+ <UInput
316
+ v-model="manualSource.dirPath"
317
+ :placeholder="t('skills.sources.dirPlaceholder')"
318
+ />
319
+ </template>
320
+
321
+ <UInput v-model="sourceRef" :placeholder="t('skills.sources.refPlaceholder')" />
322
+ <UButton
323
+ icon="i-lucide-link"
324
+ size="sm"
325
+ :disabled="!sourceValid"
326
+ :loading="linkingSource"
327
+ class="self-start"
328
+ @click="linkSource"
329
+ >
330
+ {{ t('skills.sources.link') }}
331
+ </UButton>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ </template>
336
+ </div>
337
+ </template>
@@ -35,6 +35,8 @@ export interface ApiContext {
35
35
  sendWith: ApiSendWith
36
36
  /** `/workspaces/:id` path prefix (id encoded). */
37
37
  ws: (workspaceId: string) => string
38
+ /** `/accounts/:id` path prefix (id encoded) — account-scoped routes (e.g. the skill library). */
39
+ acct: (accountId: string) => string
38
40
  /** Prompt-fragment library prefix, resolved from the owner scope (ADR 0006 §8). */
39
41
  scope: (kind: FragmentOwnerKind, id: string) => string
40
42
  /** The ambient personal-unlock password header (individual-usage vendors). */
@@ -0,0 +1,48 @@
1
+ import {
2
+ linkSkillSourceContract,
3
+ listAccountSkillsContract,
4
+ listSkillSourcesContract,
5
+ skillSourceStatusContract,
6
+ syncSkillSourceContract,
7
+ unlinkSkillSourceContract,
8
+ } from '@cat-factory/contracts'
9
+ import type { LinkSkillSourceInput } from '~/types/domain'
10
+ import type { ApiContext } from './context'
11
+
12
+ /**
13
+ * The repo-sourced Claude Skills library (docs/initiatives/repo-skills.md). Skills live in ONE
14
+ * tier — the account, shared across its workspaces — so every route is account-scoped
15
+ * (`/accounts/:accountId/...`), unlike the two-tier fragment library.
16
+ */
17
+ export function skillsApi({ send, acct }: ApiContext) {
18
+ return {
19
+ // ---- the account skill catalog (raw) ----------------------------------
20
+ listAccountSkills: (accountId: string) =>
21
+ send(listAccountSkillsContract, { pathPrefix: acct(accountId) }),
22
+
23
+ // ---- repo sources -----------------------------------------------------
24
+ listSkillSources: (accountId: string) =>
25
+ send(listSkillSourcesContract, { pathPrefix: acct(accountId) }),
26
+
27
+ linkSkillSource: (accountId: string, body: LinkSkillSourceInput) =>
28
+ send(linkSkillSourceContract, { pathPrefix: acct(accountId), body }),
29
+
30
+ unlinkSkillSource: (accountId: string, sourceId: string) =>
31
+ send(unlinkSkillSourceContract, {
32
+ pathPrefix: acct(accountId),
33
+ pathParams: { id: sourceId },
34
+ }),
35
+
36
+ skillSourceStatus: (accountId: string, sourceId: string) =>
37
+ send(skillSourceStatusContract, {
38
+ pathPrefix: acct(accountId),
39
+ pathParams: { id: sourceId },
40
+ }),
41
+
42
+ syncSkillSource: (accountId: string, sourceId: string) =>
43
+ send(syncSkillSourceContract, {
44
+ pathPrefix: acct(accountId),
45
+ pathParams: { id: sourceId },
46
+ }),
47
+ }
48
+ }
@@ -12,6 +12,7 @@ import { followUpsApi } from './api/followUps'
12
12
  import { forkDecisionApi } from './api/forkDecision'
13
13
  import { prReviewApi } from './api/prReview'
14
14
  import { fragmentsApi } from './api/fragments'
15
+ import { skillsApi } from './api/skills'
15
16
  import { githubApi } from './api/github'
16
17
  import { humanReviewApi } from './api/humanReview'
17
18
  import { humanTestApi } from './api/humanTest'
@@ -84,6 +85,7 @@ export function useApi() {
84
85
  password ? { 'X-Personal-Password': password } : undefined
85
86
 
86
87
  const ws = (workspaceId: string) => `/workspaces/${encodeURIComponent(workspaceId)}`
88
+ const acct = (accountId: string) => `/accounts/${encodeURIComponent(accountId)}`
87
89
  // Prompt-fragment library routes exist at both tiers; resolve the prefix from
88
90
  // the owner scope (ADR 0006 §8).
89
91
  const scope = (kind: FragmentOwnerKind, id: string) =>
@@ -98,11 +100,12 @@ export function useApi() {
98
100
  const send = createSend(client)
99
101
  const sendWith = createSendWith(client)
100
102
 
101
- const ctx: ApiContext = { http, client, send, sendWith, ws, scope, pwHeaders }
103
+ const ctx: ApiContext = { http, client, send, sendWith, ws, acct, scope, pwHeaders }
102
104
 
103
105
  return {
104
106
  ...authApi(ctx),
105
107
  ...fragmentsApi(ctx),
108
+ ...skillsApi(ctx),
106
109
  ...modelsApi(ctx),
107
110
  ...accountsApi(ctx),
108
111
  ...platformObservabilityApi(ctx),
@@ -293,6 +293,23 @@ export const usePipelinesStore = defineStore('pipelines', () => {
293
293
  draftStepOptions.value[index] = Object.keys(next).length ? next : null
294
294
  }
295
295
 
296
+ /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
297
+ function draftSkillId(index: number): string | undefined {
298
+ return draftStepOptions.value[index]?.skillId
299
+ }
300
+
301
+ /**
302
+ * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
303
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
304
+ * bag empties, the whole entry (so it normalizes away like the other options).
305
+ */
306
+ function setDraftSkillId(index: number, skillId: string | undefined) {
307
+ const next: StepOptions = { ...draftStepOptions.value[index] }
308
+ if (skillId) next.skillId = skillId
309
+ else delete next.skillId
310
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
311
+ }
312
+
296
313
  function clearDraft() {
297
314
  draft.value = []
298
315
  draftGates.value = []
@@ -452,6 +469,8 @@ export const usePipelinesStore = defineStore('pipelines', () => {
452
469
  toggleDraftTesterQualityGating,
453
470
  draftAutoRecommendEnabled,
454
471
  toggleDraftAutoRecommend,
472
+ draftSkillId,
473
+ setDraftSkillId,
455
474
  toggleDraftEnabled,
456
475
  toggleDraftConsensus,
457
476
  setDraftConsensus,