This repository has no description
1// Config = committed loup.config.json (non-secret: target repo, branch, repo root)
2// + .env (secret: handle, app password). Both read from CWD, falling back to the
3// package dir. repoRoot defaults to the CWD — so `loup serve` run inside a repo
4// operates on that repo.
5
6import { existsSync, readFileSync } from "node:fs";
7import { dirname, resolve } from "node:path";
8import { fileURLToPath } from "node:url";
9
10const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
12export interface Config {
13 live: boolean;
14 service: string;
15 handle: string | undefined;
16 appPassword: string | undefined;
17 targetRepoDid: string | undefined;
18 targetRepoName: string;
19 targetBranch: string;
20 repoRoot: string;
21 /** Source files/dirs (repo-relative) loup feeds the model so it can locate + fix issues. */
22 sourcePaths: string[];
23 port: number;
24 anthropicKey: string | undefined;
25 /** Vision model. Override with LOUP_MODEL. */
26 model: string;
27 /** When true, skip the vision pass. */
28 visionOff: boolean;
29 /** Offline canned demo (`--demo`): replay the known-good PRs, no LLM/network. */
30 demo: boolean;
31}
32
33function expandHome(p: string): string {
34 return p.startsWith("~/") ? resolve(process.env.HOME ?? "", p.slice(2)) : resolve(p);
35}
36
37function loadEnvFile(): void {
38 for (const dir of [process.cwd(), PKG_ROOT]) {
39 const p = resolve(dir, ".env");
40 if (!existsSync(p)) continue;
41 for (const raw of readFileSync(p, "utf8").split("\n")) {
42 const line = raw.trim();
43 if (!line || line.startsWith("#")) continue;
44 const i = line.indexOf("=");
45 if (i === -1) continue;
46 const k = line.slice(0, i).trim();
47 const v = line.slice(i + 1).trim();
48 if (k && process.env[k] === undefined) process.env[k] = v;
49 }
50 break;
51 }
52}
53
54function loadJsonConfig(): Record<string, unknown> {
55 for (const dir of [process.cwd(), PKG_ROOT]) {
56 const p = resolve(dir, "loup.config.json");
57 if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
58 }
59 return {};
60}
61
62export function loadConfig(): Config {
63 loadEnvFile();
64 const j = loadJsonConfig();
65 const env = process.env;
66 const repoRoot = expandHome((env.LOUP_REPO_ROOT ?? (j.repoRoot as string)) || process.cwd());
67 return {
68 live: env.LOUP_LIVE === "1" || env.LOUP_LIVE === "true",
69 service: (j.service as string) ?? "https://tngl.sh",
70 handle: env.TANGLED_HANDLE,
71 appPassword: env.TANGLED_APP_PASSWORD,
72 targetRepoDid: env.TANGLED_TARGET_REPO_DID ?? (j.targetRepoDid as string),
73 targetRepoName: (j.targetRepo as string) ?? "unknown/repo",
74 targetBranch: (j.targetBranch as string) ?? "main",
75 repoRoot,
76 sourcePaths: Array.isArray(j.sourcePaths) ? (j.sourcePaths as string[]) : [],
77 port: Number(env.PORT ?? j.port ?? 4319),
78 anthropicKey: env.ANTHROPIC_API_KEY,
79 model: env.LOUP_MODEL ?? (j.model as string) ?? "claude-opus-4-8",
80 visionOff: env.LOUP_NO_VISION === "1" || env.LOUP_NO_VISION === "true",
81 demo: env.LOUP_DEMO === "1" || env.LOUP_DEMO === "true",
82 };
83}
84
85// pds.ts (copied) imports resolveRepoRoot; keep a compatible shim.
86export function resolveRepoRoot(cfg: Config, _fallback: string): string {
87 return cfg.repoRoot;
88}
89
90export function canGoLive(c: Config): { ok: boolean; missing: string[] } {
91 const missing: string[] = [];
92 if (!c.handle) missing.push("TANGLED_HANDLE");
93 if (!c.appPassword) missing.push("TANGLED_APP_PASSWORD");
94 if (!c.targetRepoDid) missing.push("targetRepoDid");
95 return { ok: missing.length === 0, missing };
96}