Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/cli-init-agent-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'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.

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.
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, 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 (`<!-- cipherstash:rulebook start/end -->`), 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.
53 changes: 38 additions & 15 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,32 @@ 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.

---

## Recommended flow

```
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.

---

Expand Down Expand Up @@ -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]
Expand All @@ -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`.

---

Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"files": [
"dist",
"dist/sql",
"dist/rulebook",
"README.md",
"LICENSE",
"CHANGELOG.md"
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/commands/init/__tests__/detect-agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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)
expect(env.project.agentsMd).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('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')
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)
})
})
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
44 changes: 43 additions & 1 deletion packages/cli/src/commands/init/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -166,3 +167,44 @@ describe('runnerCommand', () => {
)
})
})

describe('isPackageInstalled', () => {
let tmp: string
let cwdSpy: ReturnType<typeof vi.spyOn>

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/<name> does not exist', () => {
expect(isPackageInstalled('stash')).toBe(false)
})

it('returns true when node_modules/<name>/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)
})
})
Loading
Loading