pruff-skills 1.3.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.mjs +1022 -307
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -7,8 +7,8 @@ import "./_chunks/libs/@kwsites/promise-deferred.mjs";
7
7
  import { t as esm_default } from "./_chunks/libs/simple-git.mjs";
8
8
  import { t as xdgConfig } from "./_chunks/libs/xdg-basedir.mjs";
9
9
  import { t as require_dist } from "./_chunks/libs/@vercel/detect-agent.mjs";
10
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
11
- import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
10
+ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
11
+ import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
12
12
  import { fileURLToPath } from "url";
13
13
  import { stripVTControlCharacters } from "node:util";
14
14
  import { homedir, platform, tmpdir } from "os";
@@ -941,7 +941,7 @@ function filterSkills(skills, inputNames) {
941
941
  const home$1 = homedir();
942
942
  const configHome = xdgConfig ?? join(home$1, ".config");
943
943
  const codexHome = process.env.CODEX_HOME?.trim() || join(home$1, ".codex");
944
- const claudeHome$1 = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home$1, ".claude");
944
+ const claudeHome$2 = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home$1, ".claude");
945
945
  const vibeHome = process.env.VIBE_HOME?.trim() || join(home$1, ".vibe");
946
946
  const hermesHome = process.env.HERMES_HOME?.trim() || join(home$1, ".hermes");
947
947
  const autohandHome = process.env.AUTOHAND_HOME?.trim() || join(home$1, ".autohand");
@@ -1030,9 +1030,9 @@ const agents = {
1030
1030
  name: "claude-code",
1031
1031
  displayName: "Claude Code",
1032
1032
  skillsDir: ".claude/skills",
1033
- globalSkillsDir: join(claudeHome$1, "skills"),
1033
+ globalSkillsDir: join(claudeHome$2, "skills"),
1034
1034
  detectInstalled: async () => {
1035
- return existsSync(claudeHome$1);
1035
+ return existsSync(claudeHome$2);
1036
1036
  }
1037
1037
  },
1038
1038
  openclaw: {
@@ -4032,23 +4032,24 @@ function parseAddOptions(args) {
4032
4032
  };
4033
4033
  }
4034
4034
  const execFileAsync$4 = promisify(execFile);
