pruff-skills 1.3.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.mjs +1004 -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,623 @@ 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_SCRIPTS = ["pruff-db-query", "pruff-db-verify"];
6651
+ const RDS_CA_URL = `https://truststore.pki.rds.amazonaws.com/${DB_READER_REGION}/${DB_READER_REGION}-bundle.pem`;
6652
+ const MAX_SUGGESTED_NAME_LENGTH = 23;
6653
+ const dbReaderConfigPath = () => join(homedir(), ".config", "pruff", "db-reader.json");
6654
+ const dbReaderCaPath = () => join(homedir(), ".config", "pruff", `rds-${DB_READER_REGION}-bundle.pem`);
6655
+ const dbReaderBinDir = () => join(homedir(), ".local", "bin");
6656
+ function aws(args) {
6657
+ const r = spawnSync("aws", args, {
6658
+ encoding: "utf-8",
6659
+ stdio: "pipe"
6660
+ });
6661
+ return {
6662
+ ok: r.status === 0,
6663
+ out: (r.stdout ?? "").trim(),
6664
+ err: (r.stderr ?? "").trim()
6665
+ };
6666
+ }
6667
+ function iamUserFromArn(arn) {
6668
+ const match = /^arn:[^:]*:iam::\d+:user\/(?:.*\/)?([^/\s]+)$/.exec(arn.trim());
6669
+ return match ? match[1] : null;
6670
+ }
6671
+ function suggestMysqlName(email, iamUser) {
6672
+ const clean = (value) => value.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, MAX_SUGGESTED_NAME_LENGTH);
6673
+ return clean(email.split("@")[0] ?? "") || clean(iamUser) || "engineer";
6674
+ }
6675
+ function parseReaderSecret(raw) {
6676
+ try {
6677
+ const data = JSON.parse(raw);
6678
+ if (typeof data.username !== "string" || typeof data.password !== "string") return null;
6679
+ if (!/^[A-Za-z0-9_]+$/.test(data.username)) return null;
6680
+ if (data.password.length === 0 || /[\u0000-\u001f\u007f]/.test(data.password)) return null;
6681
+ return {
6682
+ username: data.username,
6683
+ password: data.password
6684
+ };
6685
+ } catch {
6686
+ return null;
6687
+ }
6688
+ }
6689
+ function quoteForSecurityShell(value) {
6690
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
6691
+ }
6692
+ function storeInKeychain(username, password) {
6693
+ if (spawnSync("security", ["-i"], {
6694
+ input: `add-generic-password -U -s ${DB_READER_SERVICE} -a ${quoteForSecurityShell(username)} -w ${quoteForSecurityShell(password)}\n`,
6695
+ encoding: "utf-8",
6696
+ stdio: "pipe"
6697
+ }).status !== 0) return false;
6698
+ return spawnSync("security", [
6699
+ "find-generic-password",
6700
+ "-s",
6701
+ DB_READER_SERVICE,
6702
+ "-a",
6703
+ username
6704
+ ], { stdio: "ignore" }).status === 0;
6705
+ }
6706
+ function writeConfig(username) {
6707
+ const path = dbReaderConfigPath();
6708
+ mkdirSync(dirname(path), { recursive: true });
6709
+ writeFileSync(path, `${JSON.stringify({ username }, null, 2)}\n`, "utf-8");
6710
+ }
6711
+ function downloadCaBundle() {
6712
+ const target = dbReaderCaPath();
6713
+ mkdirSync(dirname(target), { recursive: true });
6714
+ return spawnSync("curl", [
6715
+ "-sSfo",
6716
+ target,
6717
+ RDS_CA_URL
6718
+ ], { stdio: "ignore" }).status === 0;
6719
+ }
6720
+ async function installScripts() {
6721
+ const dir = dbReaderBinDir();
6722
+ mkdirSync(dir, { recursive: true });
6723
+ for (const name of DB_READER_SCRIPTS) {
6724
+ const content = await fetchCatalogFile(PRUFF_CATALOG, `db-reader/${name}`);
6725
+ if (content === null) return `${name} could not be read from the catalog`;
6726
+ const target = join(dir, name);
6727
+ if ((existsSync(target) ? readFileSync(target, "utf-8") : null) !== content) writeFileSync(target, content, "utf-8");
6728
+ chmodSync(target, 493);
6729
+ }
6730
+ return null;
6731
+ }
6732
+ function runVerify$1() {
6733
+ const dir = dbReaderBinDir();
6734
+ const r = spawnSync(join(dir, "pruff-db-verify"), [DB_READER_VERIFY_DATABASE], {
6735
+ encoding: "utf-8",
6736
+ stdio: "pipe",
6737
+ env: {
6738
+ ...process.env,
6739
+ PRUFF_DB_QUERY_CMD: join(dir, "pruff-db-query")
6740
+ }
6741
+ });
6742
+ return {
6743
+ ok: r.status === 0,
6744
+ out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
6745
+ };
6746
+ }
6747
+ function mergeAllowlist(workspace) {
6748
+ const path = join(workspace, ".claude", "settings.json");
6749
+ let settings = {};
6750
+ let current = null;
6751
+ if (existsSync(path)) {
6752
+ current = readFileSync(path, "utf-8");
6753
+ try {
6754
+ const parsed = JSON.parse(current);
6755
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "invalid";
6756
+ settings = parsed;
6757
+ } catch {
6758
+ return "invalid";
6759
+ }
6760
+ }
6761
+ const permissions = settings.permissions && typeof settings.permissions === "object" ? settings.permissions : {};
6762
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
6763
+ if (allow.includes(DB_READER_ALLOW_ENTRY)) return "present";
6764
+ const next = {
6765
+ ...settings,
6766
+ permissions: {
6767
+ ...permissions,
6768
+ allow: [...allow, DB_READER_ALLOW_ENTRY]
6769
+ }
6770
+ };
6771
+ mkdirSync(dirname(path), { recursive: true });
6772
+ if (current !== null) copyFileSync(path, `${path}.pruff-bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
6773
+ const tmp = `${path}.tmp`;
6774
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
6775
+ renameSync(tmp, path);
6776
+ return "added";
6777
+ }
6778
+ async function setupDbReader({ workspace, note }) {
6779
+ M.step("Setting up read-only prod database access…");
6780
+ const identity = aws([
6781
+ "sts",
6782
+ "get-caller-identity",
6783
+ "--query",
6784
+ "Arn",
6785
+ "--output",
6786
+ "text"
6787
+ ]);
6788
+ if (!identity.ok) {
6789
+ 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")}.`);
6790
+ return;
6791
+ }
6792
+ const iamUser = iamUserFromArn(identity.out);
6793
+ if (!iamUser) {
6794
+ 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")}.`);
6795
+ return;
6796
+ }
6797
+ const secretId = `${DB_READER_SECRET_PREFIX}${iamUser}`;
6798
+ const fetched = aws([
6799
+ "secretsmanager",
6800
+ "get-secret-value",
6801
+ "--secret-id",
6802
+ secretId,
6803
+ "--region",
6804
+ DB_READER_REGION,
6805
+ "--query",
6806
+ "SecretString",
6807
+ "--output",
6808
+ "text"
6809
+ ]);
6810
+ if (!fetched.ok) {
6811
+ if (/ResourceNotFound/.test(fetched.err)) {
6812
+ const name = suggestMysqlName((spawnSync("git", [
6813
+ "config",
6814
+ "--global",
6815
+ "user.email"
6816
+ ], {
6817
+ encoding: "utf-8",
6818
+ stdio: "pipe"
6819
+ }).stdout ?? "").trim(), iamUser);
6820
+ 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")}.`);
6821
+ } 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}`)}`);
6822
+ return;
6823
+ }
6824
+ const secret = parseReaderSecret(fetched.out);
6825
+ if (!secret) {
6826
+ note(`Prod DB access not set up — ${secretId} does not hold a valid username and password.`);
6827
+ return;
6828
+ }
6829
+ if (!storeInKeychain(secret.username, secret.password)) {
6830
+ 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).`);
6831
+ return;
6832
+ }
6833
+ writeConfig(secret.username);
6834
+ M.success(`Keychain entry ${DB_READER_SERVICE} stored for ${secret.username}`);
6835
+ if (!downloadCaBundle()) {
6836
+ note(`Prod DB access not set up — the RDS CA bundle download failed. Run: ${import_picocolors.default.cyan(`curl -sSfo ${dbReaderCaPath()} ${RDS_CA_URL}`)}`);
6837
+ return;
6838
+ }
6839
+ const installError = await installScripts();
6840
+ if (installError) {
6841
+ note(`Prod DB access not set up — ${installError}. Re-run once the catalog is reachable.`);
6842
+ return;
6843
+ }
6844
+ M.success(`Installed ${DB_READER_SCRIPTS.join(" and ")} in ${dbReaderBinDir()}`);
6845
+ 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.`);
6846
+ const verify = runVerify$1();
6847
+ if (!verify.ok) {
6848
+ 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.`);
6849
+ M.warn(verify.out);
6850
+ return;
6851
+ }
6852
+ M.success("Read-only access verified");
6853
+ 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.`);
6854
+ const hooks = await installHooks(["prod-db-guard"]);
6855
+ for (const failure of hooks.failed) note(`Hook ${failure.name} not installed: ${failure.reason}`);
6856
+ 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}.`);
6857
+ }
6858
+ const WORKSPACE_DIRS = [".claude/scripts"];
6859
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
6860
+ const isExecutable = (name) => /\.(sh|zsh|bash)$/.test(name);
6861
+ async function installWorkspaceFiles(workspace, source = PRUFF_CATALOG) {
6862
+ const result = {
6863
+ written: [],
6864
+ unchanged: [],
6865
+ skippedLocalEdits: [],
6866
+ catalogUnavailable: false
6867
+ };
6868
+ const state = readState();
6869
+ for (const dir of WORKSPACE_DIRS) {
6870
+ const names = await listCatalogDir(source, `workspace/${dir}`);
6871
+ if (names === null) {
6872
+ result.catalogUnavailable = true;
6873
+ continue;
6874
+ }
6875
+ for (const name of names.sort()) {
6876
+ const content = await fetchCatalogFile(source, `workspace/${dir}/${name}`);
6877
+ if (content === null) continue;
6878
+ const target = join(workspace, dir, name);
6879
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
6880
+ if (current === content) {
6881
+ state.files[target] = sha256(content);
6882
+ result.unchanged.push(target);
6883
+ continue;
6884
+ }
6885
+ if (current !== null && sha256(current) !== state.files[target]) {
6886
+ result.skippedLocalEdits.push(target);
6887
+ continue;
6888
+ }
6889
+ mkdirSync(dirname(target), { recursive: true });
6890
+ writeFileSync(target, content, "utf-8");
6891
+ if (isExecutable(name)) chmodSync(target, 493);
6892
+ state.files[target] = sha256(content);
6893
+ result.written.push(target);
6894
+ }
6895
+ }
6896
+ writeState(state);
6897
+ return result;
6898
+ }
6279
6899
  const RESET = "\x1B[0m";
6280
6900
  const DIM = "\x1B[38;5;102m";
6281
6901
  const BLUES = [
@@ -6395,9 +7015,10 @@ async function showBlockLogo(lines, letterCuts, { slow = false, palette = BLUES
6395
7015
  }
6396
7016
  const WORKSPACE_CLAUDE_TEMPLATE = `# Pruff — Working Directory
