
Init Tauri App
- 24 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Scaffold a new Tauri v2 desktop app with house conventions plus opt-in modules like CLI+MCP, SQLite, tray/updater, release, and a Swift sidecar.
About
Delegates boilerplate to npm create tauri-app then layers an opinionated core and selected modules, gating on cargo check and npm build after each step. A developer uses it to start a new Tauri desktop project with proven conventions and optional JTBD product context.
- Opt-in modules composed with a build gate after each
- Includes upstream cookie/time E0119 workaround and JTBD ingestion
Init Tauri App by the numbers
- 24 all-time installs (skills.sh)
- Ranked #1,509 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill init-tauri-appAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Scaffold a new Tauri v2 desktop app with house conventions plus opt-in modules like CLI+MCP, SQLite, tray/updater, release, and a Swift sidecar.
Files
init-tauri-app
Scaffolds a new Tauri v2 project pre-loaded with conventions proven in two production apps (cenno, cull). Delegates version-current boilerplate to the official scaffolder, then applies a durable convention layer and any opt-in modules the user selects.
When to use
- "Start a new Tauri app", "init a tauri project", "scaffold a tauri desktop app".
Prerequisites (verify before scaffolding)
node+npmon PATH (node --version)cargo+rustcon PATH (cargo --version)- macOS + Xcode CLT only required if the Swift-sidecar module is selected
Procedure
1. Gather inputs (AskUserQuestion)
Ask, in one batch:
- App name (kebab-case). Validate
^[a-z][a-z0-9-]*$. - Identifier (reverse-DNS, default
com.glebkalinin.<name>). - Target directory (default
~/ai_projects/<name>). Abort if it exists and is non-empty. - Frontend framework:
react-ts|svelte-kit|vanilla-ts. - Modules (multi-select): CLI+MCP · SQLite · Tray/Updater · Release/Preflight · Swift sidecar.
- JTBD artifact (optional): a path to a
jtbd.json. If not given, the skill auto-discovers
./jtbd.json then ~/jtbd/<name>/jtbd.json. See Step 1.5.
If Swift sidecar selected but host is non-macOS or xcrun --find swift fails: warn and drop it.
1.5 Ingest JTBD (optional, additive)
1. Resolve the artifact, first hit wins: explicit path → ./jtbd.json → ~/jtbd/<name>/jtbd.json. If none found, skip this whole step (the scaffold proceeds with empty product context — no error). 2. Confirm: echo the artifact's hook and ask the user to confirm before using it. On decline, skip. 3. Validate: the artifact must parse and have name, hook, jtbd. If not, warn and skip ingestion (never abort the scaffold). render-jtbd.sh exits 3 on invalid input — treat that as "skip". 4. Pre-fill: if valid, default the app name to name and identifier to com.glebkalinin.<name> (still confirm with the user in Step 1 if not already chosen). 5. The artifacts are written during Step 3 (core layer) — see the JTBD block there.
2. Scaffold base
cd <parent-of-target>
npm create tauri-app@latest <name> -- --template <framework> --manager npm --yesThen cd <target> && npm install.
Known upstream fix (current rustc + Tauri 2.11.x): a bare scaffold's first cargo check can fail with error[E0119] on cookie/time (transitive cookie 0.18.1 vs time 0.3.48). This is not a skill bug — it hits any fresh create-tauri-app. If it occurs, run once in src-tauri/: cargo update -p time --precise 0.3.47, then re-check.
3. Apply core layer
Copy every file from assets/core/ into the project, applying the renames in the table below, then run a baseline gate. Substitute <name> (and <identifier> where the token appears) in AGENTS.md and README.
| asset | destination |
|---|---|
core/AGENTS.md | AGENTS.md |
core/CLAUDE.md | .claude/CLAUDE.md |
core/gitignore | .gitignore (merge: append only house lines not already present; skip lines — including comment headers — that already exist) |
core/rust-toolchain.toml | src-tauri/rust-toolchain.toml |
core/node-version | .node-version |
core/mcp.json | .mcp.json |
core/capabilities/default.json | src-tauri/capabilities/default.json (overwrite) |
core/scripts/check-versions.sh | scripts/check-versions.sh (chmod +x) |
core/README.md | README.md |
core/CONTRIBUTING.md | CONTRIBUTING.md |
If a JTBD artifact was confirmed in Step 1.5, also:
- Render
assets/jtbd/PRODUCT.md.template→docs/PRODUCT.mdvia
scripts/render-jtbd.sh <artifact> assets/jtbd/PRODUCT.md.template <artifact-path>.
- Render
assets/jtbd/guardrails-check.md.template→docs/internal/guardrails-check.md. - Render
assets/jtbd/agents-product-section.md.templateand **insert it intoAGENTS.md
immediately after the first heading** (so product context leads the file).
- Copy the artifact verbatim to project-root
jtbd.json(never modify the source). - Field→destination details:
assets/jtbd/jtbd-map.md.
Create empty tracked dir docs/internal/.gitkeep and docs/.gitkeep. Enable TS strict: ensure tsconfig.json has strict, noUnusedLocals, noUnusedParameters true.
Gate: cd src-tauri && cargo check and cd .. && npm run build. Both must pass before modules.
4. Compose selected modules
For each selected module, in this order — cli-mcp, sqlite, tray-updater, swift-sidecar, release-preflight — open assets/modules/<m>/INSERT.md and follow it exactly: it lists files to copy, Cargo deps to merge into src-tauri/Cargo.toml [dependencies], and insertion points in src-tauri/src/lib.rs (tauri::generate_handler![...]) and src-tauri/src/main.rs.
After EACH module: cd src-tauri && cargo check. If it fails, fix the just-applied merge before continuing (failures localize to the current module). For modules with a frontend/script part, also run the relevant check named in that INSERT.md.
5. Final verification + handoff
cd src-tauri && cargo check→ must passnpm run build→ must passbash scripts/check-versions.sh→ must pass- Offer
git init && git add -A && git commit -m "chore: scaffold via init-tauri-app". - Print a summary: framework, modules applied, modules skipped (with reason), next commands
(npm run tauri dev).
Agent guide — Tauri v2 project (Rust + web frontend)
Canonical instructions for any coding agent (Claude Code, Codex, Cursor) working in this repo. CLAUDE.md just points here.
Architecture: Rust backend in src-tauri/ ↔ web frontend (Vite) rendered in the OS-native webview. The split defines everything below: the Rust side is JSON-native; the webview side is the blind spot you close with the MCP server + unified logging.
The loop (how you verify your own work)
1. Edit Rust or frontend. 2. Rust: cargo check in src-tauri/ with JSON diagnostics — auto-apply machine-applicable fixes. 3. Frontend: Vite's error overlay + exit codes. 4. For UI/IPC behaviour: look at the running app via the Tauri MCP server.
Rust side (primary signal — fast, parsable)
Run inside src-tauri/. cargo check is 2–3× faster than build and needs no binary:
cd src-tauri
cargo check --message-format=json # primary loop; apply `machine-applicable` suggestions
cargo clippy --message-format=json # same schema + lint codes + suggested_replacement
cargo fmt --check # deterministic gate
cargo nextest run # isolated, parallel, fast test runnerDrive cargo directly for signal — tauri dev/tauri build are orchestrators with no JSON. Reserve tauri build for final verification only (it's slow and triggers signing).
rust-analyzer is native to Claude Code. Set cargo.targetDir = true (or a separate target dir) so it doesn't deadlock on the target/ lock while tauri dev is running.
Frontend side
pnpm install
pnpm dev # Vite dev server (run backgrounded; pair with `tauri dev`)
pnpm build
pnpm check # svelte-check / tsc, if configuredSeeing the running app (the hard part)
Webviews don't share devtools across platforms; only Windows WebView2 speaks CDP. So:
- Tauri MCP server (
@hypothesi/tauri-mcp-server, see.mcp.json) — screenshots, DOM
snapshots, click/type, execute + monitor live Tauri IPC, stream logs. Requires its companion Rust plugin in src-tauri/ and a dev build running. This is your eyes.
- Unify logs with `tauri-plugin-log` — Rust
println!and frontendconsole.*otherwise go
to different places. Fan both to Stdout + Webview + file for one readable stream.
IPC boundary (Rust ↔ JS)
invoke() serializes via serde. Common silent failures:
- Command error types must serialize or the call fails opaquely.
- Large/complex return values can hang the promise.
- Rust↔TS type drift → hand-write IPC types in
src/lib/api.tsmirroring the Rust command signatures — both cenno and cull deliberately avoid codegen deps.
Compile-time discipline (keeps the agent loop tight)
cargo checkoverbuild(biggest win).- Faster linker:
moldorlldin.cargo/config.toml. - Separate rust-analyzer target dir (see above).
Frontend ruleset — Svelte + TypeScript + Tauri
(Ported gap-filler; no maintained Claude skill covers this.)
- Tauri commands: define in Rust with
#[tauri::command]; call viainvoke<T>()with an
explicit return type. Keep command signatures small and serializable.
- State: Svelte stores for UI state; Rust
tauri::Statefor backend state. Don't duplicate
source-of-truth across the boundary — pick one side per piece of state.
- Errors: return
Result<T, E>whereE: serde::Serialize(e.g.thiserror+ a serializable
wrapper). Surface to the UI as typed rejections, not strings.
- Events: prefer
invokefor request/response; useemit/listenonly for true push/streaming. - Types: hand-write IPC types in
src/lib/api.tsmirroring the Rust command signatures — both cenno and cull deliberately avoid codegen deps. - Design tokens: plain CSS in
src/app.css; style-dictionary is an optional upgrade (see cenno) — not set up by default. - Security: least-privilege
capabilities/; never expose a broadfs/shellscope; validate
all command inputs in Rust.
- Vite: keep
clearScreen: falseand the fixed dev port Tauri expects; don't import Node-only
modules into frontend code.
Human gates
- macOS notarization is a slow network round-trip (2–20 min) and **sidecars/
externalBinbreak
it** — don't time-bomb the agent on it; flag for the human.
- Cross-platform release builds →
tauri-actionGitHub Action, matrix over macos/ubuntu/windows.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Baseline capability for the main window",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
}
Project instructions
See [AGENTS.md](../AGENTS.md) — the canonical agent guide for this repo. Follow it exactly.
Contributing
- Read AGENTS.md first.
- Rust loop:
cd src-tauri && cargo check. Lint:cargo clippy. Format:cargo fmt. - Frontend:
npm run build. Versions:bash scripts/check-versions.sh. - Keep
package.json,src-tauri/tauri.conf.json, andsrc-tauri/Cargo.tomlversions in sync.
# OS
.DS_Store
# Node / frontend
node_modules/
/build
/dist
dist-ssr
.svelte-kit/
src-tauri/.svelte-kit/
*.local
vite.config.js.timestamp-*
# Rust / Tauri
src-tauri/target/
src-tauri/gen/schemas
# Swift (sidecar module)
src-tauri/swift/.build/
**/xcuserdata/
**/DerivedData/
# Env & secrets
.env
.env.*
!.env.example
# Local data
*.db
*.db-shm
*.db-wal
# Logs
*.log
logs
# Internal working docs — never shipped
docs/internal/
# Agent/tooling caches
.enzyme/
.enzyme-embeddings/
.beads/
.worktrees/
{
"mcpServers": {
"tauri": {
"command": "npx",
"args": ["-y", "@hypothesi/tauri-mcp-server"]
}
}
}
22
<name>
A Tauri v2 desktop app. See AGENTS.md for the developer/agent guide.
Develop
npm install
npm run tauri dev[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
#!/usr/bin/env bash
set -euo pipefail
# Assert package.json, src-tauri/tauri.conf.json, and src-tauri/Cargo.toml agree on version.
pkg=$(node -p "require('./package.json').version")
conf=$(node -p "require('./src-tauri/tauri.conf.json').version")
cargo=$(grep -m1 '^version' src-tauri/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')
echo "package.json=$pkg tauri.conf.json=$conf Cargo.toml=$cargo"
if [ "$pkg" != "$conf" ] || [ "$pkg" != "$cargo" ]; then
echo "ERROR: version mismatch across manifests" >&2
exit 1
fi
echo "OK: versions in sync ($pkg)"
## Product context
> {{hook}}
**For** {{jtbd.situation}} — **so that** {{jtbd.outcome}}.
**Must NOT do (from JTBD guardrails):**
<!-- each:guardrails -->
- {{item}}
<!-- /each -->
Full brief: [docs/PRODUCT.md](./docs/PRODUCT.md) · Source JTBD: {{__source_path__}}
{ "hook": "no name or jtbd block here", "needs": { "functional": ["x"] }
{
"name": "tidepool",
"hook": "Capture a fleeting idea by voice and get a buildable spec before the moment fades.",
"jtbd": {
"situation": "a founder has an idea away from their desk",
"motivation": "articulate it clearly enough for agents to act on",
"outcome": "a structured spec exists within minutes, no re-explaining"
},
"problem": { "what_hurts": "static tools don't feed forward", "cost_today": "hours of re-explanation per project" },
"needs": {
"functional": ["voice-friendly capture", "structured JSON output"],
"emotional": ["feel like talking to a sharp thinker"],
"social": ["share a credible one-pager"]
},
"switch_forces": {
"push": "the gap between idea and spec kills momentum",
"pull": "specs that feed forward into code",
"habit": "Figma/Docs muscle memory",
"anxiety": "trusting an AI-framed spec over real empathy"
},
"before_after": { "before": "idea lives in your head", "after": "a spec that IS the handoff" },
"scenarios": [ { "title": "Bike commute", "vignette": "speaks the idea while riding; parks with a spec" } ],
"guardrails": ["one project per session", "never fabricate switch forces"],
"evidence": { "source": "interview", "quotes": ["\"I want to do it while riding the bike.\""], "weaknesses": [] },
"open_questions": ["is /plan needed between brainstorm and code?"],
"version": 1
}
# Guardrails review checklist
Manual pre-release pass. One box per JTBD guardrail — confirm the release does not violate it.
<!-- each:guardrails -->
- [ ] {{item}}
<!-- /each -->
Source JTBD: {{__source_path__}}
JTBD → project mapping
Shared by all init-* skills. Source schema: solopreneur-vault/references/jtbd-schema.md.
Field → destination
| Source field(s) | Destination |
|---|---|
name, hook | README tagline; PRODUCT.md title |
jtbd.{situation,motivation,outcome} | PRODUCT.md "The Job"; AGENTS.md product section |
problem.{what_hurts,cost_today} | PRODUCT.md "Problem" |
needs.{functional,emotional,social} | PRODUCT.md "Needs" |
switch_forces.* | PRODUCT.md "Switch forces" |
before_after.* | PRODUCT.md "Before / After" |
scenarios[] | PRODUCT.md "Scenarios" |
guardrails[] | AGENTS.md "Must NOT do"; PRODUCT.md "Guardrails"; guardrails-check.md |
evidence.quotes[] | PRODUCT.md "Evidence" |
open_questions[] | PRODUCT.md "Open questions" |
source path, version | PRODUCT.md + AGENTS.md footers (Source JTBD: <path>) |
| (whole file) | copied verbatim to project-root jtbd.json |
Rendering rules
- Scalars:
{{field}}(dotted, e.g.{{jtbd.situation}}). - Arrays: a block delimited by
<!-- each:FIELD -->...<!-- /each -->containing one{{item}}
line; the renderer repeats it per element. scenarios[] items are objects: use {{item.title}} and {{item.vignette}}.
- Missing optional field → render an empty string; an empty array → omit the whole
eachblock. - Required fields (
name,hook,jtbd) — if absent, ingestion is skipped entirely (see SKILL.md 1.5).
# {{name}} — Product Brief
> {{hook}}
## The Job
- **When** {{jtbd.situation}}
- **I want to** {{jtbd.motivation}}
- **So I can** {{jtbd.outcome}}
## Problem
- **What hurts:** {{problem.what_hurts}}
- **Cost today:** {{problem.cost_today}}
## Needs
**Functional**
<!-- each:needs.functional -->
- {{item}}
<!-- /each -->
**Emotional**
<!-- each:needs.emotional -->
- {{item}}
<!-- /each -->
**Social**
<!-- each:needs.social -->
- {{item}}
<!-- /each -->
## Switch forces
- **Push:** {{switch_forces.push}}
- **Pull:** {{switch_forces.pull}}
- **Habit:** {{switch_forces.habit}}
- **Anxiety:** {{switch_forces.anxiety}}
## Before / After
- **Before:** {{before_after.before}}
- **After:** {{before_after.after}}
## Scenarios
<!-- each:scenarios -->
- **{{item.title}}** — {{item.vignette}}
<!-- /each -->
## Guardrails (must NOT do)
<!-- each:guardrails -->
- {{item}}
<!-- /each -->
## Evidence
<!-- each:evidence.quotes -->
- {{item}}
<!-- /each -->
## Open questions
<!-- each:open_questions -->
- {{item}}
<!-- /each -->
---
Source JTBD: {{__source_path__}} (schema v{{version}})
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "mcp",
"description": "Capability for MCP-driven windows",
"windows": ["main"],
"permissions": ["core:default"]
}
rmcp = { version = "1.7", features = ["server", "macros"] }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
schemars = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = env!("CARGO_PKG_NAME"))]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand)]
pub enum Command {
/// Run as an MCP server over stdio
McpStdio,
/// Print a pong and exit
Ping { message: String },
}
Insert: CLI + MCP server
1. Copy cli.rs, mcp.rs, protocol.rs → src-tauri/src/. 2. Copy capability.json → src-tauri/capabilities/mcp.json. 3. Merge cargo-deps.toml lines into src-tauri/Cargo.toml under [dependencies] (skip any key already present; keep the higher version on conflict). Add anyhow = "1". 4. In src-tauri/src/lib.rs: add mod cli; mod mcp; mod protocol; near the top. 5. In src-tauri/src/main.rs: before building the Tauri app, parse the CLI and branch:
let cli = <crate>::cli::Cli::parse();
if let Some(<crate>::cli::Command::McpStdio) = cli.command {
return tokio::runtime::Runtime::new()?.block_on(<crate>::mcp::run_stdio());
}
if let Some(<crate>::cli::Command::Ping { message }) = cli.command {
println!("{}", <crate>::mcp::ping(<crate>::protocol::PingRequest { message }).reply);
return Ok(());
}(<crate> = the lib crate name from Cargo.toml.) `fn main()` must return `anyhow::Result<()>` for the ? and return Ok(()) above to compile: change its signature to fn main() -> anyhow::Result<()> and add Ok(()) as the final expression (after the existing <crate>::run() call). 6. Verify: cd src-tauri && cargo check && cargo test protocol.
// assets/modules/cli-mcp/mcp.rs
use crate::protocol::{PingRequest, PingResponse};
// NOTE: adapt the exact rmcp server boilerplate from cenno/src-tauri/src/mcp.rs.
// Expose ONE tool `ping(PingRequest) -> PingResponse`. Keep it minimal.
pub async fn run_stdio() -> anyhow::Result<()> {
// build rmcp server with the `ping` tool, serve over stdio
todo!("see cenno/src-tauri/src/mcp.rs for the rmcp serve-over-stdio shape")
}
pub fn ping(req: PingRequest) -> PingResponse {
PingResponse { reply: format!("pong: {}", req.message) }
}
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PingRequest { pub message: String }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PingResponse { pub reply: String }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let r = PingRequest { message: "hi".into() };
let s = serde_json::to_string(&r).unwrap();
let back: PingRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back.message, "hi");
}
}
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
frontend:
name: Frontend checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
rust:
name: Rust checks
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install pinned Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Format check
run: cd src-tauri && cargo fmt --check
- name: Clippy
run: cd src-tauri && cargo clippy -- -D warnings
- name: Tests
run: cd src-tauri && cargo test
Insert: Release + preflight
1. Copy preflight.sh, release.sh → scripts/ (chmod +x both). 2. Copy ci.yml, release.yml → .github/workflows/. 3. Substitute <name> throughout. 4. Verify: bash -n scripts/preflight.sh scripts/release.sh && bash scripts/preflight.sh hook.
#!/usr/bin/env bash
set -euo pipefail
TIER="${1:-quick}" # hook | quick | full | release
run() { echo "+ $*"; "$@"; }
case "$TIER" in
hook) run bash -n scripts/*.sh ;;
quick) run npm run build; (cd src-tauri && run cargo check) ;;
full) "$0" quick; (cd src-tauri && run cargo fmt --check && run cargo clippy -- -D warnings && run cargo test) ;;
release) "$0" full; run bash scripts/check-versions.sh; run npm run tauri build ;;
*) echo "unknown tier: $TIER" >&2; exit 2 ;;
esac
echo "preflight $TIER OK"
#!/usr/bin/env bash
#
# release.sh — build, notarize, and publish a <name> release to GitHub.
#
# Reads ALL secrets from the environment; nothing is typed inline or stored
# in this file. Export these before running (sourced from your secrets vault
# — see README "Releasing an update"):
#
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD updater key password
# TAURI_SIGNING_PRIVATE_KEY_PATH path to the minisign private key file
# APPLE_API_KEY, APPLE_API_ISSUER, APPLE_API_KEY_PATH App Store Connect key
# APPLE_SIGNING_IDENTITY "Developer ID Application: … (TEAMID)"
#
# Usage:
#
# scripts/release.sh # build + publish from current version
# scripts/release.sh --dry-run # build + make latest.json, skip push/release
#
set -euo pipefail
REPO="glebis/<name>"
KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-$HOME/.tauri/<name>.key}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
cd "$ROOT"
# --- Preflight: required env, version agreement -----------------
missing=()
for v in TAURI_SIGNING_PRIVATE_KEY_PASSWORD APPLE_API_KEY APPLE_API_ISSUER \
APPLE_API_KEY_PATH APPLE_SIGNING_IDENTITY; do
[[ -z "${!v:-}" ]] && missing+=("$v")
done
if ((${#missing[@]})); then
echo "error: missing env vars: ${missing[*]}" >&2
echo "see README 'Releasing an update' for what to export." >&2
exit 1
fi
[[ -f "$KEY_PATH" ]] || { echo "error: signing key not found at $KEY_PATH" >&2; exit 1; }
VERSION="$(node -p "require('./package.json').version")"
CONF_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")"
if [[ "$VERSION" != "$CONF_VERSION" ]]; then
echo "error: version mismatch — package.json $VERSION vs tauri.conf.json $CONF_VERSION" >&2
exit 1
fi
TAG="v$VERSION"
echo "==> releasing $TAG"
if git rev-parse "$TAG" >/dev/null 2>&1 || \
gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "error: $TAG already exists (tag or release). Bump the version first." >&2
exit 1
fi
# --- Build (PATH=/usr/bin first: shadow conda/Python xattr that breaks bundling) ---
echo "==> building (signed, notarized, updater artifacts)"
PATH="/usr/bin:$PATH" \
TAURI_SIGNING_PRIVATE_KEY="$(cat "$KEY_PATH")" \
npx tauri build
BUNDLE="src-tauri/target/release/bundle"
DMG="$(ls "$BUNDLE"/dmg/<name>_"$VERSION"_*.dmg)"
TARGZ="$BUNDLE/macos/<name>.app.tar.gz"
SIG="$TARGZ.sig"
for f in "$DMG" "$TARGZ" "$SIG"; do
[[ -f "$f" ]] || { echo "error: expected artifact missing: $f" >&2; exit 1; }
done
# --- latest.json (the updater manifest) -------------------------------------
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp "$DMG" "$STAGE/"
cp "$TARGZ" "$STAGE/<name>.app.tar.gz"
cp "$SIG" "$STAGE/<name>.app.tar.gz.sig"
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
NOTES="$(awk "/^## \[$VERSION\]/{f=1;next} /^## \[/{f=0} f" CHANGELOG.md | tr '\n' ' ' | sed 's/ */ /g;s/^ *//;s/ *$//')"
node -e "
const fs=require('fs');
fs.writeFileSync('$STAGE/latest.json', JSON.stringify({
version: '$VERSION',
notes: process.env.N || 'See CHANGELOG.md.',
pub_date: '$PUB_DATE',
platforms: { 'darwin-aarch64': {
signature: fs.readFileSync('$SIG','utf8').trim(),
url: 'https://github.com/$REPO/releases/download/$TAG/<name>.app.tar.gz'
}}
}, null, 2));
" N="$NOTES"
echo "==> latest.json:"; cat "$STAGE/latest.json"
if ((DRY_RUN)); then
echo "==> --dry-run: artifacts staged in $STAGE (not pushed). Copying out…"
OUT="$ROOT/dist-release-$VERSION"; mkdir -p "$OUT"; cp "$STAGE"/* "$OUT/"
echo " $OUT"
trap - EXIT
exit 0
fi
# --- Publish ----------------------------------------------------------------
echo "==> pushing main"
git push origin main
echo "==> creating GitHub release $TAG"
gh release create "$TAG" --repo "$REPO" --title "<name> $TAG" \
--notes "${NOTES:-Release $TAG. See CHANGELOG.md.}" \
"$STAGE/$(basename "$DMG")" \
"$STAGE/<name>.app.tar.gz" \
"$STAGE/<name>.app.tar.gz.sig" \
"$STAGE/latest.json"
echo "==> verifying live endpoint"
sleep 3
curl -sL "https://github.com/$REPO/releases/latest/download/latest.json" \
| node -e "const d=JSON.parse(require('fs').readFileSync(0));console.log('live version:',d.version)"
echo "==> done: https://github.com/$REPO/releases/tag/$TAG"
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
- platform: macos-latest
args: '--target x86_64-apple-darwin'
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- name: Install pinned Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Install dependencies
run: npm ci
- name: Build frontend
run: npm run build
- name: Verify release secrets
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
test -n "$APPLE_CERTIFICATE"
test -n "$APPLE_CERTIFICATE_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_PASSWORD"
test -n "$APPLE_TEAM_ID"
test -n "$KEYCHAIN_PASSWORD"
test -n "$TAURI_SIGNING_PRIVATE_KEY"
- name: Import Apple signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH="$RUNNER_TEMP/certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/<name>-signing.keychain-db"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
CERT_ID=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F '"' '/Developer ID Application/ { print $2; exit }')
test -n "$CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> "$GITHUB_ENV"
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: '<name> v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
rusqlite = { version = "0.32", features = ["bundled"] }
// tests/compat_golden.rs — adapt from cull/src-tauri/tests/compat_golden.rs
// Opens a frozen DB fixture from a prior version and asserts migrations still apply cleanly.
// Scaffold only: documents the pattern; a real fixture is committed once v0.2+ exists.
#[test]
#[ignore = "enable once a versioned DB fixture exists; see cull compat_golden"]
fn migrations_apply_to_prior_version_db() {
// 1. copy tests/fixtures/golden-v0.1.db to a temp path
// 2. <crate>::db::open(temp_path) — must not error
// 3. assert expected tables exist
}
use rusqlite::Connection;
use std::path::Path;
use crate::migrations;
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
migrations::apply(&conn)?;
Ok(conn)
}
pub fn insert_item(conn: &Connection, name: &str) -> rusqlite::Result<i64> {
conn.execute("INSERT INTO items (name) VALUES (?1)", [name])?;
Ok(conn.last_insert_rowid())
}
pub fn list_items(conn: &Connection) -> rusqlite::Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare("SELECT id, name FROM items ORDER BY id")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_list() {
let conn = Connection::open_in_memory().unwrap();
migrations::apply(&conn).unwrap();
insert_item(&conn, "a").unwrap();
insert_item(&conn, "b").unwrap();
let items = list_items(&conn).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].1, "a");
}
}
Insert: SQLite + migrations
1. Copy db.rs, migrations.rs → src-tauri/src/. 2. Copy compat_golden.rs → src-tauri/tests/compat_golden.rs. 3. Merge cargo-deps.toml into src-tauri/Cargo.toml [dependencies]. 4. In src-tauri/src/lib.rs: add mod db; mod migrations;. 5. Verify: cd src-tauri && cargo check && cargo test db::tests.
use rusqlite::Connection;
pub const MIGRATIONS: &[&str] = &[
"CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
];
pub fn apply(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("CREATE TABLE IF NOT EXISTS _migrations (idx INTEGER PRIMARY KEY);")?;
let done: i64 = conn.query_row("SELECT COUNT(*) FROM _migrations", [], |r| r.get(0))?;
for (i, sql) in MIGRATIONS.iter().enumerate().skip(done as usize) {
conn.execute_batch(sql)?;
conn.execute("INSERT INTO _migrations (idx) VALUES (?1)", [i as i64])?;
}
Ok(())
}
// --- swift-sidecar module (macOS only) ---
#[cfg(target_os = "macos")]
{
use swift_rs::SwiftLinker;
SwiftLinker::new("13.0").with_package("AppSidecar", "swift").link();
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift");
}
swift-rs = { version = "1.0.6", features = ["build"] }
Insert: Swift sidecar (macOS only)
PREREQ: macOS + xcrun --find swift succeeds. If not, SKIP this module (log it).
1. Copy Package.swift → src-tauri/swift/Package.swift. 2. Copy Sidecar.swift → src-tauri/swift/Sources/AppSidecar/Sidecar.swift. 3. Copy sidecar_ffi.rs → src-tauri/src/sidecar_ffi.rs; add mod sidecar_ffi; to lib.rs. 4. Merge cargo-deps.toml line into src-tauri/Cargo.toml under BOTH [dependencies] and [build-dependencies]. 5. Merge build.rs.fragment into src-tauri/build.rs BEFORE the tauri_build::build() call. 6. Verify (macOS): cd src-tauri && cargo check.
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "AppSidecar",
platforms: [.macOS(.v13)],
products: [
.library(name: "AppSidecar", type: .static, targets: ["AppSidecar"]),
],
targets: [
.target(name: "AppSidecar", linkerSettings: [
.linkedFramework("Foundation"),
]),
]
)
// macOS-only FFI to the AppSidecar Swift static lib.
#[cfg(target_os = "macos")]
mod ffi {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
extern "C" {
fn app_sidecar_greet(name: *const c_char) -> *mut c_char;
fn app_sidecar_free(p: *mut c_char);
}
pub fn greet(name: &str) -> String {
let c = CString::new(name).unwrap_or_default();
unsafe {
let p = app_sidecar_greet(c.as_ptr());
if p.is_null() { return String::new(); }
let s = CStr::from_ptr(p).to_string_lossy().into_owned();
app_sidecar_free(p);
s
}
}
}
#[cfg(target_os = "macos")]
pub use ffi::greet;
import Foundation
// Kind tags — KEEP IN SYNC with the Rust side (see sidecar_ffi.rs).
// 0 = OK, 1 = ERROR
@_cdecl("app_sidecar_greet")
public func app_sidecar_greet(_ name: UnsafePointer<CChar>?) -> UnsafeMutablePointer<CChar>? {
let who = name.flatMap { String(validatingUTF8: $0) } ?? "world"
return strdup("hello, \(who)")
}
@_cdecl("app_sidecar_free")
public func app_sidecar_free(_ p: UnsafeMutablePointer<CChar>?) {
if let p { free(p) }
}
tauri-plugin-window-state = "2"
tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
{
"plugins": {
"updater": {
"endpoints": ["https://github.com/glebis/<name>/releases/latest/download/latest.json"],
"pubkey": "REPLACE_WITH_MINISIGN_PUBKEY"
}
},
"bundle": {
"createUpdaterArtifacts": true,
"macOS": { "hardenedRuntime": true, "minimumSystemVersion": "12.0" }
}
}
Insert: Tray + autostart + updater
1. Copy tray.rs, updater.rs → src-tauri/src/. 2. Merge cargo-deps.toml into src-tauri/Cargo.toml [dependencies]. Also enable the `tray-icon` feature on the `tauri` crate — TrayIconBuilder will not compile without it. Edit the tauri = { version = "2", features = [...] } line to include "tray-icon" (e.g. features = ["tray-icon"]). 3. Deep-merge conf-fragment.json into src-tauri/tauri.conf.json (substitute <name>). Leave pubkey as the REPLACE marker; AGENTS.md documents generating it with npm run tauri signer generate. 4. In src-tauri/src/lib.rs: add mod tray; mod updater; near the top. Register the three plugins on the builder and build the tray in .setup(...), using these exact forms:
use tauri_plugin_autostart::MacosLauncher;
// ...inside tauri::Builder::default():
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
crate::tray::build(app.handle())?;
Ok(())
})5. Verify: cd src-tauri && cargo check.
use tauri::{AppHandle, Manager};
use tauri::tray::TrayIconBuilder;
use tauri::menu::{Menu, MenuItem};
pub fn build(app: &AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } }
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
}
// Auto-update via tauri-plugin-updater. Endpoint + pubkey are set in tauri.conf.json
// (see conf-fragment.json). This module just documents the wiring; the plugin does the work.
// To trigger a check from Rust, use the plugin's `app.updater()?.check().await` in a command.
#!/usr/bin/env bash
# Render a jtbd template by substituting fields from a jtbd.json.
# Usage: render-jtbd.sh <jtbd.json> <template> <source_path_label>
# Prints the rendered text to stdout. Exit 3 = invalid/missing required fields.
set -euo pipefail
JSON="$1"; TPL="$2"; SRC="${3:-$1}"
node - "$JSON" "$TPL" "$SRC" <<'NODE'
const fs = require('fs');
const [json, tpl, src] = process.argv.slice(2);
let data;
try { data = JSON.parse(fs.readFileSync(json, 'utf8')); }
catch (e) { console.error('invalid JSON'); process.exit(3); }
if (!data.name || !data.hook || !data.jtbd) { console.error('missing required fields'); process.exit(3); }
data.__source_path__ = src;
const get = (obj, path) => path.split('.').reduce((o,k)=> (o==null?undefined:o[k]), obj);
let t = fs.readFileSync(tpl, 'utf8');
// expand each-blocks
t = t.replace(/<!-- each:([\w.]+) -->\n([\s\S]*?)<!-- \/each -->\n?/g, (_, field, body) => {
const arr = get(data, field);
if (!Array.isArray(arr) || arr.length === 0) return '';
return arr.map(el => body.replace(/\{\{item(?:\.([\w]+))?\}\}/g,
(_, k) => String(k ? (el?.[k] ?? '') : el))).join('');
});
// scalars
t = t.replace(/\{\{([\w.]+)\}\}/g, (_, f) => { const v = get(data, f); return v==null ? '' : String(v); });
process.stdout.write(t);
NODE
#!/usr/bin/env bash
# Verifies JTBD ingestion renders the right artifacts into a throwaway project dir.
set -euo pipefail
SKILL=~/ai_projects/claude-skills/init-tauri-app
TMP="$(mktemp -d)"; cd "$TMP"
mkdir -p proj/docs/internal proj/.claude
printf '# proj\n\nA Tauri app.\n' > proj/AGENTS.md # stand-in for the core AGENTS.md
F="$SKILL/assets/jtbd/fixtures/sample-jtbd.json"
bash "$SKILL/scripts/render-jtbd.sh" "$F" "$SKILL/assets/jtbd/PRODUCT.md.template" "$F" > proj/docs/PRODUCT.md
bash "$SKILL/scripts/render-jtbd.sh" "$F" "$SKILL/assets/jtbd/guardrails-check.md.template" "$F" > proj/docs/internal/guardrails-check.md
bash "$SKILL/scripts/render-jtbd.sh" "$F" "$SKILL/assets/jtbd/agents-product-section.md.template" "$F" > proj/.jtbd-section.md
# insert section after first heading (read section from file — portable across BSD/GNU awk)
awk 'NR==FNR{sec=sec $0 ORS; next} FNR==1{print; print ""; printf "%s", sec; next} {print}' proj/.jtbd-section.md proj/AGENTS.md > proj/AGENTS.md.new && mv proj/AGENTS.md.new proj/AGENTS.md
trash proj/.jtbd-section.md
cp "$F" proj/jtbd.json
# assertions
grep -q "Capture a fleeting idea" proj/docs/PRODUCT.md
grep -q "Must NOT do" proj/AGENTS.md
grep -q "one project per session" proj/docs/internal/guardrails-check.md
test -f proj/jtbd.json
echo "JTBD_SMOKE_PASS dir=$TMP"
#!/usr/bin/env bash
# Scaffolds throwaway projects to verify the skill end-to-end.
# Usage: smoke.sh <framework> <modules-csv|none>
set -euo pipefail
FW="$1"; MODS="$2"
TMP="$(mktemp -d)"; NAME="smoke-${FW//-/}"
echo "=== $FW / $MODS in $TMP ==="
cd "$TMP"
npm create tauri-app@latest "$NAME" -- --template "$FW" --manager npm --yes
cd "$NAME" && npm install
# NOTE: the executing agent applies core + selected modules here per SKILL.md,
# since composition is agent-driven. This harness then runs the gates:
( cd src-tauri && cargo check )
npm run build
echo "=== PASS: $FW / $MODS ==="