@cruxy/cli 0.1.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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/agent/approval.d.ts +41 -0
- package/dist/agent/approval.js +179 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +4 -0
- package/dist/agent/loop.d.ts +53 -0
- package/dist/agent/loop.js +148 -0
- package/dist/agent/prompts.d.ts +53 -0
- package/dist/agent/prompts.js +99 -0
- package/dist/agent/session.d.ts +107 -0
- package/dist/agent/session.js +236 -0
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +59 -0
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +85 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +36 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +114 -0
- package/dist/cli/stream-print.d.ts +14 -0
- package/dist/cli/stream-print.js +26 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +4 -0
- package/dist/config/manager.d.ts +34 -0
- package/dist/config/manager.js +151 -0
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.js +31 -0
- package/dist/config/project.d.ts +10 -0
- package/dist/config/project.js +36 -0
- package/dist/config/schema.d.ts +303 -0
- package/dist/config/schema.js +100 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/tools/file/apply-patch.d.ts +94 -0
- package/dist/tools/file/apply-patch.js +195 -0
- package/dist/tools/file/edit-file.d.ts +14 -0
- package/dist/tools/file/edit-file.js +81 -0
- package/dist/tools/file/glob.d.ts +10 -0
- package/dist/tools/file/glob.js +52 -0
- package/dist/tools/file/grep-files.d.ts +32 -0
- package/dist/tools/file/grep-files.js +113 -0
- package/dist/tools/file/index.d.ts +7 -0
- package/dist/tools/file/index.js +7 -0
- package/dist/tools/file/paths.d.ts +24 -0
- package/dist/tools/file/paths.js +65 -0
- package/dist/tools/file/read-file.d.ts +8 -0
- package/dist/tools/file/read-file.js +52 -0
- package/dist/tools/file/write-file.d.ts +10 -0
- package/dist/tools/file/write-file.js +56 -0
- package/dist/tools/git-status.d.ts +8 -0
- package/dist/tools/git-status.js +26 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list-files.d.ts +7 -0
- package/dist/tools/list-files.js +27 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +63 -0
- package/dist/tools/shell/index.d.ts +1 -0
- package/dist/tools/shell/index.js +1 -0
- package/dist/tools/shell/run-command.d.ts +10 -0
- package/dist/tools/shell/run-command.js +100 -0
- package/dist/tools/types.d.ts +113 -0
- package/dist/tools/types.js +1 -0
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.js +43 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +42 -0
- package/package.json +52 -0
package/dist/cli/repl.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { createStreamPrinter } from "./stream-print.js";
|
|
5
|
+
const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
|
|
6
|
+
const HELP = `Commands:
|
|
7
|
+
/help show this help
|
|
8
|
+
/clear clear the conversation history (keep the session)
|
|
9
|
+
/compact summarize older history to free up context now
|
|
10
|
+
/reload re-read project instructions (CRUXY.md)
|
|
11
|
+
/exit, /quit leave cruxy
|
|
12
|
+
Ctrl+D leave cruxy`;
|
|
13
|
+
const defaultIO = () => ({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Read one line using a readline interface that is created and then **closed
|
|
19
|
+
* before this resolves**. This is the crux of the stdin coordination: while a
|
|
20
|
+
* turn runs (`session.send`), approvals grab stdin in raw mode via the Approver
|
|
21
|
+
* — so no readline interface may be live at that moment. Creating a fresh
|
|
22
|
+
* interface per line, and closing it the instant we have input, guarantees the
|
|
23
|
+
* two never contend.
|
|
24
|
+
*
|
|
25
|
+
* Resolves to the line, or `null` on EOF / Ctrl+D.
|
|
26
|
+
*
|
|
27
|
+
* The `answered` guard matters: `rl.close()` emits `'close'` **synchronously**,
|
|
28
|
+
* so the question callback's own close would otherwise fire the EOF path and
|
|
29
|
+
* clobber a perfectly good line with `null` — making every first line look like
|
|
30
|
+
* Ctrl+D. The guard ensures `'close'` only signals EOF when no line arrived.
|
|
31
|
+
*/
|
|
32
|
+
function readLine(io, prompt) {
|
|
33
|
+
const rl = readline.createInterface({
|
|
34
|
+
input: io.input,
|
|
35
|
+
output: io.output,
|
|
36
|
+
});
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let answered = false;
|
|
39
|
+
// Ctrl+D / EOF closes the interface without firing the question callback.
|
|
40
|
+
// Only treat it as EOF if no line was read (see the `answered` note above).
|
|
41
|
+
rl.on("close", () => {
|
|
42
|
+
if (!answered)
|
|
43
|
+
resolve(null);
|
|
44
|
+
});
|
|
45
|
+
rl.question(prompt, (answer) => {
|
|
46
|
+
answered = true;
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(answer);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
54
|
+
* commands or run a turn, repeat. Assistant text streams to stdout from within
|
|
55
|
+
* `session.send` (via the logger); this loop only owns input and control.
|
|
56
|
+
*
|
|
57
|
+
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
|
|
58
|
+
*/
|
|
59
|
+
export async function runInteractive(session, io = defaultIO()) {
|
|
60
|
+
logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
|
|
61
|
+
for (;;) {
|
|
62
|
+
const line = await readLine(io, PROMPT);
|
|
63
|
+
// EOF / Ctrl+D.
|
|
64
|
+
if (line === null) {
|
|
65
|
+
logger.print(pc.dim("\nbye"));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (trimmed === "")
|
|
70
|
+
continue; // empty line → reprompt, no model call
|
|
71
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
72
|
+
logger.print(pc.dim("bye"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (trimmed === "/clear") {
|
|
76
|
+
session.clear();
|
|
77
|
+
logger.print(pc.dim("history cleared"));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (trimmed === "/compact") {
|
|
81
|
+
try {
|
|
82
|
+
const n = await session.compact();
|
|
83
|
+
logger.print(pc.dim(n ? `compacted ${n} older messages` : "nothing to compact yet"));
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
logger.error(err.message);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (trimmed === "/reload") {
|
|
91
|
+
const loaded = session.reloadProjectInstructions();
|
|
92
|
+
logger.print(pc.dim(loaded
|
|
93
|
+
? "reloaded project instructions (CRUXY.md)"
|
|
94
|
+
: "no project instructions found"));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (trimmed === "/help") {
|
|
98
|
+
logger.print(HELP);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// A real turn. Assistant text streams to the output delta by delta through a
|
|
102
|
+
// single printer (which trims the model's leading blank lines); the agent
|
|
103
|
+
// loop closes the segment with one newline, so the next prompt lands on its
|
|
104
|
+
// own line. Errors (provider/API failures) log and return to the prompt
|
|
105
|
+
// rather than killing the REPL.
|
|
106
|
+
try {
|
|
107
|
+
const print = createStreamPrinter((text) => io.output.write(text));
|
|
108
|
+
await session.send(line, print);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.error(err.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a raw write sink into a printer for streamed assistant text.
|
|
3
|
+
*
|
|
4
|
+
* Models routinely begin a turn with one or more leading newlines; written
|
|
5
|
+
* verbatim those land as blank lines between the prompt and the answer. The
|
|
6
|
+
* printer drops the leading newline run of a turn (until the first visible
|
|
7
|
+
* character) so output starts on a single fresh line, and otherwise forwards
|
|
8
|
+
* text untouched — internal blank lines (markdown paragraphs, code) are
|
|
9
|
+
* preserved. State is per printer, so create one per turn.
|
|
10
|
+
*
|
|
11
|
+
* All turn output — the deltas and the loop's single segment-terminating
|
|
12
|
+
* newline — flows through this one sink, so nothing races the stream on stdout.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createStreamPrinter(write: (text: string) => void): (delta: string) => void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a raw write sink into a printer for streamed assistant text.
|
|
3
|
+
*
|
|
4
|
+
* Models routinely begin a turn with one or more leading newlines; written
|
|
5
|
+
* verbatim those land as blank lines between the prompt and the answer. The
|
|
6
|
+
* printer drops the leading newline run of a turn (until the first visible
|
|
7
|
+
* character) so output starts on a single fresh line, and otherwise forwards
|
|
8
|
+
* text untouched — internal blank lines (markdown paragraphs, code) are
|
|
9
|
+
* preserved. State is per printer, so create one per turn.
|
|
10
|
+
*
|
|
11
|
+
* All turn output — the deltas and the loop's single segment-terminating
|
|
12
|
+
* newline — flows through this one sink, so nothing races the stream on stdout.
|
|
13
|
+
*/
|
|
14
|
+
export function createStreamPrinter(write) {
|
|
15
|
+
let started = false;
|
|
16
|
+
return (delta) => {
|
|
17
|
+
let text = delta;
|
|
18
|
+
if (!started) {
|
|
19
|
+
text = text.replace(/^[\r\n]+/, "");
|
|
20
|
+
if (text === "")
|
|
21
|
+
return; // still inside the leading blank-line run
|
|
22
|
+
started = true;
|
|
23
|
+
}
|
|
24
|
+
write(text);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type CruxyConfig } from "./schema.js";
|
|
2
|
+
export interface LoadOptions {
|
|
3
|
+
/** Explicit config file, bypassing project discovery. */
|
|
4
|
+
configPath?: string;
|
|
5
|
+
/** Start dir for project-config discovery (defaults to cwd). */
|
|
6
|
+
cwd?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface LoadedConfig {
|
|
9
|
+
config: CruxyConfig;
|
|
10
|
+
sources: {
|
|
11
|
+
global: string | null;
|
|
12
|
+
project: string | null;
|
|
13
|
+
explicit: string | null;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolution order (later wins):
|
|
18
|
+
* schema defaults -> global file -> project file (or explicit) -> env vars
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadConfig(opts?: LoadOptions): LoadedConfig;
|
|
21
|
+
/** Resolve a dot-path (e.g. "model.temperature") against a config object. */
|
|
22
|
+
export declare function getPath(obj: unknown, path: string): unknown;
|
|
23
|
+
/**
|
|
24
|
+
* Set a dot-path on the given config file (creating it if needed), validating
|
|
25
|
+
* the full merged result before writing. Returns the written file path.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setValue(path: string, rawValue: string, file: string): string;
|
|
28
|
+
/** Write a default config to `file` if it does not already exist. */
|
|
29
|
+
export declare function initConfig(file: string): {
|
|
30
|
+
path: string;
|
|
31
|
+
created: boolean;
|
|
32
|
+
};
|
|
33
|
+
/** Provider API key, read from the environment only (never persisted). */
|
|
34
|
+
export declare function resolveApiKey(provider: string): string | undefined;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { CruxyConfigSchema } from "./schema.js";
|
|
4
|
+
import { globalConfigPath, findProjectConfig } from "./paths.js";
|
|
5
|
+
function isPlainObject(v) {
|
|
6
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7
|
+
}
|
|
8
|
+
/** Deep-merge plain objects; later sources win. Arrays/scalars are replaced. */
|
|
9
|
+
function deepMerge(base, override) {
|
|
10
|
+
const out = { ...base };
|
|
11
|
+
for (const [key, val] of Object.entries(override)) {
|
|
12
|
+
const prev = out[key];
|
|
13
|
+
if (isPlainObject(prev) && isPlainObject(val)) {
|
|
14
|
+
out[key] = deepMerge(prev, val);
|
|
15
|
+
}
|
|
16
|
+
else if (val !== undefined) {
|
|
17
|
+
out[key] = val;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function readJsonFile(path) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
if (!isPlainObject(parsed)) {
|
|
26
|
+
throw new Error(`config at ${path} must be a JSON object`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof SyntaxError) {
|
|
32
|
+
throw new Error(`config at ${path} is not valid JSON: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Overrides sourced from environment variables. */
|
|
38
|
+
function envOverrides() {
|
|
39
|
+
const out = {};
|
|
40
|
+
const model = {};
|
|
41
|
+
if (process.env.CRUXY_MODEL)
|
|
42
|
+
model.model = process.env.CRUXY_MODEL;
|
|
43
|
+
if (process.env.CRUXY_PROVIDER)
|
|
44
|
+
model.provider = process.env.CRUXY_PROVIDER;
|
|
45
|
+
if (Object.keys(model).length)
|
|
46
|
+
out.model = model;
|
|
47
|
+
if (process.env.CRUXY_LOG_LEVEL)
|
|
48
|
+
out.logLevel = process.env.CRUXY_LOG_LEVEL;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolution order (later wins):
|
|
53
|
+
* schema defaults -> global file -> project file (or explicit) -> env vars
|
|
54
|
+
*/
|
|
55
|
+
export function loadConfig(opts = {}) {
|
|
56
|
+
const sources = {
|
|
57
|
+
global: null,
|
|
58
|
+
project: null,
|
|
59
|
+
explicit: null,
|
|
60
|
+
};
|
|
61
|
+
let merged = {};
|
|
62
|
+
const gPath = globalConfigPath();
|
|
63
|
+
if (existsSync(gPath)) {
|
|
64
|
+
merged = deepMerge(merged, readJsonFile(gPath));
|
|
65
|
+
sources.global = gPath;
|
|
66
|
+
}
|
|
67
|
+
if (opts.configPath) {
|
|
68
|
+
merged = deepMerge(merged, readJsonFile(opts.configPath));
|
|
69
|
+
sources.explicit = opts.configPath;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const pPath = findProjectConfig(opts.cwd);
|
|
73
|
+
if (pPath) {
|
|
74
|
+
merged = deepMerge(merged, readJsonFile(pPath));
|
|
75
|
+
sources.project = pPath;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
merged = deepMerge(merged, envOverrides());
|
|
79
|
+
const result = CruxyConfigSchema.safeParse(merged);
|
|
80
|
+
if (!result.success) {
|
|
81
|
+
const issues = result.error.issues
|
|
82
|
+
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
83
|
+
.join("\n");
|
|
84
|
+
throw new Error(`invalid configuration:\n${issues}`);
|
|
85
|
+
}
|
|
86
|
+
return { config: result.data, sources };
|
|
87
|
+
}
|
|
88
|
+
/** Resolve a dot-path (e.g. "model.temperature") against a config object. */
|
|
89
|
+
export function getPath(obj, path) {
|
|
90
|
+
return path
|
|
91
|
+
.split(".")
|
|
92
|
+
.reduce((acc, key) => (isPlainObject(acc) ? acc[key] : undefined), obj);
|
|
93
|
+
}
|
|
94
|
+
/** Coerce a CLI string value to boolean/number where it parses cleanly. */
|
|
95
|
+
function coerce(value) {
|
|
96
|
+
if (value === "true")
|
|
97
|
+
return true;
|
|
98
|
+
if (value === "false")
|
|
99
|
+
return false;
|
|
100
|
+
if (value !== "" && !Number.isNaN(Number(value)))
|
|
101
|
+
return Number(value);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Set a dot-path on the given config file (creating it if needed), validating
|
|
106
|
+
* the full merged result before writing. Returns the written file path.
|
|
107
|
+
*/
|
|
108
|
+
export function setValue(path, rawValue, file) {
|
|
109
|
+
const current = existsSync(file) ? readJsonFile(file) : {};
|
|
110
|
+
const keys = path.split(".");
|
|
111
|
+
let cursor = current;
|
|
112
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
113
|
+
const k = keys[i];
|
|
114
|
+
if (!isPlainObject(cursor[k]))
|
|
115
|
+
cursor[k] = {};
|
|
116
|
+
cursor = cursor[k];
|
|
117
|
+
}
|
|
118
|
+
cursor[keys[keys.length - 1]] = coerce(rawValue);
|
|
119
|
+
const check = CruxyConfigSchema.safeParse(current);
|
|
120
|
+
if (!check.success) {
|
|
121
|
+
const issues = check.error.issues
|
|
122
|
+
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
123
|
+
.join("\n");
|
|
124
|
+
throw new Error(`refusing to write invalid config:\n${issues}`);
|
|
125
|
+
}
|
|
126
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
127
|
+
writeFileSync(file, JSON.stringify(current, null, 2) + "\n", "utf8");
|
|
128
|
+
return file;
|
|
129
|
+
}
|
|
130
|
+
/** Write a default config to `file` if it does not already exist. */
|
|
131
|
+
export function initConfig(file) {
|
|
132
|
+
if (existsSync(file))
|
|
133
|
+
return { path: file, created: false };
|
|
134
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
135
|
+
const defaults = CruxyConfigSchema.parse({});
|
|
136
|
+
writeFileSync(file, JSON.stringify(defaults, null, 2) + "\n", "utf8");
|
|
137
|
+
return { path: file, created: true };
|
|
138
|
+
}
|
|
139
|
+
/** Provider API key, read from the environment only (never persisted). */
|
|
140
|
+
export function resolveApiKey(provider) {
|
|
141
|
+
switch (provider) {
|
|
142
|
+
case "cruxy":
|
|
143
|
+
return process.env.CRUXY_API_KEY;
|
|
144
|
+
case "anthropic":
|
|
145
|
+
return process.env.ANTHROPIC_API_KEY;
|
|
146
|
+
case "openai":
|
|
147
|
+
return process.env.OPENAI_API_KEY;
|
|
148
|
+
default:
|
|
149
|
+
return process.env.CRUXY_API_KEY;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** ~/.cruxy */
|
|
2
|
+
export declare function globalDir(): string;
|
|
3
|
+
/** ~/.cruxy/config.json */
|
|
4
|
+
export declare function globalConfigPath(): string;
|
|
5
|
+
/**
|
|
6
|
+
* Walk up from `startDir` looking for a project config file. Returns the first
|
|
7
|
+
* match, or null if none is found before the filesystem root.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findProjectConfig(startDir?: string): string | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, parse } from "node:path";
|
|
4
|
+
import { GLOBAL_DIR_NAME, CONFIG_FILE_NAME, PROJECT_CONFIG_FILENAMES, } from "../constants.js";
|
|
5
|
+
/** ~/.cruxy */
|
|
6
|
+
export function globalDir() {
|
|
7
|
+
return join(homedir(), GLOBAL_DIR_NAME);
|
|
8
|
+
}
|
|
9
|
+
/** ~/.cruxy/config.json */
|
|
10
|
+
export function globalConfigPath() {
|
|
11
|
+
return join(globalDir(), CONFIG_FILE_NAME);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Walk up from `startDir` looking for a project config file. Returns the first
|
|
15
|
+
* match, or null if none is found before the filesystem root.
|
|
16
|
+
*/
|
|
17
|
+
export function findProjectConfig(startDir = process.cwd()) {
|
|
18
|
+
let dir = startDir;
|
|
19
|
+
const { root } = parse(dir);
|
|
20
|
+
while (true) {
|
|
21
|
+
for (const name of PROJECT_CONFIG_FILENAMES) {
|
|
22
|
+
const candidate = join(dir, name);
|
|
23
|
+
if (existsSync(candidate))
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
if (dir === root)
|
|
27
|
+
break;
|
|
28
|
+
dir = dirname(dir);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load project instructions from `cwd`: the first of CRUXY.md, then AGENTS.md
|
|
3
|
+
* that exists is read and returned. A missing (or unreadable) file is not an
|
|
4
|
+
* error — it simply yields `null`. Oversized files are truncated to `MAX_BYTES`
|
|
5
|
+
* with a trailing notice so they can't blow up the system prompt.
|
|
6
|
+
*
|
|
7
|
+
* Only `cwd` is consulted (no parent-directory walk) — instructions are scoped
|
|
8
|
+
* to the project you launched cruxy in.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadProjectInstructions(cwd: string): string | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { PROJECT_INSTRUCTION_FILENAMES } from "../constants.js";
|
|
4
|
+
/** Cap on instruction file size; the rest is dropped with a notice. */
|
|
5
|
+
const MAX_BYTES = 32 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Load project instructions from `cwd`: the first of CRUXY.md, then AGENTS.md
|
|
8
|
+
* that exists is read and returned. A missing (or unreadable) file is not an
|
|
9
|
+
* error — it simply yields `null`. Oversized files are truncated to `MAX_BYTES`
|
|
10
|
+
* with a trailing notice so they can't blow up the system prompt.
|
|
11
|
+
*
|
|
12
|
+
* Only `cwd` is consulted (no parent-directory walk) — instructions are scoped
|
|
13
|
+
* to the project you launched cruxy in.
|
|
14
|
+
*/
|
|
15
|
+
export function loadProjectInstructions(cwd) {
|
|
16
|
+
for (const name of PROJECT_INSTRUCTION_FILENAMES) {
|
|
17
|
+
const file = join(cwd, name);
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
continue;
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
text = readFileSync(file, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue; // unreadable — treat as absent and try the next candidate
|
|
26
|
+
}
|
|
27
|
+
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
|
|
28
|
+
const head = Buffer.from(text, "utf8")
|
|
29
|
+
.subarray(0, MAX_BYTES)
|
|
30
|
+
.toString("utf8");
|
|
31
|
+
return `${head}\n\n[truncated: ${name} exceeds ${MAX_BYTES} bytes]`;
|
|
32
|
+
}
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|