From 8f8c90fbc333c104aa0bf399d9301459ab0761c2 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 10 Jun 2026 14:31:27 -0700 Subject: [PATCH] refine skills a bit --- README.md | 12 ++ skills/README.md | 33 +-- skills/welshman-app/SKILL.md | 99 +++++++++ skills/welshman-editor/SKILL.md | 349 ++++++++++++++++++-------------- 4 files changed, 313 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 1ea7459..a41610d 100644 --- a/README.md +++ b/README.md @@ -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`. 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. diff --git a/skills/README.md b/skills/README.md index bd55f15..423bd82 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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. diff --git a/skills/welshman-app/SKILL.md b/skills/welshman-app/SKILL.md index bf34a44..89cfbeb 100644 --- a/skills/welshman-app/SKILL.md +++ b/skills/welshman-app/SKILL.md @@ -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(store: Readable): T { + const [value, setValue] = useState(() => { + // 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(store: Writable): [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
{profile?.name ?? currentPubkey}
+} +``` + +### 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(store: Readable): () => T { + let initial!: T + store.subscribe(v => { initial = v })() // sync capture then unsubscribe + + const [value, setValue] = createSignal(initial) + onCleanup(store.subscribe(v => setValue(() => v))) + return value +} + +// Returns [accessor, setter]. +function useWritable(store: Writable): [() => T, (value: T) => void] { + return [useReadable(store), store.set] +} +``` + +Usage: + +```tsx +import {userProfile} from '@welshman/app' + +function ProfileHeader() { + const profile = useReadable(userProfile) + return
{profile()?.name}
+} +``` + +### Vue + +```typescript +import {ref, onUnmounted} from 'vue' +import type {Readable, Writable} from 'svelte/store' + +function useReadable(store: Readable) { + let initial!: T + store.subscribe(v => { initial = v })() + + const value = ref(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. diff --git a/skills/welshman-editor/SKILL.md b/skills/welshman-editor/SKILL.md index 8fd3c03..c25e512 100644 --- a/skills/welshman-editor/SKILL.md +++ b/skills/welshman-editor/SKILL.md @@ -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`: + +```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 + wordCount, // optional Writable + charCount, // optional Writable 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> + wordCount?: ReturnType> + charCount?: ReturnType> + 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 + fileUpload: { + config: { + upload: async (attrs: FileAttributes): Promise => { + 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 + + +
``` --- @@ -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.