Skip to content

Commit be87385

Browse files
cmsparksclaude
andauthored
feat: Skill creation ux and fixes (#589)
* Skill creation fixes * Address skill creation review findings Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * Address skill creation review findings - Read archives with zip.js and modern-tar instead of hand-rolled parsers, and keep every archive file (only folder picks skip dot-dirs and node_modules) - Find skills only at the top level or one folder down; prune drag-and-drop walks to those folders - Look up existing names with the list name filter, one request per name - Upload a batch in one mutation and invalidate skill lists once - Recheck remaining skills against the server after Back - Count description length in characters on client and server, and share frontmatter cases between the Go and web tests Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * improve skill create --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 6762863 commit be87385

27 files changed

Lines changed: 3251 additions & 431 deletions

‎docs/tools/skills.mdx‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ description: Use when writing release notes for Acme.
3838
Follow the Acme release-note format in templates/example.md.
3939
```
4040

41-
The `name` must match the top-level directory. Write the description as a trigger—“Use when…”—because it helps the model decide when to load the skill.
41+
Write the description as a trigger—“Use when…”—because it helps the model decide when to load the skill.
42+
43+
| Rule | Limit |
44+
|---|---|
45+
| `name` | Required. Lowercase letters and digits in hyphen-separated segments (`release-notes`), up to 64 characters. Must match the archive's top-level directory. |
46+
| `description` | Required, up to 1,024 characters. |
47+
| `SKILL.md` | Must sit directly inside the top-level directory, up to 256 KB. |
48+
| Archive | Up to 25 MB and 1,000 entries. Symlinks are rejected; macOS metadata (`__MACOSX/`, `.DS_Store`, `._*`) is ignored. |
4249

4350
<Tabs>
4451
<Tab title="API">
@@ -51,17 +58,44 @@ The `name` must match the top-level directory. Write the description as a trigge
5158
For a project- or user-owned skill, set the owner accordingly: `--owner-kind project --owner-project-id proj_…` or `--owner-kind user` with the CLI, or `{"kind": "project", "project_id": "proj_…"}` / `{"kind": "user"}` as the `owner`.
5259
</Tab>
5360
<Tab title="Dashboard">
54-
1. Open **Organization Skills**, **User Skills**, or a project's **Project Skills** page. The page determines who owns the skill.
55-
2. Click **Upload skill** and select the archive.
61+
1. Open **Skills** and pick the **User** or **Organization** tab, or open a project's **Project Skills** page. The page determines who owns the skill.
62+
2. Click **Create skill**.
63+
3. Drop files or a folder onto the dialog, or use **Choose files** / **Choose a folder**. You can also switch to the **SKILL.md** tab and write the skill in the editor.
64+
65+
The dashboard accepts a `.zip`, a `.tar.gz`, a loose `SKILL.md`, or a folder, and zips folders in the browser before uploading. A folder or archive holds one skill when `SKILL.md` sits at its top, or several skills when each subfolder has its own `SKILL.md`; deeper `SKILL.md` files are not picked up. Folders skip `node_modules` and dot-directories such as `.git`; archives upload every file. The dashboard names each skill's top-level directory after its frontmatter `name`, so the folder name does not have to match.
5666

57-
Omnara reads the name and description from `SKILL.md`. Uploading the same skill name to the same owner creates a new revision.
67+
Frontmatter problems are flagged before anything uploads. When you upload several skills, reuse an existing name, or include two skills with the same name, a review step lists each skill as new, a new revision (for example `v2 → v3`), or skipped, and uploads only after you confirm. Skills upload one at a time; if some fail, **Retry** re-sends only those.
5868
</Tab>
5969
</Tabs>
6070

6171
The owner (`org`, `project`, or `user`) decides who manages the skill and cannot change. A project can attach only skills it owns or has been granted—even skills owned by its organization.
6272

6373
Uploading again with the same owner and name creates the next revision: the `skl_` ID stays the same and the `skr_` revision ID changes. Attached agents resolve the latest revision at each model call, so they do not need to be relaunched.
6474

75+
## Update a skill
76+
77+
Each update creates the next revision; the skill's name cannot change.
78+
79+
<Tabs>
80+
<Tab title="API">
81+
`POST /skills/{skillID}` takes multipart form data with exactly one of:
82+
83+
- `skill_md` — replacement `SKILL.md` text. Every other file in the current revision is kept.
84+
- `archive` — a new `.zip` or `.tar.gz` that replaces all of the skill's files.
85+
86+
```bash
87+
curl "$OMNARA_API/orgs/$ORG/skills/skl_ik7mv4qtrwz3jehcyd5n6a2bfg" \
88+
-H "Authorization: Bearer $OMNARA_TOKEN" \
89+
-F "skill_md=<release-notes/SKILL.md"
90+
```
91+
92+
The CLI has no update command. Running `omnara skills create` again with the same owner and skill name uploads the next revision.
93+
</Tab>
94+
<Tab title="Dashboard">
95+
Open the skill's row menu and click **Edit**. The **SKILL.md** tab edits the instructions in place and keeps the other files; **Upload archive** replaces every file. Frontmatter errors are marked in the editor, and **Save** stays disabled until they are fixed.
96+
</Tab>
97+
</Tabs>
98+
6599
## List, inspect, delete
66100

67101
Each skills page lists the skills owned at that scope; `omnara skills list` does the same from the terminal. Fetching a skill (`omnara skills get {skill-id}` or the API) returns `skill_md`, the full instruction text of its current revision. To delete an owned skill in the dashboard, open its row menu and click **Delete**, or run `omnara skills delete {skill-id}`. Deletion removes its revisions and grants, but is blocked with `409` while an active agent config references it.
@@ -98,8 +132,10 @@ skills:
98132
- skl_ik7mv4qtrwz3jehcyd5n6a2bfg
99133
```
100134
135+
In the dashboard's agent builder, click **+** in the **Skills** section and search for a skill to attach it. The search list includes **Create skill**, first when nothing matches the search and after the matching skills otherwise. It opens the same upload dialog, creates the skills in the project, and attaches them. A created skill replaces an attached skill with the same name.
136+
101137
Attaching a skill automatically enables the built-in `skill` tool. The model sees each skill's name and description; when it calls the tool, Omnara returns the `SKILL.md` instructions and attempts to install the supporting files on each attached machine. The instructions still work when no machine is attached or an installation fails.
102138

103139
<Info>
104-
Full schema and playground: [Create skill](/api-reference/endpoints/skills/create-skill) · [List skills](/api-reference/endpoints/skills/list-skills-visible-through-ownership-authority) · [Skill grants](/api-reference/endpoints/skills/create-skill-grant) · [Project skills](/api-reference/endpoints/skills/list-skills-available-to-project).
140+
Full schema and playground: [Create skill](/api-reference/endpoints/skills/create-skill) · [Update skill](/api-reference/endpoints/skills/update-skill-by-uploading-a-new-revision) · [List skills](/api-reference/endpoints/skills/list-skills-visible-through-ownership-authority) · [Skill grants](/api-reference/endpoints/skills/create-skill-grant) · [Project skills](/api-reference/endpoints/skills/list-skills-available-to-project).
105141
</Info>

‎frontend/apps/web/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@
2626
"@tanstack/react-form": "^1.33.0",
2727
"@tanstack/react-query": "^5.59.0",
2828
"@tanstack/react-router": "^1.69.0",
29+
"@zip.js/zip.js": "^2.15.0",
2930
"class-variance-authority": "^0.7.0",
3031
"clsx": "^2.1.1",
3132
"cronstrue": "^3.24.0",
3233
"date-fns": "^4.4.0",
3334
"lucide-react": "^0.460.0",
35+
"modern-tar": "^0.8.5",
3436
"monaco-editor": "^0.52.2",
3537
"monaco-yaml": "^5.5.1",
3638
"radix-ui": "^1.6.1",

‎frontend/apps/web/src/components/agents/AgentConfigMachineSourceComboboxes.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { useMachinePool, useProjectMachinePoolGrants, useProjectMachines } from '@omnara/react'
1+
import {
2+
exactNameGlob,
3+
useMachinePool,
4+
useProjectMachinePoolGrants,
5+
useProjectMachines,
6+
} from '@omnara/react'
27
import type { MachinePoolSummary, ProjectMachinePoolGrant } from '@omnara/sdk'
38
import { type ReactNode, useEffect, useState } from 'react'
49

@@ -8,7 +13,7 @@ import { GrantProjectMachineDialog } from '@/components/projects/GrantProjectMac
813
import { createResourceCombobox } from '@/components/ui/resource-combobox'
914
import { useCompleteInfiniteQueryItems } from '@/hooks/use-complete-infinite-query-items'
1015
import { useInfiniteQueryItems } from '@/hooks/use-infinite-query-items'
11-
import { exactNameGlob, useTypeaheadSearch } from '@/hooks/use-resource-list'
16+
import { useTypeaheadSearch } from '@/hooks/use-resource-list'
1217
import { formatMemoryGb } from '@/lib/machine-memory'
1318
import { useProjectPage } from '@/lib/use-project-page'
1419
import { cn } from '@/lib/utils'

‎frontend/apps/web/src/components/agents/AgentConfigModelField.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useClusterModelPricing, useProjectModelGrants } from '@omnara/react'
1+
import { exactNameGlob, useClusterModelPricing, useProjectModelGrants } from '@omnara/react'
22
import type { ConfiguredModelSummary, DiscoveredModelPricing } from '@omnara/sdk'
33
import { useEffect, useRef, useState } from 'react'
44

