firth 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,76 +1,75 @@
1
- # firth-cli
1
+ # firth
2
2
 
3
- > The runtime CLI for [Firth](https://github.com/firthdev/firth) the cloud platform SDK for AI coding agents.
3
+ The **Firth CLI** provision and govern a project's cloud resources (Neon Postgres,
4
+ Tigris storage, Fly.io compute) behind one CLI and one credential seam. It is a thin
5
+ client of the Firth control-plane API; you sign in, create a project (which provisions
6
+ the resources under Firth's own provider accounts), and pull a complete `.env` of
7
+ scoped, encrypted-at-rest credentials.
4
8
 
5
- **Status:** Pre-alpha. v0.0.1 ships only `firth init` (project scaffolding).
6
-
7
- This repo is the **L2 / CLI layer** of the Firth project. The companion repo [`firth`](https://github.com/firthdev/firth) holds the L1 / Knowledge layer (Skills, templates, runbooks, ARCHITECTURE.md).
8
-
9
- For the project's overall design and rationale, see [`firth/ARCHITECTURE.md`](https://github.com/firthdev/firth/blob/main/ARCHITECTURE.md). This README is just for the CLI itself.
10
-
11
- ## Local development
9
+ ## Install
12
10
 
13
11
  ```bash
14
- # from this directory
15
- npm install
16
-
17
- # run the CLI in dev (no build step)
18
- npm run dev -- init
19
-
20
- # typecheck
21
- npm run typecheck
22
-
23
- # tests
24
- npm test
25
-
26
- # build a distributable
27
- npm run build
28
-
29
- # link into your shell so `firth` works globally during dev
30
- npm link
31
- firth init my-test-app
12
+ npm install -g firth
13
+ # or run without installing
14
+ npx firth --help
32
15
  ```
33
16
 
34
- ## Commands (current)
35
-
36
- ### `firth init [name]`
17
+ Requires Node ≥ 20.
37
18
 
38
- Scaffold a Firth project. Generates `firth.config.ts` and `firth.lock.json`.
19
+ ## Quickstart
39
20
 
40
21
  ```bash
41
- # interactive
42
- firth init my-app
22
+ firth login # sign in (email/password)
23
+ firth project create my-app # provision DB + storage + compute; links ./.firth/project.json
24
+ firth branch create dev # fork an isolated DB branch (storage shared; compute redeploys)
25
+ firth branch switch dev # make dev the current branch
26
+ firth secrets # write the project's credentials into ./.env
27
+ firth deploy --image <url> # deploy a container image to the project's compute
28
+ firth events # the action ↔ side-effect timeline
29
+ ```
43
30
 
44
- # in current directory
45
- firth init .
31
+ > **Connectivity:** the CLI talks to the Firth control-plane API. Set `FIRTH_API_URL`
32
+ > (default `http://localhost:8080`) to point at your control plane. While Firth is
33
+ > pre-release, run the control plane locally and target `http://localhost:8080`.
46
34
 
47
- # non-interactive (agent-friendly): use defaults
48
- firth init my-app --yes
35
+ ## Commands
49
36
 
50
- # non-interactive with explicit overrides
51
- firth init my-app --frontend=nextjs --backend=hono --db=neon \
52
- --frontend-host=vercel --backend-host=railway --yes
37
+ ```
38
+ login Sign in (email/password)
39
+ logout Clear stored credentials
40
+ status Show login, linked project, and current branch
41
+
42
+ project create <name> Create + link a project (provisions DB/storage/compute)
43
+ project link <id> Link this directory to a project
44
+ project list List your projects
45
+ project delete Delete the linked project + all resources (--yes)
46
+
47
+ branch create <name> Create a branch (--from <parent>, default main)
48
+ branch list List the linked project's branches
49
+ branch switch <name> Set the current branch (secrets/events default to it)
50
+ branch delete <name> Delete a branch + its Neon branch (--yes)
51
+
52
+ secrets Fetch the linked project's secrets into .env (--branch <id>)
53
+ deploy Deploy --image <url> to the project's compute (--from, --port)
54
+ events Show the action ↔ side-effect timeline (--branch, --limit)
55
+ observe sync Upload local observe-hook findings (.firth/audit.jsonl) to the timeline
56
+ skills pull Install the firth skill into ./.claude/skills
53
57
  ```
54
58
 
55
- Defaults (when `--yes` is passed): Next.js + Hono + Neon Postgres + Vercel + Railway.
56
-
57
- ## Commands (planned)
58
-
59
- - `firth deploy` — provision resources and push code across the stack.
60
- - `firth secrets set/get/list` — sync secrets across providers.
61
- - `firth logs [--service]` — tail logs.
62
- - `firth status` — current deployment + resource state.
63
- - `firth handoff` — generate a context dump for a fresh agent session.
64
- - `firth db migrate / db reset` — database lifecycle.
59
+ ## Configuration & state
65
60
 
66
- ## Design notes
61
+ - **Global** — `~/.firth/config.json`: API URL, InsForge auth endpoint, and your access token.
62
+ - **Per-project** — `./.firth/project.json`: the linked project id and the current branch
63
+ (set by `firth branch switch`). `secrets` and `events` default to the current branch.
64
+ - **Override** — `FIRTH_API_URL` selects which control plane to talk to.
67
65
 
68
- - **Thin orchestrator, not a wrapper.** Every command shells out to the official provider CLI/API; we never re-implement provider features.
69
- - **Agent-friendly errors.** Failures emit `ERROR / LIKELY CAUSE / SUGGESTED ACTIONS` so an agent loop can recover.
70
- - **Local state lives in the project.** `firth.config.ts` (declarative, hand-edited) + `firth.lock.json` (generated, holds resource IDs) — both committed.
66
+ ## Credentials
71
67
 
72
- See [`firth/ARCHITECTURE.md`](https://github.com/firthdev/firth/blob/main/ARCHITECTURE.md) for the full rationale.
68
+ Treat `./.env` as the only source of resource credentials — never hardcode them or copy
69
+ them elsewhere. A branch's `DATABASE_URL` is isolated per branch; storage (`AWS_*`) is
70
+ shared across branches. Re-run `firth secrets` after `firth branch switch` to refresh
71
+ `DATABASE_URL`.
73
72
 
74
73
  ## License
75
74
 
76
- MIT (planned).
75
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,60 @@
1
+ const realFetcher = async (url, init) => {
2
+ const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body });
3
+ return { status: res.status, json: () => res.json(), text: () => res.text() };
4
+ };
5
+ export class FirthApi {
6
+ apiUrl;
7
+ token;
8
+ fetcher;
9
+ constructor(apiUrl, token, fetcher = realFetcher) {
10
+ this.apiUrl = apiUrl;
11
+ this.token = token;
12
+ this.fetcher = fetcher;
13
+ }
14
+ async req(method, path, body) {
15
+ const res = await this.fetcher(`${this.apiUrl}${path}`, {
16
+ method,
17
+ headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
18
+ body: body === undefined ? undefined : JSON.stringify(body),
19
+ });
20
+ if (res.status < 200 || res.status >= 300) {
21
+ let msg = '';
22
+ try {
23
+ msg = (await res.json())?.error ?? '';
24
+ }
25
+ catch { /* ignore */ }
26
+ throw new Error(`request failed: ${res.status}${msg ? ` ${msg}` : ''}`);
27
+ }
28
+ return res.json();
29
+ }
30
+ createProject(name) { return this.req('POST', '/projects', { name }); }
31
+ listProjects() { return this.req('GET', '/projects').then((r) => r.projects); }
32
+ createBranch(projectId, name, from) {
33
+ return this.req('POST', `/projects/${projectId}/branches`, { name, from });
34
+ }
35
+ listBranches(projectId) { return this.req('GET', `/projects/${projectId}/branches`).then((r) => r.branches); }
36
+ getSecrets(projectId, branch) {
37
+ const q = branch ? `?branch=${encodeURIComponent(branch)}` : '';
38
+ return this.req('GET', `/projects/${projectId}/secrets${q}`).then((r) => r.secrets);
39
+ }
40
+ deploy(projectId, opts) {
41
+ return this.req('POST', `/projects/${projectId}/deploy`, opts);
42
+ }
43
+ listEvents(projectId, opts = {}) {
44
+ const qs = new URLSearchParams();
45
+ if (opts.branch)
46
+ qs.set('branch', opts.branch);
47
+ if (opts.limit)
48
+ qs.set('limit', String(opts.limit));
49
+ const q = qs.toString();
50
+ return this.req('GET', `/projects/${projectId}/events${q ? `?${q}` : ''}`).then((r) => r.events);
51
+ }
52
+ postEvents(projectId, events) {
53
+ return this.req('POST', `/projects/${projectId}/events`, { events });
54
+ }
55
+ deleteProject(id) { return this.req('DELETE', `/projects/${id}`); }
56
+ deleteBranch(projectId, branchId) { return this.req('DELETE', `/projects/${projectId}/branches/${branchId}`); }
57
+ login(email, password) {
58
+ return this.req('POST', '/auth/login', { email, password });
59
+ }
60
+ }
@@ -0,0 +1,35 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { FirthApi } from '../api.js';
3
+ import { readConfig, writeConfig } from '../config.js';
4
+ export async function login(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: { email: { type: 'string' }, password: { type: 'string' }, 'api-url': { type: 'string' } }, allowPositionals: false });
6
+ const email = values.email ?? deps.env.FIRTH_EMAIL;
7
+ const password = values.password ?? deps.env.FIRTH_PASSWORD;
8
+ if (!email || !password) {
9
+ deps.print('login requires --email and --password (or FIRTH_EMAIL/FIRTH_PASSWORD)');
10
+ return 1;
11
+ }
12
+ const cfg = readConfig(deps.home, deps.env);
13
+ // --api-url sets the control-plane host for this login and persists it for later commands
14
+ // (a token is host-specific, so switching hosts means logging in there). Falls back to the
15
+ // existing config / FIRTH_API_URL env / the built-in default (the deployed control plane).
16
+ const apiUrl = values['api-url'] ?? cfg.apiUrl;
17
+ const api = deps.makeApi ? deps.makeApi() : new FirthApi(apiUrl, '');
18
+ try {
19
+ const { token } = await api.login(email, password);
20
+ writeConfig({ ...cfg, apiUrl, token }, deps.home);
21
+ deps.print(`signed in as ${email} (control plane: ${apiUrl})`);
22
+ return 0;
23
+ }
24
+ catch (e) {
25
+ deps.print(`login failed: ${e instanceof Error ? e.message : String(e)}`);
26
+ return 1;
27
+ }
28
+ }
29
+ export async function logout(_argv, deps) {
30
+ const cfg = readConfig(deps.home, deps.env);
31
+ delete cfg.token;
32
+ writeConfig(cfg, deps.home);
33
+ deps.print('signed out');
34
+ return 0;
35
+ }
@@ -0,0 +1,107 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink, setCurrentBranch } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ import { formatTeardown } from './util.js';
5
+ import { ensureFlyctl } from '../fly.js';
6
+ import { ensureSkills } from '../ensure-skills.js';
7
+ function linkedProject(deps) {
8
+ const link = readProjectLink(deps.cwd);
9
+ if (!link)
10
+ throw new Error('this directory is not linked — run `firth project link <id>` or `firth project create`');
11
+ return link.projectId;
12
+ }
13
+ export async function branchCreate(argv, deps) {
14
+ await ensureFlyctl(deps);
15
+ try {
16
+ const { values, positionals } = parseArgs({ args: argv, options: { from: { type: 'string' } }, allowPositionals: true });
17
+ const name = positionals[0];
18
+ if (!name) {
19
+ deps.print('usage: firth branch create <name> [--from <parent>]');
20
+ return 1;
21
+ }
22
+ const projectId = linkedProject(deps);
23
+ const out = await apiFromDeps(deps).createBranch(projectId, name, values.from ?? 'main');
24
+ deps.print(`created branch ${out.branch.name} (${out.branch.id})`);
25
+ return 0;
26
+ }
27
+ catch (e) {
28
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
29
+ return 1;
30
+ }
31
+ }
32
+ export async function branchList(_argv, deps) {
33
+ try {
34
+ const projectId = linkedProject(deps);
35
+ const branches = await apiFromDeps(deps).listBranches(projectId);
36
+ for (const b of branches)
37
+ deps.print(`${b.id} ${b.name}`);
38
+ return 0;
39
+ }
40
+ catch (e) {
41
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
42
+ return 1;
43
+ }
44
+ }
45
+ export async function branchSwitch(argv, deps) {
46
+ await ensureFlyctl(deps);
47
+ try {
48
+ const { positionals } = parseArgs({ args: argv, options: {}, allowPositionals: true });
49
+ const name = positionals[0];
50
+ if (!name) {
51
+ deps.print('usage: firth branch switch <name>');
52
+ return 1;
53
+ }
54
+ const projectId = linkedProject(deps);
55
+ const branches = await apiFromDeps(deps).listBranches(projectId);
56
+ const target = branches.find((b) => b.name === name || b.id === name);
57
+ if (!target) {
58
+ deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
59
+ return 1;
60
+ }
61
+ setCurrentBranch({ id: target.id, name: target.name }, deps.cwd);
62
+ deps.print(`switched to branch ${target.name} (${target.id}) — run \`firth secrets\` to refresh .env`);
63
+ await ensureSkills(deps);
64
+ return 0;
65
+ }
66
+ catch (e) {
67
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
68
+ return 1;
69
+ }
70
+ }
71
+ export async function branchDelete(argv, deps) {
72
+ try {
73
+ const { values, positionals } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: true });
74
+ const name = positionals[0];
75
+ if (!name) {
76
+ deps.print('usage: firth branch delete <name>');
77
+ return 1;
78
+ }
79
+ const projectId = linkedProject(deps);
80
+ const branches = await apiFromDeps(deps).listBranches(projectId);
81
+ const target = branches.find((b) => b.name === name || b.id === name);
82
+ if (!target) {
83
+ deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
84
+ return 1;
85
+ }
86
+ if (target.is_default) {
87
+ deps.print('cannot delete the default branch');
88
+ return 1;
89
+ }
90
+ if (!values.yes) {
91
+ deps.print(`this destroys branch "${name}" (its Neon branch). re-run with --yes to confirm.`);
92
+ return 1;
93
+ }
94
+ const out = await apiFromDeps(deps).deleteBranch(projectId, target.id);
95
+ // If the deleted branch is the current one, clear it
96
+ const link = readProjectLink(deps.cwd);
97
+ if (link?.branch?.id === target.id) {
98
+ setCurrentBranch(null, deps.cwd);
99
+ }
100
+ deps.print(`deleted branch ${target.name}${formatTeardown(out.teardown ?? {})}`);
101
+ return 0;
102
+ }
103
+ catch (e) {
104
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
105
+ return 1;
106
+ }
107
+ }
@@ -0,0 +1,25 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ export async function deploy(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: {
6
+ image: { type: 'string' }, from: { type: 'string' }, port: { type: 'string' },
7
+ }, allowPositionals: false });
8
+ if (!values.image) {
9
+ deps.print('usage: firth deploy --image <url> [--from <branch>] [--port <n>]');
10
+ return 1;
11
+ }
12
+ const link = readProjectLink(deps.cwd);
13
+ if (!link) {
14
+ deps.print('this directory is not linked — run `firth project link <id>`');
15
+ return 1;
16
+ }
17
+ const out = await apiFromDeps(deps).deploy(link.projectId, {
18
+ image: values.image,
19
+ from: values.from,
20
+ branch: link.branch?.id ?? link.branch?.name,
21
+ port: values.port ? Number(values.port) : undefined,
22
+ });
23
+ deps.print(`deployed machine ${out.machineId} → ${out.url}`);
24
+ return 0;
25
+ }
@@ -0,0 +1,20 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ export async function events(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' }, limit: { type: 'string' } }, allowPositionals: false });
6
+ const link = readProjectLink(deps.cwd);
7
+ if (!link) {
8
+ deps.print('this directory is not linked — run `firth project link <id>`');
9
+ return 1;
10
+ }
11
+ const effectiveBranch = values.branch ?? link.branch?.id;
12
+ const rows = await apiFromDeps(deps).listEvents(link.projectId, { branch: effectiveBranch, limit: values.limit ? Number(values.limit) : undefined });
13
+ if (rows.length === 0)
14
+ deps.print('(no events yet)');
15
+ for (const e of rows) {
16
+ const summary = e.payload?.url ?? e.payload?.name ?? e.payload?.machineId ?? '';
17
+ deps.print(`${e.created_at} ${e.source.padEnd(8)} ${e.kind}${summary ? ` ${summary}` : ''}`);
18
+ }
19
+ return 0;
20
+ }
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { readProjectLink } from '../config.js';
6
+ import { readAuditOffset, writeAuditOffset, readNewAuditLines } from '../sync-state.js';
7
+ import { apiFromDeps } from './project.js';
8
+ const BATCH = 500;
9
+ export async function observeSync(argv, deps) {
10
+ const { values } = parseArgs({ args: argv, options: { all: { type: 'boolean' } }, allowPositionals: true });
11
+ const link = readProjectLink(deps.cwd);
12
+ if (!link) {
13
+ deps.print('this directory is not linked — run `firth project link <id>`');
14
+ return 1;
15
+ }
16
+ const path = join(deps.cwd, '.firth', 'audit.jsonl');
17
+ if (!existsSync(path)) {
18
+ deps.print('no audit log found at .firth/audit.jsonl (is the observe hook installed?)');
19
+ return 0;
20
+ }
21
+ const content = readFileSync(path, 'utf8');
22
+ const offset = values.all ? 0 : readAuditOffset(deps.cwd);
23
+ const { lines, ends } = readNewAuditLines(content, offset);
24
+ if (lines.length === 0) {
25
+ deps.print('nothing new to sync');
26
+ return 0;
27
+ }
28
+ const api = apiFromDeps(deps);
29
+ const events = lines.map((line) => {
30
+ let parsed = {};
31
+ try {
32
+ parsed = JSON.parse(line);
33
+ }
34
+ catch {
35
+ parsed = { raw: line };
36
+ }
37
+ return {
38
+ source: 'agent',
39
+ kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`,
40
+ payload: parsed,
41
+ dedup_key: createHash('sha256').update(line).digest('hex'),
42
+ };
43
+ });
44
+ let recorded = 0, skipped = 0;
45
+ for (let i = 0; i < events.length; i += BATCH) {
46
+ const batch = events.slice(i, i + BATCH);
47
+ const res = await api.postEvents(link.projectId, batch);
48
+ recorded += res.recorded;
49
+ skipped += res.skipped ?? 0;
50
+ writeAuditOffset(deps.cwd, ends[i + batch.length - 1], new Date().toISOString());
51
+ }
52
+ let msg = `synced ${recorded} new finding(s)`;
53
+ if (skipped > 0)
54
+ msg += ` (${skipped} already uploaded)`;
55
+ deps.print(msg);
56
+ return 0;
57
+ }
@@ -0,0 +1,84 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readConfig, writeProjectLink, readProjectLink, clearProjectLink, setCurrentBranch } from '../config.js';
3
+ import { FirthApi } from '../api.js';
4
+ import { formatTeardown } from './util.js';
5
+ import { ensureFlyctl } from '../fly.js';
6
+ import { ensureSkills } from '../ensure-skills.js';
7
+ // Build a FirthApi from stored config; tests can override via deps.makeApi.
8
+ export function apiFromDeps(deps) {
9
+ if (deps.makeApi)
10
+ return deps.makeApi();
11
+ const cfg = readConfig(deps.home, deps.env);
12
+ if (!cfg.token)
13
+ throw new Error('not logged in — run `firth login`');
14
+ return new FirthApi(cfg.apiUrl, cfg.token);
15
+ }
16
+ export async function projectCreate(argv, deps) {
17
+ await ensureFlyctl(deps);
18
+ const name = argv[0];
19
+ if (!name) {
20
+ deps.print('usage: firth project create <name>');
21
+ return 1;
22
+ }
23
+ const out = await apiFromDeps(deps).createProject(name);
24
+ writeProjectLink(out.project.id, deps.cwd);
25
+ // start on the project's default branch so later commands target it without a manual `branch switch`
26
+ setCurrentBranch({ id: out.defaultBranch.id, name: out.defaultBranch.name }, deps.cwd);
27
+ deps.print(`created project ${out.project.name} (${out.project.id}); linked + on branch ${out.defaultBranch.name}`);
28
+ await ensureSkills(deps);
29
+ return 0;
30
+ }
31
+ export async function projectLink(argv, deps) {
32
+ await ensureFlyctl(deps);
33
+ const id = argv[0];
34
+ if (!id) {
35
+ deps.print('usage: firth project link <id>');
36
+ return 1;
37
+ }
38
+ writeProjectLink(id, deps.cwd);
39
+ // best-effort: switch to the project's default branch so later commands target main without a manual
40
+ // `branch switch`. If we can't reach the API (not logged in / offline / no access), the id is still linked.
41
+ let on = '';
42
+ try {
43
+ const branches = await apiFromDeps(deps).listBranches(id);
44
+ const def = branches.find((b) => b.is_default) ?? branches[0];
45
+ if (def) {
46
+ setCurrentBranch({ id: def.id, name: def.name }, deps.cwd);
47
+ on = ` on branch ${def.name}`;
48
+ }
49
+ }
50
+ catch { /* the id is still linked; secrets/events fall back to the default branch */ }
51
+ deps.print(`linked this directory to project ${id}${on}`);
52
+ await ensureSkills(deps);
53
+ return 0;
54
+ }
55
+ export async function projectList(_argv, deps) {
56
+ const projects = await apiFromDeps(deps).listProjects();
57
+ if (projects.length === 0)
58
+ deps.print('(no projects)');
59
+ for (const p of projects)
60
+ deps.print(`${p.id} ${p.name}`);
61
+ return 0;
62
+ }
63
+ export async function projectDelete(argv, deps) {
64
+ try {
65
+ const { values } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: false });
66
+ const link = readProjectLink(deps.cwd);
67
+ if (!link) {
68
+ deps.print('this directory is not linked — run `firth project link <id>` or `firth project create`');
69
+ return 1;
70
+ }
71
+ if (!values.yes) {
72
+ deps.print(`this permanently destroys the project's Neon DB, Fly app, and storage bucket. re-run with --yes to confirm.`);
73
+ return 1;
74
+ }
75
+ const out = await apiFromDeps(deps).deleteProject(link.projectId);
76
+ clearProjectLink(deps.cwd);
77
+ deps.print(`deleted project ${link.projectId}${formatTeardown(out.teardown ?? {})}; unlinked ./.firth/project.json`);
78
+ return 0;
79
+ }
80
+ catch (e) {
81
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
82
+ return 1;
83
+ }
84
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { readProjectLink } from '../config.js';
5
+ import { apiFromDeps } from './project.js';
6
+ export async function secrets(argv, deps) {
7
+ const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' } }, allowPositionals: false });
8
+ const link = readProjectLink(deps.cwd);
9
+ if (!link) {
10
+ deps.print('this directory is not linked — run `firth project link <id>`');
11
+ return 1;
12
+ }
13
+ const api = apiFromDeps(deps);
14
+ // Resolve the target branch (by name/id, default = the project's default branch).
15
+ const branches = await api.listBranches(link.projectId);
16
+ const target = values.branch
17
+ ? branches.find((b) => b.name === values.branch || b.id === values.branch)
18
+ : link.branch
19
+ ? branches.find((b) => b.id === link.branch.id)
20
+ : (branches.find((b) => b.is_default) ?? branches[0]);
21
+ if (!target) {
22
+ deps.print(`branch "${values.branch ?? '(default)'}" not found`);
23
+ return 1;
24
+ }
25
+ // The seam returns EITHER project-scoped (no branch) OR branch-scoped; merge both for a complete .env.
26
+ const project = await api.getSecrets(link.projectId);
27
+ const branch = await api.getSecrets(link.projectId, target.id);
28
+ const bundle = { ...project, ...branch };
29
+ // Merge Firth-managed keys into any existing .env, preserving user-added lines/comments.
30
+ const path = join(deps.cwd, '.env');
31
+ const firthKeys = new Set(Object.keys(bundle));
32
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
33
+ const kept = existing.split('\n').filter((line) => {
34
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
35
+ return !(key && firthKeys.has(key)); // drop stale Firth keys; keep user vars/comments/blanks
36
+ });
37
+ while (kept.length && kept[kept.length - 1].trim() === '')
38
+ kept.pop(); // trim trailing blanks
39
+ const firthLines = Object.entries(bundle).map(([k, v]) => `${k}=${v}`);
40
+ const merged = [...kept, ...firthLines];
41
+ writeFileSync(path, merged.length ? merged.join('\n') + '\n' : '');
42
+ deps.print(`wrote ${firthLines.length} secrets to ${path}`); // values never printed
43
+ return 0;
44
+ }
@@ -0,0 +1,18 @@
1
+ import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export async function skillsPull(_argv, deps) {
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ // built run: cli/dist/commands -> cli/dist/skills (build copies the top-level skills/ in)
7
+ // source run: cli/src/commands -> <repo-root>/skills
8
+ const candidates = [
9
+ join(here, '..', 'skills', 'firth', 'SKILL.md'),
10
+ join(here, '..', '..', '..', 'skills', 'firth', 'SKILL.md'),
11
+ ];
12
+ const src = candidates.find((p) => existsSync(p)) ?? candidates[candidates.length - 1];
13
+ const destDir = join(deps.cwd, '.claude', 'skills', 'firth');
14
+ mkdirSync(destDir, { recursive: true });
15
+ copyFileSync(src, join(destDir, 'SKILL.md'));
16
+ deps.print(`installed firth skill → ${join('.claude', 'skills', 'firth', 'SKILL.md')}`);
17
+ return 0;
18
+ }
@@ -0,0 +1,11 @@
1
+ import { readConfig, readProjectLink } from '../config.js';
2
+ export async function status(_argv, deps) {
3
+ const cfg = readConfig(deps.home, deps.env);
4
+ const link = readProjectLink(deps.cwd);
5
+ deps.print(`api: ${cfg.apiUrl}`);
6
+ deps.print(`auth: ${cfg.token ? 'signed in' : 'not signed in (run `firth login`)'}`);
7
+ deps.print(`project: ${link ? link.projectId : '(not linked)'}`);
8
+ const branchLabel = link?.branch ? `${link.branch.name} (${link.branch.id})` : '(default)';
9
+ deps.print(`branch: ${branchLabel}`);
10
+ return 0;
11
+ }
@@ -0,0 +1,11 @@
1
+ export function formatTeardown(t) {
2
+ let result = '';
3
+ if (t.destroyed && t.destroyed.length > 0) {
4
+ result += ` (destroyed: ${t.destroyed.join(', ')})`;
5
+ }
6
+ if (t.failed && t.failed.length > 0) {
7
+ const failedKinds = t.failed.map(f => f.kind).join(', ');
8
+ result += result ? `; FAILED: ${failedKinds}` : ` FAILED: ${failedKinds}`;
9
+ }
10
+ return result;
11
+ }