6397
7017
 
6398
- This folder is the working directory for Pruff's frontend and backend. The two main git repos are:
7018
+ This folder is the working directory for Pruff's frontend and backend. The three main git repos are:
6399
7019
 
6400
- - **\`Pruff-Client/\`** — React frontend
7020
+ - **\`Pruff-Client/\`** — React frontend (customer-facing, tenant-scoped)
7021
+ - **\`Pruff-Backoffice/\`** — React frontend (internal staff, cross-tenant)
6401
7022
  - **\`Pruff-server/\`** — Node.js/Express backend
6402
7023
 
6403
7024
  Other directories (\`sls-*/\`, \`slsl-*/\`, \`AWS-Tools/\`, etc.) are serverless microservices and tooling —
@@ -6408,14 +7029,33 @@ workspace-level context only.
6408
7029
 
6409
7030
  ---
6410
7031
 
7032
+ ## Read the repo's own rules before touching its files
7033
+
7034
+ When the session is rooted at the workspace folder, a repo's \`CLAUDE.md\` and \`.claude/rules/\` are **not**
7035
+ loaded automatically — only this file is. The repo-level rules are the authoritative, most current
7036
+ source for that codebase; this file is a workspace-level summary. Where they disagree, the repo rule wins.
7037
+
7038
+ Before your first edit to any file inside a repo subdirectory, read that repo's rules (once per session):
7039
+
7040
+ \`\`\`bash
7041
+ cat Pruff-server/CLAUDE.md Pruff-server/.claude/rules/*.md # backend work
7042
+ cat Pruff-Client/CLAUDE.md Pruff-Client/.claude/rules/*.md # frontend work
7043
+ cat Pruff-Backoffice/CLAUDE.md Pruff-Backoffice/.claude/rules/*.md
7044
+ \`\`\`
7045
+
7046
+ If the session is already rooted inside the repo (or one of its worktrees), they load automatically.
7047
+
7048
+ ---
7049
+
6411
7050
  ## Workspace Layout
6412
7051
 
6413
7052
  ### Main applications (modify freely when asked)
6414
7053
 
6415
7054
  | Folder | What it is |
6416
7055
  |---|---|
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). |
7056
+ | \`Pruff-Client/\` | React 18 + Vite + MUI frontend. Many feature pages, React Query + Redux Toolkit. |
7057
+ | \`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. |
7058
+ | \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant), many domains and routes. |
6419
7059
 
6420
7060
  ### Serverless microservices (\`sls-*/\`, \`slsl-*/\` — clone as needed, don't modify unless asked)
6421
7061
 
@@ -6434,27 +7074,57 @@ over HTTPS with an \`x-api-key\`.
6434
7074
  | \`sls-Notification-Service/\` | **Outbound notifications** (email, in-app, collections). |
6435
7075
  | \`sls-Pruff-Webhooks/\` | **Outbound webhook delivery** to client integrations. |
6436
7076
  | \`sls-Pruff-Workers/\` | **Async background jobs for Pruff-server** (PDF rendering, tenant document/summary sync crons). |
7077
+ | \`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. |
7078
+ | \`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. |
7079
+ | \`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\`. |
7080
+
7081
+ #### Deprecated / being retired — don't invest in these
7082
+
7083
+ | Folder | Status |
7084
+ |---|---|
7085
+ | \`sls-financial-statements/\` | **Deprecated.** Leave it out of workspace-wide sweeps (lockfiles, Dependabot, OIDC, CODEOWNERS). |
7086
+ | \`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. |
7087
+
7088
+ If you add a new \`sls-*\` repo, add it to the table above — workspace-wide sweeps are derived from this list.
7089
+
7090
+ **Not every Lambda is an \`sls-*\` repo.** Several live in \`Pruff-infra/stacks/\` and deploy with
7091
+ \`terraform apply\` instead (e.g. \`newsletter-automation\`, \`github-release-automation\`,
7092
+ \`intelligence-recompute\`, \`reconcile-credit-note-links\`, \`posthog-error-digest\`, \`buzz-relay\`).
7093
+
7094
+ **Scheduled Pruff-server jobs are a third list.** \`Pruff-infra/stacks/pruff-job-runner\` runs them as
7095
+ one-shot Fargate tasks (EventBridge Scheduler → \`ecs:RunTask\`, \`America/Santiago\`), one task definition
7096
+ and one schedule per job. They are not Lambdas and do not appear in \`aws lambda list-functions\`.
6437
7097
 
6438
7098
  ### Tooling & utilities
6439
7099
 
6440
7100
  | Folder | What it is |
6441
7101
  |---|---|
6442
7102
  | \`AWS-Tools/\` | Standalone Node/TypeScript scripts for AWS ops — not deployed; run locally with AWS creds. |
7103
+ | \`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. |
7104
+
7105
+ ### Worktrees & scratch (do not touch)
7106
+
7107
+ | Folder | What it is |
7108
+ |---|---|
7109
+ | \`.worktrees/\`, \`worktrees/\` | Git worktrees created by the harness for isolated agent runs. |
7110
+ | \`Pruff-Client-boneyard/\` | Archived/deleted frontend components kept for reference. |
7111
+ | \`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
7112
 
6444
7113
  ### Cross-service data flow (mental model)
6445
7114
 
6446
7115
  \`\`\`
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)
7116
+ sls-SII-Services ─┐
7117
+ sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
7118
+ sls-Public-Market │ │ ▲
7119
+ sls-external-databases ┘ ▼ │
7120
+ sls-Decision-Engine ──► Pruff-server
7121
+ │ (pull, via API Gateway:
7122
+ ▼ externalDatabasesModule)
7123
+ sls-Notification-Service / sls-Pruff-Webhooks (outbound)
6455
7124
  \`\`\`
6456
7125
 
6457
7126
  \`sls-Pruff-Workers\` sits beside \`Pruff-server\` handling async/scheduled jobs that would block the API.
7127
+ \`sls-external-databases\` is also read synchronously (Pruff-server calls its \`/consult\` endpoints on demand).
6458
7128
 
6459
7129
  ---
6460
7130
 
@@ -6534,7 +7204,7 @@ src/
6534
7204
  internal/ # Staff endpoints (JWT auth)
6535
7205
  clients/ # Customer-facing endpoints (API key)
6536
7206
  external/ # Scraper/lambda endpoints (API key)
6537
- public/ # No auth
7207
+ public/ # No auth at tier level
6538
7208
  modules/ # Infrastructure wrappers (S3, SQS, SII, GoFirmex, etc.)
6539
7209
  middlewares/ # Auth, tenant resolution, rate limiting, error handling
6540
7210
  jobs/ # Cron jobs (iterate over all tenants)
@@ -6544,33 +7214,43 @@ src/
6544
7214
 
6545
7215
  ### API Tiers
6546
7216
 
7217
+ Six tiers, all mounted in \`src/routes/api.ts\`:
7218
+
6547
7219
  | Tier | Base Route | Auth | Middleware |
6548
7220
  |------|-----------|------|-----------|
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 | — |
7221
+ | Internal | \`/api/internal\` | JWT | \`tenantMiddleware(JWTTenantResolver)\` + \`loadUser\` + \`attachUserPermissions\` |
7222
+ | External | \`/api/external\` | API Key (one shared key) | \`authenticateWithApiKey\` |
7223
+ | Clients | \`/api/clients\` | Client API Key | \`tenantMiddleware(ClientApiKeyTenantResolver)\` + \`logApiKeyRequest\` |
7224
+ | Public | \`/api/public\` | None at tier level — each endpoint self-guards | — |
7225
+ | Backoffice | \`/api/backoffice\` | None at tier level — validated per sub-router | — |
7226
+ | Service | \`/api/service\` | Pruff-ai service token | \`requirePruffAiServiceToken\` |
6553
7227
 
6554
7228
  ### Service Pattern — Result\\<T\\>
6555
7229
 
6556
- All services return \`Result<T>\`. Never throw from services.
7230
+ All services return \`Result<T>\` (where \`T extends Record<string, unknown>\`). Never throw from services.
7231
+ Services extend \`BaseService\` so \`serviceName\` is inferred — never pass a string literal.
6557
7232
 
6558
7233
  \`\`\`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' } };
7234
+ class CreateFeatureService extends BaseService {
7235
+ async execute(prisma: PrismaClient, dto: CreateFeatureDto): Promise<Result<Feature>> {
7236
+ return Result.success({ feature });
7237
+ return Result.failure({ service: this.serviceName, status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', displayMessage: 'No encontrado' });
7238
+ }
7239
+ }
7240
+ export default new CreateFeatureService();
6563
7241
  \`\`\`
6564
7242
 
6565
7243
  ### Controller Pattern — handleApiOutput / handleApiError
6566
7244
 
7245
+ Pass the **service instance**, not a string.
7246
+
6567
7247
  \`\`\`typescript
6568
7248
  async someAction(req: UserRequest, res: Response) {
6569
7249
  try {
6570
7250
  const result = await SomeService.execute(req.prisma, req.body);
6571
- return handleApiOutput('SomeService', req, res, result);
7251
+ return handleApiOutput(SomeService, req, res, result);
6572
7252
  } catch (error) {
6573
- handleApiError('SomeService', req, res, error);
7253
+ handleApiError(SomeService, req, res, error);
6574
7254
  }
6575
7255
  }
6576
7256
  \`\`\`
@@ -6580,7 +7260,7 @@ async someAction(req: UserRequest, res: Response) {
6580
7260
  - **Services:** \`PascalCase\` + \`Service\` suffix → \`CreateFactoringRequestService.ts\`
6581
7261
  - **DTOs/interfaces:** \`PascalCase\` + \`Dto\` suffix → \`CreateFactoringRequestDto\`
6582
7262
  - **Controllers:** \`camelCase\` + \`Controller\` suffix → \`invoicesController.ts\`
6583
- - **Services are singletons:** \`export default new MyService();\`
7263
+ - **Services are singletons extending \`BaseService\`:** \`export default new MyService();\`
6584
7264
  - **\`prisma\` is always the first parameter** when the service needs DB access
6585
7265
 
6586
7266
  ### Accounting Rules
@@ -6718,10 +7398,17 @@ Notes are frontmatter/filenames in English (\`kebab-case\`), body in Spanish. Se
6718
7398
 
6719
7399
  - **Always use \`/commit\`** — invoke the \`commit\` skill for all git commits. Never run \`git commit\` manually.
6720
7400
  - **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\`).
7401
+ - **You, the human, are the sole author of every commit** — never set \`--author\` to Claude and never add Claude as a co-author.
7402
+ - **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — these repos deploy from \`main\` and
7403
+ their \`dev\` branches are stale/abandoned. PR target = branch base = \`main\`. Exception: \`Pruff-server\`
7404
+ and \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`). \`Pruff-Backoffice\`
7405
+ and \`dte-signer\` have no \`dev\` — branch and target \`main\`.
7406
+ - **Create worktrees with \`.claude/scripts/wt-new.sh\`** (or \`wt new <repo> <branch>\` after sourcing
7407
+ \`.claude/scripts/wt.zsh\`) — never by hand. It picks the correct base (\`dev\` for
7408
+ Pruff-server/Pruff-Client/Pruff-ai, \`main\` for everything else), fetches it first, clones
7409
+ \`node_modules\` copy-on-write and symlinks \`test.env\`/\`local.env\`/\`dev.env\` from the main checkout.
7410
+ For a hotfix pass \`--hotfix\` so the branch comes off \`origin/main\`, or \`--base <branch>\` for any
7411
+ other base. Remove one with \`.claude/scripts/wt-rm.sh\`; diagnose with \`.claude/scripts/wt-doctor.sh\`.
6725
7412
  - **Branch names embed the Linear issue code; PRs map 1:1 with Linear issues** — name branches so they
6726
7413
  contain the issue code (e.g. \`pru-1355-...\`) for Linear's git automation. When splitting work, plan
6727
7414
  the PR grouping first, create one Linear issue per PR (1:1), and stack dependent PRs
@@ -6802,56 +7489,6 @@ async function main() {
6802
7489
  console.log('RUT_HOLDER=' + (company ? company.secretId : ''));
6803
7490
  }
6804
7491
 
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
7492
  main()
6856
7493
  .catch((e) => {
6857
7494
  console.error(e);
@@ -6888,72 +7525,28 @@ main()
6888
7525
  })
6889
7526
  .finally(() => process.exit(0));
6890
7527
  `;
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
- }
7528
+ const TENANT_CONFIG_PATH = join("scripts", "tenant", "tenant.config.ts");
7529
+ function renderTenantConfig(v) {
7530
+ return `import { CompanyType } from './consts';
7531
+ import { TenantScriptConfig } from './types';
7532
+
7533
+ const config: TenantScriptConfig = {
7534
+ secretId: ${JSON.stringify(v.secretId)},
7535
+ taxIdentifier: ${JSON.stringify(v.taxIdentifier)},
7536
+ companyName: ${JSON.stringify(v.companyName)},
7537
+ companyTypes: [CompanyType.Intelligence],
7538
+ admin: {
7539
+ email: ${JSON.stringify(v.adminEmail)},
7540
+ password: ${JSON.stringify(v.adminPassword)},
7541
+ superAdmin: true,
7542
+ },
7543
+ additionalUsers: [],
7544
+ configurationFlags: [],
7545
+ };
6954
7546
 
6955
- migrateTenants();
7547
+ export default config;
6956
7548
  `;
7549
+ }
6957
7550
  function mysqlUrl(dbPassword, db, query = "") {
6958
7551
  return `mysql://${dbPassword ? `root:${encodeURIComponent(dbPassword)}` : "root"}@localhost:3306/${db}${query}`;
6959
7552
  }