@@ -12,7 +12,7 @@ import { ResourceNameFieldError } from '@/components/ui/resource-name-error'
1212
import { Skeleton } from '@/components/ui/skeleton'
1313
import { useCompleteInfiniteQueryItems } from '@/hooks/use-complete-infinite-query-items'
1414
import { useInfiniteQueryItems } from '@/hooks/use-infinite-query-items'
15-
import { exactNameGlob, useTypeaheadSearch } from '@/hooks/use-resource-list'
15+
import { useTypeaheadSearch } from '@/hooks/use-resource-list'
1616
import { useProjectPage } from '@/lib/use-project-page'
1717

1818
interface ModelChoice extends ConfiguredModelSummary {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/** @vitest-environment happy-dom */
2+
3+
import { OmnaraClientProvider } from '@omnara/react'
4+
import { createOmnaraClient, type Skill } from '@omnara/sdk'
5+
import { listProjectAvailableSkillsInfiniteQueryKey } from '@omnara/sdk/tanstack'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { act } from 'react'
8+
import { createRoot, type Root } from 'react-dom/client'
9+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
10+
11+
import { AgentConfigSkillSearchbox } from '@/components/agents/AgentConfigSkillSearchbox'
12+
import { fakeId } from '@/test/fixtures'
13+
import { enableReactActEnvironment } from '@/test/react-act'
14+
15+
const orgId = fakeId('org')
16+
const projectId = fakeId('proj')
17+
18+
function skill(name: string, id: string): Skill {
19+
return {
20+
id,
21+
org_id: orgId,
22+
owner: { kind: 'project', project_id: projectId },
23+
name,
24+
revision_id: fakeId('skr'),
25+
revision: 1,
26+
description: `Does ${name} things.`,
27+
created_at: '2026-09-28T00:00:00Z',
28+
updated_at: '2026-09-28T00:00:00Z',
29+
}
30+
}
31+
32+
const skills = [
33+
skill('attached', `skl_${'a'.repeat(26)}`),
34+
skill('pdf-tools', `skl_${'b'.repeat(26)}`),
35+
skill('slides', `skl_${'c'.repeat(26)}`),
36+
]
37+
38+
let root: Root
39+
let container: HTMLDivElement
40+
let restore: () => void
41+
beforeEach(() => {
42+
restore = enableReactActEnvironment()
43+
container = document.createElement('div')
44+
document.body.append(container)
45+
root = createRoot(container)
46+
})
47+
afterEach(() => {
48+
act(() => {
49+
root.unmount()
50+
})
51+
container.remove()
52+
restore()
53+
})
54+
55+
async function render(canCreate = true, expanded = true) {
56+
const client = createOmnaraClient({ baseUrl: 'https://omnara.test/api/v1' })
57+
const queryClient = new QueryClient({
58+
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
59+
})
60+
const path = { orgID: orgId, projectID: projectId }
61+
queryClient.setQueryData(
62+
listProjectAvailableSkillsInfiniteQueryKey({
63+
path,
64+
query: { sort: 'name', limit: 25 },
65+
client,
66+
}),
67+
{
68+
pages: [
69+
{
70+
data: skills.map((item) => ({
71+
skill: item,
72+
project_id: projectId,
73+
availability: { source: 'direct' },
74+
})),
75+
next_cursor: null,
76+
},
77+
],
78+
pageParams: [{ path }],
79+
},
80+
)
81+
const onSelect = vi.fn()
82+
const onCreateSkill = vi.fn()
83+
await act(async () => {
84+
root.render(
85+
<OmnaraClientProvider client={client}>
86+
<QueryClientProvider client={queryClient}>
87+
<AgentConfigSkillSearchbox
88+
orgId={orgId}
89+
projectId={projectId}
90+
excludedIds={new Set([`skl_${'a'.repeat(26)}`])}
91+
excludedNames={new Set()}
92+
active
93+
expanded={expanded}
94+
onSelect={onSelect}
95+
onCreateSkill={canCreate ? onCreateSkill : undefined}
96+
/>
97+
</QueryClientProvider>
98+
</OmnaraClientProvider>,
99+
)
100+
await Promise.resolve()
101+
})
102+
return { onSelect, onCreateSkill }
103+
}
104+
105+
function options() {
106+
return [...document.querySelectorAll('[role="option"]')]
107+
}
108+
109+
function optionNames() {
110+
return options().map((option) => option.querySelector('.font-medium')?.textContent)
111+
}
112+
113+
async function type(value: string) {
114+
await act(async () => {
115+
const input = document.querySelector('input')
116+
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
117+
descriptor?.set?.call(input, value)
118+
input?.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
119+
await Promise.resolve()
120+
})
121+
}
122+
123+
it('opens focused with Create skill ahead of the unattached skills', async () => {
124+
await render()
125+
expect(document.activeElement).toBe(document.querySelector('input[aria-label="Search skills…"]'))
126+
expect(optionNames()).toEqual(['Create skill', 'pdf-tools', 'slides'])
127+
})
128+
129+
it('moves Create skill below matching skills so Enter attaches the match', async () => {
130+
const ctx = await render()
131+
await type('sli')
132+
expect(optionNames()).toEqual(['slides', 'Create skill'])
133+
expect(options()[0]?.hasAttribute('data-highlighted')).toBe(true)
134+
await act(async () => {
135+
document
136+
.querySelector('input')
137+
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
138+
await Promise.resolve()
139+
})
140+
expect(ctx.onSelect).toHaveBeenCalledWith(skills[2])
141+
expect(ctx.onCreateSkill).not.toHaveBeenCalled()
142+
})
143+
144+
it('keeps Create skill first when nothing matches and opens the create flow', async () => {
145+
const ctx = await render()
146+
await type('zzz')
147+
expect(optionNames()).toEqual(['Create skill'])
148+
act(() => {
149+
options()[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
150+
})
151+
expect(ctx.onCreateSkill).toHaveBeenCalledOnce()
152+
expect(ctx.onSelect).not.toHaveBeenCalled()
153+
})
154+
155+
it('selects a skill from the list', async () => {
156+
const ctx = await render()
157+
act(() => {
158+
options()[2]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
159+
})
160+
expect(ctx.onSelect).toHaveBeenCalledWith(skills[2])
161+
})
162+
163+
it('keeps Enter inside the search box when nothing is highlighted', async () => {
164+
const ctx = await render(false)
165+
await type('zzz')
166+
expect(options()).toEqual([])
167+
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
168+
act(() => {
169+
document.querySelector('input')?.dispatchEvent(enter)
170+
})
171+
expect(enter.defaultPrevented).toBe(true)
172+
expect(ctx.onSelect).not.toHaveBeenCalled()
173+
})
174+
175+
it('omits Create skill without create permission', async () => {
176+
await render(false)
177+
expect(optionNames()).toEqual(['pdf-tools', 'slides'])
178+
})
179+
180+
it('keeps the list closed until the search box has expanded', async () => {
181+
await render(true, false)
182+
expect(document.activeElement).toBe(document.querySelector('input[aria-label="Search skills…"]'))
183+
expect(options()).toEqual([])
184+
})

0 commit comments

Comments
 (0)