pruff-skills 0.2.0 → 0.4.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
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { __toESM } from "./_chunks/rolldown-runtime.mjs";
3
3
  import { pD, require_picocolors } from "./_chunks/libs/@clack/core.mjs";
4
- import { Ie, M, Me, Se, Y, be, fe, ve, xe, ye } from "./_chunks/libs/@clack/prompts.mjs";
4
+ import { Ie, M, Me, Se, Y, be, fe, he, ve, xe, ye } from "./_chunks/libs/@clack/prompts.mjs";
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 { 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";
@@ -16,7 +16,7 @@ import { promisify } from "util";
16
16
  import { execFile, execSync, spawn, spawnSync } from "child_process";
17
17
  import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat, symlink, writeFile } from "fs/promises";
18
18
  import { parse } from "yaml";
19
- import { createHash } from "crypto";
19
+ import { createHash, randomBytes } from "crypto";
20
20
  import { createHash as createHash$1 } from "node:crypto";
21
21
  import { gunzipSync, inflateRawSync } from "node:zlib";
22
22
  var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
@@ -555,7 +555,7 @@ function buildGitHubAuthError(url, repo, message) {
555
555
  if (repo) return `Authentication failed for ${url}.\n - For private repos, ensure you have access\n - Retry with SSH: npx pruff-skills add ${repo.sshUrl}\n - Check access with: gh auth status -h github.com or ssh -T git@github.com`;
556
556
  return `Authentication failed for ${url}.\n - For private repos, ensure you have access\n - For SSH: Check your keys with 'ssh -T git@github.com'\n - For HTTPS: Run 'gh auth login' or configure git credentials`;
557
557
  }
