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.
- package/README.md +14 -0
- package/dist/cli.mjs +1004 -307
- 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$
|
|
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$
|
|
1033
|
+
globalSkillsDir: join(claudeHome$2, "skills"),
|
|
1034
1034
|
detectInstalled: async () => {
|
|
1035
|
-
return existsSync(claudeHome$
|
|
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
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
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 (
|
|
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
|
|
4064
|
-
|
|
4065
|
-
|
|
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 (
|
|
4070
|
+
if (isGitHubSource(source)) try {
|
|
4071
4071
|
const { stdout } = await execFileAsync$4("gh", [
|
|
4072
4072
|
"api",
|
|
4073
|
-
`repos/${source}/contents
|
|
4073
|
+
`repos/${source}/contents/${dir}`,
|
|
4074
4074
|
"--jq",
|
|
4075
4075
|
".[].name"
|
|
4076
|
-
], { env:
|
|
4077
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
6449
|
-
sls-Public-Market
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
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 | \`
|
|
6550
|
-
| External | \`/api/external\` | API Key | \`authenticateWithApiKey\` |
|
|
6551
|
-
| Clients | \`/api/clients\` | Client API Key | \`
|
|
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
|
|
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
|
-
|
|
6560
|
-
|
|
6561
|
-
return {
|
|
6562
|
-
return {
|
|
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(
|
|
7251
|
+
return handleApiOutput(SomeService, req, res, result);
|
|
6572
7252
|
} catch (error) {
|
|
6573
|
-
handleApiError(
|
|
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
|
|
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
|
-
- **
|
|
6722
|
-
- **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** —
|
|
6723
|
-
\`dev\`
|
|
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
|
|
6892
|
-
|
|
6893
|
-
import
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
}
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
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
|
-
|
|
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
|
|
7094
|
-
|
|
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
|
-
|
|
7429
|
-
|
|
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
|
-
|
|
7433
|
-
|
|
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
|
|
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:
|
|
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 (!
|
|
7787
|
-
note(
|
|
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
|
-
|
|
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
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
}
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
|
|
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
|
-
|
|
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:
|
|
7837
|
-
}, [String((/* @__PURE__ */ new Date()).getFullYear())])) {
|
|
7838
|
-
note(
|
|
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(
|
|
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
|
|
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
|
-
|
|
7898
|
-
|
|
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
|
-
|
|
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);
|