@@ -7090,11 +7683,17 @@ const WORKSPACE_SETTINGS_JSON = `${JSON.stringify({ permissions: { allow: [
7090
7683
  "mcp__linear-server__get_project"
7091
7684
  ] } }, null, 2)}\n`;
7092
7685
  const CLAUDE_CODE_PKG = "@anthropic-ai/claude-code";
7093
- const INTELLIGENCE_SCHEMA = "prisma/intelligence/schema.prisma";
7094
- const SERVER_SCHEMA = "prisma/schema";
7686
+ const SERVER_SCRIPT = {
7687
+ migrate: "db-migrate-local",
7688
+ seedControlPlane: "db-seed-control-plane-local",
7689
+ createTenant: "create-tenant-local",
7690
+ openAccountingPeriod: "open-accounting-period-local",
7691
+ intelMigrate: "db-intel-migrate-local",
7692
+ intelGenerate: "db-intel-generate",
7693
+ intelSeedGlobal: "db-seed-intel-global-local",
7694
+ intelCreateTenant: "create-intel-tenant-local"
7695
+ };
7095
7696
  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
7697
  const KNOWLEDGE_REPO = "https://github.com/Pruff-Tech/pruff-knowledge";
7099
7698
  const KNOWLEDGE_DIR = "Pruff Knowledge";
