# Template System Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `--template` flag to `hatk new` so users can scaffold from bundled starter apps (starting with statusphere). **Architecture:** Templates are directories in `packages/appview/templates//` with a `template.json` manifest. The `hatk new` command runs the normal scaffold first, then overlays template files on top, merging config and dependencies from the manifest. **Tech Stack:** Node.js fs APIs, YAML (already a dependency), JSON deep merge --- ## File Structure | File | Action | Purpose | |------|--------|---------| | `packages/appview/src/cli.ts` | Modify | Add `--template` flag parsing, template loading, overlay logic | | `packages/appview/templates/statusphere/template.json` | Create | Template manifest | | `packages/appview/templates/statusphere/lexicons/` | Create | Custom lexicon schemas | | `packages/appview/templates/statusphere/feeds/recent.ts` | Create | Feed generator | | `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts` | Create | XRPC handler | | `packages/appview/templates/statusphere/seeds/seed.ts` | Create | Seed data | | `packages/appview/templates/statusphere/test/` | Create | Tests + fixtures | | `packages/appview/templates/statusphere/src/` | Create | SvelteKit frontend | --- ## Chunk 1: Template Infrastructure in CLI ### Task 1: Add --template flag parsing and usage text **Files:** - Modify: `packages/appview/src/cli.ts:39-77` (usage function) - Modify: `packages/appview/src/cli.ts:307-315` (new command args) - [ ] **Step 1: Update usage text** In `usage()` (line 41), change: ``` new [--svelte] Create a new hatk project ``` to: ``` new [--svelte] [--template ] Create a new hatk project ``` - [ ] **Step 2: Parse --template flag** After line 314 (`const withSvelte = args.includes('--svelte')`), add: ```typescript const templateIdx = args.indexOf('--template') const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null if (templateIdx !== -1 && !templateName) { console.error('Usage: hatk new --template ') process.exit(1) } ``` - [ ] **Step 3: Validate template exists** After the template parsing, add: ```typescript let template: { description?: string; svelte?: boolean; dependencies?: Record; devDependencies?: Record; config?: Record } | null = null let templateDir: string | null = null if (templateName) { templateDir = join(import.meta.dirname, '..', 'templates', templateName) if (!existsSync(templateDir)) { const available = readdirSync(join(import.meta.dirname, '..', 'templates')).filter(f => existsSync(join(import.meta.dirname, '..', 'templates', f, 'template.json'))) console.error(`Unknown template: ${templateName}`) if (available.length) console.error(`Available templates: ${available.join(', ')}`) process.exit(1) } template = JSON.parse(readFileSync(join(templateDir, 'template.json'), 'utf-8')) } ``` - [ ] **Step 4: Add readFileSync to imports** At line 2, add `readFileSync` to the existing import: ```typescript import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync, readFileSync, cpSync } from 'node:fs' ``` - [ ] **Step 5: Make template's svelte flag apply** Change `const withSvelte = args.includes('--svelte')` to: ```typescript const withSvelte = args.includes('--svelte') || template?.svelte === true ``` Note: this line must come AFTER the template parsing, so move the `--svelte` check down. - [ ] **Step 6: Commit** ```bash git add packages/appview/src/cli.ts git commit -m "feat: add --template flag parsing to hatk new" ``` --- ### Task 2: Add template overlay logic **Files:** - Modify: `packages/appview/src/cli.ts` (after scaffold, before `execSync('npx hatk generate types'...)`) - [ ] **Step 1: Add overlay logic before the final type generation** Before line 1199 (`execSync('npx hatk generate types'...)`), add: ```typescript // Apply template overlay if (template && templateDir) { // Merge dependencies into package.json if (template.dependencies || template.devDependencies) { const pkgPath = join(dir, 'package.json') const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) if (template.dependencies) Object.assign(pkg.dependencies, template.dependencies) if (template.devDependencies) Object.assign(pkg.devDependencies, template.devDependencies) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') } // Merge config into config.yaml if (template.config) { const configPath = join(dir, 'config.yaml') const { parse, stringify } = await import('yaml') const config = parse(readFileSync(configPath, 'utf-8')) deepMerge(config, template.config) writeFileSync(configPath, stringify(config)) } // Copy template files (skip template.json) const entries = readdirSync(templateDir) for (const entry of entries) { if (entry === 'template.json') continue cpSync(join(templateDir, entry), join(dir, entry), { recursive: true, force: true }) } } ``` - [ ] **Step 2: Add deepMerge helper** Add before the `usage()` function (around line 38): ```typescript function deepMerge(target: Record, source: Record): void { for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { deepMerge(target[key], source[key]) } else { target[key] = source[key] } } } ``` - [ ] **Step 3: Add cpSync to imports** Ensure `cpSync` is in the import on line 2 (should already be there from Step 4 of Task 1). - [ ] **Step 4: Commit** ```bash git add packages/appview/src/cli.ts git commit -m "feat: add template overlay logic (config merge, deps merge, file copy)" ``` --- ## Chunk 2: Statusphere Template Files ### Task 3: Create template manifest and lexicons **Files:** - Create: `packages/appview/templates/statusphere/template.json` - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/defs.json` - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/status.json` - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/getProfile.json` - Create: `packages/appview/templates/statusphere/lexicons/app/bsky/actor/profile.json` - [ ] **Step 1: Create template.json** ```json { "description": "Statusphere example app", "svelte": true, "dependencies": { "@tanstack/svelte-query": "^5" }, "config": { "oauth": { "scopes": [ "atproto", "repo:xyz.statusphere.status?action=create&action=delete" ], "clients": [ { "client_id": "http://127.0.0.1:3000/oauth-client-metadata.json", "client_name": "statusphere", "scope": "atproto repo:xyz.statusphere.status?action=create&action=delete", "redirect_uris": ["http://127.0.0.1:3000/oauth/callback"] } ] } } } ``` - [ ] **Step 2: Create lexicon files** Copy the following from exercise 10 as-is (NSIDs stay as `xyz.statusphere.*`): - `lexicons/xyz/statusphere/defs.json` — profileView and statusView definitions - `lexicons/xyz/statusphere/status.json` — status record schema - `lexicons/xyz/statusphere/getProfile.json` — getProfile query - `lexicons/app/bsky/actor/profile.json` — Bluesky profile record - [ ] **Step 3: Commit** ```bash git add packages/appview/templates/statusphere/ git commit -m "feat: add statusphere template manifest and lexicons" ``` --- ### Task 4: Create template backend files **Files:** - Create: `packages/appview/templates/statusphere/feeds/recent.ts` - Create: `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts` - Create: `packages/appview/templates/statusphere/seeds/seed.ts` - [ ] **Step 1: Create feeds/recent.ts** Copy from exercise 10 but update imports: `'../appview.generated.ts'` → `'../hatk.generated.ts'` ```typescript import { defineFeed, views, type Status, type Profile, type HydrateContext } from '../hatk.generated.ts' export default defineFeed({ collection: 'xyz.statusphere.status', label: 'Recent', hydrate: (ctx) => hydrateStatuses(ctx), async generate(ctx) { const { rows, cursor } = await ctx.paginate<{ uri: string }>( `SELECT uri, cid, indexed_at FROM "xyz.statusphere.status"`, ) return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) }, }) async function hydrateStatuses(ctx: HydrateContext) { const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))] const profiles = await ctx.lookup('app.bsky.actor.profile', 'did', dids) return ctx.items.map((item) => { const author = profiles.get(item.did) return views.statusView({ uri: item.uri, status: item.value.status, createdAt: item.value.createdAt, indexedAt: item.indexed_at, author: views.profileView({ did: item.did, handle: item.handle || item.did, displayName: author?.value.displayName, avatar: author ? ctx.blobUrl(author.did, author.value.avatar, 'avatar') : undefined, }), }) }) } ``` - [ ] **Step 2: Create xrpc/xyz/statusphere/getProfile.ts** Copy from exercise 10 but update imports: `'../../../appview.generated.ts'` → `'../../../hatk.generated.ts'` ```typescript import { defineQuery, views, type Profile } from '../../../hatk.generated.ts' export default defineQuery('xyz.statusphere.getProfile', async (ctx) => { const { ok, params, lookup, blobUrl } = ctx const actor = params.actor as string const profiles = await lookup('app.bsky.actor.profile', 'did', [actor]) const profile = profiles.get(actor) if (!profile) { return ok(views.profileView({ did: actor, handle: actor })) } return ok(views.profileView({ did: actor, handle: profile.handle || actor, displayName: profile.value.displayName, description: profile.value.description, avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'), })) }) ``` - [ ] **Step 3: Create seeds/seed.ts** Copy from exercise 10 but update import: `'../appview.generated.ts'` → `'../hatk.generated.ts'` ```typescript import { seed } from '../hatk.generated.ts' const { createAccount, createRecord } = seed() const alice = await createAccount('alice.test') const bob = await createAccount('bob.test') const carol = await createAccount('carol.test') await createRecord(alice, 'app.bsky.actor.profile', { displayName: 'Alice', description: 'Emoji enthusiast', }, { rkey: 'self' }) await createRecord(bob, 'app.bsky.actor.profile', { displayName: 'Bob', description: 'Coffee lover', }, { rkey: 'self' }) await createRecord(carol, 'app.bsky.actor.profile', { displayName: 'Carol', description: 'Butterfly chaser', }, { rkey: 'self' }) const now = Date.now() const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString() await createRecord(alice, 'xyz.statusphere.status', { status: '🚀', createdAt: ago(5), }, { rkey: 'alice-1' }) await createRecord(alice, 'xyz.statusphere.status', { status: '😎', createdAt: ago(60), }, { rkey: 'alice-2' }) await createRecord(bob, 'xyz.statusphere.status', { status: '🧑‍💻', createdAt: ago(10), }, { rkey: 'bob-1' }) await createRecord(bob, 'xyz.statusphere.status', { status: '☕', createdAt: ago(120), }, { rkey: 'bob-2' }) await createRecord(carol, 'xyz.statusphere.status', { status: '🦋', createdAt: ago(15), }, { rkey: 'carol-1' }) await createRecord(carol, 'xyz.statusphere.status', { status: '💙', createdAt: ago(90), }, { rkey: 'carol-2' }) console.log('\n[seed] Done!') ``` - [ ] **Step 4: Commit** ```bash git add packages/appview/templates/statusphere/ git commit -m "feat: add statusphere template backend files" ``` --- ### Task 5: Create template test files **Files:** - Create: `packages/appview/templates/statusphere/test/feeds/recent.test.ts` - Create: `packages/appview/templates/statusphere/test/fixtures/_repos.yaml` - Create: `packages/appview/templates/statusphere/test/fixtures/app.bsky.actor.profile.yaml` - Create: `packages/appview/templates/statusphere/test/fixtures/xyz.statusphere.status.yaml` - [ ] **Step 1: Create test/feeds/recent.test.ts** Copy from exercise 10 but update import: `'appview/test'` → `'hatk/test'` ```typescript import { describe, test, expect, beforeAll, afterAll } from 'vitest' import { createTestContext } from 'hatk/test' let ctx: Awaited> beforeAll(async () => { ctx = await createTestContext() await ctx.loadFixtures() }) afterAll(async () => ctx?.close()) describe('recent feed', () => { test('returns all statuses', async () => { const feed = ctx.loadFeed('recent') const result = await feed.generate(ctx.feedContext({ limit: 10 })) expect(result.items).toHaveLength(6) }) test('each item has required fields', async () => { const feed = ctx.loadFeed('recent') const result = await feed.generate(ctx.feedContext({ limit: 10 })) for (const item of result.items as any[]) { expect(item.uri).toMatch(/^at:\/\//) expect(item.status).toBeDefined() expect(item.createdAt).toBeDefined() expect(item.author).toBeDefined() expect(item.author.did).toBeDefined() expect(item.author.handle).toBeDefined() } }) test('includes displayName from profile', async () => { const feed = ctx.loadFeed('recent') const result = await feed.generate(ctx.feedContext({ limit: 10 })) const aliceStatus = (result.items as any[]).find((s) => s.author.handle === 'alice.test') expect(aliceStatus?.author.displayName).toBe('Alice') }) test('respects limit', async () => { const feed = ctx.loadFeed('recent') const result = await feed.generate(ctx.feedContext({ limit: 2 })) expect(result.items).toHaveLength(2) expect(result.cursor).toBeDefined() }) test('cursor pagination returns next page', async () => { const feed = ctx.loadFeed('recent') const page1 = await feed.generate(ctx.feedContext({ limit: 3 })) expect(page1.items).toHaveLength(3) expect(page1.cursor).toBeDefined() const page2 = await feed.generate(ctx.feedContext({ limit: 3, cursor: page1.cursor })) expect(page2.items).toHaveLength(3) expect(page2.cursor).toBeUndefined() const allUris = [...(page1.items as any[]), ...(page2.items as any[])].map((s) => s.uri) expect(new Set(allUris).size).toBe(6) }) test('returns no cursor when all results fit', async () => { const feed = ctx.loadFeed('recent') const result = await feed.generate(ctx.feedContext({ limit: 30 })) expect(result.items).toHaveLength(6) expect(result.cursor).toBeUndefined() }) }) ``` - [ ] **Step 2: Create fixture YAML files** Copy from exercise 10 as-is: - `test/fixtures/_repos.yaml` - `test/fixtures/app.bsky.actor.profile.yaml` - `test/fixtures/xyz.statusphere.status.yaml` - [ ] **Step 3: Commit** ```bash git add packages/appview/templates/statusphere/test/ git commit -m "feat: add statusphere template test files and fixtures" ``` --- ### Task 6: Create template frontend files **Files:** - Create: `packages/appview/templates/statusphere/src/routes/+page.svelte` - Create: `packages/appview/templates/statusphere/src/routes/+layout.svelte` - Create: `packages/appview/templates/statusphere/src/routes/oauth/callback/+page.svelte` - Create: `packages/appview/templates/statusphere/src/lib/api.ts` - Create: `packages/appview/templates/statusphere/src/lib/auth.ts` - Create: `packages/appview/templates/statusphere/src/lib/query.ts` - Create: `packages/appview/templates/statusphere/src/app.html` - Create: `packages/appview/templates/statusphere/src/app.css` - Create: `packages/appview/templates/statusphere/src/error.html` - [ ] **Step 1: Create frontend files** Copy from exercise 10 with these updates: - `src/lib/api.ts`: `'appview/xrpc-client'` → `'hatk/xrpc-client'`, `'$appview'` → `'$hatk'` - `src/lib/auth.ts`: `'@appview/oauth-client'` → `'@hatk/oauth-client'` - `src/routes/+page.svelte`: `'xyz.appview.getFeed'` → `'dev.hatk.getFeed'`, `'xyz.appview.createRecord'` → `'dev.hatk.createRecord'`, `'xyz.appview.deleteRecord'` → `'dev.hatk.deleteRecord'`, `'$appview'` → `'$hatk'` - `src/routes/+layout.svelte`: no changes needed (already uses `$lib/` imports) - `src/app.html`: title stays as `Statusphere` - `src/app.css`: use the statusphere styling (with `--primary: #6366f1` instead of default teal) - `src/error.html`: title reference stays as statusphere - [ ] **Step 2: Commit** ```bash git add packages/appview/templates/statusphere/src/ git commit -m "feat: add statusphere template frontend files" ``` --- ## Chunk 3: Verification ### Task 7: Manual verification - [ ] **Step 1: Test bare scaffold still works** ```bash cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bare ``` Verify: directory created with standard scaffold, no template files. - [ ] **Step 2: Test statusphere template** ```bash cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-statusphere --template statusphere ``` Verify: - Has config.yaml with OAuth settings merged in - Has custom lexicons under `lexicons/xyz/statusphere/` - Has `feeds/recent.ts`, `xrpc/xyz/statusphere/getProfile.ts`, `seeds/seed.ts` - Has test files and fixtures - Has SvelteKit frontend files in `src/` - `package.json` includes `@tanstack/svelte-query` dependency - `hatk.generated.ts` includes StatusView and ProfileView types - [ ] **Step 3: Test invalid template name** ```bash cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bad --template nonexistent ``` Verify: error message listing available templates. - [ ] **Step 4: Clean up** ```bash rm -rf /tmp/test-bare /tmp/test-statusphere /tmp/test-bad ```