pruff-skills 0.3.0 → 0.5.0

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/dist/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { Ie, M, Me, Se, Y, be, fe, he, ve, xe, ye } from "./_chunks/libs/@clack/
5
5
  import { esm_default } from "./_chunks/libs/simple-git.mjs";
6
6
  import { xdgConfig } from "./_chunks/libs/xdg-basedir.mjs";
7
7
  import { require_dist } from "./_chunks/libs/@vercel/detect-agent.mjs";
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
8
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
9
9
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
  import { stripVTControlCharacters } from "node:util";
@@ -434,7 +434,7 @@ const CLONE_TIMEOUT_MS = (() => {
434
434
  const parsed = Number.parseInt(raw, 10);
435
435
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLONE_TIMEOUT_MS;
436
436
  })();
437
- const execFileAsync$3 = promisify(execFile);
437
+ const execFileAsync$4 = promisify(execFile);
438
438
  var GitCloneError = class extends Error {
439
439
  url;
440
440
  isTimeout;
@@ -513,7 +513,7 @@ async function resetTempDir(dir) {
513
513
  async function tryGhClone(repo, tempDir, ref) {
514
514
  let cloneTarget = repo.slug;
515
515
  try {
516
- const { stdout, stderr } = await execFileAsync$3("gh", [
516
+ const { stdout, stderr } = await execFileAsync$4("gh", [
517
517
  "auth",
518
518
  "status",
519
519
  "-h",
@@ -530,7 +530,7 @@ async function tryGhClone(repo, tempDir, ref) {
530
530
  } catch {
531
531
  return false;
532
532
  }
533
- await execFileAsync$3("gh", [
533
+ await execFileAsync$4("gh", [
534
534
  "repo",
535
535
  "clone",
536
536
  cloneTarget,
@@ -3997,7 +3997,7 @@ function parseAddOptions(args) {
3997
3997
  options
3998
3998
  };
3999
3999
  }
4000
- const execFileAsync$2 = promisify(execFile);
4000
+ const execFileAsync$3 = promisify(execFile);
4001
4001
  const claudeAgentsDir = (global) => global ? join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "agents") : join(process.cwd(), ".claude", "agents");
4002
4002
  async function fetchAgentFile(source, name) {
4003
4003
  const rel = `agents/${name}.md`;
@@ -4006,7 +4006,7 @@ async function fetchAgentFile(source, name) {
4006
4006
  return existsSync(file) ? readFile(file, "utf8") : null;
4007
4007
  }
4008
4008
  if (/^[^/]+\/[^/]+$/.test(source)) try {
4009
- const { stdout } = await execFileAsync$2("gh", [
4009
+ const { stdout } = await execFileAsync$3("gh", [
4010
4010
  "api",
4011
4011
  `repos/${source}/contents/${rel}`,
4012
4012
  "--jq",
@@ -4103,7 +4103,7 @@ async function runAgents(args) {
4103
4103
  console.log(`Unknown agents command: ${sub}`);
4104
4104
  console.log(AGENTS_HELP);
4105
4105
  }
4106
- const execFileAsync$1 = promisify(execFile);
4106
+ const execFileAsync$2 = promisify(execFile);
4107
4107
  const RULE_SCOPES = [
4108
4108
  "client",
4109
4109
  "server",
@@ -4120,7 +4120,7 @@ async function fetchFromCatalog(source, rel) {
4120
4120
  return existsSync(file) ? readFile(file, "utf8") : null;
4121
4121
  }
4122
4122
  if (/^[^/]+\/[^/]+$/.test(source)) try {
4123
- const { stdout } = await execFileAsync$1("gh", [
4123
+ const { stdout } = await execFileAsync$2("gh", [
4124
4124
  "api",
4125
4125
  `repos/${source}/contents/${rel}`,
4126
4126
  "--jq",
@@ -4249,7 +4249,7 @@ async function runRules(args) {
4249
4249
  const { names, options } = parseRulesOptions(sub === "add" || sub === "a" || sub === "install" || sub === "i" ? args.slice(1) : args);
4250
4250
  await addRules(names, options);
4251
4251
  }
4252
- const execFileAsync = promisify(execFile);
4252
+ const execFileAsync$1 = promisify(execFile);
4253
4253
  const RESET$3 = "\x1B[0m";
4254
4254
  const BOLD$3 = "\x1B[1m";
4255
4255
  const DIM$3 = "\x1B[38;5;102m";
@@ -4266,7 +4266,7 @@ async function fetchCatalogIndex(source) {
4266
4266
  return JSON.parse(raw);
4267
4267
  }
4268
4268
  if (source.match(/^[^/]+\/[^/]+$/)) {
4269
- const { stdout } = await execFileAsync("gh", [
4269
+ const { stdout } = await execFileAsync$1("gh", [
4270
4270
  "api",
4271
4271
  `repos/${source}/contents/${rel}`,
4272
4272
  "--jq",
@@ -4618,7 +4618,7 @@ async function discoverNodeModuleSkills(cwd) {
4618
4618
  }));
4619
4619
  return skills;
4620
4620
  }
4621
- async function runSync(args, options = {}) {
4621
+ async function runSync$1(args, options = {}) {
4622
4622
  const cwd = process.cwd();
4623
4623
  const agentResult = await detectAgent();
4624
4624
  if (agentResult.isAgent) {
@@ -4883,7 +4883,7 @@ async function runInstallFromLock(args) {
4883
4883
  M.info(`${import_picocolors.default.cyan(String(nodeModuleSkills.length))} skill${nodeModuleSkills.length !== 1 ? "s" : ""} from node_modules`);
4884
4884
  try {
4885
4885
  const { options: syncOptions } = parseSyncOptions(args);
4886
- await runSync(args, {
4886
+ await runSync$1(args, {
4887
4887
  ...syncOptions,
4888
4888
  yes: true,
4889
4889
  agent: universalAgentNames
@@ -5250,7 +5250,7 @@ function buildUpdateInstallSource(entry) {
5250
5250
  if (!entry.skillPath) return formatSourceInput(entry.sourceUrl, entry.ref);
5251
5251
  return appendFolderAndRef(entry.source, entry.skillPath, entry.ref);
5252
5252
  }
5253
- const __dirname$1 = dirname(fileURLToPath(import.meta.url));
5253
+ const __dirname$2 = dirname(fileURLToPath(import.meta.url));
5254
5254
  const RESET$1 = "\x1B[0m";
5255
5255
  const BOLD$1 = "\x1B[1m";
5256
5256
  const DIM$1 = "\x1B[38;5;102m";
@@ -5521,7 +5521,7 @@ async function updateGlobalSkills(options = {}) {
5521
5521
  const safeName = sanitizeMetadata(update.name);
5522
5522
  console.log(`${TEXT$1}Updating ${safeName}...${RESET$1}`);
5523
5523
  const installUrl = buildUpdateInstallSource(update.entry);
5524
- const cliEntry = join(__dirname$1, "..", "bin", "cli.mjs");
5524
+ const cliEntry = join(__dirname$2, "..", "bin", "cli.mjs");
5525
5525
  if (!existsSync(cliEntry)) {
5526
5526
  failCount++;
5527
5527
  console.log(` ${DIM$1}✗ Failed to update ${safeName}: CLI entrypoint not found at ${cliEntry}${RESET$1}`);
@@ -5605,7 +5605,7 @@ async function updateProjectSkills(options = {}) {
5605
5605
  bySource.set(source, existing);
5606
5606
  }
5607
5607
  const localLock = await readLocalLock();
5608
- const cliEntry = join(__dirname$1, "..", "bin", "cli.mjs");
5608
+ const cliEntry = join(__dirname$2, "..", "bin", "cli.mjs");
5609
5609
  if (!existsSync(cliEntry)) {
5610
5610
  console.log(`${DIM$1}✗ CLI entrypoint not found at ${cliEntry}${RESET$1}`);
5611
5611
  return {
@@ -6126,16 +6126,557 @@ async function sweepLine(text, { slow = false, indent = " " } = {}) {
6126
6126
  process.stdout.write("\x1B[?25h");
6127
6127
  }
6128
6128
  }
6129
+ function rowColor(palette, row) {
6130
+ return palette[row % palette.length];
6131
+ }
6132
+ function printStaticBlockLogo(lines, palette) {
6133
+ lines.forEach((line, i) => console.log(`${rowColor(palette, i)}${line}${RESET}`));
6134
+ }
6135
+ function renderBlockLogoReveal(lines, palette, cut, width) {
6136
+ return lines.map((line, i) => {
6137
+ const shown = Array.from(line).slice(0, cut);
6138
+ const pad = " ".repeat(Math.max(0, width - shown.length));
6139
+ return `${rowColor(palette, i)}${shown.join("")}${RESET}${pad}`;
6140
+ }).join("\n");
6141
+ }
6142
+ function renderBlockLogoFrame(lines, palette, pos, band) {
6143
+ return lines.map((line, i) => {
6144
+ const chars = Array.from(line);
6145
+ let out = "";
6146
+ let mode = "";
6147
+ for (let c = 0; c < chars.length; c++) {
6148
+ const color = c >= pos && c < pos + band ? SWEEP : rowColor(palette, i);
6149
+ if (color !== mode) {
6150
+ out += color;
6151
+ mode = color;
6152
+ }
6153
+ out += chars[c];
6154
+ }
6155
+ return out + RESET;
6156
+ }).join("\n");
6157
+ }
6158
+ async function showBlockLogo(lines, letterCuts, { slow = false, palette = BLUES } = {}) {
6159
+ console.log();
6160
+ if (!canAnimate()) {
6161
+ printStaticBlockLogo(lines, palette);
6162
+ return;
6163
+ }
6164
+ const rows = lines.length;
6165
+ const width = Math.max(...lines.map((l) => Array.from(l).length));
6166
+ const band = 7;
6167
+ const revealMs = slow ? 190 : 90;
6168
+ const step = slow ? 3 : 4;
6169
+ const sweepMs = slow ? 34 : 18;
6170
+ process.stdout.write("\x1B[?25l");
6171
+ try {
6172
+ let first = true;
6173
+ for (const cut of letterCuts) {
6174
+ if (!first) process.stdout.write(`\x1b[${rows}A`);
6175
+ process.stdout.write("\r" + renderBlockLogoReveal(lines, palette, cut, width) + "\n");
6176
+ first = false;
6177
+ await sleep(revealMs);
6178
+ }
6179
+ for (let pos = -7; pos <= width; pos += step) {
6180
+ process.stdout.write(`\x1b[${rows}A`);
6181
+ process.stdout.write("\r" + renderBlockLogoFrame(lines, palette, pos, band) + "\n");
6182
+ await sleep(sweepMs);
6183
+ }
6184
+ process.stdout.write(`\x1b[${rows}A\r`);
6185
+ process.stdout.write(lines.map((line, i) => `${rowColor(palette, i)}${line}${RESET}`).join("\n") + "\n");
6186
+ if (slow) await sleep(500);
6187
+ } finally {
6188
+ process.stdout.write("\x1B[?25h");
6189
+ }
6190
+ }
6191
+ const WORKSPACE_CLAUDE_TEMPLATE = `# Pruff — Working Directory
6192
+
6193
+ This folder is the working directory for Pruff's frontend and backend. The two main git repos are:
6194
+
6195
+ - **\`Pruff-Client/\`** — React frontend
6196
+ - **\`Pruff-server/\`** — Node.js/Express backend
6197
+
6198
+ Other directories (\`sls-*/\`, \`slsl-*/\`, \`AWS-Tools/\`, etc.) are serverless microservices and tooling —
6199
+ clone them into this workspace as you need them, and don't modify them unless explicitly asked.
6200
+
6201
+ Each repo has its own \`CLAUDE.md\` and \`.claude/rules/\` with repo-specific patterns. This file covers
6202
+ workspace-level context only.
6203
+
6204
+ ---
6205
+
6206
+ ## Workspace Layout
6207
+
6208
+ ### Main applications (modify freely when asked)
6209
+
6210
+ | Folder | What it is |
6211
+ |---|---|
6212
+ | \`Pruff-Client/\` | React 18 + Vite + MUI frontend. React Query + Redux Toolkit. |
6213
+ | \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant). |
6214
+
6215
+ ### Serverless microservices (\`sls-*/\`, \`slsl-*/\` — clone as needed, don't modify unless asked)
6216
+
6217
+ All live in the same AWS account, share the \`lambda-sqs\` IAM role, and most use a shared
6218
+ \`prisma-client-layer\` Lambda layer so they can reach tenant databases. They talk to \`Pruff-server\`
6219
+ over HTTPS with an \`x-api-key\`.
6220
+
6221
+ | Folder | Role |
6222
+ |---|---|
6223
+ | \`sls-SII-Services/\` | **SII (Chilean tax authority) scrapers & orchestrators.** Invoice generation, certificate validation, cession execution, F29/IVA extraction, credit-note references, cron orchestrators. Talks heavily to \`slsl-Data-Injection-Syncronizer\`. |
6224
+ | \`sls-TGR-Extraction/\` | **TGR (treasury) scrapers.** Extracts tax collections, debts, payment agreements. Pushes results into the data-injection pipeline. |
6225
+ | \`sls-TGR-Services/\` | **TGR-adjacent business logic.** Smaller sibling to \`sls-TGR-Extraction\`. |
6226
+ | \`sls-Public-Market/\` | **Mercado Público (Chilean public tenders) pipeline.** Awarded-tender fetcher, tender analyzer, lead generator. |
6227
+ | \`slsl-Data-Injection-Syncronizer/\` | **Fan-out hub between scrapers and Pruff-server.** Receives scraped data, resolves interested tenants, forwards per-tenant writes. Also hosts the \`ReflexOrchestrator\` change-detection pipeline. |
6228
+ | \`sls-Decision-Engine/\` | **Risk / Motor de Riesgo executor.** Three-stage SQS pipeline: \`prepare\` → \`delayedPrepare\` → \`executor\`. |
6229
+ | \`sls-Notification-Service/\` | **Outbound notifications** (email, in-app, collections). |
6230
+ | \`sls-Pruff-Webhooks/\` | **Outbound webhook delivery** to client integrations. |
6231
+ | \`sls-Pruff-Workers/\` | **Async background jobs for Pruff-server** (PDF rendering, tenant document/summary sync crons). |
6232
+
6233
+ ### Tooling & utilities
6234
+
6235
+ | Folder | What it is |
6236
+ |---|---|
6237
+ | \`AWS-Tools/\` | Standalone Node/TypeScript scripts for AWS ops — not deployed; run locally with AWS creds. |
6238
+
6239
+ ### Cross-service data flow (mental model)
6240
+
6241
+ \`\`\`
6242
+ sls-SII-Services ─┐
6243
+ sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
6244
+ sls-Public-Market ─┘ │
6245
+ ▼
6246
+ sls-Decision-Engine ──► Pruff-server
6247
+ │
6248
+ ▼
6249
+ sls-Notification-Service / sls-Pruff-Webhooks (outbound)
6250
+ \`\`\`
6251
+
6252
+ \`sls-Pruff-Workers\` sits beside \`Pruff-server\` handling async/scheduled jobs that would block the API.
6253
+
6254
+ ---
6255
+
6256
+ ## What is Pruff
6257
+
6258
+ Pruff is a **multi-tenant B2B fintech platform** for Latin American financing institutions (factorings,
6259
+ credit companies, investment funds). It centralizes the full lifecycle of financial operations through
6260
+ three integrated modules:
6261
+
6262
+ \`\`\`
6263
+ DataHub (data ingestion) → Motor de Riesgo (risk/decision engine) → Core (operations)
6264
+ ↑_________________________retroalimentación_________________________↓
6265
+ \`\`\`
6266
+
6267
+ - **DataHub** — Consolidates client, debtor, and market data from external sources (SII, TGR, Bureau, banks).
6268
+ - **Motor de Riesgo** — Applies configurable scoring rules, compliance checks, exposure limits, and alerts.
6269
+ - **Core (FinancingHub)** — Executes approved operations: cesiones, disbursements, payment schedules,
6270
+ collections, reconciliation, and double-entry accounting.
6271
+
6272
+ ### Financial Products
6273
+
6274
+ | Product | What It Does |
6275
+ |---------|-------------|
6276
+ | **Factoring** | Client sells unpaid invoices for immediate liquidity. Core product. |
6277
+ | **Credits** | Structured financing with cuotas (installments) |
6278
+ | **Confirming** | Accelerated supplier payments (accounts payable) |
6279
+ | **Credit Lines** | Pre-approved revolving credit with partial draws |
6280
+ | **Funds** | Real-time visibility for investment funds over factoring portfolios |
6281
+
6282
+ ---
6283
+
6284
+ ## Multi-Tenancy Architecture
6285
+
6286
+ **Critical concept:** Pruff is **database-per-tenant**. Every tenant has its own MySQL database.
6287
+
6288
+ 1. User authenticates via JWT → \`secretId\` extracted from company
6289
+ 2. \`PrismaClientManager.getTenantClient(secretId)\` returns a tenant-scoped Prisma client
6290
+ 3. **Every service that needs DB access receives \`prisma\` (tenant-scoped) as its first parameter**
6291
+ 4. Use \`prismaGlobal\` only when the use case requires cross-tenant or shared data
6292
+
6293
+ ---
6294
+
6295
+ ## Pruff-server (Backend)
6296
+
6297
+ **Stack:** Node.js 22, Express 4, TypeScript 5, Prisma 5, MySQL 8, AWS (S3, SQS, SNS, Lambda, DynamoDB)
6298
+ **Package manager:** \`bun\` — \`bun.lock\` is the canonical lockfile. Use \`bun install\` / \`bun add <pkg>\`,
6299
+ never \`npm install\` or \`yarn\` (Docker/CI install with \`bun install --frozen-lockfile\`, so an
6300
+ npm/yarn-only change goes stale). \`npm run <script>\` still works for running scripts.
6301
+
6302
+ ### Commands
6303
+
6304
+ \`\`\`bash
6305
+ cd Pruff-server
6306
+
6307
+ npm run local # Run locally (uses local.env)
6308
+ npm run dev # Run against dev environment (uses dev.env)
6309
+ npm run build # Compile TypeScript (tsc + tsc-alias)
6310
+ npm run test # Jest tests (uses test.env, sequential with 1 worker)
6311
+ npm run test:file <name> # Run single jest test by file name (not path)
6312
+ npm run lint # ESLint
6313
+
6314
+ # Database (Prisma)
6315
+ npm run db-update-local # prisma migrate dev (create + apply migration, local)
6316
+ npm run db-migrate-local # prisma migrate deploy (apply existing migrations, local)
6317
+ npm run db-migrate-tenants-local # Migrate all tenant databases
6318
+ npm run db-seed-tenants-local # Seed all active tenant databases
6319
+ \`\`\`
6320
+
6321
+ ### Source Structure
6322
+
6323
+ \`\`\`
6324
+ src/
6325
+ controllers/ # Thin — validate input, call service, return response
6326
+ services/ # All business logic, organized by domain
6327
+ routes/
6328
+ api.ts # Main router
6329
+ internal/ # Staff endpoints (JWT auth)
6330
+ clients/ # Customer-facing endpoints (API key)
6331
+ external/ # Scraper/lambda endpoints (API key)
6332
+ public/ # No auth
6333
+ modules/ # Infrastructure wrappers (S3, SQS, SII, GoFirmex, etc.)
6334
+ middlewares/ # Auth, tenant resolution, rate limiting, error handling
6335
+ jobs/ # Cron jobs (iterate over all tenants)
6336
+ types/ # Shared TypeScript types
6337
+ lib/ # Utility functions
6338
+ \`\`\`
6339
+
6340
+ ### API Tiers
6341
+
6342
+ | Tier | Base Route | Auth | Middleware |
6343
+ |------|-----------|------|-----------|
6344
+ | Internal | \`/api/internal\` | JWT | \`authenticateJWT\` + \`attachUserPermissions\` |
6345
+ | External | \`/api/external\` | API Key | \`authenticateWithApiKey\` |
6346
+ | Clients | \`/api/clients\` | Client API Key | \`authenticateClientsWithApiKey\` |
6347
+ | Public | \`/api/public\` | None | — |
6348
+
6349
+ ### Service Pattern — Result\\<T\\>
6350
+
6351
+ All services return \`Result<T>\`. Never throw from services.
6352
+
6353
+ \`\`\`typescript
6354
+ type Result<T> = { ok: true; value: T } | { ok: false; error: AppError };
6355
+
6356
+ return { ok: true, value };
6357
+ return { ok: false, error: { status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', service: 'MyService', displayMessage: 'No encontrado' } };
6358
+ \`\`\`
6359
+
6360
+ ### Controller Pattern — handleApiOutput / handleApiError
6361
+
6362
+ \`\`\`typescript
6363
+ async someAction(req: UserRequest, res: Response) {
6364
+ try {
6365
+ const result = await SomeService.execute(req.prisma, req.body);
6366
+ return handleApiOutput('SomeService', req, res, result);
6367
+ } catch (error) {
6368
+ handleApiError('SomeService', req, res, error);
6369
+ }
6370
+ }
6371
+ \`\`\`
6372
+
6373
+ ### Naming Conventions
6374
+
6375
+ - **Services:** \`PascalCase\` + \`Service\` suffix → \`CreateFactoringRequestService.ts\`
6376
+ - **DTOs/interfaces:** \`PascalCase\` + \`Dto\` suffix → \`CreateFactoringRequestDto\`
6377
+ - **Controllers:** \`camelCase\` + \`Controller\` suffix → \`invoicesController.ts\`
6378
+ - **Services are singletons:** \`export default new MyService();\`
6379
+ - **\`prisma\` is always the first parameter** when the service needs DB access
6380
+
6381
+ ### Accounting Rules
6382
+
6383
+ - Every financial state change generates double-entry accounting entries automatically
6384
+ - \`SUM(debit) === SUM(credit)\` always — imbalanced entries are rejected
6385
+ - Entries use \`sourceKey\` to prevent duplicates and \`checkSum\` (SHA256) for audit integrity
6386
+ - Periods must be OPEN to accept entries
6387
+
6388
+ ### Environment Files
6389
+
6390
+ - \`local.env\` — local MySQL, mocked AWS services
6391
+ - \`dev.env\` — connects to \`api.dev.pruff.com\` dependencies
6392
+ - \`test.env\` — for Jest tests
6393
+
6394
+ ---
6395
+
6396
+ ## Pruff-Client (Frontend)
6397
+
6398
+ **Stack:** React 18, TypeScript, Vite, MUI v5, Redux Toolkit, TanStack React Query v5, Axios
6399
+ **Package manager:** \`yarn\` — \`yarn.lock\` is the canonical lockfile.
6400
+
6401
+ ### Commands
6402
+
6403
+ \`\`\`bash
6404
+ cd Pruff-Client
6405
+
6406
+ yarn start-local # Dev server → backend at http://localhost:80
6407
+ yarn start-dev # Dev server → backend at https://api.dev.pruff.com
6408
+ yarn start-prod # Dev server → backend at https://api.pruff.com
6409
+ yarn build # Production build
6410
+ yarn lint # ESLint
6411
+ yarn test # Jest + React Testing Library
6412
+ \`\`\`
6413
+
6414
+ ### Source Structure
6415
+
6416
+ \`\`\`
6417
+ src/
6418
+ pages/ # Feature modules
6419
+ lib/
6420
+ api/ # Axios API integration modules
6421
+ designSystem/ # Shared MUI-based components
6422
+ mobileDesignSystem/ # Mobile-specific components
6423
+ common/ # Domain-specific shared components
6424
+ hooks/ # Custom React hooks
6425
+ functions/ # Business logic functions (utils.ts, permission checks)
6426
+ types/ # TypeScript type definitions
6427
+ services/ # Redux store + API configuration (requestHandler)
6428
+ layouts/ # Layout components
6429
+ assets/ # Images, fonts, icons
6430
+ \`\`\`
6431
+
6432
+ ### State Management
6433
+
6434
+ - **React Query v5** — All server data (fetching, caching, mutations)
6435
+ - **Redux Toolkit** — Auth state, global alerts, client branding config only
6436
+ - **Redux Persist** — Persists auth across sessions
6437
+
6438
+ ### API Client
6439
+
6440
+ All HTTP goes through \`requestHandler\` (Axios wrapper in \`src/lib/api.ts\`). Import from the relevant
6441
+ module in \`src/lib/api/<domain>/\`.
6442
+
6443
+ ### Permission System
6444
+
6445
+ RBAC via \`user.roles[].permissions[].name\`. Key utilities in \`src/lib/functions/utils.ts\`:
6446
+ - \`validatePermissions(user, permission)\` — single check
6447
+ - \`hasAnyPermissions(user, permissions[])\` — OR logic
6448
+ - \`validateCompanyTypes(user, type)\` — product/feature gating
6449
+
6450
+ ---
6451
+
6452
+ ## Code Style (Both Repos)
6453
+
6454
+ - **Prettier:** \`printWidth: 80\`, \`singleQuote: true\`, \`trailingComma: 'all'\`, \`semi: true\`
6455
+ - **ESLint:** TypeScript strict, \`simple-import-sort\` enforced, \`no-console\` (warn/error)
6456
+ - **Import order** (enforced): external packages → absolute imports → relative imports, alphabetically sorted
6457
+
6458
+ ---
6459
+
6460
+ ## Chile-Specific Concepts
6461
+
6462
+ | Term | Meaning |
6463
+ |------|---------|
6464
+ | **RUT** | Chilean tax ID (e.g., \`12.345.678-9\`) |
6465
+ | **DTE** | Electronic tax document (33=factura, 34=exenta, 61=nota de crédito) |
6466
+ | **SII** | Servicio de Impuestos Internos — tax authority, source of invoices, F22, F29 |
6467
+ | **F22/F29** | Annual/monthly tax returns |
6468
+ | **TGR** | Tesorería General de la República — state treasury, tax debts |
6469
+ | **UF** | Unidad de Fomento — daily inflation-adjusted unit (CMF publishes value) |
6470
+ | **CMF** | Comisión para el Mercado Financiero — financial markets regulator |
6471
+ | **SGR** | Sociedad de Garantía Recíproca — mutual guarantee society for SMEs |
6472
+ | **Cedente** | Client selling receivables (factoring seller) |
6473
+ | **Deudor** | Invoice payer / debtor |
6474
+ | **Cesión** | Transfer of collection rights on an invoice |
6475
+ | **Giro/Desembolso** | Cash disbursement |
6476
+ | **Cobranza** | Collection management |
6477
+ | **Mora** | Payment delinquency |
6478
+ | **Conciliación** | Reconciling bank movements with operations |
6479
+ | **Provisión** | Recognized expected loss |
6480
+ | **Castigo** | Write-off of uncollectible amounts |
6481
+ | **Bureau** | Credit bureau with RUT-level payment history |
6482
+ | **Spread** | Profit margin between funding cost and lending rate |
6483
+
6484
+ ---
6485
+
6486
+ ## Pruff Knowledge
6487
+
6488
+ Durable, versioned knowledge (standards, architecture, how things work) lives in the \`Pruff Knowledge/\`
6489
+ repo cloned alongside this workspace by onboarding (\`Pruff-Tech/pruff-knowledge\`). Read it for context
6490
+ beyond this CLAUDE.md. Roadmap, releases, meeting notes, and tickets stay in Notion/Linear, not here.
6491
+
6492
+ **Start at a MOC** (\`_moc/\` — Maps of Content): the curated entry index per area. From there, follow the
6493
+ \`[[wikilinks]]\` to the concrete notes, or jump straight into the folder you need.
6494
+
6495
+ | Folder | What it covers |
6496
+ |---|---|
6497
+ | \`architecture/\` | Patterns and standards already implemented (SSE, error handling, multi-tenancy, database gateway…) |
6498
+ | \`infrastructure/\` | AWS services, ECS, Lambdas, and how they connect |
6499
+ | \`design-system/\` | UI components, tokens, and conventions |
6500
+ | \`operations/\` | Operational flow, modules, and how they fit together |
6501
+ | \`integrations/\` | Existing integrations and how they're used |
6502
+ | \`repos/\` | One doc per repository — what it does and how it connects |
6503
+ | \`guides/\` | How-tos (internal and external) |
6504
+ | \`glossary/\` | Domain terms — folio, cesión, RUT, mora, etc. |
6505
+ | \`postmortems/\` | Incidents — root cause and durable learnings (operational detail stays in Linear) |
6506
+
6507
+ Notes are frontmatter/filenames in English (\`kebab-case\`), body in Spanish. See the repo's own
6508
+ \`AGENTS.md\` for the agent-facing contract.
6509
+
6510
+ ---
6511
+
6512
+ ## Workflow
6513
+
6514
+ - **Always use \`/commit\`** — invoke the \`commit\` skill for all git commits. Never run \`git commit\` manually.
6515
+ - **Always use \`/create-pr\`** — invoke the \`create-pr\` skill for all pull requests. Never run \`gh pr create\` manually.
6516
+ - **Commits are authored to the human running Claude Code** — never set \`--author\` to Claude or any co-author.
6517
+ - **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — verify this against each repo's actual
6518
+ \`dev\` vs \`main\` state before assuming; it has drifted before. Exception: \`Pruff-server\` and
6519
+ \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`).
6520
+ - **Branch names embed the Linear issue code; PRs map 1:1 with Linear issues** — name branches so they
6521
+ contain the issue code (e.g. \`pru-1355-...\`) for Linear's git automation. When splitting work, plan
6522
+ the PR grouping first, create one Linear issue per PR (1:1), and stack dependent PRs
6523
+ (\`dev ← group-1 ← group-2 ← …\`) instead of one mega-PR. If more than one linear issue is in one PR, name of the branch
6524
+ should be the linear issue code of each issue.
6525
+ - **Be straightforward and concise** — lead with the core message, then the minimum supporting detail.
6526
+ Cut preamble and narration. When asked for a table or diagram, render real markdown, never a bullet
6527
+ list dressed up as one.
6528
+ - **Propose options and confirm before acting** — for any non-trivial design, format, or refactor choice,
6529
+ present alternatives with their tradeoffs in chat and wait for a pick before writing code or running
6530
+ commands. Gather context → confirm the plan → build.
6531
+ - **Explain the rationale, not just the diff** — when a change touches a non-obvious file (a new
6532
+ middleware, a \`Dockerfile\` edit, a cap/limit) or you're proposing an approach, explain in chat *why*
6533
+ it's needed and surface the cons/tradeoffs before adopting it.
6534
+ - **Numbers are verified facts, not estimates** — before stating a count, rate, latency, or cost, confirm
6535
+ it from the source (logs, metrics, a query) and cite the source + window inline. If you can't verify
6536
+ it, label it an estimate or omit it. Package-manager conventions, canonical branches, and other
6537
+ "what does this repo actually do" questions belong in this bucket too — check \`origin/main\`, not a
6538
+ possibly-stale local checkout or this file.
6539
+ - **Register out-of-scope findings in \`.claude/smell-findings/\`** — whenever you or a subagent spots a
6540
+ code smell, an unrelated bug, or an improvement opportunity outside the current task's scope, record
6541
+ it as a markdown file there instead of fixing it inline or dropping it.
6542
+ - Delegate context gathering to codebase-navigator agent if present, and implementation to implementer agent if present.
6543
+
6544
+ ---
6545
+
6546
+ ## Code Review
6547
+
6548
+ When reviewing PRs, always scope scans to the PR's changed files (never the whole repo). For
6549
+ react-doctor/diff tools, use PR-scoped diffs and manually verify risky logic.
6550
+
6551
+ ---
6552
+
6553
+ ## Critical Rules
6554
+
6555
+ 1. **Every backend service takes \`prisma\` as its first parameter** — tenant isolation depends on it.
6556
+ Never import or use a global Prisma instance inside a service.
6557
+ 2. **Services return \`Result<T>\`, never throw** — controllers handle the Result pattern.
6558
+ 3. **Accounting entries must balance** — debit total must equal credit total.
6559
+ 4. **Financial operations are fully auditable** — CloudWatch logs and approval chains must not be skipped.
6560
+ 5. **SII/RUT data is sensitive** — Credentials are encrypted via \`encryptionModule\`.
6561
+ 6. **Never modify \`prisma/schema.prisma\` without confirmation** — schema changes affect every tenant database.
6562
+ 7. **Never run DB migrations automatically** — propose the change and let the developer run it.
6563
+ 8. **Never push directly to \`dev\` or \`main\`** — all changes must go through a PR. No exceptions.
6564
+ `;
6565
+ const CONTROL_PLANE_PRESEED_SCRIPT = `import { PrismaClient } from '@prisma/client';
6566
+
6567
+ import seedRoles from './prisma/seeds/roles';
6568
+ import seedPermissions from './prisma/seeds/permissions';
6569
+ import seedRolesWithPermissions from './prisma/seeds/permissionsToRole';
6570
+ import seedCompanyTypes from './prisma/seeds/companyTypes';
6571
+
6572
+ const prisma = new PrismaClient();
6573
+
6574
+ async function main() {
6575
+ if ((await prisma.role.count()) === 0) await seedRoles();
6576
+ if ((await prisma.permission.count()) === 0) await seedPermissions();
6577
+ await seedRolesWithPermissions();
6578
+ await seedCompanyTypes();
6579
+ }
6580
+
6581
+ main()
6582
+ .catch((e) => {
6583
+ console.error(e);
6584
+ process.exit(1);
6585
+ })
6586
+ .finally(() => process.exit(0));
6587
+ `;
6588
+ const TENANT_USER_BACKFILL_SCRIPT = `import PrismaClientManager from './src/modules/prismaClient';
6589
+ import EncryptionModule from './src/modules/encryptionModule';
6590
+
6591
+ async function main() {
6592
+ const email = process.argv[2];
6593
+ const password = process.argv[3];
6594
+ const clientId = process.env.CLIENT_ID;
6595
+ if (!clientId) throw new Error('CLIENT_ID is required');
6596
+
6597
+ const tenantPrisma = PrismaClientManager.getGlobalClient();
6598
+ const existing = await tenantPrisma.user.findFirst({ where: { email } });
6599
+ if (existing) {
6600
+ console.log('tenant user already present: ' + email);
6601
+ return;
6602
+ }
6603
+
6604
+ const globalPrisma = PrismaClientManager.getGlobalClientFromTenantBase(clientId);
6605
+ const globalUser = await globalPrisma.user.findFirstOrThrow({ where: { email } });
6606
+ const encryptedPassword = await EncryptionModule.encrypt(password);
6607
+
6608
+ await tenantPrisma.user.create({
6609
+ data: {
6610
+ id: globalUser.id,
6611
+ email,
6612
+ name: email.split('@')[0],
6613
+ password: encryptedPassword,
6614
+ active: true,
6615
+ companyId: globalUser.companyId,
6616
+ roles: { connect: [{ id: 4 }] },
6617
+ },
6618
+ });
6619
+ console.log('tenant user created: ' + email + ' (id=' + globalUser.id + ')');
6620
+ }
6621
+
6622
+ main()
6623
+ .catch((e) => {
6624
+ console.error(e);
6625
+ process.exit(1);
6626
+ })
6627
+ .finally(() => process.exit(0));
6628
+ `;
6629
+ function renderLocalEnv(v) {
6630
+ const q = (value) => `"${value.replace(/"/g, "\\\"")}"`;
6631
+ return `DATABASE_URL=${q(`mysql://root@localhost:3306/pruff-local?connection_limit=5`)}
6632
+ NODE_ENV="local"
6633
+ IS_LOCAL_ENV="true"
6634
+ CLIENT_ID=${q(v.clientId)}
6635
+ CLIENT_RUT=${q(v.clientRut)}
6636
+ CLIENT_NAME=${q(v.clientName)}
6637
+ ADMIN_PASSWORD=${q(v.adminPassword)}
6638
+ JWT_SECRET=${q(v.jwtSecret)}
6639
+ API_KEY=${q(v.apiKey)}
6640
+ SECRET_SII_KEY=${q(v.siiKey)}
6641
+ SECRET_USER_REGISTRATION=${q(v.registrationSecret)}
6642
+ SECRET_PFX_CERTIFICATE=${q(v.pfxSecret)}
6643
+ USER_FORM_KEY=${q(v.userFormKey)}
6644
+ POSTHOG_ENABLED="false"
6645
+
6646
+ # --- Third-party integrations -------------------------------------------------
6647
+ # Left blank on purpose. Fill with real sandbox credentials from your team /
6648
+ # 1Password when you need the feature. The app boots and serves local data
6649
+ # without them.
6650
+ SII_SERVICE_URL=""
6651
+ SII_SERVICE_APIKEY=""
6652
+ TGR_SERVICE_URL=""
6653
+ TGR_SERVICE_APKEY=""
6654
+ AWS_ACCESS_KEY_ID=""
6655
+ AWS_SECRET_ACCESS_KEY=""
6656
+ POSTHOG_API_KEY=""
6657
+ POSTHOG_HOST=""
6658
+ BURO_USERNAME=""
6659
+ BURO_PASSWORD=""
6660
+ BURO_API_URL=""
6661
+ CMF_API_KEY=""
6662
+ MCP_OAUTH_PUBLIC_KEY=""
6663
+ MCP_ALLOWED_TENANTS=""
6664
+ `;
6665
+ }
6129
6666
  const CLAUDE_CODE_PKG = "@anthropic-ai/claude-code";
6130
6667
  const KNOWLEDGE_REPO = "https://github.com/Pruff-Tech/pruff-knowledge";
6131
6668
  const KNOWLEDGE_DIR = "Pruff Knowledge";
6132
6669
  const DEFAULT_WORKSPACE = join(homedir(), "pruff");
6133
6670
  const MAIN_REPOS = [{
6134
6671
  name: "Pruff-server",
6135
- url: "https://github.com/Pruff-Tech/Pruff-server"
6672
+ url: "https://github.com/Pruff-Tech/Pruff-server",
6673
+ installCmd: "bun",
6674
+ installArgs: ["install"]
6136
6675
  }, {
6137
6676
  name: "Pruff-Client",
6138
- url: "https://github.com/Pruff-Tech/Pruff-Client"
6677
+ url: "https://github.com/Pruff-Tech/Pruff-Client",
6678
+ installCmd: "yarn",
6679
+ installArgs: ["install"]
6139
6680
  }];
6140
6681
  const CORE_SKILLS = [
6141
6682
  "commit",
@@ -6161,12 +6702,16 @@ async function runOnboarding(args) {
6161
6702
  Ie(import_picocolors.default.bold("Pruff onboarding"));
6162
6703
  installClaudeCode();
6163
6704
  ensureObsidian();
6705
+ ensureBun();
6164
6706
  const workspace = await resolveWorkspace(args);
6165
6707
  mkdirSync(workspace, { recursive: true });
6166
6708
  M.success(`Workspace ${import_picocolors.default.cyan(workspace)}`);
6167
6709
  cloneKnowledge(workspace);
6168
- for (const repo of MAIN_REPOS) cloneRepo(repo.name, repo.url, workspace);
6710
+ for (const repo of MAIN_REPOS) cloneRepo(repo, workspace);
6711
+ for (const repo of MAIN_REPOS) installDeps(repo, workspace);
6712
+ writeWorkspaceClaudeMd(workspace);
6169
6713
  await installCatalog(workspace);
6714
+ if (await shouldSetupDatabase(args)) await setupLocalEnvironment(join(workspace, "Pruff-server"));
6170
6715
  await showGuidance(workspace);
6171
6716
  Se(`${import_picocolors.default.green("Done.")} Welcome to Pruff — happy building.`);
6172
6717
  }
@@ -6176,12 +6721,30 @@ function has(cmd) {
6176
6721
  stdio: "ignore"
6177
6722
  }).status === 0;
6178
6723
  }
6179
- function run(cmd, args, cwd) {
6724
+ function run(cmd, args, cwd, env) {
6180
6725
  return spawnSync(cmd, args, {
6181
6726
  stdio: "inherit",
6182
- cwd
6727
+ cwd,
6728
+ env: env ? {
6729
+ ...process.env,
6730
+ ...env
6731
+ } : void 0
6183
6732
  }).status === 0;
6184
6733
  }
6734
+ function capture(cmd, args, cwd, env) {
6735
+ const r = spawnSync(cmd, args, {
6736
+ encoding: "utf-8",
6737
+ cwd,
6738
+ env: env ? {
6739
+ ...process.env,
6740
+ ...env
6741
+ } : void 0
6742
+ });
6743
+ return {
6744
+ ok: r.status === 0,
6745
+ out: (r.stdout ?? "").trim()
6746
+ };
6747
+ }
6185
6748
  function expandHome(input) {
6186
6749
  return input.startsWith("~") ? join(homedir(), input.slice(1)) : input;
6187
6750
  }
@@ -6223,6 +6786,19 @@ function ensureObsidian() {
6223
6786
  M.warn(`Install manually: ${import_picocolors.default.cyan("brew install --cask obsidian")}`);
6224
6787
  }
6225
6788
  }
6789
+ function ensureBun() {
6790
+ if (has("bun")) {
6791
+ M.success("Bun already installed");
6792
+ return;
6793
+ }
6794
+ const s = Y();
6795
+ s.start("Installing Bun…");
6796
+ if (run("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"])) s.stop("Bun installed");
6797
+ else {
6798
+ s.stop(import_picocolors.default.yellow("Bun install failed"));
6799
+ M.warn(`Install it manually: ${import_picocolors.default.cyan("curl -fsSL https://bun.sh/install | bash")} (or ${import_picocolors.default.cyan("brew install oven-sh/bun/bun")})`);
6800
+ }
6801
+ }
6226
6802
  async function resolveWorkspace(args) {
6227
6803
  const fromArg = args.find((a) => !a.startsWith("-"));
6228
6804
  if (fromArg) return expandHome(fromArg);
@@ -6266,7 +6842,8 @@ function cloneKnowledge(workspace) {
6266
6842
  s.stop(import_picocolors.default.yellow("Knowledge clone failed"));
6267
6843
  M.warn(`Could not clone ${import_picocolors.default.cyan(KNOWLEDGE_REPO)}.\n It's private — authenticate first with ${import_picocolors.default.cyan("gh auth login")} (or an SSH key), then re-run.`);
6268
6844
  }
6269
- function cloneRepo(name, url, workspace) {
6845
+ function cloneRepo(repo, workspace) {
6846
+ const { name, url } = repo;
6270
6847
  const target = join(workspace, name);
6271
6848
  if (existsSync(join(target, ".git"))) {
6272
6849
  M.success(`${name} already cloned`);
@@ -6284,6 +6861,33 @@ function cloneRepo(name, url, workspace) {
6284
6861
  M.warn(`Could not clone ${name}. Authenticate with ${import_picocolors.default.cyan("gh auth login")} then run:\n ${import_picocolors.default.dim(`git clone ${url}`)} (inside ${workspace})`);
6285
6862
  }
6286
6863
  }
6864
+ function installDeps(repo, workspace) {
6865
+ const { name, installCmd, installArgs } = repo;
6866
+ const target = join(workspace, name);
6867
+ if (!existsSync(join(target, ".git"))) return;
6868
+ if (!has(installCmd)) {
6869
+ M.warn(`${import_picocolors.default.cyan(installCmd)} not found — skipping ${name} dependency install.\n Run manually once installed: ${import_picocolors.default.dim(`cd ${name} && ${installCmd} ${installArgs.join(" ")}`)}`);
6870
+ return;
6871
+ }
6872
+ const s = Y();
6873
+ s.start(`Installing ${name} dependencies (${installCmd})…`);
6874
+ const ok = run(installCmd, installArgs, target);
6875
+ if (ok && existsSync(join(target, "node_modules"))) s.stop(`${name} dependencies installed`);
6876
+ else {
6877
+ s.stop(import_picocolors.default.yellow(`${name} dependency install failed`));
6878
+ if (ok) M.warn(`${installCmd} exited 0 but ${name}/node_modules is missing — install likely no-opped.`);
6879
+ M.warn(`Run manually: ${import_picocolors.default.dim(`cd ${name} && ${installCmd} ${installArgs.join(" ")}`)}`);
6880
+ }
6881
+ }
6882
+ function writeWorkspaceClaudeMd(workspace) {
6883
+ const target = join(workspace, "CLAUDE.md");
6884
+ if (existsSync(target)) {
6885
+ M.success("Workspace CLAUDE.md already present — leaving it untouched");
6886
+ return;
6887
+ }
6888
+ writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
6889
+ M.success("Wrote workspace CLAUDE.md");
6890
+ }
6287
6891
  async function installCatalog(workspace) {
6288
6892
  const prevCwd = process.cwd();
6289
6893
  M.step("Installing Pruff rules, agents and skills into the workspace…");
@@ -6302,6 +6906,272 @@ async function installCatalog(workspace) {
6302
6906
  process.chdir(prevCwd);
6303
6907
  }
6304
6908
  }
6909
+ async function setupLocalEnvironment(serverPath) {
6910
+ if (!existsSync(join(serverPath, ".git"))) {
6911
+ M.warn("Pruff-server not cloned — skipping local database setup.");
6912
+ return;
6913
+ }
6914
+ if (!ensureMysql()) return;
6915
+ ensureDbeaver();
6916
+ generateLocalEnv(serverPath);
6917
+ const env = parseEnvFile(join(serverPath, "local.env"));
6918
+ if (!env.DATABASE_URL || !env.CLIENT_ID) {
6919
+ M.warn("local.env is missing DATABASE_URL / CLIENT_ID — skipping database setup.");
6920
+ return;
6921
+ }
6922
+ setupLocalDatabase(serverPath, env);
6923
+ }
6924
+ async function shouldSetupDatabase(args) {
6925
+ if (args.includes("--no-db")) return false;
6926
+ if (args.includes("--db")) return true;
6927
+ if (!process.stdout.isTTY) return false;
6928
+ const answer = await ye({
6929
+ message: "Set up the local Pruff-server database now? (installs MySQL + DBeaver, migrates both DBs, seeds a local \"pruff\" tenant)",
6930
+ initialValue: true
6931
+ });
6932
+ if (pD(answer)) return false;
6933
+ return answer === true;
6934
+ }
6935
+ function ensureMysql() {
6936
+ if (!has("mysql")) {
6937
+ if (!has("brew")) {
6938
+ M.warn(`MySQL not found and Homebrew is unavailable.\n Install MySQL, then re-run: ${import_picocolors.default.cyan("npx pruff-skills onboarding --db")}`);
6939
+ return false;
6940
+ }
6941
+ const s = Y();
6942
+ s.start("Installing MySQL (brew)…");
6943
+ const ok = run("brew", ["install", "mysql"]);
6944
+ s.stop(ok ? "MySQL installed" : import_picocolors.default.yellow("MySQL install failed"));
6945
+ if (!ok) return false;
6946
+ } else M.success("MySQL already installed");
6947
+ if (mysqlRunning()) {
6948
+ M.success("MySQL already running");
6949
+ return true;
6950
+ }
6951
+ const s = Y();
6952
+ s.start("Starting MySQL…");
6953
+ run("brew", [
6954
+ "services",
6955
+ "start",
6956
+ "mysql"
6957
+ ]);
6958
+ for (let i = 0; i < 30 && !mysqlRunning(); i++) spawnSync("sleep", ["1"]);
6959
+ const up = mysqlRunning();
6960
+ s.stop(up ? "MySQL running" : import_picocolors.default.yellow("MySQL did not come up"));
6961
+ if (!up) M.warn(`Start it manually: ${import_picocolors.default.cyan("brew services start mysql")}, then re-run with --db.`);
6962
+ return up;
6963
+ }
6964
+ function mysqlRunning() {
6965
+ return spawnSync("mysqladmin", [
6966
+ "ping",
6967
+ "-u",
6968
+ "root",
6969
+ "--silent"
6970
+ ], { stdio: "ignore" }).status === 0;
6971
+ }
6972
+ function ensureDbeaver() {
6973
+ if (existsSync("/Applications/DBeaver.app")) {
6974
+ M.success("DBeaver already installed");
6975
+ return;
6976
+ }
6977
+ if (!has("brew")) {
6978
+ M.warn(`DBeaver not found. Install it from ${import_picocolors.default.cyan("https://dbeaver.io")}.`);
6979
+ return;
6980
+ }
6981
+ const s = Y();
6982
+ s.start("Installing DBeaver (brew)…");
6983
+ const ok = run("brew", [
6984
+ "install",
6985
+ "--cask",
6986
+ "dbeaver-community"
6987
+ ]);
6988
+ s.stop(ok ? "DBeaver installed" : import_picocolors.default.yellow("DBeaver install failed"));
6989
+ }
6990
+ function generateLocalEnv(serverPath) {
6991
+ const target = join(serverPath, "local.env");
6992
+ if (existsSync(target)) {
6993
+ M.success("local.env already present — leaving it untouched");
6994
+ return;
6995
+ }
6996
+ const secret = () => randomBytes(16).toString("hex");
6997
+ writeFileSync(target, renderLocalEnv({
6998
+ clientId: "pruff",
6999
+ clientRut: "99.999.999-9",
7000
+ clientName: "Pruff Local",
7001
+ adminPassword: "pruff-local-1234",
7002
+ jwtSecret: secret(),
7003
+ apiKey: secret(),
7004
+ siiKey: secret(),
7005
+ registrationSecret: secret(),
7006
+ pfxSecret: secret(),
7007
+ userFormKey: secret()
7008
+ }), "utf-8");
7009
+ M.success("Wrote local.env (minimal bootable — fill third-party keys when needed)");
7010
+ }
7011
+ function parseEnvFile(path) {
7012
+ if (!existsSync(path)) return {};
7013
+ const out = {};
7014
+ for (const raw of readFileSync(path, "utf-8").split("\n")) {
7015
+ const line = raw.trim();
7016
+ if (!line || line.startsWith("#")) continue;
7017
+ const eq = line.indexOf("=");
7018
+ if (eq === -1) continue;
7019
+ const key = line.slice(0, eq).trim();
7020
+ let value = line.slice(eq + 1).trim();
7021
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
7022
+ out[key] = value;
7023
+ }
7024
+ return out;
7025
+ }
7026
+ function decideTenantAction(tenantDbExists, adminUserExists) {
7027
+ if (tenantDbExists && adminUserExists) return "already-provisioned";
7028
+ if (tenantDbExists) return "reset-and-seed";
7029
+ return "create-and-seed";
7030
+ }
7031
+ function parseMysqlUrl(url) {
7032
+ const u = new URL(url);
7033
+ return {
7034
+ host: u.hostname || "localhost",
7035
+ port: u.port || "3306",
7036
+ user: decodeURIComponent(u.username) || "root",
7037
+ password: u.password ? decodeURIComponent(u.password) : void 0
7038
+ };
7039
+ }
7040
+ function dbNameFromUrl(url) {
7041
+ return new URL(url).pathname.replace(/^\//, "");
7042
+ }
7043
+ function swapDbName(url, name) {
7044
+ const u = new URL(url);
7045
+ u.pathname = `/${name}`;
7046
+ return u.toString();
7047
+ }
7048
+ function mysqlArgs(conn) {
7049
+ const args = [
7050
+ "-h",
7051
+ conn.host,
7052
+ "-P",
7053
+ conn.port,
7054
+ "-u",
7055
+ conn.user
7056
+ ];
7057
+ if (conn.password) args.push(`-p${conn.password}`);
7058
+ return args;
7059
+ }
7060
+ function mysqlQuery(conn, sql) {
7061
+ return capture("mysql", [
7062
+ ...mysqlArgs(conn),
7063
+ "-N",
7064
+ "-B",
7065
+ "-e",
7066
+ sql
7067
+ ]);
7068
+ }
7069
+ function mysqlExec(conn, sql) {
7070
+ return run("mysql", [
7071
+ ...mysqlArgs(conn),
7072
+ "-e",
7073
+ sql
7074
+ ]);
7075
+ }
7076
+ function dbExists(conn, name) {
7077
+ const { out } = mysqlQuery(conn, `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${name}'`);
7078
+ return out.length > 0;
7079
+ }
7080
+ function tableExists(conn, db, table) {
7081
+ const { out } = mysqlQuery(conn, `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${db}' AND TABLE_NAME = '${table}'`);
7082
+ return out.length > 0;
7083
+ }
7084
+ function adminUserExists(conn, db, email) {
7085
+ if (!tableExists(conn, db, "User")) return false;
7086
+ const { ok, out } = mysqlQuery(conn, `SELECT COUNT(*) FROM \`${db}\`.\`User\` WHERE email = '${email}'`);
7087
+ return ok && parseInt(out, 10) > 0;
7088
+ }
7089
+ function migrateDeploy(serverPath, url) {
7090
+ return run("npx", [
7091
+ "prisma",
7092
+ "migrate",
7093
+ "deploy"
7094
+ ], serverPath, { DATABASE_URL: url });
7095
+ }
7096
+ function runDisposableScript(serverPath, fileName, contents, env, extraArgs = []) {
7097
+ const scriptPath = join(serverPath, fileName);
7098
+ writeFileSync(scriptPath, contents, "utf-8");
7099
+ try {
7100
+ return run("npx", [
7101
+ "ts-node",
7102
+ "--transpile-only",
7103
+ "-r",
7104
+ "tsconfig-paths/register",
7105
+ scriptPath,
7106
+ ...extraArgs
7107
+ ], serverPath, env);
7108
+ } finally {
7109
+ rmSync(scriptPath, { force: true });
7110
+ }
7111
+ }
7112
+ function setupLocalDatabase(serverPath, env) {
7113
+ const controlPlaneUrl = env.DATABASE_URL;
7114
+ const clientId = env.CLIENT_ID;
7115
+ const adminPassword = env.ADMIN_PASSWORD;
7116
+ if (!controlPlaneUrl || !clientId || !adminPassword) {
7117
+ M.warn("local.env is missing DATABASE_URL / CLIENT_ID / ADMIN_PASSWORD — skipping database setup.");
7118
+ return;
7119
+ }
7120
+ const conn = parseMysqlUrl(controlPlaneUrl);
7121
+ const controlPlaneName = dbNameFromUrl(controlPlaneUrl);
7122
+ const tenantName = `tenant-${clientId}`;
7123
+ const tenantUrl = swapDbName(controlPlaneUrl, tenantName);
7124
+ const adminEmail = `admin@${clientId}.com`;
7125
+ M.step(`Setting up local databases (${controlPlaneName} + ${tenantName})…`);
7126
+ mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${controlPlaneName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7127
+ if (!migrateDeploy(serverPath, controlPlaneUrl)) {
7128
+ M.warn("Control-plane migration failed — skipping the rest of database setup.");
7129
+ return;
7130
+ }
7131
+ runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
7132
+ ...env,
7133
+ DATABASE_URL: controlPlaneUrl
7134
+ });
7135
+ const action = decideTenantAction(dbExists(conn, tenantName), adminUserExists(conn, tenantName, adminEmail));
7136
+ if (action === "already-provisioned") {
7137
+ M.success(`Tenant "${clientId}" already provisioned — skipping seed.`);
7138
+ printLoginHint(clientId, adminEmail, adminPassword);
7139
+ return;
7140
+ }
7141
+ if (action === "reset-and-seed") {
7142
+ M.warn(`Tenant "${clientId}" partially provisioned — resetting (Pruff-server seed is not idempotent).`);
7143
+ mysqlExec(conn, `DROP DATABASE IF EXISTS \`${tenantName}\``);
7144
+ }
7145
+ mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${tenantName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7146
+ if (!migrateDeploy(serverPath, tenantUrl)) {
7147
+ M.warn("Tenant migration failed — skipping seed.");
7148
+ return;
7149
+ }
7150
+ const s = Y();
7151
+ s.start(`Seeding tenant "${clientId}"…`);
7152
+ const seeded = run("npx", [
7153
+ "prisma",
7154
+ "db",
7155
+ "seed"
7156
+ ], serverPath, {
7157
+ ...env,
7158
+ DATABASE_URL: tenantUrl
7159
+ });
7160
+ s.stop(seeded ? `Tenant "${clientId}" seeded` : import_picocolors.default.yellow("Tenant seed failed"));
7161
+ if (!seeded) return;
7162
+ if (!runDisposableScript(serverPath, ".pruff-onboarding-tenant-user.ts", TENANT_USER_BACKFILL_SCRIPT, {
7163
+ ...env,
7164
+ DATABASE_URL: tenantUrl
7165
+ }, [adminEmail, adminPassword])) {
7166
+ M.warn("Tenant admin-user backfill failed — you may need to create the user manually.");
7167
+ return;
7168
+ }
7169
+ M.success(`Local tenant "${clientId}" ready.`);
7170
+ printLoginHint(clientId, adminEmail, adminPassword);
7171
+ }
7172
+ function printLoginHint(clientId, email, password) {
7173
+ M.info(`Log in locally with:\n ${import_picocolors.default.dim("secretId:")} ${import_picocolors.default.cyan(clientId)}\n ${import_picocolors.default.dim("email: ")} ${import_picocolors.default.cyan(email)}\n ${import_picocolors.default.dim("password:")} ${import_picocolors.default.cyan(password)}`);
7174
+ }
6305
7175
  async function showGuidance(workspace) {
6306
7176
  console.log();
6307
7177
  await sweepLine("W O R K S P A C E R E A D Y", { slow: true });
@@ -6311,10 +7181,11 @@ async function showGuidance(workspace) {
6311
7181
  line("");
6312
7182
  line(`${import_picocolors.default.cyan(workspace)}/`);
6313
7183
  const tree = [
7184
+ ["CLAUDE.md", "workspace-level context — repo map, conventions, critical rules"],
6314
7185
  [".claude/", "rules · agents · skills (apply to every repo below)"],
6315
7186
  [`${KNOWLEDGE_DIR}/`, "Obsidian vault — open it in Obsidian"],
6316
- ["Pruff-server/", "backend"],
6317
- ["Pruff-Client/", "frontend"],
7187
+ ["Pruff-server/", "backend (bun)"],
7188
+ ["Pruff-Client/", "frontend (yarn)"],
6318
7189
  ["sls-*/", "serverless services — clone as you need them"]
6319
7190
  ];
6320
7191
  const labelWidth = Math.max(...tree.map(([label]) => label.length)) + 2;
@@ -6326,9 +7197,8 @@ async function showGuidance(workspace) {
6326
7197
  line(import_picocolors.default.bold("Next steps"));
6327
7198
  line(`${import_picocolors.default.dim("1.")} Open ${import_picocolors.default.bold("Obsidian")} → the ${import_picocolors.default.cyan(KNOWLEDGE_DIR)} vault is registered and ready.`);
6328
7199
  line(`${import_picocolors.default.dim("2.")} Start Claude Code from ${import_picocolors.default.cyan(workspace)} so the shared ${import_picocolors.default.dim(".claude/")} applies everywhere.`);
6329
- line(`${import_picocolors.default.dim("3.")} Backend: ${import_picocolors.default.dim("cd Pruff-server && npm i && npm run local")}`);
6330
- line(`${import_picocolors.default.dim("4.")} Frontend: ${import_picocolors.default.dim("cd Pruff-Client && yarn && yarn start-local")}`);
6331
- line(`${import_picocolors.default.dim("5.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
7200
+ line(`${import_picocolors.default.dim("3.")} Dependencies are installed — ${import_picocolors.default.dim("cd Pruff-server && npm run local")} / ${import_picocolors.default.dim("cd Pruff-Client && yarn start-local")}.`);
7201
+ line(`${import_picocolors.default.dim("4.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
6332
7202
  console.log();
6333
7203
  }
6334
7204
  function registerVault(vaultPath) {
@@ -6356,6 +7226,405 @@ function registerVault(vaultPath) {
6356
7226
  writeFileSync(configPath, JSON.stringify(config), "utf-8");
6357
7227
  M.success("Registered vault in Obsidian");
6358
7228
  }
7229
+ const execFileAsync = promisify(execFile);
7230
+ const BUZZ_LOGO_LINES = [
7231
+ "██████╗ ██╗ ██╗███████╗███████╗",
7232
+ "██╔══██╗██║ ██║╚══███╔╝╚══███╔╝",
7233
+ "██████╔╝██║ ██║ ███╔╝ ███╔╝ ",
7234
+ "██╔══██╗██║ ██║ ███╔╝ ███╔╝ ",
7235
+ "██████╔╝╚██████╔╝███████╗███████╗",
7236
+ "╚═════╝ ╚═════╝ ╚══════╝╚══════╝"
7237
+ ];
7238
+ const BUZZ_LETTER_CUTS = [
7239
+ 8,
7240
+ 17,
7241
+ 25,
7242
+ 33
7243
+ ];
7244
+ async function showBuzzLogo() {
7245
+ await showBlockLogo(BUZZ_LOGO_LINES, BUZZ_LETTER_CUTS);
7246
+ }
7247
+ const __dirname$1 = dirname(fileURLToPath(import.meta.url));
7248
+ const nestDir = () => process.env.BUZZ_NEST_DIR?.trim() || join(homedir(), ".buzz");
7249
+ function buzzAppDataDir() {
7250
+ if (process.env.BUZZ_APP_DATA_DIR?.trim()) return process.env.BUZZ_APP_DATA_DIR.trim();
7251
+ const home = homedir();
7252
+ switch (platform()) {
7253
+ case "darwin": return join(home, "Library", "Application Support", "xyz.block.buzz.app");
7254
+ case "win32": return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "xyz.block.buzz.app");
7255
+ default: return join(process.env.XDG_DATA_HOME || join(home, ".local", "share"), "xyz.block.buzz.app");
7256
+ }
7257
+ }
7258
+ const agentStorePath = () => join(buzzAppDataDir(), "agents", "managed-agents.json");
7259
+ const packsRoot = () => process.env.BUZZ_PACKS_DIR?.trim() || join(__dirname$1, "..", "packs");
7260
+ const VAULT_REPO = "https://github.com/Pruff-Tech/pruff-knowledge.git";
7261
+ const SKILLS_COMMON = [
7262
+ "commit",
7263
+ "create-pr",
7264
+ "handoff",
7265
+ "linear",
7266
+ "using-git-worktrees",
7267
+ "pruff-knowledge"
7268
+ ];
7269
+ const TEAM_SKILLS = {
7270
+ platform: [
7271
+ "prod-parity",
7272
+ "pruff-logs",
7273
+ "infra-healthcheck",
7274
+ "prisma-cli",
7275
+ "pruff-pr-review",
7276
+ "review-pr-comments",
7277
+ "logging-sweep"
7278
+ ],
7279
+ core: [
7280
+ "pruff-logs",
7281
+ "pruff-pr-review",
7282
+ "review-pr-comments",
7283
+ "logging-sweep",
7284
+ "prisma-cli"
7285
+ ],
7286
+ growth: [
7287
+ "tanstack-migrate",
7288
+ "react-doctor",
7289
+ "pr-visualizer",
7290
+ "pruff-pr-review"
7291
+ ]
7292
+ };
7293
+ const TEAM_AGENTS = {
7294
+ platform: [
7295
+ "codebase-navigator",
7296
+ "db-query-reviewer",
7297
+ "pruff-logs-investigator",
7298
+ "implementer"
7299
+ ],
7300
+ core: [
7301
+ "codebase-navigator",
7302
+ "pruff-logs-investigator",
7303
+ "implementer"
7304
+ ],
7305
+ growth: ["codebase-navigator", "implementer"]
7306
+ };
7307
+ const TEAMS = Object.keys(TEAM_SKILLS);
7308
+ async function isBuzzRunning() {
7309
+ try {
7310
+ const { stdout } = await execFileAsync("pgrep", ["-f", "Buzz.app|buzz-desktop|buzz-acp"]);
7311
+ return stdout.trim().length > 0;
7312
+ } catch {
7313
+ return false;
7314
+ }
7315
+ }
7316
+ async function claudeAuthStatus() {
7317
+ try {
7318
+ const { stdout } = await execFileAsync("claude", ["auth", "status"]);
7319
+ const parsed = JSON.parse(stdout);
7320
+ const ok = parsed.apiProvider === "firstParty";
7321
+ return {
7322
+ ok,
7323
+ detail: ok ? parsed.subscriptionType ?? "subscription" : JSON.stringify(parsed)
7324
+ };
7325
+ } catch (error) {
7326
+ return {
7327
+ ok: false,
7328
+ detail: error instanceof Error ? error.message : String(error)
7329
+ };
7330
+ }
7331
+ }
7332
+ async function checkPreconditions() {
7333
+ M.step("Checking preconditions");
7334
+ if (await isBuzzRunning()) {
7335
+ M.error("Buzz is running. Quit it first — it owns the agent store and would overwrite this.");
7336
+ process.exit(1);
7337
+ }
7338
+ M.success("Buzz is not running");
7339
+ const auth = await claudeAuthStatus();
7340
+ if (!auth.ok) {
7341
+ M.error("Claude is not on a subscription login. Run: claude auth login --claudeai");
7342
+ M.message(import_picocolors.default.dim(auth.detail));
7343
+ process.exit(1);
7344
+ }
7345
+ M.success(`Claude on subscription (${auth.detail})`);
7346
+ if (process.env.ANTHROPIC_API_KEY) {
7347
+ M.error("ANTHROPIC_API_KEY is set — this routes billing to the API instead of your seat. Unset it.");
7348
+ process.exit(1);
7349
+ }
7350
+ M.success("No ANTHROPIC_API_KEY in the environment");
7351
+ if (!existsSync(nestDir())) {
7352
+ M.error(`No nest at ${nestDir()}. Launch Buzz once to create it, then quit and re-run.`);
7353
+ process.exit(1);
7354
+ }
7355
+ M.success("Nest exists");
7356
+ }
7357
+ async function syncVault() {
7358
+ M.step("Pruff knowledge vault");
7359
+ const dest = join(nestDir(), "REPOS", "pruff-knowledge");
7360
+ if (existsSync(join(dest, ".git"))) try {
7361
+ await execFileAsync("git", [
7362
+ "-C",
7363
+ dest,
7364
+ "pull",
7365
+ "--ff-only",
7366
+ "--quiet"
7367
+ ]);
7368
+ const { stdout } = await execFileAsync("git", [
7369
+ "-C",
7370
+ dest,
7371
+ "log",
7372
+ "-1",
7373
+ "--format=%h %ad",
7374
+ "--date=short"
7375
+ ]);
7376
+ M.success(`updated → ${stdout.trim()}`);
7377
+ } catch {
7378
+ M.error(`pull failed — the clone has diverged (something committed there). Delete it and re-run: rm -rf "${dest}"`);
7379
+ process.exit(1);
7380
+ }
7381
+ else {
7382
+ mkdirSync(dirname(dest), { recursive: true });
7383
+ try {
7384
+ await execFileAsync("git", [
7385
+ "clone",
7386
+ "--quiet",
7387
+ VAULT_REPO,
7388
+ dest
7389
+ ]);
7390
+ const { stdout } = await execFileAsync("git", [
7391
+ "-C",
7392
+ dest,
7393
+ "log",
7394
+ "-1",
7395
+ "--format=%h %ad",
7396
+ "--date=short"
7397
+ ]);
7398
+ M.success(`cloned → ${stdout.trim()}`);
7399
+ } catch {
7400
+ M.error("clone failed — do you have read access to Pruff-Tech/pruff-knowledge?");
7401
+ process.exit(1);
7402
+ }
7403
+ }
7404
+ }
7405
+ async function installSkillsAndAgents(team) {
7406
+ M.step("Skills and subagents (installed into the nest, which is the agents' cwd)");
7407
+ const skills = [...SKILLS_COMMON, ...TEAM_SKILLS[team] ?? []];
7408
+ const subagents = TEAM_AGENTS[team] ?? [];
7409
+ const priorCwd = process.cwd();
7410
+ process.chdir(nestDir());
7411
+ try {
7412
+ for (const skill of skills) {
7413
+ if (existsSync(join(nestDir(), ".claude", "skills", skill))) {
7414
+ M.success(`${skill} (already present)`);
7415
+ continue;
7416
+ }
7417
+ try {
7418
+ const { source, options } = parseAddOptions([skill, "-y"]);
7419
+ await runAdd(source, options);
7420
+ M.success(skill);
7421
+ } catch {
7422
+ M.warn(`${import_picocolors.default.yellow(skill)} — not found in the catalog, skipped`);
7423
+ }
7424
+ }
7425
+ for (const agent of subagents) {
7426
+ if (existsSync(join(nestDir(), ".claude", "agents", `${agent}.md`))) {
7427
+ M.success(`subagent ${agent} (already present)`);
7428
+ continue;
7429
+ }
7430
+ try {
7431
+ await addAgents([agent], { yes: true });
7432
+ } catch {
7433
+ M.warn(`subagent ${import_picocolors.default.yellow(agent)} — not found, skipped`);
7434
+ }
7435
+ }
7436
+ } finally {
7437
+ process.chdir(priorCwd);
7438
+ }
7439
+ }
7440
+ const BASE_BUILTIN = {
7441
+ bumble: "builtin:bumble",
7442
+ honey: "builtin:honey",
7443
+ fizz: "builtin:fizz"
7444
+ };
7445
+ function roleOf(name) {
7446
+ return Object.keys(BASE_BUILTIN).find((role) => name.startsWith(role)) ?? "bumble";
7447
+ }
7448
+ async function composePersonaPrompt(packDir, personaFile, shared) {
7449
+ return `${(await readFile(join(packDir, personaFile), "utf8")).replace(/^---\n[\s\S]*?\n---\n/, "").trim()}\n\n---\n\n${shared}`;
7450
+ }
7451
+ async function seedPersonas(team) {
7452
+ M.step("Seeding persona definitions into Buzz's agent store");
7453
+ const packDir = join(packsRoot(), `pruff-${team}`);
7454
+ if (!existsSync(packDir)) {
7455
+ M.error(`No pack bundled for team "${team}" at ${packDir}.`);
7456
+ M.message(import_picocolors.default.dim(`Available: ${TEAMS.join(", ")}`));
7457
+ process.exit(1);
7458
+ }
7459
+ const store = agentStorePath();
7460
+ if (!existsSync(store)) {
7461
+ M.error(`No agent store at ${store}. Launch Buzz once, quit, then re-run.`);
7462
+ process.exit(1);
7463
+ }
7464
+ const backup = `${store}.backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
7465
+ copyFileSync(store, backup);
7466
+ M.success(`backup → ${backup}`);
7467
+ const shared = (await readFile(join(packDir, "instructions.md"), "utf8")).trim();
7468
+ const personaFiles = JSON.parse(await readFile(join(packDir, ".plugin", "plugin.json"), "utf8")).personas;
7469
+ const records = JSON.parse(readFileSync(store, "utf8"));
7470
+ const builtins = {};
7471
+ for (const r of records) if (!r.pubkey && r.slug) builtins[r.slug] = r;
7472
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7473
+ let added = 0;
7474
+ let updated = 0;
7475
+ for (const file of personaFiles) {
7476
+ const nameMatch = ((await readFile(join(packDir, file), "utf8")).match(/^---\n([\s\S]*?)\n---\n/)?.[1] ?? "").match(/^name:\s*"?([^"\n]+)"?/m);
7477
+ if (!nameMatch) {
7478
+ M.warn(`${file} has no name: in frontmatter, skipped`);
7479
+ continue;
7480
+ }
7481
+ const name = nameMatch[1].trim();
7482
+ const slug = `pruff:${name}`;
7483
+ const prompt = await composePersonaPrompt(packDir, file, shared);
7484
+ const base = builtins[BASE_BUILTIN[roleOf(name)]];
7485
+ const existing = records.find((r) => r.slug === slug);
7486
+ if (existing) {
7487
+ existing.system_prompt = prompt;
7488
+ existing.updated_at = now;
7489
+ updated++;
7490
+ } else if (base) {
7491
+ records.push({
7492
+ ...base,
7493
+ name,
7494
+ display_name: name,
7495
+ name_pool: [name],
7496
+ slug,
7497
+ is_builtin: false,
7498
+ is_active: true,
7499
+ system_prompt: prompt,
7500
+ parallelism: 4,
7501
+ persona_id: null,
7502
+ persona_source_version: null,
7503
+ created_at: now,
7504
+ updated_at: now
7505
+ });
7506
+ added++;
7507
+ } else {
7508
+ M.warn(`No builtin base found for "${name}" (role guessed: ${roleOf(name)}), skipped`);
7509
+ continue;
7510
+ }
7511
+ for (const r of records) if (r.pubkey && r.persona_id === slug) {
7512
+ r.system_prompt = prompt;
7513
+ r.updated_at = now;
7514
+ }
7515
+ }
7516
+ writeFileSync(store, JSON.stringify(records, null, 2), "utf8");
7517
+ M.success(`${added} persona(s) added, ${updated} updated`);
7518
+ }
7519
+ async function runSetup(team) {
7520
+ if (!team || !TEAMS.includes(team)) {
7521
+ M.error(`usage: pruff-skills buzz setup <${TEAMS.join("|")}>`);
7522
+ process.exit(1);
7523
+ }
7524
+ if (!existsSync(join(packsRoot(), `pruff-${team}`))) {
7525
+ M.error(`No pack bundled yet for "${team}". Run ${import_picocolors.default.cyan(`pruff-skills buzz packs init ${team}`)} first, write its personas, then re-run setup.`);
7526
+ process.exit(1);
7527
+ }
7528
+ Ie(import_picocolors.default.cyan(`pruff-skills buzz setup ${team}`));
7529
+ await checkPreconditions();
7530
+ await syncVault();
7531
+ await installSkillsAndAgents(team);
7532
+ await seedPersonas(team);
7533
+ Se([
7534
+ import_picocolors.default.bold("Remaining steps are in the Buzz app:"),
7535
+ "",
7536
+ " 1. Open Buzz.",
7537
+ " 2. Settings → Agents → Add agent, for each role:",
7538
+ " • runtime: Claude Code",
7539
+ ` • persona: pick the pruff:* persona seeded above`,
7540
+ ` • name: <role>-${team}-<your initials> e.g. bumble-${team}-ab`,
7541
+ " • login: Claude Subscription (NOT Anthropic Console)",
7542
+ " • Start on app launch: ON",
7543
+ " 3. Post your 64-char hex pubkey wherever your team collects them.",
7544
+ " 4. Test it: ask your bumble \"¿cómo maneja Pruff el multi-tenancy?\" —",
7545
+ " a cited answer (architecture/multi-tenancy.md) means it worked.",
7546
+ "",
7547
+ " This cannot be scripted further: an agent identity carries an owner",
7548
+ " attestation only the Buzz app can mint."
7549
+ ].join("\n"));
7550
+ }
7551
+ async function runSync(team) {
7552
+ if (!team || !TEAMS.includes(team)) {
7553
+ M.error(`usage: pruff-skills buzz sync <${TEAMS.join("|")}>`);
7554
+ M.message(import_picocolors.default.dim("Re-pushes persona prompts after you edit a pack — no vault pull, no skill install."));
7555
+ process.exit(1);
7556
+ }
7557
+ Ie(import_picocolors.default.cyan(`pruff-skills buzz sync ${team}`));
7558
+ await checkPreconditions();
7559
+ await seedPersonas(team);
7560
+ Se("Quit and relaunch Buzz is not needed — Buzz reads the store fresh on next start of that agent.");
7561
+ }
7562
+ function runPacksList() {
7563
+ console.log(import_picocolors.default.bold("Bundled packs:"));
7564
+ for (const team of TEAMS) {
7565
+ const dir = join(packsRoot(), `pruff-${team}`);
7566
+ console.log(` ${existsSync(dir) ? import_picocolors.default.green("✓") : import_picocolors.default.red("✗")} ${team} ${import_picocolors.default.dim(dir)}`);
7567
+ }
7568
+ }
7569
+ function runPacksInit(team) {
7570
+ if (!team) {
7571
+ M.error("usage: pruff-skills buzz packs init <team>");
7572
+ process.exit(1);
7573
+ }
7574
+ const src = join(packsRoot(), "pruff-platform");
7575
+ const dest = join(packsRoot(), `pruff-${team}`);
7576
+ if (existsSync(dest)) {
7577
+ M.error(`packs/pruff-${team} already exists.`);
7578
+ process.exit(1);
7579
+ }
7580
+ cpSync(src, dest, { recursive: true });
7581
+ M.success(`Scaffolded packs/pruff-${team} from pruff-platform. Edit the personas, then add "${team}" support in src/buzz.ts's TEAM_SKILLS / TEAM_AGENTS.`);
7582
+ }
7583
+ const BUZZ_HELP = `${import_picocolors.default.bold("Usage:")} pruff-skills buzz <command> [options]
7584
+
7585
+ Set up a Buzz (github.com/block/buzz) agent nest with the Pruff harness — the
7586
+ knowledge vault, team skills and subagents, and persona definitions. Buzz agents
7587
+ run from ~/.buzz, a different working directory than any code checkout, so
7588
+ nothing installed elsewhere is visible to them until it's installed here too.
7589
+
7590
+ ${import_picocolors.default.bold("Commands:")}
7591
+ setup <team> Full setup for one team's agents: platform | core | growth
7592
+ sync <team> Re-push persona prompts after editing a pack (no vault/skill work)
7593
+ packs list Show which team packs are bundled
7594
+ packs init <team> Scaffold a new team pack from pruff-platform
7595
+
7596
+ ${import_picocolors.default.bold("Examples:")}
7597
+ pruff-skills buzz setup platform
7598
+ pruff-skills buzz sync platform
7599
+ pruff-skills buzz packs list
7600
+
7601
+ Quit Buzz before running setup or sync — it owns the agent store while running.
7602
+ Creating the actual agents happens in the Buzz app; this command prepares
7603
+ everything up to that point.`;
7604
+ async function runBuzz(args) {
7605
+ const sub = args[0];
7606
+ const rest = args.slice(1);
7607
+ if (!sub || sub === "--help" || sub === "-h") {
7608
+ console.log(BUZZ_HELP);
7609
+ return;
7610
+ }
7611
+ switch (sub) {
7612
+ case "setup":
7613
+ await runSetup(rest[0]);
7614
+ return;
7615
+ case "sync":
7616
+ await runSync(rest[0]);
7617
+ return;
7618
+ case "packs":
7619
+ if (rest[0] === "list") return runPacksList();
7620
+ if (rest[0] === "init") return runPacksInit(rest[1]);
7621
+ console.log(BUZZ_HELP);
7622
+ return;
7623
+ default:
7624
+ console.log(`Unknown buzz command: ${sub}`);
7625
+ console.log(BUZZ_HELP);
7626
+ }
7627
+ }
6359
7628
  const __dirname = dirname(fileURLToPath(import.meta.url));
6360
7629
  function getVersion() {
6361
7630
  try {
@@ -6377,11 +7646,6 @@ const LOGO_LINES = [
6377
7646
  "██║ ██║ ██║╚██████╔╝██║ ██║ ",
6378
7647
  "╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ "
6379
7648
  ];
6380
- function printStaticLogo() {
6381
- LOGO_LINES.forEach((line, i) => {
6382
- console.log(`${BLUES[i]}${line}${RESET}`);
6383
- });
6384
- }
6385
7649
  const LETTER_CUTS = [
6386
7650
  8,
6387
7651
  16,
@@ -6389,116 +7653,71 @@ const LETTER_CUTS = [
6389
7653
  33,
6390
7654
  41
6391
7655
  ];
6392
- function renderLogoReveal(cut, width) {
6393
- return LOGO_LINES.map((line, i) => {
6394
- const shown = Array.from(line).slice(0, cut);
6395
- const pad = " ".repeat(Math.max(0, width - shown.length));
6396
- return `${BLUES[i]}${shown.join("")}${RESET}${pad}`;
6397
- }).join("\n");
6398
- }
6399
- function renderLogoFrame(pos, band) {
6400
- return LOGO_LINES.map((line, i) => {
6401
- const chars = Array.from(line);
6402
- let out = "";
6403
- let mode = "";
6404
- for (let c = 0; c < chars.length; c++) {
6405
- const color = c >= pos && c < pos + band ? SWEEP : BLUES[i];
6406
- if (color !== mode) {
6407
- out += color;
6408
- mode = color;
6409
- }
6410
- out += chars[c];
6411
- }
6412
- return out + RESET;
6413
- }).join("\n");
6414
- }
6415
7656
  async function showLogo(slow = false) {
6416
- console.log();
6417
- if (!(!!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR && !process.env.PRUFF_SKILLS_NO_ANIM)) {
6418
- printStaticLogo();
6419
- return;
6420
- }
6421
- const rows = LOGO_LINES.length;
6422
- const width = Math.max(...LOGO_LINES.map((l) => Array.from(l).length));
6423
- const band = 7;
6424
- const revealMs = slow ? 190 : 90;
6425
- const step = slow ? 3 : 4;
6426
- const sweepMs = slow ? 34 : 18;
6427
- process.stdout.write("\x1B[?25l");
6428
- try {
6429
- let first = true;
6430
- for (const cut of LETTER_CUTS) {
6431
- if (!first) process.stdout.write(`\x1b[${rows}A`);
6432
- process.stdout.write("\r" + renderLogoReveal(cut, width) + "\n");
6433
- first = false;
6434
- await sleep(revealMs);
6435
- }
6436
- for (let pos = -7; pos <= width; pos += step) {
6437
- process.stdout.write(`\x1b[${rows}A`);
6438
- process.stdout.write("\r" + renderLogoFrame(pos, band) + "\n");
6439
- await sleep(sweepMs);
6440
- }
6441
- process.stdout.write(`\x1b[${rows}A\r`);
6442
- process.stdout.write(LOGO_LINES.map((line, i) => `${BLUES[i]}${line}${RESET}`).join("\n") + "\n");
6443
- if (slow) await sleep(500);
6444
- } finally {
6445
- process.stdout.write("\x1B[?25h");
6446
- }
7657
+ await showBlockLogo(LOGO_LINES, LETTER_CUTS, { slow });
6447
7658
  }
6448
- const TAGLINE_BLOCKS = [[
7659
+ const BUILDING_LINES = [
6449
7660
  "██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗███╗ ██╗ ██████╗ ",
6450
7661
  "██╔══██╗██║ ██║██║██║ ██╔══██╗██║████╗ ██║██╔════╝ ",
6451
7662
  "██████╔╝██║ ██║██║██║ ██║ ██║██║██╔██╗ ██║██║ ███╗",
6452
7663
  "██╔══██╗██║ ██║██║██║ ██║ ██║██║██║╚██╗██║██║ ██║",
6453
7664
  "██████╔╝╚██████╔╝██║███████╗██████╔╝██║██║ ╚████║╚██████╔╝",
6454
7665
  "╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ "
6455
- ], [
7666
+ ];
7667
+ const FINTECHS_LINES = [
6456
7668
  "███████╗██╗███╗ ██╗████████╗███████╗ ██████╗██╗ ██╗███████╗",
6457
7669
  "██╔════╝██║████╗ ██║╚══██╔══╝██╔════╝██╔════╝██║ ██║██╔════╝",
6458
7670
  "█████╗ ██║██╔██╗ ██║ ██║ █████╗ ██║ ███████║███████╗",
6459
7671
  "██╔══╝ ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██╔══██║╚════██║",
6460
7672
  "██║ ██║██║ ╚████║ ██║ ███████╗╚██████╗██║ ██║███████║",
6461
7673
  "╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝"
6462
- ]];
6463
- const TAGLINE_INDENT = " ";
6464
- function renderTaglineRow(line, row, pos, band) {
7674
+ ];
7675
+ const TAGLINE_INDENT = "";
7676
+ const TAGLINE_ROWS = BUILDING_LINES.length + FINTECHS_LINES.length;
7677
+ const WHITES = [
7678
+ "\x1B[38;2;255;255;255m",
7679
+ "\x1B[38;2;228;230;236m",
7680
+ "\x1B[38;2;201;205;216m",
7681
+ "\x1B[38;2;174;180;196m",
7682
+ "\x1B[38;2;148;155;176m",
7683
+ "\x1B[38;2;122;131;156m"
7684
+ ];
7685
+ function renderRevealRow(line, row, cols, fromLeft) {
6465
7686
  const chars = Array.from(line);
6466
- let out = TAGLINE_INDENT;
6467
- let mode = "";
6468
- for (let c = 0; c < chars.length; c++) {
6469
- const color = c >= pos && c < pos + band ? SWEEP : BLUES[row];
6470
- if (color !== mode) {
6471
- out += color;
6472
- mode = color;
6473
- }
6474
- out += chars[c];
7687
+ const w = chars.length;
7688
+ let out = TAGLINE_INDENT + WHITES[Math.min(WHITES.length - 1, row)];
7689
+ for (let c = 0; c < w; c++) {
7690
+ const shown = fromLeft ? c < cols : c >= w - cols;
7691
+ out += shown ? chars[c] : " ";
6475
7692
  }
6476
7693
  return out + RESET;
6477
7694
  }
6478
- function renderTaglineFrame(pos, band) {
6479
- return TAGLINE_BLOCKS.map((block) => block.map((line, row) => renderTaglineRow(line, row, pos, band)).join("\n")).join("\n");
7695
+ function renderTaglineReveal(cols) {
7696
+ const rows = [];
7697
+ BUILDING_LINES.forEach((line, row) => rows.push(renderRevealRow(line, row, cols, true)));
7698
+ FINTECHS_LINES.forEach((line, row) => rows.push(renderRevealRow(line, row, cols, false)));
7699
+ return rows.join("\n");
6480
7700
  }
6481
7701
  async function showTagline(slow = false) {
6482
7702
  console.log();
6483
- const rows = TAGLINE_BLOCKS.reduce((n, b) => n + b.length, 0);
6484
- const width = Math.max(...TAGLINE_BLOCKS.flat().map((l) => Array.from(l).length));
6485
- const band = 7;
6486
- const step = slow ? 3 : 4;
6487
- const sweepMs = slow ? 34 : 18;
7703
+ const rows = TAGLINE_ROWS;
7704
+ const width = Math.max(...[...BUILDING_LINES, ...FINTECHS_LINES].map((l) => Array.from(l).length));
7705
+ const step = slow ? 2 : 3;
7706
+ const ms = slow ? 26 : 14;
6488
7707
  if (!(!!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR && !process.env.PRUFF_SKILLS_NO_ANIM)) {
6489
- console.log(renderTaglineFrame(-1, 0));
7708
+ console.log(renderTaglineReveal(width));
6490
7709
  return;
6491
7710
  }
6492
7711
  process.stdout.write("\x1B[?25l");
6493
7712
  try {
6494
7713
  let first = true;
6495
- for (let pos = -7; pos <= width; pos += step) {
7714
+ for (let cols = 0; cols <= width; cols += step) {
6496
7715
  if (!first) process.stdout.write(`\x1b[${rows}A`);
6497
- process.stdout.write("\r" + renderTaglineFrame(pos, band) + "\n");
7716
+ process.stdout.write("\r" + renderTaglineReveal(cols) + "\n");
6498
7717
  first = false;
6499
- await sleep(sweepMs);
7718
+ await sleep(ms);
6500
7719
  }
6501
- process.stdout.write(`\x1b[${rows}A\r` + renderTaglineFrame(-1, 0) + "\n");
7720
+ process.stdout.write(`\x1b[${rows}A\r` + renderTaglineReveal(width) + "\n");
6502
7721
  if (slow) await sleep(500);
6503
7722
  } finally {
6504
7723
  process.stdout.write("\x1B[?25h");
@@ -6757,6 +7976,10 @@ async function main() {
6757
7976
  if (!inAgent) await showLogo();
6758
7977
  await runRules(restArgs);
6759
7978
  break;
7979
+ case "buzz":
7980
+ if (!inAgent) await showBuzzLogo();
7981
+ await runBuzz(restArgs);
7982
+ break;
6760
7983
  case "onboarding":
6761
7984
  case "onboard":
6762
7985
  if (!inAgent) {
@@ -6778,7 +8001,7 @@ async function main() {
6778
8001
  case "experimental_sync": {
6779
8002
  if (!inAgent) await showLogo();
6780
8003
  const { options: syncOptions } = parseSyncOptions(restArgs);
6781
- await runSync(restArgs, syncOptions);
8004
+ await runSync$1(restArgs, syncOptions);
6782
8005
  break;
6783
8006
  }
6784
8007
  case "list":