From f315334d1ae846b1931285b0a32aa3fd59df0b9c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 15:43:16 +1000 Subject: [PATCH 01/10] feat(cli): hand off init to Claude Code with versioned rulebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After authentication, schema generation, and Forge install, init now offers to hand off the rest of setup to Claude Code. Choosing the handoff installs a project-local skill at .claude/skills/cipherstash-setup/SKILL.md and writes .cipherstash/context.json describing the integration, encryption client path, columns, env keys, and package manager — then spawns claude interactively. The skill body comes from a versioned rulebook (core + integration partials) that ships bundled with the CLI. When wizard.getstash.sh is reachable, the CLI prefers the gateway-served version so content updates between releases land without a CLI bump; network failures fall through to the bundled copy silently. CIPHERSTASH_WIZARD_URL overrides the gateway endpoint for local testing. Re-running init upserts the managed region of SKILL.md via sentinel markers () so user edits outside the block are preserved. Three completion modes are available at the new "how to proceed" step: - Hand off to Claude Code (default when claude detected) - Just write the rules files (no spawn — drive your own agent) - Use the CipherStash Agent (run stash wizard later) Phase 1 only targets Claude Code; Codex (AGENTS.md), Cursor, and Copilot writers are scoped for follow-up phases. --- .changeset/cli-init-agent-handoff.md | 19 ++ packages/cli/package.json | 1 + .../init/__tests__/detect-agents.test.ts | 48 +++++ .../init/__tests__/sentinel-upsert.test.ts | 50 +++++ .../cli/src/commands/init/detect-agents.ts | 91 +++++++++ packages/cli/src/commands/init/index.ts | 4 + .../src/commands/init/lib/fetch-rulebook.ts | 93 +++++++++ .../src/commands/init/lib/sentinel-upsert.ts | 65 ++++++ .../src/commands/init/steps/build-schema.ts | 18 +- .../src/commands/init/steps/gather-context.ts | 82 ++++++++ .../src/commands/init/steps/handoff-claude.ts | 188 ++++++++++++++++++ .../src/commands/init/steps/how-to-proceed.ts | 81 ++++++++ packages/cli/src/commands/init/types.ts | 11 + packages/cli/src/commands/init/utils.ts | 39 ++-- .../src/rulebook/__tests__/renderers.test.ts | 40 ++++ packages/cli/src/rulebook/index.ts | 8 + packages/cli/src/rulebook/partials.ts | 55 +++++ packages/cli/src/rulebook/partials/core.md | 55 +++++ .../rulebook/partials/integrations/drizzle.md | 63 ++++++ .../partials/integrations/postgresql.md | 36 ++++ .../partials/integrations/supabase.md | 65 ++++++ .../src/rulebook/renderers/claude-skill.ts | 68 +++++++ .../cli/src/rulebook/renderers/gateway.ts | 31 +++ packages/cli/src/rulebook/version.ts | 11 + packages/cli/tsup.config.ts | 6 + 25 files changed, 1208 insertions(+), 20 deletions(-) create mode 100644 .changeset/cli-init-agent-handoff.md create mode 100644 packages/cli/src/commands/init/__tests__/detect-agents.test.ts create mode 100644 packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts create mode 100644 packages/cli/src/commands/init/detect-agents.ts create mode 100644 packages/cli/src/commands/init/lib/fetch-rulebook.ts create mode 100644 packages/cli/src/commands/init/lib/sentinel-upsert.ts create mode 100644 packages/cli/src/commands/init/steps/gather-context.ts create mode 100644 packages/cli/src/commands/init/steps/handoff-claude.ts create mode 100644 packages/cli/src/commands/init/steps/how-to-proceed.ts create mode 100644 packages/cli/src/rulebook/__tests__/renderers.test.ts create mode 100644 packages/cli/src/rulebook/index.ts create mode 100644 packages/cli/src/rulebook/partials.ts create mode 100644 packages/cli/src/rulebook/partials/core.md create mode 100644 packages/cli/src/rulebook/partials/integrations/drizzle.md create mode 100644 packages/cli/src/rulebook/partials/integrations/postgresql.md create mode 100644 packages/cli/src/rulebook/partials/integrations/supabase.md create mode 100644 packages/cli/src/rulebook/renderers/claude-skill.ts create mode 100644 packages/cli/src/rulebook/renderers/gateway.ts create mode 100644 packages/cli/src/rulebook/version.ts diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md new file mode 100644 index 00000000..a26506e4 --- /dev/null +++ b/.changeset/cli-init-agent-handoff.md @@ -0,0 +1,19 @@ +--- +'@cipherstash/cli': minor +--- + +`stash init` can now hand off the rest of setup to your local coding agent. + +When `claude` is on PATH, `init` offers to install a project-local Claude Code skill at `.claude/skills/cipherstash-setup/SKILL.md` and write a `.cipherstash/context.json` describing the integration, encryption client path, columns, env keys, and package manager. Choosing the handoff option then launches `claude` interactively with a prompt that points at the skill and context file. The skill body is rendered from a versioned rulebook with integration-specific rules for Drizzle, Supabase, and plain PostgreSQL — so the agent gets the same correctness rules our hosted wizard uses. + +Three follow-up modes are available at the new "how to proceed" step: + +- **Hand off to Claude Code** — install skill, write context, spawn `claude`. Default when `claude` is detected. +- **Just write the rules files** — same writes, no spawn. For users driving Codex / Cursor / their own agent. +- **Use the built-in wizard** — keeps the existing `stash wizard` flow as the fallback. + +The rulebook ships bundled with the CLI; if `wizard.getstash.sh/v1/wizard/rulebook` is reachable, the CLI prefers the gateway-served version (so content updates between releases land without a CLI bump). Network failures fall through to the bundled copy silently. + +Re-running `init` is safe — both the SKILL.md and any future shared artifact use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. + +Phase 1 only targets Claude Code; Codex (`AGENTS.md` + spawn `codex`), Cursor `.cursor/rules/*.mdc`, and `.github/copilot-instructions.md` are scoped for follow-up phases. diff --git a/packages/cli/package.json b/packages/cli/package.json index 99245df7..614a98af 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,6 +7,7 @@ "files": [ "dist", "dist/sql", + "dist/rulebook", "README.md", "LICENSE", "CHANGELOG.md" diff --git a/packages/cli/src/commands/init/__tests__/detect-agents.test.ts b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts new file mode 100644 index 00000000..fe4c9703 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts @@ -0,0 +1,48 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { detectAgents, shouldOfferClaudeCode } from '../detect-agents.js' + +describe('detectAgents', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'detect-agents-test-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('reports no project artifacts in a fresh directory', () => { + const env = detectAgents(tmp, {}) + expect(env.project.claudeDir).toBe(false) + expect(env.project.claudeMd).toBe(false) + expect(env.project.claudeSkillsDir).toBe(false) + }) + + it('detects CLAUDE.md, .claude/, and .claude/skills/', () => { + writeFileSync(join(tmp, 'CLAUDE.md'), 'hi') + mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true }) + + const env = detectAgents(tmp, {}) + expect(env.project.claudeMd).toBe(true) + expect(env.project.claudeDir).toBe(true) + expect(env.project.claudeSkillsDir).toBe(true) + }) + + it('classifies the editor from env signals', () => { + expect(detectAgents(tmp, { CURSOR_TRACE_ID: 'abc' }).editor).toBe('cursor') + expect(detectAgents(tmp, { TERM_PROGRAM: 'vscode' }).editor).toBe('vscode') + expect(detectAgents(tmp, {}).editor).toBe('unknown') + }) + + it('shouldOfferClaudeCode follows CLI presence', () => { + const env = detectAgents(tmp, {}) + // We can't reliably mock command -v from a unit test, so just assert the + // helper reads the field without throwing. + expect(typeof shouldOfferClaudeCode(env)).toBe('boolean') + expect(shouldOfferClaudeCode(env)).toBe(env.cli.claudeCode) + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts b/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts new file mode 100644 index 00000000..182814c3 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + SENTINEL_END, + SENTINEL_START, + upsertManagedBlock, +} from '../lib/sentinel-upsert.js' + +describe('upsertManagedBlock', () => { + const managed = 'rule one\nrule two' + + it('creates a wrapped block when file is missing', () => { + const result = upsertManagedBlock({ managed }) + expect(result).toContain(SENTINEL_START) + expect(result).toContain(SENTINEL_END) + expect(result).toContain('rule one') + expect(result).toContain('rule two') + }) + + it('replaces only the managed region on re-run', () => { + const initial = upsertManagedBlock({ managed: 'old rule' }) + const wrapped = `# user header\n\n${initial}\n# user footer\n` + + const next = upsertManagedBlock({ existing: wrapped, managed: 'new rule' }) + expect(next).toContain('# user header') + expect(next).toContain('# user footer') + expect(next).toContain('new rule') + expect(next).not.toContain('old rule') + }) + + it('appends managed block when sentinels absent', () => { + const existing = '# pre-existing CLAUDE.md content\n' + const result = upsertManagedBlock({ existing, managed }) + expect(result.startsWith('# pre-existing CLAUDE.md content')).toBe(true) + expect(result).toContain(SENTINEL_START) + }) + + it('throws on a malformed sentinel pair', () => { + const broken = `${SENTINEL_END}\nstuff\n${SENTINEL_START}\n` + expect(() => upsertManagedBlock({ existing: broken, managed })).toThrow( + /malformed/i, + ) + }) + + it('throws when only one sentinel is present', () => { + const orphan = `intro\n${SENTINEL_START}\nstuff\n` + expect(() => upsertManagedBlock({ existing: orphan, managed })).toThrow( + /malformed/i, + ) + }) +}) diff --git a/packages/cli/src/commands/init/detect-agents.ts b/packages/cli/src/commands/init/detect-agents.ts new file mode 100644 index 00000000..1d624f70 --- /dev/null +++ b/packages/cli/src/commands/init/detect-agents.ts @@ -0,0 +1,91 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +export type Editor = 'vscode' | 'cursor' | 'unknown' + +export interface AgentEnvironment { + cli: { + /** `claude` is on PATH. */ + claudeCode: boolean + } + project: { + /** A `.claude/` directory exists at the project root. */ + claudeDir: boolean + /** A `CLAUDE.md` file exists at the project root. */ + claudeMd: boolean + /** A `.claude/skills/` directory exists at the project root. */ + claudeSkillsDir: boolean + } + /** Which editor is hosting the current terminal, if recognisable. */ + editor: Editor +} + +/** + * Look up an executable on PATH without running it. We use `command -v` (POSIX) + * because it is built into every shell we support and prints a usable path on + * success / nothing on failure. `which` is not always installed on minimal + * containers; `command -v` is. + */ +function isOnPath(bin: string): boolean { + // `command -v` is a shell builtin, so we run it via /bin/sh -c with the + // command argument inlined. Avoids the DEP0190 warning that fires when you + // combine `shell: true` with an args array. + if (!/^[a-z0-9_-]+$/i.test(bin)) return false + const result = spawnSync('/bin/sh', ['-c', `command -v ${bin}`], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (result.status !== 0) return false + const out = result.stdout?.toString().trim() ?? '' + return out.length > 0 +} + +function detectEditor(env: NodeJS.ProcessEnv): Editor { + if (env.CURSOR_TRACE_ID) return 'cursor' + if (env.TERM_PROGRAM === 'vscode') return 'vscode' + return 'unknown' +} + +function isDirectory(path: string): boolean { + if (!existsSync(path)) return false + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** + * Detect available coding agents and editor context. + * + * Phase 1 only surfaces Claude Code. The shape leaves room for `codex`, + * `gemini`, `cursor` etc. in later phases without changing call sites. + * + * `cwd` and `env` are injected so tests can mock them; production callers can + * use the no-arg form. + */ +export function detectAgents( + cwd: string = process.cwd(), + env: NodeJS.ProcessEnv = process.env, +): AgentEnvironment { + return { + cli: { + claudeCode: isOnPath('claude'), + }, + project: { + claudeDir: isDirectory(resolve(cwd, '.claude')), + claudeMd: existsSync(resolve(cwd, 'CLAUDE.md')), + claudeSkillsDir: isDirectory(resolve(cwd, '.claude', 'skills')), + }, + editor: detectEditor(env), + } +} + +/** + * Convenience predicate. The handoff offer in the init flow wants to know + * "should we default to Claude Code?", which collapses CLI presence + any + * project-level Claude artifact into a single yes/no. + */ +export function shouldOfferClaudeCode(env: AgentEnvironment): boolean { + return env.cli.claudeCode +} diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 4e395c50..94262404 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -4,6 +4,8 @@ import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' +import { gatherContextStep } from './steps/gather-context.js' +import { howToProceedStep } from './steps/how-to-proceed.js' import { installForgeStep } from './steps/install-forge.js' import { nextStepsStep } from './steps/next-steps.js' import type { InitProvider, InitState } from './types.js' @@ -18,6 +20,8 @@ const STEPS = [ authenticateStep, buildSchemaStep, installForgeStep, + gatherContextStep, + howToProceedStep, nextStepsStep, ] diff --git a/packages/cli/src/commands/init/lib/fetch-rulebook.ts b/packages/cli/src/commands/init/lib/fetch-rulebook.ts new file mode 100644 index 00000000..1409cad8 --- /dev/null +++ b/packages/cli/src/commands/init/lib/fetch-rulebook.ts @@ -0,0 +1,93 @@ +import { renderClaudeSkill } from '../../../rulebook/index.js' +import { RULEBOOK_VERSION } from '../../../rulebook/index.js' +import type { Integration } from '../types.js' + +const DEFAULT_GATEWAY_URL = 'https://wizard.getstash.sh/v1/wizard/rulebook' + +/** + * Resolve the gateway URL at call time so tests and local-dev can override it + * via `CIPHERSTASH_WIZARD_URL` without rebuilding the CLI. The override is + * always a full URL — accepting just a host complicates path handling and we + * already control the path on both sides. + */ +function gatewayUrl(): string { + return process.env.CIPHERSTASH_WIZARD_URL ?? DEFAULT_GATEWAY_URL +} + +/** + * Map the CLI's `Integration` enum (`postgresql` for "no recognised ORM") to + * the gateway's enum (`generic` for the same case). The gateway and the + * `@cipherstash/rulebook` package use the term `generic` to align with the + * existing `/v1/wizard/prompt` integrations. + */ +function gatewayIntegration(integration: Integration): string { + return integration === 'postgresql' ? 'generic' : integration +} + +interface RulebookResponse { + /** Server-rendered SKILL.md body. */ + skill: string + /** Version string the gateway used to render — for drift logging. */ + rulebookVersion: string +} + +interface FetchedRulebook { + skill: string + rulebookVersion: string + source: 'gateway' | 'bundled' +} + +/** + * Fetch the latest rulebook from the gateway, with bundled fallback. + * + * Network and auth failures are non-fatal — we always have the bundled copy. + * The gateway is the long-term source of truth for content updates between + * CLI releases. Phase 1 keeps the call best-effort and short-timeout; we don't + * want a flaky network turning init into a 60-second wait. + */ +export async function fetchRulebook({ + integration, + clientVersion, +}: { + integration: Integration + clientVersion: string +}): Promise { + const bundled = (): FetchedRulebook => ({ + skill: renderClaudeSkill({ integration }), + rulebookVersion: RULEBOOK_VERSION, + source: 'bundled', + }) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5_000) + + try { + const res = await fetch(gatewayUrl(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agent: 'claude-code', + integration: gatewayIntegration(integration), + clientVersion, + bundledVersion: RULEBOOK_VERSION, + }), + signal: controller.signal, + }) + + if (!res.ok) return bundled() + + const data = (await res.json()) as Partial + if (typeof data.skill !== 'string' || data.skill.length === 0) { + return bundled() + } + return { + skill: data.skill, + rulebookVersion: data.rulebookVersion ?? RULEBOOK_VERSION, + source: 'gateway', + } + } catch { + return bundled() + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/cli/src/commands/init/lib/sentinel-upsert.ts b/packages/cli/src/commands/init/lib/sentinel-upsert.ts new file mode 100644 index 00000000..1fd47834 --- /dev/null +++ b/packages/cli/src/commands/init/lib/sentinel-upsert.ts @@ -0,0 +1,65 @@ +/** + * Managed-block upsert for files that we co-own with the user. + * + * The sentinel pair lets us re-run `stash init` and replace only our managed + * region, leaving anything the user wrote outside the sentinels alone. + * + * + * ...managed content... + * + */ + +const START = '' +const END = '' + +export interface UpsertOptions { + /** Existing file contents, or undefined when the file does not yet exist. */ + existing?: string + /** New content to put between the sentinels. Trailing newline normalised. */ + managed: string +} + +/** + * Insert or replace the managed block. + * + * - File missing → return managed content wrapped in sentinels. + * - Sentinel pair found → replace what is between them. + * - Sentinels missing but file exists → append the managed block, separated by + * a blank line so we never collide with the user's last paragraph. + * - Mismatched sentinels (only start, only end, or end before start) → throw. + * Surfacing this loudly is better than silently mangling the file. + */ +export function upsertManagedBlock({ + existing, + managed, +}: UpsertOptions): string { + const block = `${START}\n${managed.replace(/\s+$/, '')}\n${END}\n` + + if (existing === undefined || existing.length === 0) { + return block + } + + const startIdx = existing.indexOf(START) + const endIdx = existing.indexOf(END) + + if (startIdx === -1 && endIdx === -1) { + const sep = existing.endsWith('\n') ? '\n' : '\n\n' + return `${existing}${sep}${block}` + } + + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { + throw new Error( + 'cipherstash:rulebook sentinel pair is malformed. Refusing to overwrite. ' + + 'Remove the leftover sentinel manually and re-run.', + ) + } + + const before = existing.slice(0, startIdx) + const after = existing.slice(endIdx + END.length) + // Drop a single leading newline on `after` to avoid double-blank lines. + const tail = after.startsWith('\n') ? after.slice(1) : after + return `${before}${block}${tail}` +} + +export const SENTINEL_START = START +export const SENTINEL_END = END diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index 0b966dcd..553a6fab 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -9,7 +9,7 @@ import type { InitStep, } from '../types.js' import { CancelledError } from '../types.js' -import { generatePlaceholderClient } from '../utils.js' +import { generatePlaceholderClient, PLACEHOLDER_SCHEMA } from '../utils.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' @@ -57,7 +57,13 @@ export const buildSchemaStep: InitStep = { if (action === 'keep') { p.log.info('Keeping existing encryption client file.') - return { ...state, clientFilePath, schemaGenerated: false } + return { + ...state, + clientFilePath, + schemaGenerated: false, + integration, + schema: PLACEHOLDER_SCHEMA, + } } } @@ -73,6 +79,12 @@ export const buildSchemaStep: InitStep = { `Encryption client written to ${clientFilePath} (${integration} template)`, ) - return { ...state, clientFilePath, schemaGenerated: true } + return { + ...state, + clientFilePath, + schemaGenerated: true, + integration, + schema: PLACEHOLDER_SCHEMA, + } }, } diff --git a/packages/cli/src/commands/init/steps/gather-context.ts b/packages/cli/src/commands/init/steps/gather-context.ts new file mode 100644 index 00000000..f424c83c --- /dev/null +++ b/packages/cli/src/commands/init/steps/gather-context.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { detectAgents } from '../detect-agents.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { detectPackageManager } from '../utils.js' + +/** + * Names of env keys observed in the project's `.env*` files. We never read or + * propagate the values — only the names tell the agent which keys to expect. + */ +function readEnvKeyNames(cwd: string): string[] { + const candidates = [ + '.env', + '.env.local', + '.env.development', + '.env.development.local', + ] + const seen = new Set() + for (const file of candidates) { + const path = resolve(cwd, file) + if (!existsSync(path)) continue + let text: string + try { + text = readFileSync(path, 'utf-8') + } catch { + continue + } + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const key = trimmed.slice(0, eq).trim() + if (key) seen.add(key) + } + } + return Array.from(seen).sort() +} + +/** + * Pull together everything an external agent will need into in-memory state. + * + * No file writes happen here — `handoff-claude` is what serialises this to + * `.cipherstash/context.json`. We split the responsibilities so the wizard / + * rules-only branches can also reuse the gathered facts later if we ever + * surface them. + */ +export const gatherContextStep: InitStep = { + id: 'gather-context', + name: 'Gather setup context', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const agents = detectAgents(cwd, process.env) + const envKeys = readEnvKeyNames(cwd) + const pm = detectPackageManager() + + const detectedBits: string[] = [] + if (state.integration) + detectedBits.push(`integration: ${state.integration}`) + detectedBits.push(`package manager: ${pm}`) + if (agents.cli.claudeCode) detectedBits.push('Claude Code CLI: yes') + if (envKeys.length > 0) { + detectedBits.push(`env keys: ${envKeys.length} found`) + } + + p.log.info(`Detected — ${detectedBits.join(', ')}`) + + return { + ...state, + agents, + // Stash env key names directly on state via a side channel so handoff + // doesn't have to re-read .env files. Re-using `agents` shape would + // pollute it, so we use a private getter on the next step instead by + // reading env keys again — they're cheap. We deliberately don't store + // values here. + } + }, +} + +/** Re-export so handoff-claude can call it with the same semantics. */ +export { readEnvKeyNames } diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts new file mode 100644 index 00000000..67c7ada8 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -0,0 +1,188 @@ +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +import { CLAUDE_SKILL_NAME } from '../../../rulebook/index.js' +import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import type { + InitProvider, + InitState, + InitStep, + Integration, + SchemaDef, +} from '../types.js' +import { detectPackageManager, prodInstallCommand } from '../utils.js' +import { readEnvKeyNames } from './gather-context.js' + +const SKILL_REL_PATH = `.claude/skills/${CLAUDE_SKILL_NAME}/SKILL.md` +const CONTEXT_REL_PATH = '.cipherstash/context.json' + +interface ContextFile { + rulebookVersion: string + cliVersion: string + integration: Integration + encryptionClientPath: string + packageManager: string + installCommand: string + envKeys: string[] + schema: SchemaDef + generatedAt: string +} + +function readCliVersion(): string { + // package.json sits two levels above the compiled file (dist/) and three + // levels above the source file. Walk up until we find it. Falling back to + // 'unknown' is fine — the field is informational. + let dir = dirname(fileURLToPath(import.meta.url)) + for (let i = 0; i < 6; i++) { + const candidate = resolve(dir, 'package.json') + if (existsSync(candidate)) { + try { + const pkg = JSON.parse(readFileSync(candidate, 'utf-8')) as { + name?: string + version?: string + } + if (pkg.name === '@cipherstash/cli' && pkg.version) return pkg.version + } catch { + // keep walking + } + } + dir = dirname(dir) + } + return 'unknown' +} + +function ensureDir(path: string) { + const dir = dirname(path) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +function writeSkillFile(absPath: string, body: string) { + const existing = existsSync(absPath) + ? readFileSync(absPath, 'utf-8') + : undefined + const next = upsertManagedBlock({ existing, managed: body }) + ensureDir(absPath) + writeFileSync(absPath, next, 'utf-8') +} + +function writeContextFile(absPath: string, ctx: ContextFile) { + ensureDir(absPath) + writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') +} + +/** + * Spawn `claude` interactively in the user's terminal so they can watch tool + * calls and approve edits. We attach stdio to inherit; this step blocks until + * the user exits Claude Code. + * + * Returns the exit code — 0 means the user finished the session normally, + * non-zero means `claude` crashed or was interrupted. We don't fail init + * either way: the artifacts are already written, the user can re-run claude. + */ +function spawnClaude(prompt: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn('claude', [prompt], { + stdio: 'inherit', + shell: false, + }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', () => resolvePromise(-1)) + }) +} + +/** + * Final step on the Claude Code path: write the project skill, write the + * context file, then either spawn `claude` (handoff='claude-code') or print + * the next-steps for the user to drive their own agent (handoff='rules-only'). + */ +export const handoffClaudeStep: InitStep = { + id: 'handoff-claude', + name: 'Hand off to Claude Code', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' + const schema = state.schema + if (!schema) { + // Should not happen — build-schema always populates this. Keep the + // assertion explicit so a future refactor that drops the field gets + // caught here rather than producing a half-empty context.json. + throw new Error('Schema missing from init state — cannot write context.') + } + + const pm = detectPackageManager() + const cliVersion = readCliVersion() + const envKeys = readEnvKeyNames(cwd) + + const rulebookSpinner = p.spinner() + rulebookSpinner.start('Fetching rulebook...') + const rulebook = await fetchRulebook({ + integration, + clientVersion: cliVersion, + }) + rulebookSpinner.stop( + rulebook.source === 'gateway' + ? `Rulebook ${rulebook.rulebookVersion} fetched.` + : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, + ) + + const skillAbs = resolve(cwd, SKILL_REL_PATH) + writeSkillFile(skillAbs, rulebook.skill) + p.log.success(`Wrote ${SKILL_REL_PATH}`) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx: ContextFile = { + rulebookVersion: rulebook.rulebookVersion, + cliVersion, + integration, + encryptionClientPath: clientFilePath, + packageManager: pm, + installCommand: prodInstallCommand(pm, '@cipherstash/stack'), + envKeys, + schema, + generatedAt: new Date().toISOString(), + } + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + if (state.handoff === 'rules-only') { + p.note( + [ + `Rules installed at ${SKILL_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Point your agent at the skill, or read it directly:', + ` cat ${SKILL_REL_PATH}`, + ].join('\n'), + 'Drive your own agent', + ) + return state + } + + if (!state.agents?.cli.claudeCode) { + p.log.warn('`claude` is not on PATH. Skipping spawn.') + p.note( + [ + 'When you have Claude Code installed, run:', + ` claude "Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}."`, + ].join('\n'), + 'Manual handoff', + ) + return state + } + + p.log.info('Launching Claude Code...') + const prompt = `Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}.` + const exitCode = await spawnClaude(prompt) + if (exitCode !== 0) { + p.log.warn( + `Claude Code exited with code ${exitCode}. Re-run \`claude "${prompt}"\` to resume.`, + ) + } + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/init/steps/how-to-proceed.ts new file mode 100644 index 00000000..633b0f14 --- /dev/null +++ b/packages/cli/src/commands/init/steps/how-to-proceed.ts @@ -0,0 +1,81 @@ +import * as p from '@clack/prompts' +import { shouldOfferClaudeCode } from '../detect-agents.js' +import { + CancelledError, + type HandoffChoice, + type InitProvider, + type InitState, + type InitStep, +} from '../types.js' +import { handoffClaudeStep } from './handoff-claude.js' + +/** + * Ask the user how they want to finish setup, then dispatch. + * + * - Claude Code handoff is offered as the default when `claude` is on PATH. + * - The built-in wizard option points the user at `stash wizard` rather than + * running it inline; the wizard is a separate command and Phase 1 keeps + * that boundary intact. + * - "Just write the rules files" is always offered as the no-spawn escape + * hatch for users who drive their own agent (Codex / Cursor / hand-rolled). + */ +export const howToProceedStep: InitStep = { + id: 'how-to-proceed', + name: 'How to proceed', + async run(state: InitState, _provider: InitProvider): Promise { + const claudeAvailable = state.agents + ? shouldOfferClaudeCode(state.agents) + : false + + const options: { value: HandoffChoice; label: string; hint?: string }[] = [] + + if (claudeAvailable) { + options.push({ + value: 'claude-code', + label: 'Hand off to Claude Code', + hint: 'install a project skill, then launch `claude` interactively', + }) + } + + options.push({ + value: 'rules-only', + label: 'Just write the rules files', + hint: 'I will drive my own agent (Codex / Cursor / etc.)', + }) + + options.push({ + value: 'wizard', + label: "Use CipherStash's built-in wizard", + hint: 'run `stash wizard` after init finishes', + }) + + const choice = await p.select({ + message: 'How would you like to finish setup?', + options, + initialValue: claudeAvailable ? 'claude-code' : 'rules-only', + }) + + if (p.isCancel(choice)) throw new CancelledError() + + const next: InitState = { ...state, handoff: choice } + + if (choice === 'claude-code') { + return handoffClaudeStep.run(next, _provider) + } + + if (choice === 'rules-only') { + // Rules-only path still installs the project skill so Codex / Cursor / + // hand-rolled agents can be pointed at .claude/skills/cipherstash-setup + // (or read it directly). Same writer, no spawn. + return handoffClaudeStep.run( + { ...next, handoff: 'rules-only' }, + _provider, + ) + } + + p.log.info( + 'When you are ready, run `stash wizard` to launch the built-in setup agent.', + ) + return next + }, +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 75f491c4..cca34f35 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -1,3 +1,4 @@ +import type { AgentEnvironment } from './detect-agents.js' import type { PackageManager } from './utils.js' export type Integration = 'drizzle' | 'supabase' | 'postgresql' @@ -17,12 +18,22 @@ export interface SchemaDef { columns: ColumnDef[] } +export type HandoffChoice = 'claude-code' | 'wizard' | 'rules-only' + export interface InitState { authenticated?: boolean clientFilePath?: string schemaGenerated?: boolean stackInstalled?: boolean forgeInstalled?: boolean + /** Detected ORM / framework integration. Set by build-schema. */ + integration?: Integration + /** Schema definition that was written to the client file (placeholder for now). */ + schema?: SchemaDef + /** Available coding agents in the user's environment. Set by detect-agents. */ + agents?: AgentEnvironment + /** What the user picked at the "how to proceed" step. */ + handoff?: HandoffChoice } export interface InitStep { diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index e7563f6b..bccefe1a 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -244,23 +244,28 @@ export function generateClientFromSchema( } } +/** + * Schema definition we ship as the "fresh project" placeholder. Exported + * separately so steps that follow `build-schema` (gather-context, handoff) + * can read it back without re-parsing the generated client file. + */ +export const PLACEHOLDER_SCHEMA: SchemaDef = { + tableName: 'users', + columns: [ + { + name: 'email', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + { + name: 'name', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + ], +} + /** Generates an encryption client file with a placeholder schema for getting started. */ export function generatePlaceholderClient(integration: Integration): string { - const placeholder: SchemaDef = { - tableName: 'users', - columns: [ - { - name: 'email', - dataType: 'string', - searchOps: ['equality', 'freeTextSearch'], - }, - { - name: 'name', - dataType: 'string', - searchOps: ['equality', 'freeTextSearch'], - }, - ], - } - - return generateClientFromSchema(integration, placeholder) + return generateClientFromSchema(integration, PLACEHOLDER_SCHEMA) } diff --git a/packages/cli/src/rulebook/__tests__/renderers.test.ts b/packages/cli/src/rulebook/__tests__/renderers.test.ts new file mode 100644 index 00000000..40dae487 --- /dev/null +++ b/packages/cli/src/rulebook/__tests__/renderers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_SKILL_NAME, + RULEBOOK_VERSION, + renderClaudeSkill, + renderGatewayPrompt, +} from '../index.js' + +describe('renderGatewayPrompt', () => { + it('includes the rulebook version, integration name, and core rules', () => { + const out = renderGatewayPrompt({ integration: 'drizzle' }) + expect(out).toContain(RULEBOOK_VERSION) + expect(out).toContain('Integration: drizzle') + expect(out).toContain('.cipherstash/context.json') + }) + + it('switches body per integration', () => { + const drizzle = renderGatewayPrompt({ integration: 'drizzle' }) + const supabase = renderGatewayPrompt({ integration: 'supabase' }) + expect(drizzle).toContain('drizzle-orm') + expect(supabase).toContain('encryptedSupabase') + expect(drizzle).not.toContain('encryptedSupabase') + }) +}) + +describe('renderClaudeSkill', () => { + it('emits valid YAML frontmatter naming the skill', () => { + const out = renderClaudeSkill({ integration: 'drizzle' }) + const lines = out.split('\n') + expect(lines[0]).toBe('---') + expect(out).toMatch(new RegExp(`name: ${CLAUDE_SKILL_NAME}`)) + expect(out).toMatch(/integration: drizzle/) + expect(out).toMatch(/rulebook_version:/) + }) + + it('mentions context.json as the first action', () => { + const out = renderClaudeSkill({ integration: 'supabase' }) + expect(out).toContain('.cipherstash/context.json') + }) +}) diff --git a/packages/cli/src/rulebook/index.ts b/packages/cli/src/rulebook/index.ts new file mode 100644 index 00000000..a467da1a --- /dev/null +++ b/packages/cli/src/rulebook/index.ts @@ -0,0 +1,8 @@ +export { RULEBOOK_VERSION } from './version.js' +export { renderGatewayPrompt } from './renderers/gateway.js' +export type { GatewayPromptContext } from './renderers/gateway.js' +export { + renderClaudeSkill, + CLAUDE_SKILL_NAME, +} from './renderers/claude-skill.js' +export type { ClaudeSkillContext } from './renderers/claude-skill.js' diff --git a/packages/cli/src/rulebook/partials.ts b/packages/cli/src/rulebook/partials.ts new file mode 100644 index 00000000..259c6793 --- /dev/null +++ b/packages/cli/src/rulebook/partials.ts @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import type { Integration } from '../commands/init/types.js' + +/** + * Get the directory of the current file, supporting both ESM and CJS. + * Mirrors the pattern in `src/installer/index.ts` so we work in both bundle + * variants tsup produces (`dist/index.js` ESM, `dist/index.cjs` CJS). + */ +function currentDir(): string { + if (typeof import.meta?.url === 'string' && import.meta.url) { + return dirname(new URL(import.meta.url).pathname) + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — __dirname is the CJS fallback + return __dirname +} + +/** + * Resolve the directory holding the bundled rulebook partials. + * + * Layouts to support: + * - Source (vitest, dev): src/rulebook/partials.ts → src/rulebook/partials/ + * - Library bundle (ESM): dist/index.js → dist/rulebook/partials/ + * - Library bundle (CJS): dist/index.cjs → dist/rulebook/partials/ + * + * tsup flattens the bundle entry to `dist/index.{js,cjs}`, so from the + * library entrypoint the partials live at `./rulebook/partials/`. From source + * they live at `./partials/`. Try both, pick the first that exists. + */ +function partialsDir(): string { + const here = currentDir() + const candidates = [ + resolve(here, 'partials'), + resolve(here, 'rulebook', 'partials'), + resolve(here, '..', 'rulebook', 'partials'), + ] + for (const dir of candidates) { + if (existsSync(dir)) return dir + } + // Last-ditch: return the source-layout candidate so the readFileSync error + // names a path the developer can act on. The literal index 0 is always set; + // we keep the fallback narrow rather than throwing here because the actual + // file read below will produce a clearer error than a generic throw. + return candidates[0] ?? resolve(here, 'partials') +} + +export function loadCorePartial(): string { + return readFileSync(resolve(partialsDir(), 'core.md'), 'utf-8') +} + +export function loadIntegrationPartial(integration: Integration): string { + const path = resolve(partialsDir(), 'integrations', `${integration}.md`) + return readFileSync(path, 'utf-8') +} diff --git a/packages/cli/src/rulebook/partials/core.md b/packages/cli/src/rulebook/partials/core.md new file mode 100644 index 00000000..eef31ef9 --- /dev/null +++ b/packages/cli/src/rulebook/partials/core.md @@ -0,0 +1,55 @@ +## Core rules — non-negotiable + +These rules apply regardless of ORM. The user has run `stash init` already; the project is authenticated and `@cipherstash/stack` is installed. All discovery has been written to `.cipherstash/context.json`. + +### Read context first + +Before doing anything, read `.cipherstash/context.json`. It contains: + +- The detected integration (drizzle, supabase, postgresql) +- The selected encryption columns with their data types and search ops +- The encryption client output path +- The package manager and install command +- The names of env keys (never values) +- The rulebook + CLI version this context was written against + +If any of those keys are missing from the context file, stop and ask the user to re-run `stash init`. Do not guess. + +### Never run discovery yourself + +Do not run `psql`, `\d`, `pg_dump`, `supabase db dump`, Drizzle introspect, or any other database introspection. The CLI already did this. Use the column list from `.cipherstash/context.json`. + +If you genuinely need fresh introspection, ask the user to re-run `stash init` — they own the credentials and the right to introspect. + +### Never read or echo secrets + +You may reference env key **names** (e.g. `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `DATABASE_URL`) in code and docs. You must not read their values from `.env*` files or print them. If you need to add a new env key, write it to `.env.example` with a placeholder, and instruct the user to add the real value to their local `.env`. + +### Encrypted column conventions + +- Encrypted columns are stored as `jsonb` in Postgres. Never declare them as `text`, `varchar`, `bytea`, or any plaintext type. +- Add new encrypted columns alongside any existing plaintext column you are replacing — name them `_encrypted` until the migration cuts over. Do not drop the plaintext column without an explicit user decision. +- Never mark encrypted columns `NOT NULL` at creation time. The encrypted ciphertext is added by the application layer and an immediate `NOT NULL` constraint will break inserts. + +### Never modify these + +- `stash.config.ts` — generated by `stash init`. Edits go to `.env`, not here. +- `.cipherstash/` — owned by the CLI. +- The `eql_v2` schema and `eql_v2_*` functions installed by `stash db install`. If a function or trigger you need is missing, instruct the user to run `stash db upgrade`. + +### Schema changes go through Stack + +The encryption client lives at the path in `context.json`. New encrypted columns must be: + +1. Added to that schema file using the integration's encrypted column type. +2. Reflected in any migration the ORM produces. +3. Re-applied to the running database. + +Do not write raw SQL `CREATE TABLE` migrations that include encrypted columns without going through the Stack schema first — the column types and EQL function bindings are derived from the schema. + +### Stop and ask when + +- The context file is missing or out of date. +- A column the user wants to encrypt has existing plaintext rows (this needs a backfill plan, not a column rename). +- The repo already has partial CipherStash setup that disagrees with `context.json` (someone else's edits, or an older `stash init`). +- You are about to delete or rename a file the user did not mention. diff --git a/packages/cli/src/rulebook/partials/integrations/drizzle.md b/packages/cli/src/rulebook/partials/integrations/drizzle.md new file mode 100644 index 00000000..4b09749e --- /dev/null +++ b/packages/cli/src/rulebook/partials/integrations/drizzle.md @@ -0,0 +1,63 @@ +## Drizzle ORM integration rules + +The project uses Drizzle. Apply these rules on top of the core rules. + +### Imports + +```ts +import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' +import { Encryption } from '@cipherstash/stack' +``` + +Do not import from `@cipherstash/stack/supabase` or `@cipherstash/stack/schema` — those are different integrations. + +### Encrypted column declarations + +Use `encryptedType(name, opts?)` for every encrypted column. The TypeScript generic is the **plaintext** type (`string`, `number`, `boolean`, `Date`, `Record`). + +```ts +email: encryptedType('email', { equality: true, freeTextSearch: true }), +joinedAt: encryptedType('joined_at', { dataType: 'date', orderAndRange: true }), +``` + +Pass the search-op options (`equality`, `orderAndRange`, `freeTextSearch`) only for ops the user actually selected during `stash init` — they are recorded in `context.json` under `columns[i].searchOps`. Do not enable an op the user did not select. + +### Never on encrypted columns + +- `.notNull()` — same reason as the core rule, the application writes the ciphertext. +- `.primaryKey()` — encrypted columns must not be primary keys. +- `.references(...)` / foreign keys — encrypted columns are not referential. +- `.default(...)` — Postgres defaults are plaintext; you'd be storing a plaintext literal in a JSONB ciphertext column. +- `.unique()` — uniqueness on ciphertext is not stable; use `equality` indexed search instead. + +### Schema extraction + Encryption client + +After the table definition, extract and instantiate exactly once per file: + +```ts +const usersSchema = extractEncryptionSchema(usersTable) +export const encryptionClient = await Encryption({ schemas: [usersSchema] }) +``` + +Multiple tables in the same encryption client → one `extractEncryptionSchema` call per table, all schemas in the array. + +### Querying encrypted columns + +Use the operator helpers from `@cipherstash/stack/drizzle`, not Drizzle's stock operators, when comparing against an encrypted column: + +```ts +import { eq, like, ilike, gt, gte, lt, lte, between, inArray, asc, desc } from '@cipherstash/stack/drizzle' + +const matches = await db + .select() + .from(usersTable) + .where(eq(usersTable.email, 'alice@example.com')) +``` + +Mixing stock `eq` (from `drizzle-orm`) with an encrypted column is a silent bug — it compares the JSONB literal, not the underlying value. + +### Migrations + +When `drizzle-kit generate` produces a migration that creates an encrypted column, the column should be `jsonb` and **nullable**. If the generated migration has `NOT NULL` on an encrypted column, edit it before applying. + +To install the EQL extension, the user runs `stash db install --drizzle` — do not write or run that migration yourself. diff --git a/packages/cli/src/rulebook/partials/integrations/postgresql.md b/packages/cli/src/rulebook/partials/integrations/postgresql.md new file mode 100644 index 00000000..7646470f --- /dev/null +++ b/packages/cli/src/rulebook/partials/integrations/postgresql.md @@ -0,0 +1,36 @@ +## Generic PostgreSQL integration rules + +The project does not use a recognised ORM. Apply these rules on top of the core rules. + +### Imports + +```ts +import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' +import { Encryption } from '@cipherstash/stack' +``` + +### Schema definition + +```ts +export const usersTable = encryptedTable('users', { + email: encryptedColumn('email').equality().freeTextSearch(), +}) + +export const encryptionClient = await Encryption({ schemas: [usersTable] }) +``` + +Only enable search ops listed for that column in `context.json`. + +### Postgres column types + +`jsonb`, nullable. Never `text` or `NOT NULL` on creation. + +### Querying + +The encryption client gives you `encrypt(plaintext)` and `decrypt(ciphertext)` methods. Use these at the application boundary: + +- Before inserting/updating: encrypt the plaintext. +- After selecting: decrypt the ciphertext. +- For searches that require server-side comparison, use the EQL functions installed by `stash db install` — `eql_v2.eq`, `eql_v2.like`, `eql_v2.gt`, etc. The encryption client exposes the right shape via its query helpers; do not hand-roll JSONB path expressions. + +When in doubt about which EQL function to use, read the schema partial for that integration in this skill rather than guessing. diff --git a/packages/cli/src/rulebook/partials/integrations/supabase.md b/packages/cli/src/rulebook/partials/integrations/supabase.md new file mode 100644 index 00000000..e425ebe7 --- /dev/null +++ b/packages/cli/src/rulebook/partials/integrations/supabase.md @@ -0,0 +1,65 @@ +## Supabase integration rules + +The project uses Supabase. Apply these rules on top of the core rules. + +### Imports + +```ts +import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' +import { Encryption } from '@cipherstash/stack' +import { encryptedSupabase } from '@cipherstash/stack/supabase' +``` + +Do not import from `@cipherstash/stack/drizzle`. + +### Schema definition + +`encryptedColumn(name)` is the column builder. Chain `.dataType(...)`, `.equality()`, `.orderAndRange()`, `.freeTextSearch()` only for ops listed in `context.json`. + +```ts +export const usersTable = encryptedTable('users', { + email: encryptedColumn('email').equality().freeTextSearch(), + joinedAt: encryptedColumn('joined_at').dataType('date').orderAndRange(), +}) + +export const encryptionClient = await Encryption({ schemas: [usersTable] }) +``` + +### Postgres column types + +Encrypted columns in the Supabase database are `jsonb`, nullable. If migrations are generated by Supabase CLI, the SQL must read: + +```sql +ALTER TABLE users ADD COLUMN email_encrypted jsonb; +``` + +Never `text`, never `NOT NULL` on creation. + +### Wrap the Supabase client + +Every `createClient` call that touches encrypted tables must be wrapped: + +```ts +import { createClient } from '@supabase/supabase-js' +import { encryptedSupabase } from '@cipherstash/stack/supabase' +import { encryptionClient } from '' + +const baseClient = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!) +export const supabase = encryptedSupabase(baseClient, encryptionClient) +``` + +Do not call `.from('users').select(...)` on an unwrapped Supabase client — the ciphertext will not decrypt. + +### Querying + +- Never use `.select('*')` against tables with encrypted columns. Always enumerate columns explicitly so the wrapper can decrypt only what you ask for. +- Use the wrapped client's filter helpers (`eq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `in`, `or`, `match`) for encrypted columns. They take the **plaintext** value and the wrapper handles encryption. +- Inserts and updates take plaintext objects; the wrapper encrypts on the way out. + +### RLS policies + +Row-level security policies on encrypted columns must be written against metadata or non-encrypted columns. A policy comparing a ciphertext column to a literal will always fail. If the user wants policies that depend on encrypted values, that needs `eql_v2` operators in the policy and a separate conversation — flag it and stop. + +### Identity-aware encryption (lock contexts) + +If `context.json` indicates lock contexts are configured, pass `lockContext` through `encryptedSupabase(baseClient, encryptionClient, { lockContext })` rather than re-creating the client per request. Do not embed lock-context tokens in URLs or logs. diff --git a/packages/cli/src/rulebook/renderers/claude-skill.ts b/packages/cli/src/rulebook/renderers/claude-skill.ts new file mode 100644 index 00000000..9c45d977 --- /dev/null +++ b/packages/cli/src/rulebook/renderers/claude-skill.ts @@ -0,0 +1,68 @@ +import type { Integration } from '../../commands/init/types.js' +import { loadCorePartial, loadIntegrationPartial } from '../partials.js' +import { RULEBOOK_VERSION } from '../version.js' + +export interface ClaudeSkillContext { + integration: Integration +} + +const SKILL_NAME = 'cipherstash-setup' + +const SKILL_DESCRIPTION = + 'Complete a CipherStash field-level encryption setup that was started by ' + + '`stash init`. Reads the prepared context at .cipherstash/context.json, ' + + 'adds encrypted columns to the user-selected schema file, wires the ' + + 'encryption client into the relevant integration (Drizzle / Supabase / ' + + 'plain Postgres), and prepares migrations. Use this skill when the project ' + + 'contains .cipherstash/context.json and the user wants to finish CipherStash setup.' + +/** + * Render the SKILL.md body for a project-local Claude Code skill at + * `.claude/skills/cipherstash-setup/SKILL.md`. + * + * The skill is project-local on purpose: it pins to the rulebook version that + * `stash init` ran with, so re-running on a different project does not get + * rules from a different point in time. + */ +export function renderClaudeSkill(ctx: ClaudeSkillContext): string { + const core = loadCorePartial() + const integration = loadIntegrationPartial(ctx.integration) + const frontmatter = [ + '---', + `name: ${SKILL_NAME}`, + `description: ${SKILL_DESCRIPTION}`, + `rulebook_version: ${RULEBOOK_VERSION}`, + `integration: ${ctx.integration}`, + '---', + ].join('\n') + + return [ + frontmatter, + '', + `# CipherStash Setup (${ctx.integration})`, + '', + 'You are completing a CipherStash field-level encryption setup that the user already started with `stash init`. The CLI did the discovery and authentication; your job is to land the code changes the rulebook below describes.', + '', + '## First step', + '', + '1. Read `.cipherstash/context.json`. If it is missing, stop and tell the user to run `stash init`.', + `2. Confirm the integration listed in the context matches this skill (\`integration: ${ctx.integration}\`). If it does not, stop and ask the user to re-run \`stash init\`.`, + '3. Apply the rules below to the file at `context.encryptionClientPath` and any related migration / client wiring.', + '4. Show the user a diff before applying any database migration.', + '', + core.trim(), + '', + integration.trim(), + '', + '## Done when', + '', + '- Encrypted columns from `context.json` are in the schema file with correct types and search ops.', + '- The encryption client is exported from the path in `context.json`.', + '- Any new env keys are listed in `.env.example`, and the user knows which values to add to their local `.env`.', + '- Drizzle / Supabase / Postgres-specific wiring (per the section above) is in place.', + '- Migrations have been generated but not applied — the user runs them.', + '', + ].join('\n') +} + +export const CLAUDE_SKILL_NAME = SKILL_NAME diff --git a/packages/cli/src/rulebook/renderers/gateway.ts b/packages/cli/src/rulebook/renderers/gateway.ts new file mode 100644 index 00000000..7e2d41d4 --- /dev/null +++ b/packages/cli/src/rulebook/renderers/gateway.ts @@ -0,0 +1,31 @@ +import type { Integration } from '../../commands/init/types.js' +import { loadCorePartial, loadIntegrationPartial } from '../partials.js' +import { RULEBOOK_VERSION } from '../version.js' + +export interface GatewayPromptContext { + integration: Integration +} + +/** + * Render the prompt body served by `POST /v1/wizard/prompt` on the gateway. + * + * The gateway and the CLI both consume this so the in-house wizard and any + * external-agent handoff produce the same setup outcome. Format mirrors the + * existing `apps/wizard/src/prompts.ts` shape: a header, the core rules, then + * the integration-specific rules. + */ +export function renderGatewayPrompt(ctx: GatewayPromptContext): string { + const core = loadCorePartial() + const integration = loadIntegrationPartial(ctx.integration) + return [ + '# CipherStash setup wizard', + '', + `Rulebook version: ${RULEBOOK_VERSION}`, + `Integration: ${ctx.integration}`, + '', + core.trim(), + '', + integration.trim(), + '', + ].join('\n') +} diff --git a/packages/cli/src/rulebook/version.ts b/packages/cli/src/rulebook/version.ts new file mode 100644 index 00000000..b38a0e46 --- /dev/null +++ b/packages/cli/src/rulebook/version.ts @@ -0,0 +1,11 @@ +/** + * Single source of truth for the rulebook content version. + * + * Bump this whenever any partial under `src/rulebook/partials/` changes in a + * way that should invalidate previously-installed project skills. The CLI + * writes this value into `.cipherstash/context.json` and into the installed + * skill body so future runs can detect drift. + * + * Format: `YYYY-MM-DD-` for easy human ordering. Letter resets per day. + */ +export const RULEBOOK_VERSION = '2026-05-01-a' diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e4b46883..9b832a9b 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -21,6 +21,12 @@ export default defineConfig([ onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package cpSync('src/sql', 'dist/sql', { recursive: true }) + // Copy rulebook partials. The runtime resolver in + // src/rulebook/partials.ts looks for a sibling `partials/` directory, + // so we mirror the source layout under `dist/rulebook/`. + cpSync('src/rulebook/partials', 'dist/rulebook/partials', { + recursive: true, + }) }, }, { From a4bb818a40257146f2f0062453d60b85b4c9d7f4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 15:55:34 +1000 Subject: [PATCH 02/10] feat(cli): four-way init handoff menu (Claude / Codex / Agent / AGENTS.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the init agent handoff. Replaces the three-mode "how to proceed" prompt with a four-option menu so users can pick the agent that matches their workflow: - Hand off to Claude Code (default when claude on PATH) - Hand off to Codex (default when codex on PATH and claude is not) - Use the CipherStash Agent (fallback — runs `stash wizard`) - Write AGENTS.md (default when neither CLI agent is detected) Detection is non-blocking: a missing CLI doesn't hide the option, the handoff step still writes the rules files and prints install + manual-launch instructions. The user's progress is never wasted. Adds renderAgentsMd to @cipherstash/cli's bundled rulebook (plain markdown, no YAML frontmatter — sentinel-marker friendly). Extends fetchRulebook to take an `agent` parameter so the gateway can serve SKILL.md or AGENTS.md from the same endpoint and the bundled fallback picks the right renderer. Refactors the shared writer logic (context.json + sentinel-upserted artifact files + CLI version walker) into lib/write-context.ts so all four handoff steps share one implementation. --- .changeset/cli-init-agent-handoff.md | 19 +-- .../init/__tests__/detect-agents.test.ts | 13 ++ .../cli/src/commands/init/detect-agents.ts | 9 +- .../src/commands/init/lib/fetch-rulebook.ts | 54 ++++--- .../src/commands/init/lib/write-context.ts | 110 ++++++++++++++ .../commands/init/steps/handoff-agents-md.ts | 69 +++++++++ .../src/commands/init/steps/handoff-claude.ts | 138 ++++-------------- .../src/commands/init/steps/handoff-codex.ts | 93 ++++++++++++ .../src/commands/init/steps/handoff-wizard.ts | 42 ++++++ .../src/commands/init/steps/how-to-proceed.ts | 118 ++++++++------- packages/cli/src/commands/init/types.ts | 2 +- .../src/rulebook/__tests__/renderers.test.ts | 21 +++ packages/cli/src/rulebook/index.ts | 2 + .../cli/src/rulebook/renderers/agents-md.ts | 53 +++++++ 14 files changed, 549 insertions(+), 194 deletions(-) create mode 100644 packages/cli/src/commands/init/lib/write-context.ts create mode 100644 packages/cli/src/commands/init/steps/handoff-agents-md.ts create mode 100644 packages/cli/src/commands/init/steps/handoff-codex.ts create mode 100644 packages/cli/src/commands/init/steps/handoff-wizard.ts create mode 100644 packages/cli/src/rulebook/renderers/agents-md.ts diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md index a26506e4..d73d377b 100644 --- a/.changeset/cli-init-agent-handoff.md +++ b/.changeset/cli-init-agent-handoff.md @@ -2,18 +2,19 @@ '@cipherstash/cli': minor --- -`stash init` can now hand off the rest of setup to your local coding agent. +`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with. -When `claude` is on PATH, `init` offers to install a project-local Claude Code skill at `.claude/skills/cipherstash-setup/SKILL.md` and write a `.cipherstash/context.json` describing the integration, encryption client path, columns, env keys, and package manager. Choosing the handoff option then launches `claude` interactively with a prompt that points at the skill and context file. The skill body is rendered from a versioned rulebook with integration-specific rules for Drizzle, Supabase, and plain PostgreSQL — so the agent gets the same correctness rules our hosted wizard uses. +After authentication, schema generation, and Forge install, init shows a four-option menu: -Three follow-up modes are available at the new "how to proceed" step: +- **Hand off to Claude Code** — installs `.claude/skills/cipherstash-setup/SKILL.md`, writes `.cipherstash/context.json`, spawns `claude` interactively. Default when `claude` is on PATH. +- **Hand off to Codex** — writes `AGENTS.md` + `.cipherstash/context.json`, spawns `codex` interactively. Default when `codex` is on PATH (and `claude` is not). +- **Use the CipherStash Agent** — writes `.cipherstash/context.json` and runs `stash wizard`. The fallback for users without a local CLI agent. +- **Write AGENTS.md** — writes `AGENTS.md` + `.cipherstash/context.json` and stops. For Cursor, Windsurf, Cline, and any tool that follows the AGENTS.md convention. -- **Hand off to Claude Code** — install skill, write context, spawn `claude`. Default when `claude` is detected. -- **Just write the rules files** — same writes, no spawn. For users driving Codex / Cursor / their own agent. -- **Use the built-in wizard** — keeps the existing `stash wizard` flow as the fallback. +Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, the CLI still writes the rules files and prints install + manual-launch instructions. The user's progress is never wasted. -The rulebook ships bundled with the CLI; if `wizard.getstash.sh/v1/wizard/rulebook` is reachable, the CLI prefers the gateway-served version (so content updates between releases land without a CLI bump). Network failures fall through to the bundled copy silently. +The rules content comes from a versioned rulebook (core + integration partials for Drizzle, Supabase, and plain PostgreSQL) shipped bundled with the CLI. When `wizard.getstash.sh/v1/wizard/rulebook` is reachable, the CLI prefers the gateway-served version so content updates between releases land without a CLI bump; network failures fall through to the bundled copy silently. `CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for local testing. -Re-running `init` is safe — both the SKILL.md and any future shared artifact use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. +Re-running `init` is safe — both `SKILL.md` and `AGENTS.md` use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. -Phase 1 only targets Claude Code; Codex (`AGENTS.md` + spawn `codex`), Cursor `.cursor/rules/*.mdc`, and `.github/copilot-instructions.md` are scoped for follow-up phases. +The `.cipherstash/context.json` file is the universal "what shape is this project" payload — integration, encryption client path, schema, env key names (never values), package manager, install command, rulebook + CLI versions, generation timestamp. diff --git a/packages/cli/src/commands/init/__tests__/detect-agents.test.ts b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts index fe4c9703..a10954d6 100644 --- a/packages/cli/src/commands/init/__tests__/detect-agents.test.ts +++ b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts @@ -20,6 +20,7 @@ describe('detectAgents', () => { expect(env.project.claudeDir).toBe(false) expect(env.project.claudeMd).toBe(false) expect(env.project.claudeSkillsDir).toBe(false) + expect(env.project.agentsMd).toBe(false) }) it('detects CLAUDE.md, .claude/, and .claude/skills/', () => { @@ -32,6 +33,18 @@ describe('detectAgents', () => { expect(env.project.claudeSkillsDir).toBe(true) }) + it('detects AGENTS.md at the project root', () => { + writeFileSync(join(tmp, 'AGENTS.md'), '# project rules\n') + const env = detectAgents(tmp, {}) + expect(env.project.agentsMd).toBe(true) + }) + + it('exposes both claudeCode and codex as boolean fields on cli', () => { + const env = detectAgents(tmp, {}) + expect(typeof env.cli.claudeCode).toBe('boolean') + expect(typeof env.cli.codex).toBe('boolean') + }) + it('classifies the editor from env signals', () => { expect(detectAgents(tmp, { CURSOR_TRACE_ID: 'abc' }).editor).toBe('cursor') expect(detectAgents(tmp, { TERM_PROGRAM: 'vscode' }).editor).toBe('vscode') diff --git a/packages/cli/src/commands/init/detect-agents.ts b/packages/cli/src/commands/init/detect-agents.ts index 1d624f70..43a57670 100644 --- a/packages/cli/src/commands/init/detect-agents.ts +++ b/packages/cli/src/commands/init/detect-agents.ts @@ -8,6 +8,8 @@ export interface AgentEnvironment { cli: { /** `claude` is on PATH. */ claudeCode: boolean + /** `codex` is on PATH. */ + codex: boolean } project: { /** A `.claude/` directory exists at the project root. */ @@ -16,6 +18,8 @@ export interface AgentEnvironment { claudeMd: boolean /** A `.claude/skills/` directory exists at the project root. */ claudeSkillsDir: boolean + /** An `AGENTS.md` file exists at the project root. */ + agentsMd: boolean } /** Which editor is hosting the current terminal, if recognisable. */ editor: Editor @@ -58,9 +62,6 @@ function isDirectory(path: string): boolean { /** * Detect available coding agents and editor context. * - * Phase 1 only surfaces Claude Code. The shape leaves room for `codex`, - * `gemini`, `cursor` etc. in later phases without changing call sites. - * * `cwd` and `env` are injected so tests can mock them; production callers can * use the no-arg form. */ @@ -71,11 +72,13 @@ export function detectAgents( return { cli: { claudeCode: isOnPath('claude'), + codex: isOnPath('codex'), }, project: { claudeDir: isDirectory(resolve(cwd, '.claude')), claudeMd: existsSync(resolve(cwd, 'CLAUDE.md')), claudeSkillsDir: isDirectory(resolve(cwd, '.claude', 'skills')), + agentsMd: existsSync(resolve(cwd, 'AGENTS.md')), }, editor: detectEditor(env), } diff --git a/packages/cli/src/commands/init/lib/fetch-rulebook.ts b/packages/cli/src/commands/init/lib/fetch-rulebook.ts index 1409cad8..59f35dc7 100644 --- a/packages/cli/src/commands/init/lib/fetch-rulebook.ts +++ b/packages/cli/src/commands/init/lib/fetch-rulebook.ts @@ -1,5 +1,8 @@ -import { renderClaudeSkill } from '../../../rulebook/index.js' -import { RULEBOOK_VERSION } from '../../../rulebook/index.js' +import { + RULEBOOK_VERSION, + renderAgentsMd, + renderClaudeSkill, +} from '../../../rulebook/index.js' import type { Integration } from '../types.js' const DEFAULT_GATEWAY_URL = 'https://wizard.getstash.sh/v1/wizard/rulebook' @@ -24,40 +27,57 @@ function gatewayIntegration(integration: Integration): string { return integration === 'postgresql' ? 'generic' : integration } +/** Agents we know how to render rulebook content for. */ +export type RulebookAgent = 'claude-code' | 'codex' + interface RulebookResponse { - /** Server-rendered SKILL.md body. */ + /** Server-rendered artifact body (SKILL.md or AGENTS.md, depending on agent). */ skill: string /** Version string the gateway used to render — for drift logging. */ rulebookVersion: string } interface FetchedRulebook { - skill: string + /** Rendered artifact body. Field name is `body` rather than `skill` here so + * the in-process variable matches the artifact it represents. */ + body: string rulebookVersion: string source: 'gateway' | 'bundled' } +/** + * Render the bundled rulebook for an agent without going through the network. + * Used as the fallback when the gateway is unreachable, and as the source of + * truth when running offline. + */ +function bundledRulebook( + integration: Integration, + agent: RulebookAgent, +): FetchedRulebook { + const body = + agent === 'claude-code' + ? renderClaudeSkill({ integration }) + : renderAgentsMd({ integration }) + return { body, rulebookVersion: RULEBOOK_VERSION, source: 'bundled' } +} + /** * Fetch the latest rulebook from the gateway, with bundled fallback. * * Network and auth failures are non-fatal — we always have the bundled copy. * The gateway is the long-term source of truth for content updates between - * CLI releases. Phase 1 keeps the call best-effort and short-timeout; we don't - * want a flaky network turning init into a 60-second wait. + * CLI releases. We keep the call best-effort with a 5s timeout so a flaky + * network can't turn init into a 60-second wait. */ export async function fetchRulebook({ integration, + agent, clientVersion, }: { integration: Integration + agent: RulebookAgent clientVersion: string }): Promise { - const bundled = (): FetchedRulebook => ({ - skill: renderClaudeSkill({ integration }), - rulebookVersion: RULEBOOK_VERSION, - source: 'bundled', - }) - const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5_000) @@ -66,7 +86,7 @@ export async function fetchRulebook({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - agent: 'claude-code', + agent, integration: gatewayIntegration(integration), clientVersion, bundledVersion: RULEBOOK_VERSION, @@ -74,19 +94,19 @@ export async function fetchRulebook({ signal: controller.signal, }) - if (!res.ok) return bundled() + if (!res.ok) return bundledRulebook(integration, agent) const data = (await res.json()) as Partial if (typeof data.skill !== 'string' || data.skill.length === 0) { - return bundled() + return bundledRulebook(integration, agent) } return { - skill: data.skill, + body: data.skill, rulebookVersion: data.rulebookVersion ?? RULEBOOK_VERSION, source: 'gateway', } } catch { - return bundled() + return bundledRulebook(integration, agent) } finally { clearTimeout(timeout) } diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts new file mode 100644 index 00000000..04353e7e --- /dev/null +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -0,0 +1,110 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { InitState, Integration, SchemaDef } from '../types.js' +import { + type PackageManager, + detectPackageManager, + prodInstallCommand, +} from '../utils.js' +import { upsertManagedBlock } from './sentinel-upsert.js' + +export const CONTEXT_REL_PATH = '.cipherstash/context.json' + +export interface ContextFile { + rulebookVersion: string + cliVersion: string + integration: Integration + encryptionClientPath: string + packageManager: PackageManager + installCommand: string + envKeys: string[] + schema: SchemaDef + generatedAt: string +} + +/** + * Walk up from this file to find the CLI's package.json. The compiled file + * lives at `dist/index.js` (or similar) and the source at + * `src/commands/init/lib/write-context.ts`, so we walk up to six levels. + * Falling back to `'unknown'` is fine — the field is informational. + */ +export function readCliVersion(): string { + let dir = dirname(fileURLToPath(import.meta.url)) + for (let i = 0; i < 6; i++) { + const candidate = resolve(dir, 'package.json') + if (existsSync(candidate)) { + try { + const pkg = JSON.parse(readFileSync(candidate, 'utf-8')) as { + name?: string + version?: string + } + if (pkg.name === '@cipherstash/cli' && pkg.version) return pkg.version + } catch { + // keep walking + } + } + dir = dirname(dir) + } + return 'unknown' +} + +function ensureDir(path: string): void { + const dir = dirname(path) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +/** + * Write a project artifact (SKILL.md / AGENTS.md / etc.) using the + * managed-block upsert util so re-runs replace only our region. + */ +export function writeArtifact(absPath: string, body: string): void { + const existing = existsSync(absPath) + ? readFileSync(absPath, 'utf-8') + : undefined + const next = upsertManagedBlock({ existing, managed: body }) + ensureDir(absPath) + writeFileSync(absPath, next, 'utf-8') +} + +/** + * Build the universal `.cipherstash/context.json` from `InitState` plus the + * resolved rulebook version. Throws on a missing schema — the build-schema + * step is required to have run before any handoff fires. + */ +export function buildContextFile( + state: InitState, + rulebookVersion: string, +): ContextFile { + const integration = state.integration ?? 'postgresql' + const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' + const schema = state.schema + if (!schema) { + // Should not happen — build-schema always populates this. Keep the + // assertion explicit so a future refactor that drops the field gets + // caught here rather than producing a half-empty context.json. + throw new Error('Schema missing from init state — cannot write context.') + } + + const pm = detectPackageManager() + return { + rulebookVersion, + cliVersion: readCliVersion(), + integration, + encryptionClientPath: clientFilePath, + packageManager: pm, + installCommand: prodInstallCommand(pm, '@cipherstash/stack'), + envKeys: [], + schema, + generatedAt: new Date().toISOString(), + } +} + +/** + * Persist the context file to disk. The CLI owns this path; never call from + * outside the init / handoff steps. + */ +export function writeContextFile(absPath: string, ctx: ContextFile): void { + ensureDir(absPath) + writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') +} diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts new file mode 100644 index 00000000..e9368fd3 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -0,0 +1,69 @@ +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { + CONTEXT_REL_PATH, + buildContextFile, + readCliVersion, + writeArtifact, + writeContextFile, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { readEnvKeyNames } from './gather-context.js' + +const AGENTS_MD_REL_PATH = 'AGENTS.md' + +/** + * Write `AGENTS.md` + `.cipherstash/context.json` and stop. + * + * For users running editor-based agents (Cursor, Windsurf, Cline) or any + * tool that follows the AGENTS.md convention. We do not spawn anything — + * the user opens their tool and the agent picks the file up from the + * project root automatically. + */ +export const handoffAgentsMdStep: InitStep = { + id: 'handoff-agents-md', + name: 'Write AGENTS.md', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const cliVersion = readCliVersion() + const envKeys = readEnvKeyNames(cwd) + + const rulebookSpinner = p.spinner() + rulebookSpinner.start('Fetching rulebook...') + const rulebook = await fetchRulebook({ + integration, + agent: 'codex', + clientVersion: cliVersion, + }) + rulebookSpinner.stop( + rulebook.source === 'gateway' + ? `Rulebook ${rulebook.rulebookVersion} fetched.` + : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, + ) + + const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) + writeArtifact(agentsMdAbs, rulebook.body) + p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state, rulebook.rulebookVersion) + ctx.envKeys = envKeys + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + p.note( + [ + `Rules at ${AGENTS_MD_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Cursor / Windsurf / Cline pick up AGENTS.md automatically.', + 'For other tools, point your agent at the file and the context.', + ].join('\n'), + 'Drive your editor agent', + ) + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index 67c7ada8..97f331b9 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -1,77 +1,22 @@ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' import * as p from '@clack/prompts' import { CLAUDE_SKILL_NAME } from '../../../rulebook/index.js' import { fetchRulebook } from '../lib/fetch-rulebook.js' -import { upsertManagedBlock } from '../lib/sentinel-upsert.js' -import type { - InitProvider, - InitState, - InitStep, - Integration, - SchemaDef, -} from '../types.js' -import { detectPackageManager, prodInstallCommand } from '../utils.js' +import { + CONTEXT_REL_PATH, + buildContextFile, + readCliVersion, + writeArtifact, + writeContextFile, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' import { readEnvKeyNames } from './gather-context.js' const SKILL_REL_PATH = `.claude/skills/${CLAUDE_SKILL_NAME}/SKILL.md` -const CONTEXT_REL_PATH = '.cipherstash/context.json' -interface ContextFile { - rulebookVersion: string - cliVersion: string - integration: Integration - encryptionClientPath: string - packageManager: string - installCommand: string - envKeys: string[] - schema: SchemaDef - generatedAt: string -} - -function readCliVersion(): string { - // package.json sits two levels above the compiled file (dist/) and three - // levels above the source file. Walk up until we find it. Falling back to - // 'unknown' is fine — the field is informational. - let dir = dirname(fileURLToPath(import.meta.url)) - for (let i = 0; i < 6; i++) { - const candidate = resolve(dir, 'package.json') - if (existsSync(candidate)) { - try { - const pkg = JSON.parse(readFileSync(candidate, 'utf-8')) as { - name?: string - version?: string - } - if (pkg.name === '@cipherstash/cli' && pkg.version) return pkg.version - } catch { - // keep walking - } - } - dir = dirname(dir) - } - return 'unknown' -} - -function ensureDir(path: string) { - const dir = dirname(path) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) -} - -function writeSkillFile(absPath: string, body: string) { - const existing = existsSync(absPath) - ? readFileSync(absPath, 'utf-8') - : undefined - const next = upsertManagedBlock({ existing, managed: body }) - ensureDir(absPath) - writeFileSync(absPath, next, 'utf-8') -} - -function writeContextFile(absPath: string, ctx: ContextFile) { - ensureDir(absPath) - writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') -} +const CLAUDE_INSTALL_URL = + 'https://docs.claude.com/en/docs/claude-code/quickstart' /** * Spawn `claude` interactively in the user's terminal so they can watch tool @@ -94,9 +39,9 @@ function spawnClaude(prompt: string): Promise { } /** - * Final step on the Claude Code path: write the project skill, write the - * context file, then either spawn `claude` (handoff='claude-code') or print - * the next-steps for the user to drive their own agent (handoff='rules-only'). + * Hand off to Claude Code: install the project skill, write context.json, + * spawn `claude`. If `claude` is not on PATH we still write the artifacts + * (so the user has them ready) and print install + manual-launch instructions. */ export const handoffClaudeStep: InitStep = { id: 'handoff-claude', @@ -104,16 +49,6 @@ export const handoffClaudeStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' - const schema = state.schema - if (!schema) { - // Should not happen — build-schema always populates this. Keep the - // assertion explicit so a future refactor that drops the field gets - // caught here rather than producing a half-empty context.json. - throw new Error('Schema missing from init state — cannot write context.') - } - - const pm = detectPackageManager() const cliVersion = readCliVersion() const envKeys = readEnvKeyNames(cwd) @@ -121,6 +56,7 @@ export const handoffClaudeStep: InitStep = { rulebookSpinner.start('Fetching rulebook...') const rulebook = await fetchRulebook({ integration, + agent: 'claude-code', clientVersion: cliVersion, }) rulebookSpinner.stop( @@ -130,56 +66,36 @@ export const handoffClaudeStep: InitStep = { ) const skillAbs = resolve(cwd, SKILL_REL_PATH) - writeSkillFile(skillAbs, rulebook.skill) + writeArtifact(skillAbs, rulebook.body) p.log.success(`Wrote ${SKILL_REL_PATH}`) const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx: ContextFile = { - rulebookVersion: rulebook.rulebookVersion, - cliVersion, - integration, - encryptionClientPath: clientFilePath, - packageManager: pm, - installCommand: prodInstallCommand(pm, '@cipherstash/stack'), - envKeys, - schema, - generatedAt: new Date().toISOString(), - } + const ctx = buildContextFile(state, rulebook.rulebookVersion) + ctx.envKeys = envKeys writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - if (state.handoff === 'rules-only') { - p.note( - [ - `Rules installed at ${SKILL_REL_PATH}`, - `Context at ${CONTEXT_REL_PATH}`, - '', - 'Point your agent at the skill, or read it directly:', - ` cat ${SKILL_REL_PATH}`, - ].join('\n'), - 'Drive your own agent', - ) - return state - } + const launchPrompt = `Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}.` if (!state.agents?.cli.claudeCode) { - p.log.warn('`claude` is not on PATH. Skipping spawn.') p.note( [ - 'When you have Claude Code installed, run:', - ` claude "Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}."`, + 'Claude Code is not installed on this machine.', + `Install: ${CLAUDE_INSTALL_URL}`, + '', + 'Once installed, run:', + ` claude "${launchPrompt}"`, ].join('\n'), - 'Manual handoff', + 'Files written — install Claude Code to run the handoff', ) return state } p.log.info('Launching Claude Code...') - const prompt = `Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}.` - const exitCode = await spawnClaude(prompt) + const exitCode = await spawnClaude(launchPrompt) if (exitCode !== 0) { p.log.warn( - `Claude Code exited with code ${exitCode}. Re-run \`claude "${prompt}"\` to resume.`, + `Claude Code exited with code ${exitCode}. Re-run \`claude "${launchPrompt}"\` to resume.`, ) } diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts new file mode 100644 index 00000000..f54833e4 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -0,0 +1,93 @@ +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { + CONTEXT_REL_PATH, + buildContextFile, + readCliVersion, + writeArtifact, + writeContextFile, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { readEnvKeyNames } from './gather-context.js' + +const AGENTS_MD_REL_PATH = 'AGENTS.md' + +const CODEX_INSTALL_URL = 'https://github.com/openai/codex' + +function spawnCodex(prompt: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn('codex', [prompt], { + stdio: 'inherit', + shell: false, + }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', () => resolvePromise(-1)) + }) +} + +/** + * Hand off to Codex CLI: write AGENTS.md (sentinel-upserted) + context.json, + * spawn `codex`. If `codex` is not on PATH we still write the artifacts and + * print install + manual-launch instructions. + */ +export const handoffCodexStep: InitStep = { + id: 'handoff-codex', + name: 'Hand off to Codex', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const cliVersion = readCliVersion() + const envKeys = readEnvKeyNames(cwd) + + const rulebookSpinner = p.spinner() + rulebookSpinner.start('Fetching rulebook...') + const rulebook = await fetchRulebook({ + integration, + agent: 'codex', + clientVersion: cliVersion, + }) + rulebookSpinner.stop( + rulebook.source === 'gateway' + ? `Rulebook ${rulebook.rulebookVersion} fetched.` + : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, + ) + + const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) + writeArtifact(agentsMdAbs, rulebook.body) + p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state, rulebook.rulebookVersion) + ctx.envKeys = envKeys + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + const launchPrompt = `Read AGENTS.md and complete the CipherStash setup. Context is in ${CONTEXT_REL_PATH}.` + + if (!state.agents?.cli.codex) { + p.note( + [ + 'Codex is not installed on this machine.', + `Install: ${CODEX_INSTALL_URL}`, + '', + 'Once installed, run:', + ` codex "${launchPrompt}"`, + ].join('\n'), + 'Files written — install Codex to run the handoff', + ) + return state + } + + p.log.info('Launching Codex...') + const exitCode = await spawnCodex(launchPrompt) + if (exitCode !== 0) { + p.log.warn( + `Codex exited with code ${exitCode}. Re-run \`codex "${launchPrompt}"\` to resume.`, + ) + } + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/init/steps/handoff-wizard.ts new file mode 100644 index 00000000..7a616852 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-wizard.ts @@ -0,0 +1,42 @@ +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { RULEBOOK_VERSION } from '../../../rulebook/index.js' +import { wizardCommand } from '../../wizard/index.js' +import { + CONTEXT_REL_PATH, + buildContextFile, + writeContextFile, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { readEnvKeyNames } from './gather-context.js' + +/** + * Hand off to the CipherStash Agent (the in-house wizard package). + * + * Writes `.cipherstash/context.json` so the wizard has the same prepared + * facts the other handoffs use, then invokes `wizardCommand` — the same + * thin-wrapper subcommand a user would get from `stash wizard` directly. + * + * No SKILL.md / AGENTS.md is written here. The wizard renders its own + * agent-side prompt from the gateway and doesn't read disk-bound rulebooks. + */ +export const handoffWizardStep: InitStep = { + id: 'handoff-wizard', + name: 'Use the CipherStash Agent', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const envKeys = readEnvKeyNames(cwd) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state, RULEBOOK_VERSION) + ctx.envKeys = envKeys + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + // Pass through no extra flags. If a user wants to debug the wizard, they + // can re-run `stash wizard --debug` directly afterwards. + await wizardCommand([]) + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/init/steps/how-to-proceed.ts index 633b0f14..46d85499 100644 --- a/packages/cli/src/commands/init/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/init/steps/how-to-proceed.ts @@ -1,5 +1,4 @@ import * as p from '@clack/prompts' -import { shouldOfferClaudeCode } from '../detect-agents.js' import { CancelledError, type HandoffChoice, @@ -7,75 +6,88 @@ import { type InitState, type InitStep, } from '../types.js' +import { handoffAgentsMdStep } from './handoff-agents-md.js' import { handoffClaudeStep } from './handoff-claude.js' +import { handoffCodexStep } from './handoff-codex.js' +import { handoffWizardStep } from './handoff-wizard.js' /** - * Ask the user how they want to finish setup, then dispatch. + * Pick the default option in the four-way menu. * - * - Claude Code handoff is offered as the default when `claude` is on PATH. - * - The built-in wizard option points the user at `stash wizard` rather than - * running it inline; the wizard is a separate command and Phase 1 keeps - * that boundary intact. - * - "Just write the rules files" is always offered as the no-spawn escape - * hatch for users who drive their own agent (Codex / Cursor / hand-rolled). + * Detected CLIs win — Claude Code first, then Codex. Otherwise we default to + * the AGENTS.md path because that's the broadest "works without anything else + * installed" option. The CipherStash Agent option is positioned as a fallback + * (slow first run, requires the wizard package on top of the CLI) and is + * never selected by default. */ -export const howToProceedStep: InitStep = { - id: 'how-to-proceed', - name: 'How to proceed', - async run(state: InitState, _provider: InitProvider): Promise { - const claudeAvailable = state.agents - ? shouldOfferClaudeCode(state.agents) - : false - - const options: { value: HandoffChoice; label: string; hint?: string }[] = [] - - if (claudeAvailable) { - options.push({ - value: 'claude-code', - label: 'Hand off to Claude Code', - hint: 'install a project skill, then launch `claude` interactively', - }) - } +function defaultChoice(state: InitState): HandoffChoice { + if (state.agents?.cli.claudeCode) return 'claude-code' + if (state.agents?.cli.codex) return 'codex' + return 'agents-md' +} - options.push({ - value: 'rules-only', - label: 'Just write the rules files', - hint: 'I will drive my own agent (Codex / Cursor / etc.)', - }) +/** + * Build the option list for the four-way menu. Hints reflect detection state + * — a missing CLI doesn't hide the option (handoff steps still write the + * rules files and print install instructions), it just nudges the user. + */ +function buildOptions( + state: InitState, +): { value: HandoffChoice; label: string; hint?: string }[] { + const claudeHint = state.agents?.cli.claudeCode + ? 'claude detected — will launch interactively' + : 'claude not on PATH — files will be written, install link shown' + const codexHint = state.agents?.cli.codex + ? 'codex detected — will launch interactively' + : 'codex not on PATH — files will be written, install link shown' - options.push({ + return [ + { + value: 'claude-code', + label: 'Hand off to Claude Code', + hint: claudeHint, + }, + { + value: 'codex', + label: 'Hand off to Codex', + hint: codexHint, + }, + { value: 'wizard', - label: "Use CipherStash's built-in wizard", - hint: 'run `stash wizard` after init finishes', - }) + label: 'Use the CipherStash Agent', + hint: 'our hosted setup wizard (runs `stash wizard`)', + }, + { + value: 'agents-md', + label: 'Write AGENTS.md', + hint: 'works with Cursor, Windsurf, Cline, and more', + }, + ] +} +export const howToProceedStep: InitStep = { + id: 'how-to-proceed', + name: 'How to proceed', + async run(state: InitState, provider: InitProvider): Promise { const choice = await p.select({ message: 'How would you like to finish setup?', - options, - initialValue: claudeAvailable ? 'claude-code' : 'rules-only', + options: buildOptions(state), + initialValue: defaultChoice(state), }) if (p.isCancel(choice)) throw new CancelledError() const next: InitState = { ...state, handoff: choice } - if (choice === 'claude-code') { - return handoffClaudeStep.run(next, _provider) - } - - if (choice === 'rules-only') { - // Rules-only path still installs the project skill so Codex / Cursor / - // hand-rolled agents can be pointed at .claude/skills/cipherstash-setup - // (or read it directly). Same writer, no spawn. - return handoffClaudeStep.run( - { ...next, handoff: 'rules-only' }, - _provider, - ) + switch (choice) { + case 'claude-code': + return handoffClaudeStep.run(next, provider) + case 'codex': + return handoffCodexStep.run(next, provider) + case 'agents-md': + return handoffAgentsMdStep.run(next, provider) + case 'wizard': + return handoffWizardStep.run(next, provider) } - - p.log.info( - 'When you are ready, run `stash wizard` to launch the built-in setup agent.', - ) - return next }, } diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index cca34f35..cb919af0 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -18,7 +18,7 @@ export interface SchemaDef { columns: ColumnDef[] } -export type HandoffChoice = 'claude-code' | 'wizard' | 'rules-only' +export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' export interface InitState { authenticated?: boolean diff --git a/packages/cli/src/rulebook/__tests__/renderers.test.ts b/packages/cli/src/rulebook/__tests__/renderers.test.ts index 40dae487..10904033 100644 --- a/packages/cli/src/rulebook/__tests__/renderers.test.ts +++ b/packages/cli/src/rulebook/__tests__/renderers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_SKILL_NAME, RULEBOOK_VERSION, + renderAgentsMd, renderClaudeSkill, renderGatewayPrompt, } from '../index.js' @@ -38,3 +39,23 @@ describe('renderClaudeSkill', () => { expect(out).toContain('.cipherstash/context.json') }) }) + +describe('renderAgentsMd', () => { + it('emits plain markdown without YAML frontmatter', () => { + const out = renderAgentsMd({ integration: 'drizzle' }) + expect(out.startsWith('---')).toBe(false) + expect(out.startsWith('# CipherStash Setup')).toBe(true) + }) + + it('includes the rulebook version and integration in the header', () => { + const out = renderAgentsMd({ integration: 'drizzle' }) + expect(out).toContain(`Rulebook version: ${RULEBOOK_VERSION}`) + expect(out).toContain('integration: drizzle') + }) + + it('points the agent at .cipherstash/context.json', () => { + const out = renderAgentsMd({ integration: 'supabase' }) + expect(out).toContain('.cipherstash/context.json') + expect(out).toContain('First step') + }) +}) diff --git a/packages/cli/src/rulebook/index.ts b/packages/cli/src/rulebook/index.ts index a467da1a..7825f868 100644 --- a/packages/cli/src/rulebook/index.ts +++ b/packages/cli/src/rulebook/index.ts @@ -6,3 +6,5 @@ export { CLAUDE_SKILL_NAME, } from './renderers/claude-skill.js' export type { ClaudeSkillContext } from './renderers/claude-skill.js' +export { renderAgentsMd } from './renderers/agents-md.js' +export type { AgentsMdContext } from './renderers/agents-md.js' diff --git a/packages/cli/src/rulebook/renderers/agents-md.ts b/packages/cli/src/rulebook/renderers/agents-md.ts new file mode 100644 index 00000000..e4e11c39 --- /dev/null +++ b/packages/cli/src/rulebook/renderers/agents-md.ts @@ -0,0 +1,53 @@ +import type { Integration } from '../../commands/init/types.js' +import { loadCorePartial, loadIntegrationPartial } from '../partials.js' +import { RULEBOOK_VERSION } from '../version.js' + +export interface AgentsMdContext { + integration: Integration +} + +/** + * Render the `AGENTS.md` body that lives at the project root. + * + * `AGENTS.md` is the convention used by Codex CLI, ChatGPT Codex (cloud), + * Cursor (as a fallback), Windsurf, Cline, and most other coding agents that + * are not Claude Code. They all read it from the repo root automatically. + * + * Unlike the Claude Code skill body, AGENTS.md is plain markdown — no YAML + * frontmatter. The CLI wraps the rendered body in sentinel markers + * (``) before writing, so re-runs + * upsert in place and any user-authored content outside the block is + * preserved. + */ +export function renderAgentsMd(ctx: AgentsMdContext): string { + const core = loadCorePartial() + const integration = loadIntegrationPartial(ctx.integration) + + return [ + `# CipherStash Setup (${ctx.integration})`, + '', + `Rulebook version: ${RULEBOOK_VERSION}`, + '', + 'You are completing a CipherStash field-level encryption setup that the user already started with `stash init`. The CLI did the discovery and authentication; your job is to land the code changes the rulebook below describes.', + '', + '## First step', + '', + '1. Read `.cipherstash/context.json`. If it is missing, stop and tell the user to run `stash init`.', + `2. Confirm the integration listed in the context matches this rulebook (\`integration: ${ctx.integration}\`). If it does not, stop and ask the user to re-run \`stash init\`.`, + '3. Apply the rules below to the file at `context.encryptionClientPath` and any related migration / client wiring.', + '4. Show the user a diff before applying any database migration.', + '', + core.trim(), + '', + integration.trim(), + '', + '## Done when', + '', + '- Encrypted columns from `context.json` are in the schema file with correct types and search ops.', + '- The encryption client is exported from the path in `context.json`.', + '- Any new env keys are listed in `.env.example`, and the user knows which values to add to their local `.env`.', + '- Drizzle / Supabase / Postgres-specific wiring (per the section above) is in place.', + '- Migrations have been generated but not applied — the user runs them.', + '', + ].join('\n') +} From f36e58342005629d12bacf8efe7fece82a815738 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 19:01:28 +1000 Subject: [PATCH 03/10] feat(cli): introspect, install EQL, and emit a project-specific action prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that turn the agent handoff from "here are the rules, figure it out" into "here's exactly what to do": 1. **Resolve DATABASE_URL upfront and require it.** New resolve-database step wraps the existing resolveDatabaseUrl resolver (--flag → env → supabase status → interactive prompt → hard fail). The URL is threaded through state so downstream steps don't re-prompt. 2. **Introspect the database and let the user pick columns.** build-schema now connects, lists tables in the public schema, and runs the same multi-select UX as `stash schema build` (lifted into a shared lib). Empty databases still fall back to the placeholder users/email/name client; the action prompt notes that and asks the agent to reshape it. 3. **Install EQL during init.** New install-eql step runs `stash db install` programmatically after a y/N confirm, using the URL we already resolved. No second credential prompt. Skipping isn't a dead end — the action prompt's first TODO becomes "run stash db install". 4. **Write `.cipherstash/setup-prompt.md`** — a per-project action plan generated from init state. Lists what init already did and what's left with exact commands and paths (drizzle-kit generate / migrate, supabase migration new, etc.) tailored to the detected integration and package manager. Claude / Codex launch prompts now point the agent at this file first; the skill / AGENTS.md provides the reusable rulebook the prompt references. For IDE users, it's ready to paste into the first chat. Renames install-forge → install-deps. "Forge" was the legacy name for the CLI itself (renamed to `stash` in #383); the step installs `@cipherstash/stack` and `stash`, so install-deps says what it actually does. Refactor: introspectDatabase + selectTableColumns lifted from schema/build.ts into init/lib/introspect.ts so both the standalone command and the new init step share one codepath. generateClientFromSchemas similarly consolidated into init/utils.ts. --- .changeset/cli-init-agent-handoff.md | 26 +- packages/cli/src/commands/init/index.ts | 8 +- .../cli/src/commands/init/lib/introspect.ts | 232 ++++++++++++ .../src/commands/init/lib/write-context.ts | 54 ++- .../src/commands/init/steps/build-schema.ts | 67 +++- .../commands/init/steps/handoff-agents-md.ts | 18 +- .../src/commands/init/steps/handoff-claude.ts | 21 +- .../src/commands/init/steps/handoff-codex.ts | 17 +- .../{install-forge.ts => install-deps.ts} | 34 +- .../src/commands/init/steps/install-eql.ts | 64 ++++ .../commands/init/steps/resolve-database.ts | 30 ++ packages/cli/src/commands/init/types.ts | 14 +- packages/cli/src/commands/init/utils.ts | 105 ++++++ packages/cli/src/commands/schema/build.ts | 333 +----------------- .../rulebook/__tests__/setup-prompt.test.ts | 91 +++++ packages/cli/src/rulebook/index.ts | 2 + .../src/rulebook/renderers/setup-prompt.ts | 226 ++++++++++++ 17 files changed, 965 insertions(+), 377 deletions(-) create mode 100644 packages/cli/src/commands/init/lib/introspect.ts rename packages/cli/src/commands/init/steps/{install-forge.ts => install-deps.ts} (69%) create mode 100644 packages/cli/src/commands/init/steps/install-eql.ts create mode 100644 packages/cli/src/commands/init/steps/resolve-database.ts create mode 100644 packages/cli/src/rulebook/__tests__/setup-prompt.test.ts create mode 100644 packages/cli/src/rulebook/renderers/setup-prompt.ts diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md index d73d377b..536b39c1 100644 --- a/.changeset/cli-init-agent-handoff.md +++ b/.changeset/cli-init-agent-handoff.md @@ -1,20 +1,28 @@ --- -'@cipherstash/cli': minor +'stash': minor --- -`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with. +`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with — and it leaves them with a project-specific action plan, not just generic rules. -After authentication, schema generation, and Forge install, init shows a four-option menu: +The new pipeline: -- **Hand off to Claude Code** — installs `.claude/skills/cipherstash-setup/SKILL.md`, writes `.cipherstash/context.json`, spawns `claude` interactively. Default when `claude` is on PATH. -- **Hand off to Codex** — writes `AGENTS.md` + `.cipherstash/context.json`, spawns `codex` interactively. Default when `codex` is on PATH (and `claude` is not). -- **Use the CipherStash Agent** — writes `.cipherstash/context.json` and runs `stash wizard`. The fallback for users without a local CLI agent. -- **Write AGENTS.md** — writes `AGENTS.md` + `.cipherstash/context.json` and stops. For Cursor, Windsurf, Cline, and any tool that follows the AGENTS.md convention. +1. **Authenticate** (unchanged). +2. **Resolve `DATABASE_URL`** — uses the same resolver as `stash db install` (flag → env → `supabase status` → interactive prompt). Hard-fails with an actionable message if nothing resolves. +3. **Build the encryption client.** When the database has tables, `init` introspects them (the same multi-select UX `stash schema build` has) and generates a real client from the user's selection. When the database is empty, it falls back to the placeholder so fresh projects still work — and the action prompt notes the placeholder so the agent reshapes it later. +4. **Install dependencies** — `@cipherstash/stack` (runtime) + `stash` (CLI dev dep). Renamed from "Forge" since that name no longer means anything. +5. **Install EQL into the database** — y/N confirm, then runs `stash db install` programmatically against the URL we already resolved. No second prompt for credentials. +6. **Pick a handoff** from the four-option menu: + - **Hand off to Claude Code** — installs `.claude/skills/cipherstash-setup/SKILL.md`, writes `.cipherstash/context.json` and `.cipherstash/setup-prompt.md`, spawns `claude` interactively. Default when `claude` is on PATH. + - **Hand off to Codex** — writes `AGENTS.md` + `.cipherstash/context.json` + `.cipherstash/setup-prompt.md`, spawns `codex` interactively. Default when `codex` is on PATH (and `claude` is not). + - **Use the CipherStash Agent** — writes `.cipherstash/context.json` and runs `stash wizard`. Fallback for users without a local CLI agent. + - **Write AGENTS.md** — writes `AGENTS.md` + `.cipherstash/context.json` + `.cipherstash/setup-prompt.md` and stops. For Cursor, Windsurf, Cline, and any tool that follows the AGENTS.md convention. -Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, the CLI still writes the rules files and prints install + manual-launch instructions. The user's progress is never wasted. +Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, init still writes the rules files and prints install + manual-launch instructions. Progress is never wasted. + +`.cipherstash/setup-prompt.md` is the new headline artifact. It's the project-specific action plan — *"init has done X and Y; you need to do Z next, with these exact commands and paths"* — generated from the current init state. The launch prompt for Claude / Codex points the agent at this file first; the skill / AGENTS.md provides the reusable rulebook the prompt references. For IDE users, it's ready to paste into the first chat. The rules content comes from a versioned rulebook (core + integration partials for Drizzle, Supabase, and plain PostgreSQL) shipped bundled with the CLI. When `wizard.getstash.sh/v1/wizard/rulebook` is reachable, the CLI prefers the gateway-served version so content updates between releases land without a CLI bump; network failures fall through to the bundled copy silently. `CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for local testing. -Re-running `init` is safe — both `SKILL.md` and `AGENTS.md` use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. +Re-running `init` is safe — both `SKILL.md` and `AGENTS.md` use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. `setup-prompt.md` is regenerated wholesale each run since it's meant to reflect the current state. The `.cipherstash/context.json` file is the universal "what shape is this project" payload — integration, encryption client path, schema, env key names (never values), package manager, install command, rulebook + CLI versions, generation timestamp. diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 94262404..20c79696 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -6,8 +6,10 @@ import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' import { gatherContextStep } from './steps/gather-context.js' import { howToProceedStep } from './steps/how-to-proceed.js' -import { installForgeStep } from './steps/install-forge.js' +import { installDepsStep } from './steps/install-deps.js' +import { installEqlStep } from './steps/install-eql.js' import { nextStepsStep } from './steps/next-steps.js' +import { resolveDatabaseStep } from './steps/resolve-database.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' @@ -18,8 +20,10 @@ const PROVIDER_MAP: Record InitProvider> = { const STEPS = [ authenticateStep, + resolveDatabaseStep, buildSchemaStep, - installForgeStep, + installDepsStep, + installEqlStep, gatherContextStep, howToProceedStep, nextStepsStep, diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts new file mode 100644 index 00000000..8ab8eb0f --- /dev/null +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -0,0 +1,232 @@ +import * as p from '@clack/prompts' +import pg from 'pg' +import type { ColumnDef, DataType, SchemaDef, SearchOp } from '../types.js' + +export interface DbColumn { + columnName: string + dataType: string + udtName: string + isEqlEncrypted: boolean +} + +export interface DbTable { + tableName: string + columns: DbColumn[] +} + +/** + * Map a Postgres `udt_name` (e.g. `int4`, `timestamptz`) onto the CipherStash + * `DataType` taxonomy. Anything we can't classify falls back to `string`, + * which is the safest "treat the value as opaque text" default. + */ +export function pgTypeToDataType(udtName: string): DataType { + switch (udtName) { + case 'int2': + case 'int4': + case 'int8': + case 'float4': + case 'float8': + case 'numeric': + return 'number' + case 'bool': + return 'boolean' + case 'date': + case 'timestamp': + case 'timestamptz': + return 'date' + case 'json': + case 'jsonb': + return 'json' + default: + return 'string' + } +} + +/** + * Read every base table in the `public` schema along with its columns. + * + * The `eql_v2_encrypted` UDT marker tells us a column is already managed by + * CipherStash — useful for re-runs against a partially set up DB so we can + * pre-select those columns rather than asking the user to reconfirm. + */ +export async function introspectDatabase( + databaseUrl: string, +): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + try { + await client.connect() + + const { rows } = await client.query<{ + table_name: string + column_name: string + data_type: string + udt_name: string + }>(` + SELECT c.table_name, c.column_name, c.data_type, c.udt_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name, c.ordinal_position + `) + + const tableMap = new Map() + for (const row of rows) { + const cols = tableMap.get(row.table_name) ?? [] + cols.push({ + columnName: row.column_name, + dataType: row.data_type, + udtName: row.udt_name, + isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', + }) + tableMap.set(row.table_name, cols) + } + + return Array.from(tableMap.entries()).map(([tableName, columns]) => ({ + tableName, + columns, + })) + } finally { + await client.end() + } +} + +function allSearchOps(dataType: DataType): SearchOp[] { + const ops: SearchOp[] = ['equality', 'orderAndRange'] + if (dataType === 'string') { + ops.push('freeTextSearch') + } + return ops +} + +/** + * Interactive multi-select: which columns in which table should be encrypted? + * + * Returns `undefined` if the user cancels at any prompt — callers should + * propagate the cancellation rather than treating it as "no columns selected". + * + * Pre-selects columns that are already `eql_v2_encrypted` so re-running on a + * partially encrypted DB is a no-op by default. + */ +export async function selectTableColumns( + tables: DbTable[], +): Promise { + const selectedTable = await p.select({ + message: 'Which table do you want to encrypt columns in?', + options: tables.map((t) => { + const eqlCount = t.columns.filter((c) => c.isEqlEncrypted).length + const hint = + eqlCount > 0 + ? `${t.columns.length} columns, ${eqlCount} already encrypted` + : `${t.columns.length} column${t.columns.length !== 1 ? 's' : ''}` + return { value: t.tableName, label: t.tableName, hint } + }), + }) + + if (p.isCancel(selectedTable)) return undefined + + const table = tables.find((t) => t.tableName === selectedTable) + if (!table) return undefined + + const eqlColumns = table.columns.filter((c) => c.isEqlEncrypted) + + if (eqlColumns.length > 0) { + p.log.info( + `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`, + ) + } + + const selectedColumns = await p.multiselect({ + message: `Which columns in "${selectedTable}" should be in the encryption schema?`, + options: table.columns.map((col) => ({ + value: col.columnName, + label: col.columnName, + hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType, + })), + required: true, + initialValues: eqlColumns.map((c) => c.columnName), + }) + + if (p.isCancel(selectedColumns)) return undefined + + const searchable = await p.confirm({ + message: + 'Enable searchable encryption on these columns? (you can fine-tune indexes later)', + initialValue: true, + }) + + if (p.isCancel(searchable)) return undefined + + const columns: ColumnDef[] = selectedColumns.map((colName) => { + const dbCol = table.columns.find((c) => c.columnName === colName) + if (!dbCol) { + // Unreachable — multiselect only emits values from the source array. + throw new Error(`Column ${colName} not found in table ${selectedTable}`) + } + const dataType = pgTypeToDataType(dbCol.udtName) + const searchOps = searchable ? allSearchOps(dataType) : [] + return { name: colName, dataType, searchOps } + }) + + p.log.success( + `Schema defined: ${selectedTable} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, + ) + + return { tableName: selectedTable, columns } +} + +/** + * Connect, introspect, and let the user pick columns in one or more tables. + * + * Returns `undefined` for any of: + * - connection failure + * - empty database (no public tables) + * - user cancellation at any prompt + * + * Callers distinguish "user wanted no schemas" from "DB has nothing to pick" + * by also checking `introspectDatabase` separately when needed. + */ +export async function buildSchemasFromDatabase( + databaseUrl: string, +): Promise { + const s = p.spinner() + s.start('Connecting to database and reading schema...') + + let tables: DbTable[] + try { + tables = await introspectDatabase(databaseUrl) + } catch (error) { + s.stop('Failed to connect to database.') + p.log.error(error instanceof Error ? error.message : 'Unknown error') + return undefined + } + + if (tables.length === 0) { + s.stop('No tables found in the public schema.') + return undefined + } + + s.stop( + `Found ${tables.length} table${tables.length !== 1 ? 's' : ''} in the public schema.`, + ) + + const schemas: SchemaDef[] = [] + + while (true) { + const schema = await selectTableColumns(tables) + if (!schema) return undefined + + schemas.push(schema) + + const addMore = await p.confirm({ + message: 'Encrypt columns in another table?', + initialValue: false, + }) + + if (p.isCancel(addMore)) return undefined + if (!addMore) break + } + + return schemas +} diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 04353e7e..8b829723 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -1,7 +1,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { InitState, Integration, SchemaDef } from '../types.js' +import { + type SetupPromptContext, + renderSetupPrompt, +} from '../../../rulebook/index.js' +import type { + HandoffChoice, + InitState, + Integration, + SchemaDef, +} from '../types.js' import { type PackageManager, detectPackageManager, @@ -10,6 +19,7 @@ import { import { upsertManagedBlock } from './sentinel-upsert.js' export const CONTEXT_REL_PATH = '.cipherstash/context.json' +export const SETUP_PROMPT_REL_PATH = '.cipherstash/setup-prompt.md' export interface ContextFile { rulebookVersion: string @@ -39,7 +49,7 @@ export function readCliVersion(): string { name?: string version?: string } - if (pkg.name === '@cipherstash/cli' && pkg.version) return pkg.version + if (pkg.name === 'stash' && pkg.version) return pkg.version } catch { // keep walking } @@ -108,3 +118,43 @@ export function writeContextFile(absPath: string, ctx: ContextFile): void { ensureDir(absPath) writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') } + +/** + * Build a `SetupPromptContext` from the current init state for the given + * handoff choice. Returns `undefined` for the wizard handoff — the wizard + * has its own prompt logic and doesn't read this file. + */ +export function buildSetupPromptContext( + state: InitState, + handoff: HandoffChoice, +): SetupPromptContext | undefined { + if (handoff === 'wizard') return undefined + const integration = state.integration ?? 'postgresql' + const encryptionClientPath = + state.clientFilePath ?? './src/encryption/index.ts' + return { + integration, + encryptionClientPath, + packageManager: detectPackageManager(), + schema: state.schema ?? { tableName: 'users', columns: [] }, + schemaFromIntrospection: state.schemaFromIntrospection ?? false, + eqlInstalled: state.eqlInstalled ?? false, + stackInstalled: state.stackInstalled ?? false, + cliInstalled: state.cliInstalled ?? false, + handoff, + } +} + +/** + * Render and persist `.cipherstash/setup-prompt.md`. The file is plain + * markdown — no sentinel markers — because it's regenerated wholesale on + * every init run and is meant to reflect the current state, not a managed + * block alongside user content. + */ +export function writeSetupPrompt( + absPath: string, + ctx: SetupPromptContext, +): void { + ensureDir(absPath) + writeFileSync(absPath, renderSetupPrompt(ctx), 'utf-8') +} diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index 553a6fab..760d6891 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -2,19 +2,25 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' import { detectDrizzle, detectSupabase } from '../../db/detect.js' +import { buildSchemasFromDatabase } from '../lib/introspect.js' import type { - Integration, InitProvider, InitState, InitStep, + Integration, + SchemaDef, } from '../types.js' import { CancelledError } from '../types.js' -import { generatePlaceholderClient, PLACEHOLDER_SCHEMA } from '../utils.js' +import { + PLACEHOLDER_SCHEMA, + generateClientFromSchemas, + generatePlaceholderClient, +} from '../utils.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' /** - * Pick the placeholder template by reading the same signals `db install` + * Pick the integration template by reading the same signals `db install` * uses — Drizzle config / dependency for `drizzle`, Supabase host in * `DATABASE_URL` for `supabase`, otherwise raw Postgres. Silent: never * prompts the user. @@ -28,18 +34,30 @@ function detectIntegration( return 'postgresql' } +/** + * Generate the encryption client from a real DB introspection. Returns + * `undefined` when introspection fails, the DB has no tables, or the user + * cancels — callers fall back to the placeholder. + * + * Uses the URL already resolved by `resolve-database` (threaded through + * state) rather than calling the resolver again. + */ +async function buildFromIntrospection( + databaseUrl: string, +): Promise { + return buildSchemasFromDatabase(databaseUrl) +} + export const buildSchemaStep: InitStep = { id: 'build-schema', name: 'Generate encryption client', async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() - const integration = detectIntegration(cwd, process.env.DATABASE_URL) + const integration = detectIntegration(cwd, state.databaseUrl) const clientFilePath = DEFAULT_CLIENT_PATH const resolvedPath = resolve(cwd, clientFilePath) - // Existing-file branch is the only place we still prompt — silently - // overwriting someone's encryption client is bad. Everywhere else we - // pick sensible defaults and move on. + // Existing-file branch: silent overwrite is bad. Ask once. if (existsSync(resolvedPath)) { const action = await p.select({ message: `${clientFilePath} already exists. What would you like to do?`, @@ -63,11 +81,37 @@ export const buildSchemaStep: InitStep = { schemaGenerated: false, integration, schema: PLACEHOLDER_SCHEMA, + schemaFromIntrospection: false, } } } - const fileContents = generatePlaceholderClient(integration) + // Try real introspection first. Falls through to placeholder for an + // empty database, a connection error, or user cancellation at any prompt. + let schemas: SchemaDef[] | undefined + if (state.databaseUrl) { + schemas = await buildFromIntrospection(state.databaseUrl) + } + + let fileContents: string + let recordedSchema: SchemaDef + let fromIntrospection: boolean + + if (schemas && schemas.length > 0 && schemas[0]) { + fileContents = generateClientFromSchemas(integration, schemas) + // We record the first schema for context.json so handoffs have a + // canonical "what got encrypted" pointer. Multi-table users can read + // the full set from the generated client file. + recordedSchema = schemas[0] + fromIntrospection = true + } else { + p.log.info( + 'No tables found in the public schema — writing a placeholder client. The handoff prompt will note this so the agent reshapes it to your real schema.', + ) + fileContents = generatePlaceholderClient(integration) + recordedSchema = PLACEHOLDER_SCHEMA + fromIntrospection = false + } const dir = dirname(resolvedPath) if (!existsSync(dir)) { @@ -76,7 +120,9 @@ export const buildSchemaStep: InitStep = { writeFileSync(resolvedPath, fileContents, 'utf-8') p.log.success( - `Encryption client written to ${clientFilePath} (${integration} template)`, + fromIntrospection + ? `Encryption client written to ${clientFilePath} (${integration}, ${schemas?.length ?? 0} table${(schemas?.length ?? 0) !== 1 ? 's' : ''} from introspection)` + : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, ) return { @@ -84,7 +130,8 @@ export const buildSchemaStep: InitStep = { clientFilePath, schemaGenerated: true, integration, - schema: PLACEHOLDER_SCHEMA, + schema: recordedSchema, + schemaFromIntrospection: fromIntrospection, } }, } diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts index e9368fd3..78756167 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -3,10 +3,13 @@ import * as p from '@clack/prompts' import { fetchRulebook } from '../lib/fetch-rulebook.js' import { CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, buildContextFile, + buildSetupPromptContext, readCliVersion, writeArtifact, writeContextFile, + writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { readEnvKeyNames } from './gather-context.js' @@ -14,12 +17,14 @@ import { readEnvKeyNames } from './gather-context.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' /** - * Write `AGENTS.md` + `.cipherstash/context.json` and stop. + * Write `AGENTS.md`, `.cipherstash/context.json`, and + * `.cipherstash/setup-prompt.md`, then stop. * * For users running editor-based agents (Cursor, Windsurf, Cline) or any * tool that follows the AGENTS.md convention. We do not spawn anything — * the user opens their tool and the agent picks the file up from the - * project root automatically. + * project root automatically. Pointing them at setup-prompt.md gives them + * the project-specific action plan to copy into their first chat. */ export const handoffAgentsMdStep: InitStep = { id: 'handoff-agents-md', @@ -53,13 +58,20 @@ export const handoffAgentsMdStep: InitStep = { writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + const promptCtx = buildSetupPromptContext(state, 'agents-md') + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + p.note( [ `Rules at ${AGENTS_MD_REL_PATH}`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, `Context at ${CONTEXT_REL_PATH}`, '', 'Cursor / Windsurf / Cline pick up AGENTS.md automatically.', - 'For other tools, point your agent at the file and the context.', + `Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`, ].join('\n'), 'Drive your editor agent', ) diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index 97f331b9..6f3b8436 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -5,10 +5,13 @@ import { CLAUDE_SKILL_NAME } from '../../../rulebook/index.js' import { fetchRulebook } from '../lib/fetch-rulebook.js' import { CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, buildContextFile, + buildSetupPromptContext, readCliVersion, writeArtifact, writeContextFile, + writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { readEnvKeyNames } from './gather-context.js' @@ -39,9 +42,13 @@ function spawnClaude(prompt: string): Promise { } /** - * Hand off to Claude Code: install the project skill, write context.json, - * spawn `claude`. If `claude` is not on PATH we still write the artifacts - * (so the user has them ready) and print install + manual-launch instructions. + * Hand off to Claude Code: install the project skill, write context.json + * and setup-prompt.md, spawn `claude`. If `claude` is not on PATH we still + * write the artifacts and print install + manual-launch instructions. + * + * The launch prompt points the agent at `setup-prompt.md` first — that's the + * project-specific action plan. The skill body is the reusable rulebook and + * is referenced from the prompt. */ export const handoffClaudeStep: InitStep = { id: 'handoff-claude', @@ -75,7 +82,13 @@ export const handoffClaudeStep: InitStep = { writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - const launchPrompt = `Use the ${CLAUDE_SKILL_NAME} skill. Context is in ${CONTEXT_REL_PATH}.` + const promptCtx = buildSetupPromptContext(state, 'claude-code') + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The ${CLAUDE_SKILL_NAME} skill has the rules; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.claudeCode) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts index f54833e4..eb3b471f 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -4,10 +4,13 @@ import * as p from '@clack/prompts' import { fetchRulebook } from '../lib/fetch-rulebook.js' import { CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, buildContextFile, + buildSetupPromptContext, readCliVersion, writeArtifact, writeContextFile, + writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { readEnvKeyNames } from './gather-context.js' @@ -28,9 +31,9 @@ function spawnCodex(prompt: string): Promise { } /** - * Hand off to Codex CLI: write AGENTS.md (sentinel-upserted) + context.json, - * spawn `codex`. If `codex` is not on PATH we still write the artifacts and - * print install + manual-launch instructions. + * Hand off to Codex CLI: write AGENTS.md (sentinel-upserted), context.json, + * and setup-prompt.md, then spawn `codex`. If `codex` is not on PATH we + * still write the artifacts and print install + manual-launch instructions. */ export const handoffCodexStep: InitStep = { id: 'handoff-codex', @@ -64,7 +67,13 @@ export const handoffCodexStep: InitStep = { writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - const launchPrompt = `Read AGENTS.md and complete the CipherStash setup. Context is in ${CONTEXT_REL_PATH}.` + const promptCtx = buildSetupPromptContext(state, 'codex') + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the rules; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.codex) { p.note( diff --git a/packages/cli/src/commands/init/steps/install-forge.ts b/packages/cli/src/commands/init/steps/install-deps.ts similarity index 69% rename from packages/cli/src/commands/init/steps/install-forge.ts rename to packages/cli/src/commands/init/steps/install-deps.ts index 53b45188..073b95d2 100644 --- a/packages/cli/src/commands/init/steps/install-forge.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -9,26 +9,38 @@ import { } from '../utils.js' const STACK_PACKAGE = '@cipherstash/stack' -const FORGE_PACKAGE = 'stash' +const CLI_PACKAGE = 'stash' -export const installForgeStep: InitStep = { - id: 'install-forge', - name: 'Install stack dependencies', +/** + * Install the runtime + dev npm packages the user needs to run encryption: + * + * - `@cipherstash/stack` (prod) — the encryption client and per-integration + * helpers (drizzle, supabase, schema). + * - `stash` (dev) — the CLI itself, so the user can run `stash db install`, + * `stash wizard`, etc. as a project script without the global install. + * + * Skips silently when both are already present. Prompts before running the + * install commands so the user sees the package manager invocation that's + * about to execute. + */ +export const installDepsStep: InitStep = { + id: 'install-deps', + name: 'Install dependencies', async run(state: InitState, _provider: InitProvider): Promise { const stackPresent = isPackageInstalled(STACK_PACKAGE) - const forgePresent = isPackageInstalled(FORGE_PACKAGE) + const cliPresent = isPackageInstalled(CLI_PACKAGE) // Both already there — silent success, no prompts. - if (stackPresent && forgePresent) { + if (stackPresent && cliPresent) { p.log.success( - `${STACK_PACKAGE} and ${FORGE_PACKAGE} are already installed.`, + `${STACK_PACKAGE} and ${CLI_PACKAGE} are already installed.`, ) - return { ...state, stackInstalled: true, forgeInstalled: true } + return { ...state, stackInstalled: true, cliInstalled: true } } const pm = detectPackageManager() const prodPackages = stackPresent ? [] : [STACK_PACKAGE] - const devPackages = forgePresent ? [] : [FORGE_PACKAGE] + const devPackages = cliPresent ? [] : [CLI_PACKAGE] const commands = combinedInstallCommands(pm, prodPackages, devPackages) const missingList = [ @@ -51,7 +63,7 @@ export const installForgeStep: InitStep = { return { ...state, stackInstalled: stackPresent, - forgeInstalled: forgePresent, + cliInstalled: cliPresent, } } @@ -84,7 +96,7 @@ export const installForgeStep: InitStep = { return { ...state, stackInstalled: stackPresent || allSucceeded, - forgeInstalled: forgePresent || allSucceeded, + cliInstalled: cliPresent || allSucceeded, } }, } diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts new file mode 100644 index 00000000..35aa56af --- /dev/null +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -0,0 +1,64 @@ +import * as p from '@clack/prompts' +import { installCommand } from '../../db/install.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { CancelledError } from '../types.js' + +/** + * Run `stash db install` programmatically after a y/N confirm. + * + * EQL is the Postgres extension every CipherStash query relies on. Without + * it, the encryption client can't read or write to encrypted columns. + * Skipping isn't a dead end — the action prompt fed to the agent will note + * it as the first thing to run before any migration. + * + * We pass the URL we already resolved at the start of init (state.databaseUrl) + * through to `installCommand` so the user is never re-prompted. The installer + * picks the Supabase migration / direct mode itself based on `--supabase` and + * project layout — we don't pre-decide it here. + * + * `installCommand` may `process.exit(1)` on a hard failure (mutually-exclusive + * flag clash, scaffold cancellation). That's fine — by that point the user + * has already authenticated and written the encryption client, and a clean + * exit is preferable to a half-installed setup. + */ +export const installEqlStep: InitStep = { + id: 'install-eql', + name: 'Install EQL extension', + async run(state: InitState, provider: InitProvider): Promise { + const integration = state.integration ?? 'postgresql' + const supabase = integration === 'supabase' || provider.name === 'supabase' + const drizzle = integration === 'drizzle' || provider.name === 'drizzle' + + const proceed = await p.confirm({ + message: + 'Install the EQL extension into your database now? (required for encryption)', + initialValue: true, + }) + + if (p.isCancel(proceed)) throw new CancelledError() + + if (!proceed) { + p.log.info('Skipping EQL installation.') + p.note( + 'Run `stash db install` before applying any migration that references encrypted columns.', + 'EQL not installed', + ) + return { ...state, eqlInstalled: false } + } + + try { + await installCommand({ + supabase: supabase || undefined, + drizzle: drizzle || undefined, + databaseUrl: state.databaseUrl, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.error(`EQL install failed: ${message}`) + p.note('Re-run with: stash db install', 'You can retry manually') + return { ...state, eqlInstalled: false } + } + + return { ...state, eqlInstalled: true } + }, +} diff --git a/packages/cli/src/commands/init/steps/resolve-database.ts b/packages/cli/src/commands/init/steps/resolve-database.ts new file mode 100644 index 00000000..72df5a90 --- /dev/null +++ b/packages/cli/src/commands/init/steps/resolve-database.ts @@ -0,0 +1,30 @@ +import { resolveDatabaseUrl } from '../../../config/database-url.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +/** + * Resolve the project's `DATABASE_URL` and stash it on init state. + * + * Delegates to `resolveDatabaseUrl()` (the same resolver `stash.config.ts` + * uses), which walks: `--database-url` flag → `process.env.DATABASE_URL` → + * `supabase status` → interactive prompt → hard fail. We pass `supabase: true` + * when the project clearly is one so the resolver tries the Supabase CLI + * even if the user hasn't passed `--supabase`. + * + * The resolver `process.exit(1)`s on failure with an actionable message, so + * this step either produces a valid URL or stops the program cleanly. Every + * downstream init step that needs DB access (build-schema introspection, + * install-eql) reads `state.databaseUrl` rather than calling the resolver + * again — one prompt, one failure mode. + */ +export const resolveDatabaseStep: InitStep = { + id: 'resolve-database', + name: 'Resolve database URL', + async run(state: InitState, _provider: InitProvider): Promise { + // The provider name carries the integration flag the user passed at the + // CLI (`--supabase` → 'supabase'), which lets the resolver try + // `supabase status` even before we've inspected the project layout. + const supabaseHint = _provider.name === 'supabase' + const databaseUrl = await resolveDatabaseUrl({ supabase: supabaseHint }) + return { ...state, databaseUrl } + }, +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index cb919af0..6e1949ad 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -22,13 +22,23 @@ export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' export interface InitState { authenticated?: boolean + /** Resolved DATABASE_URL. Set by resolve-database; threaded into every + * downstream step that needs DB access. Never logged or echoed. */ + databaseUrl?: string clientFilePath?: string schemaGenerated?: boolean + /** True when the encryption schema was sourced from live DB introspection + * rather than the placeholder. Drives messaging in the action prompt. */ + schemaFromIntrospection?: boolean stackInstalled?: boolean - forgeInstalled?: boolean + /** Renamed from `forgeInstalled` — "Forge" was the legacy name for the + * `stash` CLI. Kept on InitState as `cliInstalled` for clarity. */ + cliInstalled?: boolean + /** True when EQL was installed (or already-installed) by install-eql. */ + eqlInstalled?: boolean /** Detected ORM / framework integration. Set by build-schema. */ integration?: Integration - /** Schema definition that was written to the client file (placeholder for now). */ + /** Schema definition that was written to the client file. */ schema?: SchemaDef /** Available coding agents in the user's environment. Set by detect-agents. */ agents?: AgentEnvironment diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index bccefe1a..6b32b40e 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -244,6 +244,111 @@ export function generateClientFromSchema( } } +function generateDrizzleFromSchemas(schemas: SchemaDef[]): string { + const tableDefs = schemas.map((schema) => { + const varName = `${toCamelCase(schema.tableName)}Table` + const schemaVarName = `${toCamelCase(schema.tableName)}Schema` + + const columnDefs = schema.columns.map((col) => { + const opts: string[] = [] + if (col.dataType !== 'string') { + opts.push(`dataType: '${col.dataType}'`) + } + if (col.searchOps.includes('equality')) { + opts.push('equality: true') + } + if (col.searchOps.includes('orderAndRange')) { + opts.push('orderAndRange: true') + } + if (col.searchOps.includes('freeTextSearch')) { + opts.push('freeTextSearch: true') + } + + const tsType = drizzleTsType(col.dataType) + const optsStr = + opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' + return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` + }) + + return `export const ${varName} = pgTable('${schema.tableName}', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), +${columnDefs.join('\n')} + createdAt: timestamp('created_at').defaultNow(), +}) + +const ${schemaVarName} = extractEncryptionSchema(${varName})` + }) + + const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) + + return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' +import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' +import { Encryption } from '@cipherstash/stack' + +${tableDefs.join('\n\n')} + +export const encryptionClient = await Encryption({ + schemas: [${schemaVarNames.join(', ')}], +}) +` +} + +function generateGenericFromSchemas(schemas: SchemaDef[]): string { + const tableDefs = schemas.map((schema) => { + const varName = `${toCamelCase(schema.tableName)}Table` + + const columnDefs = schema.columns.map((col) => { + const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] + + if (col.dataType !== 'string') { + parts.push(`.dataType('${col.dataType}')`) + } + + for (const op of col.searchOps) { + parts.push(`.${op}()`) + } + + return `${parts.join('\n ')},` + }) + + return `export const ${varName} = encryptedTable('${schema.tableName}', { +${columnDefs.join('\n')} +})` + }) + + const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) + + return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' +import { Encryption } from '@cipherstash/stack' + +${tableDefs.join('\n\n')} + +export const encryptionClient = await Encryption({ + schemas: [${tableVarNames.join(', ')}], +}) +` +} + +/** + * Generate the encryption client file contents for one or more schemas. + * + * The single-schema variants above are kept for the placeholder path (which + * is always exactly one table); this is the variant that renders a real + * multi-table client from DB introspection. + */ +export function generateClientFromSchemas( + integration: Integration, + schemas: SchemaDef[], +): string { + switch (integration) { + case 'drizzle': + return generateDrizzleFromSchemas(schemas) + case 'supabase': + case 'postgresql': + return generateGenericFromSchemas(schemas) + } +} + /** * Schema definition we ship as the "fresh project" placeholder. Exported * separately so steps that follow `build-schema` (gather-context, handoff) diff --git a/packages/cli/src/commands/schema/build.ts b/packages/cli/src/commands/schema/build.ts index f2435e07..3e5ae79c 100644 --- a/packages/cli/src/commands/schema/build.ts +++ b/packages/cli/src/commands/schema/build.ts @@ -1,337 +1,10 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' -import pg from 'pg' import { loadStashConfig } from '../../config/index.js' - -type Integration = 'drizzle' | 'supabase' | 'postgresql' -type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' -type SearchOp = 'equality' | 'orderAndRange' | 'freeTextSearch' - -interface ColumnDef { - name: string - dataType: DataType - searchOps: SearchOp[] -} - -interface SchemaDef { - tableName: string - columns: ColumnDef[] -} - -interface DbColumn { - columnName: string - dataType: string - udtName: string - isEqlEncrypted: boolean -} - -interface DbTable { - tableName: string - columns: DbColumn[] -} - -// --- Database introspection --- - -function pgTypeToDataType(udtName: string): DataType { - switch (udtName) { - case 'int2': - case 'int4': - case 'int8': - case 'float4': - case 'float8': - case 'numeric': - return 'number' - case 'bool': - return 'boolean' - case 'date': - case 'timestamp': - case 'timestamptz': - return 'date' - case 'json': - case 'jsonb': - return 'json' - default: - return 'string' - } -} - -async function introspectDatabase(databaseUrl: string): Promise { - const client = new pg.Client({ connectionString: databaseUrl }) - try { - await client.connect() - - const { rows } = await client.query<{ - table_name: string - column_name: string - data_type: string - udt_name: string - }>(` - SELECT c.table_name, c.column_name, c.data_type, c.udt_name - FROM information_schema.columns c - JOIN information_schema.tables t - ON t.table_name = c.table_name AND t.table_schema = c.table_schema - WHERE c.table_schema = 'public' - AND t.table_type = 'BASE TABLE' - ORDER BY c.table_name, c.ordinal_position - `) - - const tableMap = new Map() - for (const row of rows) { - const cols = tableMap.get(row.table_name) ?? [] - cols.push({ - columnName: row.column_name, - dataType: row.data_type, - udtName: row.udt_name, - isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', - }) - tableMap.set(row.table_name, cols) - } - - return Array.from(tableMap.entries()).map(([tableName, columns]) => ({ - tableName, - columns, - })) - } finally { - await client.end() - } -} - -// --- Code generation --- - -function toCamelCase(str: string): string { - return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) -} - -function drizzleTsType(dataType: string): string { - switch (dataType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'date': - return 'Date' - case 'json': - return 'Record' - default: - return 'string' - } -} - -function generateClientFromSchemas( - integration: Integration, - schemas: SchemaDef[], -): string { - switch (integration) { - case 'drizzle': - return generateDrizzleClient(schemas) - case 'supabase': - case 'postgresql': - return generateGenericClient(schemas) - } -} - -function generateDrizzleClient(schemas: SchemaDef[]): string { - const tableDefs = schemas.map((schema) => { - const varName = `${toCamelCase(schema.tableName)}Table` - const schemaVarName = `${toCamelCase(schema.tableName)}Schema` - - const columnDefs = schema.columns.map((col) => { - const opts: string[] = [] - if (col.dataType !== 'string') { - opts.push(`dataType: '${col.dataType}'`) - } - if (col.searchOps.includes('equality')) { - opts.push('equality: true') - } - if (col.searchOps.includes('orderAndRange')) { - opts.push('orderAndRange: true') - } - if (col.searchOps.includes('freeTextSearch')) { - opts.push('freeTextSearch: true') - } - - const tsType = drizzleTsType(col.dataType) - const optsStr = - opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' - return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` - }) - - return `export const ${varName} = pgTable('${schema.tableName}', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), -${columnDefs.join('\n')} - createdAt: timestamp('created_at').defaultNow(), -}) - -const ${schemaVarName} = extractEncryptionSchema(${varName})` - }) - - const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) - - return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' -import { Encryption } from '@cipherstash/stack' - -${tableDefs.join('\n\n')} - -export const encryptionClient = await Encryption({ - schemas: [${schemaVarNames.join(', ')}], -}) -` -} - -function generateGenericClient(schemas: SchemaDef[]): string { - const tableDefs = schemas.map((schema) => { - const varName = `${toCamelCase(schema.tableName)}Table` - - const columnDefs = schema.columns.map((col) => { - const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] - - if (col.dataType !== 'string') { - parts.push(`.dataType('${col.dataType}')`) - } - - for (const op of col.searchOps) { - parts.push(`.${op}()`) - } - - return `${parts.join('\n ')},` - }) - - return `export const ${varName} = encryptedTable('${schema.tableName}', { -${columnDefs.join('\n')} -})` - }) - - const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) - - return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' - -${tableDefs.join('\n\n')} - -export const encryptionClient = await Encryption({ - schemas: [${tableVarNames.join(', ')}], -}) -` -} - -// --- Shared helpers --- - -function allSearchOps(dataType: DataType): SearchOp[] { - const ops: SearchOp[] = ['equality', 'orderAndRange'] - if (dataType === 'string') { - ops.push('freeTextSearch') - } - return ops -} - -// --- Database-driven schema builder --- - -async function selectTableColumns( - tables: DbTable[], -): Promise { - const selectedTable = await p.select({ - message: 'Which table do you want to encrypt columns in?', - options: tables.map((t) => { - const eqlCount = t.columns.filter((c) => c.isEqlEncrypted).length - const hint = - eqlCount > 0 - ? `${t.columns.length} columns, ${eqlCount} already encrypted` - : `${t.columns.length} column${t.columns.length !== 1 ? 's' : ''}` - return { value: t.tableName, label: t.tableName, hint } - }), - }) - - if (p.isCancel(selectedTable)) return undefined - - const table = tables.find((t) => t.tableName === selectedTable)! - const eqlColumns = table.columns.filter((c) => c.isEqlEncrypted) - - if (eqlColumns.length > 0) { - p.log.info( - `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`, - ) - } - - const selectedColumns = await p.multiselect({ - message: `Which columns in "${selectedTable}" should be in the encryption schema?`, - options: table.columns.map((col) => ({ - value: col.columnName, - label: col.columnName, - hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType, - })), - required: true, - initialValues: eqlColumns.map((c) => c.columnName), - }) - - if (p.isCancel(selectedColumns)) return undefined - - const searchable = await p.confirm({ - message: - 'Enable searchable encryption on these columns? (you can fine-tune indexes later)', - initialValue: true, - }) - - if (p.isCancel(searchable)) return undefined - - const columns: ColumnDef[] = selectedColumns.map((colName) => { - const dbCol = table.columns.find((c) => c.columnName === colName)! - const dataType = pgTypeToDataType(dbCol.udtName) - const searchOps = searchable ? allSearchOps(dataType) : [] - return { name: colName, dataType, searchOps } - }) - - p.log.success( - `Schema defined: ${selectedTable} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, - ) - - return { tableName: selectedTable, columns } -} - -async function buildSchemasFromDatabase( - databaseUrl: string, -): Promise { - const s = p.spinner() - s.start('Connecting to database and reading schema...') - - let tables: DbTable[] - try { - tables = await introspectDatabase(databaseUrl) - } catch (error) { - s.stop('Failed to connect to database.') - p.log.error(error instanceof Error ? error.message : 'Unknown error') - return undefined - } - - if (tables.length === 0) { - s.stop('No tables found in the public schema.') - return undefined - } - - s.stop( - `Found ${tables.length} table${tables.length !== 1 ? 's' : ''} in the public schema.`, - ) - - const schemas: SchemaDef[] = [] - - while (true) { - const schema = await selectTableColumns(tables) - if (!schema) return undefined - - schemas.push(schema) - - const addMore = await p.confirm({ - message: 'Encrypt columns in another table?', - initialValue: false, - }) - - if (p.isCancel(addMore)) return undefined - if (!addMore) break - } - - return schemas -} +import { buildSchemasFromDatabase } from '../init/lib/introspect.js' +import type { Integration } from '../init/types.js' +import { generateClientFromSchemas } from '../init/utils.js' // --- Command --- diff --git a/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts b/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts new file mode 100644 index 00000000..aee5df5a --- /dev/null +++ b/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + RULEBOOK_VERSION, + type SetupPromptContext, + renderSetupPrompt, +} from '../index.js' + +const baseCtx: SetupPromptContext = { + integration: 'drizzle', + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm', + schema: { + tableName: 'users', + columns: [{ name: 'email', dataType: 'string', searchOps: ['equality'] }], + }, + schemaFromIntrospection: false, + eqlInstalled: false, + stackInstalled: false, + cliInstalled: false, + handoff: 'claude-code', +} + +describe('renderSetupPrompt', () => { + it('emits the rulebook version + integration in the header', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toContain(`Rulebook version: ${RULEBOOK_VERSION}`) + expect(out).toContain('Integration: drizzle') + expect(out).toContain('Package manager: pnpm') + }) + + it('marks placeholder schema as a TODO when not from introspection', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toMatch(/PLACEHOLDER schema/) + expect(out).toMatch(/Reshape the encryption client/) + }) + + it('drops the reshape TODO when schema came from introspection', () => { + const out = renderSetupPrompt({ + ...baseCtx, + schemaFromIntrospection: true, + }) + expect(out).toMatch(/sourced from live database introspection/) + expect(out).not.toMatch(/Reshape the encryption client/) + }) + + it('lists EQL install as a TODO when not installed', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toMatch(/Install EQL into the database/) + }) + + it('drops the EQL install TODO when already installed', () => { + const out = renderSetupPrompt({ ...baseCtx, eqlInstalled: true }) + expect(out).toMatch(/Installed the EQL extension/) + expect(out).not.toMatch(/Install EQL into the database/) + }) + + it('emits drizzle-kit commands for drizzle integration', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toContain('pnpm exec drizzle-kit generate') + expect(out).toContain('pnpm exec drizzle-kit migrate') + }) + + it('emits supabase migration commands for supabase integration', () => { + const out = renderSetupPrompt({ + ...baseCtx, + integration: 'supabase', + }) + expect(out).toContain('supabase migration new') + expect(out).toContain('encryptedSupabase') + }) + + it('uses the right runner per package manager', () => { + const npm = renderSetupPrompt({ ...baseCtx, packageManager: 'npm' }) + const bun = renderSetupPrompt({ ...baseCtx, packageManager: 'bun' }) + const yarn = renderSetupPrompt({ ...baseCtx, packageManager: 'yarn' }) + + expect(npm).toContain('npx --no-install drizzle-kit generate') + expect(bun).toContain('bun x drizzle-kit generate') + expect(yarn).toContain('yarn drizzle-kit generate') + }) + + it('points claude-code handoffs at the skill, others at AGENTS.md', () => { + const claude = renderSetupPrompt({ ...baseCtx, handoff: 'claude-code' }) + const codex = renderSetupPrompt({ ...baseCtx, handoff: 'codex' }) + const agents = renderSetupPrompt({ ...baseCtx, handoff: 'agents-md' }) + + expect(claude).toContain('cipherstash-setup` skill') + expect(codex).toContain('AGENTS.md') + expect(agents).toContain('AGENTS.md') + }) +}) diff --git a/packages/cli/src/rulebook/index.ts b/packages/cli/src/rulebook/index.ts index 7825f868..81365df5 100644 --- a/packages/cli/src/rulebook/index.ts +++ b/packages/cli/src/rulebook/index.ts @@ -8,3 +8,5 @@ export { export type { ClaudeSkillContext } from './renderers/claude-skill.js' export { renderAgentsMd } from './renderers/agents-md.js' export type { AgentsMdContext } from './renderers/agents-md.js' +export { renderSetupPrompt } from './renderers/setup-prompt.js' +export type { SetupPromptContext } from './renderers/setup-prompt.js' diff --git a/packages/cli/src/rulebook/renderers/setup-prompt.ts b/packages/cli/src/rulebook/renderers/setup-prompt.ts new file mode 100644 index 00000000..335db375 --- /dev/null +++ b/packages/cli/src/rulebook/renderers/setup-prompt.ts @@ -0,0 +1,226 @@ +import type { + HandoffChoice, + Integration, + SchemaDef, +} from '../../commands/init/types.js' +import { + type PackageManager, + runnerCommand, +} from '../../commands/init/utils.js' +import { RULEBOOK_VERSION } from '../version.js' + +export interface SetupPromptContext { + integration: Integration + encryptionClientPath: string + packageManager: PackageManager + schema: SchemaDef + schemaFromIntrospection: boolean + eqlInstalled: boolean + stackInstalled: boolean + cliInstalled: boolean + /** Which handoff option the user picked. Lets us tailor wording (e.g. the + * Codex prompt names AGENTS.md, Claude names the skill). */ + handoff: HandoffChoice +} + +interface MigrationCommands { + generate: string + apply: string + /** Human-readable label for the migration tool ("Drizzle Kit", "Prisma"). */ + tool: string +} + +/** + * Per-integration migration commands. We compute these from the detected + * package manager + integration so the agent gets the exact string it should + * run, not a generic "run your migrations" hand-wave. + */ +function migrationCommands( + integration: Integration, + pm: PackageManager, +): MigrationCommands | undefined { + if (integration === 'drizzle') { + return { + tool: 'Drizzle Kit', + generate: `${execCommand(pm)} drizzle-kit generate`, + apply: `${execCommand(pm)} drizzle-kit migrate`, + } + } + if (integration === 'supabase') { + return { + tool: 'Supabase CLI', + generate: 'supabase migration new ', + apply: 'supabase migration up (remote) or supabase db reset (local)', + } + } + return undefined +} + +/** + * Map the package manager to the right "run a binary from node_modules" form. + * npm → `npx --no-install` (avoid surprise downloads when the dep should + * already be installed) + * pnpm → `pnpm exec` + * yarn → `yarn` (yarn 1) or `yarn run` — `yarn ` works for both + * bun → `bun x` (binary-runner mode, not the dlx alias) + */ +function execCommand(pm: PackageManager): string { + switch (pm) { + case 'npm': + return 'npx --no-install' + case 'pnpm': + return 'pnpm exec' + case 'yarn': + return 'yarn' + case 'bun': + return 'bun x' + } +} + +function bullet(line: string): string { + return `- ${line}` +} + +function checked(line: string): string { + return `- [x] ${line}` +} + +function todo(line: string): string { + return `- [ ] ${line}` +} + +/** + * Render the project-specific action prompt. + * + * This is the file the agent reads first — it tells them exactly what state + * the project is in, what's already done, and what to do next, with concrete + * paths and commands. The skill / AGENTS.md provides reusable rules; this + * file is the imperative for *this run*. + * + * Structure: header → "what's done" checklist → "what's next" actionable list + * → reference to the skill/AGENTS.md for the rules. + */ +export function renderSetupPrompt(ctx: SetupPromptContext): string { + const cli = runnerCommand(ctx.packageManager, 'stash') + const migration = migrationCommands(ctx.integration, ctx.packageManager) + + const done: string[] = [ + checked('Authenticated to CipherStash and selected a workspace'), + checked(`Detected integration: \`${ctx.integration}\``), + checked( + `Wrote the encryption client to \`${ctx.encryptionClientPath}\` (${ + ctx.schemaFromIntrospection + ? 'sourced from live database introspection' + : "PLACEHOLDER schema — not yet aligned to the user's real data model" + })`, + ), + ] + if (ctx.stackInstalled) { + done.push(checked('Installed `@cipherstash/stack` (runtime)')) + } + if (ctx.cliInstalled) { + done.push(checked('Installed `stash` (CLI, dev dep)')) + } + if (ctx.eqlInstalled) { + done.push( + checked( + 'Installed the EQL extension into the database (`stash db install`)', + ), + ) + } + + const next: string[] = [] + + if (!ctx.eqlInstalled) { + next.push( + todo( + `**Install EQL into the database** — run \`${cli} db install\`. This is required before any migration that creates encrypted columns.`, + ), + ) + } + + if (!ctx.schemaFromIntrospection) { + next.push( + todo( + `**Reshape the encryption client.** \`${ctx.encryptionClientPath}\` currently uses a placeholder \`users\` table with \`email\` and \`name\` columns. Read the user's existing schema (probably under \`src/db/\` or similar for ${ctx.integration}), decide which real tables and columns should be encrypted, and update the encryption client to match. Refer to the integration rules for the column types and constraints to use.`, + ), + ) + } + + if (ctx.integration === 'drizzle') { + next.push( + todo( + `**Wire the encryption client into Drizzle config.** Make sure \`drizzle.config.ts\`'s \`schema\` field includes the encryption client file so \`drizzle-kit generate\` picks up the encrypted columns. If the user keeps a single \`schema.ts\`, re-export the table definitions from there instead.`, + ), + ) + } + + if (ctx.integration === 'supabase') { + next.push( + todo( + '**Wrap the Supabase client.** Find every call to `createClient` / `createServerClient` / `createBrowserClient` from `@supabase/supabase-js` or `@supabase/ssr`. Wrap each with `encryptedSupabase({ encryptionClient, supabaseClient })` from `@cipherstash/stack/supabase` (see the rulebook for the exact API).', + ), + ) + } + + if (migration) { + next.push( + todo( + `**Generate the migration** — \`${migration.generate}\` (${migration.tool}). Verify the generated SQL declares encrypted columns as nullable \`jsonb\`. Never \`NOT NULL\` on creation.`, + ), + ) + next.push( + todo( + `**Apply the migration** — \`${migration.apply}\`. Show the user the generated SQL before running.`, + ), + ) + } else { + next.push( + todo( + '**Generate and apply a migration** that adds the encrypted columns as nullable `jsonb`. The exact tooling depends on the project — pick the one already in use.', + ), + ) + } + + next.push( + todo( + '**Verify with a round-trip.** Insert a record through the encryption client, select it back, confirm the value decrypts and the search ops work as expected.', + ), + ) + + const ruleSource = + ctx.handoff === 'claude-code' + ? 'the `cipherstash-setup` skill (already loaded — `.claude/skills/cipherstash-setup/SKILL.md`)' + : 'the `AGENTS.md` at the project root' + + return [ + '# CipherStash setup — action plan', + '', + `Rulebook version: ${RULEBOOK_VERSION}`, + `Integration: ${ctx.integration}`, + `Package manager: ${ctx.packageManager}`, + '', + `You are picking up a CipherStash setup that \`stash init\` has started. Read this file in full before touching anything. Project-specific facts live in \`.cipherstash/context.json\`. Reusable rules (column types, things never to touch, never-\`.notNull()\`-on-encrypted etc.) live in ${ruleSource}.`, + '', + '## What `stash init` already did', + '', + ...done, + '', + '## What you need to do', + '', + ...next, + '', + '## Stop and ask the user when', + '', + bullet( + 'Schema reshaping involves dropping or renaming a column with existing data — this needs a backfill plan, not a rename.', + ), + bullet( + 'You discover existing encrypted columns that disagree with the encryption client — someone else may have run `stash init` earlier with different choices.', + ), + bullet( + 'A migration would change the data type of a column the user has already filled.', + ), + '', + ].join('\n') +} From d70dcc02d6ff28a6505df87636654de882a0223f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 19:28:08 +1000 Subject: [PATCH 04/10] fix(cli): require package.json for isPackageInstalled, guard install-eql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leftover `node_modules//` directory with no `package.json` (from a prior aborted install, a stale workspace symlink, or npm pruning a package mid-install) was previously treated as installed. install-deps would skip the install — and then install-eql's call to installCommand would scaffold `stash.config.ts`, try to load it via jiti, and crash with `Cannot find module 'stash'` deep inside jiti's resolver. Two changes: 1. `isPackageInstalled` now requires both the directory AND a `package.json` inside it. Matches what Node's resolver actually needs to load the module. 2. install-eql double-checks that `stash` is loadable before calling `installCommand`. If install-deps was skipped or rolled back, we exit the step with a clear error pointing the user at the right manual command, instead of letting the failure surface as an opaque jiti trace mid-flow. Adds tests covering the directory-without-manifest case so this can't regress silently. --- .../src/commands/init/__tests__/utils.test.ts | 44 ++++++++++++++++++- .../src/commands/init/steps/install-eql.ts | 18 ++++++++ packages/cli/src/commands/init/utils.ts | 14 ++++-- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/init/__tests__/utils.test.ts b/packages/cli/src/commands/init/__tests__/utils.test.ts index 1e331deb..83c5afcd 100644 --- a/packages/cli/src/commands/init/__tests__/utils.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils.test.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { detectPackageManager, devInstallCommand, + isPackageInstalled, prodInstallCommand, runnerCommand, } from '../utils.js' @@ -166,3 +167,44 @@ describe('runnerCommand', () => { ) }) }) + +describe('isPackageInstalled', () => { + let tmp: string + let cwdSpy: ReturnType + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'isinstalled-test-')) + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp) + }) + + afterEach(() => { + cwdSpy.mockRestore() + rmSync(tmp, { recursive: true, force: true }) + }) + + it('returns false when node_modules/ does not exist', () => { + expect(isPackageInstalled('stash')).toBe(false) + }) + + it('returns true when node_modules//package.json exists', () => { + const pkgDir = join(tmp, 'node_modules', 'stash') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), '{"name":"stash"}') + expect(isPackageInstalled('stash')).toBe(true) + }) + + it('returns false when the directory exists but no package.json', () => { + // The bug we fixed: a leftover dir from an aborted install or stale + // workspace symlink would previously be treated as a real install. + const pkgDir = join(tmp, 'node_modules', 'stash') + mkdirSync(pkgDir, { recursive: true }) + expect(isPackageInstalled('stash')).toBe(false) + }) + + it('handles scoped package names', () => { + const pkgDir = join(tmp, 'node_modules', '@cipherstash', 'stack') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), '{"name":"@cipherstash/stack"}') + expect(isPackageInstalled('@cipherstash/stack')).toBe(true) + }) +}) diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 35aa56af..9ae2de13 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -2,6 +2,7 @@ import * as p from '@clack/prompts' import { installCommand } from '../../db/install.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' +import { isPackageInstalled } from '../utils.js' /** * Run `stash db install` programmatically after a y/N confirm. @@ -46,6 +47,23 @@ export const installEqlStep: InitStep = { return { ...state, eqlInstalled: false } } + // installCommand scaffolds stash.config.ts (which `import`s from `stash`) + // and immediately loads it via jiti. If `stash` isn't actually loadable + // from the project, that load throws `Cannot find module 'stash'` from + // deep inside jiti — confusing and fatal mid-flow. Detect the precondition + // and bail with a clear message instead. install-deps is what installs + // the package, so a "no" there leaves us here. + if (!isPackageInstalled('stash')) { + p.log.error( + '`stash` is not installed in this project. The previous step (install-deps) was skipped or failed. Re-run `stash init` and accept the dependency install when prompted, or install it manually:', + ) + p.note( + ' npm install --save-dev stash\n pnpm add -D stash\n yarn add -D stash\n bun add -D stash', + 'Then re-run init', + ) + return { ...state, eqlInstalled: false } + } + try { await installCommand({ supabase: supabase || undefined, diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 6b32b40e..403c53f2 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -3,12 +3,20 @@ import { resolve } from 'node:path' import type { Integration, SchemaDef } from './types.js' /** - * Checks if a package is installed in the current project by looking - * for its directory in node_modules. + * Checks if a package is installed and loadable from the current project. + * + * We require both the package directory AND a `package.json` inside it. A + * leftover directory without a manifest (from an aborted install, a previous + * tool that wrote the path before failing, or a workspace symlink whose + * target was removed) was previously treated as installed — that caused + * `installCommand` later in init to load `stash.config.ts` and fail with + * `Cannot find module 'stash'` at the jiti import. Requiring the manifest + * matches what Node's resolver actually needs to load the module. */ export function isPackageInstalled(packageName: string): boolean { const modulePath = resolve(process.cwd(), 'node_modules', packageName) - return existsSync(modulePath) + const manifestPath = resolve(modulePath, 'package.json') + return existsSync(modulePath) && existsSync(manifestPath) } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' From 0878da1fc9acc882a6dc0754a26a8f0f37f482e7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 19:39:31 +1000 Subject: [PATCH 05/10] fix(cli): write baseline context.json after build-schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this, `.cipherstash/context.json` was only written inside the handoff step. If init aborted between build-schema and the handoff — install-eql crashing, Ctrl+C, anything — the previous run's context.json would still be on disk, and a user manually launching `claude` against the stale file would see a placeholder schema instead of what was just introspected. The fix writes a baseline context.json at the end of build-schema using the bundled rulebook version. Handoff steps still refresh it with the gateway-served rulebook version (when reachable), but the file is no longer dependent on the handoff completing — it tracks the encryption client at the moment that client is generated. This was the root cause of a tester seeing claude reading `{"tableName":"users","columns":[...]}` after they had selected `transactions` during the (working) introspection prompt. --- .../src/commands/init/lib/write-context.ts | 24 +++++++++++++++++++ .../src/commands/init/steps/build-schema.ts | 14 ++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 8b829723..0e12bf1d 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { + RULEBOOK_VERSION, type SetupPromptContext, renderSetupPrompt, } from '../../../rulebook/index.js' @@ -119,6 +120,29 @@ export function writeContextFile(absPath: string, ctx: ContextFile): void { writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') } +/** + * Write `.cipherstash/context.json` immediately after the encryption client + * is generated, using the bundled rulebook version. Handoff steps refresh + * it later with the gateway-served rulebook version (when reachable), but + * having a baseline here means the file is always in sync with the + * encryption client even if init aborts mid-flow. + * + * Without this baseline, a failed install-eql or a Ctrl+C between + * build-schema and the handoff would leave context.json from a previous + * run on disk — which an agent reading it would happily believe. + */ +export function writeBaselineContextFile( + state: InitState, + cwd: string, + envKeys: string[], +): void { + if (!state.schema) return + const absPath = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state, RULEBOOK_VERSION) + ctx.envKeys = envKeys + writeContextFile(absPath, ctx) +} + /** * Build a `SetupPromptContext` from the current init state for the given * handoff choice. Returns `undefined` for the wizard handoff — the wizard diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index 760d6891..b92915af 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' import { detectDrizzle, detectSupabase } from '../../db/detect.js' import { buildSchemasFromDatabase } from '../lib/introspect.js' +import { writeBaselineContextFile } from '../lib/write-context.js' import type { InitProvider, InitState, @@ -16,6 +17,7 @@ import { generateClientFromSchemas, generatePlaceholderClient, } from '../utils.js' +import { readEnvKeyNames } from './gather-context.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' @@ -125,7 +127,7 @@ export const buildSchemaStep: InitStep = { : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, ) - return { + const nextState: InitState = { ...state, clientFilePath, schemaGenerated: true, @@ -133,5 +135,15 @@ export const buildSchemaStep: InitStep = { schema: recordedSchema, schemaFromIntrospection: fromIntrospection, } + + // Write a baseline `.cipherstash/context.json` immediately so it tracks + // the encryption client we just generated. Handoff steps refresh it later + // with the gateway-served rulebook version, but this guarantees the file + // is consistent with the client even if init aborts before the handoff + // (e.g. install-eql failure, Ctrl+C). Without this, an agent reading a + // stale context.json from a previous run would happily believe it. + writeBaselineContextFile(nextState, cwd, readEnvKeyNames(cwd)) + + return nextState }, } From 60fe1fa1a6a5da7fb0d9201b3e7b1fe4730fe789 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 21:52:20 +1000 Subject: [PATCH 06/10] refactor(cli): self-review fixes (Windows paths, wizard exit code, multi-table schemas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-review pass over the init agent handoff: - Use `fileURLToPath` instead of `new URL(...).pathname` in `rulebook/partials.ts` — the latter breaks on Windows (`/C:/...`) and on paths with spaces. - Replace the POSIX-only `/bin/sh -c command -v` with a pure-Node PATH walk in `detect-agents.ts`. Honours `PATHEXT`-style suffixes (`.cmd`, `.exe`, `.bat`) on Windows so `claude.cmd` is detected. - Split `wizardCommand` into `runWizardSpawn` (returns exit code) and `wizardCommand` (calls `process.exit`). The init handoff path uses `runWizardSpawn` so init can finish its outro and run `next-steps` instead of aborting on a non-zero wizard exit. - Single-quote the launch-prompt examples printed to the user when `claude`/`codex` aren't on PATH — robust against any future shell-special characters in the prompt. - Store env-key names on `InitState` once (in `build-schema`) rather than scanning `.env*` files three times across `gather-context`, the baseline write, and the chosen handoff. Drops the awkward "side channel" comment in gather-context. - Change `state.schema: SchemaDef` to `state.schemas: SchemaDef[]` (and same in `ContextFile`) so multi-table introspection runs are represented faithfully. Drop the unused `schema` field from `SetupPromptContext` (the renderer never read it). - Drop the stale "Renamed from `forgeInstalled`" comment from `InitState`. The rename is in git history. - Inline the `buildFromIntrospection` one-line forwarder. README updated with the new init flow, the four-option handoff menu, and a `stash wizard` reference section. Outdated "edit your encryption client by hand" guidance removed in favour of the action-prompt-driven workflow. 154 stack CLI tests pass (no behaviour change in the test suite). 17 suite rulebook tests pass. --- packages/cli/README.md | 53 +++++++++++++------ .../cli/src/commands/init/detect-agents.ts | 44 ++++++++------- .../src/commands/init/lib/write-context.ts | 16 +++--- .../src/commands/init/steps/build-schema.ts | 45 ++++++---------- .../src/commands/init/steps/gather-context.ts | 36 ++++++------- .../commands/init/steps/handoff-agents-md.ts | 3 +- .../src/commands/init/steps/handoff-claude.ts | 10 ++-- .../src/commands/init/steps/handoff-codex.ts | 7 ++- .../src/commands/init/steps/handoff-wizard.ts | 18 ++++--- packages/cli/src/commands/init/types.ts | 14 +++-- packages/cli/src/commands/wizard/index.ts | 32 ++++++----- .../rulebook/__tests__/setup-prompt.test.ts | 4 -- packages/cli/src/rulebook/partials.ts | 9 ++-- .../src/rulebook/renderers/setup-prompt.ts | 2 - 14 files changed, 162 insertions(+), 131 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 913172b7..7cdbac9e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,17 +12,19 @@ The single CLI for CipherStash. It handles authentication, project initializatio ```bash npm install -D stash npx stash auth login # authenticate with CipherStash -npx stash init # scaffold encryption schema and install dependencies -npx stash db install # scaffold stash.config.ts (if missing) and install EQL +npx stash init # scaffold, introspect, install EQL, hand off to your agent ``` -What each step does: +`stash init` runs the whole setup as one flow: authenticate, resolve `DATABASE_URL`, introspect your database and let you pick which columns to encrypt, install dependencies, install the EQL extension, and finish by handing off to your local coding agent. At the end it presents a four-option menu: -- `auth login` — opens a browser-based device code flow and saves a token to `~/.cipherstash/auth.json`. -- `init` — generates your encryption client file and installs `stash` as a dev dependency. Pass `--supabase` or `--drizzle` for provider-specific setup. -- `db install` — detects your encryption client, writes `stash.config.ts` if it's missing, and installs EQL extensions in a single step. +- **Hand off to Claude Code** — installs a project-local skill at `.claude/skills/cipherstash-setup/SKILL.md`, then launches `claude` interactively. +- **Hand off to Codex** — writes `AGENTS.md` at the project root, then launches `codex`. +- **Use the CipherStash Agent** — runs the in-house wizard (`@cipherstash/wizard`). +- **Write AGENTS.md** — writes the rules file and stops, for Cursor / Windsurf / Cline / any AGENTS.md-aware tool. -After `db install`, declare which columns to encrypt — either run [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard) to do it automatically, or edit your encryption client file (default `./src/encryption/index.ts`) by hand. +A project-specific action plan is written to `.cipherstash/setup-prompt.md` regardless of which option you pick — it tells the agent exactly what's already done and what's left, with the right commands for your package manager and ORM. The matching context (selected columns, env keys, paths, versions) is at `.cipherstash/context.json`. + +If neither `claude` nor `codex` is on PATH, init still writes the rules files and prints install instructions — your progress is never wasted. --- @@ -30,14 +32,12 @@ After `db install`, declare which columns to encrypt — either run [`@ciphersta ``` npx stash auth login - └── npx stash init - └── npx stash db install - └── npx @cipherstash/wizard ← fast path: AI edits your files - OR - Edit schema files by hand ← escape hatch + └── npx stash init ← introspects DB, installs EQL, hands off to your agent + └── Agent edits schema files / generates migrations + └── npx stash db push ← when ready to roll out further changes ``` -`stash` covers authentication, initialization, EQL install/upgrade/validate/push/migrate, and schema introspection. The wizard ([`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard)) is a separate package that calls back into these cli commands after its AI agent finishes editing your schema files. +`stash` covers authentication, initialization, EQL install/upgrade/validate/push/migrate, schema introspection, and a `stash wizard` subcommand that thin-wraps [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard). The wizard package itself is a separate npm install — kept out of the `stash` bundle so the agent SDK doesn't bloat the CLI. --- @@ -69,7 +69,7 @@ Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db push`, ### `npx stash init` -Scaffold CipherStash for your project. Generates an encryption client file, writes initial schema code, and installs `stash` as a dev dependency. +Set up CipherStash end-to-end: authenticate, introspect your database, install dependencies, install EQL, and hand off the rest to your local coding agent. ```bash npx stash init [--supabase] [--drizzle] @@ -80,7 +80,18 @@ npx stash init [--supabase] [--drizzle] | `--supabase` | Use the Supabase-specific setup flow | | `--drizzle` | Use the Drizzle-specific setup flow | -After `init` completes, the Next Steps output tells you to run `npx stash db install`, then edit your encryption client file directly. +What `init` does, in order: + +1. **Authenticate** — re-uses an existing token if found, otherwise opens the browser device-code flow. +2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `db install` uses. +3. **Generate the encryption client** — connects to your database, lists tables, and prompts you to multi-select which columns to encrypt. Writes `./src/encryption/index.ts` with the right shape for the detected ORM (Drizzle / Supabase / plain Postgres). Falls back to a placeholder if the database has no tables yet. +4. **Install dependencies** — `@cipherstash/stack` (runtime) and `stash` (dev), with a confirmation prompt. +5. **Install EQL** — runs `stash db install` against the resolved URL after a y/N confirm. +6. **Hand off** — four-option menu (Claude Code / Codex / CipherStash Agent / write `AGENTS.md`). See the Quickstart section above for what each option writes and spawns. + +The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` tells whichever agent picks up next what's already done and what's left. + +`CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for the rulebook fetch. Useful for local-dev against a wizard gateway running on `localhost`. --- @@ -96,6 +107,18 @@ Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check --- +### `npx stash wizard` + +Launch the CipherStash AI wizard. Thin wrapper around [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard) — the wizard ships as a separate npm package so the agent SDK stays out of the `stash` bundle, but you don't need to remember a second tool name. + +```bash +npx stash wizard [...flags] +``` + +Any flags after `wizard` are forwarded verbatim to the wizard package. On the first run the package manager downloads the wizard (~5s); subsequent runs are instant. + +--- + ### `npx stash secrets` Manage end-to-end encrypted secrets. diff --git a/packages/cli/src/commands/init/detect-agents.ts b/packages/cli/src/commands/init/detect-agents.ts index 43a57670..6df2d7f9 100644 --- a/packages/cli/src/commands/init/detect-agents.ts +++ b/packages/cli/src/commands/init/detect-agents.ts @@ -1,6 +1,6 @@ -import { spawnSync } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { resolve } from 'node:path' +import { delimiter, resolve } from 'node:path' +import { platform } from 'node:os' export type Editor = 'vscode' | 'cursor' | 'unknown' @@ -26,22 +26,30 @@ export interface AgentEnvironment { } /** - * Look up an executable on PATH without running it. We use `command -v` (POSIX) - * because it is built into every shell we support and prints a usable path on - * success / nothing on failure. `which` is not always installed on minimal - * containers; `command -v` is. + * Walk `PATH` looking for an executable. Pure-Node lookup so we don't + * depend on `/bin/sh -c command -v` (POSIX-only) or `where` (Windows-only). + * Allowlists the bin name to a conservative pattern — a defensive + * belt-and-braces given callers only pass closed-enum literals today. */ -function isOnPath(bin: string): boolean { - // `command -v` is a shell builtin, so we run it via /bin/sh -c with the - // command argument inlined. Avoids the DEP0190 warning that fires when you - // combine `shell: true` with an args array. +function isOnPath(bin: string, env: NodeJS.ProcessEnv): boolean { if (!/^[a-z0-9_-]+$/i.test(bin)) return false - const result = spawnSync('/bin/sh', ['-c', `command -v ${bin}`], { - stdio: ['ignore', 'pipe', 'ignore'], - }) - if (result.status !== 0) return false - const out = result.stdout?.toString().trim() ?? '' - return out.length > 0 + const path = env.PATH ?? env.Path ?? env.path ?? '' + if (!path) return false + + const isWindows = platform() === 'win32' + // PATHEXT lets us match `claude.cmd` / `claude.exe` on Windows; on POSIX we + // only look for the bare name. We don't honour `process.env.PATHEXT` for + // arbitrary user-set casing — `.cmd`, `.exe`, `.bat` cover ~99% of installs. + const exts = isWindows ? ['.cmd', '.exe', '.bat', ''] : [''] + + for (const dir of path.split(delimiter)) { + if (!dir) continue + for (const ext of exts) { + const candidate = resolve(dir, `${bin}${ext}`) + if (existsSync(candidate)) return true + } + } + return false } function detectEditor(env: NodeJS.ProcessEnv): Editor { @@ -71,8 +79,8 @@ export function detectAgents( ): AgentEnvironment { return { cli: { - claudeCode: isOnPath('claude'), - codex: isOnPath('codex'), + claudeCode: isOnPath('claude', env), + codex: isOnPath('codex', env), }, project: { claudeDir: isDirectory(resolve(cwd, '.claude')), diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 0e12bf1d..7852e1b7 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -30,7 +30,10 @@ export interface ContextFile { packageManager: PackageManager installCommand: string envKeys: string[] - schema: SchemaDef + /** Every encrypted-table schema written to the encryption client. The + * generated client file is still authoritative for column types and ops; + * this lets agents see the full set without parsing TypeScript. */ + schemas: SchemaDef[] generatedAt: string } @@ -89,12 +92,12 @@ export function buildContextFile( ): ContextFile { const integration = state.integration ?? 'postgresql' const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' - const schema = state.schema - if (!schema) { + const schemas = state.schemas + if (!schemas || schemas.length === 0) { // Should not happen — build-schema always populates this. Keep the // assertion explicit so a future refactor that drops the field gets // caught here rather than producing a half-empty context.json. - throw new Error('Schema missing from init state — cannot write context.') + throw new Error('Schemas missing from init state — cannot write context.') } const pm = detectPackageManager() @@ -106,7 +109,7 @@ export function buildContextFile( packageManager: pm, installCommand: prodInstallCommand(pm, '@cipherstash/stack'), envKeys: [], - schema, + schemas, generatedAt: new Date().toISOString(), } } @@ -136,7 +139,7 @@ export function writeBaselineContextFile( cwd: string, envKeys: string[], ): void { - if (!state.schema) return + if (!state.schemas || state.schemas.length === 0) return const absPath = resolve(cwd, CONTEXT_REL_PATH) const ctx = buildContextFile(state, RULEBOOK_VERSION) ctx.envKeys = envKeys @@ -160,7 +163,6 @@ export function buildSetupPromptContext( integration, encryptionClientPath, packageManager: detectPackageManager(), - schema: state.schema ?? { tableName: 'users', columns: [] }, schemaFromIntrospection: state.schemaFromIntrospection ?? false, eqlInstalled: state.eqlInstalled ?? false, stackInstalled: state.stackInstalled ?? false, diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index b92915af..eea739c2 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -36,20 +36,6 @@ function detectIntegration( return 'postgresql' } -/** - * Generate the encryption client from a real DB introspection. Returns - * `undefined` when introspection fails, the DB has no tables, or the user - * cancels — callers fall back to the placeholder. - * - * Uses the URL already resolved by `resolve-database` (threaded through - * state) rather than calling the resolver again. - */ -async function buildFromIntrospection( - databaseUrl: string, -): Promise { - return buildSchemasFromDatabase(databaseUrl) -} - export const buildSchemaStep: InitStep = { id: 'build-schema', name: 'Generate encryption client', @@ -82,7 +68,7 @@ export const buildSchemaStep: InitStep = { clientFilePath, schemaGenerated: false, integration, - schema: PLACEHOLDER_SCHEMA, + schemas: [PLACEHOLDER_SCHEMA], schemaFromIntrospection: false, } } @@ -90,28 +76,25 @@ export const buildSchemaStep: InitStep = { // Try real introspection first. Falls through to placeholder for an // empty database, a connection error, or user cancellation at any prompt. - let schemas: SchemaDef[] | undefined + let introspected: SchemaDef[] | undefined if (state.databaseUrl) { - schemas = await buildFromIntrospection(state.databaseUrl) + introspected = await buildSchemasFromDatabase(state.databaseUrl) } let fileContents: string - let recordedSchema: SchemaDef + let recordedSchemas: SchemaDef[] let fromIntrospection: boolean - if (schemas && schemas.length > 0 && schemas[0]) { - fileContents = generateClientFromSchemas(integration, schemas) - // We record the first schema for context.json so handoffs have a - // canonical "what got encrypted" pointer. Multi-table users can read - // the full set from the generated client file. - recordedSchema = schemas[0] + if (introspected && introspected.length > 0) { + fileContents = generateClientFromSchemas(integration, introspected) + recordedSchemas = introspected fromIntrospection = true } else { p.log.info( 'No tables found in the public schema — writing a placeholder client. The handoff prompt will note this so the agent reshapes it to your real schema.', ) fileContents = generatePlaceholderClient(integration) - recordedSchema = PLACEHOLDER_SCHEMA + recordedSchemas = [PLACEHOLDER_SCHEMA] fromIntrospection = false } @@ -123,17 +106,23 @@ export const buildSchemaStep: InitStep = { writeFileSync(resolvedPath, fileContents, 'utf-8') p.log.success( fromIntrospection - ? `Encryption client written to ${clientFilePath} (${integration}, ${schemas?.length ?? 0} table${(schemas?.length ?? 0) !== 1 ? 's' : ''} from introspection)` + ? `Encryption client written to ${clientFilePath} (${integration}, ${recordedSchemas.length} table${recordedSchemas.length !== 1 ? 's' : ''} from introspection)` : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, ) + // Read env-key names once and put them on state. gather-context (later in + // the pipeline) and the handoff steps all read from there rather than + // re-scanning `.env*` files. Names only — never values. + const envKeys = readEnvKeyNames(cwd) + const nextState: InitState = { ...state, clientFilePath, schemaGenerated: true, integration, - schema: recordedSchema, + schemas: recordedSchemas, schemaFromIntrospection: fromIntrospection, + envKeys, } // Write a baseline `.cipherstash/context.json` immediately so it tracks @@ -142,7 +131,7 @@ export const buildSchemaStep: InitStep = { // is consistent with the client even if init aborts before the handoff // (e.g. install-eql failure, Ctrl+C). Without this, an agent reading a // stale context.json from a previous run would happily believe it. - writeBaselineContextFile(nextState, cwd, readEnvKeyNames(cwd)) + writeBaselineContextFile(nextState, cwd, envKeys) return nextState }, diff --git a/packages/cli/src/commands/init/steps/gather-context.ts b/packages/cli/src/commands/init/steps/gather-context.ts index f424c83c..0629a7d2 100644 --- a/packages/cli/src/commands/init/steps/gather-context.ts +++ b/packages/cli/src/commands/init/steps/gather-context.ts @@ -8,8 +8,13 @@ import { detectPackageManager } from '../utils.js' /** * Names of env keys observed in the project's `.env*` files. We never read or * propagate the values — only the names tell the agent which keys to expect. + * + * Exported so build-schema can populate `state.envKeys` once at the start of + * the run; the handoff steps then read from state. Keeping the function here + * (rather than under `lib/`) groups it with the other context-gathering + * helpers. */ -function readEnvKeyNames(cwd: string): string[] { +export function readEnvKeyNames(cwd: string): string[] { const candidates = [ '.env', '.env.local', @@ -39,12 +44,11 @@ function readEnvKeyNames(cwd: string): string[] { } /** - * Pull together everything an external agent will need into in-memory state. + * Detect available coding agents and log a one-line summary of the state + * the user just set up. * - * No file writes happen here — `handoff-claude` is what serialises this to - * `.cipherstash/context.json`. We split the responsibilities so the wizard / - * rules-only branches can also reuse the gathered facts later if we ever - * surface them. + * Env keys are already on `state.envKeys` (populated by build-schema); we + * only read them off state here to mention the count. No file writes. */ export const gatherContextStep: InitStep = { id: 'gather-context', @@ -52,31 +56,21 @@ export const gatherContextStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const agents = detectAgents(cwd, process.env) - const envKeys = readEnvKeyNames(cwd) const pm = detectPackageManager() + const envKeyCount = state.envKeys?.length ?? 0 const detectedBits: string[] = [] if (state.integration) detectedBits.push(`integration: ${state.integration}`) detectedBits.push(`package manager: ${pm}`) if (agents.cli.claudeCode) detectedBits.push('Claude Code CLI: yes') - if (envKeys.length > 0) { - detectedBits.push(`env keys: ${envKeys.length} found`) + if (agents.cli.codex) detectedBits.push('Codex CLI: yes') + if (envKeyCount > 0) { + detectedBits.push(`env keys: ${envKeyCount} found`) } p.log.info(`Detected — ${detectedBits.join(', ')}`) - return { - ...state, - agents, - // Stash env key names directly on state via a side channel so handoff - // doesn't have to re-read .env files. Re-using `agents` shape would - // pollute it, so we use a private getter on the next step instead by - // reading env keys again — they're cheap. We deliberately don't store - // values here. - } + return { ...state, agents } }, } - -/** Re-export so handoff-claude can call it with the same semantics. */ -export { readEnvKeyNames } diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts index 78756167..2da3e5fb 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -12,7 +12,6 @@ import { writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' -import { readEnvKeyNames } from './gather-context.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' @@ -33,7 +32,7 @@ export const handoffAgentsMdStep: InitStep = { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' const cliVersion = readCliVersion() - const envKeys = readEnvKeyNames(cwd) + const envKeys = state.envKeys ?? [] const rulebookSpinner = p.spinner() rulebookSpinner.start('Fetching rulebook...') diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index 6f3b8436..94d3c98c 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -14,7 +14,6 @@ import { writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' -import { readEnvKeyNames } from './gather-context.js' const SKILL_REL_PATH = `.claude/skills/${CLAUDE_SKILL_NAME}/SKILL.md` @@ -57,7 +56,7 @@ export const handoffClaudeStep: InitStep = { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' const cliVersion = readCliVersion() - const envKeys = readEnvKeyNames(cwd) + const envKeys = state.envKeys ?? [] const rulebookSpinner = p.spinner() rulebookSpinner.start('Fetching rulebook...') @@ -97,7 +96,10 @@ export const handoffClaudeStep: InitStep = { `Install: ${CLAUDE_INSTALL_URL}`, '', 'Once installed, run:', - ` claude "${launchPrompt}"`, + // Single-quote the prompt for the printed example. The launchPrompt + // is a closed-form string we control, but printing it inside double + // quotes would break if any path inside ever contained a quote. + ` claude '${launchPrompt}'`, ].join('\n'), 'Files written — install Claude Code to run the handoff', ) @@ -108,7 +110,7 @@ export const handoffClaudeStep: InitStep = { const exitCode = await spawnClaude(launchPrompt) if (exitCode !== 0) { p.log.warn( - `Claude Code exited with code ${exitCode}. Re-run \`claude "${launchPrompt}"\` to resume.`, + `Claude Code exited with code ${exitCode}. Re-run \`claude '${launchPrompt}'\` to resume.`, ) } diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts index eb3b471f..548f96ac 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -13,7 +13,6 @@ import { writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' -import { readEnvKeyNames } from './gather-context.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' @@ -42,7 +41,7 @@ export const handoffCodexStep: InitStep = { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' const cliVersion = readCliVersion() - const envKeys = readEnvKeyNames(cwd) + const envKeys = state.envKeys ?? [] const rulebookSpinner = p.spinner() rulebookSpinner.start('Fetching rulebook...') @@ -82,7 +81,7 @@ export const handoffCodexStep: InitStep = { `Install: ${CODEX_INSTALL_URL}`, '', 'Once installed, run:', - ` codex "${launchPrompt}"`, + ` codex '${launchPrompt}'`, ].join('\n'), 'Files written — install Codex to run the handoff', ) @@ -93,7 +92,7 @@ export const handoffCodexStep: InitStep = { const exitCode = await spawnCodex(launchPrompt) if (exitCode !== 0) { p.log.warn( - `Codex exited with code ${exitCode}. Re-run \`codex "${launchPrompt}"\` to resume.`, + `Codex exited with code ${exitCode}. Re-run \`codex '${launchPrompt}'\` to resume.`, ) } diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/init/steps/handoff-wizard.ts index 7a616852..f68c8012 100644 --- a/packages/cli/src/commands/init/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/init/steps/handoff-wizard.ts @@ -1,21 +1,22 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' import { RULEBOOK_VERSION } from '../../../rulebook/index.js' -import { wizardCommand } from '../../wizard/index.js' +import { runWizardSpawn } from '../../wizard/index.js' import { CONTEXT_REL_PATH, buildContextFile, writeContextFile, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' -import { readEnvKeyNames } from './gather-context.js' /** * Hand off to the CipherStash Agent (the in-house wizard package). * * Writes `.cipherstash/context.json` so the wizard has the same prepared - * facts the other handoffs use, then invokes `wizardCommand` — the same - * thin-wrapper subcommand a user would get from `stash wizard` directly. + * facts the other handoffs use, then spawns the wizard via `runWizardSpawn` + * — the same path the top-level `stash wizard` subcommand takes, but with + * the exit code surfaced rather than `process.exit`-ed so init can finish + * its own outro and `next-steps` step. * * No SKILL.md / AGENTS.md is written here. The wizard renders its own * agent-side prompt from the gateway and doesn't read disk-bound rulebooks. @@ -25,7 +26,7 @@ export const handoffWizardStep: InitStep = { name: 'Use the CipherStash Agent', async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() - const envKeys = readEnvKeyNames(cwd) + const envKeys = state.envKeys ?? [] const contextAbs = resolve(cwd, CONTEXT_REL_PATH) const ctx = buildContextFile(state, RULEBOOK_VERSION) @@ -35,7 +36,12 @@ export const handoffWizardStep: InitStep = { // Pass through no extra flags. If a user wants to debug the wizard, they // can re-run `stash wizard --debug` directly afterwards. - await wizardCommand([]) + const exitCode = await runWizardSpawn([]) + if (exitCode !== 0) { + p.log.warn( + `Wizard exited with code ${exitCode}. Re-run \`stash wizard\` to resume.`, + ) + } return state }, diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 6e1949ad..6e957fc8 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -31,15 +31,21 @@ export interface InitState { * rather than the placeholder. Drives messaging in the action prompt. */ schemaFromIntrospection?: boolean stackInstalled?: boolean - /** Renamed from `forgeInstalled` — "Forge" was the legacy name for the - * `stash` CLI. Kept on InitState as `cliInstalled` for clarity. */ + /** True when the `stash` CLI is in the project's devDependencies. */ cliInstalled?: boolean /** True when EQL was installed (or already-installed) by install-eql. */ eqlInstalled?: boolean /** Detected ORM / framework integration. Set by build-schema. */ integration?: Integration - /** Schema definition that was written to the client file. */ - schema?: SchemaDef + /** Schema definitions written to the encryption client. Carries every + * table the user picked during introspection (or the single placeholder + * for empty databases). The generated client file is still the canonical + * source for the full set of column types and search ops. */ + schemas?: SchemaDef[] + /** Names of env keys observed in `.env*` files at init time. Never the + * values. Set by build-schema (so the baseline context.json has them); + * read by the handoff steps without re-scanning. */ + envKeys?: string[] /** Available coding agents in the user's environment. Set by detect-agents. */ agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ diff --git a/packages/cli/src/commands/wizard/index.ts b/packages/cli/src/commands/wizard/index.ts index 0b663b99..88d1d05e 100644 --- a/packages/cli/src/commands/wizard/index.ts +++ b/packages/cli/src/commands/wizard/index.ts @@ -28,20 +28,17 @@ function splitRunner(cmd: string): { bin: string; preArgs: string[] } { } /** - * Thin wrapper around `@cipherstash/wizard`. + * Spawn `@cipherstash/wizard` and return its exit code. * - * The wizard ships as its own package so the heavy agent SDK stays out of the - * `stash` CLI bundle. This wrapper exists so users see one CLI surface - * (`stash wizard`) instead of being told to remember a second tool name. - * - * On a cold cache (the wizard package isn't installed in the project) the - * package manager will download it before running — that can take a few - * seconds. We surface that explicitly so the user doesn't think the CLI is - * hung. We don't show a spinner because the wizard itself uses clack and - * needs an inherited TTY; intercepting child stdout would break the wizard's - * own UI. + * The wizard ships as its own package so the heavy agent SDK stays out of + * the `stash` CLI bundle. Returning the exit code (rather than calling + * `process.exit`) lets callers decide whether to abort: the top-level + * `stash wizard` subcommand exits the process; the `init` handoff path + * keeps init alive so it can run its outro, log final state, etc. */ -export async function wizardCommand(passthroughArgs: string[]): Promise { +export async function runWizardSpawn( + passthroughArgs: string[], +): Promise { const pm = detectPackageManager() const runner = runnerCommand(pm, WIZARD_PACKAGE) const cached = isPackageInstalled(WIZARD_PACKAGE) @@ -57,7 +54,7 @@ export async function wizardCommand(passthroughArgs: string[]): Promise { const { bin, preArgs } = splitRunner(runner) const args = [...preArgs, ...passthroughArgs] - const exitCode = await new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const child = spawn(bin, args, { stdio: 'inherit', shell: false }) child.on('close', (code) => resolvePromise(code ?? 0)) child.on('error', (err) => { @@ -65,7 +62,16 @@ export async function wizardCommand(passthroughArgs: string[]): Promise { resolvePromise(127) }) }) +} +/** + * Top-level `stash wizard` subcommand. Spawns the wizard and exits with + * its exit code so users see the wizard's failure state directly. For the + * in-process `init` handoff that wants to preserve init's lifecycle, call + * `runWizardSpawn` instead. + */ +export async function wizardCommand(passthroughArgs: string[]): Promise { + const exitCode = await runWizardSpawn(passthroughArgs) if (exitCode !== 0) { process.exit(exitCode) } diff --git a/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts b/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts index aee5df5a..405561f9 100644 --- a/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts @@ -9,10 +9,6 @@ const baseCtx: SetupPromptContext = { integration: 'drizzle', encryptionClientPath: './src/encryption/index.ts', packageManager: 'pnpm', - schema: { - tableName: 'users', - columns: [{ name: 'email', dataType: 'string', searchOps: ['equality'] }], - }, schemaFromIntrospection: false, eqlInstalled: false, stackInstalled: false, diff --git a/packages/cli/src/rulebook/partials.ts b/packages/cli/src/rulebook/partials.ts index 259c6793..bb6dc110 100644 --- a/packages/cli/src/rulebook/partials.ts +++ b/packages/cli/src/rulebook/partials.ts @@ -1,15 +1,18 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import type { Integration } from '../commands/init/types.js' /** * Get the directory of the current file, supporting both ESM and CJS. - * Mirrors the pattern in `src/installer/index.ts` so we work in both bundle - * variants tsup produces (`dist/index.js` ESM, `dist/index.cjs` CJS). + * + * `fileURLToPath` is the right way to convert `import.meta.url` into a real + * path — `new URL(...).pathname` gives `/C:/...` on Windows and percent- + * encodes spaces, both of which break `existsSync` lookups. */ function currentDir(): string { if (typeof import.meta?.url === 'string' && import.meta.url) { - return dirname(new URL(import.meta.url).pathname) + return dirname(fileURLToPath(import.meta.url)) } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore — __dirname is the CJS fallback diff --git a/packages/cli/src/rulebook/renderers/setup-prompt.ts b/packages/cli/src/rulebook/renderers/setup-prompt.ts index 335db375..1d1ad19e 100644 --- a/packages/cli/src/rulebook/renderers/setup-prompt.ts +++ b/packages/cli/src/rulebook/renderers/setup-prompt.ts @@ -1,7 +1,6 @@ import type { HandoffChoice, Integration, - SchemaDef, } from '../../commands/init/types.js' import { type PackageManager, @@ -13,7 +12,6 @@ export interface SetupPromptContext { integration: Integration encryptionClientPath: string packageManager: PackageManager - schema: SchemaDef schemaFromIntrospection: boolean eqlInstalled: boolean stackInstalled: boolean From 5314d3f699cb1d1c9018f295a1b4e21aed697ec4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 3 May 2026 23:34:07 +1000 Subject: [PATCH 07/10] refactor(cli): drop rulebook package, install authored skills directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rulebook package shipped a thinner, parallel restatement of content already authored as Claude Code skills at the repo root (`/skills/`). Replace it: copy the right per-integration subset of those skills into the user's project at handoff time, and split AGENTS.md into "durable doctrine" per OpenAI's Codex guidance. Architecture per handoff: - claude-code → `.claude/skills/{stash-encryption,stash-,stash-cli}/` - codex → `.codex/skills/<...>` + AGENTS.md (sentinel-wrapped doctrine) - agents-md → AGENTS.md only, doctrine + inlined skill content (for Cursor / Windsurf / Cline which don't auto-load skills) - wizard → unchanged; wizard installs its own skills Per-integration subset extends the wizard's existing SKILL_MAP with postgresql + dynamodb so init covers every integration the CLI knows about. Changes: - New: install-skills.ts (port of wizard's helper, parameterised destDir, no prompt — by handoff time the choice is made), build-agents-md.ts (doctrine-only / doctrine-plus-skills modes), AGENTS-doctrine.md (the durable-rules fragment), tests for both. - Move: rulebook/renderers/setup-prompt.ts → init/lib/setup-prompt.ts with its test. Setup-prompt is project-specific; it belonged with the rest of init/ all along. Drop "Rulebook version: ..." header line; update the rules-pointer to name the installed skills per handoff. - Update: tsup.config.ts copies skills/ + doctrine/ into dist/ at build time so the CLI tarball ships them. write-context.ts drops rulebookVersion from ContextFile, adds installedSkills: string[]. Handoff steps switch from fetchRulebook+writeArtifact to installSkills/buildAgentsMd. - Delete: rulebook/ directory, fetch-rulebook.ts, gateway-fetch path (no longer needed; everything ships bundled). Net diff: -170 lines vs the previous handoff implementation. PR #506 (suite gateway endpoint) is no longer needed and should be closed. --- .changeset/cli-init-agent-handoff.md | 35 +++-- .../commands/init/doctrine/AGENTS-doctrine.md | 48 +++++++ .../lib/__tests__/build-agents-md.test.ts | 55 ++++++++ .../init/lib/__tests__/install-skills.test.ts | 97 ++++++++++++++ .../init/lib}/__tests__/setup-prompt.test.ts | 21 +-- .../src/commands/init/lib/build-agents-md.ts | 115 ++++++++++++++++ .../src/commands/init/lib/fetch-rulebook.ts | 113 ---------------- .../src/commands/init/lib/install-skills.ts | 123 ++++++++++++++++++ .../init/lib}/setup-prompt.ts | 58 ++++++--- .../src/commands/init/lib/write-context.ts | 48 +++---- .../src/commands/init/steps/build-schema.ts | 4 +- .../commands/init/steps/handoff-agents-md.ts | 48 +++---- .../src/commands/init/steps/handoff-claude.ts | 51 +++----- .../src/commands/init/steps/handoff-codex.ts | 54 ++++---- .../src/commands/init/steps/handoff-wizard.ts | 7 +- .../src/rulebook/__tests__/renderers.test.ts | 61 --------- packages/cli/src/rulebook/index.ts | 12 -- packages/cli/src/rulebook/partials.ts | 58 --------- packages/cli/src/rulebook/partials/core.md | 55 -------- .../rulebook/partials/integrations/drizzle.md | 63 --------- .../partials/integrations/postgresql.md | 36 ----- .../partials/integrations/supabase.md | 65 --------- .../cli/src/rulebook/renderers/agents-md.ts | 53 -------- .../src/rulebook/renderers/claude-skill.ts | 68 ---------- .../cli/src/rulebook/renderers/gateway.ts | 31 ----- packages/cli/src/rulebook/version.ts | 11 -- packages/cli/tsup.config.ts | 18 ++- 27 files changed, 619 insertions(+), 789 deletions(-) create mode 100644 packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md create mode 100644 packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts create mode 100644 packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts rename packages/cli/src/{rulebook => commands/init/lib}/__tests__/setup-prompt.test.ts (78%) create mode 100644 packages/cli/src/commands/init/lib/build-agents-md.ts delete mode 100644 packages/cli/src/commands/init/lib/fetch-rulebook.ts create mode 100644 packages/cli/src/commands/init/lib/install-skills.ts rename packages/cli/src/{rulebook/renderers => commands/init/lib}/setup-prompt.ts (78%) delete mode 100644 packages/cli/src/rulebook/__tests__/renderers.test.ts delete mode 100644 packages/cli/src/rulebook/index.ts delete mode 100644 packages/cli/src/rulebook/partials.ts delete mode 100644 packages/cli/src/rulebook/partials/core.md delete mode 100644 packages/cli/src/rulebook/partials/integrations/drizzle.md delete mode 100644 packages/cli/src/rulebook/partials/integrations/postgresql.md delete mode 100644 packages/cli/src/rulebook/partials/integrations/supabase.md delete mode 100644 packages/cli/src/rulebook/renderers/agents-md.ts delete mode 100644 packages/cli/src/rulebook/renderers/claude-skill.ts delete mode 100644 packages/cli/src/rulebook/renderers/gateway.ts delete mode 100644 packages/cli/src/rulebook/version.ts diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md index 536b39c1..513248f1 100644 --- a/.changeset/cli-init-agent-handoff.md +++ b/.changeset/cli-init-agent-handoff.md @@ -2,27 +2,36 @@ 'stash': minor --- -`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with — and it leaves them with a project-specific action plan, not just generic rules. +`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with — and it leaves them with a project-specific action plan and the right reference material, not just generic rules. The new pipeline: 1. **Authenticate** (unchanged). 2. **Resolve `DATABASE_URL`** — uses the same resolver as `stash db install` (flag → env → `supabase status` → interactive prompt). Hard-fails with an actionable message if nothing resolves. -3. **Build the encryption client.** When the database has tables, `init` introspects them (the same multi-select UX `stash schema build` has) and generates a real client from the user's selection. When the database is empty, it falls back to the placeholder so fresh projects still work — and the action prompt notes the placeholder so the agent reshapes it later. -4. **Install dependencies** — `@cipherstash/stack` (runtime) + `stash` (CLI dev dep). Renamed from "Forge" since that name no longer means anything. +3. **Build the encryption client.** When the database has tables, `init` introspects them and generates a real client from the user's selection. When the database is empty, it falls back to a placeholder so fresh projects still work — and the action prompt notes the placeholder so the agent reshapes it later. +4. **Install dependencies** — `@cipherstash/stack` (runtime) + `stash` (CLI dev dep). 5. **Install EQL into the database** — y/N confirm, then runs `stash db install` programmatically against the URL we already resolved. No second prompt for credentials. -6. **Pick a handoff** from the four-option menu: - - **Hand off to Claude Code** — installs `.claude/skills/cipherstash-setup/SKILL.md`, writes `.cipherstash/context.json` and `.cipherstash/setup-prompt.md`, spawns `claude` interactively. Default when `claude` is on PATH. - - **Hand off to Codex** — writes `AGENTS.md` + `.cipherstash/context.json` + `.cipherstash/setup-prompt.md`, spawns `codex` interactively. Default when `codex` is on PATH (and `claude` is not). - - **Use the CipherStash Agent** — writes `.cipherstash/context.json` and runs `stash wizard`. Fallback for users without a local CLI agent. - - **Write AGENTS.md** — writes `AGENTS.md` + `.cipherstash/context.json` + `.cipherstash/setup-prompt.md` and stops. For Cursor, Windsurf, Cline, and any tool that follows the AGENTS.md convention. +6. **Pick a handoff** from the four-option menu. Each handoff installs the right artifacts for the chosen tool: + - **Hand off to Claude Code** — copies the per-integration set of authored skills (`stash-encryption` + `stash-` + `stash-cli`) into `.claude/skills/`, writes `.cipherstash/context.json` and `.cipherstash/setup-prompt.md`, spawns `claude`. Default when `claude` is on PATH. + - **Hand off to Codex** — writes a sentinel-managed `AGENTS.md` (durable doctrine) + copies the same skills into `.codex/skills/` (procedural workflows), writes `context.json` + `setup-prompt.md`, spawns `codex`. Default when `codex` is on PATH and `claude` is not. Follows OpenAI's Codex guidance: AGENTS.md for repo doctrine, skills for repeatable workflows. + - **Use the CipherStash Agent** — writes `context.json` and runs `stash wizard`. Fallback for users without a local CLI agent. The wizard installs its own skills. + - **Write AGENTS.md** — for editor agents (Cursor, Windsurf, Cline) that don't auto-load skill directories. Writes a single `AGENTS.md` with the doctrine *plus* the relevant skill content inlined under a sentinel block, so the agent has the API details without needing to follow file references. Plus `context.json` + `setup-prompt.md`. No spawn. -Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, init still writes the rules files and prints install + manual-launch instructions. Progress is never wasted. +Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, init still writes the artifacts and prints install + manual-launch instructions. Progress is never wasted. -`.cipherstash/setup-prompt.md` is the new headline artifact. It's the project-specific action plan — *"init has done X and Y; you need to do Z next, with these exact commands and paths"* — generated from the current init state. The launch prompt for Claude / Codex points the agent at this file first; the skill / AGENTS.md provides the reusable rulebook the prompt references. For IDE users, it's ready to paste into the first chat. +`.cipherstash/setup-prompt.md` is the headline artifact. It's the project-specific action plan — *"init has done X and Y; you need to do Z next, with these exact commands and paths"* — generated from the current init state. The launch prompt for Claude / Codex points the agent at this file first; the installed skills provide the reusable rulebook the prompt references. For IDE users, it's ready to paste into the first chat. -The rules content comes from a versioned rulebook (core + integration partials for Drizzle, Supabase, and plain PostgreSQL) shipped bundled with the CLI. When `wizard.getstash.sh/v1/wizard/rulebook` is reachable, the CLI prefers the gateway-served version so content updates between releases land without a CLI bump; network failures fall through to the bundled copy silently. `CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for local testing. +Per-integration skill subset: -Re-running `init` is safe — both `SKILL.md` and `AGENTS.md` use sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. `setup-prompt.md` is regenerated wholesale each run since it's meant to reflect the current state. +``` +drizzle → stash-encryption + stash-drizzle + stash-cli +supabase → stash-encryption + stash-supabase + stash-cli +postgresql → stash-encryption + stash-cli +dynamodb → stash-encryption + stash-dynamodb + stash-cli +``` -The `.cipherstash/context.json` file is the universal "what shape is this project" payload — integration, encryption client path, schema, env key names (never values), package manager, install command, rulebook + CLI versions, generation timestamp. +The skills themselves are the authored ones at the repo root (`/skills/`); they ship inside the CLI tarball via `tsup` so init can copy them locally without a network round-trip. The AGENTS.md doctrine fragment ships the same way. + +Re-running `init` is safe — `AGENTS.md` uses sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. Skill directories are overwritten so the user always gets the latest content. `setup-prompt.md` is regenerated wholesale each run since it's meant to reflect the current state. + +`.cipherstash/context.json` is the universal "what shape is this project" payload — integration, encryption client path, schema, env key names (never values), package manager, install command, CLI version, names of installed skills, generation timestamp. diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md new file mode 100644 index 00000000..b5027c43 --- /dev/null +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -0,0 +1,48 @@ +# CipherStash + +This project uses [CipherStash](https://cipherstash.com) for searchable, field-level encryption. Plaintext values are encrypted client-side via `@cipherstash/stack` before they leave the application; ciphertext is stored as `jsonb` in Postgres (or as encrypted attributes in DynamoDB) and decrypted on read. + +This document is the **durable rule book** for any agent working on this codebase. Read it before touching encryption-related code. Repeatable workflows (setup, schema design, migrations) live in skills installed alongside this file — see "Where to read more" at the bottom. + +## What you are working with + +- **Encryption client** — a per-project module that defines which tables and columns are encrypted, what data type each column holds, and which search operations are enabled. The path is in `.cipherstash/context.json` under `encryptionClientPath`. +- **EQL extension** — a Postgres extension installed via `stash db install` that provides server-side functions for searchable encryption (`eql_v2.*`). Required before any migration that creates encrypted columns. +- **Project context** — `.cipherstash/context.json` records what `stash init` discovered: integration, package manager, env key names (never values), schemas, install command, CLI version. Treat it as authoritative. +- **Action plan** — `.cipherstash/setup-prompt.md` is the project-specific to-do list for the current setup run. Read it first. + +## Invariants — never break these + +1. **Encrypted columns are nullable `jsonb`.** Never declare them as `text`, `varchar`, `bytea`, or any plaintext type. Never mark them `NOT NULL` at creation — the application writes ciphertext after the column exists, and a `NOT NULL` constraint will break inserts. (DynamoDB equivalent: encrypted attributes are written as the SDK's encrypted-blob shape; do not invent your own scheme.) +2. **Never log plaintext.** Do not add `console.log`, `logger.info`, or test-fixture dumps that print decrypted values. Sensitive fields stay in memory only as long as the request needs them. Encrypted blobs are also not for logs — they reveal which records were touched. +3. **Never read or echo secrets.** Env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) are fine to reference in code and docs. Their *values* are not. New env keys go in `.env.example` with placeholders; instruct the user to add the real value locally. +4. **Never invent CipherStash APIs.** If you don't know how a function is called, read the relevant skill (see below) — don't guess. The TypeScript types in `@cipherstash/stack` are the source of truth for what's callable. +5. **Never run database introspection yourself.** Don't run `psql`, `\d`, `pg_dump`, `supabase db dump`, or `drizzle-kit introspect`. The CLI already did this; the result is in `context.json`. If you need fresh introspection, ask the user to re-run `stash init`. +6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash db upgrade`, not a hand-edit). + +## Migrations — three phases, always reversible + +Encryption migrations affect production data. Treat every change as **plan → implement → verify**. + +**Phase 1 — Plan.** Identify the candidate encrypted fields. Identify the migration tool already in use (Drizzle Kit, Supabase CLI, Prisma migrate, raw SQL). Produce a written migration plan including the rollback. Ask the user before applying anything that would change existing data. + +**Phase 2 — Implement.** Add the encrypted column alongside the plaintext column (suffix `_encrypted` while both exist). Write a backfill script that reads the plaintext, encrypts via the encryption client, and writes the ciphertext. Keep the plaintext column readable during the transition; do not drop it in the same migration as the cutover. + +**Phase 3 — Verify.** Run the migration in a dev or test database first. Confirm the round-trip: insert through the encryption client, select back, assert the value decrypts. Confirm the search operations declared on each column actually work. Only then propose dropping the plaintext column — and that's a separate migration, not the same one. + +## Stop and ask the user when + +- The context file is missing, stale, or disagrees with the encryption client. +- A column targeted for encryption already has plaintext rows — that's a backfill plan, not a rename. +- The repo already has partial CipherStash setup that doesn't match `context.json` — someone else may have run `stash init` with different choices. +- A migration would change the data type of a column the user has already filled. +- You are about to delete or rename a file the user did not mention. + +## Where to read more + +The CipherStash setup skills are installed alongside this file. Use them when you need API details — don't re-invent. Likely paths: + +- `.claude/skills//SKILL.md` (Claude Code) +- `.codex/skills//SKILL.md` (Codex) + +Skills relevant to this project depend on the integration. Common ones: `stash-encryption` (encryption API), `stash-cli` (`stash` commands), and one of `stash-drizzle` / `stash-supabase` / `stash-dynamodb` for the chosen ORM. diff --git a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts new file mode 100644 index 00000000..e4ad76e2 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentsMdBody } from '../build-agents-md.js' + +const SENTINEL_START = '' +const SENTINEL_END = '' + +describe('buildAgentsMdBody', () => { + it('wraps the body in the rulebook sentinel pair', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-only') + expect(out.startsWith(SENTINEL_START)).toBe(true) + expect(out.trimEnd().endsWith(SENTINEL_END)).toBe(true) + }) + + it('doctrine-only includes the durable doctrine but no skill content', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-only') + expect(out).toContain('# CipherStash') + // Doctrine references invariants — pick a stable phrase that's unlikely + // to drift across rewrites. + expect(out).toMatch(/Never log plaintext/) + // Inlined skill markers should NOT appear. + expect(out).not.toContain('# Skill: stash-encryption') + expect(out).not.toContain('# Skill: stash-drizzle') + }) + + it('doctrine-plus-skills inlines the per-integration skills', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-plus-skills') + expect(out).toContain('# CipherStash') + expect(out).toContain('# Skill: stash-encryption') + expect(out).toContain('# Skill: stash-drizzle') + expect(out).toContain('# Skill: stash-cli') + // Frontmatter from individual skill files should be stripped — the + // `name: ` line is part of YAML frontmatter and should not leak. + expect(out).not.toMatch(/^---\nname: stash-encryption/m) + }) + + it('inlines a different skill set per integration', () => { + const drizzleOut = buildAgentsMdBody('drizzle', 'doctrine-plus-skills') + const supabaseOut = buildAgentsMdBody('supabase', 'doctrine-plus-skills') + + expect(drizzleOut).toContain('# Skill: stash-drizzle') + expect(drizzleOut).not.toContain('# Skill: stash-supabase') + + expect(supabaseOut).toContain('# Skill: stash-supabase') + expect(supabaseOut).not.toContain('# Skill: stash-drizzle') + }) + + it('postgresql integration omits ORM-specific skills', () => { + const out = buildAgentsMdBody('postgresql', 'doctrine-plus-skills') + expect(out).toContain('# Skill: stash-encryption') + expect(out).toContain('# Skill: stash-cli') + expect(out).not.toContain('# Skill: stash-drizzle') + expect(out).not.toContain('# Skill: stash-supabase') + expect(out).not.toContain('# Skill: stash-dynamodb') + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts new file mode 100644 index 00000000..362cec38 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + SKILL_MAP, + installSkills, + readBundledSkill, +} from '../install-skills.js' + +describe('SKILL_MAP', () => { + it('always includes stash-encryption and stash-cli for every integration', () => { + for (const [integration, skills] of Object.entries(SKILL_MAP)) { + expect(skills, integration).toContain('stash-encryption') + expect(skills, integration).toContain('stash-cli') + } + }) + + it('drizzle includes stash-drizzle', () => { + expect(SKILL_MAP.drizzle).toContain('stash-drizzle') + }) + + it('supabase includes stash-supabase', () => { + expect(SKILL_MAP.supabase).toContain('stash-supabase') + }) + + it('dynamodb includes stash-dynamodb', () => { + expect(SKILL_MAP.dynamodb).toContain('stash-dynamodb') + }) + + it('postgresql skips ORM-specific skills', () => { + expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle') + expect(SKILL_MAP.postgresql).not.toContain('stash-supabase') + expect(SKILL_MAP.postgresql).not.toContain('stash-dynamodb') + }) +}) + +describe('installSkills', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'install-skills-test-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('copies the per-integration skills into destDir', () => { + const copied = installSkills(tmp, '.claude/skills', 'drizzle') + expect(copied).toEqual(['stash-encryption', 'stash-drizzle', 'stash-cli']) + for (const name of copied) { + expect( + existsSync(join(tmp, '.claude/skills', name, 'SKILL.md')), + `${name}/SKILL.md should be present`, + ).toBe(true) + } + }) + + it('honours the destDir parameter (codex)', () => { + const copied = installSkills(tmp, '.codex/skills', 'supabase') + expect(copied).toContain('stash-supabase') + expect(existsSync(join(tmp, '.codex/skills/stash-supabase/SKILL.md'))).toBe( + true, + ) + // Does not write to .claude/ when codex is the target. + expect(existsSync(join(tmp, '.claude'))).toBe(false) + }) + + it('is idempotent — re-running does not throw and yields the same result', () => { + const first = installSkills(tmp, '.claude/skills', 'postgresql') + const second = installSkills(tmp, '.claude/skills', 'postgresql') + expect(second).toEqual(first) + }) + + it('writes SKILL.md content from the bundled source', () => { + installSkills(tmp, '.claude/skills', 'drizzle') + const content = readFileSync( + join(tmp, '.claude/skills/stash-encryption/SKILL.md'), + 'utf-8', + ) + expect(content).toMatch(/^---/) + expect(content).toContain('name: stash-encryption') + }) +}) + +describe('readBundledSkill', () => { + it('returns the SKILL.md body for a bundled skill', () => { + const body = readBundledSkill('stash-encryption') + expect(body).toBeDefined() + expect(body).toContain('name: stash-encryption') + }) + + it('returns undefined for an unknown skill name', () => { + expect(readBundledSkill('does-not-exist')).toBeUndefined() + }) +}) diff --git a/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts similarity index 78% rename from packages/cli/src/rulebook/__tests__/setup-prompt.test.ts rename to packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index 405561f9..a9acf3dc 100644 --- a/packages/cli/src/rulebook/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - RULEBOOK_VERSION, - type SetupPromptContext, - renderSetupPrompt, -} from '../index.js' +import { type SetupPromptContext, renderSetupPrompt } from '../setup-prompt.js' const baseCtx: SetupPromptContext = { integration: 'drizzle', @@ -14,14 +10,16 @@ const baseCtx: SetupPromptContext = { stackInstalled: false, cliInstalled: false, handoff: 'claude-code', + installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], } describe('renderSetupPrompt', () => { - it('emits the rulebook version + integration in the header', () => { + it('emits integration + package manager in the header', () => { const out = renderSetupPrompt(baseCtx) - expect(out).toContain(`Rulebook version: ${RULEBOOK_VERSION}`) expect(out).toContain('Integration: drizzle') expect(out).toContain('Package manager: pnpm') + // The rulebook version line is gone — the rulebook package no longer exists. + expect(out).not.toMatch(/Rulebook version:/) }) it('marks placeholder schema as a TODO when not from introspection', () => { @@ -60,6 +58,7 @@ describe('renderSetupPrompt', () => { const out = renderSetupPrompt({ ...baseCtx, integration: 'supabase', + installedSkills: ['stash-encryption', 'stash-supabase', 'stash-cli'], }) expect(out).toContain('supabase migration new') expect(out).toContain('encryptedSupabase') @@ -75,13 +74,17 @@ describe('renderSetupPrompt', () => { expect(yarn).toContain('yarn drizzle-kit generate') }) - it('points claude-code handoffs at the skill, others at AGENTS.md', () => { + it('points each handoff at the right rule source', () => { const claude = renderSetupPrompt({ ...baseCtx, handoff: 'claude-code' }) const codex = renderSetupPrompt({ ...baseCtx, handoff: 'codex' }) const agents = renderSetupPrompt({ ...baseCtx, handoff: 'agents-md' }) - expect(claude).toContain('cipherstash-setup` skill') + expect(claude).toContain('.claude/skills/') + expect(claude).toContain('`stash-encryption`') expect(codex).toContain('AGENTS.md') + expect(codex).toContain('.codex/skills/') expect(agents).toContain('AGENTS.md') + expect(agents).not.toContain('.claude/skills/') + expect(agents).not.toContain('.codex/skills/') }) }) diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts new file mode 100644 index 00000000..692a605c --- /dev/null +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +import type { Integration } from '../types.js' +import { SKILL_MAP, readBundledSkill } from './install-skills.js' + +/** Sentinel pair so re-runs replace only our region in the user's file. */ +const SENTINEL_START = '' +const SENTINEL_END = '' + +export type AgentsMdMode = 'doctrine-only' | 'doctrine-plus-skills' + +/** + * Render the managed body of `AGENTS.md` (the bit that goes inside the + * sentinel block — the caller is responsible for the upsert). + * + * doctrine-only — the durable AGENTS.md doctrine file. Used by + * the Codex handoff, where workflows live in + * `.codex/skills/` and AGENTS.md is reserved for + * durable rules per OpenAI's Codex guidance. + * + * doctrine-plus-skills — doctrine + the relevant skill SKILL.md bodies + * inlined under "## Skill references". Used by + * the AGENTS.md handoff for editor agents + * (Cursor / Windsurf / Cline) that don't auto- + * load skill directories. + */ +export function buildAgentsMdBody( + integration: Integration, + mode: AgentsMdMode, +): string { + const doctrine = readDoctrine() + if (!doctrine) { + p.log.warn( + 'AGENTS.md doctrine fragment not found in this CLI build — writing a minimal AGENTS.md.', + ) + return [ + SENTINEL_START, + '', + '# CipherStash', + '', + 'See `.cipherstash/setup-prompt.md` for the action plan and the installed skills for the rules.', + '', + SENTINEL_END, + ].join('\n') + } + + const parts: string[] = [SENTINEL_START, '', doctrine.trim(), ''] + + if (mode === 'doctrine-plus-skills') { + const skillNames = SKILL_MAP[integration] + const skillBodies: string[] = [] + for (const name of skillNames) { + const body = readBundledSkill(name) + if (body) { + skillBodies.push(`---\n\n# Skill: ${name}\n\n${stripFrontmatter(body)}`) + } + } + if (skillBodies.length > 0) { + parts.push('## Skill references', '') + parts.push( + 'These are the CipherStash skills that apply to this project. They contain the API details and patterns the rules above reference.', + '', + ) + parts.push(skillBodies.join('\n\n')) + parts.push('') + } + } + + parts.push(SENTINEL_END) + return parts.join('\n') +} + +/** + * Strip a leading YAML frontmatter block (`---\n...---\n`) from a SKILL.md + * body. Skill files use frontmatter for the Claude/Codex skill registry; + * once we inline the content into AGENTS.md it just adds noise, since the + * `# Skill: ` heading we emit already labels each section. + */ +function stripFrontmatter(body: string): string { + if (!body.startsWith('---')) return body.trim() + const end = body.indexOf('\n---', 3) + if (end === -1) return body.trim() + return body.slice(end + 4).trim() +} + +/** + * Locate and read the bundled doctrine markdown. `tsup` copies + * `src/commands/init/doctrine/` into `dist/commands/init/doctrine/` at + * build time. Multi-layout fallback mirrors `install-skills.ts` so dev + * (running from `src/`) and prod builds both find the file. + */ +function readDoctrine(): string | undefined { + const here = currentDir() + const candidates = [ + join(here, 'doctrine', 'AGENTS-doctrine.md'), + join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), + // Dev: running from `packages/cli/src/commands/init/lib/` — sibling dir. + join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), + // Prod: dist may flatten or preserve layout. + join(here, '..', '..', 'doctrine', 'AGENTS-doctrine.md'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return readFileSync(resolve(candidate), 'utf-8') + } + return undefined +} + +function currentDir(): string { + if (typeof import.meta?.url === 'string' && import.meta.url) { + return dirname(fileURLToPath(import.meta.url)) + } + return __dirname +} diff --git a/packages/cli/src/commands/init/lib/fetch-rulebook.ts b/packages/cli/src/commands/init/lib/fetch-rulebook.ts deleted file mode 100644 index 59f35dc7..00000000 --- a/packages/cli/src/commands/init/lib/fetch-rulebook.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - RULEBOOK_VERSION, - renderAgentsMd, - renderClaudeSkill, -} from '../../../rulebook/index.js' -import type { Integration } from '../types.js' - -const DEFAULT_GATEWAY_URL = 'https://wizard.getstash.sh/v1/wizard/rulebook' - -/** - * Resolve the gateway URL at call time so tests and local-dev can override it - * via `CIPHERSTASH_WIZARD_URL` without rebuilding the CLI. The override is - * always a full URL — accepting just a host complicates path handling and we - * already control the path on both sides. - */ -function gatewayUrl(): string { - return process.env.CIPHERSTASH_WIZARD_URL ?? DEFAULT_GATEWAY_URL -} - -/** - * Map the CLI's `Integration` enum (`postgresql` for "no recognised ORM") to - * the gateway's enum (`generic` for the same case). The gateway and the - * `@cipherstash/rulebook` package use the term `generic` to align with the - * existing `/v1/wizard/prompt` integrations. - */ -function gatewayIntegration(integration: Integration): string { - return integration === 'postgresql' ? 'generic' : integration -} - -/** Agents we know how to render rulebook content for. */ -export type RulebookAgent = 'claude-code' | 'codex' - -interface RulebookResponse { - /** Server-rendered artifact body (SKILL.md or AGENTS.md, depending on agent). */ - skill: string - /** Version string the gateway used to render — for drift logging. */ - rulebookVersion: string -} - -interface FetchedRulebook { - /** Rendered artifact body. Field name is `body` rather than `skill` here so - * the in-process variable matches the artifact it represents. */ - body: string - rulebookVersion: string - source: 'gateway' | 'bundled' -} - -/** - * Render the bundled rulebook for an agent without going through the network. - * Used as the fallback when the gateway is unreachable, and as the source of - * truth when running offline. - */ -function bundledRulebook( - integration: Integration, - agent: RulebookAgent, -): FetchedRulebook { - const body = - agent === 'claude-code' - ? renderClaudeSkill({ integration }) - : renderAgentsMd({ integration }) - return { body, rulebookVersion: RULEBOOK_VERSION, source: 'bundled' } -} - -/** - * Fetch the latest rulebook from the gateway, with bundled fallback. - * - * Network and auth failures are non-fatal — we always have the bundled copy. - * The gateway is the long-term source of truth for content updates between - * CLI releases. We keep the call best-effort with a 5s timeout so a flaky - * network can't turn init into a 60-second wait. - */ -export async function fetchRulebook({ - integration, - agent, - clientVersion, -}: { - integration: Integration - agent: RulebookAgent - clientVersion: string -}): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5_000) - - try { - const res = await fetch(gatewayUrl(), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - agent, - integration: gatewayIntegration(integration), - clientVersion, - bundledVersion: RULEBOOK_VERSION, - }), - signal: controller.signal, - }) - - if (!res.ok) return bundledRulebook(integration, agent) - - const data = (await res.json()) as Partial - if (typeof data.skill !== 'string' || data.skill.length === 0) { - return bundledRulebook(integration, agent) - } - return { - body: data.skill, - rulebookVersion: data.rulebookVersion ?? RULEBOOK_VERSION, - source: 'gateway', - } - } catch { - return bundledRulebook(integration, agent) - } finally { - clearTimeout(timeout) - } -} diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts new file mode 100644 index 00000000..967dfcde --- /dev/null +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -0,0 +1,123 @@ +import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +import type { Integration } from '../types.js' + +/** + * Per-integration set of skills to install. The skills themselves live at + * the monorepo root in `/skills//SKILL.md` and ship inside the CLI + * tarball — see `tsup.config.ts`, which copies the directory into + * `dist/skills/` at build time. + * + * Mirror of the wizard's `SKILL_MAP` (packages/wizard/src/lib/install-skills.ts) + * extended with `postgresql` and `dynamodb` so init can hand off to those + * stacks too. Wizard keeps its own copy because it has its own `Integration` + * union (no `dynamodb`); merging is a separate cleanup. + */ +export const SKILL_MAP: Record = { + drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'], + supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'], + postgresql: ['stash-encryption', 'stash-cli'], + dynamodb: ['stash-encryption', 'stash-dynamodb', 'stash-cli'], +} + +/** + * Copy the per-integration set of skills into `///`. + * + * Unlike the wizard's variant, this does NOT prompt — by the time it runs, + * the user has already picked a handoff and the skills are part of that + * choice. Returns the names of skills actually copied. + * + * `destDir` is relative to `cwd` and dictates the per-tool location: + * `.claude/skills` for Claude Code, `.codex/skills` for Codex. + * + * Idempotent: re-runs overwrite the skill folders so the user always gets + * the latest content shipped with this CLI. + */ +export function installSkills( + cwd: string, + destDir: string, + integration: Integration, +): string[] { + const skills = SKILL_MAP[integration] + const bundledRoot = resolveBundledSkillsRoot() + if (!bundledRoot) { + p.log.warn( + 'Skills bundle not found in this CLI build — skipping skills install.', + ) + return [] + } + + const available = skills.filter((name) => existsSync(join(bundledRoot, name))) + if (available.length === 0) return [] + + const destRoot = resolve(cwd, destDir) + mkdirSync(destRoot, { recursive: true }) + + const copied: string[] = [] + for (const name of available) { + const src = join(bundledRoot, name) + const dest = join(destRoot, name) + try { + cpSync(src, dest, { recursive: true, force: true }) + copied.push(name) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.warn(`Failed to install skill ${name}: ${message}`) + } + } + + return copied +} + +/** + * Read the body of a single bundled skill's SKILL.md. Used by the AGENTS.md + * builder when the handoff target is an editor agent (Cursor / Windsurf / + * Cline) that doesn't auto-load skill directories — we inline the content. + * + * Returns undefined if the bundle isn't found or the named skill isn't part + * of the bundle. Callers should treat that as "skip this skill" rather than + * a fatal error so a stripped CLI build still produces a usable AGENTS.md. + */ +export function readBundledSkill(name: string): string | undefined { + const bundledRoot = resolveBundledSkillsRoot() + if (!bundledRoot) return undefined + const skillFile = join(bundledRoot, name, 'SKILL.md') + if (!existsSync(skillFile)) return undefined + return readFileSync(skillFile, 'utf-8') +} + +/** + * Locate the `skills/` directory bundled with this CLI. `tsup` copies the + * monorepo's top-level `skills/` into `dist/skills/`, so the build sits + * alongside the compiled binary regardless of where pnpm/npm installs it. + * + * Walks up from the current file looking for a sibling `skills` dir so + * both the library entry (`dist/index.js`) and the CLI entry + * (`dist/bin/stash.js`) can find it. In dev (running from `src/`) we also + * fall back to the monorepo root. + */ +function resolveBundledSkillsRoot(): string | undefined { + const here = currentDir() + const candidates = [ + join(here, 'skills'), + join(here, '..', 'skills'), + join(here, '..', '..', 'skills'), + join(here, '..', '..', '..', 'skills'), + // Dev fallback: when running from `packages/cli/src/commands/init/lib/`, + // the monorepo's `skills/` is six levels up. + join(here, '..', '..', '..', '..', '..', '..', 'skills'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return resolve(candidate) + } + return undefined +} + +function currentDir(): string { + if (typeof import.meta?.url === 'string' && import.meta.url) { + return dirname(fileURLToPath(import.meta.url)) + } + return __dirname +} diff --git a/packages/cli/src/rulebook/renderers/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts similarity index 78% rename from packages/cli/src/rulebook/renderers/setup-prompt.ts rename to packages/cli/src/commands/init/lib/setup-prompt.ts index 1d1ad19e..e43a4765 100644 --- a/packages/cli/src/rulebook/renderers/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -1,12 +1,5 @@ -import type { - HandoffChoice, - Integration, -} from '../../commands/init/types.js' -import { - type PackageManager, - runnerCommand, -} from '../../commands/init/utils.js' -import { RULEBOOK_VERSION } from '../version.js' +import type { HandoffChoice, Integration } from '../types.js' +import { type PackageManager, runnerCommand } from '../utils.js' export interface SetupPromptContext { integration: Integration @@ -19,6 +12,12 @@ export interface SetupPromptContext { /** Which handoff option the user picked. Lets us tailor wording (e.g. the * Codex prompt names AGENTS.md, Claude names the skill). */ handoff: HandoffChoice + /** Names of skills `stash init` copied into the project (e.g. + * `stash-encryption`, `stash-drizzle`, `stash-cli`). The action prompt + * names them so the agent knows which references to consult. Empty for + * the `agents-md` handoff (no skills directory installed) and for + * `wizard` (the wizard installs its own). */ + installedSkills: string[] } interface MigrationCommands { @@ -87,16 +86,41 @@ function todo(line: string): string { return `- [ ] ${line}` } +/** + * Phrase the "where the rules live" pointer for each handoff target. + * + * claude-code → skills loaded into `.claude/skills/` + * codex → AGENTS.md (durable doctrine) + skills in `.codex/skills/` + * agents-md → AGENTS.md only (Cursor / Windsurf / Cline don't load + * skill directories, so the rules are inlined there) + * wizard → handled separately; this prompt isn't written for wizard + */ +function rulesPointer( + handoff: HandoffChoice, + installedSkills: string[], +): string { + const skillNames = installedSkills.length + ? installedSkills.map((s) => `\`${s}\``).join(', ') + : '' + if (handoff === 'claude-code') { + return `the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.claude/skills/\`` + } + if (handoff === 'codex') { + return `\`AGENTS.md\` (durable rules) + the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.codex/skills/\`` + } + return 'the `AGENTS.md` at the project root' +} + /** * Render the project-specific action prompt. * * This is the file the agent reads first — it tells them exactly what state * the project is in, what's already done, and what to do next, with concrete - * paths and commands. The skill / AGENTS.md provides reusable rules; this + * paths and commands. The skills / AGENTS.md provide reusable rules; this * file is the imperative for *this run*. * * Structure: header → "what's done" checklist → "what's next" actionable list - * → reference to the skill/AGENTS.md for the rules. + * → reference to the skills/AGENTS.md for the rules. */ export function renderSetupPrompt(ctx: SetupPromptContext): string { const cli = runnerCommand(ctx.packageManager, 'stash') @@ -140,7 +164,7 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { if (!ctx.schemaFromIntrospection) { next.push( todo( - `**Reshape the encryption client.** \`${ctx.encryptionClientPath}\` currently uses a placeholder \`users\` table with \`email\` and \`name\` columns. Read the user's existing schema (probably under \`src/db/\` or similar for ${ctx.integration}), decide which real tables and columns should be encrypted, and update the encryption client to match. Refer to the integration rules for the column types and constraints to use.`, + `**Reshape the encryption client.** \`${ctx.encryptionClientPath}\` currently uses a placeholder \`users\` table with \`email\` and \`name\` columns. Read the user's existing schema (probably under \`src/db/\` or similar for ${ctx.integration}), decide which real tables and columns should be encrypted, and update the encryption client to match. Refer to the skills for the column types and constraints to use.`, ), ) } @@ -156,7 +180,7 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { if (ctx.integration === 'supabase') { next.push( todo( - '**Wrap the Supabase client.** Find every call to `createClient` / `createServerClient` / `createBrowserClient` from `@supabase/supabase-js` or `@supabase/ssr`. Wrap each with `encryptedSupabase({ encryptionClient, supabaseClient })` from `@cipherstash/stack/supabase` (see the rulebook for the exact API).', + '**Wrap the Supabase client.** Find every call to `createClient` / `createServerClient` / `createBrowserClient` from `@supabase/supabase-js` or `@supabase/ssr`. Wrap each with `encryptedSupabase({ encryptionClient, supabaseClient })` from `@cipherstash/stack/supabase` (see the `stash-supabase` skill for the exact API).', ), ) } @@ -186,19 +210,13 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { ), ) - const ruleSource = - ctx.handoff === 'claude-code' - ? 'the `cipherstash-setup` skill (already loaded — `.claude/skills/cipherstash-setup/SKILL.md`)' - : 'the `AGENTS.md` at the project root' - return [ '# CipherStash setup — action plan', '', - `Rulebook version: ${RULEBOOK_VERSION}`, `Integration: ${ctx.integration}`, `Package manager: ${ctx.packageManager}`, '', - `You are picking up a CipherStash setup that \`stash init\` has started. Read this file in full before touching anything. Project-specific facts live in \`.cipherstash/context.json\`. Reusable rules (column types, things never to touch, never-\`.notNull()\`-on-encrypted etc.) live in ${ruleSource}.`, + `You are picking up a CipherStash setup that \`stash init\` has started. Read this file in full before touching anything. Project-specific facts live in \`.cipherstash/context.json\`. Reusable rules (column types, things never to touch, never-\`.notNull()\`-on-encrypted etc.) live in ${rulesPointer(ctx.handoff, ctx.installedSkills)}.`, '', '## What `stash init` already did', '', diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 7852e1b7..20ff4836 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -1,11 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { - RULEBOOK_VERSION, - type SetupPromptContext, - renderSetupPrompt, -} from '../../../rulebook/index.js' import type { HandoffChoice, InitState, @@ -17,13 +12,12 @@ import { detectPackageManager, prodInstallCommand, } from '../utils.js' -import { upsertManagedBlock } from './sentinel-upsert.js' +import { type SetupPromptContext, renderSetupPrompt } from './setup-prompt.js' export const CONTEXT_REL_PATH = '.cipherstash/context.json' export const SETUP_PROMPT_REL_PATH = '.cipherstash/setup-prompt.md' export interface ContextFile { - rulebookVersion: string cliVersion: string integration: Integration encryptionClientPath: string @@ -34,6 +28,11 @@ export interface ContextFile { * generated client file is still authoritative for column types and ops; * this lets agents see the full set without parsing TypeScript. */ schemas: SchemaDef[] + /** Names of skills `stash init` copied into the project (e.g. + * `stash-encryption`, `stash-drizzle`, `stash-cli`). Empty for the + * AGENTS.md handoff (skill content is inlined into AGENTS.md instead) + * and for wizard (the wizard installs its own). */ + installedSkills: string[] generatedAt: string } @@ -69,27 +68,11 @@ function ensureDir(path: string): void { } /** - * Write a project artifact (SKILL.md / AGENTS.md / etc.) using the - * managed-block upsert util so re-runs replace only our region. + * Build the universal `.cipherstash/context.json` from `InitState`. Throws + * on a missing schema — the build-schema step is required to have run + * before any handoff fires. */ -export function writeArtifact(absPath: string, body: string): void { - const existing = existsSync(absPath) - ? readFileSync(absPath, 'utf-8') - : undefined - const next = upsertManagedBlock({ existing, managed: body }) - ensureDir(absPath) - writeFileSync(absPath, next, 'utf-8') -} - -/** - * Build the universal `.cipherstash/context.json` from `InitState` plus the - * resolved rulebook version. Throws on a missing schema — the build-schema - * step is required to have run before any handoff fires. - */ -export function buildContextFile( - state: InitState, - rulebookVersion: string, -): ContextFile { +export function buildContextFile(state: InitState): ContextFile { const integration = state.integration ?? 'postgresql' const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' const schemas = state.schemas @@ -102,7 +85,6 @@ export function buildContextFile( const pm = detectPackageManager() return { - rulebookVersion, cliVersion: readCliVersion(), integration, encryptionClientPath: clientFilePath, @@ -110,6 +92,7 @@ export function buildContextFile( installCommand: prodInstallCommand(pm, '@cipherstash/stack'), envKeys: [], schemas, + installedSkills: [], generatedAt: new Date().toISOString(), } } @@ -125,9 +108,8 @@ export function writeContextFile(absPath: string, ctx: ContextFile): void { /** * Write `.cipherstash/context.json` immediately after the encryption client - * is generated, using the bundled rulebook version. Handoff steps refresh - * it later with the gateway-served rulebook version (when reachable), but - * having a baseline here means the file is always in sync with the + * is generated. Handoff steps refresh it later with the list of skills they + * installed; this baseline guarantees the file is always in sync with the * encryption client even if init aborts mid-flow. * * Without this baseline, a failed install-eql or a Ctrl+C between @@ -141,7 +123,7 @@ export function writeBaselineContextFile( ): void { if (!state.schemas || state.schemas.length === 0) return const absPath = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state, RULEBOOK_VERSION) + const ctx = buildContextFile(state) ctx.envKeys = envKeys writeContextFile(absPath, ctx) } @@ -154,6 +136,7 @@ export function writeBaselineContextFile( export function buildSetupPromptContext( state: InitState, handoff: HandoffChoice, + installedSkills: string[], ): SetupPromptContext | undefined { if (handoff === 'wizard') return undefined const integration = state.integration ?? 'postgresql' @@ -168,6 +151,7 @@ export function buildSetupPromptContext( stackInstalled: state.stackInstalled ?? false, cliInstalled: state.cliInstalled ?? false, handoff, + installedSkills, } } diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index eea739c2..e2b9ab01 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -126,8 +126,8 @@ export const buildSchemaStep: InitStep = { } // Write a baseline `.cipherstash/context.json` immediately so it tracks - // the encryption client we just generated. Handoff steps refresh it later - // with the gateway-served rulebook version, but this guarantees the file + // the encryption client we just generated. Handoff steps refresh it + // later with the list of installed skills, but this guarantees the file // is consistent with the client even if init aborts before the handoff // (e.g. install-eql failure, Ctrl+C). Without this, an agent reading a // stale context.json from a previous run would happily believe it. diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts index 2da3e5fb..9a4660f1 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -1,13 +1,13 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { upsertManagedBlock } from '../lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, buildContextFile, buildSetupPromptContext, - readCliVersion, - writeArtifact, writeContextFile, writeSetupPrompt, } from '../lib/write-context.js' @@ -20,10 +20,13 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md' * `.cipherstash/setup-prompt.md`, then stop. * * For users running editor-based agents (Cursor, Windsurf, Cline) or any - * tool that follows the AGENTS.md convention. We do not spawn anything — - * the user opens their tool and the agent picks the file up from the - * project root automatically. Pointing them at setup-prompt.md gives them - * the project-specific action plan to copy into their first chat. + * tool that follows the AGENTS.md convention but does NOT auto-load skill + * directories. We inline the relevant skill content into AGENTS.md so the + * agent has the API details right there. + * + * No `.codex/skills/` or `.claude/skills/` directory is written — those + * tools wouldn't know to look there. Re-runs replace only the sentinel + * region in AGENTS.md. */ export const handoffAgentsMdStep: InitStep = { id: 'handoff-agents-md', @@ -31,33 +34,30 @@ export const handoffAgentsMdStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const cliVersion = readCliVersion() const envKeys = state.envKeys ?? [] - const rulebookSpinner = p.spinner() - rulebookSpinner.start('Fetching rulebook...') - const rulebook = await fetchRulebook({ - integration, - agent: 'codex', - clientVersion: cliVersion, - }) - rulebookSpinner.stop( - rulebook.source === 'gateway' - ? `Rulebook ${rulebook.rulebookVersion} fetched.` - : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, - ) - const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) - writeArtifact(agentsMdAbs, rulebook.body) + const managed = buildAgentsMdBody(integration, 'doctrine-plus-skills') + const existing = existsSync(agentsMdAbs) + ? readFileSync(agentsMdAbs, 'utf-8') + : undefined + writeFileSync( + agentsMdAbs, + upsertManagedBlock({ existing, managed }), + 'utf-8', + ) p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state, rulebook.rulebookVersion) + const ctx = buildContextFile(state) ctx.envKeys = envKeys + // No skill directory installed for editor-agent users; the rules are + // inlined directly into AGENTS.md. + ctx.installedSkills = [] writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - const promptCtx = buildSetupPromptContext(state, 'agents-md') + const promptCtx = buildSetupPromptContext(state, 'agents-md', []) if (promptCtx) { writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index 94d3c98c..ddaf21f6 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -1,21 +1,18 @@ import { spawn } from 'node:child_process' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { CLAUDE_SKILL_NAME } from '../../../rulebook/index.js' -import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { installSkills } from '../lib/install-skills.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, buildContextFile, buildSetupPromptContext, - readCliVersion, - writeArtifact, writeContextFile, writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' -const SKILL_REL_PATH = `.claude/skills/${CLAUDE_SKILL_NAME}/SKILL.md` +const CLAUDE_SKILLS_DIR = '.claude/skills' const CLAUDE_INSTALL_URL = 'https://docs.claude.com/en/docs/claude-code/quickstart' @@ -41,13 +38,15 @@ function spawnClaude(prompt: string): Promise { } /** - * Hand off to Claude Code: install the project skill, write context.json - * and setup-prompt.md, spawn `claude`. If `claude` is not on PATH we still - * write the artifacts and print install + manual-launch instructions. + * Hand off to Claude Code: copy the per-integration set of skills into + * `.claude/skills/`, write `.cipherstash/context.json` and + * `.cipherstash/setup-prompt.md`, then spawn `claude`. If `claude` is not + * on PATH we still write the artifacts and print install + manual-launch + * instructions. * - * The launch prompt points the agent at `setup-prompt.md` first — that's the - * project-specific action plan. The skill body is the reusable rulebook and - * is referenced from the prompt. + * The launch prompt points the agent at `setup-prompt.md` first — that's + * the project-specific action plan. Claude auto-loads the installed skills + * for the durable rules and API references. */ export const handoffClaudeStep: InitStep = { id: 'handoff-claude', @@ -55,39 +54,29 @@ export const handoffClaudeStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const cliVersion = readCliVersion() const envKeys = state.envKeys ?? [] - const rulebookSpinner = p.spinner() - rulebookSpinner.start('Fetching rulebook...') - const rulebook = await fetchRulebook({ - integration, - agent: 'claude-code', - clientVersion: cliVersion, - }) - rulebookSpinner.stop( - rulebook.source === 'gateway' - ? `Rulebook ${rulebook.rulebookVersion} fetched.` - : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, - ) - - const skillAbs = resolve(cwd, SKILL_REL_PATH) - writeArtifact(skillAbs, rulebook.body) - p.log.success(`Wrote ${SKILL_REL_PATH}`) + const installed = installSkills(cwd, CLAUDE_SKILLS_DIR, integration) + if (installed.length > 0) { + p.log.success( + `Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''} into ${CLAUDE_SKILLS_DIR}/: ${installed.join(', ')}`, + ) + } const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state, rulebook.rulebookVersion) + const ctx = buildContextFile(state) ctx.envKeys = envKeys + ctx.installedSkills = installed writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - const promptCtx = buildSetupPromptContext(state, 'claude-code') + const promptCtx = buildSetupPromptContext(state, 'claude-code', installed) if (promptCtx) { writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) } - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The ${CLAUDE_SKILL_NAME} skill has the rules; ${CONTEXT_REL_PATH} has the project facts.` + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.claudeCode) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts index 548f96ac..a1b58e57 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -1,20 +1,22 @@ import { spawn } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { fetchRulebook } from '../lib/fetch-rulebook.js' +import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { installSkills } from '../lib/install-skills.js' +import { upsertManagedBlock } from '../lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, buildContextFile, buildSetupPromptContext, - readCliVersion, - writeArtifact, writeContextFile, writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' +const CODEX_SKILLS_DIR = '.codex/skills' const CODEX_INSTALL_URL = 'https://github.com/openai/codex' @@ -30,9 +32,13 @@ function spawnCodex(prompt: string): Promise { } /** - * Hand off to Codex CLI: write AGENTS.md (sentinel-upserted), context.json, - * and setup-prompt.md, then spawn `codex`. If `codex` is not on PATH we - * still write the artifacts and print install + manual-launch instructions. + * Hand off to Codex CLI. Following OpenAI's Codex guidance, AGENTS.md + * holds durable doctrine ("never log plaintext", "encrypted columns are + * jsonb null", three-phase migration etc.) while the procedural skills + * live in `.codex/skills/`. Both are written here. + * + * AGENTS.md is sentinel-upserted so re-runs replace only our region and + * any user content outside it survives. */ export const handoffCodexStep: InitStep = { id: 'handoff-codex', @@ -40,39 +46,41 @@ export const handoffCodexStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const cliVersion = readCliVersion() const envKeys = state.envKeys ?? [] - const rulebookSpinner = p.spinner() - rulebookSpinner.start('Fetching rulebook...') - const rulebook = await fetchRulebook({ - integration, - agent: 'codex', - clientVersion: cliVersion, - }) - rulebookSpinner.stop( - rulebook.source === 'gateway' - ? `Rulebook ${rulebook.rulebookVersion} fetched.` - : `Rulebook ${rulebook.rulebookVersion} (bundled — gateway unavailable).`, - ) + const installed = installSkills(cwd, CODEX_SKILLS_DIR, integration) + if (installed.length > 0) { + p.log.success( + `Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''} into ${CODEX_SKILLS_DIR}/: ${installed.join(', ')}`, + ) + } const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) - writeArtifact(agentsMdAbs, rulebook.body) + const managed = buildAgentsMdBody(integration, 'doctrine-only') + const existing = existsSync(agentsMdAbs) + ? readFileSync(agentsMdAbs, 'utf-8') + : undefined + writeFileSync( + agentsMdAbs, + upsertManagedBlock({ existing, managed }), + 'utf-8', + ) p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state, rulebook.rulebookVersion) + const ctx = buildContextFile(state) ctx.envKeys = envKeys + ctx.installedSkills = installed writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - const promptCtx = buildSetupPromptContext(state, 'codex') + const promptCtx = buildSetupPromptContext(state, 'codex', installed) if (promptCtx) { writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) } - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the rules; ${CONTEXT_REL_PATH} has the project facts.` + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.codex) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/init/steps/handoff-wizard.ts index f68c8012..160b24c5 100644 --- a/packages/cli/src/commands/init/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/init/steps/handoff-wizard.ts @@ -1,6 +1,5 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { RULEBOOK_VERSION } from '../../../rulebook/index.js' import { runWizardSpawn } from '../../wizard/index.js' import { CONTEXT_REL_PATH, @@ -18,8 +17,8 @@ import type { InitProvider, InitState, InitStep } from '../types.js' * the exit code surfaced rather than `process.exit`-ed so init can finish * its own outro and `next-steps` step. * - * No SKILL.md / AGENTS.md is written here. The wizard renders its own - * agent-side prompt from the gateway and doesn't read disk-bound rulebooks. + * No skills are installed here. The wizard fetches its own agent-side + * prompt from the gateway and runs its own `maybeInstallSkills` flow. */ export const handoffWizardStep: InitStep = { id: 'handoff-wizard', @@ -29,7 +28,7 @@ export const handoffWizardStep: InitStep = { const envKeys = state.envKeys ?? [] const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state, RULEBOOK_VERSION) + const ctx = buildContextFile(state) ctx.envKeys = envKeys writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) diff --git a/packages/cli/src/rulebook/__tests__/renderers.test.ts b/packages/cli/src/rulebook/__tests__/renderers.test.ts deleted file mode 100644 index 10904033..00000000 --- a/packages/cli/src/rulebook/__tests__/renderers.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CLAUDE_SKILL_NAME, - RULEBOOK_VERSION, - renderAgentsMd, - renderClaudeSkill, - renderGatewayPrompt, -} from '../index.js' - -describe('renderGatewayPrompt', () => { - it('includes the rulebook version, integration name, and core rules', () => { - const out = renderGatewayPrompt({ integration: 'drizzle' }) - expect(out).toContain(RULEBOOK_VERSION) - expect(out).toContain('Integration: drizzle') - expect(out).toContain('.cipherstash/context.json') - }) - - it('switches body per integration', () => { - const drizzle = renderGatewayPrompt({ integration: 'drizzle' }) - const supabase = renderGatewayPrompt({ integration: 'supabase' }) - expect(drizzle).toContain('drizzle-orm') - expect(supabase).toContain('encryptedSupabase') - expect(drizzle).not.toContain('encryptedSupabase') - }) -}) - -describe('renderClaudeSkill', () => { - it('emits valid YAML frontmatter naming the skill', () => { - const out = renderClaudeSkill({ integration: 'drizzle' }) - const lines = out.split('\n') - expect(lines[0]).toBe('---') - expect(out).toMatch(new RegExp(`name: ${CLAUDE_SKILL_NAME}`)) - expect(out).toMatch(/integration: drizzle/) - expect(out).toMatch(/rulebook_version:/) - }) - - it('mentions context.json as the first action', () => { - const out = renderClaudeSkill({ integration: 'supabase' }) - expect(out).toContain('.cipherstash/context.json') - }) -}) - -describe('renderAgentsMd', () => { - it('emits plain markdown without YAML frontmatter', () => { - const out = renderAgentsMd({ integration: 'drizzle' }) - expect(out.startsWith('---')).toBe(false) - expect(out.startsWith('# CipherStash Setup')).toBe(true) - }) - - it('includes the rulebook version and integration in the header', () => { - const out = renderAgentsMd({ integration: 'drizzle' }) - expect(out).toContain(`Rulebook version: ${RULEBOOK_VERSION}`) - expect(out).toContain('integration: drizzle') - }) - - it('points the agent at .cipherstash/context.json', () => { - const out = renderAgentsMd({ integration: 'supabase' }) - expect(out).toContain('.cipherstash/context.json') - expect(out).toContain('First step') - }) -}) diff --git a/packages/cli/src/rulebook/index.ts b/packages/cli/src/rulebook/index.ts deleted file mode 100644 index 81365df5..00000000 --- a/packages/cli/src/rulebook/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { RULEBOOK_VERSION } from './version.js' -export { renderGatewayPrompt } from './renderers/gateway.js' -export type { GatewayPromptContext } from './renderers/gateway.js' -export { - renderClaudeSkill, - CLAUDE_SKILL_NAME, -} from './renderers/claude-skill.js' -export type { ClaudeSkillContext } from './renderers/claude-skill.js' -export { renderAgentsMd } from './renderers/agents-md.js' -export type { AgentsMdContext } from './renderers/agents-md.js' -export { renderSetupPrompt } from './renderers/setup-prompt.js' -export type { SetupPromptContext } from './renderers/setup-prompt.js' diff --git a/packages/cli/src/rulebook/partials.ts b/packages/cli/src/rulebook/partials.ts deleted file mode 100644 index bb6dc110..00000000 --- a/packages/cli/src/rulebook/partials.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Integration } from '../commands/init/types.js' - -/** - * Get the directory of the current file, supporting both ESM and CJS. - * - * `fileURLToPath` is the right way to convert `import.meta.url` into a real - * path — `new URL(...).pathname` gives `/C:/...` on Windows and percent- - * encodes spaces, both of which break `existsSync` lookups. - */ -function currentDir(): string { - if (typeof import.meta?.url === 'string' && import.meta.url) { - return dirname(fileURLToPath(import.meta.url)) - } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore — __dirname is the CJS fallback - return __dirname -} - -/** - * Resolve the directory holding the bundled rulebook partials. - * - * Layouts to support: - * - Source (vitest, dev): src/rulebook/partials.ts → src/rulebook/partials/ - * - Library bundle (ESM): dist/index.js → dist/rulebook/partials/ - * - Library bundle (CJS): dist/index.cjs → dist/rulebook/partials/ - * - * tsup flattens the bundle entry to `dist/index.{js,cjs}`, so from the - * library entrypoint the partials live at `./rulebook/partials/`. From source - * they live at `./partials/`. Try both, pick the first that exists. - */ -function partialsDir(): string { - const here = currentDir() - const candidates = [ - resolve(here, 'partials'), - resolve(here, 'rulebook', 'partials'), - resolve(here, '..', 'rulebook', 'partials'), - ] - for (const dir of candidates) { - if (existsSync(dir)) return dir - } - // Last-ditch: return the source-layout candidate so the readFileSync error - // names a path the developer can act on. The literal index 0 is always set; - // we keep the fallback narrow rather than throwing here because the actual - // file read below will produce a clearer error than a generic throw. - return candidates[0] ?? resolve(here, 'partials') -} - -export function loadCorePartial(): string { - return readFileSync(resolve(partialsDir(), 'core.md'), 'utf-8') -} - -export function loadIntegrationPartial(integration: Integration): string { - const path = resolve(partialsDir(), 'integrations', `${integration}.md`) - return readFileSync(path, 'utf-8') -} diff --git a/packages/cli/src/rulebook/partials/core.md b/packages/cli/src/rulebook/partials/core.md deleted file mode 100644 index eef31ef9..00000000 --- a/packages/cli/src/rulebook/partials/core.md +++ /dev/null @@ -1,55 +0,0 @@ -## Core rules — non-negotiable - -These rules apply regardless of ORM. The user has run `stash init` already; the project is authenticated and `@cipherstash/stack` is installed. All discovery has been written to `.cipherstash/context.json`. - -### Read context first - -Before doing anything, read `.cipherstash/context.json`. It contains: - -- The detected integration (drizzle, supabase, postgresql) -- The selected encryption columns with their data types and search ops -- The encryption client output path -- The package manager and install command -- The names of env keys (never values) -- The rulebook + CLI version this context was written against - -If any of those keys are missing from the context file, stop and ask the user to re-run `stash init`. Do not guess. - -### Never run discovery yourself - -Do not run `psql`, `\d`, `pg_dump`, `supabase db dump`, Drizzle introspect, or any other database introspection. The CLI already did this. Use the column list from `.cipherstash/context.json`. - -If you genuinely need fresh introspection, ask the user to re-run `stash init` — they own the credentials and the right to introspect. - -### Never read or echo secrets - -You may reference env key **names** (e.g. `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `DATABASE_URL`) in code and docs. You must not read their values from `.env*` files or print them. If you need to add a new env key, write it to `.env.example` with a placeholder, and instruct the user to add the real value to their local `.env`. - -### Encrypted column conventions - -- Encrypted columns are stored as `jsonb` in Postgres. Never declare them as `text`, `varchar`, `bytea`, or any plaintext type. -- Add new encrypted columns alongside any existing plaintext column you are replacing — name them `_encrypted` until the migration cuts over. Do not drop the plaintext column without an explicit user decision. -- Never mark encrypted columns `NOT NULL` at creation time. The encrypted ciphertext is added by the application layer and an immediate `NOT NULL` constraint will break inserts. - -### Never modify these - -- `stash.config.ts` — generated by `stash init`. Edits go to `.env`, not here. -- `.cipherstash/` — owned by the CLI. -- The `eql_v2` schema and `eql_v2_*` functions installed by `stash db install`. If a function or trigger you need is missing, instruct the user to run `stash db upgrade`. - -### Schema changes go through Stack - -The encryption client lives at the path in `context.json`. New encrypted columns must be: - -1. Added to that schema file using the integration's encrypted column type. -2. Reflected in any migration the ORM produces. -3. Re-applied to the running database. - -Do not write raw SQL `CREATE TABLE` migrations that include encrypted columns without going through the Stack schema first — the column types and EQL function bindings are derived from the schema. - -### Stop and ask when - -- The context file is missing or out of date. -- A column the user wants to encrypt has existing plaintext rows (this needs a backfill plan, not a column rename). -- The repo already has partial CipherStash setup that disagrees with `context.json` (someone else's edits, or an older `stash init`). -- You are about to delete or rename a file the user did not mention. diff --git a/packages/cli/src/rulebook/partials/integrations/drizzle.md b/packages/cli/src/rulebook/partials/integrations/drizzle.md deleted file mode 100644 index 4b09749e..00000000 --- a/packages/cli/src/rulebook/partials/integrations/drizzle.md +++ /dev/null @@ -1,63 +0,0 @@ -## Drizzle ORM integration rules - -The project uses Drizzle. Apply these rules on top of the core rules. - -### Imports - -```ts -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' -import { Encryption } from '@cipherstash/stack' -``` - -Do not import from `@cipherstash/stack/supabase` or `@cipherstash/stack/schema` — those are different integrations. - -### Encrypted column declarations - -Use `encryptedType(name, opts?)` for every encrypted column. The TypeScript generic is the **plaintext** type (`string`, `number`, `boolean`, `Date`, `Record`). - -```ts -email: encryptedType('email', { equality: true, freeTextSearch: true }), -joinedAt: encryptedType('joined_at', { dataType: 'date', orderAndRange: true }), -``` - -Pass the search-op options (`equality`, `orderAndRange`, `freeTextSearch`) only for ops the user actually selected during `stash init` — they are recorded in `context.json` under `columns[i].searchOps`. Do not enable an op the user did not select. - -### Never on encrypted columns - -- `.notNull()` — same reason as the core rule, the application writes the ciphertext. -- `.primaryKey()` — encrypted columns must not be primary keys. -- `.references(...)` / foreign keys — encrypted columns are not referential. -- `.default(...)` — Postgres defaults are plaintext; you'd be storing a plaintext literal in a JSONB ciphertext column. -- `.unique()` — uniqueness on ciphertext is not stable; use `equality` indexed search instead. - -### Schema extraction + Encryption client - -After the table definition, extract and instantiate exactly once per file: - -```ts -const usersSchema = extractEncryptionSchema(usersTable) -export const encryptionClient = await Encryption({ schemas: [usersSchema] }) -``` - -Multiple tables in the same encryption client → one `extractEncryptionSchema` call per table, all schemas in the array. - -### Querying encrypted columns - -Use the operator helpers from `@cipherstash/stack/drizzle`, not Drizzle's stock operators, when comparing against an encrypted column: - -```ts -import { eq, like, ilike, gt, gte, lt, lte, between, inArray, asc, desc } from '@cipherstash/stack/drizzle' - -const matches = await db - .select() - .from(usersTable) - .where(eq(usersTable.email, 'alice@example.com')) -``` - -Mixing stock `eq` (from `drizzle-orm`) with an encrypted column is a silent bug — it compares the JSONB literal, not the underlying value. - -### Migrations - -When `drizzle-kit generate` produces a migration that creates an encrypted column, the column should be `jsonb` and **nullable**. If the generated migration has `NOT NULL` on an encrypted column, edit it before applying. - -To install the EQL extension, the user runs `stash db install --drizzle` — do not write or run that migration yourself. diff --git a/packages/cli/src/rulebook/partials/integrations/postgresql.md b/packages/cli/src/rulebook/partials/integrations/postgresql.md deleted file mode 100644 index 7646470f..00000000 --- a/packages/cli/src/rulebook/partials/integrations/postgresql.md +++ /dev/null @@ -1,36 +0,0 @@ -## Generic PostgreSQL integration rules - -The project does not use a recognised ORM. Apply these rules on top of the core rules. - -### Imports - -```ts -import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' -``` - -### Schema definition - -```ts -export const usersTable = encryptedTable('users', { - email: encryptedColumn('email').equality().freeTextSearch(), -}) - -export const encryptionClient = await Encryption({ schemas: [usersTable] }) -``` - -Only enable search ops listed for that column in `context.json`. - -### Postgres column types - -`jsonb`, nullable. Never `text` or `NOT NULL` on creation. - -### Querying - -The encryption client gives you `encrypt(plaintext)` and `decrypt(ciphertext)` methods. Use these at the application boundary: - -- Before inserting/updating: encrypt the plaintext. -- After selecting: decrypt the ciphertext. -- For searches that require server-side comparison, use the EQL functions installed by `stash db install` — `eql_v2.eq`, `eql_v2.like`, `eql_v2.gt`, etc. The encryption client exposes the right shape via its query helpers; do not hand-roll JSONB path expressions. - -When in doubt about which EQL function to use, read the schema partial for that integration in this skill rather than guessing. diff --git a/packages/cli/src/rulebook/partials/integrations/supabase.md b/packages/cli/src/rulebook/partials/integrations/supabase.md deleted file mode 100644 index e425ebe7..00000000 --- a/packages/cli/src/rulebook/partials/integrations/supabase.md +++ /dev/null @@ -1,65 +0,0 @@ -## Supabase integration rules - -The project uses Supabase. Apply these rules on top of the core rules. - -### Imports - -```ts -import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' -import { encryptedSupabase } from '@cipherstash/stack/supabase' -``` - -Do not import from `@cipherstash/stack/drizzle`. - -### Schema definition - -`encryptedColumn(name)` is the column builder. Chain `.dataType(...)`, `.equality()`, `.orderAndRange()`, `.freeTextSearch()` only for ops listed in `context.json`. - -```ts -export const usersTable = encryptedTable('users', { - email: encryptedColumn('email').equality().freeTextSearch(), - joinedAt: encryptedColumn('joined_at').dataType('date').orderAndRange(), -}) - -export const encryptionClient = await Encryption({ schemas: [usersTable] }) -``` - -### Postgres column types - -Encrypted columns in the Supabase database are `jsonb`, nullable. If migrations are generated by Supabase CLI, the SQL must read: - -```sql -ALTER TABLE users ADD COLUMN email_encrypted jsonb; -``` - -Never `text`, never `NOT NULL` on creation. - -### Wrap the Supabase client - -Every `createClient` call that touches encrypted tables must be wrapped: - -```ts -import { createClient } from '@supabase/supabase-js' -import { encryptedSupabase } from '@cipherstash/stack/supabase' -import { encryptionClient } from '' - -const baseClient = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!) -export const supabase = encryptedSupabase(baseClient, encryptionClient) -``` - -Do not call `.from('users').select(...)` on an unwrapped Supabase client — the ciphertext will not decrypt. - -### Querying - -- Never use `.select('*')` against tables with encrypted columns. Always enumerate columns explicitly so the wrapper can decrypt only what you ask for. -- Use the wrapped client's filter helpers (`eq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `in`, `or`, `match`) for encrypted columns. They take the **plaintext** value and the wrapper handles encryption. -- Inserts and updates take plaintext objects; the wrapper encrypts on the way out. - -### RLS policies - -Row-level security policies on encrypted columns must be written against metadata or non-encrypted columns. A policy comparing a ciphertext column to a literal will always fail. If the user wants policies that depend on encrypted values, that needs `eql_v2` operators in the policy and a separate conversation — flag it and stop. - -### Identity-aware encryption (lock contexts) - -If `context.json` indicates lock contexts are configured, pass `lockContext` through `encryptedSupabase(baseClient, encryptionClient, { lockContext })` rather than re-creating the client per request. Do not embed lock-context tokens in URLs or logs. diff --git a/packages/cli/src/rulebook/renderers/agents-md.ts b/packages/cli/src/rulebook/renderers/agents-md.ts deleted file mode 100644 index e4e11c39..00000000 --- a/packages/cli/src/rulebook/renderers/agents-md.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Integration } from '../../commands/init/types.js' -import { loadCorePartial, loadIntegrationPartial } from '../partials.js' -import { RULEBOOK_VERSION } from '../version.js' - -export interface AgentsMdContext { - integration: Integration -} - -/** - * Render the `AGENTS.md` body that lives at the project root. - * - * `AGENTS.md` is the convention used by Codex CLI, ChatGPT Codex (cloud), - * Cursor (as a fallback), Windsurf, Cline, and most other coding agents that - * are not Claude Code. They all read it from the repo root automatically. - * - * Unlike the Claude Code skill body, AGENTS.md is plain markdown — no YAML - * frontmatter. The CLI wraps the rendered body in sentinel markers - * (``) before writing, so re-runs - * upsert in place and any user-authored content outside the block is - * preserved. - */ -export function renderAgentsMd(ctx: AgentsMdContext): string { - const core = loadCorePartial() - const integration = loadIntegrationPartial(ctx.integration) - - return [ - `# CipherStash Setup (${ctx.integration})`, - '', - `Rulebook version: ${RULEBOOK_VERSION}`, - '', - 'You are completing a CipherStash field-level encryption setup that the user already started with `stash init`. The CLI did the discovery and authentication; your job is to land the code changes the rulebook below describes.', - '', - '## First step', - '', - '1. Read `.cipherstash/context.json`. If it is missing, stop and tell the user to run `stash init`.', - `2. Confirm the integration listed in the context matches this rulebook (\`integration: ${ctx.integration}\`). If it does not, stop and ask the user to re-run \`stash init\`.`, - '3. Apply the rules below to the file at `context.encryptionClientPath` and any related migration / client wiring.', - '4. Show the user a diff before applying any database migration.', - '', - core.trim(), - '', - integration.trim(), - '', - '## Done when', - '', - '- Encrypted columns from `context.json` are in the schema file with correct types and search ops.', - '- The encryption client is exported from the path in `context.json`.', - '- Any new env keys are listed in `.env.example`, and the user knows which values to add to their local `.env`.', - '- Drizzle / Supabase / Postgres-specific wiring (per the section above) is in place.', - '- Migrations have been generated but not applied — the user runs them.', - '', - ].join('\n') -} diff --git a/packages/cli/src/rulebook/renderers/claude-skill.ts b/packages/cli/src/rulebook/renderers/claude-skill.ts deleted file mode 100644 index 9c45d977..00000000 --- a/packages/cli/src/rulebook/renderers/claude-skill.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Integration } from '../../commands/init/types.js' -import { loadCorePartial, loadIntegrationPartial } from '../partials.js' -import { RULEBOOK_VERSION } from '../version.js' - -export interface ClaudeSkillContext { - integration: Integration -} - -const SKILL_NAME = 'cipherstash-setup' - -const SKILL_DESCRIPTION = - 'Complete a CipherStash field-level encryption setup that was started by ' + - '`stash init`. Reads the prepared context at .cipherstash/context.json, ' + - 'adds encrypted columns to the user-selected schema file, wires the ' + - 'encryption client into the relevant integration (Drizzle / Supabase / ' + - 'plain Postgres), and prepares migrations. Use this skill when the project ' + - 'contains .cipherstash/context.json and the user wants to finish CipherStash setup.' - -/** - * Render the SKILL.md body for a project-local Claude Code skill at - * `.claude/skills/cipherstash-setup/SKILL.md`. - * - * The skill is project-local on purpose: it pins to the rulebook version that - * `stash init` ran with, so re-running on a different project does not get - * rules from a different point in time. - */ -export function renderClaudeSkill(ctx: ClaudeSkillContext): string { - const core = loadCorePartial() - const integration = loadIntegrationPartial(ctx.integration) - const frontmatter = [ - '---', - `name: ${SKILL_NAME}`, - `description: ${SKILL_DESCRIPTION}`, - `rulebook_version: ${RULEBOOK_VERSION}`, - `integration: ${ctx.integration}`, - '---', - ].join('\n') - - return [ - frontmatter, - '', - `# CipherStash Setup (${ctx.integration})`, - '', - 'You are completing a CipherStash field-level encryption setup that the user already started with `stash init`. The CLI did the discovery and authentication; your job is to land the code changes the rulebook below describes.', - '', - '## First step', - '', - '1. Read `.cipherstash/context.json`. If it is missing, stop and tell the user to run `stash init`.', - `2. Confirm the integration listed in the context matches this skill (\`integration: ${ctx.integration}\`). If it does not, stop and ask the user to re-run \`stash init\`.`, - '3. Apply the rules below to the file at `context.encryptionClientPath` and any related migration / client wiring.', - '4. Show the user a diff before applying any database migration.', - '', - core.trim(), - '', - integration.trim(), - '', - '## Done when', - '', - '- Encrypted columns from `context.json` are in the schema file with correct types and search ops.', - '- The encryption client is exported from the path in `context.json`.', - '- Any new env keys are listed in `.env.example`, and the user knows which values to add to their local `.env`.', - '- Drizzle / Supabase / Postgres-specific wiring (per the section above) is in place.', - '- Migrations have been generated but not applied — the user runs them.', - '', - ].join('\n') -} - -export const CLAUDE_SKILL_NAME = SKILL_NAME diff --git a/packages/cli/src/rulebook/renderers/gateway.ts b/packages/cli/src/rulebook/renderers/gateway.ts deleted file mode 100644 index 7e2d41d4..00000000 --- a/packages/cli/src/rulebook/renderers/gateway.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Integration } from '../../commands/init/types.js' -import { loadCorePartial, loadIntegrationPartial } from '../partials.js' -import { RULEBOOK_VERSION } from '../version.js' - -export interface GatewayPromptContext { - integration: Integration -} - -/** - * Render the prompt body served by `POST /v1/wizard/prompt` on the gateway. - * - * The gateway and the CLI both consume this so the in-house wizard and any - * external-agent handoff produce the same setup outcome. Format mirrors the - * existing `apps/wizard/src/prompts.ts` shape: a header, the core rules, then - * the integration-specific rules. - */ -export function renderGatewayPrompt(ctx: GatewayPromptContext): string { - const core = loadCorePartial() - const integration = loadIntegrationPartial(ctx.integration) - return [ - '# CipherStash setup wizard', - '', - `Rulebook version: ${RULEBOOK_VERSION}`, - `Integration: ${ctx.integration}`, - '', - core.trim(), - '', - integration.trim(), - '', - ].join('\n') -} diff --git a/packages/cli/src/rulebook/version.ts b/packages/cli/src/rulebook/version.ts deleted file mode 100644 index b38a0e46..00000000 --- a/packages/cli/src/rulebook/version.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Single source of truth for the rulebook content version. - * - * Bump this whenever any partial under `src/rulebook/partials/` changes in a - * way that should invalidate previously-installed project skills. The CLI - * writes this value into `.cipherstash/context.json` and into the installed - * skill body so future runs can detect drift. - * - * Format: `YYYY-MM-DD-` for easy human ordering. Letter resets per day. - */ -export const RULEBOOK_VERSION = '2026-05-01-a' diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 9b832a9b..0a85deea 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,4 +1,4 @@ -import { cpSync } from 'node:fs' +import { cpSync, existsSync } from 'node:fs' import { defineConfig } from 'tsup' export default defineConfig([ @@ -21,10 +21,18 @@ export default defineConfig([ onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package cpSync('src/sql', 'dist/sql', { recursive: true }) - // Copy rulebook partials. The runtime resolver in - // src/rulebook/partials.ts looks for a sibling `partials/` directory, - // so we mirror the source layout under `dist/rulebook/`. - cpSync('src/rulebook/partials', 'dist/rulebook/partials', { + // Skills live at the monorepo root and ship inside the CLI tarball so + // `stash init` can copy them into the user's `.claude/skills/` or + // `.codex/skills/` directory at handoff time. Mirror of + // packages/wizard/tsup.config.ts:24. + if (existsSync('../../skills')) { + cpSync('../../skills', 'dist/skills', { recursive: true }) + } + // The AGENTS.md doctrine fragment is read at handoff time and + // wrapped in a sentinel block. The runtime resolver in + // src/commands/init/lib/build-agents-md.ts walks up looking for a + // sibling `doctrine/` dir, so mirror the source layout under dist. + cpSync('src/commands/init/doctrine', 'dist/commands/init/doctrine', { recursive: true, }) }, From bd0b6e4f2024de53a9178d14737708f16dfb9c85 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 4 May 2026 09:48:37 +1000 Subject: [PATCH 08/10] fix(cli): address CodeRabbit findings on init handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - introspect.ts: set pg.Client connectionTimeoutMillis to 10s. The default is no timeout, so an unreachable / firewalled DB hung the spinner indefinitely. - introspect.ts: dedupe table list across the buildSchemasFromDatabase loop iterations. Previously the user could pick the same table twice and we'd push two SchemaDef entries for it, causing duplicate encrypted column declarations downstream. Skip the redundant "another table?" prompt when no tables remain. - install-eql.ts: don't echo the underlying error message on EQL install failure — Postgres client errors routinely embed the connection string (with credentials) and state.databaseUrl flows into this code path. - build-agents-md.ts: dedupe the readDoctrine candidate path list (two entries resolved to the same path). Add a deeper fallback for flattened tsup chunk layouts. - setup-prompt.ts: rulesPointer no longer emits "the skills loaded into …" (double space, no names) when installedSkills is empty. Falls back to a generic pointer. New test locks this in. - README.md: refresh the four-handoff descriptions — the Claude line still referenced the old `cipherstash-setup` SKILL.md, the Codex line omitted the `.codex/skills/` artifact and AGENTS.md doctrine, the AGENTS.md line undersold the inlined skill content. --- packages/cli/README.md | 6 ++--- .../init/lib/__tests__/setup-prompt.test.ts | 21 ++++++++++++++++++ .../src/commands/init/lib/build-agents-md.ts | 10 ++++++--- .../cli/src/commands/init/lib/introspect.ts | 22 +++++++++++++++++-- .../cli/src/commands/init/lib/setup-prompt.ts | 15 ++++++++++--- .../src/commands/init/steps/install-eql.ts | 10 ++++++--- 6 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 7cdbac9e..ba63754f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,10 +17,10 @@ npx stash init # scaffold, introspect, install EQL, hand off to your ag `stash init` runs the whole setup as one flow: authenticate, resolve `DATABASE_URL`, introspect your database and let you pick which columns to encrypt, install dependencies, install the EQL extension, and finish by handing off to your local coding agent. At the end it presents a four-option menu: -- **Hand off to Claude Code** — installs a project-local skill at `.claude/skills/cipherstash-setup/SKILL.md`, then launches `claude` interactively. -- **Hand off to Codex** — writes `AGENTS.md` at the project root, then launches `codex`. +- **Hand off to Claude Code** — copies the per-integration set of skills (`stash-encryption`, `stash-`, `stash-cli`) into `.claude/skills/`, writes `.cipherstash/context.json` and `setup-prompt.md`, then launches `claude` interactively. +- **Hand off to Codex** — copies the same skills into `.codex/skills/`, writes a sentinel-managed `AGENTS.md` (durable doctrine), plus `.cipherstash/` context files, then launches `codex`. - **Use the CipherStash Agent** — runs the in-house wizard (`@cipherstash/wizard`). -- **Write AGENTS.md** — writes the rules file and stops, for Cursor / Windsurf / Cline / any AGENTS.md-aware tool. +- **Write AGENTS.md** — for editor agents (Cursor / Windsurf / Cline) that don't auto-load skill directories. Writes a single `AGENTS.md` with the doctrine *plus* the relevant skill content inlined under a sentinel block, and stops. A project-specific action plan is written to `.cipherstash/setup-prompt.md` regardless of which option you pick — it tells the agent exactly what's already done and what's left, with the right commands for your package manager and ORM. The matching context (selected columns, env keys, paths, versions) is at `.cipherstash/context.json`. diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index a9acf3dc..0dfd2412 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -87,4 +87,25 @@ describe('renderSetupPrompt', () => { expect(agents).not.toContain('.claude/skills/') expect(agents).not.toContain('.codex/skills/') }) + + it('falls back to a generic pointer when no skills were installed', () => { + // Defensive case — when bundled skills are missing, installSkills + // returns []. The rules pointer must not emit "the skills loaded + // into …" (double space, no names). + const claude = renderSetupPrompt({ + ...baseCtx, + handoff: 'claude-code', + installedSkills: [], + }) + const codex = renderSetupPrompt({ + ...baseCtx, + handoff: 'codex', + installedSkills: [], + }) + + expect(claude).not.toMatch(/the {2,}skill/) + expect(claude).toContain('.claude/skills/') + expect(codex).not.toMatch(/the {2,}skill/) + expect(codex).toContain('.codex/skills/') + }) }) diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts index 692a605c..c3169fb2 100644 --- a/packages/cli/src/commands/init/lib/build-agents-md.ts +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -94,12 +94,16 @@ function stripFrontmatter(body: string): string { function readDoctrine(): string | undefined { const here = currentDir() const candidates = [ + // Layout-preserving: same directory as the compiled lib file. join(here, 'doctrine', 'AGENTS-doctrine.md'), + // Dev / preserved-layout build: sibling of `lib/`. join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), - // Dev: running from `packages/cli/src/commands/init/lib/` — sibling dir. - join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), - // Prod: dist may flatten or preserve layout. + // Prod with shallow flattening (e.g. tsup chunk dir). join(here, '..', '..', 'doctrine', 'AGENTS-doctrine.md'), + // Prod with deeper flattening — `dist/bin/` calling back into init. + join(here, '..', '..', '..', 'doctrine', 'AGENTS-doctrine.md'), + // Final fallback: walk further up. Costs ~1ms of stat calls; harmless. + join(here, '..', '..', '..', '..', 'doctrine', 'AGENTS-doctrine.md'), ] for (const candidate of candidates) { if (existsSync(candidate)) return readFileSync(resolve(candidate), 'utf-8') diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 8ab8eb0f..2f81dc65 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -52,7 +52,14 @@ export function pgTypeToDataType(udtName: string): DataType { export async function introspectDatabase( databaseUrl: string, ): Promise { - const client = new pg.Client({ connectionString: databaseUrl }) + // pg.Client defaults `connectionTimeoutMillis` to "no timeout"; without + // this, an unreachable / firewalled database silently hangs the spinner + // until the user kills the process. 10 s is generous for healthy hosts + // and short enough to surface a real failure quickly. + const client = new pg.Client({ + connectionString: databaseUrl, + connectionTimeoutMillis: 10_000, + }) try { await client.connect() @@ -212,13 +219,24 @@ export async function buildSchemasFromDatabase( ) const schemas: SchemaDef[] = [] + // Track names already configured this run so we never offer the same + // table twice — picking it again would push a duplicate `SchemaDef` and + // emit duplicate encrypted-column declarations downstream. + const alreadySelected = new Set() while (true) { - const schema = await selectTableColumns(tables) + const remaining = tables.filter((t) => !alreadySelected.has(t.tableName)) + if (remaining.length === 0) break + + const schema = await selectTableColumns(remaining) if (!schema) return undefined + alreadySelected.add(schema.tableName) schemas.push(schema) + // No tables left after this one — skip the redundant "another?" prompt. + if (alreadySelected.size === tables.length) break + const addMore = await p.confirm({ message: 'Encrypt columns in another table?', initialValue: false, diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index e43a4765..26f7fd3e 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -99,13 +99,22 @@ function rulesPointer( handoff: HandoffChoice, installedSkills: string[], ): string { - const skillNames = installedSkills.length - ? installedSkills.map((s) => `\`${s}\``).join(', ') - : '' + // Empty `installedSkills` means the bundled skills were missing at install + // time (`installSkills` warned and returned []). Avoid emitting the broken + // "the skills loaded into …" string by falling back to a generic + // pointer that doesn't try to enumerate. if (handoff === 'claude-code') { + if (installedSkills.length === 0) { + return 'the installed skills under `.claude/skills/`' + } + const skillNames = installedSkills.map((s) => `\`${s}\``).join(', ') return `the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.claude/skills/\`` } if (handoff === 'codex') { + if (installedSkills.length === 0) { + return '`AGENTS.md` (durable rules) + the installed skills under `.codex/skills/`' + } + const skillNames = installedSkills.map((s) => `\`${s}\``).join(', ') return `\`AGENTS.md\` (durable rules) + the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.codex/skills/\`` } return 'the `AGENTS.md` at the project root' diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 9ae2de13..e1bf3cd7 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -70,9 +70,13 @@ export const installEqlStep: InitStep = { drizzle: drizzle || undefined, databaseUrl: state.databaseUrl, }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - p.log.error(`EQL install failed: ${message}`) + } catch { + // Don't echo the underlying error — Postgres client errors routinely + // include the connection string (with credentials) in the message, + // and `state.databaseUrl` flows into this code path. + p.log.error( + 'EQL install failed — check your database connection and try again.', + ) p.note('Re-run with: stash db install', 'You can retry manually') return { ...state, eqlInstalled: false } } From bd635f8fd4acd4829c3c79afb6b0ea0aaed34638 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 4 May 2026 10:01:21 +1000 Subject: [PATCH 09/10] refactor(cli): simplify init handoff after multi-agent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - Drop `dynamodb` from SKILL_MAP — `Integration` doesn't include it, so the entry was a TypeScript error that tsup didn't catch (it doesn't typecheck non-entry files). Also drops the orphaned tests. The key comes back when DynamoDB integration detection lands. - Rename `_provider` to `provider` in resolve-database.ts; the param is used at line 26 — the underscore prefix was misleading. Reuse: - New `lib/bundled-paths.ts` with `findBundledDir(name)` — replaces the near-identical candidate-walking in `install-skills.ts` and `build-agents-md.ts`. Memoized so the bundled-root probe runs at most once per directory name (matters for the AGENTS.md inline-skills mode that calls it per skill). - New `lib/handoff-helpers.ts`: - `spawnAgent('claude' | 'codex', prompt)` — replaces the two near-identical `spawnClaude` / `spawnCodex` wrappers. - `writeArtifacts(cwd, state, handoff, installedSkills)` — folds the repeated context.json + setup-prompt.md write block from the three non-wizard handoff steps into one place. Efficiency: - `detectPackageManager` is now memoized per (cwd, user-agent) pair. It's called multiple times per init run plus on every other CLI command, and each call did up to 4 fs.existsSync probes. Cache key preserves test isolation under `vi.spyOn(process, 'cwd')`. - `readCliVersion` is memoized — single-slot, since the answer is fixed for the lifetime of the process. Cleanup: - Drop two narrating comments that just restated the next line of code (the quote-escaping one in handoff-claude, the no-skill-directory one in handoff-agents-md). Tighten the empty- installedSkills comment in setup-prompt to one line. Net: -134 lines, all tests + biome clean. --- .../lib/__tests__/build-agents-md.test.ts | 1 - .../init/lib/__tests__/install-skills.test.ts | 5 -- .../src/commands/init/lib/build-agents-md.ts | 38 ++---------- .../src/commands/init/lib/bundled-paths.ts | 50 +++++++++++++++ .../src/commands/init/lib/handoff-helpers.ts | 61 +++++++++++++++++++ .../src/commands/init/lib/install-skills.ts | 48 ++------------- .../cli/src/commands/init/lib/setup-prompt.ts | 6 +- .../src/commands/init/lib/write-context.ts | 13 +++- .../commands/init/steps/handoff-agents-md.ts | 21 +------ .../src/commands/init/steps/handoff-claude.ts | 50 +-------------- .../src/commands/init/steps/handoff-codex.ts | 33 +--------- .../commands/init/steps/resolve-database.ts | 4 +- packages/cli/src/commands/init/utils.ts | 33 +++++++--- 13 files changed, 170 insertions(+), 193 deletions(-) create mode 100644 packages/cli/src/commands/init/lib/bundled-paths.ts create mode 100644 packages/cli/src/commands/init/lib/handoff-helpers.ts diff --git a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts index e4ad76e2..90c2d7e7 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts @@ -50,6 +50,5 @@ describe('buildAgentsMdBody', () => { expect(out).toContain('# Skill: stash-cli') expect(out).not.toContain('# Skill: stash-drizzle') expect(out).not.toContain('# Skill: stash-supabase') - expect(out).not.toContain('# Skill: stash-dynamodb') }) }) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index 362cec38..792fcb8a 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -24,14 +24,9 @@ describe('SKILL_MAP', () => { expect(SKILL_MAP.supabase).toContain('stash-supabase') }) - it('dynamodb includes stash-dynamodb', () => { - expect(SKILL_MAP.dynamodb).toContain('stash-dynamodb') - }) - it('postgresql skips ORM-specific skills', () => { expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle') expect(SKILL_MAP.postgresql).not.toContain('stash-supabase') - expect(SKILL_MAP.postgresql).not.toContain('stash-dynamodb') }) }) diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts index c3169fb2..a5cfa016 100644 --- a/packages/cli/src/commands/init/lib/build-agents-md.ts +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -1,8 +1,8 @@ import { existsSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join } from 'node:path' import * as p from '@clack/prompts' import type { Integration } from '../types.js' +import { findBundledDir } from './bundled-paths.js' import { SKILL_MAP, readBundledSkill } from './install-skills.js' /** Sentinel pair so re-runs replace only our region in the user's file. */ @@ -85,35 +85,9 @@ function stripFrontmatter(body: string): string { return body.slice(end + 4).trim() } -/** - * Locate and read the bundled doctrine markdown. `tsup` copies - * `src/commands/init/doctrine/` into `dist/commands/init/doctrine/` at - * build time. Multi-layout fallback mirrors `install-skills.ts` so dev - * (running from `src/`) and prod builds both find the file. - */ function readDoctrine(): string | undefined { - const here = currentDir() - const candidates = [ - // Layout-preserving: same directory as the compiled lib file. - join(here, 'doctrine', 'AGENTS-doctrine.md'), - // Dev / preserved-layout build: sibling of `lib/`. - join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), - // Prod with shallow flattening (e.g. tsup chunk dir). - join(here, '..', '..', 'doctrine', 'AGENTS-doctrine.md'), - // Prod with deeper flattening — `dist/bin/` calling back into init. - join(here, '..', '..', '..', 'doctrine', 'AGENTS-doctrine.md'), - // Final fallback: walk further up. Costs ~1ms of stat calls; harmless. - join(here, '..', '..', '..', '..', 'doctrine', 'AGENTS-doctrine.md'), - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return readFileSync(resolve(candidate), 'utf-8') - } - return undefined -} - -function currentDir(): string { - if (typeof import.meta?.url === 'string' && import.meta.url) { - return dirname(fileURLToPath(import.meta.url)) - } - return __dirname + const dir = findBundledDir('doctrine') + if (!dir) return undefined + const file = join(dir, 'AGENTS-doctrine.md') + return existsSync(file) ? readFileSync(file, 'utf-8') : undefined } diff --git a/packages/cli/src/commands/init/lib/bundled-paths.ts b/packages/cli/src/commands/init/lib/bundled-paths.ts new file mode 100644 index 00000000..559df617 --- /dev/null +++ b/packages/cli/src/commands/init/lib/bundled-paths.ts @@ -0,0 +1,50 @@ +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Walk up from the running file looking for a `` directory bundled + * with the CLI. `tsup` may flatten or preserve the source layout depending + * on entry / chunking, so we probe several parent depths until one matches. + * + * Returns the absolute directory path, or `undefined` if nothing matched — + * callers warn-and-skip rather than crash so a stripped CLI build still + * produces something usable. + * + * Memoized per directory name; the bundled layout doesn't change at + * runtime, so we only pay the stat calls once. + */ +const cache = new Map() + +export function findBundledDir(name: string): string | undefined { + const cached = cache.get(name) + if (cached !== undefined || cache.has(name)) return cached + + const here = currentDir() + const candidates = [ + join(here, name), + join(here, '..', name), + join(here, '..', '..', name), + join(here, '..', '..', '..', name), + join(here, '..', '..', '..', '..', name), + // Dev fallback: running from `packages/cli/src/commands/init/lib/`, + // the monorepo `/` is six levels up. + join(here, '..', '..', '..', '..', '..', '..', name), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + const resolved = resolve(candidate) + cache.set(name, resolved) + return resolved + } + } + cache.set(name, undefined) + return undefined +} + +function currentDir(): string { + if (typeof import.meta?.url === 'string' && import.meta.url) { + return dirname(fileURLToPath(import.meta.url)) + } + return __dirname +} diff --git a/packages/cli/src/commands/init/lib/handoff-helpers.ts b/packages/cli/src/commands/init/lib/handoff-helpers.ts new file mode 100644 index 00000000..45b2e96e --- /dev/null +++ b/packages/cli/src/commands/init/lib/handoff-helpers.ts @@ -0,0 +1,61 @@ +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import type { HandoffChoice, InitState } from '../types.js' +import { + CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, + buildContextFile, + buildSetupPromptContext, + writeContextFile, + writeSetupPrompt, +} from './write-context.js' + +/** + * Spawn an interactive CLI agent (`claude` / `codex`) with the launch + * prompt as a single argument. `stdio: 'inherit'` so the user sees tool + * calls and approves edits live; the call resolves with the exit code. + * + * Returns -1 if the binary isn't on PATH (the spawn `error` event fires + * before `close` does). Init never aborts on a non-zero code — the + * artifacts are already written, the user can re-run the agent. + */ +export function spawnAgent( + binary: 'claude' | 'codex', + prompt: string, +): Promise { + return new Promise((resolvePromise) => { + const child = spawn(binary, [prompt], { stdio: 'inherit', shell: false }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', () => resolvePromise(-1)) + }) +} + +/** + * Write `.cipherstash/context.json` and `.cipherstash/setup-prompt.md` for + * a non-wizard handoff. Shared across the Claude / Codex / AGENTS.md + * paths, which all need the same artifacts with handoff-specific values + * threaded into the setup prompt. + * + * `installedSkills` is the list of skill names the handoff installed (or + * `[]` for the AGENTS.md path that inlines content instead of installing + * a skill directory). + */ +export function writeArtifacts( + cwd: string, + state: InitState, + handoff: HandoffChoice, + installedSkills: string[], +): void { + const ctx = buildContextFile(state) + ctx.envKeys = state.envKeys ?? [] + ctx.installedSkills = installedSkills + writeContextFile(resolve(cwd, CONTEXT_REL_PATH), ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + const promptCtx = buildSetupPromptContext(state, handoff, installedSkills) + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } +} diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts index 967dfcde..ce368e11 100644 --- a/packages/cli/src/commands/init/lib/install-skills.ts +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -1,25 +1,19 @@ import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join, resolve } from 'node:path' import * as p from '@clack/prompts' import type { Integration } from '../types.js' +import { findBundledDir } from './bundled-paths.js' /** * Per-integration set of skills to install. The skills themselves live at * the monorepo root in `/skills//SKILL.md` and ship inside the CLI * tarball — see `tsup.config.ts`, which copies the directory into * `dist/skills/` at build time. - * - * Mirror of the wizard's `SKILL_MAP` (packages/wizard/src/lib/install-skills.ts) - * extended with `postgresql` and `dynamodb` so init can hand off to those - * stacks too. Wizard keeps its own copy because it has its own `Integration` - * union (no `dynamodb`); merging is a separate cleanup. */ export const SKILL_MAP: Record = { drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'], supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'], postgresql: ['stash-encryption', 'stash-cli'], - dynamodb: ['stash-encryption', 'stash-dynamodb', 'stash-cli'], } /** @@ -41,7 +35,7 @@ export function installSkills( integration: Integration, ): string[] { const skills = SKILL_MAP[integration] - const bundledRoot = resolveBundledSkillsRoot() + const bundledRoot = findBundledDir('skills') if (!bundledRoot) { p.log.warn( 'Skills bundle not found in this CLI build — skipping skills install.', @@ -81,43 +75,9 @@ export function installSkills( * a fatal error so a stripped CLI build still produces a usable AGENTS.md. */ export function readBundledSkill(name: string): string | undefined { - const bundledRoot = resolveBundledSkillsRoot() + const bundledRoot = findBundledDir('skills') if (!bundledRoot) return undefined const skillFile = join(bundledRoot, name, 'SKILL.md') if (!existsSync(skillFile)) return undefined return readFileSync(skillFile, 'utf-8') } - -/** - * Locate the `skills/` directory bundled with this CLI. `tsup` copies the - * monorepo's top-level `skills/` into `dist/skills/`, so the build sits - * alongside the compiled binary regardless of where pnpm/npm installs it. - * - * Walks up from the current file looking for a sibling `skills` dir so - * both the library entry (`dist/index.js`) and the CLI entry - * (`dist/bin/stash.js`) can find it. In dev (running from `src/`) we also - * fall back to the monorepo root. - */ -function resolveBundledSkillsRoot(): string | undefined { - const here = currentDir() - const candidates = [ - join(here, 'skills'), - join(here, '..', 'skills'), - join(here, '..', '..', 'skills'), - join(here, '..', '..', '..', 'skills'), - // Dev fallback: when running from `packages/cli/src/commands/init/lib/`, - // the monorepo's `skills/` is six levels up. - join(here, '..', '..', '..', '..', '..', '..', 'skills'), - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return resolve(candidate) - } - return undefined -} - -function currentDir(): string { - if (typeof import.meta?.url === 'string' && import.meta.url) { - return dirname(fileURLToPath(import.meta.url)) - } - return __dirname -} diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 26f7fd3e..82094fc7 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -99,10 +99,8 @@ function rulesPointer( handoff: HandoffChoice, installedSkills: string[], ): string { - // Empty `installedSkills` means the bundled skills were missing at install - // time (`installSkills` warned and returned []). Avoid emitting the broken - // "the skills loaded into …" string by falling back to a generic - // pointer that doesn't try to enumerate. + // Empty list = bundled skills missing at install time. Skip the + // enumeration so we don't emit "the skills loaded into …". if (handoff === 'claude-code') { if (installedSkills.length === 0) { return 'the installed skills under `.claude/skills/`' diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 20ff4836..15f6c928 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -41,8 +41,13 @@ export interface ContextFile { * lives at `dist/index.js` (or similar) and the source at * `src/commands/init/lib/write-context.ts`, so we walk up to six levels. * Falling back to `'unknown'` is fine — the field is informational. + * + * Memoized: the answer is fixed for the lifetime of the process. */ +let cliVersionCache: string | undefined + export function readCliVersion(): string { + if (cliVersionCache !== undefined) return cliVersionCache let dir = dirname(fileURLToPath(import.meta.url)) for (let i = 0; i < 6; i++) { const candidate = resolve(dir, 'package.json') @@ -52,14 +57,18 @@ export function readCliVersion(): string { name?: string version?: string } - if (pkg.name === 'stash' && pkg.version) return pkg.version + if (pkg.name === 'stash' && pkg.version) { + cliVersionCache = pkg.version + return pkg.version + } } catch { // keep walking } } dir = dirname(dir) } - return 'unknown' + cliVersionCache = 'unknown' + return cliVersionCache } function ensureDir(path: string): void { diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts index 9a4660f1..281112c3 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -2,14 +2,11 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { writeArtifacts } from '../lib/handoff-helpers.js' import { upsertManagedBlock } from '../lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, - buildContextFile, - buildSetupPromptContext, - writeContextFile, - writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' @@ -34,7 +31,6 @@ export const handoffAgentsMdStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const envKeys = state.envKeys ?? [] const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) const managed = buildAgentsMdBody(integration, 'doctrine-plus-skills') @@ -48,20 +44,7 @@ export const handoffAgentsMdStep: InitStep = { ) p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) - const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state) - ctx.envKeys = envKeys - // No skill directory installed for editor-agent users; the rules are - // inlined directly into AGENTS.md. - ctx.installedSkills = [] - writeContextFile(contextAbs, ctx) - p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - - const promptCtx = buildSetupPromptContext(state, 'agents-md', []) - if (promptCtx) { - writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) - p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) - } + writeArtifacts(cwd, state, 'agents-md', []) p.note( [ diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index ddaf21f6..881b2aa1 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -1,14 +1,9 @@ -import { spawn } from 'node:child_process' -import { resolve } from 'node:path' import * as p from '@clack/prompts' +import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' import { installSkills } from '../lib/install-skills.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, - buildContextFile, - buildSetupPromptContext, - writeContextFile, - writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' @@ -17,36 +12,12 @@ const CLAUDE_SKILLS_DIR = '.claude/skills' const CLAUDE_INSTALL_URL = 'https://docs.claude.com/en/docs/claude-code/quickstart' -/** - * Spawn `claude` interactively in the user's terminal so they can watch tool - * calls and approve edits. We attach stdio to inherit; this step blocks until - * the user exits Claude Code. - * - * Returns the exit code — 0 means the user finished the session normally, - * non-zero means `claude` crashed or was interrupted. We don't fail init - * either way: the artifacts are already written, the user can re-run claude. - */ -function spawnClaude(prompt: string): Promise { - return new Promise((resolvePromise) => { - const child = spawn('claude', [prompt], { - stdio: 'inherit', - shell: false, - }) - child.on('close', (code) => resolvePromise(code ?? 0)) - child.on('error', () => resolvePromise(-1)) - }) -} - /** * Hand off to Claude Code: copy the per-integration set of skills into * `.claude/skills/`, write `.cipherstash/context.json` and * `.cipherstash/setup-prompt.md`, then spawn `claude`. If `claude` is not * on PATH we still write the artifacts and print install + manual-launch * instructions. - * - * The launch prompt points the agent at `setup-prompt.md` first — that's - * the project-specific action plan. Claude auto-loads the installed skills - * for the durable rules and API references. */ export const handoffClaudeStep: InitStep = { id: 'handoff-claude', @@ -54,7 +25,6 @@ export const handoffClaudeStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const envKeys = state.envKeys ?? [] const installed = installSkills(cwd, CLAUDE_SKILLS_DIR, integration) if (installed.length > 0) { @@ -63,18 +33,7 @@ export const handoffClaudeStep: InitStep = { ) } - const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state) - ctx.envKeys = envKeys - ctx.installedSkills = installed - writeContextFile(contextAbs, ctx) - p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - - const promptCtx = buildSetupPromptContext(state, 'claude-code', installed) - if (promptCtx) { - writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) - p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) - } + writeArtifacts(cwd, state, 'claude-code', installed) const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` @@ -85,9 +44,6 @@ export const handoffClaudeStep: InitStep = { `Install: ${CLAUDE_INSTALL_URL}`, '', 'Once installed, run:', - // Single-quote the prompt for the printed example. The launchPrompt - // is a closed-form string we control, but printing it inside double - // quotes would break if any path inside ever contained a quote. ` claude '${launchPrompt}'`, ].join('\n'), 'Files written — install Claude Code to run the handoff', @@ -96,7 +52,7 @@ export const handoffClaudeStep: InitStep = { } p.log.info('Launching Claude Code...') - const exitCode = await spawnClaude(launchPrompt) + const exitCode = await spawnAgent('claude', launchPrompt) if (exitCode !== 0) { p.log.warn( `Claude Code exited with code ${exitCode}. Re-run \`claude '${launchPrompt}'\` to resume.`, diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts index a1b58e57..dd243d40 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -1,17 +1,13 @@ -import { spawn } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' import { installSkills } from '../lib/install-skills.js' import { upsertManagedBlock } from '../lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, - buildContextFile, - buildSetupPromptContext, - writeContextFile, - writeSetupPrompt, } from '../lib/write-context.js' import type { InitProvider, InitState, InitStep } from '../types.js' @@ -20,17 +16,6 @@ const CODEX_SKILLS_DIR = '.codex/skills' const CODEX_INSTALL_URL = 'https://github.com/openai/codex' -function spawnCodex(prompt: string): Promise { - return new Promise((resolvePromise) => { - const child = spawn('codex', [prompt], { - stdio: 'inherit', - shell: false, - }) - child.on('close', (code) => resolvePromise(code ?? 0)) - child.on('error', () => resolvePromise(-1)) - }) -} - /** * Hand off to Codex CLI. Following OpenAI's Codex guidance, AGENTS.md * holds durable doctrine ("never log plaintext", "encrypted columns are @@ -46,7 +31,6 @@ export const handoffCodexStep: InitStep = { async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' - const envKeys = state.envKeys ?? [] const installed = installSkills(cwd, CODEX_SKILLS_DIR, integration) if (installed.length > 0) { @@ -67,18 +51,7 @@ export const handoffCodexStep: InitStep = { ) p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) - const contextAbs = resolve(cwd, CONTEXT_REL_PATH) - const ctx = buildContextFile(state) - ctx.envKeys = envKeys - ctx.installedSkills = installed - writeContextFile(contextAbs, ctx) - p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - - const promptCtx = buildSetupPromptContext(state, 'codex', installed) - if (promptCtx) { - writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) - p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) - } + writeArtifacts(cwd, state, 'codex', installed) const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` @@ -97,7 +70,7 @@ export const handoffCodexStep: InitStep = { } p.log.info('Launching Codex...') - const exitCode = await spawnCodex(launchPrompt) + const exitCode = await spawnAgent('codex', launchPrompt) if (exitCode !== 0) { p.log.warn( `Codex exited with code ${exitCode}. Re-run \`codex '${launchPrompt}'\` to resume.`, diff --git a/packages/cli/src/commands/init/steps/resolve-database.ts b/packages/cli/src/commands/init/steps/resolve-database.ts index 72df5a90..2c53cbb4 100644 --- a/packages/cli/src/commands/init/steps/resolve-database.ts +++ b/packages/cli/src/commands/init/steps/resolve-database.ts @@ -19,11 +19,11 @@ import type { InitProvider, InitState, InitStep } from '../types.js' export const resolveDatabaseStep: InitStep = { id: 'resolve-database', name: 'Resolve database URL', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, provider: InitProvider): Promise { // The provider name carries the integration flag the user passed at the // CLI (`--supabase` → 'supabase'), which lets the resolver try // `supabase status` even before we've inspected the project layout. - const supabaseHint = _provider.name === 'supabase' + const supabaseHint = provider.name === 'supabase' const databaseUrl = await resolveDatabaseUrl({ supabase: supabaseHint }) return { ...state, databaseUrl } }, diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 403c53f2..0b0d4547 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -49,20 +49,39 @@ function packageManagerFromUserAgent(): PackageManager | undefined { * without a matching lockfile (e.g. fresh projects). * 2. Lockfile in cwd — respects the existing project convention. * 3. Default to `npm`. + * + * Cached per (cwd, user-agent) pair: the same CLI process never changes + * either, but tests vary both via `vi.spyOn(process, 'cwd')` so the cache + * has to be input-keyed rather than a single slot. */ +const pmCache = new Map() + export function detectPackageManager(): PackageManager { + const cwd = process.cwd() + const ua = process.env.npm_config_user_agent ?? '' + const cacheKey = `${cwd}\n${ua}` + const cached = pmCache.get(cacheKey) + if (cached) return cached + const fromUserAgent = packageManagerFromUserAgent() - if (fromUserAgent) return fromUserAgent + if (fromUserAgent) { + pmCache.set(cacheKey, fromUserAgent) + return fromUserAgent + } - const cwd = process.cwd() + let pm: PackageManager = 'npm' if ( existsSync(resolve(cwd, 'bun.lockb')) || existsSync(resolve(cwd, 'bun.lock')) - ) - return 'bun' - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' - return 'npm' + ) { + pm = 'bun' + } else if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) { + pm = 'pnpm' + } else if (existsSync(resolve(cwd, 'yarn.lock'))) { + pm = 'yarn' + } + pmCache.set(cacheKey, pm) + return pm } /** Returns the install command for adding a production dependency with the given package manager. */ From 4310f021e7041b71c37ce744737d0fbe9f470632 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 4 May 2026 11:04:09 +1000 Subject: [PATCH 10/10] fix(cli): address Toby's review feedback on init handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md double-sentinel bug: buildAgentsMdBody was returning sentinel-wrapped content, which upsertManagedBlock then wrapped again. First run produced an invalid file; second run threw "malformed sentinel". Strip sentinels from the body builder; upsertManagedBlock is the single owner of the sentinel pair. - build-schema "keep existing file" branch: previously short-circuited to placeholder schemas regardless of what the database actually has. Now runs introspection in the keep-branch too so context.json reflects the live DB; codegen is the only thing skipped. - readEnvKeyNames moved from steps/gather-context.ts to lib/env-keys.ts. build-schema importing across step boundaries was a layering smell. - install-deps: per-package tracking. Re-check isPackageInstalled after the install commands instead of inferring stack/cli state from a composite allSucceeded flag — partial success was previously marked as full failure. - Claude install URL redirect: docs.claude.com/en/docs/claude-code/quickstart → code.claude.com/docs/en/quickstart. - package.json: drop redundant dist/sql and dist/rulebook from the files array. dist/ already covers them; dist/rulebook is dead since the rulebook package was deleted. - Changeset: drop the dynamodb row from the per-integration table (SKILL_MAP no longer includes it as of the simplify pass) and add a `text` language tag to the fenced block. --- .changeset/cli-init-agent-handoff.md | 7 +- packages/cli/package.json | 2 - .../lib/__tests__/build-agents-md.test.ts | 12 +-- .../src/commands/init/lib/build-agents-md.ts | 34 +++------ .../cli/src/commands/init/lib/env-keys.ts | 39 ++++++++++ .../src/commands/init/steps/build-schema.ts | 73 +++++++++---------- .../src/commands/init/steps/gather-context.ts | 40 ---------- .../src/commands/init/steps/handoff-claude.ts | 3 +- .../src/commands/init/steps/install-deps.ts | 25 ++++--- 9 files changed, 111 insertions(+), 124 deletions(-) create mode 100644 packages/cli/src/commands/init/lib/env-keys.ts diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md index 513248f1..f698fd23 100644 --- a/.changeset/cli-init-agent-handoff.md +++ b/.changeset/cli-init-agent-handoff.md @@ -23,11 +23,10 @@ Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't i Per-integration skill subset: -``` -drizzle → stash-encryption + stash-drizzle + stash-cli -supabase → stash-encryption + stash-supabase + stash-cli +```text +drizzle → stash-encryption + stash-drizzle + stash-cli +supabase → stash-encryption + stash-supabase + stash-cli postgresql → stash-encryption + stash-cli -dynamodb → stash-encryption + stash-dynamodb + stash-cli ``` The skills themselves are the authored ones at the repo root (`/skills/`); they ship inside the CLI tarball via `tsup` so init can copy them locally without a network round-trip. The AGENTS.md doctrine fragment ships the same way. diff --git a/packages/cli/package.json b/packages/cli/package.json index 614a98af..cdf95f81 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,8 +6,6 @@ "author": "CipherStash ", "files": [ "dist", - "dist/sql", - "dist/rulebook", "README.md", "LICENSE", "CHANGELOG.md" diff --git a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts index 90c2d7e7..9aebdb34 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest' import { buildAgentsMdBody } from '../build-agents-md.js' -const SENTINEL_START = '' -const SENTINEL_END = '' - describe('buildAgentsMdBody', () => { - it('wraps the body in the rulebook sentinel pair', () => { + it('returns content WITHOUT sentinel wrappers (upsertManagedBlock owns those)', () => { + // Regression guard: if buildAgentsMdBody emits sentinels itself, + // upsertManagedBlock will wrap them again and produce a malformed + // file on the second init run. const out = buildAgentsMdBody('drizzle', 'doctrine-only') - expect(out.startsWith(SENTINEL_START)).toBe(true) - expect(out.trimEnd().endsWith(SENTINEL_END)).toBe(true) + expect(out).not.toContain('') + expect(out).not.toContain('') }) it('doctrine-only includes the durable doctrine but no skill content', () => { diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts index a5cfa016..7fee3c17 100644 --- a/packages/cli/src/commands/init/lib/build-agents-md.ts +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -5,15 +5,14 @@ import type { Integration } from '../types.js' import { findBundledDir } from './bundled-paths.js' import { SKILL_MAP, readBundledSkill } from './install-skills.js' -/** Sentinel pair so re-runs replace only our region in the user's file. */ -const SENTINEL_START = '' -const SENTINEL_END = '' - export type AgentsMdMode = 'doctrine-only' | 'doctrine-plus-skills' /** - * Render the managed body of `AGENTS.md` (the bit that goes inside the - * sentinel block — the caller is responsible for the upsert). + * Render the managed body of `AGENTS.md` (the bit that goes *inside* the + * sentinel block — the caller is responsible for the upsert via + * `upsertManagedBlock`, which owns the sentinel pair). This function must + * NOT emit sentinels itself or we get nested sentinels and a malformed + * file on the second init run. * * doctrine-only — the durable AGENTS.md doctrine file. Used by * the Codex handoff, where workflows live in @@ -35,40 +34,31 @@ export function buildAgentsMdBody( p.log.warn( 'AGENTS.md doctrine fragment not found in this CLI build — writing a minimal AGENTS.md.', ) - return [ - SENTINEL_START, - '', - '# CipherStash', - '', - 'See `.cipherstash/setup-prompt.md` for the action plan and the installed skills for the rules.', - '', - SENTINEL_END, - ].join('\n') + return '# CipherStash\n\nSee `.cipherstash/setup-prompt.md` for the action plan and the installed skills for the rules.' } - const parts: string[] = [SENTINEL_START, '', doctrine.trim(), ''] + const parts: string[] = [doctrine.trim()] if (mode === 'doctrine-plus-skills') { - const skillNames = SKILL_MAP[integration] const skillBodies: string[] = [] - for (const name of skillNames) { + for (const name of SKILL_MAP[integration]) { const body = readBundledSkill(name) if (body) { skillBodies.push(`---\n\n# Skill: ${name}\n\n${stripFrontmatter(body)}`) } } if (skillBodies.length > 0) { - parts.push('## Skill references', '') parts.push( + '', + '## Skill references', + '', 'These are the CipherStash skills that apply to this project. They contain the API details and patterns the rules above reference.', '', + skillBodies.join('\n\n'), ) - parts.push(skillBodies.join('\n\n')) - parts.push('') } } - parts.push(SENTINEL_END) return parts.join('\n') } diff --git a/packages/cli/src/commands/init/lib/env-keys.ts b/packages/cli/src/commands/init/lib/env-keys.ts new file mode 100644 index 00000000..089410b8 --- /dev/null +++ b/packages/cli/src/commands/init/lib/env-keys.ts @@ -0,0 +1,39 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * Names of env keys observed in the project's `.env*` files. We never read or + * propagate the values — only the names tell the agent which keys to expect. + * + * Lives in `lib/` so both `build-schema` (populates `state.envKeys` once at + * the start of the run) and `gather-context` (reads from state) can import + * without crossing step boundaries. + */ +export function readEnvKeyNames(cwd: string): string[] { + const candidates = [ + '.env', + '.env.local', + '.env.development', + '.env.development.local', + ] + const seen = new Set() + for (const file of candidates) { + const path = resolve(cwd, file) + if (!existsSync(path)) continue + let text: string + try { + text = readFileSync(path, 'utf-8') + } catch { + continue + } + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const key = trimmed.slice(0, eq).trim() + if (key) seen.add(key) + } + } + return Array.from(seen).sort() +} diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index e2b9ab01..807349d6 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' import { detectDrizzle, detectSupabase } from '../../db/detect.js' +import { readEnvKeyNames } from '../lib/env-keys.js' import { buildSchemasFromDatabase } from '../lib/introspect.js' import { writeBaselineContextFile } from '../lib/write-context.js' import type { @@ -17,7 +18,6 @@ import { generateClientFromSchemas, generatePlaceholderClient, } from '../utils.js' -import { readEnvKeyNames } from './gather-context.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' @@ -46,6 +46,7 @@ export const buildSchemaStep: InitStep = { const resolvedPath = resolve(cwd, clientFilePath) // Existing-file branch: silent overwrite is bad. Ask once. + let keepExisting = false if (existsSync(resolvedPath)) { const action = await p.select({ message: `${clientFilePath} already exists. What would you like to do?`, @@ -53,7 +54,7 @@ export const buildSchemaStep: InitStep = { { value: 'keep', label: 'Keep existing file', - hint: 'skip code generation', + hint: 'skip code generation, still record schema in context.json', }, { value: 'overwrite', label: 'Overwrite with new schema' }, ], @@ -61,54 +62,48 @@ export const buildSchemaStep: InitStep = { if (p.isCancel(action)) throw new CancelledError() - if (action === 'keep') { - p.log.info('Keeping existing encryption client file.') - return { - ...state, - clientFilePath, - schemaGenerated: false, - integration, - schemas: [PLACEHOLDER_SCHEMA], - schemaFromIntrospection: false, - } - } + keepExisting = action === 'keep' + if (keepExisting) p.log.info('Keeping existing encryption client file.') } // Try real introspection first. Falls through to placeholder for an // empty database, a connection error, or user cancellation at any prompt. + // We run introspection even when keeping the existing file so + // `context.json` reflects what's actually in the database — agents read + // those schemas to understand the project, not just to drive codegen. let introspected: SchemaDef[] | undefined if (state.databaseUrl) { introspected = await buildSchemasFromDatabase(state.databaseUrl) } - let fileContents: string - let recordedSchemas: SchemaDef[] - let fromIntrospection: boolean + const fromIntrospection = Boolean(introspected && introspected.length > 0) + const recordedSchemas: SchemaDef[] = fromIntrospection + ? (introspected as SchemaDef[]) + : [PLACEHOLDER_SCHEMA] - if (introspected && introspected.length > 0) { - fileContents = generateClientFromSchemas(integration, introspected) - recordedSchemas = introspected - fromIntrospection = true - } else { - p.log.info( - 'No tables found in the public schema — writing a placeholder client. The handoff prompt will note this so the agent reshapes it to your real schema.', - ) - fileContents = generatePlaceholderClient(integration) - recordedSchemas = [PLACEHOLDER_SCHEMA] - fromIntrospection = false - } + // When `keepExisting`, skip codegen but keep the introspected schemas + // on state so the handoff prompt + context.json reflect the live DB. + // The agent reconciles any divergence as part of its action plan. + if (!keepExisting) { + const fileContents = fromIntrospection + ? generateClientFromSchemas(integration, introspected as SchemaDef[]) + : generatePlaceholderClient(integration) - const dir = dirname(resolvedPath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } + if (!fromIntrospection) { + p.log.info( + 'No tables found in the public schema — writing a placeholder client. The handoff prompt will note this so the agent reshapes it to your real schema.', + ) + } - writeFileSync(resolvedPath, fileContents, 'utf-8') - p.log.success( - fromIntrospection - ? `Encryption client written to ${clientFilePath} (${integration}, ${recordedSchemas.length} table${recordedSchemas.length !== 1 ? 's' : ''} from introspection)` - : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, - ) + const dir = dirname(resolvedPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(resolvedPath, fileContents, 'utf-8') + p.log.success( + fromIntrospection + ? `Encryption client written to ${clientFilePath} (${integration}, ${recordedSchemas.length} table${recordedSchemas.length !== 1 ? 's' : ''} from introspection)` + : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, + ) + } // Read env-key names once and put them on state. gather-context (later in // the pipeline) and the handoff steps all read from there rather than @@ -118,7 +113,7 @@ export const buildSchemaStep: InitStep = { const nextState: InitState = { ...state, clientFilePath, - schemaGenerated: true, + schemaGenerated: !keepExisting, integration, schemas: recordedSchemas, schemaFromIntrospection: fromIntrospection, diff --git a/packages/cli/src/commands/init/steps/gather-context.ts b/packages/cli/src/commands/init/steps/gather-context.ts index 0629a7d2..f190d25d 100644 --- a/packages/cli/src/commands/init/steps/gather-context.ts +++ b/packages/cli/src/commands/init/steps/gather-context.ts @@ -1,48 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' import * as p from '@clack/prompts' import { detectAgents } from '../detect-agents.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { detectPackageManager } from '../utils.js' -/** - * Names of env keys observed in the project's `.env*` files. We never read or - * propagate the values — only the names tell the agent which keys to expect. - * - * Exported so build-schema can populate `state.envKeys` once at the start of - * the run; the handoff steps then read from state. Keeping the function here - * (rather than under `lib/`) groups it with the other context-gathering - * helpers. - */ -export function readEnvKeyNames(cwd: string): string[] { - const candidates = [ - '.env', - '.env.local', - '.env.development', - '.env.development.local', - ] - const seen = new Set() - for (const file of candidates) { - const path = resolve(cwd, file) - if (!existsSync(path)) continue - let text: string - try { - text = readFileSync(path, 'utf-8') - } catch { - continue - } - for (const line of text.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eq = trimmed.indexOf('=') - if (eq <= 0) continue - const key = trimmed.slice(0, eq).trim() - if (key) seen.add(key) - } - } - return Array.from(seen).sort() -} - /** * Detect available coding agents and log a one-line summary of the state * the user just set up. diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index 881b2aa1..ce018e32 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -9,8 +9,7 @@ import type { InitProvider, InitState, InitStep } from '../types.js' const CLAUDE_SKILLS_DIR = '.claude/skills' -const CLAUDE_INSTALL_URL = - 'https://docs.claude.com/en/docs/claude-code/quickstart' +const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart' /** * Hand off to Claude Code: copy the per-integration set of skills into diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index 073b95d2..4c7bb105 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -71,7 +71,7 @@ export const installDepsStep: InitStep = { // Package installs can take tens of seconds and a silent spinner makes // the CLI look hung. We log a "starting" line here and a success line // after, letting the package manager own the terminal in between. - let allSucceeded = true + const failed: string[] = [] for (const cmd of commands) { p.log.step(`Running: ${cmd}`) try { @@ -80,23 +80,30 @@ export const installDepsStep: InitStep = { const message = err instanceof Error ? err.message : String(err) p.log.error(`Install failed: ${cmd}`) p.log.error(message) - allSucceeded = false + failed.push(cmd) } } - if (allSucceeded) { + // Re-check from disk rather than inferring from exit codes — partial + // success (one command works, the other fails) needs precise + // per-package tracking, not a composite flag. + const stackInstalled = isPackageInstalled(STACK_PACKAGE) + const cliInstalled = isPackageInstalled(CLI_PACKAGE) + + if (stackInstalled && cliInstalled) { p.log.success('Stack dependencies installed.') } else { + const stillMissing = [ + ...(stackInstalled ? [] : [`${STACK_PACKAGE} (prod)`]), + ...(cliInstalled ? [] : [`${CLI_PACKAGE} (dev)`]), + ] + p.log.warn(`Still missing: ${stillMissing.join(', ')}.`) p.note( - `You can retry manually:\n ${commands.join('\n ')}`, + `You can retry manually:\n ${(failed.length ? failed : commands).join('\n ')}`, 'Manual Installation', ) } - return { - ...state, - stackInstalled: stackPresent || allSucceeded, - cliInstalled: cliPresent || allSucceeded, - } + return { ...state, stackInstalled, cliInstalled } }, }