558
- async function cloneRepo(url, ref) {
558
+ async function cloneRepo$1(url, ref) {
559
559
  const tempDir = await mkdtemp(join(tmpdir(), "skills-"));
560
560
  const cloneOptions = ref ? [
561
561
  "--depth",
@@ -3467,7 +3467,7 @@ async function runAdd(args, options = {}) {
3467
3467
  spinner.stop(`Found ${import_picocolors.default.green(skills.length)} skill${skills.length > 1 ? "s" : ""}`);
3468
3468
  } else {
3469
3469
  spinner.start("Cloning repository...");
3470
- tempDir = await cloneRepo(parsed.url, parsed.ref);
3470
+ tempDir = await cloneRepo$1(parsed.url, parsed.ref);
3471
3471
  spinner.stop("Repository cloned");
3472
3472
  spinner.start("Discovering skills...");
3473
3473
  skills = await discoverSkills(tempDir, parsed.subpath, {
@@ -3477,7 +3477,7 @@ async function runAdd(args, options = {}) {
3477
3477
  }
3478
3478
  } else {
3479
3479
  spinner.start("Cloning repository...");
3480
- tempDir = await cloneRepo(parsed.url, parsed.ref);
3480
+ tempDir = await cloneRepo$1(parsed.url, parsed.ref);
3481
3481
  spinner.stop("Repository cloned");
3482
3482
  spinner.start("Discovering skills...");
3483
3483
  skills = await discoverSkills(tempDir, parsed.subpath, {
@@ -4020,7 +4020,7 @@ async function fetchAgentFile(source, name) {
4020
4020
  return null;
4021
4021
  }
4022
4022
  try {
4023
- const file = join(await cloneRepo(source), rel);
4023
+ const file = join(await cloneRepo$1(source), rel);
4024
4024
  return existsSync(file) ? readFile(file, "utf8") : null;
4025
4025
  } catch {
4026
4026
  return null;
@@ -4134,7 +4134,7 @@ async function fetchFromCatalog(source, rel) {
4134
4134
  return null;
4135
4135
  }
4136
4136
  try {
4137
- const file = join(await cloneRepo(source), rel);
4137
+ const file = join(await cloneRepo$1(source), rel);
4138
4138
  return existsSync(file) ? readFile(file, "utf8") : null;
4139
4139
  } catch {
4140
4140
  return null;
@@ -5466,7 +5466,7 @@ async function updateGlobalSkills(options = {}) {
5466
5466
  }
5467
5467
  continue;
5468
5468
  }
5469
- tempDir = await cloneRepo(sourceUrl, firstEntry.ref);
5469
+ tempDir = await cloneRepo$1(sourceUrl, firstEntry.ref);
5470
5470
  const discoveredPaths = (await discoverSkills(tempDir)).map((skill) => {
5471
5471
  return join(relative(tempDir, skill.path), "SKILL.md").split(sep).join("/");
5472
5472
  });
@@ -5622,7 +5622,7 @@ async function updateProjectSkills(options = {}) {
5622
5622
  let tempDir = null;
5623
5623
  let deletedSkills = [];
5624
5624
  try {
5625
- tempDir = await cloneRepo(sourceUrl, ref);
5625
+ tempDir = await cloneRepo$1(sourceUrl, ref);
5626
5626
  const discoveredPaths = (await discoverSkills(tempDir)).map((s) => {
5627
5627
  return join(relative(tempDir, s.path), "SKILL.md").split(sep).join("/");
5628
5628
  });
@@ -5841,14 +5841,14 @@ async function runUse(sourceArgs, options = {}, parseErrors = []) {
5841
5841
  });
5842
5842
  if (blobResult) skills = blobResult.skills;
5843
5843
  else {
5844
- cloneTempDir = await cloneRepo(parsed.url, parsed.ref);
5844
+ cloneTempDir = await cloneRepo$1(parsed.url, parsed.ref);
5845
5845
  skills = await discoverSkills(cloneTempDir, parsed.subpath, {
5846
5846
  includeInternal,
5847
5847
  fullDepth: options.fullDepth
5848
5848
  });
5849
5849
  }
5850
5850
  } else {
5851
- cloneTempDir = await cloneRepo(parsed.url, parsed.ref);
5851
+ cloneTempDir = await cloneRepo$1(parsed.url, parsed.ref);
5852
5852
  skills = await discoverSkills(cloneTempDir, parsed.subpath, {
5853
5853
  includeInternal,
5854
5854
  fullDepth: options.fullDepth
@@ -6071,6 +6071,1099 @@ function fail(message) {
6071
6071
  process.exit(1);
6072
6072
  }
6073
6073
  var UseCommandError = class extends Error {};
6074
+ const RESET = "\x1B[0m";
6075
+ const DIM = "\x1B[38;5;102m";
6076
+ const BLUES = [
6077
+ "\x1B[38;2;110;139;255m",
6078
+ "\x1B[38;2;91;118;249m",
6079
+ "\x1B[38;2;72;97;242m",
6080
+ "\x1B[38;2;53;77;236m",
6081
+ "\x1B[38;2;34;56;229m",
6082
+ "\x1B[38;2;15;35;223m"
6083
+ ];
6084
+ const SWEEP = "\x1B[38;2;214;226;255m";
6085
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6086
+ function canAnimate() {
6087
+ return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR && !process.env.PRUFF_SKILLS_NO_ANIM;
6088
+ }
6089
+ function columnColor(col, width) {
6090
+ return BLUES[Math.min(BLUES.length - 1, Math.floor(col / Math.max(1, width - 1) * BLUES.length))];
6091
+ }
6092
+ function renderSweepLine(chars, indent, pos, band) {
6093
+ let out = indent;
6094
+ let mode = "";
6095
+ for (let c = 0; c < chars.length; c++) {
6096
+ const color = c >= pos && c < pos + band ? SWEEP : columnColor(c, chars.length);
6097
+ if (color !== mode) {
6098
+ out += color;
6099
+ mode = color;
6100
+ }
6101
+ out += chars[c];
6102
+ }
6103
+ return out + RESET;
6104
+ }
6105
+ async function sweepLine(text, { slow = false, indent = " " } = {}) {
6106
+ const chars = Array.from(text);
6107
+ const width = chars.length;
6108
+ const band = 5;
6109
+ const step = slow ? 2 : 3;
6110
+ const ms = slow ? 42 : 24;
6111
+ if (!canAnimate()) {
6112
+ console.log(renderSweepLine(chars, indent, -1, 0));
6113
+ return;
6114
+ }
6115
+ process.stdout.write("\x1B[?25l");
6116
+ try {
6117
+ let first = true;
6118
+ for (let pos = -5; pos <= width; pos += step) {
6119
+ if (!first) process.stdout.write("\x1B[1A");
6120
+ process.stdout.write("\r" + renderSweepLine(chars, indent, pos, band) + "\n");
6121
+ first = false;
6122
+ await sleep(ms);
6123
+ }
6124
+ process.stdout.write("\x1B[1A\r" + renderSweepLine(chars, indent, -1, 0) + "\n");
6125
+ } finally {
6126
+ process.stdout.write("\x1B[?25h");
6127
+ }
6128
+ }
6129
+ const WORKSPACE_CLAUDE_TEMPLATE = `# Pruff — Working Directory
6130
+
6131
+ This folder is the working directory for Pruff's frontend and backend. The two main git repos are:
6132
+
6133
+ - **\`Pruff-Client/\`** — React frontend
6134
+ - **\`Pruff-server/\`** — Node.js/Express backend
6135
+
6136
+ Other directories (\`sls-*/\`, \`slsl-*/\`, \`AWS-Tools/\`, etc.) are serverless microservices and tooling —
6137
+ clone them into this workspace as you need them, and don't modify them unless explicitly asked.
6138
+
6139
+ Each repo has its own \`CLAUDE.md\` and \`.claude/rules/\` with repo-specific patterns. This file covers
6140
+ workspace-level context only.
6141
+
6142
+ ---
6143
+
6144
+ ## Workspace Layout
6145
+
6146
+ ### Main applications (modify freely when asked)
6147
+
6148
+ | Folder | What it is |
6149
+ |---|---|
6150
+ | \`Pruff-Client/\` | React 18 + Vite + MUI frontend. React Query + Redux Toolkit. |
6151
+ | \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant). |
6152
+
6153
+ ### Serverless microservices (\`sls-*/\`, \`slsl-*/\` — clone as needed, don't modify unless asked)
6154
+
6155
+ All live in the same AWS account, share the \`lambda-sqs\` IAM role, and most use a shared
6156
+ \`prisma-client-layer\` Lambda layer so they can reach tenant databases. They talk to \`Pruff-server\`
6157
+ over HTTPS with an \`x-api-key\`.
6158
+
6159
+ | Folder | Role |
6160
+ |---|---|
6161
+ | \`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\`. |
6162
+ | \`sls-TGR-Extraction/\` | **TGR (treasury) scrapers.** Extracts tax collections, debts, payment agreements. Pushes results into the data-injection pipeline. |
6163
+ | \`sls-TGR-Services/\` | **TGR-adjacent business logic.** Smaller sibling to \`sls-TGR-Extraction\`. |
6164
+ | \`sls-Public-Market/\` | **Mercado Público (Chilean public tenders) pipeline.** Awarded-tender fetcher, tender analyzer, lead generator. |
6165
+ | \`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. |
6166
+ | \`sls-Decision-Engine/\` | **Risk / Motor de Riesgo executor.** Three-stage SQS pipeline: \`prepare\` → \`delayedPrepare\` → \`executor\`. |
6167
+ | \`sls-Notification-Service/\` | **Outbound notifications** (email, in-app, collections). |
6168
+ | \`sls-Pruff-Webhooks/\` | **Outbound webhook delivery** to client integrations. |
6169
+ | \`sls-Pruff-Workers/\` | **Async background jobs for Pruff-server** (PDF rendering, tenant document/summary sync crons). |
6170
+
6171
+ ### Tooling & utilities
6172
+
6173
+ | Folder | What it is |
6174
+ |---|---|
6175
+ | \`AWS-Tools/\` | Standalone Node/TypeScript scripts for AWS ops — not deployed; run locally with AWS creds. |
6176
+
6177
+ ### Cross-service data flow (mental model)
6178
+
6179
+ \`\`\`
6180
+ sls-SII-Services ─┐
6181
+ sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
6182
+ sls-Public-Market ─┘ │
6183
+ ▼
6184
+ sls-Decision-Engine ──► Pruff-server
6185
+ │
6186
+ ▼
6187
+ sls-Notification-Service / sls-Pruff-Webhooks (outbound)
6188
+ \`\`\`
6189
+
6190
+ \`sls-Pruff-Workers\` sits beside \`Pruff-server\` handling async/scheduled jobs that would block the API.
6191
+
6192
+ ---
6193
+
6194
+ ## What is Pruff
6195
+
6196
+ Pruff is a **multi-tenant B2B fintech platform** for Latin American financing institutions (factorings,
6197
+ credit companies, investment funds). It centralizes the full lifecycle of financial operations through
6198
+ three integrated modules:
6199
+
6200
+ \`\`\`
6201
+ DataHub (data ingestion) → Motor de Riesgo (risk/decision engine) → Core (operations)
6202
+ ↑_________________________retroalimentación_________________________↓
6203
+ \`\`\`
6204
+
6205
+ - **DataHub** — Consolidates client, debtor, and market data from external sources (SII, TGR, Bureau, banks).
6206
+ - **Motor de Riesgo** — Applies configurable scoring rules, compliance checks, exposure limits, and alerts.
6207
+ - **Core (FinancingHub)** — Executes approved operations: cesiones, disbursements, payment schedules,
6208
+ collections, reconciliation, and double-entry accounting.
6209
+
6210
+ ### Financial Products
6211
+
6212
+ | Product | What It Does |
6213
+ |---------|-------------|
6214
+ | **Factoring** | Client sells unpaid invoices for immediate liquidity. Core product. |
6215
+ | **Credits** | Structured financing with cuotas (installments) |
6216
+ | **Confirming** | Accelerated supplier payments (accounts payable) |
6217
+ | **Credit Lines** | Pre-approved revolving credit with partial draws |
6218
+ | **Funds** | Real-time visibility for investment funds over factoring portfolios |
6219
+
6220
+ ---
6221
+
6222
+ ## Multi-Tenancy Architecture
6223
+
6224
+ **Critical concept:** Pruff is **database-per-tenant**. Every tenant has its own MySQL database.
6225
+
6226
+ 1. User authenticates via JWT → \`secretId\` extracted from company
6227
+ 2. \`PrismaClientManager.getTenantClient(secretId)\` returns a tenant-scoped Prisma client
6228
+ 3. **Every service that needs DB access receives \`prisma\` (tenant-scoped) as its first parameter**
6229
+ 4. Use \`prismaGlobal\` only when the use case requires cross-tenant or shared data
6230
+
6231
+ ---
6232
+
6233
+ ## Pruff-server (Backend)
6234
+
6235
+ **Stack:** Node.js 22, Express 4, TypeScript 5, Prisma 5, MySQL 8, AWS (S3, SQS, SNS, Lambda, DynamoDB)
6236
+ **Package manager:** \`bun\` — \`bun.lock\` is the canonical lockfile. Use \`bun install\` / \`bun add <pkg>\`,
6237
+ never \`npm install\` or \`yarn\` (Docker/CI install with \`bun install --frozen-lockfile\`, so an
6238
+ npm/yarn-only change goes stale). \`npm run <script>\` still works for running scripts.
6239
+
6240
+ ### Commands
6241
+
6242
+ \`\`\`bash
6243
+ cd Pruff-server
6244
+
6245
+ npm run local # Run locally (uses local.env)
6246
+ npm run dev # Run against dev environment (uses dev.env)
6247
+ npm run build # Compile TypeScript (tsc + tsc-alias)
6248
+ npm run test # Jest tests (uses test.env, sequential with 1 worker)
6249
+ npm run test:file <name> # Run single jest test by file name (not path)
6250
+ npm run lint # ESLint
6251
+
6252
+ # Database (Prisma)
6253
+ npm run db-update-local # prisma migrate dev (create + apply migration, local)
6254
+ npm run db-migrate-local # prisma migrate deploy (apply existing migrations, local)
6255
+ npm run db-migrate-tenants-local # Migrate all tenant databases
6256
+ npm run db-seed-tenants-local # Seed all active tenant databases
6257
+ \`\`\`
6258
+
6259
+ ### Source Structure
6260
+
6261
+ \`\`\`
6262
+ src/
6263
+ controllers/ # Thin — validate input, call service, return response
6264
+ services/ # All business logic, organized by domain
6265
+ routes/
6266
+ api.ts # Main router
6267
+ internal/ # Staff endpoints (JWT auth)
6268
+ clients/ # Customer-facing endpoints (API key)
6269
+ external/ # Scraper/lambda endpoints (API key)
6270
+ public/ # No auth
6271
+ modules/ # Infrastructure wrappers (S3, SQS, SII, GoFirmex, etc.)
6272
+ middlewares/ # Auth, tenant resolution, rate limiting, error handling
6273
+ jobs/ # Cron jobs (iterate over all tenants)
6274
+ types/ # Shared TypeScript types
6275
+ lib/ # Utility functions
6276
+ \`\`\`
6277
+
6278
+ ### API Tiers
6279
+
6280
+ | Tier | Base Route | Auth | Middleware |
6281
+ |------|-----------|------|-----------|
6282
+ | Internal | \`/api/internal\` | JWT | \`authenticateJWT\` + \`attachUserPermissions\` |
6283
+ | External | \`/api/external\` | API Key | \`authenticateWithApiKey\` |
6284
+ | Clients | \`/api/clients\` | Client API Key | \`authenticateClientsWithApiKey\` |
6285
+ | Public | \`/api/public\` | None | — |
6286
+
6287
+ ### Service Pattern — Result\\<T\\>
6288
+
6289
+ All services return \`Result<T>\`. Never throw from services.
6290
+
6291
+ \`\`\`typescript
6292
+ type Result<T> = { ok: true; value: T } | { ok: false; error: AppError };
6293
+
6294
+ return { ok: true, value };
6295
+ return { ok: false, error: { status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', service: 'MyService', displayMessage: 'No encontrado' } };
6296
+ \`\`\`
6297
+
6298
+ ### Controller Pattern — handleApiOutput / handleApiError
6299
+
6300
+ \`\`\`typescript
6301
+ async someAction(req: UserRequest, res: Response) {
6302
+ try {
6303
+ const result = await SomeService.execute(req.prisma, req.body);
6304
+ return handleApiOutput('SomeService', req, res, result);
6305
+ } catch (error) {
6306
+ handleApiError('SomeService', req, res, error);
6307
+ }
6308
+ }
6309
+ \`\`\`
6310
+
6311
+ ### Naming Conventions
6312
+
6313
+ - **Services:** \`PascalCase\` + \`Service\` suffix → \`CreateFactoringRequestService.ts\`
6314
+ - **DTOs/interfaces:** \`PascalCase\` + \`Dto\` suffix → \`CreateFactoringRequestDto\`
6315
+ - **Controllers:** \`camelCase\` + \`Controller\` suffix → \`invoicesController.ts\`
6316
+ - **Services are singletons:** \`export default new MyService();\`
6317
+ - **\`prisma\` is always the first parameter** when the service needs DB access
6318
+
6319
+ ### Accounting Rules
6320
+
6321
+ - Every financial state change generates double-entry accounting entries automatically
6322
+ - \`SUM(debit) === SUM(credit)\` always — imbalanced entries are rejected
6323
+ - Entries use \`sourceKey\` to prevent duplicates and \`checkSum\` (SHA256) for audit integrity
6324
+ - Periods must be OPEN to accept entries
6325
+
6326
+ ### Environment Files
6327
+
6328
+ - \`local.env\` — local MySQL, mocked AWS services
6329
+ - \`dev.env\` — connects to \`api.dev.pruff.com\` dependencies
6330
+ - \`test.env\` — for Jest tests
6331
+
6332
+ ---
6333
+
6334
+ ## Pruff-Client (Frontend)
6335
+
6336
+ **Stack:** React 18, TypeScript, Vite, MUI v5, Redux Toolkit, TanStack React Query v5, Axios
6337
+ **Package manager:** \`yarn\` — \`yarn.lock\` is the canonical lockfile.
6338
+
6339
+ ### Commands
6340
+
6341
+ \`\`\`bash
6342
+ cd Pruff-Client
6343
+
6344
+ yarn start-local # Dev server → backend at http://localhost:80
6345
+ yarn start-dev # Dev server → backend at https://api.dev.pruff.com
6346
+ yarn start-prod # Dev server → backend at https://api.pruff.com
6347
+ yarn build # Production build
6348
+ yarn lint # ESLint
6349
+ yarn test # Jest + React Testing Library
6350
+ \`\`\`
6351
+
6352
+ ### Source Structure
6353
+
6354
+ \`\`\`
6355
+ src/
6356
+ pages/ # Feature modules
6357
+ lib/
6358
+ api/ # Axios API integration modules
6359
+ designSystem/ # Shared MUI-based components
6360
+ mobileDesignSystem/ # Mobile-specific components
6361
+ common/ # Domain-specific shared components
6362
+ hooks/ # Custom React hooks
6363
+ functions/ # Business logic functions (utils.ts, permission checks)
6364
+ types/ # TypeScript type definitions
6365
+ services/ # Redux store + API configuration (requestHandler)
6366
+ layouts/ # Layout components
6367
+ assets/ # Images, fonts, icons
6368
+ \`\`\`
6369
+
6370
+ ### State Management
6371
+
6372
+ - **React Query v5** — All server data (fetching, caching, mutations)
6373
+ - **Redux Toolkit** — Auth state, global alerts, client branding config only
6374
+ - **Redux Persist** — Persists auth across sessions
6375
+
6376
+ ### API Client
6377
+
6378
+ All HTTP goes through \`requestHandler\` (Axios wrapper in \`src/lib/api.ts\`). Import from the relevant
6379
+ module in \`src/lib/api/<domain>/\`.
6380
+
6381
+ ### Permission System
6382
+
6383
+ RBAC via \`user.roles[].permissions[].name\`. Key utilities in \`src/lib/functions/utils.ts\`:
6384
+ - \`validatePermissions(user, permission)\` — single check
6385
+ - \`hasAnyPermissions(user, permissions[])\` — OR logic
6386
+ - \`validateCompanyTypes(user, type)\` — product/feature gating
6387
+
6388
+ ---
6389
+
6390
+ ## Code Style (Both Repos)
6391
+
6392
+ - **Prettier:** \`printWidth: 80\`, \`singleQuote: true\`, \`trailingComma: 'all'\`, \`semi: true\`
6393
+ - **ESLint:** TypeScript strict, \`simple-import-sort\` enforced, \`no-console\` (warn/error)
6394
+ - **Import order** (enforced): external packages → absolute imports → relative imports, alphabetically sorted
6395
+
6396
+ ---
6397
+
6398
+ ## Chile-Specific Concepts
6399
+
6400
+ | Term | Meaning |
6401
+ |------|---------|
6402
+ | **RUT** | Chilean tax ID (e.g., \`12.345.678-9\`) |
6403
+ | **DTE** | Electronic tax document (33=factura, 34=exenta, 61=nota de crédito) |
6404
+ | **SII** | Servicio de Impuestos Internos — tax authority, source of invoices, F22, F29 |
6405
+ | **F22/F29** | Annual/monthly tax returns |
6406
+ | **TGR** | Tesorería General de la República — state treasury, tax debts |
6407
+ | **UF** | Unidad de Fomento — daily inflation-adjusted unit (CMF publishes value) |
6408
+ | **CMF** | Comisión para el Mercado Financiero — financial markets regulator |
6409
+ | **SGR** | Sociedad de Garantía Recíproca — mutual guarantee society for SMEs |
6410
+ | **Cedente** | Client selling receivables (factoring seller) |
6411
+ | **Deudor** | Invoice payer / debtor |
6412
+ | **Cesión** | Transfer of collection rights on an invoice |
6413
+ | **Giro/Desembolso** | Cash disbursement |
6414
+ | **Cobranza** | Collection management |
6415
+ | **Mora** | Payment delinquency |
6416
+ | **Conciliación** | Reconciling bank movements with operations |
6417
+ | **Provisión** | Recognized expected loss |
6418
+ | **Castigo** | Write-off of uncollectible amounts |
6419
+ | **Bureau** | Credit bureau with RUT-level payment history |
6420
+ | **Spread** | Profit margin between funding cost and lending rate |
6421
+
6422
+ ---
6423
+
6424
+ ## Pruff Knowledge
6425
+
6426
+ Durable, versioned knowledge (standards, architecture, how things work) lives in the \`Pruff Knowledge/\`
6427
+ repo cloned alongside this workspace by onboarding (\`Pruff-Tech/pruff-knowledge\`). Read it for context
6428
+ beyond this CLAUDE.md. Roadmap, releases, meeting notes, and tickets stay in Notion/Linear, not here.
6429
+
6430
+ **Start at a MOC** (\`_moc/\` — Maps of Content): the curated entry index per area. From there, follow the
6431
+ \`[[wikilinks]]\` to the concrete notes, or jump straight into the folder you need.
6432
+
6433
+ | Folder | What it covers |
6434
+ |---|---|
6435
+ | \`architecture/\` | Patterns and standards already implemented (SSE, error handling, multi-tenancy, database gateway…) |
6436
+ | \`infrastructure/\` | AWS services, ECS, Lambdas, and how they connect |
6437
+ | \`design-system/\` | UI components, tokens, and conventions |
6438
+ | \`operations/\` | Operational flow, modules, and how they fit together |
6439
+ | \`integrations/\` | Existing integrations and how they're used |
6440
+ | \`repos/\` | One doc per repository — what it does and how it connects |
6441
+ | \`guides/\` | How-tos (internal and external) |
6442
+ | \`glossary/\` | Domain terms — folio, cesión, RUT, mora, etc. |
6443
+ | \`postmortems/\` | Incidents — root cause and durable learnings (operational detail stays in Linear) |
6444
+
6445
+ Notes are frontmatter/filenames in English (\`kebab-case\`), body in Spanish. See the repo's own
6446
+ \`AGENTS.md\` for the agent-facing contract.
6447
+
6448
+ ---
6449
+
6450
+ ## Workflow
6451
+
6452
+ - **Always use \`/commit\`** — invoke the \`commit\` skill for all git commits. Never run \`git commit\` manually.
6453
+ - **Always use \`/create-pr\`** — invoke the \`create-pr\` skill for all pull requests. Never run \`gh pr create\` manually.
6454
+ - **Commits are authored to the human running Claude Code** — never set \`--author\` to Claude or any co-author.
6455
+ - **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — verify this against each repo's actual
6456
+ \`dev\` vs \`main\` state before assuming; it has drifted before. Exception: \`Pruff-server\` and
6457
+ \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`).
6458
+ - **Branch names embed the Linear issue code; PRs map 1:1 with Linear issues** — name branches so they
6459
+ contain the issue code (e.g. \`pru-1355-...\`) for Linear's git automation. When splitting work, plan
6460
+ the PR grouping first, create one Linear issue per PR (1:1), and stack dependent PRs
6461
+ (\`dev ← group-1 ← group-2 ← …\`) instead of one mega-PR. If more than one linear issue is in one PR, name of the branch
6462
+ should be the linear issue code of each issue.
6463
+ - **Be straightforward and concise** — lead with the core message, then the minimum supporting detail.
6464
+ Cut preamble and narration. When asked for a table or diagram, render real markdown, never a bullet
6465
+ list dressed up as one.
6466
+ - **Propose options and confirm before acting** — for any non-trivial design, format, or refactor choice,
6467
+ present alternatives with their tradeoffs in chat and wait for a pick before writing code or running
6468
+ commands. Gather context → confirm the plan → build.
6469
+ - **Explain the rationale, not just the diff** — when a change touches a non-obvious file (a new
6470
+ middleware, a \`Dockerfile\` edit, a cap/limit) or you're proposing an approach, explain in chat *why*
6471
+ it's needed and surface the cons/tradeoffs before adopting it.
6472
+ - **Numbers are verified facts, not estimates** — before stating a count, rate, latency, or cost, confirm
6473
+ it from the source (logs, metrics, a query) and cite the source + window inline. If you can't verify
6474
+ it, label it an estimate or omit it. Package-manager conventions, canonical branches, and other
6475
+ "what does this repo actually do" questions belong in this bucket too — check \`origin/main\`, not a
6476
+ possibly-stale local checkout or this file.
6477
+ - **Register out-of-scope findings in \`.claude/smell-findings/\`** — whenever you or a subagent spots a
6478
+ code smell, an unrelated bug, or an improvement opportunity outside the current task's scope, record
6479
+ it as a markdown file there instead of fixing it inline or dropping it.
6480
+ - Delegate context gathering to codebase-navigator agent if present, and implementation to implementer agent if present.
6481
+
6482
+ ---
6483
+
6484
+ ## Code Review
6485
+
6486
+ When reviewing PRs, always scope scans to the PR's changed files (never the whole repo). For
6487
+ react-doctor/diff tools, use PR-scoped diffs and manually verify risky logic.
6488
+
6489
+ ---
6490
+
6491
+ ## Critical Rules
6492
+
6493
+ 1. **Every backend service takes \`prisma\` as its first parameter** — tenant isolation depends on it.
6494
+ Never import or use a global Prisma instance inside a service.
6495
+ 2. **Services return \`Result<T>\`, never throw** — controllers handle the Result pattern.
6496
+ 3. **Accounting entries must balance** — debit total must equal credit total.
6497
+ 4. **Financial operations are fully auditable** — CloudWatch logs and approval chains must not be skipped.
6498
+ 5. **SII/RUT data is sensitive** — Credentials are encrypted via \`encryptionModule\`.
6499
+ 6. **Never modify \`prisma/schema.prisma\` without confirmation** — schema changes affect every tenant database.
6500
+ 7. **Never run DB migrations automatically** — propose the change and let the developer run it.
6501
+ 8. **Never push directly to \`dev\` or \`main\`** — all changes must go through a PR. No exceptions.
6502
+ `;
6503
+ const CONTROL_PLANE_PRESEED_SCRIPT = `import { PrismaClient } from '@prisma/client';
6504
+
6505
+ import seedRoles from './prisma/seeds/roles';
6506
+ import seedPermissions from './prisma/seeds/permissions';
6507
+ import seedRolesWithPermissions from './prisma/seeds/permissionsToRole';
6508
+ import seedCompanyTypes from './prisma/seeds/companyTypes';
6509
+
6510
+ const prisma = new PrismaClient();
6511
+
6512
+ async function main() {
6513
+ if ((await prisma.role.count()) === 0) await seedRoles();
6514
+ if ((await prisma.permission.count()) === 0) await seedPermissions();
6515
+ await seedRolesWithPermissions();
6516
+ await seedCompanyTypes();
6517
+ }
6518
+
6519
+ main()
6520
+ .catch((e) => {
6521
+ console.error(e);
6522
+ process.exit(1);
6523
+ })
6524
+ .finally(() => process.exit(0));
6525
+ `;
6526
+ const TENANT_USER_BACKFILL_SCRIPT = `import PrismaClientManager from './src/modules/prismaClient';
6527
+ import EncryptionModule from './src/modules/encryptionModule';
6528
+
6529
+ async function main() {
6530
+ const email = process.argv[2];
6531
+ const password = process.argv[3];
6532
+ const clientId = process.env.CLIENT_ID;
6533
+ if (!clientId) throw new Error('CLIENT_ID is required');
6534
+
6535
+ const tenantPrisma = PrismaClientManager.getGlobalClient();
6536
+ const existing = await tenantPrisma.user.findFirst({ where: { email } });
6537
+ if (existing) {
6538
+ console.log('tenant user already present: ' + email);
6539
+ return;
6540
+ }
6541
+
6542
+ const globalPrisma = PrismaClientManager.getGlobalClientFromTenantBase(clientId);
6543
+ const globalUser = await globalPrisma.user.findFirstOrThrow({ where: { email } });
6544
+ const encryptedPassword = await EncryptionModule.encrypt(password);
6545
+
6546
+ await tenantPrisma.user.create({
6547
+ data: {
6548
+ id: globalUser.id,
6549
+ email,
6550
+ name: email.split('@')[0],
6551
+ password: encryptedPassword,
6552
+ active: true,
6553
+ companyId: globalUser.companyId,
6554
+ roles: { connect: [{ id: 4 }] },
6555
+ },
6556
+ });
6557
+ console.log('tenant user created: ' + email + ' (id=' + globalUser.id + ')');
6558
+ }
6559
+
6560
+ main()
6561
+ .catch((e) => {
6562
+ console.error(e);
6563
+ process.exit(1);
6564
+ })
6565
+ .finally(() => process.exit(0));
6566
+ `;
6567
+ function renderLocalEnv(v) {
6568
+ const q = (value) => `"${value.replace(/"/g, "\\\"")}"`;
6569
+ return `DATABASE_URL=${q(`mysql://root@localhost:3306/pruff-local?connection_limit=5`)}
6570
+ NODE_ENV="local"
6571
+ IS_LOCAL_ENV="true"
6572
+ CLIENT_ID=${q(v.clientId)}
6573
+ CLIENT_RUT=${q(v.clientRut)}
6574
+ CLIENT_NAME=${q(v.clientName)}
6575
+ ADMIN_PASSWORD=${q(v.adminPassword)}
6576
+ JWT_SECRET=${q(v.jwtSecret)}
6577
+ API_KEY=${q(v.apiKey)}
6578
+ SECRET_SII_KEY=${q(v.siiKey)}
6579
+ SECRET_USER_REGISTRATION=${q(v.registrationSecret)}
6580
+ SECRET_PFX_CERTIFICATE=${q(v.pfxSecret)}
6581
+ USER_FORM_KEY=${q(v.userFormKey)}
6582
+ POSTHOG_ENABLED="false"
6583
+
6584
+ # --- Third-party integrations -------------------------------------------------
6585
+ # Left blank on purpose. Fill with real sandbox credentials from your team /
6586
+ # 1Password when you need the feature. The app boots and serves local data
6587
+ # without them.
6588
+ SII_SERVICE_URL=""
6589
+ SII_SERVICE_APIKEY=""
6590
+ TGR_SERVICE_URL=""
6591
+ TGR_SERVICE_APKEY=""
6592
+ AWS_ACCESS_KEY_ID=""
6593
+ AWS_SECRET_ACCESS_KEY=""
6594
+ POSTHOG_API_KEY=""
6595
+ POSTHOG_HOST=""
6596
+ BURO_USERNAME=""
6597
+ BURO_PASSWORD=""
6598
+ BURO_API_URL=""
6599
+ CMF_API_KEY=""
6600
+ MCP_OAUTH_PUBLIC_KEY=""
6601
+ MCP_ALLOWED_TENANTS=""
6602
+ `;
6603
+ }
6604
+ const CLAUDE_CODE_PKG = "@anthropic-ai/claude-code";
6605
+ const KNOWLEDGE_REPO = "https://github.com/Pruff-Tech/pruff-knowledge";
6606
+ const KNOWLEDGE_DIR = "Pruff Knowledge";
6607
+ const DEFAULT_WORKSPACE = join(homedir(), "pruff");
6608
+ const MAIN_REPOS = [{
6609
+ name: "Pruff-server",
6610
+ url: "https://github.com/Pruff-Tech/Pruff-server",
6611
+ installCmd: "bun",
6612
+ installArgs: ["install"]
6613
+ }, {
6614
+ name: "Pruff-Client",
6615
+ url: "https://github.com/Pruff-Tech/Pruff-Client",
6616
+ installCmd: "yarn",
6617
+ installArgs: ["install"]
6618
+ }];
6619
+ const CORE_SKILLS = [
6620
+ "commit",
6621
+ "create-pr",
6622
+ "linear",
6623
+ "start-issue",
6624
+ "handoff",
6625
+ "pruff-logs",
6626
+ "pruff-pr-review",
6627
+ "review-pr-comments",
6628
+ "react-doctor"
6629
+ ];
6630
+ const CORE_AGENTS = [
6631
+ "codebase-navigator",
6632
+ "db-query-reviewer",
6633
+ "pruff-logs-investigator"
6634
+ ];
6635
+ async function runOnboarding(args) {
6636
+ if (process.platform !== "darwin") {
6637
+ M.error(`${import_picocolors.default.bold("onboarding")} currently supports macOS only (detected ${import_picocolors.default.cyan(process.platform)}).`);
6638
+ return;
6639
+ }
6640
+ Ie(import_picocolors.default.bold("Pruff onboarding"));
6641
+ installClaudeCode();
6642
+ ensureObsidian();
6643
+ ensureBun();
6644
+ const workspace = await resolveWorkspace(args);
6645
+ mkdirSync(workspace, { recursive: true });
6646
+ M.success(`Workspace ${import_picocolors.default.cyan(workspace)}`);
6647
+ cloneKnowledge(workspace);
6648
+ for (const repo of MAIN_REPOS) cloneRepo(repo, workspace);
6649
+ for (const repo of MAIN_REPOS) installDeps(repo, workspace);
6650
+ writeWorkspaceClaudeMd(workspace);
6651
+ await installCatalog(workspace);
6652
+ if (await shouldSetupDatabase(args)) await setupLocalEnvironment(join(workspace, "Pruff-server"));
6653
+ await showGuidance(workspace);
6654
+ Se(`${import_picocolors.default.green("Done.")} Welcome to Pruff — happy building.`);
6655
+ }
6656
+ function has(cmd) {
6657
+ return spawnSync("command", ["-v", cmd], {
6658
+ shell: true,
6659
+ stdio: "ignore"
6660
+ }).status === 0;
6661
+ }
6662
+ function run(cmd, args, cwd, env) {
6663
+ return spawnSync(cmd, args, {
6664
+ stdio: "inherit",
6665
+ cwd,
6666
+ env: env ? {
6667
+ ...process.env,
6668
+ ...env
6669
+ } : void 0
6670
+ }).status === 0;
6671
+ }
6672
+ function capture(cmd, args, cwd, env) {
6673
+ const r = spawnSync(cmd, args, {
6674
+ encoding: "utf-8",
6675
+ cwd,
6676
+ env: env ? {
6677
+ ...process.env,
6678
+ ...env
6679
+ } : void 0
6680
+ });
6681
+ return {
6682
+ ok: r.status === 0,
6683
+ out: (r.stdout ?? "").trim()
6684
+ };
6685
+ }
6686
+ function expandHome(input) {
6687
+ return input.startsWith("~") ? join(homedir(), input.slice(1)) : input;
6688
+ }
6689
+ function installClaudeCode() {
6690
+ if (has("claude")) {
6691
+ M.success("Claude Code already installed");
6692
+ return;
6693
+ }
6694
+ const s = Y();
6695
+ s.start("Installing Claude Code…");
6696
+ if (run("npm", [
6697
+ "install",
6698
+ "-g",
6699
+ CLAUDE_CODE_PKG
6700
+ ])) s.stop("Claude Code installed");
6701
+ else {
6702
+ s.stop(import_picocolors.default.yellow("Claude Code install failed"));
6703
+ M.warn(`Install it manually: ${import_picocolors.default.cyan(`npm i -g ${CLAUDE_CODE_PKG}`)}`);
6704
+ }
6705
+ }
6706
+ function ensureObsidian() {
6707
+ if (existsSync("/Applications/Obsidian.app")) {
6708
+ M.success("Obsidian already installed");
6709
+ return;
6710
+ }
6711
+ if (!has("brew")) {
6712
+ M.warn(`Obsidian not found and Homebrew is unavailable.\n Install it from ${import_picocolors.default.cyan("https://obsidian.md")} and re-run.`);
6713
+ return;
6714
+ }
6715
+ const s = Y();
6716
+ s.start("Installing Obsidian (brew)…");
6717
+ if (run("brew", [
6718
+ "install",
6719
+ "--cask",
6720
+ "obsidian"
6721
+ ])) s.stop("Obsidian installed");
6722
+ else {
6723
+ s.stop(import_picocolors.default.yellow("Obsidian install failed"));
6724
+ M.warn(`Install manually: ${import_picocolors.default.cyan("brew install --cask obsidian")}`);
6725
+ }
6726
+ }
6727
+ function ensureBun() {
6728
+ if (has("bun")) {
6729
+ M.success("Bun already installed");
6730
+ return;
6731
+ }
6732
+ const s = Y();
6733
+ s.start("Installing Bun…");
6734
+ if (run("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"])) s.stop("Bun installed");
6735
+ else {
6736
+ s.stop(import_picocolors.default.yellow("Bun install failed"));
6737
+ 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")})`);
6738
+ }
6739
+ }
6740
+ async function resolveWorkspace(args) {
6741
+ const fromArg = args.find((a) => !a.startsWith("-"));
6742
+ if (fromArg) return expandHome(fromArg);
6743
+ if (!process.stdout.isTTY) return DEFAULT_WORKSPACE;
6744
+ const answer = await he({
6745
+ message: "Where should the Pruff workspace live?",
6746
+ placeholder: DEFAULT_WORKSPACE,
6747
+ defaultValue: DEFAULT_WORKSPACE
6748
+ });
6749
+ if (pD(answer)) {
6750
+ xe("Onboarding cancelled.");
6751
+ process.exit(0);
6752
+ }
6753
+ return expandHome((answer || DEFAULT_WORKSPACE).trim());
6754
+ }
6755
+ function cloneKnowledge(workspace) {
6756
+ const vaultPath = join(workspace, KNOWLEDGE_DIR);
6757
+ const s = Y();
6758
+ if (existsSync(join(vaultPath, ".git"))) {
6759
+ s.start("Updating Pruff knowledge…");
6760
+ const ok = run("git", [
6761
+ "-C",
6762
+ vaultPath,
6763
+ "pull",
6764
+ "--ff-only"
6765
+ ]);
6766
+ s.stop(ok ? "Pruff knowledge up to date" : import_picocolors.default.yellow("git pull failed (keeping local copy)"));
6767
+ registerVault(vaultPath);
6768
+ return;
6769
+ }
6770
+ s.start("Cloning Pruff knowledge…");
6771
+ if (run("git", [
6772
+ "clone",
6773
+ KNOWLEDGE_REPO,
6774
+ vaultPath
6775
+ ])) {
6776
+ s.stop(`Cloned knowledge to ${import_picocolors.default.dim(vaultPath)}`);
6777
+ registerVault(vaultPath);
6778
+ return;
6779
+ }
6780
+ s.stop(import_picocolors.default.yellow("Knowledge clone failed"));
6781
+ 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.`);
6782
+ }
6783
+ function cloneRepo(repo, workspace) {
6784
+ const { name, url } = repo;
6785
+ const target = join(workspace, name);
6786
+ if (existsSync(join(target, ".git"))) {
6787
+ M.success(`${name} already cloned`);
6788
+ return;
6789
+ }
6790
+ const s = Y();
6791
+ s.start(`Cloning ${name}…`);
6792
+ if (run("git", [
6793
+ "clone",
6794
+ url,
6795
+ target
6796
+ ], workspace)) s.stop(`Cloned ${import_picocolors.default.cyan(name)}`);
6797
+ else {
6798
+ s.stop(import_picocolors.default.yellow(`${name} clone failed`));
6799
+ 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})`);
6800
+ }
6801
+ }
6802
+ function installDeps(repo, workspace) {
6803
+ const { name, installCmd, installArgs } = repo;
6804
+ const target = join(workspace, name);
6805
+ if (!existsSync(join(target, ".git"))) return;
6806
+ if (!has(installCmd)) {
6807
+ 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(" ")}`)}`);
6808
+ return;
6809
+ }
6810
+ const s = Y();
6811
+ s.start(`Installing ${name} dependencies (${installCmd})…`);
6812
+ const ok = run(installCmd, installArgs, target);
6813
+ if (ok && existsSync(join(target, "node_modules"))) s.stop(`${name} dependencies installed`);
6814
+ else {
6815
+ s.stop(import_picocolors.default.yellow(`${name} dependency install failed`));
6816
+ if (ok) M.warn(`${installCmd} exited 0 but ${name}/node_modules is missing — install likely no-opped.`);
6817
+ M.warn(`Run manually: ${import_picocolors.default.dim(`cd ${name} && ${installCmd} ${installArgs.join(" ")}`)}`);
6818
+ }
6819
+ }
6820
+ function writeWorkspaceClaudeMd(workspace) {
6821
+ const target = join(workspace, "CLAUDE.md");
6822
+ if (existsSync(target)) {
6823
+ M.success("Workspace CLAUDE.md already present — leaving it untouched");
6824
+ return;
6825
+ }
6826
+ writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
6827
+ M.success("Wrote workspace CLAUDE.md");
6828
+ }
6829
+ async function installCatalog(workspace) {
6830
+ const prevCwd = process.cwd();
6831
+ M.step("Installing Pruff rules, agents and skills into the workspace…");
6832
+ try {
6833
+ process.chdir(workspace);
6834
+ await runRules(["add", "--all"]);
6835
+ await runAgents(["add", ...CORE_AGENTS]);
6836
+ await runAdd([PRUFF_CATALOG], {
6837
+ skill: CORE_SKILLS,
6838
+ agent: ["claude-code"],
6839
+ yes: true
6840
+ });
6841
+ } catch (error) {
6842
+ M.warn(`Catalog install hit an error: ${error instanceof Error ? error.message : String(error)}\n Re-run once authenticated: ${import_picocolors.default.cyan("npx pruff-skills onboarding")}`);
6843
+ } finally {
6844
+ process.chdir(prevCwd);
6845
+ }
6846
+ }
6847
+ async function setupLocalEnvironment(serverPath) {
6848
+ if (!existsSync(join(serverPath, ".git"))) {
6849
+ M.warn("Pruff-server not cloned — skipping local database setup.");
6850
+ return;
6851
+ }
6852
+ if (!ensureMysql()) return;
6853
+ ensureDbeaver();
6854
+ generateLocalEnv(serverPath);
6855
+ const env = parseEnvFile(join(serverPath, "local.env"));
6856
+ if (!env.DATABASE_URL || !env.CLIENT_ID) {
6857
+ M.warn("local.env is missing DATABASE_URL / CLIENT_ID — skipping database setup.");
6858
+ return;
6859
+ }
6860
+ setupLocalDatabase(serverPath, env);
6861
+ }
6862
+ async function shouldSetupDatabase(args) {
6863
+ if (args.includes("--no-db")) return false;
6864
+ if (args.includes("--db")) return true;
6865
+ if (!process.stdout.isTTY) return false;
6866
+ const answer = await ye({
6867
+ message: "Set up the local Pruff-server database now? (installs MySQL + DBeaver, migrates both DBs, seeds a local \"pruff\" tenant)",
6868
+ initialValue: true
6869
+ });
6870
+ if (pD(answer)) return false;
6871
+ return answer === true;
6872
+ }
6873
+ function ensureMysql() {
6874
+ if (!has("mysql")) {
6875
+ if (!has("brew")) {
6876
+ M.warn(`MySQL not found and Homebrew is unavailable.\n Install MySQL, then re-run: ${import_picocolors.default.cyan("npx pruff-skills onboarding --db")}`);
6877
+ return false;
6878
+ }
6879
+ const s = Y();
6880
+ s.start("Installing MySQL (brew)…");
6881
+ const ok = run("brew", ["install", "mysql"]);
6882
+ s.stop(ok ? "MySQL installed" : import_picocolors.default.yellow("MySQL install failed"));
6883
+ if (!ok) return false;
6884
+ } else M.success("MySQL already installed");
6885
+ if (mysqlRunning()) {
6886
+ M.success("MySQL already running");
6887
+ return true;
6888
+ }
6889
+ const s = Y();
6890
+ s.start("Starting MySQL…");
6891
+ run("brew", [
6892
+ "services",
6893
+ "start",
6894
+ "mysql"
6895
+ ]);
6896
+ for (let i = 0; i < 30 && !mysqlRunning(); i++) spawnSync("sleep", ["1"]);
6897
+ const up = mysqlRunning();
6898
+ s.stop(up ? "MySQL running" : import_picocolors.default.yellow("MySQL did not come up"));
6899
+ if (!up) M.warn(`Start it manually: ${import_picocolors.default.cyan("brew services start mysql")}, then re-run with --db.`);
6900
+ return up;
6901
+ }
6902
+ function mysqlRunning() {
6903
+ return spawnSync("mysqladmin", [
6904
+ "ping",
6905
+ "-u",
6906
+ "root",
6907
+ "--silent"
6908
+ ], { stdio: "ignore" }).status === 0;
6909
+ }
6910
+ function ensureDbeaver() {
6911
+ if (existsSync("/Applications/DBeaver.app")) {
6912
+ M.success("DBeaver already installed");
6913
+ return;
6914
+ }
6915
+ if (!has("brew")) {
6916
+ M.warn(`DBeaver not found. Install it from ${import_picocolors.default.cyan("https://dbeaver.io")}.`);
6917
+ return;
6918
+ }
6919
+ const s = Y();
6920
+ s.start("Installing DBeaver (brew)…");
6921
+ const ok = run("brew", [
6922
+ "install",
6923
+ "--cask",
6924
+ "dbeaver-community"
6925
+ ]);
6926
+ s.stop(ok ? "DBeaver installed" : import_picocolors.default.yellow("DBeaver install failed"));
6927
+ }
6928
+ function generateLocalEnv(serverPath) {
6929
+ const target = join(serverPath, "local.env");
6930
+ if (existsSync(target)) {
6931
+ M.success("local.env already present — leaving it untouched");
6932
+ return;
6933
+ }
6934
+ const secret = () => randomBytes(16).toString("hex");
6935
+ writeFileSync(target, renderLocalEnv({
6936
+ clientId: "pruff",
6937
+ clientRut: "99.999.999-9",
6938
+ clientName: "Pruff Local",
6939
+ adminPassword: "pruff-local-1234",
6940
+ jwtSecret: secret(),
6941
+ apiKey: secret(),
6942
+ siiKey: secret(),
6943
+ registrationSecret: secret(),
6944
+ pfxSecret: secret(),
6945
+ userFormKey: secret()
6946
+ }), "utf-8");
6947
+ M.success("Wrote local.env (minimal bootable — fill third-party keys when needed)");
6948
+ }
6949
+ function parseEnvFile(path) {
6950
+ if (!existsSync(path)) return {};
6951
+ const out = {};
6952
+ for (const raw of readFileSync(path, "utf-8").split("\n")) {
6953
+ const line = raw.trim();
6954
+ if (!line || line.startsWith("#")) continue;
6955
+ const eq = line.indexOf("=");
6956
+ if (eq === -1) continue;
6957
+ const key = line.slice(0, eq).trim();
6958
+ let value = line.slice(eq + 1).trim();
6959
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
6960
+ out[key] = value;
6961
+ }
6962
+ return out;
6963
+ }
6964
+ function decideTenantAction(tenantDbExists, adminUserExists) {
6965
+ if (tenantDbExists && adminUserExists) return "already-provisioned";
6966
+ if (tenantDbExists) return "reset-and-seed";
6967
+ return "create-and-seed";
6968
+ }
6969
+ function parseMysqlUrl(url) {
6970
+ const u = new URL(url);
6971
+ return {
6972
+ host: u.hostname || "localhost",
6973
+ port: u.port || "3306",
6974
+ user: decodeURIComponent(u.username) || "root",
6975
+ password: u.password ? decodeURIComponent(u.password) : void 0
6976
+ };
6977
+ }
6978
+ function dbNameFromUrl(url) {
6979
+ return new URL(url).pathname.replace(/^\//, "");
6980
+ }
6981
+ function swapDbName(url, name) {
6982
+ const u = new URL(url);
6983
+ u.pathname = `/${name}`;
6984
+ return u.toString();
6985
+ }
6986
+ function mysqlArgs(conn) {
6987
+ const args = [
6988
+ "-h",
6989
+ conn.host,
6990
+ "-P",
6991
+ conn.port,
6992
+ "-u",
6993
+ conn.user
6994
+ ];
6995
+ if (conn.password) args.push(`-p${conn.password}`);
6996
+ return args;
6997
+ }
6998
+ function mysqlQuery(conn, sql) {
6999
+ return capture("mysql", [
7000
+ ...mysqlArgs(conn),
7001
+ "-N",
7002
+ "-B",
7003
+ "-e",
7004
+ sql
7005
+ ]);
7006
+ }
7007
+ function mysqlExec(conn, sql) {
7008
+ return run("mysql", [
7009
+ ...mysqlArgs(conn),
7010
+ "-e",
7011
+ sql
7012
+ ]);
7013
+ }
7014
+ function dbExists(conn, name) {
7015
+ const { out } = mysqlQuery(conn, `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${name}'`);
7016
+ return out.length > 0;
7017
+ }
7018
+ function tableExists(conn, db, table) {
7019
+ const { out } = mysqlQuery(conn, `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${db}' AND TABLE_NAME = '${table}'`);
7020
+ return out.length > 0;
7021
+ }
7022
+ function adminUserExists(conn, db, email) {
7023
+ if (!tableExists(conn, db, "User")) return false;
7024
+ const { ok, out } = mysqlQuery(conn, `SELECT COUNT(*) FROM \`${db}\`.\`User\` WHERE email = '${email}'`);
7025
+ return ok && parseInt(out, 10) > 0;
7026
+ }
7027
+ function migrateDeploy(serverPath, url) {
7028
+ return run("npx", [
7029
+ "prisma",
7030
+ "migrate",
7031
+ "deploy"
7032
+ ], serverPath, { DATABASE_URL: url });
7033
+ }
7034
+ function runDisposableScript(serverPath, fileName, contents, env, extraArgs = []) {
7035
+ const scriptPath = join(serverPath, fileName);
7036
+ writeFileSync(scriptPath, contents, "utf-8");
7037
+ try {
7038
+ return run("npx", [
7039
+ "ts-node",
7040
+ "--transpile-only",
7041
+ "-r",
7042
+ "tsconfig-paths/register",
7043
+ scriptPath,
7044
+ ...extraArgs
7045
+ ], serverPath, env);
7046
+ } finally {
7047
+ rmSync(scriptPath, { force: true });
7048
+ }
7049
+ }
7050
+ function setupLocalDatabase(serverPath, env) {
7051
+ const controlPlaneUrl = env.DATABASE_URL;
7052
+ const clientId = env.CLIENT_ID;
7053
+ const adminPassword = env.ADMIN_PASSWORD;
7054
+ if (!controlPlaneUrl || !clientId || !adminPassword) {
7055
+ M.warn("local.env is missing DATABASE_URL / CLIENT_ID / ADMIN_PASSWORD — skipping database setup.");
7056
+ return;
7057
+ }
7058
+ const conn = parseMysqlUrl(controlPlaneUrl);
7059
+ const controlPlaneName = dbNameFromUrl(controlPlaneUrl);
7060
+ const tenantName = `tenant-${clientId}`;
7061
+ const tenantUrl = swapDbName(controlPlaneUrl, tenantName);
7062
+ const adminEmail = `admin@${clientId}.com`;
7063
+ M.step(`Setting up local databases (${controlPlaneName} + ${tenantName})…`);
7064
+ mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${controlPlaneName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7065
+ if (!migrateDeploy(serverPath, controlPlaneUrl)) {
7066
+ M.warn("Control-plane migration failed — skipping the rest of database setup.");
7067
+ return;
7068
+ }
7069
+ runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
7070
+ ...env,
7071
+ DATABASE_URL: controlPlaneUrl
7072
+ });
7073
+ const action = decideTenantAction(dbExists(conn, tenantName), adminUserExists(conn, tenantName, adminEmail));
7074
+ if (action === "already-provisioned") {
7075
+ M.success(`Tenant "${clientId}" already provisioned — skipping seed.`);
7076
+ printLoginHint(clientId, adminEmail, adminPassword);
7077
+ return;
7078
+ }
7079
+ if (action === "reset-and-seed") {
7080
+ M.warn(`Tenant "${clientId}" partially provisioned — resetting (Pruff-server seed is not idempotent).`);
7081
+ mysqlExec(conn, `DROP DATABASE IF EXISTS \`${tenantName}\``);
7082
+ }
7083
+ mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${tenantName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7084
+ if (!migrateDeploy(serverPath, tenantUrl)) {
7085
+ M.warn("Tenant migration failed — skipping seed.");
7086
+ return;
7087
+ }
7088
+ const s = Y();
7089
+ s.start(`Seeding tenant "${clientId}"…`);
7090
+ const seeded = run("npx", [
7091
+ "prisma",
7092
+ "db",
7093
+ "seed"
7094
+ ], serverPath, {
7095
+ ...env,
7096
+ DATABASE_URL: tenantUrl
7097
+ });
7098
+ s.stop(seeded ? `Tenant "${clientId}" seeded` : import_picocolors.default.yellow("Tenant seed failed"));
7099
+ if (!seeded) return;
7100
+ if (!runDisposableScript(serverPath, ".pruff-onboarding-tenant-user.ts", TENANT_USER_BACKFILL_SCRIPT, {
7101
+ ...env,
7102
+ DATABASE_URL: tenantUrl
7103
+ }, [adminEmail, adminPassword])) {
7104
+ M.warn("Tenant admin-user backfill failed — you may need to create the user manually.");
7105
+ return;
7106
+ }
7107
+ M.success(`Local tenant "${clientId}" ready.`);
7108
+ printLoginHint(clientId, adminEmail, adminPassword);
7109
+ }
7110
+ function printLoginHint(clientId, email, password) {
7111
+ 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)}`);
7112
+ }
7113
+ async function showGuidance(workspace) {
7114
+ console.log();
7115
+ await sweepLine("W O R K S P A C E R E A D Y", { slow: true });
7116
+ console.log();
7117
+ const line = (s) => console.log(` ${s}`);
7118
+ line(import_picocolors.default.bold("Recommended structure") + import_picocolors.default.dim(" (keep everything under one parent)"));
7119
+ line("");
7120
+ line(`${import_picocolors.default.cyan(workspace)}/`);
7121
+ const tree = [
7122
+ ["CLAUDE.md", "workspace-level context — repo map, conventions, critical rules"],
7123
+ [".claude/", "rules · agents · skills (apply to every repo below)"],
7124
+ [`${KNOWLEDGE_DIR}/`, "Obsidian vault — open it in Obsidian"],
7125
+ ["Pruff-server/", "backend (bun)"],
7126
+ ["Pruff-Client/", "frontend (yarn)"],
7127
+ ["sls-*/", "serverless services — clone as you need them"]
7128
+ ];
7129
+ const labelWidth = Math.max(...tree.map(([label]) => label.length)) + 2;
7130
+ tree.forEach(([label, desc], i) => {
7131
+ const connector = i === tree.length - 1 ? "└──" : "├──";
7132
+ line(`${import_picocolors.default.dim(connector)} ${label.padEnd(labelWidth)}${import_picocolors.default.dim(desc)}`);
7133
+ });
7134
+ line("");
7135
+ line(import_picocolors.default.bold("Next steps"));
7136
+ line(`${import_picocolors.default.dim("1.")} Open ${import_picocolors.default.bold("Obsidian")} → the ${import_picocolors.default.cyan(KNOWLEDGE_DIR)} vault is registered and ready.`);
7137
+ 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.`);
7138
+ 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")}.`);
7139
+ line(`${import_picocolors.default.dim("4.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
7140
+ console.log();
7141
+ }
7142
+ function registerVault(vaultPath) {
7143
+ const dir = join(homedir(), "Library", "Application Support", "obsidian");
7144
+ const configPath = join(dir, "obsidian.json");
7145
+ let config = { vaults: {} };
7146
+ if (existsSync(configPath)) try {
7147
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
7148
+ if (!config.vaults) config.vaults = {};
7149
+ } catch {
7150
+ M.warn(`Could not parse ${import_picocolors.default.dim(configPath)} — leaving it untouched.`);
7151
+ return;
7152
+ }
7153
+ else mkdirSync(dir, { recursive: true });
7154
+ if (Object.values(config.vaults).some((v) => v.path === vaultPath)) {
7155
+ M.success("Vault already registered in Obsidian");
7156
+ return;
7157
+ }
7158
+ const id = randomBytes(8).toString("hex");
7159
+ config.vaults[id] = {
7160
+ path: vaultPath,
7161
+ ts: Date.now(),
7162
+ open: true
7163
+ };
7164
+ writeFileSync(configPath, JSON.stringify(config), "utf-8");
7165
+ M.success("Registered vault in Obsidian");
7166
+ }
6074
7167
  const __dirname = dirname(fileURLToPath(import.meta.url));
6075
7168
  function getVersion() {
6076
7169
  try {
@@ -6081,9 +7174,7 @@ function getVersion() {
6081
7174
  }
6082
7175
  }
6083
7176
  const VERSION = getVersion();
6084
- const RESET = "\x1B[0m";
6085
7177
  const BOLD = "\x1B[1m";
6086
- const DIM = "\x1B[38;5;102m";
6087
7178
  const TEXT = "\x1B[38;5;145m";
6088
7179
  const BLUE = "\x1B[38;2;110;139;255m";
6089
7180
  const LOGO_LINES = [
@@ -6094,16 +7185,6 @@ const LOGO_LINES = [
6094
7185
  "██║ ██║ ██║╚██████╔╝██║ ██║ ",
6095
7186
  "╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ "
6096
7187
  ];
6097
- const BLUES = [
6098
- "\x1B[38;2;110;139;255m",
6099
- "\x1B[38;2;91;118;249m",
6100
- "\x1B[38;2;72;97;242m",
6101
- "\x1B[38;2;53;77;236m",
6102
- "\x1B[38;2;34;56;229m",
6103
- "\x1B[38;2;15;35;223m"
6104
- ];
6105
- const SWEEP = "\x1B[38;2;214;226;255m";
6106
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6107
7188
  function printStaticLogo() {
6108
7189
  LOGO_LINES.forEach((line, i) => {
6109
7190
  console.log(`${BLUES[i]}${line}${RESET}`);
@@ -6139,7 +7220,7 @@ function renderLogoFrame(pos, band) {
6139
7220
  return out + RESET;
6140
7221
  }).join("\n");
6141
7222
  }
6142
- async function showLogo() {
7223
+ async function showLogo(slow = false) {
6143
7224
  console.log();
6144
7225
  if (!(!!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR && !process.env.PRUFF_SKILLS_NO_ANIM)) {
6145
7226
  printStaticLogo();
@@ -6148,6 +7229,9 @@ async function showLogo() {
6148
7229
  const rows = LOGO_LINES.length;
6149
7230
  const width = Math.max(...LOGO_LINES.map((l) => Array.from(l).length));
6150
7231
  const band = 7;
7232
+ const revealMs = slow ? 190 : 90;
7233
+ const step = slow ? 3 : 4;
7234
+ const sweepMs = slow ? 34 : 18;
6151
7235
  process.stdout.write("\x1B[?25l");
6152
7236
  try {
6153
7237
  let first = true;
@@ -6155,15 +7239,83 @@ async function showLogo() {
6155
7239
  if (!first) process.stdout.write(`\x1b[${rows}A`);
6156
7240
  process.stdout.write("\r" + renderLogoReveal(cut, width) + "\n");
6157
7241
  first = false;
6158
- await sleep(90);
7242
+ await sleep(revealMs);
6159
7243
  }
6160
- for (let pos = -7; pos <= width; pos += 4) {
7244
+ for (let pos = -7; pos <= width; pos += step) {
6161
7245
  process.stdout.write(`\x1b[${rows}A`);
6162
7246
  process.stdout.write("\r" + renderLogoFrame(pos, band) + "\n");
6163
- await sleep(18);
7247
+ await sleep(sweepMs);
6164
7248
  }
6165
7249
  process.stdout.write(`\x1b[${rows}A\r`);
6166
7250
  process.stdout.write(LOGO_LINES.map((line, i) => `${BLUES[i]}${line}${RESET}`).join("\n") + "\n");
7251
+ if (slow) await sleep(500);
7252
+ } finally {
7253
+ process.stdout.write("\x1B[?25h");
7254
+ }
7255
+ }
7256
+ const BUILDING_LINES = [
7257
+ "██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗███╗ ██╗ ██████╗ ",
7258
+ "██╔══██╗██║ ██║██║██║ ██╔══██╗██║████╗ ██║██╔════╝ ",
7259
+ "██████╔╝██║ ██║██║██║ ██║ ██║██║██╔██╗ ██║██║ ███╗",
7260
+ "██╔══██╗██║ ██║██║██║ ██║ ██║██║██║╚██╗██║██║ ██║",
7261
+ "██████╔╝╚██████╔╝██║███████╗██████╔╝██║██║ ╚████║╚██████╔╝",
7262
+ "╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ "
7263
+ ];
7264
+ const FINTECHS_LINES = [
7265
+ "███████╗██╗███╗ ██╗████████╗███████╗ ██████╗██╗ ██╗███████╗",
7266
+ "██╔════╝██║████╗ ██║╚══██╔══╝██╔════╝██╔════╝██║ ██║██╔════╝",
7267
+ "█████╗ ██║██╔██╗ ██║ ██║ █████╗ ██║ ███████║███████╗",
7268
+ "██╔══╝ ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██╔══██║╚════██║",
7269
+ "██║ ██║██║ ╚████║ ██║ ███████╗╚██████╗██║ ██║███████║",
7270
+ "╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝"
7271
+ ];
7272
+ const TAGLINE_INDENT = "";
7273
+ const TAGLINE_ROWS = BUILDING_LINES.length + FINTECHS_LINES.length;
7274
+ const WHITES = [
7275
+ "\x1B[38;2;255;255;255m",
7276
+ "\x1B[38;2;228;230;236m",
7277
+ "\x1B[38;2;201;205;216m",
7278
+ "\x1B[38;2;174;180;196m",
7279
+ "\x1B[38;2;148;155;176m",
7280
+ "\x1B[38;2;122;131;156m"
7281
+ ];
7282
+ function renderRevealRow(line, row, cols, fromLeft) {
7283
+ const chars = Array.from(line);
7284
+ const w = chars.length;
7285
+ let out = TAGLINE_INDENT + WHITES[Math.min(WHITES.length - 1, row)];
7286
+ for (let c = 0; c < w; c++) {
7287
+ const shown = fromLeft ? c < cols : c >= w - cols;
7288
+ out += shown ? chars[c] : " ";
7289
+ }
7290
+ return out + RESET;
7291
+ }
7292
+ function renderTaglineReveal(cols) {
7293
+ const rows = [];
7294
+ BUILDING_LINES.forEach((line, row) => rows.push(renderRevealRow(line, row, cols, true)));
7295
+ FINTECHS_LINES.forEach((line, row) => rows.push(renderRevealRow(line, row, cols, false)));
7296
+ return rows.join("\n");
7297
+ }
7298
+ async function showTagline(slow = false) {
7299
+ console.log();
7300
+ const rows = TAGLINE_ROWS;
7301
+ const width = Math.max(...[...BUILDING_LINES, ...FINTECHS_LINES].map((l) => Array.from(l).length));
7302
+ const step = slow ? 2 : 3;
7303
+ const ms = slow ? 26 : 14;
7304
+ if (!(!!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR && !process.env.PRUFF_SKILLS_NO_ANIM)) {
7305
+ console.log(renderTaglineReveal(width));
7306
+ return;
7307
+ }
7308
+ process.stdout.write("\x1B[?25l");
7309
+ try {
7310
+ let first = true;
7311
+ for (let cols = 0; cols <= width; cols += step) {
7312
+ if (!first) process.stdout.write(`\x1b[${rows}A`);
7313
+ process.stdout.write("\r" + renderTaglineReveal(cols) + "\n");
7314
+ first = false;
7315
+ await sleep(ms);
7316
+ }
7317
+ process.stdout.write(`\x1b[${rows}A\r` + renderTaglineReveal(width) + "\n");
7318
+ if (slow) await sleep(500);
6167
7319
  } finally {
6168
7320
  process.stdout.write("\x1B[?25h");
6169
7321
  }
@@ -6212,6 +7364,10 @@ ${BOLD}Rules:${RESET}
6212
7364
  rules add --scope <client|server|common>
6213
7365
  Install a whole repo's rule set (client/server also pull in common)
6214
7366
 
7367
+ ${BOLD}Onboarding:${RESET}
7368
+ onboarding Set up a new machine (macOS): install Claude Code,
7369
+ install Obsidian, and clone the Pruff knowledge vault
7370
+
6215
7371
  ${BOLD}Updates:${RESET}
6216
7372
  update [skills...] Update skills to latest versions (alias: upgrade)
6217
7373
 
@@ -6417,6 +7573,14 @@ async function main() {
6417
7573
  if (!inAgent) await showLogo();
6418
7574
  await runRules(restArgs);
6419
7575
  break;
7576
+ case "onboarding":
7577
+ case "onboard":
7578
+ if (!inAgent) {
7579
+ await showLogo(true);
7580
+ await showTagline(true);
7581
+ }
7582
+ await runOnboarding(restArgs);
7583
+ break;
6420
7584
  case "remove":
6421
7585
  case "rm":
6422
7586
  case "r":