Skill: pi-provisioner

Application-level patterns for the pi-provisioner project.

7 stars

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

$curl -o ~/.claude/skills/pi-provisioner/SKILL.md --create-dirs "https://raw.githubusercontent.com/heldernoid/agentic-build-templates/main/projects/hardware-iot/pi-provisioner/skills/pi-provisioner/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/pi-provisioner/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How Skill: pi-provisioner Compares

Feature / AgentSkill: pi-provisionerStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/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

7
from heldernoid/agentic-build-templates

## Overview

Skill: Status Page

7
from heldernoid/agentic-build-templates

## Overview

Skill: unit-conversion

7
from heldernoid/agentic-build-templates

## Overview

Skill: recipe-scaler

7
from heldernoid/agentic-build-templates

## Overview

reading-list

7
from heldernoid/agentic-build-templates

Operate the reading-list API to save, manage, tag, search, and export articles.

email-digest

7
from heldernoid/agentic-build-templates

Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.

websocket-realtime

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

## Overview

Skill: csv-import

7
from heldernoid/agentic-build-templates

## Overview

Skill: Syntax Highlighting

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Pastebin Core

7
from heldernoid/agentic-build-templates

## Purpose