4035
- const claudeAgentsDir = (global) => global ? join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "agents") : join(process.cwd(), ".claude", "agents");
4036
- async function fetchAgentFile(source, name) {
4037
- const rel = `agents/${name}.md`;
4038
- if (source.startsWith(".") || isAbsolute(source)) {
4035
+ const isLocalSource = (source) => source.startsWith(".") || isAbsolute(source);
4036
+ const isGitHubSource = (source) => /^[^/]+\/[^/]+$/.test(source);
4037
+ const ghEnv = () => ({
4038
+ ...process.env,
4039
+ GIT_TERMINAL_PROMPT: "0"
4040
+ });
4041
+ async function fetchCatalogFile(source, rel) {
4042
+ if (isLocalSource(source)) {
4039
4043
  const file = join(source, rel);
4040
4044
  return existsSync(file) ? readFile(file, "utf8") : null;
4041
4045
  }
4042
- if (/^[^/]+\/[^/]+$/.test(source)) try {
4046
+ if (isGitHubSource(source)) try {
4043
4047
  const { stdout } = await execFileAsync$4("gh", [
4044
4048
  "api",
4045
4049
  `repos/${source}/contents/${rel}`,
4046
4050
  "--jq",
4047
4051
  ".content"
4048
- ], { env: {
4049
- ...process.env,
4050
- GIT_TERMINAL_PROMPT: "0"
4051
- } });
4052
+ ], { env: ghEnv() });
4052
4053
  return Buffer.from(stdout.trim(), "base64").toString("utf8");
4053
4054
  } catch {
4054
4055
  return null;
@@ -4060,33 +4061,35 @@ async function fetchAgentFile(source, name) {
4060
4061
  return null;
4061
4062
  }
4062
4063
  }
4063
- async function listCatalogAgents(source) {
4064
- const toNames = (files) => files.filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
4065
- if (source.startsWith(".") || isAbsolute(source)) try {
4066
- return toNames(await readdir(join(source, "agents")));
4064
+ async function listCatalogDir(source, dir) {
4065
+ if (isLocalSource(source)) try {
4066
+ return await readdir(join(source, dir));
4067
4067
  } catch {
4068
4068
  return null;
4069
4069
  }
4070
- if (/^[^/]+\/[^/]+$/.test(source)) try {
4070
+ if (isGitHubSource(source)) try {
4071
4071
  const { stdout } = await execFileAsync$4("gh", [
4072
4072
  "api",
4073
- `repos/${source}/contents/agents`,
4073
+ `repos/${source}/contents/${dir}`,
4074
4074
  "--jq",
4075
4075
  ".[].name"
4076
- ], { env: {
4077
- ...process.env,
4078
- GIT_TERMINAL_PROMPT: "0"
4079
- } });
4080
- return toNames(stdout.trim().split("\n").filter(Boolean));
4076
+ ], { env: ghEnv() });
4077
+ return stdout.trim().split("\n").filter(Boolean);
4081
4078
  } catch {
4082
4079
  return null;
4083
4080
  }
4084
4081
  try {
4085
- return toNames(await readdir(join(await cloneRepo$1(source), "agents")));
4082
+ return await readdir(join(await cloneRepo$1(source), dir));
4086
4083
  } catch {
4087
4084
  return null;
4088
4085
  }
4089
4086
  }
4087
+ const claudeAgentsDir = (global) => global ? join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "agents") : join(process.cwd(), ".claude", "agents");
4088
+ const fetchAgentFile = (source, name) => fetchCatalogFile(source, `agents/${name}.md`);
4089
+ async function listCatalogAgents(source) {
4090
+ const files = await listCatalogDir(source, "agents");
4091
+ return files ? files.filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3)) : null;
4092
+ }
4090
4093
  async function listInstalledAgents(targetDir) {
4091
4094
  try {
4092
4095
  return (await readdir(targetDir, { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.slice(0, -3));
@@ -6276,6 +6279,641 @@ function fail(message) {
6276
6279
  process.exit(1);
6277
6280
  }
6278
6281
  var UseCommandError = class extends Error {};
6282
+ const claudeHome$1 = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
6283
+ const claudeSettingsPath$1 = () => join(claudeHome$1(), "settings.json");
6284
+ const pruffHooksDir = () => join(claudeHome$1(), "hooks", "pruff");
6285
+ const statePath = () => join(claudeHome$1(), "pruff-skills", "state.json");
6286
+ function readClaudeSettings$1() {
6287
+ const path = claudeSettingsPath$1();
6288
+ if (!existsSync(path)) return {};
6289
+ let parsed;
6290
+ try {
6291
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
6292
+ } catch {
6293
+ throw new Error(`${path} is not valid JSON. Fix it (or move it aside) before running this command.`);
6294
+ }
6295
+ return parsed && typeof parsed === "object" ? parsed : {};
6296
+ }
6297
+ function writeClaudeSettings$1(settings) {
6298
+ const path = claudeSettingsPath$1();
6299
+ const next = `${JSON.stringify(settings, null, 2)}\n`;
6300
+ const current = existsSync(path) ? readFileSync(path, "utf-8") : null;
6301
+ if (current === next) return null;
6302
+ mkdirSync(claudeHome$1(), { recursive: true });
6303
+ let backup = null;
6304
+ if (current !== null) {
6305
+ backup = `${path}.pruff-bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
6306
+ copyFileSync(path, backup);
6307
+ }
6308
+ writeFileSync(path, next, "utf-8");
6309
+ return backup;
6310
+ }
6311
+ function readState() {
6312
+ try {
6313
+ const parsed = JSON.parse(readFileSync(statePath(), "utf-8"));
6314
+ return {
6315
+ hooks: parsed.hooks ?? {},
6316
+ env: parsed.env ?? [],
6317
+ files: parsed.files ?? {}
6318
+ };
6319
+ } catch {
6320
+ return {
6321
+ hooks: {},
6322
+ env: [],
6323
+ files: {}
6324
+ };
6325
+ }
6326
+ }
6327
+ function writeState(state) {
6328
+ mkdirSync(join(claudeHome$1(), "pruff-skills"), { recursive: true });
6329
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
6330
+ }
6331
+ function upsertHook(settings, event, matcher, command) {
6332
+ const hooks = { ...settings.hooks ?? {} };
6333
+ const groups = (hooks[event] ?? []).map((g) => ({
6334
+ ...g,
6335
+ hooks: [...g.hooks]
6336
+ }));
6337
+ const existing = groups.find((g) => g.matcher === matcher && g.hooks.some((h) => h.command === command.command));
6338
+ if (existing) existing.hooks = existing.hooks.map((h) => h.command === command.command ? command : h);
6339
+ else groups.push(matcher === void 0 ? { hooks: [command] } : {
6340
+ matcher,
6341
+ hooks: [command]
6342
+ });
6343
+ hooks[event] = groups;
6344
+ return {
6345
+ ...settings,
6346
+ hooks
6347
+ };
6348
+ }
6349
+ function removeHooks(settings, isOwned) {
6350
+ if (!settings.hooks) return settings;
6351
+ const hooks = {};
6352
+ for (const [event, groups] of Object.entries(settings.hooks)) {
6353
+ const kept = groups.map((g) => ({
6354
+ ...g,
6355
+ hooks: g.hooks.filter((h) => !isOwned(h.command))
6356
+ })).filter((g) => g.hooks.length > 0);
6357
+ if (kept.length > 0) hooks[event] = kept;
6358
+ }
6359
+ const next = { ...settings };
6360
+ if (Object.keys(hooks).length === 0) delete next.hooks;
6361
+ else next.hooks = hooks;
6362
+ return next;
6363
+ }
6364
+ function applyOwnedEnv(settings, desired, previouslyOwned) {
6365
+ const env = { ...settings.env ?? {} };
6366
+ const owned = [];
6367
+ const keptUserValues = [];
6368
+ for (const key of previouslyOwned) if (!(key in desired)) delete env[key];
6369
+ for (const [key, value] of Object.entries(desired)) {
6370
+ const isOurs = previouslyOwned.includes(key);
6371
+ if (key in env && env[key] !== value && !isOurs) {
6372
+ keptUserValues.push(key);
6373
+ continue;
6374
+ }
6375
+ env[key] = value;
6376
+ owned.push(key);
6377
+ }
6378
+ const next = { ...settings };
6379
+ if (Object.keys(env).length === 0) delete next.env;
6380
+ else next.env = env;
6381
+ return {
6382
+ settings: next,
6383
+ owned,
6384
+ keptUserValues
6385
+ };
6386
+ }
6387
+ const HOOK_EVENTS = [
6388
+ "PreToolUse",
6389
+ "PostToolUse",
6390
+ "UserPromptSubmit",
6391
+ "Notification",
6392
+ "Stop",
6393
+ "SubagentStop",
6394
+ "SessionStart",
6395
+ "SessionEnd",
6396
+ "PreCompact"
6397
+ ];
6398
+ const sha256$1 = (content) => createHash("sha256").update(content).digest("hex");
6399
+ const hookDir = (name) => join(pruffHooksDir(), name);
6400
+ const commandFor = (name, script) => `"${join(hookDir(name), script)}"`;
6401
+ const ownedBy = (name) => (command) => command.includes(`${join(pruffHooksDir(), name)}/`);
6402
+ function parseHookManifest(raw, expectedName) {
6403
+ let data;
6404
+ try {
6405
+ data = JSON.parse(raw);
6406
+ } catch {
6407
+ throw new Error(`hooks/${expectedName}/hook.json is not valid JSON`);
6408
+ }
6409
+ if (data.name !== expectedName) throw new Error(`hooks/${expectedName}/hook.json: "name" must be "${expectedName}"`);
6410
+ if (!data.script || !/^[\w.-]+$/.test(data.script)) throw new Error(`hooks/${expectedName}/hook.json: "script" must be a bare file name`);
6411
+ if (!Array.isArray(data.registrations) || data.registrations.length === 0) throw new Error(`hooks/${expectedName}/hook.json: needs at least one registration`);
6412
+ for (const r of data.registrations) if (!HOOK_EVENTS.includes(r.event)) throw new Error(`hooks/${expectedName}/hook.json: unknown event "${String(r.event)}"`);
6413
+ return data;
6414
+ }
6415
+ async function listCatalogHooks(source = PRUFF_CATALOG) {
6416
+ const entries = await listCatalogDir(source, "hooks");
6417
+ return entries ? entries.filter(isBareSkillName).sort() : null;
6418
+ }
6419
+ function applyHook(settings, manifest) {
6420
+ const command = commandFor(manifest.name, manifest.script);
6421
+ const registrations = manifest.registrations.map((r) => ({
6422
+ event: r.event,
6423
+ ...r.matcher ? { matcher: r.matcher } : {},
6424
+ command
6425
+ }));
6426
+ const hookFor = (r) => ({
6427
+ type: "command",
6428
+ command,
6429
+ ...r.timeout ? { timeout: r.timeout } : {}
6430
+ });
6431
+ const owned = ownedBy(manifest.name);
6432
+ const current = Object.entries(settings.hooks ?? {}).flatMap(([event, groups]) => groups.flatMap((g) => g.hooks.filter((h) => owned(h.command)).map((h) => ({
6433
+ event,
6434
+ matcher: g.matcher,
6435
+ hook: h
6436
+ }))));
6437
+ if (current.length === manifest.registrations.length && manifest.registrations.every((r) => current.some((c) => c.event === r.event && c.matcher === r.matcher && JSON.stringify(c.hook) === JSON.stringify(hookFor(r))))) return {
6438
+ settings,
6439
+ registrations
6440
+ };
6441
+ let next = removeHooks(settings, owned);
6442
+ for (const r of manifest.registrations) next = upsertHook(next, r.event, r.matcher, hookFor(r));
6443
+ return {
6444
+ settings: next,
6445
+ registrations
6446
+ };
6447
+ }
6448
+ async function installHooks(names, options = {}) {
6449
+ const result = {
6450
+ installed: [],
6451
+ unchanged: [],
6452
+ skippedLocalEdits: [],
6453
+ failed: [],
6454
+ backup: null
6455
+ };
6456
+ const state = readState();
6457
+ let settings = readClaudeSettings$1();
6458
+ const writes = [];
6459
+ for (const name of names) {
6460
+ if (!isBareSkillName(name)) {
6461
+ result.failed.push({
6462
+ name,
6463
+ reason: "invalid hook name"
6464
+ });
6465
+ continue;
6466
+ }
6467
+ const rawManifest = await fetchCatalogFile(PRUFF_CATALOG, `hooks/${name}/hook.json`);
6468
+ if (rawManifest === null) {
6469
+ result.failed.push({
6470
+ name,
6471
+ reason: `not found in the catalog (${PRUFF_CATALOG})`
6472
+ });
6473
+ continue;
6474
+ }
6475
+ let manifest;
6476
+ try {
6477
+ manifest = parseHookManifest(rawManifest, name);
6478
+ } catch (error) {
6479
+ result.failed.push({
6480
+ name,
6481
+ reason: error.message
6482
+ });
6483
+ continue;
6484
+ }
6485
+ const script = await fetchCatalogFile(PRUFF_CATALOG, `hooks/${name}/${manifest.script}`);
6486
+ if (script === null) {
6487
+ result.failed.push({
6488
+ name,
6489
+ reason: `script ${manifest.script} missing from the catalog`
6490
+ });
6491
+ continue;
6492
+ }
6493
+ const target = join(hookDir(name), manifest.script);
6494
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
6495
+ const recorded = state.hooks[name]?.files[manifest.script];
6496
+ if (current !== null && sha256$1(current) !== recorded && current !== script && !options.force) {
6497
+ result.skippedLocalEdits.push(name);
6498
+ continue;
6499
+ }
6500
+ const applied = applyHook(settings, manifest);
6501
+ const settingsChanged = JSON.stringify(applied.settings) !== JSON.stringify(settings);
6502
+ settings = applied.settings;
6503
+ if (current === script && !settingsChanged && recorded === sha256$1(script)) {
6504
+ result.unchanged.push(name);
6505
+ continue;
6506
+ }
6507
+ if (current !== script) writes.push({
6508
+ path: target,
6509
+ content: script
6510
+ });
6511
+ state.hooks[name] = {
6512
+ files: { [manifest.script]: sha256$1(script) },
6513
+ registrations: applied.registrations
6514
+ };
6515
+ result.installed.push(name);
6516
+ }
6517
+ if (options.dryRun || result.installed.length === 0) return result;
6518
+ for (const w of writes) {
6519
+ mkdirSync(join(w.path, ".."), { recursive: true });
6520
+ writeFileSync(w.path, w.content, "utf-8");
6521
+ chmodSync(w.path, 493);
6522
+ }
6523
+ result.backup = writeClaudeSettings$1(settings);
6524
+ writeState(state);
6525
+ return result;
6526
+ }
6527
+ function uninstallHooks(names, options = {}) {
6528
+ const state = readState();
6529
+ let settings = readClaudeSettings$1();
6530
+ const removed = [];
6531
+ for (const name of names) {
6532
+ if (!isBareSkillName(name)) continue;
6533
+ const next = removeHooks(settings, ownedBy(name));
6534
+ const hadFiles = existsSync(hookDir(name));
6535
+ if (!state.hooks[name] && !hadFiles && JSON.stringify(next) === JSON.stringify(settings)) continue;
6536
+ settings = next;
6537
+ delete state.hooks[name];
6538
+ removed.push(name);
6539
+ if (!options.dryRun) rmSync(hookDir(name), {
6540
+ recursive: true,
6541
+ force: true
6542
+ });
6543
+ }
6544
+ if (!options.dryRun && removed.length > 0) {
6545
+ writeClaudeSettings$1(settings);
6546
+ writeState(state);
6547
+ }
6548
+ return removed;
6549
+ }
6550
+ function parseHooksOptions(args) {
6551
+ const names = [];
6552
+ const options = {};
6553
+ for (const arg of args) if (arg === "--dry-run") options.dryRun = true;
6554
+ else if (arg === "--force" || arg === "-f") options.force = true;
6555
+ else if (!arg.startsWith("-")) names.push(arg);
6556
+ return {
6557
+ names,
6558
+ options
6559
+ };
6560
+ }
6561
+ async function addCommand(names, options) {
6562
+ Ie(import_picocolors.default.cyan(`pruff-skills hooks${options.dryRun ? " (dry run)" : ""}`));
6563
+ if (names.length === 0) {
6564
+ M.error("Specify at least one hook name, e.g. `pruff-skills hooks add <name>`.");
6565
+ M.message(import_picocolors.default.dim("List them with `npx pruff-skills hooks list`."));
6566
+ process.exit(1);
6567
+ }
6568
+ let result;
6569
+ try {
6570
+ result = await installHooks(names, options);
6571
+ } catch (error) {
6572
+ M.error(error.message);
6573
+ process.exit(1);
6574
+ }
6575
+ const verb = options.dryRun ? "would install" : "installed";
6576
+ for (const name of result.installed) M.success(`${import_picocolors.default.green(name)} ${verb}`);
6577
+ for (const name of result.unchanged) M.info(`${name} already up to date`);
6578
+ for (const name of result.skippedLocalEdits) M.warn(`${import_picocolors.default.yellow(name)} skipped — the installed script was edited locally. Re-run with --force to overwrite it.`);
6579
+ for (const f of result.failed) M.error(`${import_picocolors.default.yellow(f.name)}: ${f.reason}`);
6580
+ if (result.backup) M.message(import_picocolors.default.dim(`Previous settings saved to ${result.backup}`));
6581
+ if (result.installed.length + result.unchanged.length === 0) {
6582
+ Se(import_picocolors.default.red("No hooks installed."));
6583
+ process.exit(1);
6584
+ }
6585
+ Se(options.dryRun ? "Dry run — nothing was written." : import_picocolors.default.dim("Hooks apply to new Claude Code sessions. They run with full agent permissions."));
6586
+ }
6587
+ async function listCommand() {
6588
+ const state = readState();
6589
+ const catalog = await listCatalogHooks();
6590
+ const installed = Object.keys(state.hooks).sort();
6591
+ console.log(import_picocolors.default.bold("Installed"));
6592
+ if (installed.length === 0) console.log(import_picocolors.default.dim(" none"));
6593
+ for (const name of installed) {
6594
+ const events = state.hooks[name].registrations.map((r) => `${r.event}${r.matcher ? ` [${r.matcher}]` : ""}`);
6595
+ console.log(` ${import_picocolors.default.green(name)} ${import_picocolors.default.dim(events.join(", "))}`);
6596
+ }
6597
+ if (catalog === null) {
6598
+ console.log(import_picocolors.default.yellow(`\nCould not read the catalog (${PRUFF_CATALOG}).`));
6599
+ return;
6600
+ }
6601
+ const available = catalog.filter((n) => !state.hooks[n]);
6602
+ console.log(import_picocolors.default.bold("\nAvailable"));
6603
+ if (available.length === 0) console.log(import_picocolors.default.dim(" none"));
6604
+ for (const name of available) console.log(` ${name}`);
6605
+ }
6606
+ const HOOKS_HELP = `${import_picocolors.default.bold("Usage:")} pruff-skills hooks <command> [options]
6607
+
6608
+ ${import_picocolors.default.bold("Commands:")}
6609
+ add <name...> Install hooks into ~/.claude/hooks/pruff/ and register them in ~/.claude/settings.json
6610
+ list Show installed and available hooks
6611
+ remove <name...> Unregister hooks and delete their scripts
6612
+
6613
+ ${import_picocolors.default.bold("Options:")}
6614
+ --dry-run Show what would change without writing anything
6615
+ -f, --force Overwrite a hook script that was edited locally
6616
+
6617
+ Hooks always install at user scope: Claude Code only reads a project settings file
6618
+ in the directory a session starts from, so a user-level hook is the only one that
6619
+ reaches every repo and worktree. Your own settings and hooks are never modified;
6620
+ ~/.claude/settings.json is backed up before every change.`;
6621
+ async function runHooks(args) {
6622
+ const sub = args[0];
6623
+ const { names, options } = parseHooksOptions(args.slice(1));
6624
+ if (!sub || sub === "--help" || sub === "-h") {
6625
+ console.log(HOOKS_HELP);
6626
+ return;
6627
+ }
6628
+ if (sub === "add" || sub === "a" || sub === "install" || sub === "i") {
6629
+ await addCommand(names, options);
6630
+ return;
6631
+ }
6632
+ if (sub === "list" || sub === "ls") {
6633
+ await listCommand();
6634
+ return;
6635
+ }
6636
+ if (sub === "remove" || sub === "rm") {
6637
+ const removed = uninstallHooks(names, options);
6638
+ if (removed.length === 0) console.log(import_picocolors.default.dim("Nothing to remove."));
6639
+ for (const name of removed) console.log(`${import_picocolors.default.green("✓")} ${name} ${options.dryRun ? "would be removed" : "removed"}`);
6640
+ return;
6641
+ }
6642
+ console.log(`Unknown hooks command: ${sub}`);
6643
+ console.log(HOOKS_HELP);
6644
+ }
6645
+ const DB_READER_REGION = "us-east-1";
6646
+ const DB_READER_SERVICE = "pruff-db-reader";
6647
+ const DB_READER_SECRET_PREFIX = "pruff/db-reader/";
6648
+ const DB_READER_ALLOW_ENTRY = "Bash(pruff-db-query:*)";
6649
+ const DB_READER_VERIFY_DATABASE = "pruff-sii";
6650
+ const DB_READER_SKILL = "prod-db-query";
6651
+ const DB_READER_SCRIPTS = ["pruff-db-query", "pruff-db-verify"];
6652
+ const RDS_CA_URL = `https://truststore.pki.rds.amazonaws.com/${DB_READER_REGION}/${DB_READER_REGION}-bundle.pem`;
6653
+ const MAX_SUGGESTED_NAME_LENGTH = 23;
6654
+ const dbReaderConfigPath = () => join(homedir(), ".config", "pruff", "db-reader.json");
6655
+ const dbReaderCaPath = () => join(homedir(), ".config", "pruff", `rds-${DB_READER_REGION}-bundle.pem`);
6656
+ const dbReaderBinDir = () => join(homedir(), ".local", "bin");
6657
+ function aws(args) {
6658
+ const r = spawnSync("aws", args, {
6659
+ encoding: "utf-8",
6660
+ stdio: "pipe"
6661
+ });
6662
+ return {
6663
+ ok: r.status === 0,
6664
+ out: (r.stdout ?? "").trim(),
6665
+ err: (r.stderr ?? "").trim()
6666
+ };
6667
+ }
6668
+ function iamUserFromArn(arn) {
6669
+ const match = /^arn:[^:]*:iam::\d+:user\/(?:.*\/)?([^/\s]+)$/.exec(arn.trim());
6670
+ return match ? match[1] : null;
6671
+ }
6672
+ function suggestMysqlName(email, iamUser) {
6673
+ const clean = (value) => value.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, MAX_SUGGESTED_NAME_LENGTH);
6674
+ return clean(email.split("@")[0] ?? "") || clean(iamUser) || "engineer";
6675
+ }
6676
+ function parseReaderSecret(raw) {
6677
+ try {
6678
+ const data = JSON.parse(raw);
6679
+ if (typeof data.username !== "string" || typeof data.password !== "string") return null;
6680
+ if (!/^[A-Za-z0-9_]+$/.test(data.username)) return null;
6681
+ if (data.password.length === 0 || /[\u0000-\u001f\u007f]/.test(data.password)) return null;
6682
+ return {
6683
+ username: data.username,
6684
+ password: data.password
6685
+ };
6686
+ } catch {
6687
+ return null;
6688
+ }
6689
+ }
6690
+ function quoteForSecurityShell(value) {
6691
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
6692
+ }
6693
+ function storeInKeychain(username, password) {
6694
+ if (spawnSync("security", ["-i"], {
6695
+ input: `add-generic-password -U -s ${DB_READER_SERVICE} -a ${quoteForSecurityShell(username)} -w ${quoteForSecurityShell(password)}\n`,
6696
+ encoding: "utf-8",
6697
+ stdio: "pipe"
6698
+ }).status !== 0) return false;
6699
+ return spawnSync("security", [
6700
+ "find-generic-password",
6701
+ "-s",
6702
+ DB_READER_SERVICE,
6703
+ "-a",
6704
+ username
6705
+ ], { stdio: "ignore" }).status === 0;
6706
+ }
6707
+ function writeConfig(username) {
6708
+ const path = dbReaderConfigPath();
6709
+ mkdirSync(dirname(path), { recursive: true });
6710
+ writeFileSync(path, `${JSON.stringify({ username }, null, 2)}\n`, "utf-8");
6711
+ }
6712
+ function downloadCaBundle() {
6713
+ const target = dbReaderCaPath();
6714
+ mkdirSync(dirname(target), { recursive: true });
6715
+ return spawnSync("curl", [
6716
+ "-sSfo",
6717
+ target,
6718
+ RDS_CA_URL
6719
+ ], { stdio: "ignore" }).status === 0;
6720
+ }
6721
+ async function installScripts() {
6722
+ const dir = dbReaderBinDir();
6723
+ mkdirSync(dir, { recursive: true });
6724
+ for (const name of DB_READER_SCRIPTS) {
6725
+ const content = await fetchCatalogFile(PRUFF_CATALOG, `db-reader/${name}`);
6726
+ if (content === null) return `${name} could not be read from the catalog`;
6727
+ const target = join(dir, name);
6728
+ if ((existsSync(target) ? readFileSync(target, "utf-8") : null) !== content) writeFileSync(target, content, "utf-8");
6729
+ chmodSync(target, 493);
6730
+ }
6731
+ return null;
6732
+ }
6733
+ function runVerify$1() {
6734
+ const dir = dbReaderBinDir();
6735
+ const r = spawnSync(join(dir, "pruff-db-verify"), [DB_READER_VERIFY_DATABASE], {
6736
+ encoding: "utf-8",
6737
+ stdio: "pipe",
6738
+ env: {
6739
+ ...process.env,
6740
+ PRUFF_DB_QUERY_CMD: join(dir, "pruff-db-query")
6741
+ }
6742
+ });
6743
+ return {
6744
+ ok: r.status === 0,
6745
+ out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
6746
+ };
6747
+ }
6748
+ function mergeAllowlist(workspace) {
6749
+ const path = join(workspace, ".claude", "settings.json");
6750
+ let settings = {};
6751
+ let current = null;
6752
+ if (existsSync(path)) {
6753
+ current = readFileSync(path, "utf-8");
6754
+ try {
6755
+ const parsed = JSON.parse(current);
6756
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "invalid";
6757
+ settings = parsed;
6758
+ } catch {
6759
+ return "invalid";
6760
+ }
6761
+ }
6762
+ const permissions = settings.permissions && typeof settings.permissions === "object" ? settings.permissions : {};
6763
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
6764
+ if (allow.includes(DB_READER_ALLOW_ENTRY)) return "present";
6765
+ const next = {
6766
+ ...settings,
6767
+ permissions: {
6768
+ ...permissions,
6769
+ allow: [...allow, DB_READER_ALLOW_ENTRY]
6770
+ }
6771
+ };
6772
+ mkdirSync(dirname(path), { recursive: true });
6773
+ if (current !== null) copyFileSync(path, `${path}.pruff-bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
6774
+ const tmp = `${path}.tmp`;
6775
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
6776
+ renameSync(tmp, path);
6777
+ return "added";
6778
+ }
6779
+ async function installReaderSkill(workspace, note) {
6780
+ if (existsSync(join(workspace, ".claude", "skills", DB_READER_SKILL, "SKILL.md"))) return;
6781
+ const prevCwd = process.cwd();
6782
+ try {
6783
+ process.chdir(workspace);
6784
+ await runAdd([PRUFF_CATALOG], {
6785
+ skill: [DB_READER_SKILL],
6786
+ agent: ["claude-code"],
6787
+ yes: true
6788
+ });
6789
+ } catch {} finally {
6790
+ process.chdir(prevCwd);
6791
+ }
6792
+ if (existsSync(join(workspace, ".claude", "skills", DB_READER_SKILL, "SKILL.md"))) M.success(`Skill ${DB_READER_SKILL} installed`);
6793
+ else note(`Skill ${DB_READER_SKILL} not installed — run ${import_picocolors.default.cyan(`npx pruff-skills add ${PRUFF_CATALOG} --skill ${DB_READER_SKILL}`)} from ${workspace}.`);
6794
+ }
6795
+ async function setupDbReader({ workspace, note }) {
6796
+ M.step("Setting up read-only prod database access…");
6797
+ const identity = aws([
6798
+ "sts",
6799
+ "get-caller-identity",
6800
+ "--query",
6801
+ "Arn",
6802
+ "--output",
6803
+ "text"
6804
+ ]);
6805
+ if (!identity.ok) {
6806
+ note(`Prod DB access not set up — no AWS credentials. Ask your team for AWS access, run ${import_picocolors.default.cyan("aws configure")}, then ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
6807
+ return;
6808
+ }
6809
+ const iamUser = iamUserFromArn(identity.out);
6810
+ if (!iamUser) {
6811
+ note(`Prod DB access not set up — the AWS identity is not an IAM user. Use your personal IAM user credentials and re-run ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
6812
+ return;
6813
+ }
6814
+ const secretId = `${DB_READER_SECRET_PREFIX}${iamUser}`;
6815
+ const fetched = aws([
6816
+ "secretsmanager",
6817
+ "get-secret-value",
6818
+ "--secret-id",
6819
+ secretId,
6820
+ "--region",
6821
+ DB_READER_REGION,
6822
+ "--query",
6823
+ "SecretString",
6824
+ "--output",
6825
+ "text"
6826
+ ]);
6827
+ if (!fetched.ok) {
6828
+ if (/ResourceNotFound/.test(fetched.err)) {
6829
+ const name = suggestMysqlName((spawnSync("git", [
6830
+ "config",
6831
+ "--global",
6832
+ "user.email"
6833
+ ], {
6834
+ encoding: "utf-8",
6835
+ stdio: "pipe"
6836
+ }).stdout ?? "").trim(), iamUser);
6837
+ note(`Prod DB access pending — ask the infra owner to run: ${import_picocolors.default.cyan(`stacks/buzz-db-reader/scripts/create-reader ${name} ${iamUser}`)}, then re-run ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
6838
+ } else note(`Prod DB access not set up — could not read ${secretId}. Check it manually: ${import_picocolors.default.cyan(`aws secretsmanager describe-secret --secret-id ${secretId} --region ${DB_READER_REGION}`)}`);
6839
+ return;
6840
+ }
6841
+ const secret = parseReaderSecret(fetched.out);
6842
+ if (!secret) {
6843
+ note(`Prod DB access not set up — ${secretId} does not hold a valid username and password.`);
6844
+ return;
6845
+ }
6846
+ if (!storeInKeychain(secret.username, secret.password)) {
6847
+ note(`Prod DB access not set up — the Keychain write failed. Add it by hand: ${import_picocolors.default.cyan(`security add-generic-password -U -s ${DB_READER_SERVICE} -a ${secret.username} -w`)} (prompts for the password).`);
6848
+ return;
6849
+ }
6850
+ writeConfig(secret.username);
6851
+ M.success(`Keychain entry ${DB_READER_SERVICE} stored for ${secret.username}`);
6852
+ if (!downloadCaBundle()) {
6853
+ note(`Prod DB access not set up — the RDS CA bundle download failed. Run: ${import_picocolors.default.cyan(`curl -sSfo ${dbReaderCaPath()} ${RDS_CA_URL}`)}`);
6854
+ return;
6855
+ }
6856
+ const installError = await installScripts();
6857
+ if (installError) {
6858
+ note(`Prod DB access not set up — ${installError}. Re-run once the catalog is reachable.`);
6859
+ return;
6860
+ }
6861
+ M.success(`Installed ${DB_READER_SCRIPTS.join(" and ")} in ${dbReaderBinDir()}`);
6862
+ if (!(process.env.PATH ?? "").split(delimiter).includes(dbReaderBinDir())) M.warn(`${dbReaderBinDir()} is not on your PATH. Add ${import_picocolors.default.cyan("export PATH=\"$HOME/.local/bin:$PATH\"")} to ~/.zshrc.`);
6863
+ const verify = runVerify$1();
6864
+ if (!verify.ok) {
6865
+ note(`Prod DB access not enabled — ${import_picocolors.default.cyan("pruff-db-verify")} failed. Run ${import_picocolors.default.cyan(`${join(dbReaderBinDir(), "pruff-db-verify")} ${DB_READER_VERIFY_DATABASE}`)} and share the output with the infra owner.`);
6866
+ M.warn(verify.out);
6867
+ return;
6868
+ }
6869
+ M.success("Read-only access verified");
6870
+ if (mergeAllowlist(workspace) === "invalid") note(`Add ${import_picocolors.default.cyan(DB_READER_ALLOW_ENTRY)} to permissions.allow in ${join(workspace, ".claude", "settings.json")} by hand — the file is not valid JSON.`);
6871
+ const hooks = await installHooks(["prod-db-guard"]);
6872
+ for (const failure of hooks.failed) note(`Hook ${failure.name} not installed: ${failure.reason}`);
6873
+ await installReaderSkill(workspace, note);
6874
+ if (!existsSync(join(workspace, ".claude", "rules", "prod-db-reads.md"))) note(`The prod-db-reads rule is missing from the workspace — run ${import_picocolors.default.cyan("npx pruff-skills rules add --all")} from ${workspace}.`);
6875
+ }
6876
+ const WORKSPACE_DIRS = [".claude/scripts"];
6877
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
6878
+ const isExecutable = (name) => /\.(sh|zsh|bash)$/.test(name);
6879
+ async function installWorkspaceFiles(workspace, source = PRUFF_CATALOG) {
6880
+ const result = {
6881
+ written: [],
6882
+ unchanged: [],
6883
+ skippedLocalEdits: [],
6884
+ catalogUnavailable: false
6885
+ };
6886
+ const state = readState();
6887
+ for (const dir of WORKSPACE_DIRS) {
6888
+ const names = await listCatalogDir(source, `workspace/${dir}`);
6889
+ if (names === null) {
6890
+ result.catalogUnavailable = true;
6891
+ continue;
6892
+ }
6893
+ for (const name of names.sort()) {
6894
+ const content = await fetchCatalogFile(source, `workspace/${dir}/${name}`);
6895
+ if (content === null) continue;
6896
+ const target = join(workspace, dir, name);
6897
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
6898
+ if (current === content) {
6899
+ state.files[target] = sha256(content);
6900
+ result.unchanged.push(target);
6901
+ continue;
6902
+ }
6903
+ if (current !== null && sha256(current) !== state.files[target]) {
6904
+ result.skippedLocalEdits.push(target);
6905
+ continue;
6906
+ }
6907
+ mkdirSync(dirname(target), { recursive: true });
6908
+ writeFileSync(target, content, "utf-8");
6909
+ if (isExecutable(name)) chmodSync(target, 493);
6910
+ state.files[target] = sha256(content);
6911
+ result.written.push(target);
6912
+ }
6913
+ }
6914
+ writeState(state);
6915
+ return result;
6916
+ }
6279
6917
  const RESET = "\x1B[0m";
6280
6918
  const DIM = "\x1B[38;5;102m";
6281
6919
  const BLUES = [
@@ -6395,9 +7033,10 @@ async function showBlockLogo(lines, letterCuts, { slow = false, palette = BLUES
6395
7033
  }
6396
7034
  const WORKSPACE_CLAUDE_TEMPLATE = `# Pruff — Working Directory
6397
7035
 
6398
- This folder is the working directory for Pruff's frontend and backend. The two main git repos are:
7036
+ This folder is the working directory for Pruff's frontend and backend. The three main git repos are:
6399
7037
 
6400
- - **\`Pruff-Client/\`** — React frontend
7038
+ - **\`Pruff-Client/\`** — React frontend (customer-facing, tenant-scoped)
7039
+ - **\`Pruff-Backoffice/\`** — React frontend (internal staff, cross-tenant)
6401
7040
  - **\`Pruff-server/\`** — Node.js/Express backend
6402
7041
 
6403
7042
  Other directories (\`sls-*/\`, \`slsl-*/\`, \`AWS-Tools/\`, etc.) are serverless microservices and tooling —
@@ -6408,14 +7047,33 @@ workspace-level context only.
6408
7047
 
6409
7048
  ---
6410
7049
 
7050
+ ## Read the repo's own rules before touching its files
7051
+
7052
+ When the session is rooted at the workspace folder, a repo's \`CLAUDE.md\` and \`.claude/rules/\` are **not**
7053
+ loaded automatically — only this file is. The repo-level rules are the authoritative, most current
7054
+ source for that codebase; this file is a workspace-level summary. Where they disagree, the repo rule wins.
7055
+
7056
+ Before your first edit to any file inside a repo subdirectory, read that repo's rules (once per session):
7057
+
7058
+ \`\`\`bash
7059
+ cat Pruff-server/CLAUDE.md Pruff-server/.claude/rules/*.md # backend work
7060
+ cat Pruff-Client/CLAUDE.md Pruff-Client/.claude/rules/*.md # frontend work
7061
+ cat Pruff-Backoffice/CLAUDE.md Pruff-Backoffice/.claude/rules/*.md
7062
+ \`\`\`
7063
+
7064
+ If the session is already rooted inside the repo (or one of its worktrees), they load automatically.
7065
+
7066
+ ---
7067
+
6411
7068
  ## Workspace Layout
6412
7069
 
6413
7070
  ### Main applications (modify freely when asked)
6414
7071
 
6415
7072
  | Folder | What it is |
6416
7073
  |---|---|
6417
- | \`Pruff-Client/\` | React 18 + Vite + MUI frontend. React Query + Redux Toolkit. |
6418
- | \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant). |
7074
+ | \`Pruff-Client/\` | React 18 + Vite + MUI frontend. Many feature pages, React Query + Redux Toolkit. |
7075
+ | \`Pruff-Backoffice/\` | React 19 + Vite + MUI v7 internal staff frontend. Cross-tenant views, tenant config, roles/permissions. React Query + Zustand. Consumes \`/api/backoffice\`. Branches from \`main\` (no \`dev\`); no test suite. |
7076
+ | \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant), many domains and routes. |
6419
7077
 
6420
7078
  ### Serverless microservices (\`sls-*/\`, \`slsl-*/\` — clone as needed, don't modify unless asked)
6421
7079
 
@@ -6434,27 +7092,57 @@ over HTTPS with an \`x-api-key\`.
6434
7092
  | \`sls-Notification-Service/\` | **Outbound notifications** (email, in-app, collections). |
6435
7093
  | \`sls-Pruff-Webhooks/\` | **Outbound webhook delivery** to client integrations. |
6436
7094
  | \`sls-Pruff-Workers/\` | **Async background jobs for Pruff-server** (PDF rendering, tenant document/summary sync crons). |
7095
+ | \`sls-Judiciary-Extraction/\` | **PJUD (Poder Judicial) scraper.** Given a RUT, returns every *causa* across the jurisdictional competences; no database, writes the aggregate to S3. Build on \`extractCourtCases\`; \`judiciaryOrchestrator\` + \`judiciaryScrapper\` is the legacy rollback path — don't add features to it. |
7096
+ | \`sls-external-databases/\` | **External compliance/risk data sources behind API Gateway** (Bankruptcy, Rutificator, DepartmentOfLabor): scheduled bulk extractors plus query endpoints. Also read synchronously by Pruff-server via \`externalDatabasesModule\`, so it sits on the request path. |
7097
+ | \`sls-server-scheduler/\` | **Cron triggers for Pruff-server's jobs.** EventBridge-scheduled Lambdas, each a thin \`POST\` to an \`/api/external/jobs/*\` endpoint with an \`x-api-key\`. Schedules are UTC only. Jobs are migrating to \`Pruff-infra/stacks/pruff-job-runner\`. |
7098
+
7099
+ #### Deprecated / being retired — don't invest in these
7100
+
7101
+ | Folder | Status |
7102
+ |---|---|
7103
+ | \`sls-financial-statements/\` | **Deprecated.** Leave it out of workspace-wide sweeps (lockfiles, Dependabot, OIDC, CODEOWNERS). |
7104
+ | \`sls-TGR-Extraction/\` | **Slated for deprecation.** Still live and deployed, so it stays in the table above; check the tracking issue before starting non-trivial work in it. |
7105
+
7106
+ If you add a new \`sls-*\` repo, add it to the table above — workspace-wide sweeps are derived from this list.
7107
+
7108
+ **Not every Lambda is an \`sls-*\` repo.** Several live in \`Pruff-infra/stacks/\` and deploy with
7109
+ \`terraform apply\` instead (e.g. \`newsletter-automation\`, \`github-release-automation\`,
7110
+ \`intelligence-recompute\`, \`reconcile-credit-note-links\`, \`posthog-error-digest\`, \`buzz-relay\`).
7111
+
7112
+ **Scheduled Pruff-server jobs are a third list.** \`Pruff-infra/stacks/pruff-job-runner\` runs them as
7113
+ one-shot Fargate tasks (EventBridge Scheduler → \`ecs:RunTask\`, \`America/Santiago\`), one task definition
7114
+ and one schedule per job. They are not Lambdas and do not appear in \`aws lambda list-functions\`.
6437
7115
 
6438
7116
  ### Tooling & utilities
6439
7117
 
6440
7118
  | Folder | What it is |
6441
7119
  |---|---|
6442
7120
  | \`AWS-Tools/\` | Standalone Node/TypeScript scripts for AWS ops — not deployed; run locally with AWS creds. |
7121
+ | \`dte-signer/\` | \`@pruff/dte-signer\` — TypeScript library to emit and cede DTEs before the SII. Published as a package, not deployed. Prototype: most of the public surface is not implemented yet. Single \`main\` branch. |
7122
+
7123
+ ### Worktrees & scratch (do not touch)
7124
+
7125
+ | Folder | What it is |
7126
+ |---|---|
7127
+ | \`.worktrees/\`, \`worktrees/\` | Git worktrees created by the harness for isolated agent runs. |
7128
+ | \`Pruff-Client-boneyard/\` | Archived/deleted frontend components kept for reference. |
7129
+ | \`Pruff-Client-pru-*/\`, \`Pruff-server-pru-*/\` | Per-ticket worktrees for in-progress PRs. Work in \`Pruff-Client/\` and \`Pruff-server/\` unless pointed at one of these. |
6443
7130
 
6444
7131
  ### Cross-service data flow (mental model)
6445
7132
 
6446
7133
  \`\`\`
6447
- sls-SII-Services ─┐
6448
- sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
6449
- sls-Public-Market ─┘ │
6450
- ▼
6451
- sls-Decision-Engine ──► Pruff-server
6452
- │
6453
- ▼
6454
- sls-Notification-Service / sls-Pruff-Webhooks (outbound)
7134
+ sls-SII-Services ─┐
7135
+ sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
7136
+ sls-Public-Market │ │ ▲
7137
+ sls-external-databases ┘ ▼ │
7138
+ sls-Decision-Engine ──► Pruff-server
7139
+ │ (pull, via API Gateway:
7140
+ ▼ externalDatabasesModule)
7141
+ sls-Notification-Service / sls-Pruff-Webhooks (outbound)
6455
7142
  \`\`\`
6456
7143
 
6457
7144
  \`sls-Pruff-Workers\` sits beside \`Pruff-server\` handling async/scheduled jobs that would block the API.
7145
+ \`sls-external-databases\` is also read synchronously (Pruff-server calls its \`/consult\` endpoints on demand).
6458
7146
 
6459
7147
  ---
6460
7148
 
@@ -6534,7 +7222,7 @@ src/
6534
7222
  internal/ # Staff endpoints (JWT auth)
6535
7223
  clients/ # Customer-facing endpoints (API key)
6536
7224
  external/ # Scraper/lambda endpoints (API key)
6537
- public/ # No auth
7225
+ public/ # No auth at tier level
6538
7226
  modules/ # Infrastructure wrappers (S3, SQS, SII, GoFirmex, etc.)
6539
7227
  middlewares/ # Auth, tenant resolution, rate limiting, error handling
6540
7228
  jobs/ # Cron jobs (iterate over all tenants)
@@ -6544,33 +7232,43 @@ src/
6544
7232
 
6545
7233
  ### API Tiers
6546
7234
 
7235
+ Six tiers, all mounted in \`src/routes/api.ts\`:
7236
+
6547
7237
  | Tier | Base Route | Auth | Middleware |
6548
7238
  |------|-----------|------|-----------|
6549
- | Internal | \`/api/internal\` | JWT | \`authenticateJWT\` + \`attachUserPermissions\` |
6550
- | External | \`/api/external\` | API Key | \`authenticateWithApiKey\` |
6551
- | Clients | \`/api/clients\` | Client API Key | \`authenticateClientsWithApiKey\` |
6552
- | Public | \`/api/public\` | None | — |
7239
+ | Internal | \`/api/internal\` | JWT | \`tenantMiddleware(JWTTenantResolver)\` + \`loadUser\` + \`attachUserPermissions\` |
7240
+ | External | \`/api/external\` | API Key (one shared key) | \`authenticateWithApiKey\` |
7241
+ | Clients | \`/api/clients\` | Client API Key | \`tenantMiddleware(ClientApiKeyTenantResolver)\` + \`logApiKeyRequest\` |
7242
+ | Public | \`/api/public\` | None at tier level — each endpoint self-guards | — |
7243
+ | Backoffice | \`/api/backoffice\` | None at tier level — validated per sub-router | — |
7244
+ | Service | \`/api/service\` | Pruff-ai service token | \`requirePruffAiServiceToken\` |
6553
7245
 
6554
7246
  ### Service Pattern — Result\\<T\\>
6555
7247
 
6556
- All services return \`Result<T>\`. Never throw from services.
7248
+ All services return \`Result<T>\` (where \`T extends Record<string, unknown>\`). Never throw from services.
7249
+ Services extend \`BaseService\` so \`serviceName\` is inferred — never pass a string literal.
6557
7250
 
6558
7251
  \`\`\`typescript
6559
- type Result<T> = { ok: true; value: T } | { ok: false; error: AppError };
6560
-
6561
- return { ok: true, value };
6562
- return { ok: false, error: { status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', service: 'MyService', displayMessage: 'No encontrado' } };
7252
+ class CreateFeatureService extends BaseService {
7253
+ async execute(prisma: PrismaClient, dto: CreateFeatureDto): Promise<Result<Feature>> {
7254
+ return Result.success({ feature });
7255
+ return Result.failure({ service: this.serviceName, status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', displayMessage: 'No encontrado' });
7256
+ }
7257
+ }
7258
+ export default new CreateFeatureService();
6563
7259
  \`\`\`
6564
7260
 
6565
7261
  ### Controller Pattern — handleApiOutput / handleApiError
6566
7262
 
7263
+ Pass the **service instance**, not a string.
7264
+
6567
7265
  \`\`\`typescript
6568
7266
  async someAction(req: UserRequest, res: Response) {
6569
7267
  try {
6570
7268
  const result = await SomeService.execute(req.prisma, req.body);
6571
- return handleApiOutput('SomeService', req, res, result);
7269
+ return handleApiOutput(SomeService, req, res, result);
6572
7270
  } catch (error) {
6573
- handleApiError('SomeService', req, res, error);
7271
+ handleApiError(SomeService, req, res, error);
6574
7272
  }
6575
7273
  }
6576
7274
  \`\`\`
@@ -6580,7 +7278,7 @@ async someAction(req: UserRequest, res: Response) {
6580
7278
  - **Services:** \`PascalCase\` + \`Service\` suffix → \`CreateFactoringRequestService.ts\`
6581
7279
  - **DTOs/interfaces:** \`PascalCase\` + \`Dto\` suffix → \`CreateFactoringRequestDto\`
6582
7280
  - **Controllers:** \`camelCase\` + \`Controller\` suffix → \`invoicesController.ts\`
6583
- - **Services are singletons:** \`export default new MyService();\`
7281
+ - **Services are singletons extending \`BaseService\`:** \`export default new MyService();\`
6584
7282
  - **\`prisma\` is always the first parameter** when the service needs DB access
6585
7283
 
6586
7284
  ### Accounting Rules
@@ -6718,10 +7416,17 @@ Notes are frontmatter/filenames in English (\`kebab-case\`), body in Spanish. Se
6718
7416
 
6719
7417
  - **Always use \`/commit\`** — invoke the \`commit\` skill for all git commits. Never run \`git commit\` manually.
6720
7418
  - **Always use \`/create-pr\`** — invoke the \`create-pr\` skill for all pull requests. Never run \`gh pr create\` manually.
6721
- - **Commits are authored to the human running Claude Code** — never set \`--author\` to Claude or any co-author.
6722
- - **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — verify this against each repo's actual
6723
- \`dev\` vs \`main\` state before assuming; it has drifted before. Exception: \`Pruff-server\` and
6724
- \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`).
7419
+ - **You, the human, are the sole author of every commit** — never set \`--author\` to Claude and never add Claude as a co-author.
7420
+ - **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — these repos deploy from \`main\` and
7421
+ their \`dev\` branches are stale/abandoned. PR target = branch base = \`main\`. Exception: \`Pruff-server\`
7422
+ and \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`). \`Pruff-Backoffice\`
7423
+ and \`dte-signer\` have no \`dev\` — branch and target \`main\`.
7424
+ - **Create worktrees with \`.claude/scripts/wt-new.sh\`** (or \`wt new <repo> <branch>\` after sourcing
7425
+ \`.claude/scripts/wt.zsh\`) — never by hand. It picks the correct base (\`dev\` for
7426
+ Pruff-server/Pruff-Client/Pruff-ai, \`main\` for everything else), fetches it first, clones
7427
+ \`node_modules\` copy-on-write and symlinks \`test.env\`/\`local.env\`/\`dev.env\` from the main checkout.
7428
+ For a hotfix pass \`--hotfix\` so the branch comes off \`origin/main\`, or \`--base <branch>\` for any
7429
+ other base. Remove one with \`.claude/scripts/wt-rm.sh\`; diagnose with \`.claude/scripts/wt-doctor.sh\`.
6725
7430
  - **Branch names embed the Linear issue code; PRs map 1:1 with Linear issues** — name branches so they
6726
7431
  contain the issue code (e.g. \`pru-1355-...\`) for Linear's git automation. When splitting work, plan
6727
7432
  the PR grouping first, create one Linear issue per PR (1:1), and stack dependent PRs
@@ -6802,56 +7507,6 @@ async function main() {
6802
7507
  console.log('RUT_HOLDER=' + (company ? company.secretId : ''));
6803
7508
  }
6804
7509
 
6805
- main()
6806
- .catch((e) => {
6807
- console.error(e);
6808
- process.exit(1);
6809
- })
6810
- .finally(() => process.exit(0));
6811
- `;
6812
- const TENANT_USER_BACKFILL_SCRIPT = `import PrismaClientManager from './src/modules/prismaClient';
6813
- import EncryptionModule from './src/modules/encryptionModule';
6814
-
6815
- const SUPER_ADMIN_ROLE_ID = 4;
6816
-
6817
- async function main() {
6818
- const email = process.argv[2];
6819
- const password = process.argv[3];
6820
- const clientId = process.env.CLIENT_ID;
6821
- if (!clientId) throw new Error('CLIENT_ID is required');
6822
-
6823
- const tenantPrisma = PrismaClientManager.getGlobalClient();
6824
- const existing = await tenantPrisma.user.findFirst({ where: { email } });
6825
-
6826
- if (existing) {
6827
- // create-intel-tenant-local resets tenant roles to Administrador only;
6828
- // reconnect Super Administrador so the local admin keeps full access.
6829
- await tenantPrisma.user.update({
6830
- where: { id: existing.id },
6831
- data: { roles: { connect: [{ id: SUPER_ADMIN_ROLE_ID }] } },
6832
- });
6833
- console.log('tenant user present: ' + email + ' (super-admin role ensured)');
6834
- return;
6835
- }
6836
-
6837
- const globalPrisma = PrismaClientManager.getGlobalClientFromTenantBase(clientId);
6838
- const globalUser = await globalPrisma.user.findFirstOrThrow({ where: { email } });
6839
- const encryptedPassword = await EncryptionModule.encrypt(password);
6840
-
6841
- await tenantPrisma.user.create({
6842
- data: {
6843
- id: globalUser.id,
6844
- email,
6845
- name: email.split('@')[0],
6846
- password: encryptedPassword,
6847
- active: true,
6848
- companyId: globalUser.companyId,
6849
- roles: { connect: [{ id: SUPER_ADMIN_ROLE_ID }] },
6850
- },
6851
- });
6852
- console.log('tenant user created: ' + email + ' (id=' + globalUser.id + ')');
6853
- }
6854
-
6855
7510
  main()
6856
7511
  .catch((e) => {
6857
7512
  console.error(e);
@@ -6888,72 +7543,28 @@ main()
6888
7543
  })
6889
7544
  .finally(() => process.exit(0));
6890
7545
  `;
6891
- const MIGRATE_TENANTS_LOCAL_SCRIPT = `import { exec } from 'child_process';
6892
- import dotenv from 'dotenv';
6893
- import PrismaClientManager from '../src/modules/prismaClient';
6894
- const env = 'local.env';
6895
- dotenv.config({ path: env });
6896
-
6897
- async function getActiveTenants() {
6898
- const prismaGlobal = PrismaClientManager.getGlobalClient();
6899
- const companies = await prismaGlobal.company.findMany({
6900
- where: { tenant: true },
6901
- select: { secretId: true },
6902
- });
6903
- console.log('Companies:', companies.length);
6904
- return companies.map((company) => company.secretId);
6905
- }
6906
-
6907
- function migrate(label: string, databaseUrl: string) {
6908
- return new Promise((resolve, reject) => {
6909
- const command = \`DATABASE_URL="\${databaseUrl}" npx prisma migrate deploy\`;
6910
- console.log(\`Running command: \${command}\`);
6911
- exec(command, (error, stdout, stderr) => {
6912
- if (error) {
6913
- console.error(\`Error migrating \${label}:\`, error);
6914
- reject(error);
6915
- return;
6916
- }
6917
- if (stderr) {
6918
- console.error(\`Stderr for \${label}:\`, stderr);
6919
- reject(new Error(stderr));
6920
- return;
6921
- }
6922
- console.log(\`Migration output for \${label}:\`, stdout);
6923
- resolve(stdout);
6924
- });
6925
- });
6926
- }
6927
-
6928
- async function migrateTenants() {
6929
- console.log('Migrating main database');
6930
- try {
6931
- await migrate('local database', process.env.DATABASE_URL!);
6932
- } catch (error) {
6933
- console.error('Failed to migrate local database:', error);
6934
- }
6935
-
6936
- try {
6937
- const tenantIds = await getActiveTenants();
6938
- for (const tenantId of tenantIds) {
6939
- console.log(\`Migrating tenant: \${tenantId}\`);
6940
- const databaseUrl = process.env.DATABASE_URL!.replace(
6941
- '/${CONTROL_PLANE_DB}',
6942
- \`/tenant-\${tenantId}\`,
6943
- );
6944
- try {
6945
- await migrate(\`tenant \${tenantId}\`, databaseUrl);
6946
- } catch (error) {
6947
- console.error(\`Failed to migrate tenant \${tenantId}:\`, error);
6948
- }
6949
- }
6950
- } catch (error) {
6951
- console.error('Error fetching tenants or migrating:', error);
6952
- }
6953
- }
7546
+ const TENANT_CONFIG_PATH = join("scripts", "tenant", "tenant.config.ts");
7547
+ function renderTenantConfig(v) {
7548
+ return `import { CompanyType } from './consts';
7549
+ import { TenantScriptConfig } from './types';
7550
+
7551
+ const config: TenantScriptConfig = {
7552
+ secretId: ${JSON.stringify(v.secretId)},
7553
+ taxIdentifier: ${JSON.stringify(v.taxIdentifier)},
7554
+ companyName: ${JSON.stringify(v.companyName)},
7555
+ companyTypes: [CompanyType.Intelligence],
7556
+ admin: {
7557
+ email: ${JSON.stringify(v.adminEmail)},
7558
+ password: ${JSON.stringify(v.adminPassword)},
7559
+ superAdmin: true,
7560
+ },
7561
+ additionalUsers: [],
7562
+ configurationFlags: [],
7563
+ };
6954
7564
 
6955
- migrateTenants();
7565
+ export default config;
6956
7566
  `;
7567
+ }
6957
7568
  function mysqlUrl(dbPassword, db, query = "") {
6958
7569
  return `mysql://${dbPassword ? `root:${encodeURIComponent(dbPassword)}` : "root"}@localhost:3306/${db}${query}`;
6959
7570
  }
@@ -7090,11 +7701,17 @@ const WORKSPACE_SETTINGS_JSON = `${JSON.stringify({ permissions: { allow: [
7090
7701
  "mcp__linear-server__get_project"
7091
7702
  ] } }, null, 2)}\n`;
7092
7703
  const CLAUDE_CODE_PKG = "@anthropic-ai/claude-code";
7093
- const INTELLIGENCE_SCHEMA = "prisma/intelligence/schema.prisma";
7094
- const SERVER_SCHEMA = "prisma/schema";
7704
+ const SERVER_SCRIPT = {
7705
+ migrate: "db-migrate-local",
7706
+ seedControlPlane: "db-seed-control-plane-local",
7707
+ createTenant: "create-tenant-local",
7708
+ openAccountingPeriod: "open-accounting-period-local",
7709
+ intelMigrate: "db-intel-migrate-local",
7710
+ intelGenerate: "db-intel-generate",
7711
+ intelSeedGlobal: "db-seed-intel-global-local",
7712
+ intelCreateTenant: "create-intel-tenant-local"
7713
+ };
7095
7714
  const PRISMA_BIN = join("node_modules", ".bin", "prisma");
7096
- const INTELLIGENCE_GLOBAL_SEED = "prisma/intelligence/seeds/seedIntelligenceGlobal.ts";
7097
- const INTELLIGENCE_TENANT_SCRIPT = "scripts/intelligence/createIntelligenceTenant.ts";
7098
7715
  const KNOWLEDGE_REPO = "https://github.com/Pruff-Tech/pruff-knowledge";
7099
7716
  const KNOWLEDGE_DIR = "Pruff Knowledge";
7100
7717
  const DEFAULT_WORKSPACE = join(homedir(), "pruff");
@@ -7129,6 +7746,27 @@ const CORE_AGENTS = [
7129
7746
  "db-query-reviewer",
7130
7747
  "pruff-logs-investigator"
7131
7748
  ];
7749
+ const TEAM_HOOKS = [
7750
+ "sweep-nudge",
7751
+ "prod-db-guard",
7752
+ "handoff-nudge",
7753
+ "save-session"
7754
+ ];
7755
+ const TEAM_USER_ENV = { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" };
7756
+ function catalogInstallNames(rawIndex) {
7757
+ if (!rawIndex) return {
7758
+ skills: CORE_SKILLS,
7759
+ agents: CORE_AGENTS
7760
+ };
7761
+ const index = parseCatalogIndex(rawIndex, PRUFF_CATALOG);
7762
+ const names = (entries) => Array.isArray(entries) ? entries.map((e) => e.name).filter((n) => typeof n === "string") : [];
7763
+ const skills = names(index.skills);
7764
+ const agents = names(index.agents);
7765
+ return {
7766
+ skills: skills.length > 0 ? [...new Set([...skills, "find-skills"])] : CORE_SKILLS,
7767
+ agents: agents.length > 0 ? agents : CORE_AGENTS
7768
+ };
7769
+ }
7132
7770
  const issues = [];
7133
7771
  function note(issue) {
7134
7772
  issues.push(issue);
@@ -7138,8 +7776,21 @@ async function runOnboarding(args) {
7138
7776
  M.error(`${import_picocolors.default.bold("onboarding")} currently supports macOS only (detected ${import_picocolors.default.cyan(process.platform)}).`);
7139
7777
  return;
7140
7778
  }
7779
+ if (args.includes("--print-claude-md")) {
7780
+ process.stdout.write(WORKSPACE_CLAUDE_TEMPLATE);
7781
+ return;
7782
+ }
7141
7783
  Ie(import_picocolors.default.bold("Pruff onboarding"));
7142
7784
  issues.length = 0;
7785
+ if (args.includes("--db-access")) {
7786
+ await setupDbReader({
7787
+ workspace: await resolveWorkspace(args),
7788
+ note
7789
+ });
7790
+ showIssues();
7791
+ Se(issues.length === 0 ? import_picocolors.default.green("Done.") : import_picocolors.default.yellow("Prod DB access is not complete — fix the items above and re-run."));
7792
+ return;
7793
+ }
7143
7794
  if (!await preflight()) {
7144
7795
  Se(import_picocolors.default.yellow("Onboarding stopped — fix the items above and re-run."));
7145
7796
  return;
@@ -7156,10 +7807,16 @@ async function runOnboarding(args) {
7156
7807
  writeWorkspaceClaudeMd(workspace);
7157
7808
  writeHarnessConfig(workspace);
7158
7809
  await installCatalog(workspace);
7810
+ await installHarness(workspace);
7159
7811
  if (await shouldSetupDatabase(args)) await setupLocalEnvironment(join(workspace, "Pruff-server"));
7812
+ if (await shouldSetupDbAccess(args)) await setupDbReader({
7813
+ workspace,
7814
+ note
7815
+ });
7160
7816
  await showGuidance(workspace);
7161
7817
  showTeamCredentialsAsk(workspace);
7162
7818
  showIssues();
7819
+ showLocalLogin();
7163
7820
  if (issues.length === 0) Se(`${import_picocolors.default.green("Done.")} Welcome to Pruff — happy building.`);
7164
7821
  else Se(`${import_picocolors.default.yellow(`Finished with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`)} Fix them and re-run — every step is idempotent.`);
7165
7822
  }
@@ -7425,12 +8082,19 @@ function installDeps(repo, workspace) {
7425
8082
  }
7426
8083
  function writeWorkspaceClaudeMd(workspace) {
7427
8084
  const target = join(workspace, "CLAUDE.md");
7428
- if (existsSync(target)) {
7429
- M.success("Workspace CLAUDE.md already present — leaving it untouched");
8085
+ const hash = (s) => createHash("sha256").update(s).digest("hex");
8086
+ const state = readState();
8087
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
8088
+ if (current === WORKSPACE_CLAUDE_TEMPLATE) M.success("Workspace CLAUDE.md is up to date");
8089
+ else if (current !== null && hash(current) !== state.files[target]) {
8090
+ M.warn(`Workspace CLAUDE.md has your own edits — left untouched. The team template changed; compare with ${import_picocolors.default.cyan("npx pruff-skills onboarding --print-claude-md")}.`);
7430
8091
  return;
8092
+ } else {
8093
+ writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
8094
+ M.success(current === null ? "Wrote workspace CLAUDE.md" : "Updated workspace CLAUDE.md");
7431
8095
  }
7432
- writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
7433
- M.success("Wrote workspace CLAUDE.md");
8096
+ state.files[target] = hash(WORKSPACE_CLAUDE_TEMPLATE);
8097
+ writeState(state);
7434
8098
  }
7435
8099
  function writeHarnessConfig(workspace) {
7436
8100
  writeIfAbsent(join(workspace, ".mcp.json"), WORKSPACE_MCP_JSON, "MCP servers (.mcp.json)");
@@ -7452,9 +8116,10 @@ async function installCatalog(workspace) {
7452
8116
  try {
7453
8117
  process.chdir(workspace);
7454
8118
  await runRules(["add", "--all"]);
7455
- await runAgents(["add", ...CORE_AGENTS]);
8119
+ const { skills, agents } = catalogInstallNames(await fetchCatalogFile(PRUFF_CATALOG, ".well-known/agent-skills/index.json"));
8120
+ await runAgents(["add", ...agents]);
7456
8121
  await runAdd([PRUFF_CATALOG], {
7457
- skill: CORE_SKILLS,
8122
+ skill: skills,
7458
8123
  agent: ["claude-code"],
7459
8124
  yes: true
7460
8125
  });
@@ -7465,6 +8130,53 @@ async function installCatalog(workspace) {
7465
8130
  process.chdir(prevCwd);
7466
8131
  }
7467
8132
  }
8133
+ async function installHarness(workspace) {
8134
+ M.step("Installing the team harness (hooks, settings, scripts)…");
8135
+ try {
8136
+ const state = readState();
8137
+ const env = applyOwnedEnv(readClaudeSettings$1(), TEAM_USER_ENV, state.env);
8138
+ writeClaudeSettings$1(env.settings);
8139
+ writeState({
8140
+ ...readState(),
8141
+ env: env.owned
8142
+ });
8143
+ for (const key of env.keptUserValues) M.info(`${key} already set in ~/.claude/settings.json — kept your value`);
8144
+ const hooks = await installHooks(TEAM_HOOKS);
8145
+ const ready = [...hooks.installed, ...hooks.unchanged];
8146
+ if (ready.length > 0) M.success(`Hooks: ${ready.join(", ")}`);
8147
+ for (const name of hooks.skippedLocalEdits) M.warn(`Hook ${name} was edited locally — kept. Overwrite with ${import_picocolors.default.cyan(`npx pruff-skills hooks add ${name} --force`)}`);
8148
+ for (const f of hooks.failed) note(`Hook ${f.name} not installed: ${f.reason}`);
8149
+ const files = await installWorkspaceFiles(workspace);
8150
+ if (files.catalogUnavailable) note("Workspace scripts could not be read from the catalog.");
8151
+ for (const path of files.skippedLocalEdits) M.warn(`${path} has local edits — kept`);
8152
+ M.message(import_picocolors.default.dim(`Worktree helper: add ${import_picocolors.default.cyan(`source ${join(workspace, ".claude", "scripts", "wt.zsh")}`)} to ~/.zshrc`));
8153
+ } catch (error) {
8154
+ note("Team harness did not finish installing.");
8155
+ M.warn(error instanceof Error ? error.message : String(error));
8156
+ }
8157
+ ensureRtk();
8158
+ }
8159
+ function ensureRtk() {
8160
+ if (!has("rtk")) {
8161
+ const s = Y();
8162
+ s.start("Installing RTK…");
8163
+ const ok = run("brew", ["install", "rtk"]);
8164
+ s.stop(ok ? "RTK installed" : import_picocolors.default.yellow("RTK install failed"));
8165
+ if (!ok) {
8166
+ note(`RTK not installed — run ${import_picocolors.default.cyan("brew install rtk")}.`);
8167
+ return;
8168
+ }
8169
+ }
8170
+ if (spawnSync("rtk", ["gain"], { stdio: "ignore" }).status !== 0) {
8171
+ note(`The ${import_picocolors.default.cyan("rtk")} on PATH is not RTK (name collision with Rust Type Kit).`);
8172
+ return;
8173
+ }
8174
+ if (!run("rtk", [
8175
+ "init",
8176
+ "-g",
8177
+ "--auto-patch"
8178
+ ])) note("`rtk init -g` failed.");
8179
+ }
7468
8180
  async function setupLocalEnvironment(serverPath) {
7469
8181
  if (!existsSync(join(serverPath, ".git"))) {
7470
8182
  note("Local database not set up — Pruff-server was not cloned.");
@@ -7481,7 +8193,6 @@ async function setupLocalEnvironment(serverPath) {
7481
8193
  const dbPassword = await resolveRootPassword();
7482
8194
  if (dbPassword === null) return;
7483
8195
  generateLocalEnv(serverPath, dbPassword);
7484
- writeMigrateTenantsLocal(serverPath);
7485
8196
  const env = parseEnvFile(join(serverPath, "local.env"));
7486
8197
  if (!env.DATABASE_URL || !env.CLIENT_ID) {
7487
8198
  note("Local database not set up — local.env is missing DATABASE_URL / CLIENT_ID.");
@@ -7490,6 +8201,15 @@ async function setupLocalEnvironment(serverPath) {
7490
8201
  }
7491
8202
  setupLocalDatabase(serverPath, env);
7492
8203
  }
8204
+ async function shouldSetupDbAccess(args) {
8205
+ if (args.includes("--yes") || args.includes("-y") || !process.stdout.isTTY) return true;
8206
+ const answer = await ye({
8207
+ message: "Set up read-only production database access now? (needs your AWS credentials; stores your personal reader password in the macOS Keychain)",
8208
+ initialValue: true
8209
+ });
8210
+ if (pD(answer)) return false;
8211
+ return answer === true;
8212
+ }
7493
8213
  async function shouldSetupDatabase(args) {
7494
8214
  if (args.includes("--no-db")) return false;
7495
8215
  if (args.includes("--db")) return true;
@@ -7627,11 +8347,6 @@ function generateLocalEnv(serverPath, dbPassword) {
7627
8347
  }), "utf-8");
7628
8348
  M.success("Wrote local.env (minimal bootable — fill third-party keys when needed)");
7629
8349
  }
7630
- function writeMigrateTenantsLocal(serverPath) {
7631
- const target = join(serverPath, "sincronizations", "migrateTenantsLocal.ts");
7632
- if (!existsSync(join(serverPath, "sincronizations"))) return;
7633
- writeIfAbsent(target, MIGRATE_TENANTS_LOCAL_SCRIPT, "sincronizations/migrateTenantsLocal.ts");
7634
- }
7635
8350
  function parseEnvFile(path) {
7636
8351
  if (!existsSync(path)) return {};
7637
8352
  const out = {};
@@ -7647,11 +8362,6 @@ function parseEnvFile(path) {
7647
8362
  }
7648
8363
  return out;
7649
8364
  }
7650
- function decideTenantAction(tenantDbExists, adminUserExists) {
7651
- if (tenantDbExists && adminUserExists) return "already-provisioned";
7652
- if (tenantDbExists) return "reset-and-seed";
7653
- return "create-and-seed";
7654
- }
7655
8365
  function parseMysqlUrl(url) {
7656
8366
  const u = new URL(url);
7657
8367
  return {
@@ -7681,15 +8391,6 @@ function mysqlArgs(conn) {
7681
8391
  if (conn.password) args.push(`-p${conn.password}`);
7682
8392
  return args;
7683
8393
  }
7684
- function mysqlQuery(conn, sql) {
7685
- return capture("mysql", [
7686
- ...mysqlArgs(conn),
7687
- "-N",
7688
- "-B",
7689
- "-e",
7690
- sql
7691
- ]);
7692
- }
7693
8394
  function mysqlExec(conn, sql) {
7694
8395
  return run("mysql", [
7695
8396
  ...mysqlArgs(conn),
@@ -7697,33 +8398,12 @@ function mysqlExec(conn, sql) {
7697
8398
  sql
7698
8399
  ]);
7699
8400
  }
7700
- function dbExists(conn, name) {
7701
- const { out } = mysqlQuery(conn, `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${name}'`);
7702
- return out.length > 0;
7703
- }
7704
- function tableExists(conn, db, table) {
7705
- const { out } = mysqlQuery(conn, `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${db}' AND TABLE_NAME = '${table}'`);
7706
- return out.length > 0;
7707
- }
7708
- function adminUserExists(conn, db, email) {
7709
- if (!tableExists(conn, db, "User")) return false;
7710
- const { ok, out } = mysqlQuery(conn, `SELECT COUNT(*) FROM \`${db}\`.\`User\` WHERE email = '${email}'`);
7711
- return ok && parseInt(out, 10) > 0;
7712
- }
7713
8401
  function prismaBin(serverPath) {
7714
8402
  return join(serverPath, PRISMA_BIN);
7715
8403
  }
7716
8404
  function prismaInstalled(serverPath) {
7717
8405
  return existsSync(prismaBin(serverPath));
7718
8406
  }
7719
- function migrateDeploy(serverPath, url) {
7720
- return run(prismaBin(serverPath), [
7721
- "migrate",
7722
- "deploy",
7723
- "--schema",
7724
- SERVER_SCHEMA
7725
- ], serverPath, { DATABASE_URL: url });
7726
- }
7727
8407
  function rutConflict(holderSecretId, clientId) {
7728
8408
  return holderSecretId !== "" && holderSecretId !== clientId;
7729
8409
  }
@@ -7763,6 +8443,37 @@ function runDisposableScript(serverPath, fileName, contents, env, extraArgs = []
7763
8443
  rmSync(scriptPath, { force: true });
7764
8444
  }
7765
8445
  }
8446
+ function readServerScripts(serverPath) {
8447
+ try {
8448
+ return JSON.parse(readFileSync(join(serverPath, "package.json"), "utf-8")).scripts ?? {};
8449
+ } catch {
8450
+ return {};
8451
+ }
8452
+ }
8453
+ function makeServerRunner(serverPath, env) {
8454
+ const pm = has("bun") ? "bun" : "npm";
8455
+ return {
8456
+ exec: (script, extraEnv) => run(pm, ["run", script], serverPath, {
8457
+ ...env,
8458
+ ...extraEnv
8459
+ }),
8460
+ rerun: (script) => `cd Pruff-server && ${pm} run ${script}`
8461
+ };
8462
+ }
8463
+ const ONBOARDING_RERUN = "npx pruff-skills onboarding --db";
8464
+ function withTenantConfig(serverPath, contents, fn) {
8465
+ const target = join(serverPath, TENANT_CONFIG_PATH);
8466
+ const previous = existsSync(target) ? readFileSync(target, "utf-8") : null;
8467
+ writeFileSync(target, contents, "utf-8");
8468
+ let ok = false;
8469
+ try {
8470
+ ok = fn();
8471
+ } finally {
8472
+ if (previous !== null) writeFileSync(target, previous, "utf-8");
8473
+ else if (ok) rmSync(target, { force: true });
8474
+ }
8475
+ return ok;
8476
+ }
7766
8477
  function setupLocalDatabase(serverPath, env) {
7767
8478
  const controlPlaneUrl = env.DATABASE_URL;
7768
8479
  const clientId = env.CLIENT_ID;
@@ -7774,134 +8485,125 @@ function setupLocalDatabase(serverPath, env) {
7774
8485
  const conn = parseMysqlUrl(controlPlaneUrl);
7775
8486
  const controlPlaneName = dbNameFromUrl(controlPlaneUrl);
7776
8487
  const tenantName = `tenant-${clientId}`;
7777
- const tenantUrl = swapDbName(controlPlaneUrl, tenantName);
7778
8488
  const adminEmail = `admin@${clientId}.com`;
7779
8489
  if (controlPlaneName !== CONTROL_PLANE_DB) {
7780
8490
  note(`Local database not set up — DATABASE_URL must point at "${CONTROL_PLANE_DB}".`);
7781
8491
  M.warn(`DATABASE_URL points at ${import_picocolors.default.cyan(controlPlaneName)}, but Pruff-server hardcodes the local control plane as ${import_picocolors.default.cyan(CONTROL_PLANE_DB)}.\n Company/user seeds would silently write to the wrong database — fix local.env and re-run with --db.`);
7782
8492
  return;
7783
8493
  }
8494
+ const scripts = readServerScripts(serverPath);
8495
+ const missing = [SERVER_SCRIPT.migrate, SERVER_SCRIPT.createTenant].filter((name) => !scripts[name]);
8496
+ if (missing.length > 0) {
8497
+ note(`Local database not set up — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
8498
+ M.warn(`Pruff-server's package.json has no ${import_picocolors.default.cyan(missing.join(", "))}.\n Pull the latest ${import_picocolors.default.dim("dev")} in Pruff-server, then re-run with ${import_picocolors.default.cyan("--db")}.`);
8499
+ return;
8500
+ }
8501
+ const server = makeServerRunner(serverPath, env);
7784
8502
  M.step(`Setting up local databases (${controlPlaneName} + ${tenantName})…`);
7785
8503
  mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${controlPlaneName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7786
- if (!migrateDeploy(serverPath, controlPlaneUrl)) {
7787
- note("Local database not set up — control-plane migration failed.");
8504
+ if (!server.exec(SERVER_SCRIPT.migrate)) {
8505
+ note(`Local database not set up — control-plane migration failed. Rerun: ${server.rerun(SERVER_SCRIPT.migrate)}`);
7788
8506
  M.warn("Control-plane migration failed — skipping the rest of database setup.");
7789
8507
  return;
7790
8508
  }
7791
- runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
8509
+ const hasControlPlaneSeed = Boolean(scripts[SERVER_SCRIPT.seedControlPlane]);
8510
+ if (!(hasControlPlaneSeed ? server.exec(SERVER_SCRIPT.seedControlPlane) : runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
7792
8511
  ...env,
7793
8512
  DATABASE_URL: controlPlaneUrl
7794
- });
7795
- const action = decideTenantAction(dbExists(conn, tenantName), adminUserExists(conn, tenantName, adminEmail));
7796
- if (action !== "already-provisioned") {
7797
- const holder = findRutHolder(serverPath, env, controlPlaneUrl);
7798
- if (rutConflict(holder, clientId)) {
7799
- note(`Tenant "${clientId}" not seeded — CLIENT_RUT is already used by "${holder}".`);
7800
- M.warn(`CLIENT_RUT ${import_picocolors.default.cyan(env.CLIENT_RUT ?? "")} already belongs to tenant ${import_picocolors.default.cyan(holder)} in ${controlPlaneName}.\n Seeding would reuse that company and never register "${clientId}" — the tenant could not be logged into.\n Change CLIENT_RUT (or CLIENT_ID) in local.env and re-run with --db.`);
7801
- return;
7802
- }
7803
- if (action === "reset-and-seed") {
7804
- M.warn(`Tenant "${clientId}" partially provisioned — resetting (Pruff-server seed is not idempotent).`);
7805
- mysqlExec(conn, `DROP DATABASE IF EXISTS \`${tenantName}\``);
7806
- }
7807
- mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${tenantName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7808
- if (!migrateDeploy(serverPath, tenantUrl)) {
7809
- note("Local database not set up — tenant migration failed.");
7810
- M.warn("Tenant migration failed — skipping seed.");
7811
- return;
7812
- }
7813
- const s = Y();
7814
- s.start(`Seeding tenant "${clientId}"…`);
7815
- const seeded = run(prismaBin(serverPath), ["db", "seed"], serverPath, {
7816
- ...env,
7817
- DATABASE_URL: tenantUrl
7818
- });
7819
- s.stop(seeded ? `Tenant "${clientId}" seeded` : import_picocolors.default.yellow("Tenant seed failed"));
7820
- if (!seeded) {
7821
- note(`Tenant "${clientId}" seed failed.`);
7822
- return;
7823
- }
7824
- } else M.success(`Tenant "${clientId}" already provisioned — skipping seed.`);
7825
- setupIntelligence(serverPath, env, conn, controlPlaneUrl, adminEmail, adminPassword);
7826
- if (!runDisposableScript(serverPath, ".pruff-onboarding-tenant-user.ts", TENANT_USER_BACKFILL_SCRIPT, {
7827
- ...env,
7828
- DATABASE_URL: tenantUrl
7829
- }, [adminEmail, adminPassword])) {
7830
- note("Tenant admin user may be missing — the backfill script failed.");
7831
- M.warn("Tenant admin-user backfill failed — you may need to create the user manually.");
8513
+ }))) {
8514
+ note(`Local database not set up — control-plane seed failed. Rerun: ${hasControlPlaneSeed ? server.rerun(SERVER_SCRIPT.seedControlPlane) : ONBOARDING_RERUN}`);
8515
+ M.warn("Control-plane seed failed — skipping the rest of database setup.");
7832
8516
  return;
7833
8517
  }
7834
- if (!runDisposableScript(serverPath, ".pruff-onboarding-accounting-period.ts", ACCOUNTING_PERIOD_SCRIPT, {
8518
+ const holder = findRutHolder(serverPath, env, controlPlaneUrl);
8519
+ if (rutConflict(holder, clientId)) {
8520
+ note(`Tenant "${clientId}" not seeded — CLIENT_RUT is already used by "${holder}".`);
8521
+ M.warn(`CLIENT_RUT ${import_picocolors.default.cyan(env.CLIENT_RUT ?? "")} already belongs to tenant ${import_picocolors.default.cyan(holder)} in ${controlPlaneName}.\n Seeding would reuse that company and never register "${clientId}" — the tenant could not be logged into.\n Change CLIENT_RUT (or CLIENT_ID) in local.env and re-run with --db.`);
8522
+ return;
8523
+ }
8524
+ const s = Y();
8525
+ s.start(`Provisioning tenant "${clientId}"…`);
8526
+ const tenantCreated = withTenantConfig(serverPath, renderTenantConfig({
8527
+ secretId: clientId,
8528
+ taxIdentifier: env.CLIENT_RUT ?? "",
8529
+ companyName: env.CLIENT_NAME ?? clientId,
8530
+ adminEmail,
8531
+ adminPassword
8532
+ }), () => server.exec(SERVER_SCRIPT.createTenant));
8533
+ s.stop(tenantCreated ? `Tenant "${clientId}" provisioned` : import_picocolors.default.yellow("Tenant provisioning failed"));
8534
+ if (!tenantCreated) {
8535
+ note(`Tenant "${clientId}" not provisioned — ${SERVER_SCRIPT.createTenant} failed. Rerun: ${server.rerun(SERVER_SCRIPT.createTenant)} (reads ${TENANT_CONFIG_PATH})`);
8536
+ return;
8537
+ }
8538
+ const hasPeriodScript = Boolean(scripts[SERVER_SCRIPT.openAccountingPeriod]);
8539
+ if (!(hasPeriodScript ? server.exec(SERVER_SCRIPT.openAccountingPeriod) : runDisposableScript(serverPath, ".pruff-onboarding-accounting-period.ts", ACCOUNTING_PERIOD_SCRIPT, {
7835
8540
  ...env,
7836
- DATABASE_URL: tenantUrl
7837
- }, [String((/* @__PURE__ */ new Date()).getFullYear())])) {
7838
- note("No open AccountingPeriod — accounting entries will be rejected until one exists.");
8541
+ DATABASE_URL: swapDbName(controlPlaneUrl, tenantName)
8542
+ }, [String((/* @__PURE__ */ new Date()).getFullYear())]))) {
8543
+ note(`No open AccountingPeriod — accounting entries will be rejected until one exists. Rerun: ${hasPeriodScript ? server.rerun(SERVER_SCRIPT.openAccountingPeriod) : ONBOARDING_RERUN}`);
7839
8544
  M.warn("Could not open the current-year accounting period — create one under Contabilidad → Períodos.");
7840
8545
  }
8546
+ setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword);
7841
8547
  M.success(`Local tenant "${clientId}" ready.`);
7842
8548
  printLoginHint(clientId, adminEmail, adminPassword);
7843
8549
  }
7844
- function setupIntelligence(serverPath, env, conn, controlPlaneUrl, adminEmail, adminPassword) {
8550
+ function setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword) {
7845
8551
  const intelligenceUrl = env.INTELLIGENCE_DATABASE_URL;
7846
8552
  if (!intelligenceUrl) {
7847
8553
  note("Intelligence module unavailable — INTELLIGENCE_DATABASE_URL is not set in local.env.");
7848
8554
  M.warn(`local.env has no ${import_picocolors.default.cyan("INTELLIGENCE_DATABASE_URL")} — skipping Intelligence setup.`);
7849
8555
  return;
7850
8556
  }
8557
+ const steps = [
8558
+ SERVER_SCRIPT.intelMigrate,
8559
+ SERVER_SCRIPT.intelGenerate,
8560
+ SERVER_SCRIPT.intelSeedGlobal,
8561
+ SERVER_SCRIPT.intelCreateTenant
8562
+ ];
8563
+ const missing = steps.filter((name) => !scripts[name]);
8564
+ if (missing.length > 0) {
8565
+ note(`Intelligence module unavailable — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
8566
+ M.warn(`Pruff-server's package.json has no ${import_picocolors.default.cyan(missing.join(", "))} — skipping Intelligence setup.`);
8567
+ return;
8568
+ }
7851
8569
  const s = Y();
7852
8570
  s.start("Setting up Intelligence…");
7853
8571
  mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${dbNameFromUrl(intelligenceUrl)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
7854
- const intelligenceEnv = {
7855
- ...env,
7856
- INTELLIGENCE_DATABASE_URL: intelligenceUrl
7857
- };
7858
- if (!(run(prismaBin(serverPath), [
7859
- "migrate",
7860
- "deploy",
7861
- "--schema",
7862
- INTELLIGENCE_SCHEMA
7863
- ], serverPath, intelligenceEnv) && run(prismaBin(serverPath), [
7864
- "generate",
7865
- "--schema",
7866
- INTELLIGENCE_SCHEMA
7867
- ], serverPath, intelligenceEnv))) {
7868
- s.stop(import_picocolors.default.yellow("Intelligence database setup failed"));
7869
- note(`Intelligence module unavailable — could not migrate/generate ${INTELLIGENCE_DB}.`);
7870
- return;
7871
- }
7872
- if (!(run("npx", [
7873
- "ts-node",
7874
- "--transpile-only",
7875
- "-r",
7876
- "tsconfig-paths/register",
7877
- INTELLIGENCE_GLOBAL_SEED
7878
- ], serverPath, {
7879
- ...env,
7880
- DATABASE_URL: controlPlaneUrl
7881
- }) && run("npx", [
7882
- "ts-node",
7883
- "--transpile-only",
7884
- "-r",
7885
- "tsconfig-paths/register",
7886
- INTELLIGENCE_TENANT_SCRIPT
7887
- ], serverPath, {
7888
- ...env,
7889
- DATABASE_URL: controlPlaneUrl,
8572
+ const enrolment = {
7890
8573
  TENANT_SECRET_ID: env.CLIENT_ID,
7891
8574
  TENANT_RUT: env.CLIENT_RUT,
7892
8575
  TENANT_NAME: env.CLIENT_NAME,
7893
8576
  USER_EMAIL: adminEmail,
7894
8577
  USER_PASSWORD: adminPassword,
7895
8578
  USER_NAME: "Pruff Local Admin"
7896
- }))) {
7897
- s.stop(import_picocolors.default.yellow("Intelligence tenant enrolment failed"));
7898
- note("Intelligence module unavailable — tenant enrolment failed.");
8579
+ };
8580
+ for (const script of steps) {
8581
+ if (server.exec(script, script === SERVER_SCRIPT.intelCreateTenant ? enrolment : void 0)) continue;
8582
+ s.stop(import_picocolors.default.yellow(`Intelligence setup failed at ${script}`));
8583
+ note(`Intelligence module unavailable — ${script} failed. Rerun: ${server.rerun(script)}`);
7899
8584
  return;
7900
8585
  }
7901
8586
  s.stop("Intelligence ready");
7902
8587
  }
8588
+ let localLogin = null;
7903
8589
  function printLoginHint(clientId, email, password) {
7904
- 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)}`);
8590
+ localLogin = {
8591
+ clientId,
8592
+ email,
8593
+ password
8594
+ };
8595
+ }
8596
+ function showLocalLogin() {
8597
+ if (!localLogin) return;
8598
+ const line = (s) => console.log(` ${s}`);
8599
+ console.log();
8600
+ line(import_picocolors.default.bold("Your local Pruff login") + import_picocolors.default.dim(" (Super Administrador)"));
8601
+ line(`${import_picocolors.default.dim("url: ")} ${import_picocolors.default.cyan(FRONTEND_URL)}`);
8602
+ line(`${import_picocolors.default.dim("tenant: ")} ${import_picocolors.default.cyan(localLogin.clientId)}`);
8603
+ line(`${import_picocolors.default.dim("email: ")} ${import_picocolors.default.cyan(localLogin.email)}`);
8604
+ line(`${import_picocolors.default.dim("password:")} ${import_picocolors.default.cyan(localLogin.password)}`);
8605
+ line(import_picocolors.default.dim("Local only: this user exists in your local databases, never in dev or prod."));
8606
+ console.log();
7905
8607
  }
7906
8608
  async function showGuidance(workspace) {
7907
8609
  console.log();
@@ -7931,6 +8633,8 @@ async function showGuidance(workspace) {
7931
8633
  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")}.`);
7932
8634
  line(`${import_picocolors.default.dim("4.")} Run ${import_picocolors.default.dim("/mcp")} inside Claude Code to authenticate Linear + PostHog (already configured in ${import_picocolors.default.dim(".mcp.json")}).`);
7933
8635
  line(`${import_picocolors.default.dim("5.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
8636
+ line(`${import_picocolors.default.dim("6.")} Install the ${import_picocolors.default.bold("Claude in Chrome")} extension and run ${import_picocolors.default.dim("claude --chrome")} — UI checks and PR screenshots drive the browser through it.`);
8637
+ line(`${import_picocolors.default.dim("7.")} Prod data is read-only: ${import_picocolors.default.dim("pruff-db-query <database> -e \"SELECT …\"")} — your own reader user, password in the Keychain, verified TLS. Finish a pending request with ${import_picocolors.default.dim("npx pruff-skills onboarding --db-access")}.`);
7934
8638
  console.log();
7935
8639
  }
7936
8640
  function showTeamCredentialsAsk(workspace) {
@@ -8913,6 +9617,11 @@ ${BOLD}Rules:${RESET}
8913
9617
  rules add --scope <client|server|common>
8914
9618
  Install a whole repo's rule set (client/server also pull in common)
8915
9619
 
9620
+ ${BOLD}Hooks:${RESET}
9621
+ hooks add <name> Install a Claude Code hook into ~/.claude/hooks/pruff/ and register it
9622
+ hooks list Show installed and available hooks
9623
+ hooks remove <name> Unregister a hook and delete its script
9624
+
8916
9625
  ${BOLD}Usage analytics:${RESET}
8917
9626
  analytics status Is Claude Code reporting usage to Pruff's PostHog? (alias: telemetry)
8918
9627
  analytics enable Turn it on in ~/.claude/settings.json
@@ -8941,6 +9650,7 @@ ${BOLD}Onboarding:${RESET}
8941
9650
  ${BOLD}Onboarding Options:${RESET}
8942
9651
  --db Force the local database step (non-interactive shells)
8943
9652
  --no-db Skip the local database step
9653
+ --db-access Only set up read-only prod database access (needs AWS credentials)
8944
9654
 
8945
9655
  ${BOLD}Updates:${RESET}
8946
9656
  update [skills...] Update skills to latest versions (alias: upgrade)
@@ -9151,6 +9861,11 @@ async function main() {
9151
9861
  if (!inAgent) await showLogo();
9152
9862
  await runRules(restArgs);
9153
9863
  break;
9864
+ case "hooks":
9865
+ case "hook":
9866
+ if (!inAgent) await showLogo();
9867
+ await runHooks(restArgs);
9868
+ break;
9154
9869
  case "buzz":
9155
9870
  if (!inAgent) await showBuzzLogo();
9156
9871
  await runBuzz(restArgs);