Skill: pi-provisioner
Application-level patterns for the pi-provisioner project.
Best use case
Skill: pi-provisioner is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Application-level patterns for the pi-provisioner project.
Teams using Skill: pi-provisioner should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/pi-provisioner/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How Skill: pi-provisioner Compares
| Feature / Agent | Skill: pi-provisioner | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Application-level patterns for the pi-provisioner project.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Skill: pi-provisioner
Application-level patterns for the pi-provisioner project.
## Wizard Zustand store
```ts
// packages/client/src/store/useWizardStore.ts
import { create } from 'zustand';
interface WizardStore {
step: 1 | 2 | 3 | 4 | 5;
image: OsImage | null;
device: BlockDevice | null;
config: Partial<ProvisionConfig>;
jobId: number | null;
setStep: (step: 1 | 2 | 3 | 4 | 5) => void;
setImage: (image: OsImage) => void;
setDevice: (device: BlockDevice) => void;
setConfig: (config: Partial<ProvisionConfig>) => void;
setJobId: (id: number) => void;
reset: () => void;
}
const initialState = {
step: 1 as const,
image: null,
device: null,
config: {},
jobId: null,
};
export const useWizardStore = create<WizardStore>((set) => ({
...initialState,
setStep: (step) => set({ step }),
setImage: (image) => set({ image }),
setDevice: (device) => set({ device }),
setConfig: (config) => set((s) => ({ config: { ...s.config, ...config } })),
setJobId: (jobId) => set({ jobId }),
reset: () => set(initialState),
}));
```
## SSE provision progress hook
```ts
// packages/client/src/hooks/useProvisionStream.ts
import { useEffect, useState } from 'react';
export interface ProvisionProgress {
stage: 'downloading' | 'writing' | 'configuring' | 'done' | 'failed';
progress: number;
message: string;
error?: string;
}
export function useProvisionStream(jobId: number | null) {
const [progress, setProgress] = useState<ProvisionProgress | null>(null);
useEffect(() => {
if (!jobId) return;
const es = new EventSource(`/api/provision/${jobId}/progress`);
es.onmessage = (e) => {
const data: ProvisionProgress = JSON.parse(e.data);
setProgress(data);
if (data.stage === 'done' || data.stage === 'failed') {
es.close();
}
};
es.onerror = () => es.close();
return () => es.close();
}, [jobId]);
return progress;
}
```
## Starting a provision
```ts
async function startProvision(config: ProvisionConfig): Promise<number> {
const res = await fetch('/api/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? 'Failed to start provision');
}
const data = await res.json();
return data.id; // provision job ID
}
```
## Step5Progress component - connect SSE to progress bars
```tsx
// packages/client/src/wizard/Step5Progress.tsx
export function Step5Progress() {
const { jobId, reset } = useWizardStore();
const progress = useProvisionStream(jobId);
const stages = ['downloading', 'writing', 'configuring'];
function stageStatus(stage: string) {
if (!progress) return 'waiting';
const idx = stages.indexOf(stage);
const curIdx = stages.indexOf(progress.stage);
if (progress.stage === 'done' || (curIdx > idx)) return 'done';
if (progress.stage === stage) return 'running';
return 'waiting';
}
if (progress?.stage === 'done') {
return <SuccessCard onProvisionAnother={reset} />;
}
if (progress?.stage === 'failed') {
return <ErrorCard message={progress.error ?? 'Unknown error'} onRetry={reset} />;
}
return (
<div>
{stages.map((stage) => (
<StageRow
key={stage}
name={stage}
status={stageStatus(stage)}
progress={progress?.stage === stage ? progress.progress : 0}
message={progress?.stage === stage ? progress.message : null}
/>
))}
</div>
);
}
```
## Server-side provision route
```ts
// packages/server/src/routes/provision.ts
import { z } from 'zod';
const ProvisionSchema = z.object({
image_id: z.string(),
device: z.string().regex(/^\/dev\//),
hostname: z.string().min(1).max(63).regex(/^[a-zA-Z0-9-]+$/),
enable_ssh: z.boolean(),
wifi_ssid: z.string().nullable(),
wifi_password: z.string().nullable(),
wifi_country: z.string().length(2).nullable(),
username: z.string().min(1).max(32),
password_hash: z.string().nullable(),
});
router.post('/', async (req, res) => {
const parsed = ProvisionSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0].message });
}
// Prevent writing to the system/boot disk
const devices = await listBlockDevices();
const target = devices.find((d) => d.path === parsed.data.device);
if (!target) return res.status(400).json({ error: 'Device not found' });
if (target.is_boot) return res.status(400).json({ error: 'Cannot write to system boot device' });
const id = insertProvision(db, {
image_id: parsed.data.image_id,
device: parsed.data.device,
hostname: parsed.data.hostname ?? null,
started_at: Date.now(),
});
// Run provision in background - do not await
engine.start(id, parsed.data).catch((err) => {
updateProvision(db, id, { status: 'failed', error: err.message, finished_at: Date.now() });
});
res.status(202).json({ id });
});
```
## Provision engine emitter
```ts
// packages/server/src/provision/engine.ts
import EventEmitter from 'events';
export const provisionEmitter = new EventEmitter();
export async function start(jobId: number, config: ProvisionConfig) {
function emit(data: ProvisionProgress) {
provisionEmitter.emit(String(jobId), data);
}
try {
emit({ stage: 'downloading', progress: 0, message: 'Checking image cache...' });
const imagePath = await ensureImageCached(config.image_id, (p) =>
emit({ stage: 'downloading', progress: p, message: `Downloading... ${p}%` })
);
emit({ stage: 'writing', progress: 0, message: `Writing to ${config.device}...` });
await writeImage(imagePath, config.device, (bytes, total) => {
const pct = Math.round((bytes / total) * 100);
emit({ stage: 'writing', progress: pct, message: `${formatBytes(bytes)} / ${formatBytes(total)}` });
});
emit({ stage: 'configuring', progress: 0, message: 'Mounting boot partition...' });
const mountPoint = await mountBootPartition(config.device);
await injectConfig(mountPoint, config);
await unmount(mountPoint);
emit({ stage: 'done', progress: 100, message: 'Provision complete!' });
updateProvision(db, jobId, { status: 'done', finished_at: Date.now() });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
emit({ stage: 'failed', progress: 0, message, error: message });
updateProvision(db, jobId, { status: 'failed', error: message, finished_at: Date.now() });
throw err;
}
}
```
## SSE route pattern
```ts
router.get('/:id/progress', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
function onEvent(data: ProvisionProgress) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
provisionEmitter.on(req.params.id, onEvent);
req.on('close', () => provisionEmitter.off(req.params.id, onEvent));
});
```
## TanStack Query for images list
```ts
// packages/client/src/hooks/useImages.ts
import { useQuery } from '@tanstack/react-query';
export function useImages() {
return useQuery({
queryKey: ['images'],
queryFn: async (): Promise<OsImage[]> => {
const res = await fetch('/api/images');
if (!res.ok) throw new Error('Failed to load images');
return res.json();
},
refetchInterval: 3000, // poll while download is in progress
});
}
```
## Download image from Step 1
```ts
async function downloadImage(imageId: string) {
const res = await fetch(`/api/images/${imageId}/download`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start download');
// useImages() polling will pick up progress automatically
}
```
## Cancel provision
```ts
async function cancelProvision(jobId: number) {
await fetch(`/api/provision/${jobId}/cancel`, { method: 'POST' });
}
```
Server side: store the `execa` child process ref, kill it on cancel:
```ts
const activeProcesses = new Map<number, import('execa').ExecaChildProcess>();
// In writer.ts: store the process
activeProcesses.set(jobId, proc);
// In cancel route:
router.post('/:id/cancel', (req, res) => {
const proc = activeProcesses.get(parseInt(req.params.id, 10));
if (proc) { proc.kill('SIGTERM'); }
updateProvision(db, parseInt(req.params.id), { status: 'cancelled', finished_at: Date.now() });
res.json({ ok: true });
});
```
## Vite proxy config
```ts
// packages/client/vite.config.ts
export default {
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
};
```
## Password hashing (openssl sha512crypt)
Pi OS reads `userconf` as `username:sha512crypt_hash`. Generate the hash on the server:
```ts
import { execa } from 'execa';
export async function hashPassword(password: string): Promise<string> {
const { stdout } = await execa('openssl', ['passwd', '-6', password]);
return stdout.trim();
}
```
The `userconf` file format: `pi:$6$rounds=...` (one line, no newline).Related Skills
Skill: Uptime Monitoring
## Overview
Skill: Status Page
## Overview
Skill: unit-conversion
## Overview
Skill: recipe-scaler
## Overview
reading-list
Operate the reading-list API to save, manage, tag, search, and export articles.
email-digest
Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.
websocket-realtime
Use the WebSocket connection in poll-builder to receive live vote updates. Use when you need to stream real-time poll results, monitor a poll for new votes, or build a live dashboard. Triggers include "live results", "real-time updates", "stream votes", "watch poll", or "WebSocket".
poll-builder
Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.
Skill: personal-finance
## Overview
Skill: csv-import
## Overview
Skill: Syntax Highlighting
## Purpose
Skill: Pastebin Core
## Purpose