refine skills a bit

This commit is contained in:
Jon Staab
2026-06-10 14:31:27 -07:00
parent dbd043f105
commit 8f8c90fbc3
4 changed files with 313 additions and 180 deletions
+12
View File
@@ -11,3 +11,15 @@ If you're developing an application which requires changes to welshman, you'll n
- Within your application directory, link all welshman dependencies _simultaneously_ (or else only one will get linked. A command that does this is: `rm -rf node_modules; npm i; cat package.json|js '.dependencies|keys[]'|grep welshman|xargs npm link`. - Within your application directory, link all welshman dependencies _simultaneously_ (or else only one will get linked. A command that does this is: `rm -rf node_modules; npm i; cat package.json|js '.dependencies|keys[]'|grep welshman|xargs npm link`.
If you run `npm install` in your application directory, you'll need to repeat the final step above. Finally, if you're using the `editor` module, you may run into some dependency version conflicts. I recommend editing the command above to exclude the editor. If you run `npm install` in your application directory, you'll need to repeat the final step above. Finally, if you're using the `editor` module, you may run into some dependency version conflicts. I recommend editing the command above to exclude the editor.
# Agent Skills
Welshman ships with agent skills for AI coding tools. Each skill covers a specific package and is auto-loaded when relevant to your current task.
```bash
npx skills add coracle-social/welshman
```
This will prompt you to select which skills to install and which agent tool to install them for. Use `-g` to install globally across all your projects instead of just the current one.
See `skills/README.md` for the full list and details on how they activate.
+1 -32
View File
@@ -11,34 +11,7 @@ Agent skills are Markdown instruction sets that are auto-loaded by AI coding too
### Via npx (recommended) ### Via npx (recommended)
```bash ```bash
npx skills add jstaab/welshman npx skills add coracle-social/welshman
```
This will prompt you to:
1. Select which skills to install from the available list
2. Select which agent(s) to install them for (Claude Code, Cursor, Cline, etc.)
### Manual (Claude Code)
Copy or symlink the skill files into your `.claude/skills/` directory:
```bash
# Copy a skill
cp welshman-net.md ~/.claude/skills/
# Or symlink the entire collection
ln -s /path/to/welshman/skills ~/.claude/skills/welshman
```
### Scope options
By default, skills are installed project-scoped — placed in the project's `.claude/skills/` directory and committed to the repo so your whole team benefits.
Use the `-g` flag to install globally instead, making the skills available across all your projects:
```bash
npx skills add -g jstaab/welshman
``` ```
## Available skills ## Available skills
@@ -70,7 +43,3 @@ You can also invoke a skill manually using its slash command:
``` ```
This is useful when you want to prime the agent with a specific skill before starting a task. This is useful when you want to prime the agent with a specific skill before starting a task.
## Contributing
The source of truth for these skills is the [welshman repository](https://github.com/coracle-social/welshman). The `docs/` directory contains the underlying documentation that skills are derived from. If you find inaccuracies or want to improve coverage, please open an issue or pull request there.
+99
View File
@@ -521,6 +521,105 @@ abort.abort()
--- ---
## Using Welshman Stores Outside Svelte
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
### React
```typescript
import {useState, useEffect} from 'react'
import type {Readable, Writable} from 'svelte/store'
// Returns the current store value; re-renders when it changes.
function useReadable<T>(store: Readable<T>): T {
const [value, setValue] = useState<T>(() => {
// subscribe fires synchronously — capture the initial value then unsub immediately
let initial!: T
store.subscribe(v => { initial = v })()
return initial
})
useEffect(() => store.subscribe(setValue), [store])
return value
}
// Returns [currentValue, setter] — setter calls store.set directly.
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
return [useReadable(store), store.set]
}
```
Usage:
```tsx
import {userProfile, pubkey} from '@welshman/app'
function ProfileHeader() {
const profile = useReadable(userProfile)
const [currentPubkey, setPubkey] = useWritable(pubkey)
return <div>{profile?.name ?? currentPubkey}</div>
}
```
### SolidJS
```typescript
import {createSignal, onCleanup} from 'solid-js'
import type {Readable, Writable} from 'svelte/store'
// Returns a SolidJS accessor (getter function); updates reactively.
function useReadable<T>(store: Readable<T>): () => T {
let initial!: T
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
const [value, setValue] = createSignal<T>(initial)
onCleanup(store.subscribe(v => setValue(() => v)))
return value
}
// Returns [accessor, setter].
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
return [useReadable(store), store.set]
}
```
Usage:
```tsx
import {userProfile} from '@welshman/app'
function ProfileHeader() {
const profile = useReadable(userProfile)
return <div>{profile()?.name}</div>
}
```
### Vue
```typescript
import {ref, onUnmounted} from 'vue'
import type {Readable, Writable} from 'svelte/store'
function useReadable<T>(store: Readable<T>) {
let initial!: T
store.subscribe(v => { initial = v })()
const value = ref<T>(initial)
const unsub = store.subscribe(v => { value.value = v as any })
onUnmounted(unsub)
return value // use as a readonly ref
}
```
### Notes
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
---
## Gotchas & Tips ## Gotchas & Tips
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes. - **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
+201 -148
View File
@@ -25,7 +25,7 @@ npm install @welshman/lib @welshman/util nostr-tools nostr-editor
Import the bundled CSS to get default object/suggestion styles (optional but recommended): Import the bundled CSS to get default object/suggestion styles (optional but recommended):
```ts ```typescript
import "@welshman/editor/index.css" import "@welshman/editor/index.css"
``` ```
@@ -57,19 +57,34 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char` (trigger character), `name` (ProseMirror node type name), `editor`, `search` (returns string items), and `select` (handles item selection). | | `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`; accepts optional UI customization. | | `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. | | `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
**`TippySuggestion` options:**
| Option | Required | Description |
|--------|----------|-------------|
| `char` | yes | Trigger character (e.g. `"@"`, `"~"`) |
| `name` | yes | ProseMirror node type name to insert on selection |
| `editor` | yes | The Tiptap `Editor` instance |
| `search` | yes | `(term: string) => string[]` — returns item values matching the query |
| `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node |
| `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
`MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`.
### Re-exports from upstream ### Re-exports from upstream
| Export | Source | | Export | Source |
|--------|--------| |--------|--------|
| `Editor` | `@tiptap/core` — the editor instance class | | `Editor` | `@tiptap/core` — the editor instance class |
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories | | `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload | | `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
| `FileAttributes` | `nostr-editor``{ file: File, … }` passed to the `upload` callback | | `FileAttributes` | `nostr-editor``{ file: File, … }` passed to the `upload` callback |
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor | | `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
--- ---
@@ -77,7 +92,7 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults. All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults.
```ts ```typescript
type WelshmanExtensionOptions = { type WelshmanExtensionOptions = {
bolt11?: false bolt11?: false
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions } breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
@@ -103,169 +118,205 @@ type WelshmanExtensionOptions = {
} }
``` ```
`fileUpload.config` requires at minimum an `upload` function. Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`. `fileUpload.config` requires at minimum an `upload` function. The `upload` callback must return `Promise<UploadTask>`:
```typescript
interface UploadResult { url: string; sha256: string; tags: string[][] }
interface UploadTask { result?: UploadResult; error?: string }
```
Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`.
--- ---
## Common Patterns ## Example
### 1. Minimal editor with submit on Mod-Enter A full-featured editor factory covering file upload, @-mention and custom-trigger autocomplete, reactive node views, word count, and draft persistence.
```ts ```typescript
import {Editor} from "@welshman/editor" import {get, writable} from "svelte/store"
import {WelshmanExtension} from "@welshman/editor" import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
const editor = new Editor({ import type {NodeViewRendererProps} from "@tiptap/core"
element: document.getElementById("editor")!,
extensions: [
WelshmanExtension.configure({
submit: () => {
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags()
console.log({content, tags})
editor.chain().clearContent().run()
},
}),
],
})
```
### 2. Chat-style editor (Enter submits)
```ts
WelshmanExtension.configure({
submit,
extensions: {
breakOrSubmit: {
config: {aggressive: true}, // Enter submits; Shift-Enter inserts line break
},
placeholder: {
config: {placeholder: "Send a message…"},
},
},
})
```
### 3. File upload with Blossom/NIP-96 server
```ts
import type {FileAttributes} from "@welshman/editor"
WelshmanExtension.configure({
submit,
extensions: {
fileUpload: {
config: {
upload: async (attrs: FileAttributes) => {
try {
const {uploaded, url} = await uploadFile("https://cdn.satellite.earth", attrs.file)
if (!uploaded) return {error: "Server refused the file"}
return {result: {url, tags: []}}
} catch (e) {
return {error: String(e)}
}
},
onDrop() { uploading.set(true) },
onComplete() { uploading.set(false) },
onUploadError(ed, task) {
ed.commands.removeFailedUploads()
uploading.set(false)
},
},
},
},
})
// Trigger the file picker programmatically:
editor.chain().selectFiles().run()
```
### 4. @-mention autocomplete with custom node view
```ts
import {get} from "svelte/store"
import type {NodeViewProps} from "@welshman/editor"
import {WelshmanExtension, MentionSuggestion} from "@welshman/editor"
import {profileSearch, deriveProfileDisplay} from "@welshman/app"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
} from "@welshman/editor"
import type {FileAttributes, UploadTask} from "@welshman/editor"
const CustomMentionNodeView = ({node}: NodeViewProps) => { // ── Custom inline node: room reference (~) ───────────────────────────────────
const dom = document.createElement("span") // Defines a new ProseMirror node type for inline room references.
dom.classList.add("tiptap-object") // Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
const display = deriveProfileDisplay(node.attrs.pubkey) const RoomReferenceExtension = Node.create({
const unsub = display.subscribe($d => { dom.textContent = "@" + $d }) name: "roomref",
atom: true, inline: true, group: "inline", selectable: true, priority: 1000,
addAttributes: () => ({url: {default: undefined}, h: {default: undefined}}),
parseHTML: () => [{tag: 'span[data-type="roomref"]'}],
renderHTML: ({HTMLAttributes}) =>
["span", mergeAttributes(HTMLAttributes, {"data-type": "roomref"}), "~"],
renderText: ({node}) => `~${node.attrs.url ?? ""}:${node.attrs.h ?? ""}`,
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("room-ref")
const unsub = deriveRoomDisplay(node.attrs.url, node.attrs.h)
.subscribe(d => { dom.textContent = "~" + d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("room-ref--active"),
deselectNode: () => dom.classList.remove("room-ref--active"),
}
},
})
return { // ── Editor factory ────────────────────────────────────────────────────────────
dom,
destroy: unsub,
selectNode() { dom.classList.add("tiptap-active") },
deselectNode() { dom.classList.remove("tiptap-active") },
}
}
WelshmanExtension.configure({ export const makeEditor = ({
content = "" as string | object,
placeholder = "",
uploading, // optional Writable<boolean>
wordCount, // optional Writable<number>
charCount, // optional Writable<number>
submit, submit,
extensions: { }: {
nprofile: { content?: string | object
extend: { placeholder?: string
addNodeView: () => CustomMentionNodeView, uploading?: ReturnType<typeof writable<boolean>>
addProseMirrorPlugins() { wordCount?: ReturnType<typeof writable<number>>
return [ charCount?: ReturnType<typeof writable<number>>
MentionSuggestion({ submit: () => void
editor: (this as any).editor, }) => {
search: (term: string) => get(profileSearch).searchValues(term), const profileSearch = createSearch(get(profiles), {
getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(), onSearch: searchProfiles,
// Optional: render a richer item in the dropdown getValue: (p: any) => p.event.pubkey,
createSuggestion: (pubkey: string) => { fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
const el = document.createElement("span") })
el.textContent = pubkey.slice(0, 12) + "…"
return el const editor = new Editor({
content,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({
submit,
extensions: {
// Chat-style: Enter submits, Shift-Enter inserts line break
breakOrSubmit: {config: {aggressive: true}},
placeholder: {config: {placeholder}},
// File upload — upload() must return Promise<UploadTask>
fileUpload: {
config: {
upload: async (attrs: FileAttributes): Promise<UploadTask> => {
try {
const {url, sha256, tags} = await myUploadServer(attrs.file)
return {result: {url, sha256, tags}}
} catch (e) {
return {error: String(e)}
}
}, },
}), onDrop: () => uploading?.set(true),
] onComplete: () => uploading?.set(false),
onUploadError: (ed, _task) => {
ed.commands.removeFailedUploads()
uploading?.set(false)
},
},
},
// Custom reactive nprofile node view + "@" and "~" autocomplete
nprofile: {
extend: {
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey)
.subscribe($d => { dom.textContent = "@" + $d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("mention--active"),
deselectNode: () => dom.classList.remove("mention--active"),
}
},
addProseMirrorPlugins() {
return [
// "@" — nprofile mention; updateSignal re-renders when search index changes
MentionSuggestion({
editor: (this as any).editor,
search: term => profileSearch.searchValues(term),
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: pubkey => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
return el
},
}),
// "~" — custom roomref node; select must call props.command({...attrs})
TippySuggestion({
char: "~", name: "roomref",
editor: (this as any).editor,
search: term => roomSearch.searchValues(term),
select: (id, props) => {
const [url, h] = splitRoomId(id)
if (url && h) props.command({url, h})
},
createSuggestion: id => {
const el = document.createElement("span")
el.textContent = id.slice(0, 16) + "…"
return el
},
}),
]
},
},
},
}, },
}, }),
],
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
}, },
}, })
})
```
### 5. Reading content and tags after submit return editor
```ts
const submit = () => {
// Plain text with newlines between paragraphs
const content = editor.getText({blockSeparator: "\n"}).trim()
// NIP-10 / NIP-27 tags built from inline nostr objects
const tags = editor.storage.nostr.getEditorTags()
// Full ProseMirror JSON (for saving draft state)
const json = editor.getJSON()
// Restore from saved JSON
editor.commands.setContent(json)
} }
// ── Reading content on submit ─────────────────────────────────────────────────
const onSubmit = (editor: Editor) => {
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags() // NIP-10 / NIP-27 tags
console.log({content, tags})
editor.chain().clearContent().run()
}
// ── Mounting in Svelte ────────────────────────────────────────────────────────
``` ```
### 6. Tracking word / character count ```svelte
<script lang="ts">
import type {Editor} from "@welshman/editor"
import {onMount, onDestroy} from "svelte"
```ts const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
import {writable} from "svelte/store"
import {Editor, WelshmanExtension} from "@welshman/editor"
const wordCount = writable(0) let element: HTMLElement
const charCount = writable(0)
const editor = new Editor({ onMount(() => {
element, element.append(editor.options.element)
extensions: [WelshmanExtension.configure({submit})], if (autofocus) {
onUpdate({editor}) { const atEnd = editor.getText().trim().length > 0
wordCount.set(editor.storage.wordCount.words) requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
charCount.set(editor.storage.wordCount.chars) }
}, })
})
onDestroy(() => editor.destroy())
</script>
<div bind:this={element}></div>
``` ```
--- ---
@@ -292,3 +343,5 @@ const editor = new Editor({
- **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish. - **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish.
- **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`. - **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`.
- **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state. - **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state.
- **`addFile(file, pos)` command.** Programmatically inserts a file upload node at a given ProseMirror position — useful for native clipboard paste (e.g. mobile) where the browser paste event carries no file data.
- **`editor.commands.focus('start' | 'end' | number)`.** Pass `"end"` to place the cursor after existing content (restoring a draft), `"start"` for a fresh empty editor. Call inside `requestAnimationFrame` when the editor element was just mounted.