Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/cli-init-agent-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 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 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. 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-<integration>` + `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 artifacts and prints install + manual-launch instructions. Progress is never wasted.

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

Per-integration skill subset:

```text
drizzle → stash-encryption + stash-drizzle + stash-cli
supabase → stash-encryption + stash-supabase + stash-cli
postgresql → stash-encryption + 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.

Re-running `init` is safe — `AGENTS.md` uses sentinel-marker upsert (`<!-- cipherstash:rulebook start/end -->`), 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.
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** — copies the per-integration set of skills (`stash-encryption`, `stash-<integration>`, `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** — 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.

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: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"author": "CipherStash <hello@cipherstash.com>",
"files": [
"dist",
"dist/sql",
"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