7100
7699
  const DEFAULT_WORKSPACE = join(homedir(), "pruff");
@@ -7129,6 +7728,27 @@ const CORE_AGENTS = [
7129
7728
  "db-query-reviewer",
7130
7729
  "pruff-logs-investigator"
7131
7730
  ];
7731
+ const TEAM_HOOKS = [
7732
+ "sweep-nudge",
7733
+ "prod-db-guard",
7734
+ "handoff-nudge",
7735
+ "save-session"
7736
+ ];
7737
+ const TEAM_USER_ENV = { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" };
7738
+ function catalogInstallNames(rawIndex) {
7739
+ if (!rawIndex) return {
7740
+ skills: CORE_SKILLS,
7741
+ agents: CORE_AGENTS
7742
+ };
7743
+ const index = parseCatalogIndex(rawIndex, PRUFF_CATALOG);
7744
+ const names = (entries) => Array.isArray(entries) ? entries.map((e) => e.name).filter((n) => typeof n === "string") : [];
7745
+ const skills = names(index.skills);
7746
+ const agents = names(index.agents);
7747
+ return {
7748
+ skills: skills.length > 0 ? [...new Set([...skills, "find-skills"])] : CORE_SKILLS,
7749
+ agents: agents.length > 0 ? agents : CORE_AGENTS
7750
+ };
7751
+ }
7132
7752
  const issues = [];
7133
7753
  function note(issue) {
7134
7754
  issues.push(issue);
@@ -7138,8 +7758,21 @@ async function runOnboarding(args) {
7138
7758
  M.error(`${import_picocolors.default.bold("onboarding")} currently supports macOS only (detected ${import_picocolors.default.cyan(process.platform)}).`);
7139
7759
  return;
7140
7760
  }
7761
+ if (args.includes("--print-claude-md")) {
7762
+ process.stdout.write(WORKSPACE_CLAUDE_TEMPLATE);
7763
+ return;
7764
+ }
7141
7765
  Ie(import_picocolors.default.bold("Pruff onboarding"));
7142
7766
  issues.length = 0;
7767
+ if (args.includes("--db-access")) {
7768
+ await setupDbReader({
7769
+ workspace: await resolveWorkspace(args),
7770
+ note
7771
+ });
7772
+ showIssues();
7773
+ 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."));
7774
+ return;
7775
+ }
7143
7776
  if (!await preflight()) {
7144
7777
  Se(import_picocolors.default.yellow("Onboarding stopped — fix the items above and re-run."));
7145
7778
  return;
@@ -7156,10 +7789,16 @@ async function runOnboarding(args) {
7156
7789
  writeWorkspaceClaudeMd(workspace);
7157
7790
  writeHarnessConfig(workspace);
7158
7791
  await installCatalog(workspace);
7792
+ await installHarness(workspace);
7159
7793
  if (await shouldSetupDatabase(args)) await setupLocalEnvironment(join(workspace, "Pruff-server"));
7794
+ if (await shouldSetupDbAccess(args)) await setupDbReader({
7795
+ workspace,
7796
+ note
7797
+ });
7160
7798
  await showGuidance(workspace);
7161
7799
  showTeamCredentialsAsk(workspace);
7162
7800
  showIssues();
7801
+ showLocalLogin();
7163
7802
  if (issues.length === 0) Se(`${import_picocolors.default.green("Done.")} Welcome to Pruff — happy building.`);
7164
7803
  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
7804
  }
@@ -7425,12 +8064,19 @@ function installDeps(repo, workspace) {
7425
8064
  }
7426
8065
  function writeWorkspaceClaudeMd(workspace) {
7427
8066
  const target = join(workspace, "CLAUDE.md");
7428
- if (existsSync(target)) {
7429
- M.success("Workspace CLAUDE.md already present — leaving it untouched");
8067
+ const hash = (s) => createHash("sha256").update(s).digest("hex");
8068
+ const state = readState();
8069
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
8070
+ if (current === WORKSPACE_CLAUDE_TEMPLATE) M.success("Workspace CLAUDE.md is up to date");
8071
+ else if (current !== null && hash(current) !== state.files[target]) {
8072
+ 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
8073
  return;
8074
+ } else {
8075
+ writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
8076
+ M.success(current === null ? "Wrote workspace CLAUDE.md" : "Updated workspace CLAUDE.md");
7431
8077
  }
7432
- writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
7433
- M.success("Wrote workspace CLAUDE.md");
8078
+ state.files[target] = hash(WORKSPACE_CLAUDE_TEMPLATE);
8079
+ writeState(state);
7434
8080
  }
7435
8081
  function writeHarnessConfig(workspace) {
7436
8082
  writeIfAbsent(join(workspace, ".mcp.json"), WORKSPACE_MCP_JSON, "MCP servers (.mcp.json)");
@@ -7452,9 +8098,10 @@ async function installCatalog(workspace) {
7452
8098
  try {
7453
8099
  process.chdir(workspace);
7454
8100
  await runRules(["add", "--all"]);
7455
- await runAgents(["add", ...CORE_AGENTS]);
8101
+ const { skills, agents } = catalogInstallNames(await fetchCatalogFile(PRUFF_CATALOG, ".well-known/agent-skills/index.json"));
8102
+ await runAgents(["add", ...agents]);
7456
8103
  await runAdd([PRUFF_CATALOG], {
7457
- skill: CORE_SKILLS,
8104
+ skill: skills,
7458
8105
  agent: ["claude-code"],
7459
8106
  yes: true
7460
8107
  });
@@ -7465,6 +8112,53 @@ async function installCatalog(workspace) {
7465
8112
  process.chdir(prevCwd);
7466
8113
  }
7467
8114
  }
8115
+ async function installHarness(workspace) {
8116
+ M.step("Installing the team harness (hooks, settings, scripts)…");
8117
+ try {
8118
+ const state = readState();
8119
+ const env = applyOwnedEnv(readClaudeSettings$1(), TEAM_USER_ENV, state.env);
8120
+ writeClaudeSettings$1(env.settings);
8121
+ writeState({
8122
+ ...readState(),
8123
+ env: env.owned
8124
+ });
8125
+ for (const key of env.keptUserValues) M.info(`${key} already set in ~/.claude/settings.json — kept your value`);
8126
+ const hooks = await installHooks(TEAM_HOOKS);
8127
+ const ready = [...hooks.installed, ...hooks.unchanged];
8128
+ if (ready.length > 0) M.success(`Hooks: ${ready.join(", ")}`);
8129
+ 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`)}`);
8130
+ for (const f of hooks.failed) note(`Hook ${f.name} not installed: ${f.reason}`);
8131
+ const files = await installWorkspaceFiles(workspace);
8132
+ if (files.catalogUnavailable) note("Workspace scripts could not be read from the catalog.");
8133
+ for (const path of files.skippedLocalEdits) M.warn(`${path} has local edits — kept`);
8134
+ M.message(import_picocolors.default.dim(`Worktree helper: add ${import_picocolors.default.cyan(`source ${join(workspace, ".claude", "scripts", "wt.zsh")}`)} to ~/.zshrc`));
8135
+ } catch (error) {
8136
+ note("Team harness did not finish installing.");
8137
+ M.warn(error instanceof Error ? error.message : String(error));
8138
+ }
8139
+ ensureRtk();
8140
+ }
8141
+ function ensureRtk() {
8142
+ if (!has("rtk")) {
8143
+ const s = Y();
8144
+ s.start("Installing RTK…");
8145
+ const ok = run("brew", ["install", "rtk"]);
8146
+ s.stop(ok ? "RTK installed" : import_picocolors.default.yellow("RTK install failed"));
8147
+ if (!ok) {
8148
+ note(`RTK not installed — run ${import_picocolors.default.cyan("brew install rtk")}.`);
8149
+ return;
8150
+ }
8151
+ }
8152
+ if (spawnSync("rtk", ["gain"], { stdio: "ignore" }).status !== 0) {
8153
+ note(`The ${import_picocolors.default.cyan("rtk")} on PATH is not RTK (name collision with Rust Type Kit).`);
8154
+ return;
8155
+ }
8156
+ if (!run("rtk", [
8157
+ "init",
8158
+ "-g",
8159
+ "--auto-patch"
8160
+ ])) note("`rtk init -g` failed.");
8161
+ }
7468
8162
  async function setupLocalEnvironment(serverPath) {
7469
8163
  if (!existsSync(join(serverPath, ".git"))) {
7470
8164
  note("Local database not set up — Pruff-server was not cloned.");
@@ -7481,7 +8175,6 @@ async function setupLocalEnvironment(serverPath) {
7481
8175
  const dbPassword = await resolveRootPassword();
7482
8176
  if (dbPassword === null) return;
7483
8177
  generateLocalEnv(serverPath, dbPassword);
7484
- writeMigrateTenantsLocal(serverPath);
7485
8178
  const env = parseEnvFile(join(serverPath, "local.env"));
7486
8179
  if (!env.DATABASE_URL || !env.CLIENT_ID) {
7487
8180
  note("Local database not set up — local.env is missing DATABASE_URL / CLIENT_ID.");
@@ -7490,6 +8183,15 @@ async function setupLocalEnvironment(serverPath) {
7490
8183
  }
7491
8184
  setupLocalDatabase(serverPath, env);
7492
8185
  }
8186
+ async function shouldSetupDbAccess(args) {
8187
+ if (args.includes("--yes") || args.includes("-y") || !process.stdout.isTTY) return true;
8188
+ const answer = await ye({
8189
+ message: "Set up read-only production database access now? (needs your AWS credentials; stores your personal reader password in the macOS Keychain)",
8190
+ initialValue: true
8191
+ });
8192
+ if (pD(answer)) return false;
8193
+ return answer === true;
8194
+ }
7493
8195
  async function shouldSetupDatabase(args) {
7494
8196
  if (args.includes("--no-db")) return false;
7495
8197
  if (args.includes("--db")) return true;
@@ -7627,11 +8329,6 @@ function generateLocalEnv(serverPath, dbPassword) {
7627
8329
  }), "utf-8");
7628
8330
  M.success("Wrote local.env (minimal bootable — fill third-party keys when needed)");
7629
8331
  }
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
8332
  function parseEnvFile(path) {
7636
8333
  if (!existsSync(path)) return {};
7637
8334
  const out = {};
@@ -7647,11 +8344,6 @@ function parseEnvFile(path) {
7647
8344
  }
7648
8345
  return out;
7649
8346
  }
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
8347
  function parseMysqlUrl(url) {
7656
8348
  const u = new URL(url);
7657
8349
  return {
@@ -7681,15 +8373,6 @@ function mysqlArgs(conn) {
7681
8373
  if (conn.password) args.push(`-p${conn.password}`);
7682
8374
  return args;
7683
8375
  }
7684
- function mysqlQuery(conn, sql) {
7685
- return capture("mysql", [
7686
- ...mysqlArgs(conn),
7687
- "-N",
7688
- "-B",
7689
- "-e",
7690
- sql
7691
- ]);
7692
- }
7693
8376
  function mysqlExec(conn, sql) {
7694
8377
  return run("mysql", [
7695
8378
  ...mysqlArgs(conn),
@@ -7697,33 +8380,12 @@ function mysqlExec(conn, sql) {
7697
8380
  sql
7698
8381
  ]);
7699
8382
  }
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
8383
  function prismaBin(serverPath) {
7714
8384
  return join(serverPath, PRISMA_BIN);
7715
8385
  }
7716
8386
  function prismaInstalled(serverPath) {
7717
8387
  return existsSync(prismaBin(serverPath));
7718
8388
  }
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
8389
  function rutConflict(holderSecretId, clientId) {
7728
8390
  return holderSecretId !== "" && holderSecretId !== clientId;
7729
8391
  }
@@ -7763,6 +8425,37 @@ function runDisposableScript(serverPath, fileName, contents, env, extraArgs = []
7763
8425
  rmSync(scriptPath, { force: true });
7764
8426
  }
7765
8427
  }
8428
+ function readServerScripts(serverPath) {
8429
+ try {
8430
+ return JSON.parse(readFileSync(join(serverPath, "package.json"), "utf-8")).scripts ?? {};
8431
+ } catch {
8432
+ return {};
8433
+ }
8434
+ }
8435
+ function makeServerRunner(serverPath, env) {
8436
+ const pm = has("bun") ? "bun" : "npm";
8437
+ return {
8438
+ exec: (script, extraEnv) => run(pm, ["run", script], serverPath, {
8439
+ ...env,
8440
+ ...extraEnv
8441
+ }),
8442
+ rerun: (script) => `cd Pruff-server && ${pm} run ${script}`
8443
+ };
8444
+ }
8445
+ const ONBOARDING_RERUN = "npx pruff-skills onboarding --db";
8446
+ function withTenantConfig(serverPath, contents, fn) {
8447
+ const target = join(serverPath, TENANT_CONFIG_PATH);
8448
+ const previous = existsSync(target) ? readFileSync(target, "utf-8") : null;
8449
+ writeFileSync(target, contents, "utf-8");
8450
+ let ok = false;
8451
+ try {
8452
+ ok = fn();
8453
+ } finally {
8454
+ if (previous !== null) writeFileSync(target, previous, "utf-8");
8455
+ else if (ok) rmSync(target, { force: true });
8456
+ }
8457
+ return ok;
8458
+ }
7766
8459
  function setupLocalDatabase(serverPath, env) {
7767
8460
  const controlPlaneUrl = env.DATABASE_URL;
7768
8461
  const clientId = env.CLIENT_ID;
@@ -7774,134 +8467,125 @@ function setupLocalDatabase(serverPath, env) {
7774
8467
  const conn = parseMysqlUrl(controlPlaneUrl);
7775
8468
  const controlPlaneName = dbNameFromUrl(controlPlaneUrl);
7776
8469
  const tenantName = `tenant-${clientId}`;
7777
- const tenantUrl = swapDbName(controlPlaneUrl, tenantName);
7778
8470
  const adminEmail = `admin@${clientId}.com`;
7779
8471
  if (controlPlaneName !== CONTROL_PLANE_DB) {
7780
8472
  note(`Local database not set up — DATABASE_URL must point at "${CONTROL_PLANE_DB}".`);
7781
8473
  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
8474
  return;
7783
8475
  }
8476
+ const scripts = readServerScripts(serverPath);
8477
+ const missing = [SERVER_SCRIPT.migrate, SERVER_SCRIPT.createTenant].filter((name) => !scripts[name]);
8478
+ if (missing.length > 0) {
8479
+ note(`Local database not set up — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
8480
+ 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")}.`);
8481
+ return;
8482
+ }
8483
+ const server = makeServerRunner(serverPath, env);
7784
8484
  M.step(`Setting up local databases (${controlPlaneName} + ${tenantName})…`);
7785
8485
  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.");
8486
+ if (!server.exec(SERVER_SCRIPT.migrate)) {
8487
+ note(`Local database not set up — control-plane migration failed. Rerun: ${server.rerun(SERVER_SCRIPT.migrate)}`);
7788
8488
  M.warn("Control-plane migration failed — skipping the rest of database setup.");
7789
8489
  return;
7790
8490
  }
7791
- runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
8491
+ const hasControlPlaneSeed = Boolean(scripts[SERVER_SCRIPT.seedControlPlane]);
8492
+ if (!(hasControlPlaneSeed ? server.exec(SERVER_SCRIPT.seedControlPlane) : runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
7792
8493
  ...env,
7793
8494
  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.");
8495
+ }))) {
8496
+ note(`Local database not set up — control-plane seed failed. Rerun: ${hasControlPlaneSeed ? server.rerun(SERVER_SCRIPT.seedControlPlane) : ONBOARDING_RERUN}`);
8497
+ M.warn("Control-plane seed failed — skipping the rest of database setup.");
8498
+ return;
8499
+ }
8500
+ const holder = findRutHolder(serverPath, env, controlPlaneUrl);
8501
+ if (rutConflict(holder, clientId)) {
8502
+ note(`Tenant "${clientId}" not seeded — CLIENT_RUT is already used by "${holder}".`);
8503
+ 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.`);
8504
+ return;
8505
+ }
8506
+ const s = Y();
8507
+ s.start(`Provisioning tenant "${clientId}"…`);
8508
+ const tenantCreated = withTenantConfig(serverPath, renderTenantConfig({
8509
+ secretId: clientId,
8510
+ taxIdentifier: env.CLIENT_RUT ?? "",
8511
+ companyName: env.CLIENT_NAME ?? clientId,
8512
+ adminEmail,
8513
+ adminPassword
8514
+ }), () => server.exec(SERVER_SCRIPT.createTenant));
8515
+ s.stop(tenantCreated ? `Tenant "${clientId}" provisioned` : import_picocolors.default.yellow("Tenant provisioning failed"));
8516
+ if (!tenantCreated) {
8517
+ note(`Tenant "${clientId}" not provisioned — ${SERVER_SCRIPT.createTenant} failed. Rerun: ${server.rerun(SERVER_SCRIPT.createTenant)} (reads ${TENANT_CONFIG_PATH})`);
7832
8518
  return;
7833
8519
  }
7834
- if (!runDisposableScript(serverPath, ".pruff-onboarding-accounting-period.ts", ACCOUNTING_PERIOD_SCRIPT, {
8520
+ const hasPeriodScript = Boolean(scripts[SERVER_SCRIPT.openAccountingPeriod]);
8521
+ if (!(hasPeriodScript ? server.exec(SERVER_SCRIPT.openAccountingPeriod) : runDisposableScript(serverPath, ".pruff-onboarding-accounting-period.ts", ACCOUNTING_PERIOD_SCRIPT, {
7835
8522
  ...env,
7836
- DATABASE_URL: tenantUrl
7837
- }, [String((/* @__PURE__ */ new Date()).getFullYear())])) {
7838
- note("No open AccountingPeriod — accounting entries will be rejected until one exists.");
8523
+ DATABASE_URL: swapDbName(controlPlaneUrl, tenantName)
8524
+ }, [String((/* @__PURE__ */ new Date()).getFullYear())]))) {
8525
+ note(`No open AccountingPeriod — accounting entries will be rejected until one exists. Rerun: ${hasPeriodScript ? server.rerun(SERVER_SCRIPT.openAccountingPeriod) : ONBOARDING_RERUN}`);
7839
8526
  M.warn("Could not open the current-year accounting period — create one under Contabilidad → Períodos.");
7840
8527
  }
8528
+ setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword);
7841
8529
  M.success(`Local tenant "${clientId}" ready.`);
7842
8530
  printLoginHint(clientId, adminEmail, adminPassword);
7843
8531
  }
7844
- function setupIntelligence(serverPath, env, conn, controlPlaneUrl, adminEmail, adminPassword) {
8532
+ function setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword) {
7845
8533
  const intelligenceUrl = env.INTELLIGENCE_DATABASE_URL;
7846
8534
  if (!intelligenceUrl) {
7847
8535
  note("Intelligence module unavailable — INTELLIGENCE_DATABASE_URL is not set in local.env.");
7848
8536
  M.warn(`local.env has no ${import_picocolors.default.cyan("INTELLIGENCE_DATABASE_URL")} — skipping Intelligence setup.`);
7849
8537
  return;
7850
8538
  }
8539
+ const steps = [
8540
+ SERVER_SCRIPT.intelMigrate,
8541
+ SERVER_SCRIPT.intelGenerate,
8542
+ SERVER_SCRIPT.intelSeedGlobal,
8543
+ SERVER_SCRIPT.intelCreateTenant
8544
+ ];
8545
+ const missing = steps.filter((name) => !scripts[name]);
8546
+ if (missing.length > 0) {
8547
+ note(`Intelligence module unavailable — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
8548
+ M.warn(`Pruff-server's package.json has no ${import_picocolors.default.cyan(missing.join(", "))} — skipping Intelligence setup.`);
8549
+ return;
8550
+ }
7851
8551
  const s = Y();
7852
8552
  s.start("Setting up Intelligence…");
7853
8553
  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,
8554
+ const enrolment = {
7890
8555
  TENANT_SECRET_ID: env.CLIENT_ID,
7891
8556
  TENANT_RUT: env.CLIENT_RUT,
7892
8557
  TENANT_NAME: env.CLIENT_NAME,
7893
8558
  USER_EMAIL: adminEmail,
7894
8559
  USER_PASSWORD: adminPassword,
7895
8560
  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.");
8561
+ };
8562
+ for (const script of steps) {
8563
+ if (server.exec(script, script === SERVER_SCRIPT.intelCreateTenant ? enrolment : void 0)) continue;
8564
+ s.stop(import_picocolors.default.yellow(`Intelligence setup failed at ${script}`));
8565
+ note(`Intelligence module unavailable — ${script} failed. Rerun: ${server.rerun(script)}`);
7899
8566
  return;
7900
8567
  }
7901
8568
  s.stop("Intelligence ready");
7902
8569
  }
8570
+ let localLogin = null;
7903
8571
  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)}`);
8572
+ localLogin = {
8573
+ clientId,
8574
+ email,
8575
+ password
8576
+ };
8577
+ }
8578
+ function showLocalLogin() {
8579
+ if (!localLogin) return;
8580
+ const line = (s) => console.log(` ${s}`);
8581
+ console.log();
8582
+ line(import_picocolors.default.bold("Your local Pruff login") + import_picocolors.default.dim(" (Super Administrador)"));
8583
+ line(`${import_picocolors.default.dim("url: ")} ${import_picocolors.default.cyan(FRONTEND_URL)}`);
8584
+ line(`${import_picocolors.default.dim("tenant: ")} ${import_picocolors.default.cyan(localLogin.clientId)}`);
8585
+ line(`${import_picocolors.default.dim("email: ")} ${import_picocolors.default.cyan(localLogin.email)}`);
8586
+ line(`${import_picocolors.default.dim("password:")} ${import_picocolors.default.cyan(localLogin.password)}`);
8587
+ line(import_picocolors.default.dim("Local only: this user exists in your local databases, never in dev or prod."));
8588
+ console.log();
7905
8589
  }
7906
8590
  async function showGuidance(workspace) {
7907
8591
  console.log();
@@ -7931,6 +8615,8 @@ async function showGuidance(workspace) {
7931
8615
  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
8616
  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
8617
  line(`${import_picocolors.default.dim("5.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
8618
+ 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.`);
8619
+ 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
8620
  console.log();
7935
8621
  }
7936
8622
  function showTeamCredentialsAsk(workspace) {
@@ -8913,6 +9599,11 @@ ${BOLD}Rules:${RESET}
8913
9599
  rules add --scope <client|server|common>
8914
9600
  Install a whole repo's rule set (client/server also pull in common)
8915
9601
 
9602
+ ${BOLD}Hooks:${RESET}
9603
+ hooks add <name> Install a Claude Code hook into ~/.claude/hooks/pruff/ and register it
9604
+ hooks list Show installed and available hooks
9605
+ hooks remove <name> Unregister a hook and delete its script
9606
+
8916
9607
  ${BOLD}Usage analytics:${RESET}
8917
9608
  analytics status Is Claude Code reporting usage to Pruff's PostHog? (alias: telemetry)
8918
9609
  analytics enable Turn it on in ~/.claude/settings.json
@@ -8941,6 +9632,7 @@ ${BOLD}Onboarding:${RESET}
8941
9632
  ${BOLD}Onboarding Options:${RESET}
8942
9633
  --db Force the local database step (non-interactive shells)
8943
9634
  --no-db Skip the local database step
9635
+ --db-access Only set up read-only prod database access (needs AWS credentials)
8944
9636
 
8945
9637
  ${BOLD}Updates:${RESET}
8946
9638
  update [skills...] Update skills to latest versions (alias: upgrade)
@@ -9151,6 +9843,11 @@ async function main() {
9151
9843
  if (!inAgent) await showLogo();
9152
9844
  await runRules(restArgs);
9153
9845
  break;
9846
+ case "hooks":
9847
+ case "hook":
9848
+ if (!inAgent) await showLogo();
9849
+ await runHooks(restArgs);
9850
+ break;
9154
9851
  case "buzz":
9155
9852
  if (!inAgent) await showBuzzLogo();
9156
9853
  await runBuzz(restArgs);