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
+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)
```bash
npx skills add jstaab/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
npx skills add coracle-social/welshman
```
## 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.
## 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
- **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):
```ts
```typescript
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 |
|--------|-------------|
| `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). |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`; accepts optional UI customization. |
| `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`. Optional: `updateSignal`, `createSuggestion`. |
| `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
| Export | Source |
|--------|--------|
| `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 |
| `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.
```ts
```typescript
type WelshmanExtensionOptions = {
bolt11?: false
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
import {Editor} from "@welshman/editor"
import {WelshmanExtension} from "@welshman/editor"
const editor = new Editor({
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"
```typescript
import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core"
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) => {
const dom = document.createElement("span")
dom.classList.add("tiptap-object")
// ── Custom inline node: room reference (~) ───────────────────────────────────
// Defines a new ProseMirror node type for inline room references.
// Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
const display = deriveProfileDisplay(node.attrs.pubkey)
const unsub = display.subscribe($d => { dom.textContent = "@" + $d })
const RoomReferenceExtension = Node.create({
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 {
dom,
destroy: unsub,
selectNode() { dom.classList.add("tiptap-active") },
deselectNode() { dom.classList.remove("tiptap-active") },
}
}
// ── Editor factory ────────────────────────────────────────────────────────────
WelshmanExtension.configure({
export const makeEditor = ({
content = "" as string | object,
placeholder = "",
uploading, // optional Writable<boolean>
wordCount, // optional Writable<number>
charCount, // optional Writable<number>
submit,
extensions: {
nprofile: {
extend: {
addNodeView: () => CustomMentionNodeView,
addProseMirrorPlugins() {
return [
MentionSuggestion({
editor: (this as any).editor,
search: (term: string) => get(profileSearch).searchValues(term),
getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
// Optional: render a richer item in the dropdown
createSuggestion: (pubkey: string) => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
return el
}: {
content?: string | object
placeholder?: string
uploading?: ReturnType<typeof writable<boolean>>
wordCount?: ReturnType<typeof writable<number>>
charCount?: ReturnType<typeof writable<number>>
submit: () => void
}) => {
const profileSearch = createSearch(get(profiles), {
onSearch: searchProfiles,
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
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
```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)
return editor
}
// ── 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
import {writable} from "svelte/store"
import {Editor, WelshmanExtension} from "@welshman/editor"
const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
const wordCount = writable(0)
const charCount = writable(0)
let element: HTMLElement
const editor = new Editor({
element,
extensions: [WelshmanExtension.configure({submit})],
onUpdate({editor}) {
wordCount.set(editor.storage.wordCount.words)
charCount.set(editor.storage.wordCount.chars)
},
})
onMount(() => {
element.append(editor.options.element)
if (autofocus) {
const atEnd = editor.getText().trim().length > 0
requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
}
})
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.
- **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.
- **`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.