Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
 · 
<a href="https://react-call.desko.dev/examples">Examples gallery</a>
&nbsp;·&nbsp;
<a href="#ai-agent-skill">🤖 AI agent skill</a>
&nbsp;·&nbsp;
<a href="#getting-started">Getting started</a>
</p>
</div>
Expand Down Expand Up @@ -64,6 +66,7 @@ menus, pickers — any UI that conceptually returns a value to its caller.
- [Lazy loading](#lazy-loading)
- [SSR](#ssr)
- [Next.js / RSC](#nextjs--rsc)
- [AI agent skill](#ai-agent-skill)
- [Migrating from v1](#migrating-from-v1)

# Getting started
Expand Down Expand Up @@ -507,6 +510,16 @@ export const Confirm = createCallable(...)

Then `<Confirm />` mounts cleanly from any Server Component (e.g. `app/layout.tsx`).

# AI agent skill

Using an AI coding assistant (Claude Code, Cursor, …)? Install the official react-call skill so it writes correct Callables — the Declare→Root→Call model, `call` vs `upsert`, mutation flow, multi-preview hosts, SSR, the single-Root rule, and the canonical vocabulary:

```sh
npx skills add desko27/react-call --skill react-call
```

The `--skill react-call` flag pins exactly this skill (this repo also hosts the maintainers' internal workflow skills, which you don't want). Powered by [`skills`](https://github.com/vercel-labs/skills) — works with any agent it supports.

# Migrating from v1

If you're upgrading from 1.x, see the [full v2 changelog](packages/react-call/CHANGELOG.md). The breaking changes in short:
Expand Down
23 changes: 23 additions & 0 deletions docs/adr/0021-publish-consumer-usage-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Ship a consumer-usage agent skill, published from `skills/react-call/`

To help LLM coding agents write correct react-call code, the repo ships an installable agent skill — a `SKILL.md` consumers add to their own project with `npx skills add desko27/react-call --skill react-call` (the `vercel-labs/skills` CLI, the same tool this repo already uses to vendor its workflow skills). It is a single, **consumer-usage** skill: it teaches an agent to author Callables (the Declare→Root→Call model, `call` vs `upsert`, mutation flow, Hosts, SSR, the single-Root rule), not how to work on the library's internals. It lives in a top-level `skills/` directory — kept deliberately separate from the consumed/vendored skills in `.agents/skills/` — and is distributed from GitHub, not the npm tarball.

The skill triggers **broadly**: it fires on the class of problem react-call solves (UI that resolves a value back to its caller — confirmations, dialogs, form modals, toasts, pickers), and if react-call is not yet a dependency but the problem fits, it proposes adding it. The rationale is that *installing the skill is itself the opt-in*: a developer only adds it to a project where they intend to use react-call for this kind of UI, so a conservative "only fire when react-call is already imported" trigger would withhold help exactly when it is most useful — at the moment new code is being written. Its conduct is low-friction: capture new cases, surface react-call as an option where another solution is already in place, but do not crusade to replace working incumbents.

## Considered options

- **A single broad-trigger skill named `react-call` (chosen).** One artifact, clean install command, no duplication to maintain. The broad trigger matches install-intent.
- **Two skills: scoped `react-call` + broad `reach-for-react-call`, composed from a shared DRY source.** Considered at length. The scoped variant would fire only when react-call is already present (safe for global `-g` installs and mixed codebases); the broad variant would proactively recommend it. Rejected for v1: install-is-intent makes the scoped variant redundant for the dominant per-project install, and maintaining two self-contained artifacts (the CLI copies a skill's own directory, so they cannot share files at install time) requires a release-time compose step — cost without payoff until a real global-install audience asks for it. Adding `react-call-scoped` later is cheap.
- **Place the skill in `.agents/skills/` next to the vendored workflow skills.** Rejected: `npx skills add desko27/react-call` walks `.agents/skills/` and `.claude/skills/` as discovery locations, so the authored skill would be listed alongside all ~15 vendored workflow skills (tdd, caveman, grill-with-docs…) — irrelevant and confusing to a library consumer. A top-level `skills/` directory cleanly separates *authored-for-publication* from *consumed*.
- **Document the bare `npx skills add desko27/react-call` (no `--skill`).** Rejected: discovery still surfaces the vendored skills from `.agents/skills/`/`.claude/skills/`, so the consumer gets a menu. Pinning with `--skill react-call` installs exactly one and makes the first impression clean. The `--skill` flag is therefore mandatory in the documented command.
- **Ship `SKILL.md` inside the npm package (`files`).** Rejected: `npx skills` resolves from GitHub, not npm; bundling it would bloat every `npm install react-call` for zero install-path benefit.
- **Publish from a separate dedicated repo (`desko27/react-call-skill`).** Rejected: a second repo to maintain, guaranteed drift from the library it documents, and not where people look. Co-locating with the code and README keeps one source of truth.

## Consequences

- **The README documents one canonical install command: `npx skills add desko27/react-call --skill react-call`.** Dropping `--skill` is a documented foot-gun (lists the vendored skills), so the flag is never omitted in docs.
- **Discovery space is shared.** `.agents/skills/` and `.claude/skills/` remain discovery locations the CLI walks; `skills/react-call/` simply adds one more. Nothing prevents a user from `--list`-ing and seeing everything — the `--skill` pin is the contract, not an enforcement.
- **Content is decision- and pitfall-oriented, not a README clone.** `SKILL.md` carries the mental model, a `call`/`upsert`/mutation-flow decision guide, the hard rules (single Root, client-only `call()`, where to mount), one canonical example, and a "when to reach for react-call (and when not)" gate. Depth lives in `references/` (`mutation-flow.md`, `host.md`, `ssr-and-lazy.md`, `types.md`). It is self-contained (no fetch at task time) with pointers to `react-call.desko.dev` for further reading. This avoids a second copy of the ~530-line README drifting from the original.
- **The skill consumes `CONTEXT.md`'s vocabulary; it adds none.** Library machinery is named with canonical terms (Callable, Root, Call, Upsert, MutationFlow, MutationFn, Host…) and respects the glossary's _Avoid_ lists; use cases stay in plain language (a "toast" is a use case, the operation is **Upsert**). No new domain terms, so `CONTEXT.md` is unchanged.
- **Versioning is git/hash-based, not changesets.** `npx skills` pins the skill by commit/hash in the consumer's lockfile; the skill is not part of the npm release. It declares "Covers react-call v2.x" and instructs the agent to defer to the installed version if it differs.
- **A CI symbol-existence guard keeps the skill honest.** A lightweight check in this repo fails the build if the skill references a public export that no longer exists (`createCallable`, `useMutationFlow`, the public types, the `react-call/*` subpaths) against `main.ts` and the `.public` types. Operating rule: an API-breaking change to the library updates the skill in the same PR.
143 changes: 143 additions & 0 deletions packages/react-call/src/__tests__/skill-symbols.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'

// Drift guard for the published consumer skill (see ADR-0021). The skill at
// `skills/react-call/` teaches an LLM the public API; if a `react-call` export
// or subpath it imports is renamed/removed, this test fails so the skill is
// updated in the same PR rather than silently lying to consumers.

const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..')
const PKG = join(REPO_ROOT, 'packages', 'react-call')
const SKILL_DIR = join(REPO_ROOT, 'skills', 'react-call')

const SKILL_FILES = [
join(SKILL_DIR, 'SKILL.md'),
join(SKILL_DIR, 'references', 'mutation-flow.md'),
join(SKILL_DIR, 'references', 'host.md'),
join(SKILL_DIR, 'references', 'ssr-and-lazy.md'),
join(SKILL_DIR, 'references', 'types.md'),
]

// Subpath specifier -> source entry whose exports back it.
const ENTRY_SOURCES: Record<string, string> = {
'react-call': join(PKG, 'src', 'main.ts'),
'react-call/mutation-flow': join(PKG, 'src', 'mutation-flow', 'index.ts'),
'react-call/host': join(PKG, 'src', 'host', 'index.tsx'),
'react-call/vite': join(PKG, 'src', 'vite', 'index.ts'),
}

const rel = (file: string) => relative(REPO_ROOT, file)

/** Names a source file exports (incl. type-only and re-exports), plus default. */
function exportedNames(src: string): {
names: Set<string>
hasDefault: boolean
} {
const names = new Set<string>()
// `export { a, b as c } [from '…']` and `export type { … }`
for (const m of src.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
for (const raw of m[1].split(',')) {
const name = raw
.trim()
.replace(/^type\s+/, '')
.split(/\s+as\s+/)[0]
.trim()
if (name) names.add(name)
}
}
// `export const|function|class|interface|type NAME`
const decl =
/export\s+(?:async\s+)?(?:const|function|class|interface|type)\s+([A-Za-z0-9_$]+)/g
for (const m of src.matchAll(decl)) names.add(m[1])
return { names, hasDefault: /export\s+default\b/.test(src) }
}

/** Parse the binding clause of an `import … from` statement. */
function parseClause(clause: string): { named: string[]; hasDefault: boolean } {
const named: string[] = []
let rest = clause.trim()
const brace = rest.match(/\{([^}]*)\}/)
if (brace) {
for (const raw of brace[1].split(',')) {
const t = raw.trim()
if (!t) continue
const name = t
.replace(/^type\s+/, '')
.split(/\s+as\s+/)[0]
.trim()
if (name) named.push(name)
}
rest = rest.replace(brace[0], '').replace(/,/g, ' ')
}
rest = rest.replace(/^type\b/, '').trim()
const def = rest.match(/^([A-Za-z0-9_$*]+)/)
const hasDefault = !!def && def[1] !== '*'
return { named, hasDefault }
}

interface ImportRef {
file: string
spec: string
named: string[]
hasDefault: boolean
}

function extractReactCallImports(file: string): ImportRef[] {
const text = readFileSync(file, 'utf8')
const refs: ImportRef[] = []
const re = /import\s+([^;]*?)\s+from\s+['"](react-call(?:\/[^'"]+)?)['"]/g
for (const m of text.matchAll(re)) {
const { named, hasDefault } = parseClause(m[1])
refs.push({ file, spec: m[2], named, hasDefault })
}
return refs
}

const pkg = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8'))
const validSubpaths = new Set(
Object.keys(pkg.exports)
.filter((k) => k !== './package.json')
.map((k) => (k === '.' ? 'react-call' : k.replace(/^\.\//, 'react-call/'))),
)

const refs = SKILL_FILES.flatMap(extractReactCallImports)
const sourceExports = new Map<string, ReturnType<typeof exportedNames>>()
for (const spec of Object.keys(ENTRY_SOURCES)) {
sourceExports.set(
spec,
exportedNames(readFileSync(ENTRY_SOURCES[spec], 'utf8')),
)
}

describe('react-call skill stays in sync with the public API (ADR-0021)', () => {
it('finds the react-call imports it is meant to guard', () => {
// Catches a broken path/glob silently making the suite vacuous.
expect(refs.length).toBeGreaterThan(0)
})

it('imports only declared package subpaths', () => {
for (const ref of refs) {
expect(
[...validSubpaths],
`${ref.spec} imported in ${rel(ref.file)} is not a package export`,
).toContain(ref.spec)
}
})

it('imports only real public exports', () => {
for (const ref of refs) {
const exp = sourceExports.get(ref.spec)
if (!exp) continue // unknown subpath already failed above
for (const name of ref.named) {
expect(
[...exp.names],
`${name} from '${ref.spec}' (in ${rel(ref.file)}) is not exported`,
).toContain(name)
}
if (ref.hasDefault) {
expect(exp.hasDefault, `'${ref.spec}' has no default export`).toBe(true)
}
}
})
})
85 changes: 85 additions & 0 deletions sites/web/src/components/CopyCommand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from 'react'

interface CopyCommandProps {
command: string
label?: string
}

// A single, fixed command with a copy button — mirrors the lower row of
// InstallCommand but without the package-manager tabs. Used for one-off
// commands like the agent-skill install.
export const CopyCommand = ({ command, label }: CopyCommandProps) => {
const [copied, setCopied] = useState(false)

const copy = async () => {
try {
await navigator.clipboard.writeText(command)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard unavailable
}
}

return (
<div
className="
inline-flex max-w-full items-center gap-2 overflow-hidden rounded-md
border border-[var(--color-border)]
bg-[var(--color-bg-subtle)]
px-4 py-3
text-left font-mono text-sm text-[var(--color-fg)]
"
>
<span className="text-[var(--color-fg-subtle)]">$</span>
<span className="flex-1 overflow-x-auto whitespace-nowrap">
{command}
</span>
<button
type="button"
onClick={copy}
aria-label={copied ? 'Copied' : (label ?? 'Copy command')}
className="
inline-flex h-6 w-6 shrink-0 items-center justify-center rounded
text-[var(--color-fg-subtle)]
transition-colors
hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-fg)]
"
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="text-[var(--color-accent)]"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
)
}
4 changes: 2 additions & 2 deletions sites/web/src/components/landing/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { InstallCommand } from '../InstallCommand'
import { HeroConfirm } from './HeroConfirm'
import { HeroInstall } from './HeroInstall'
import { type Result, ResultBadge } from './ResultBadge'

export const Hero = () => {
Expand Down Expand Up @@ -32,7 +32,7 @@ export const Hero = () => {

<section className="mx-auto max-w-6xl px-6 pt-16 pb-24 md:pt-24 md:pb-32">
<div className="text-center">
<InstallCommand />
<HeroInstall />
<h1
className="
mt-6 text-4xl font-medium tracking-tight
Expand Down
79 changes: 79 additions & 0 deletions sites/web/src/components/landing/HeroInstall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { CopyCommand } from '../CopyCommand'
import { InstallCommand } from '../InstallCommand'

type Mode = 'lib' | 'skill'

const TABS: { id: Mode; label: string }[] = [
{ id: 'lib', label: 'Install' },
{ id: 'skill', label: '🤖 AI skill' },
]

const SKILL_COMMAND = 'npx skills add desko27/react-call --skill react-call'

// Hero install widget: a subtle text toggle that swaps the package install
// (InstallCommand, with its PM tabs) for the agent-skill install command.
export const HeroInstall = () => {
const [mode, setMode] = useState<Mode>('lib')

return (
<div className="flex flex-col items-center gap-3">
<div
role="tablist"
aria-label="Install method"
className="inline-flex items-center gap-4 font-mono text-xs"
>
{TABS.map((tab) => {
const active = mode === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setMode(tab.id)}
className={`
border-b-2 pb-0.5 transition-colors
${
active
? 'border-[var(--color-accent)] text-[var(--color-fg)]'
: 'border-transparent text-[var(--color-fg-subtle)] hover:text-[var(--color-fg-muted)]'
}
`}
>
{tab.label}
</button>
)
})}
</div>

{/* Both boxes share one grid cell so the widget always sizes to the
taller one (the lib box, with its PM tab row) — toggling never
shifts the layout. The hidden box stays in flow via `invisible`.
`grid-cols-1` bounds the track to minmax(0,1fr) and `min-w-0` lets
the cell shrink below the command's width, so a long command scrolls
inside the box on narrow screens instead of overflowing the page. */}
<div className="grid w-full grid-cols-1">
<div
className={`col-start-1 row-start-1 flex min-w-0 items-center justify-center ${
mode === 'lib' ? '' : 'pointer-events-none invisible'
}`}
aria-hidden={mode !== 'lib'}
>
<InstallCommand />
</div>
<div
className={`col-start-1 row-start-1 flex min-w-0 items-center justify-center ${
mode === 'skill' ? '' : 'pointer-events-none invisible'
}`}
aria-hidden={mode !== 'skill'}
>
<CopyCommand
command={SKILL_COMMAND}
label="Install the react-call agent skill"
/>
</div>
</div>
</div>
)
}
Loading
Loading