@taranek/orche 0.0.16 → 0.0.18
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/dist/cli.js +21 -26
- package/dist/config.d.ts +7 -0
- package/dist/config.js +52 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +124 -0
- package/package.json +6 -3
- package/src/cli.ts +22 -31
- package/src/config.test.ts +148 -0
- package/src/config.ts +59 -0
package/dist/cli.js
CHANGED
|
@@ -7,25 +7,12 @@ import { pruneCommand } from "./prune.js";
|
|
|
7
7
|
import { getMultiplexer } from "./multiplexer.js";
|
|
8
8
|
import { buildLayout } from "./layout.js";
|
|
9
9
|
import { getReviewBinaryPath } from "./review-manager.js";
|
|
10
|
+
import { loadConfig, parsePresetFlag } from "./config.js";
|
|
10
11
|
const CONFIG_NAME = ".orche.json";
|
|
11
|
-
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
12
12
|
function die(msg) {
|
|
13
13
|
console.error(`error: ${msg}`);
|
|
14
14
|
process.exit(1);
|
|
15
15
|
}
|
|
16
|
-
function loadConfig(cwd) {
|
|
17
|
-
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
18
|
-
const configPath = path.join(cwd, CONFIG_NAME);
|
|
19
|
-
if (existsSync(localPath)) {
|
|
20
|
-
const raw = readFileSync(localPath, "utf-8");
|
|
21
|
-
return JSON.parse(raw);
|
|
22
|
-
}
|
|
23
|
-
if (!existsSync(configPath)) {
|
|
24
|
-
die(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
25
|
-
}
|
|
26
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
27
|
-
return JSON.parse(raw);
|
|
28
|
-
}
|
|
29
16
|
function deliverReview(pendingPath) {
|
|
30
17
|
try {
|
|
31
18
|
const pending = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
@@ -134,8 +121,10 @@ function getRepoRoot(cwd) {
|
|
|
134
121
|
function startSession() {
|
|
135
122
|
const cwd = getRepoRoot(process.cwd());
|
|
136
123
|
const repoName = path.basename(cwd);
|
|
137
|
-
const
|
|
138
|
-
const
|
|
124
|
+
const args = process.argv.slice(3);
|
|
125
|
+
const preset = parsePresetFlag(args);
|
|
126
|
+
const taskName = args.find((a) => !a.startsWith("-") && a !== preset) || "session";
|
|
127
|
+
const config = loadConfig(cwd, preset);
|
|
139
128
|
const muxFlag = process.argv.includes("--tmux") ? "tmux" :
|
|
140
129
|
process.argv.includes("--cmux") ? "cmux" :
|
|
141
130
|
undefined;
|
|
@@ -157,20 +146,26 @@ function printUsage() {
|
|
|
157
146
|
orche — orchestrate agents across git worktrees
|
|
158
147
|
|
|
159
148
|
Usage:
|
|
160
|
-
orche start <task>
|
|
161
|
-
orche review [path]
|
|
162
|
-
orche prune [--all] [-f]
|
|
149
|
+
orche start <task> [-p <preset>] Start a new session for <task>
|
|
150
|
+
orche review [path] Open the review UI for a worktree
|
|
151
|
+
orche prune [--all] [-f] Remove orche worktrees (interactive multiselect)
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
-p, --preset=<name> Load .orche.<name>.json instead of .orche.json
|
|
163
155
|
|
|
164
156
|
Examples:
|
|
165
|
-
orche start fix-auth
|
|
166
|
-
orche
|
|
167
|
-
orche
|
|
168
|
-
orche
|
|
169
|
-
orche
|
|
170
|
-
orche prune
|
|
157
|
+
orche start fix-auth Create worktree + session for "fix-auth"
|
|
158
|
+
orche start fix-auth -p mobile Use .orche.mobile.json preset
|
|
159
|
+
orche start fix-auth -p debug Use .orche.debug.json preset
|
|
160
|
+
orche review Review changes in current directory
|
|
161
|
+
orche review ./worktree Review changes in a specific worktree
|
|
162
|
+
orche prune Pick worktrees to remove
|
|
163
|
+
orche prune --all Remove all orche worktrees
|
|
164
|
+
orche prune --force Allow removing worktrees with uncommitted changes
|
|
171
165
|
|
|
172
166
|
Requires a ${CONFIG_NAME} file in the current directory.
|
|
173
|
-
Use
|
|
167
|
+
Use .orche.local.json for local overrides (not committed).
|
|
168
|
+
Use .orche.<preset>.json for named presets (e.g. .orche.mobile.json).
|
|
174
169
|
See .orche.example.json for the config format.
|
|
175
170
|
`);
|
|
176
171
|
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentsConfig } from "./types.js";
|
|
2
|
+
export declare class ConfigError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function listPresets(cwd: string): string[];
|
|
6
|
+
export declare function loadConfig(cwd: string, preset?: string): AgentsConfig;
|
|
7
|
+
export declare function parsePresetFlag(args: string[]): string | undefined;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const CONFIG_NAME = ".orche.json";
|
|
4
|
+
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
5
|
+
const CONFIG_PRESET_PREFIX = ".orche.";
|
|
6
|
+
const CONFIG_PRESET_SUFFIX = ".json";
|
|
7
|
+
export class ConfigError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ConfigError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function listPresets(cwd) {
|
|
14
|
+
const files = readdirSync(cwd);
|
|
15
|
+
return files
|
|
16
|
+
.filter(f => f.startsWith(CONFIG_PRESET_PREFIX) && f.endsWith(CONFIG_PRESET_SUFFIX) && f !== CONFIG_NAME && f !== CONFIG_LOCAL_NAME)
|
|
17
|
+
.map(f => f.slice(CONFIG_PRESET_PREFIX.length, -CONFIG_PRESET_SUFFIX.length));
|
|
18
|
+
}
|
|
19
|
+
export function loadConfig(cwd, preset) {
|
|
20
|
+
if (preset) {
|
|
21
|
+
const presetPath = path.join(cwd, `${CONFIG_PRESET_PREFIX}${preset}${CONFIG_PRESET_SUFFIX}`);
|
|
22
|
+
if (!existsSync(presetPath)) {
|
|
23
|
+
const available = listPresets(cwd);
|
|
24
|
+
const list = available.length > 0
|
|
25
|
+
? `\navailable presets: ${available.join(", ")}`
|
|
26
|
+
: "\nno preset files found in current directory";
|
|
27
|
+
throw new ConfigError(`preset "${preset}" not found (looked for ${CONFIG_PRESET_PREFIX}${preset}${CONFIG_PRESET_SUFFIX})${list}`);
|
|
28
|
+
}
|
|
29
|
+
const raw = readFileSync(presetPath, "utf-8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
33
|
+
const configPath = path.join(cwd, CONFIG_NAME);
|
|
34
|
+
if (existsSync(localPath)) {
|
|
35
|
+
const raw = readFileSync(localPath, "utf-8");
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(configPath)) {
|
|
39
|
+
throw new ConfigError(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
40
|
+
}
|
|
41
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
export function parsePresetFlag(args) {
|
|
45
|
+
const idx = args.indexOf("-p");
|
|
46
|
+
if (idx !== -1 && idx + 1 < args.length)
|
|
47
|
+
return args[idx + 1];
|
|
48
|
+
const long = args.find(a => a.startsWith("--preset="));
|
|
49
|
+
if (long)
|
|
50
|
+
return long.split("=")[1];
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { loadConfig, listPresets, parsePresetFlag, ConfigError } from "./config.js";
|
|
6
|
+
function makeTmpDir() {
|
|
7
|
+
return mkdtempSync(path.join(os.tmpdir(), "orche-test-"));
|
|
8
|
+
}
|
|
9
|
+
function writeJSON(dir, name, data) {
|
|
10
|
+
writeFileSync(path.join(dir, name), JSON.stringify(data));
|
|
11
|
+
}
|
|
12
|
+
const baseConfig = { layout: { name: "main", command: "echo hi" } };
|
|
13
|
+
const mobileConfig = { layout: { name: "mobile", command: "echo mobile" } };
|
|
14
|
+
const debugConfig = { layout: { name: "debug", command: "echo debug" } };
|
|
15
|
+
describe("parsePresetFlag", () => {
|
|
16
|
+
it("parses -p flag", () => {
|
|
17
|
+
expect(parsePresetFlag(["-p", "mobile"])).toBe("mobile");
|
|
18
|
+
});
|
|
19
|
+
it("parses --preset= flag", () => {
|
|
20
|
+
expect(parsePresetFlag(["--preset=debug"])).toBe("debug");
|
|
21
|
+
});
|
|
22
|
+
it("returns undefined when no preset flag", () => {
|
|
23
|
+
expect(parsePresetFlag(["fix-auth", "--tmux"])).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
it("returns undefined when -p has no value", () => {
|
|
26
|
+
expect(parsePresetFlag(["-p"])).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
it("ignores -p when it is the last arg", () => {
|
|
29
|
+
expect(parsePresetFlag(["start", "-p"])).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("loadConfig", () => {
|
|
33
|
+
let tmp;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
tmp = makeTmpDir();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
it("loads .orche.json", () => {
|
|
41
|
+
writeJSON(tmp, ".orche.json", baseConfig);
|
|
42
|
+
const config = loadConfig(tmp);
|
|
43
|
+
expect(config.layout).toEqual(baseConfig.layout);
|
|
44
|
+
});
|
|
45
|
+
it("prefers .orche.local.json over .orche.json", () => {
|
|
46
|
+
writeJSON(tmp, ".orche.json", baseConfig);
|
|
47
|
+
writeJSON(tmp, ".orche.local.json", mobileConfig);
|
|
48
|
+
const config = loadConfig(tmp);
|
|
49
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
50
|
+
});
|
|
51
|
+
it("throws ConfigError when no config found", () => {
|
|
52
|
+
expect(() => loadConfig(tmp)).toThrow(ConfigError);
|
|
53
|
+
expect(() => loadConfig(tmp)).toThrow(".orche.json");
|
|
54
|
+
});
|
|
55
|
+
it("loads a preset config", () => {
|
|
56
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
57
|
+
const config = loadConfig(tmp, "mobile");
|
|
58
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
59
|
+
});
|
|
60
|
+
it("throws ConfigError for missing preset", () => {
|
|
61
|
+
expect(() => loadConfig(tmp, "nonexistent")).toThrow(ConfigError);
|
|
62
|
+
expect(() => loadConfig(tmp, "nonexistent")).toThrow('preset "nonexistent" not found');
|
|
63
|
+
});
|
|
64
|
+
it("lists available presets in error when preset not found", () => {
|
|
65
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
66
|
+
writeJSON(tmp, ".orche.debug.json", debugConfig);
|
|
67
|
+
try {
|
|
68
|
+
loadConfig(tmp, "nonexistent");
|
|
69
|
+
expect.unreachable("should have thrown");
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const msg = err.message;
|
|
73
|
+
expect(msg).toContain("available presets:");
|
|
74
|
+
expect(msg).toContain("mobile");
|
|
75
|
+
expect(msg).toContain("debug");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it("shows 'no preset files found' when none exist", () => {
|
|
79
|
+
try {
|
|
80
|
+
loadConfig(tmp, "nonexistent");
|
|
81
|
+
expect.unreachable("should have thrown");
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
expect(err.message).toContain("no preset files found");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
it("preset flag ignores .orche.local.json", () => {
|
|
88
|
+
writeJSON(tmp, ".orche.local.json", baseConfig);
|
|
89
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
90
|
+
const config = loadConfig(tmp, "mobile");
|
|
91
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("listPresets", () => {
|
|
95
|
+
let tmp;
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
tmp = makeTmpDir();
|
|
98
|
+
});
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
it("returns empty array when no presets", () => {
|
|
103
|
+
expect(listPresets(tmp)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
it("lists preset names without .orche. prefix and .json suffix", () => {
|
|
106
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
107
|
+
writeJSON(tmp, ".orche.debug.json", {});
|
|
108
|
+
writeJSON(tmp, ".orche.test.json", {});
|
|
109
|
+
const presets = listPresets(tmp).sort();
|
|
110
|
+
expect(presets).toEqual(["debug", "mobile", "test"]);
|
|
111
|
+
});
|
|
112
|
+
it("excludes .orche.json and .orche.local.json", () => {
|
|
113
|
+
writeJSON(tmp, ".orche.json", {});
|
|
114
|
+
writeJSON(tmp, ".orche.local.json", {});
|
|
115
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
116
|
+
expect(listPresets(tmp)).toEqual(["mobile"]);
|
|
117
|
+
});
|
|
118
|
+
it("ignores non-orche json files", () => {
|
|
119
|
+
writeJSON(tmp, "package.json", {});
|
|
120
|
+
writeJSON(tmp, "tsconfig.json", {});
|
|
121
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
122
|
+
expect(listPresets(tmp)).toEqual(["mobile"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taranek/orche",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"orche": "./dist/cli.js"
|
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc",
|
|
19
|
-
"dev": "tsc --watch"
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
24
|
"@types/node": "^25.2.0",
|
|
23
|
-
"typescript": "^5.2.2"
|
|
25
|
+
"typescript": "^5.2.2",
|
|
26
|
+
"vitest": "^4.1.5"
|
|
24
27
|
},
|
|
25
28
|
"dependencies": {
|
|
26
29
|
"@clack/prompts": "^1.2.0",
|
package/src/cli.ts
CHANGED
|
@@ -8,33 +8,16 @@ import { pruneCommand } from "./prune.js";
|
|
|
8
8
|
import { getMultiplexer } from "./multiplexer.js";
|
|
9
9
|
import { buildLayout } from "./layout.js";
|
|
10
10
|
import { getReviewBinaryPath } from "./review-manager.js";
|
|
11
|
-
import
|
|
11
|
+
import { loadConfig, parsePresetFlag } from "./config.js";
|
|
12
|
+
import type { MultiplexerType } from "./types.js";
|
|
12
13
|
|
|
13
14
|
const CONFIG_NAME = ".orche.json";
|
|
14
|
-
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
15
15
|
|
|
16
16
|
function die(msg: string): never {
|
|
17
17
|
console.error(`error: ${msg}`);
|
|
18
18
|
process.exit(1);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
function loadConfig(cwd: string): AgentsConfig {
|
|
23
|
-
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
24
|
-
const configPath = path.join(cwd, CONFIG_NAME);
|
|
25
|
-
|
|
26
|
-
if (existsSync(localPath)) {
|
|
27
|
-
const raw = readFileSync(localPath, "utf-8");
|
|
28
|
-
return JSON.parse(raw) as AgentsConfig;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!existsSync(configPath)) {
|
|
32
|
-
die(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
33
|
-
}
|
|
34
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
35
|
-
return JSON.parse(raw) as AgentsConfig;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
21
|
function deliverReview(pendingPath: string): void {
|
|
39
22
|
try {
|
|
40
23
|
const pending = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
@@ -142,9 +125,11 @@ function getRepoRoot(cwd: string): string {
|
|
|
142
125
|
function startSession(): void {
|
|
143
126
|
const cwd = getRepoRoot(process.cwd());
|
|
144
127
|
const repoName = path.basename(cwd);
|
|
145
|
-
const
|
|
128
|
+
const args = process.argv.slice(3);
|
|
129
|
+
const preset = parsePresetFlag(args);
|
|
130
|
+
const taskName = args.find((a) => !a.startsWith("-") && a !== preset) || "session";
|
|
146
131
|
|
|
147
|
-
const config = loadConfig(cwd);
|
|
132
|
+
const config = loadConfig(cwd, preset);
|
|
148
133
|
const muxFlag: MultiplexerType | undefined =
|
|
149
134
|
process.argv.includes("--tmux") ? "tmux" :
|
|
150
135
|
process.argv.includes("--cmux") ? "cmux" :
|
|
@@ -176,20 +161,26 @@ function printUsage(): void {
|
|
|
176
161
|
orche — orchestrate agents across git worktrees
|
|
177
162
|
|
|
178
163
|
Usage:
|
|
179
|
-
orche start <task>
|
|
180
|
-
orche review [path]
|
|
181
|
-
orche prune [--all] [-f]
|
|
164
|
+
orche start <task> [-p <preset>] Start a new session for <task>
|
|
165
|
+
orche review [path] Open the review UI for a worktree
|
|
166
|
+
orche prune [--all] [-f] Remove orche worktrees (interactive multiselect)
|
|
167
|
+
|
|
168
|
+
Options:
|
|
169
|
+
-p, --preset=<name> Load .orche.<name>.json instead of .orche.json
|
|
182
170
|
|
|
183
171
|
Examples:
|
|
184
|
-
orche start fix-auth
|
|
185
|
-
orche
|
|
186
|
-
orche
|
|
187
|
-
orche
|
|
188
|
-
orche
|
|
189
|
-
orche prune
|
|
172
|
+
orche start fix-auth Create worktree + session for "fix-auth"
|
|
173
|
+
orche start fix-auth -p mobile Use .orche.mobile.json preset
|
|
174
|
+
orche start fix-auth -p debug Use .orche.debug.json preset
|
|
175
|
+
orche review Review changes in current directory
|
|
176
|
+
orche review ./worktree Review changes in a specific worktree
|
|
177
|
+
orche prune Pick worktrees to remove
|
|
178
|
+
orche prune --all Remove all orche worktrees
|
|
179
|
+
orche prune --force Allow removing worktrees with uncommitted changes
|
|
190
180
|
|
|
191
181
|
Requires a ${CONFIG_NAME} file in the current directory.
|
|
192
|
-
Use
|
|
182
|
+
Use .orche.local.json for local overrides (not committed).
|
|
183
|
+
Use .orche.<preset>.json for named presets (e.g. .orche.mobile.json).
|
|
193
184
|
See .orche.example.json for the config format.
|
|
194
185
|
`);
|
|
195
186
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { loadConfig, listPresets, parsePresetFlag, ConfigError } from "./config.js";
|
|
6
|
+
|
|
7
|
+
function makeTmpDir(): string {
|
|
8
|
+
return mkdtempSync(path.join(os.tmpdir(), "orche-test-"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeJSON(dir: string, name: string, data: unknown): void {
|
|
12
|
+
writeFileSync(path.join(dir, name), JSON.stringify(data));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const baseConfig = { layout: { name: "main", command: "echo hi" } };
|
|
16
|
+
const mobileConfig = { layout: { name: "mobile", command: "echo mobile" } };
|
|
17
|
+
const debugConfig = { layout: { name: "debug", command: "echo debug" } };
|
|
18
|
+
|
|
19
|
+
describe("parsePresetFlag", () => {
|
|
20
|
+
it("parses -p flag", () => {
|
|
21
|
+
expect(parsePresetFlag(["-p", "mobile"])).toBe("mobile");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses --preset= flag", () => {
|
|
25
|
+
expect(parsePresetFlag(["--preset=debug"])).toBe("debug");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns undefined when no preset flag", () => {
|
|
29
|
+
expect(parsePresetFlag(["fix-auth", "--tmux"])).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns undefined when -p has no value", () => {
|
|
33
|
+
expect(parsePresetFlag(["-p"])).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("ignores -p when it is the last arg", () => {
|
|
37
|
+
expect(parsePresetFlag(["start", "-p"])).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("loadConfig", () => {
|
|
42
|
+
let tmp: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
tmp = makeTmpDir();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("loads .orche.json", () => {
|
|
53
|
+
writeJSON(tmp, ".orche.json", baseConfig);
|
|
54
|
+
const config = loadConfig(tmp);
|
|
55
|
+
expect(config.layout).toEqual(baseConfig.layout);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("prefers .orche.local.json over .orche.json", () => {
|
|
59
|
+
writeJSON(tmp, ".orche.json", baseConfig);
|
|
60
|
+
writeJSON(tmp, ".orche.local.json", mobileConfig);
|
|
61
|
+
const config = loadConfig(tmp);
|
|
62
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("throws ConfigError when no config found", () => {
|
|
66
|
+
expect(() => loadConfig(tmp)).toThrow(ConfigError);
|
|
67
|
+
expect(() => loadConfig(tmp)).toThrow(".orche.json");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("loads a preset config", () => {
|
|
71
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
72
|
+
const config = loadConfig(tmp, "mobile");
|
|
73
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws ConfigError for missing preset", () => {
|
|
77
|
+
expect(() => loadConfig(tmp, "nonexistent")).toThrow(ConfigError);
|
|
78
|
+
expect(() => loadConfig(tmp, "nonexistent")).toThrow('preset "nonexistent" not found');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lists available presets in error when preset not found", () => {
|
|
82
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
83
|
+
writeJSON(tmp, ".orche.debug.json", debugConfig);
|
|
84
|
+
try {
|
|
85
|
+
loadConfig(tmp, "nonexistent");
|
|
86
|
+
expect.unreachable("should have thrown");
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const msg = (err as Error).message;
|
|
89
|
+
expect(msg).toContain("available presets:");
|
|
90
|
+
expect(msg).toContain("mobile");
|
|
91
|
+
expect(msg).toContain("debug");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("shows 'no preset files found' when none exist", () => {
|
|
96
|
+
try {
|
|
97
|
+
loadConfig(tmp, "nonexistent");
|
|
98
|
+
expect.unreachable("should have thrown");
|
|
99
|
+
} catch (err) {
|
|
100
|
+
expect((err as Error).message).toContain("no preset files found");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("preset flag ignores .orche.local.json", () => {
|
|
105
|
+
writeJSON(tmp, ".orche.local.json", baseConfig);
|
|
106
|
+
writeJSON(tmp, ".orche.mobile.json", mobileConfig);
|
|
107
|
+
const config = loadConfig(tmp, "mobile");
|
|
108
|
+
expect(config.layout).toEqual(mobileConfig.layout);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("listPresets", () => {
|
|
113
|
+
let tmp: string;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
tmp = makeTmpDir();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns empty array when no presets", () => {
|
|
124
|
+
expect(listPresets(tmp)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("lists preset names without .orche. prefix and .json suffix", () => {
|
|
128
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
129
|
+
writeJSON(tmp, ".orche.debug.json", {});
|
|
130
|
+
writeJSON(tmp, ".orche.test.json", {});
|
|
131
|
+
const presets = listPresets(tmp).sort();
|
|
132
|
+
expect(presets).toEqual(["debug", "mobile", "test"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("excludes .orche.json and .orche.local.json", () => {
|
|
136
|
+
writeJSON(tmp, ".orche.json", {});
|
|
137
|
+
writeJSON(tmp, ".orche.local.json", {});
|
|
138
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
139
|
+
expect(listPresets(tmp)).toEqual(["mobile"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ignores non-orche json files", () => {
|
|
143
|
+
writeJSON(tmp, "package.json", {});
|
|
144
|
+
writeJSON(tmp, "tsconfig.json", {});
|
|
145
|
+
writeJSON(tmp, ".orche.mobile.json", {});
|
|
146
|
+
expect(listPresets(tmp)).toEqual(["mobile"]);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AgentsConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const CONFIG_NAME = ".orche.json";
|
|
6
|
+
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
7
|
+
const CONFIG_PRESET_PREFIX = ".orche.";
|
|
8
|
+
const CONFIG_PRESET_SUFFIX = ".json";
|
|
9
|
+
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ConfigError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listPresets(cwd: string): string[] {
|
|
18
|
+
const files = readdirSync(cwd);
|
|
19
|
+
return files
|
|
20
|
+
.filter(f => f.startsWith(CONFIG_PRESET_PREFIX) && f.endsWith(CONFIG_PRESET_SUFFIX) && f !== CONFIG_NAME && f !== CONFIG_LOCAL_NAME)
|
|
21
|
+
.map(f => f.slice(CONFIG_PRESET_PREFIX.length, -CONFIG_PRESET_SUFFIX.length));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadConfig(cwd: string, preset?: string): AgentsConfig {
|
|
25
|
+
if (preset) {
|
|
26
|
+
const presetPath = path.join(cwd, `${CONFIG_PRESET_PREFIX}${preset}${CONFIG_PRESET_SUFFIX}`);
|
|
27
|
+
if (!existsSync(presetPath)) {
|
|
28
|
+
const available = listPresets(cwd);
|
|
29
|
+
const list = available.length > 0
|
|
30
|
+
? `\navailable presets: ${available.join(", ")}`
|
|
31
|
+
: "\nno preset files found in current directory";
|
|
32
|
+
throw new ConfigError(`preset "${preset}" not found (looked for ${CONFIG_PRESET_PREFIX}${preset}${CONFIG_PRESET_SUFFIX})${list}`);
|
|
33
|
+
}
|
|
34
|
+
const raw = readFileSync(presetPath, "utf-8");
|
|
35
|
+
return JSON.parse(raw) as AgentsConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
39
|
+
const configPath = path.join(cwd, CONFIG_NAME);
|
|
40
|
+
|
|
41
|
+
if (existsSync(localPath)) {
|
|
42
|
+
const raw = readFileSync(localPath, "utf-8");
|
|
43
|
+
return JSON.parse(raw) as AgentsConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(configPath)) {
|
|
47
|
+
throw new ConfigError(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
48
|
+
}
|
|
49
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
50
|
+
return JSON.parse(raw) as AgentsConfig;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parsePresetFlag(args: string[]): string | undefined {
|
|
54
|
+
const idx = args.indexOf("-p");
|
|
55
|
+
if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
|
|
56
|
+
const long = args.find(a => a.startsWith("--preset="));
|
|
57
|
+
if (long) return long.split("=")[1];
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|