copillm 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/README.md +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Minimal interactive prompt helpers for the CLI. Reads stdin in raw mode
|
|
2
|
+
// when attached to a TTY; falls back to line mode otherwise. Always restores
|
|
3
|
+
// stdin state on completion (success, error, or abort) via try/finally.
|
|
4
|
+
//
|
|
5
|
+
// These helpers are intended for very short interactions (single-keypress
|
|
6
|
+
// y/n confirmation, single-keypress menu choice). Anything longer should use
|
|
7
|
+
// readline directly.
|
|
8
|
+
const CTRL_C = "\u0003";
|
|
9
|
+
const CTRL_D = "\u0004";
|
|
10
|
+
function getStdin() {
|
|
11
|
+
// Cast through unknown — node's stream types are wider than what we need.
|
|
12
|
+
return process.stdin;
|
|
13
|
+
}
|
|
14
|
+
function getStdout() {
|
|
15
|
+
return process.stdout;
|
|
16
|
+
}
|
|
17
|
+
async function readSingleKey() {
|
|
18
|
+
const stdin = getStdin();
|
|
19
|
+
if (!stdin.isTTY) {
|
|
20
|
+
throw new Error("Cannot read interactive input: stdin is not a TTY.");
|
|
21
|
+
}
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let settled = false;
|
|
24
|
+
const onData = (chunk) => {
|
|
25
|
+
if (settled) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
settled = true;
|
|
29
|
+
stdin.off("data", onData);
|
|
30
|
+
try {
|
|
31
|
+
stdin.setRawMode?.(false);
|
|
32
|
+
stdin.pause();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve(chunk);
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
stdin.setEncoding("utf8");
|
|
42
|
+
stdin.setRawMode?.(true);
|
|
43
|
+
stdin.resume();
|
|
44
|
+
stdin.once("data", onData);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
stdin.off("data", onData);
|
|
48
|
+
try {
|
|
49
|
+
stdin.setRawMode?.(false);
|
|
50
|
+
stdin.pause();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// best-effort restoration
|
|
54
|
+
}
|
|
55
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function isAbortKey(key) {
|
|
60
|
+
return key === CTRL_C || key === CTRL_D;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Prompt the user with a y/n question. Returns true for y/Y, false for n/N.
|
|
64
|
+
* Throws if the user sends Ctrl+C / Ctrl+D, or if stdin is not a TTY.
|
|
65
|
+
*/
|
|
66
|
+
export async function confirm(message) {
|
|
67
|
+
const stdout = getStdout();
|
|
68
|
+
stdout.write(`${message} [y/N] `);
|
|
69
|
+
let answer = "";
|
|
70
|
+
try {
|
|
71
|
+
answer = await readSingleKey();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
stdout.write("\n");
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const first = answer.charAt(0);
|
|
78
|
+
if (isAbortKey(first)) {
|
|
79
|
+
stdout.write("\n");
|
|
80
|
+
throw new Error("Interactive prompt aborted by user.");
|
|
81
|
+
}
|
|
82
|
+
const lowered = first.toLowerCase();
|
|
83
|
+
const isYes = lowered === "y";
|
|
84
|
+
stdout.write(`${isYes ? "y" : "n"}\n`);
|
|
85
|
+
return isYes;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Prompt the user with a single-keypress menu. Each option has a one-character
|
|
89
|
+
* `key` (case-insensitive match) shown in parens before its label.
|
|
90
|
+
*
|
|
91
|
+
* Throws if the user sends Ctrl+C / Ctrl+D, or if stdin is not a TTY. Retries
|
|
92
|
+
* silently on unrecognised keypresses (up to `maxAttempts` times).
|
|
93
|
+
*/
|
|
94
|
+
export async function choose(message, options, maxAttempts = 5) {
|
|
95
|
+
if (options.length === 0) {
|
|
96
|
+
throw new Error("choose() requires at least one option.");
|
|
97
|
+
}
|
|
98
|
+
const seenKeys = new Set();
|
|
99
|
+
for (const opt of options) {
|
|
100
|
+
if (opt.key.length !== 1) {
|
|
101
|
+
throw new Error(`choose() option keys must be a single character (got '${opt.key}').`);
|
|
102
|
+
}
|
|
103
|
+
const lowered = opt.key.toLowerCase();
|
|
104
|
+
if (seenKeys.has(lowered)) {
|
|
105
|
+
throw new Error(`choose() option keys must be unique (duplicate '${opt.key}').`);
|
|
106
|
+
}
|
|
107
|
+
seenKeys.add(lowered);
|
|
108
|
+
}
|
|
109
|
+
const stdout = getStdout();
|
|
110
|
+
const rendered = options.map((opt) => `(${opt.key.toLowerCase()})${opt.label}`).join(" / ");
|
|
111
|
+
stdout.write(`${message}\n ${rendered}: `);
|
|
112
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
113
|
+
let key = "";
|
|
114
|
+
try {
|
|
115
|
+
key = await readSingleKey();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
stdout.write("\n");
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
const first = key.charAt(0);
|
|
122
|
+
if (isAbortKey(first)) {
|
|
123
|
+
stdout.write("\n");
|
|
124
|
+
throw new Error("Interactive prompt aborted by user.");
|
|
125
|
+
}
|
|
126
|
+
const match = options.find((opt) => opt.key.toLowerCase() === first.toLowerCase());
|
|
127
|
+
if (match) {
|
|
128
|
+
stdout.write(`${match.key.toLowerCase()} (${match.label})\n`);
|
|
129
|
+
return match.value;
|
|
130
|
+
}
|
|
131
|
+
stdout.write("\n invalid choice — try again: ");
|
|
132
|
+
}
|
|
133
|
+
stdout.write("\n");
|
|
134
|
+
throw new Error(`No valid choice after ${maxAttempts} attempts.`);
|
|
135
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function claudeGatewayCachePath() {
|
|
5
|
+
return path.join(os.homedir(), ".claude", "cache", "gateway-models.json");
|
|
6
|
+
}
|
|
7
|
+
export function clearClaudeGatewayCache() {
|
|
8
|
+
const target = claudeGatewayCachePath();
|
|
9
|
+
if (!fs.existsSync(target)) {
|
|
10
|
+
return { cleared: false, path: target, reason: "not_present" };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
fs.unlinkSync(target);
|
|
14
|
+
return { cleared: true, path: target, reason: null };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const detail = error instanceof Error ? error.message : "unknown_error";
|
|
18
|
+
return { cleared: false, path: target, reason: detail };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function claudeSettingsPath() {
|
|
5
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
6
|
+
}
|
|
7
|
+
export function detectClaudeSettingsConflicts(launcherEnv, settingsPathOverride) {
|
|
8
|
+
const settingsPath = settingsPathOverride ?? claudeSettingsPath();
|
|
9
|
+
const empty = { settingsPath, exists: false, parseError: null, conflicts: [] };
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = fs.readFileSync(settingsPath, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === "ENOENT") {
|
|
16
|
+
return empty;
|
|
17
|
+
}
|
|
18
|
+
return { ...empty, exists: true, parseError: errMessage(error) };
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return { settingsPath, exists: true, parseError: errMessage(error), conflicts: [] };
|
|
26
|
+
}
|
|
27
|
+
const env = readEnvBlock(parsed);
|
|
28
|
+
if (!env) {
|
|
29
|
+
return { settingsPath, exists: true, parseError: null, conflicts: [] };
|
|
30
|
+
}
|
|
31
|
+
const conflicts = [];
|
|
32
|
+
for (const [key, launcherValue] of Object.entries(launcherEnv)) {
|
|
33
|
+
const settingsValue = env[key];
|
|
34
|
+
if (typeof settingsValue !== "string")
|
|
35
|
+
continue;
|
|
36
|
+
if (settingsValue === launcherValue)
|
|
37
|
+
continue;
|
|
38
|
+
conflicts.push({ key, settingsValue, launcherValue });
|
|
39
|
+
}
|
|
40
|
+
return { settingsPath, exists: true, parseError: null, conflicts };
|
|
41
|
+
}
|
|
42
|
+
export function formatSettingsConflictWarning(result) {
|
|
43
|
+
if (result.parseError !== null) {
|
|
44
|
+
return [
|
|
45
|
+
"",
|
|
46
|
+
"⚠ copillm could not inspect Claude Code's settings.json for env overrides.",
|
|
47
|
+
` File: ${result.settingsPath}`,
|
|
48
|
+
` Reason: ${result.parseError}`,
|
|
49
|
+
" If the file exists and sets `env` keys like ANTHROPIC_BASE_URL or ANTHROPIC_AUTH_TOKEN,",
|
|
50
|
+
" Claude Code will silently override copillm's values once launched. Inspect the file",
|
|
51
|
+
" manually (or fix the read/parse error above) so this check can run.",
|
|
52
|
+
""
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
if (result.conflicts.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
const lines = [];
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push("⚠ Claude Code settings.json overrides copillm's env vars.");
|
|
60
|
+
lines.push(` File: ${result.settingsPath}`);
|
|
61
|
+
lines.push(" Claude Code exports its settings.json `env` block into its own process environment,");
|
|
62
|
+
lines.push(" which takes precedence over values supplied by `copillm claude` or your shell.");
|
|
63
|
+
lines.push(" The following keys will silently override copillm's values:");
|
|
64
|
+
for (const conflict of result.conflicts) {
|
|
65
|
+
lines.push(` • ${conflict.key}`);
|
|
66
|
+
lines.push(` settings.json: ${conflict.settingsValue}`);
|
|
67
|
+
lines.push(` copillm value: ${conflict.launcherValue}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push(" Fix: remove these keys from the `env` block in the file above, then re-run.");
|
|
70
|
+
lines.push("");
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
function readEnvBlock(parsed) {
|
|
74
|
+
if (!parsed || typeof parsed !== "object")
|
|
75
|
+
return null;
|
|
76
|
+
const env = parsed.env;
|
|
77
|
+
if (!env || typeof env !== "object" || Array.isArray(env))
|
|
78
|
+
return null;
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
function errMessage(error) {
|
|
82
|
+
if (error instanceof Error)
|
|
83
|
+
return error.message;
|
|
84
|
+
return String(error);
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computeAnthropicDefaults, readModelIdsFromCache } from "../models/anthropicDefaults.js";
|
|
2
|
+
export function buildClaudeEnvBundle(input) {
|
|
3
|
+
const defaults = input.defaults ?? computeAnthropicDefaults(readModelIdsFromCache());
|
|
4
|
+
const enableGateway = input.enableGatewayDiscovery !== false;
|
|
5
|
+
const env = {
|
|
6
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}/anthropic`,
|
|
7
|
+
ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local"
|
|
8
|
+
};
|
|
9
|
+
const trailingNotes = [];
|
|
10
|
+
if (defaults.opus) {
|
|
11
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = defaults.opus;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
trailingNotes.push("no opus variant detected — set ANTHROPIC_DEFAULT_OPUS_MODEL manually");
|
|
15
|
+
}
|
|
16
|
+
if (defaults.sonnet) {
|
|
17
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = defaults.sonnet;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
trailingNotes.push("no sonnet variant detected — set ANTHROPIC_DEFAULT_SONNET_MODEL manually");
|
|
21
|
+
}
|
|
22
|
+
if (defaults.haiku) {
|
|
23
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = defaults.haiku;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
trailingNotes.push("no haiku variant detected — set ANTHROPIC_DEFAULT_HAIKU_MODEL manually");
|
|
27
|
+
}
|
|
28
|
+
if (enableGateway) {
|
|
29
|
+
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
30
|
+
}
|
|
31
|
+
return { env, inlineComments: {}, trailingNotes, defaults };
|
|
32
|
+
}
|
|
33
|
+
export function buildCodexEnvBundle(absHomeDir) {
|
|
34
|
+
return {
|
|
35
|
+
env: { CODEX_HOME: absHomeDir },
|
|
36
|
+
inlineComments: {},
|
|
37
|
+
trailingNotes: []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Pi has no environment-variable override for its config directory; it reads
|
|
42
|
+
* `~/.pi/agent/models.json` unconditionally. So this bundle is intentionally
|
|
43
|
+
* empty — the real configuration work happens in `generatePiHome()` writing
|
|
44
|
+
* that file. We expose the helper for symmetry with the other agents and to
|
|
45
|
+
* carry a trailing note explaining what to look at when debugging.
|
|
46
|
+
*/
|
|
47
|
+
export function buildPiEnvBundle(absMirrorDir) {
|
|
48
|
+
return {
|
|
49
|
+
env: {},
|
|
50
|
+
inlineComments: {},
|
|
51
|
+
trailingNotes: [
|
|
52
|
+
`pi reads ~/.pi/agent/models.json directly (no env var override).`,
|
|
53
|
+
`copillm regenerated it on \`copillm start\` and mirrored it at ${absMirrorDir}/models.json.`
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
|
+
import { getCopillmHome } from "../config/home.js";
|
|
5
|
+
import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
6
|
+
import { AgentConfigError, loadAgentConfig } from "../agentconfig/load.js";
|
|
7
|
+
import { applyAgentConfig, formatApplyNotes } from "../agentconfig/apply.js";
|
|
8
|
+
const SCAFFOLD_TOML = `# copillm agent config — one source of truth for instructions and MCP servers
|
|
9
|
+
# fanned out to each coding agent on \`copillm <agent>\` launch.
|
|
10
|
+
# See: https://github.com/jcjc-dev/copillm (plans/unified-booping-mango.md)
|
|
11
|
+
|
|
12
|
+
active_profile = "default"
|
|
13
|
+
|
|
14
|
+
[defaults.instructions]
|
|
15
|
+
body = ""
|
|
16
|
+
|
|
17
|
+
# Uncomment to add an MCP server visible to every profile:
|
|
18
|
+
# [defaults.mcp.servers.github]
|
|
19
|
+
# transport = "http"
|
|
20
|
+
# url = "https://api.githubcopilot.com/mcp/"
|
|
21
|
+
# headers = { Authorization = "Bearer \${GITHUB_TOKEN}" }
|
|
22
|
+
|
|
23
|
+
[profiles.default]
|
|
24
|
+
`;
|
|
25
|
+
export function registerConfigCommands(program) {
|
|
26
|
+
const config = program.command("config").description("Manage ~/.copillm/agent.toml (unified agent config)");
|
|
27
|
+
config
|
|
28
|
+
.command("init")
|
|
29
|
+
.description("Scaffold ~/.copillm/agent.toml with an empty default profile")
|
|
30
|
+
.option("--force", "Overwrite an existing agent.toml", false)
|
|
31
|
+
.action((opts) => {
|
|
32
|
+
const target = path.join(getCopillmHome(), "agent.toml");
|
|
33
|
+
if (fs.existsSync(target) && !opts.force) {
|
|
34
|
+
process.stderr.write(`${target} already exists; pass --force to overwrite.\n`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
ensureSecureDirectory(path.dirname(target));
|
|
38
|
+
writeFileSecureAtomic(target, SCAFFOLD_TOML, 0o600);
|
|
39
|
+
process.stdout.write(`Scaffolded ${target}\n`);
|
|
40
|
+
});
|
|
41
|
+
config
|
|
42
|
+
.command("show")
|
|
43
|
+
.description("Print the resolved profile (post-merge, post-env-expansion)")
|
|
44
|
+
.option("--profile <name>", "Resolve a specific profile instead of the active one")
|
|
45
|
+
.action((opts) => {
|
|
46
|
+
try {
|
|
47
|
+
const result = loadAgentConfig({ cwd: process.cwd(), profileOverride: opts.profile ?? null });
|
|
48
|
+
if (!result) {
|
|
49
|
+
process.stdout.write("No ~/.copillm/agent.toml or ./.copillm/agent.toml found.\n");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
process.stdout.write(`active profile: ${result.active}\n`);
|
|
53
|
+
process.stdout.write(`sources:\n`);
|
|
54
|
+
for (const src of result.sources) {
|
|
55
|
+
process.stdout.write(` - ${src.scope}: ${src.path}\n`);
|
|
56
|
+
}
|
|
57
|
+
process.stdout.write(`\nresolved:\n${JSON.stringify(result.resolved, null, 2)}\n`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
handleAgentConfigError(error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const profile = config.command("profile").description("Profile management");
|
|
64
|
+
profile
|
|
65
|
+
.command("list")
|
|
66
|
+
.description("List all profiles in the global agent.toml")
|
|
67
|
+
.action(() => {
|
|
68
|
+
const target = path.join(getCopillmHome(), "agent.toml");
|
|
69
|
+
if (!fs.existsSync(target)) {
|
|
70
|
+
process.stdout.write("No global agent.toml. Run `copillm config init` first.\n");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parseToml(fs.readFileSync(target, "utf8"));
|
|
75
|
+
const active = parsed.active_profile ?? "default";
|
|
76
|
+
const names = Object.keys(parsed.profiles ?? {});
|
|
77
|
+
if (names.length === 0) {
|
|
78
|
+
process.stdout.write("No profiles defined. Add [profiles.<name>] to agent.toml.\n");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
process.stdout.write(`${name === active ? "* " : " "}${name}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
handleAgentConfigError(error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
profile
|
|
90
|
+
.command("use <name>")
|
|
91
|
+
.description("Set active_profile in the global agent.toml")
|
|
92
|
+
.action((name) => {
|
|
93
|
+
const target = path.join(getCopillmHome(), "agent.toml");
|
|
94
|
+
if (!fs.existsSync(target)) {
|
|
95
|
+
process.stderr.write("No global agent.toml. Run `copillm config init` first.\n");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const raw = fs.readFileSync(target, "utf8");
|
|
100
|
+
const parsed = parseToml(raw);
|
|
101
|
+
if (!parsed.profiles || !(name in parsed.profiles)) {
|
|
102
|
+
process.stderr.write(`Profile "${name}" not found. Existing: ${Object.keys(parsed.profiles ?? {}).join(", ") || "(none)"}\n`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
parsed.active_profile = name;
|
|
106
|
+
writeFileSecureAtomic(target, stringifyToml(parsed), 0o600);
|
|
107
|
+
process.stdout.write(`active_profile = "${name}"\n`);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
handleAgentConfigError(error);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
config
|
|
114
|
+
.command("sync")
|
|
115
|
+
.description("Run fan-out without launching an agent (debug aid)")
|
|
116
|
+
.requiredOption("--agent <kind>", "codex | claude | pi | copilot")
|
|
117
|
+
.option("--profile <name>", "Override active profile for this run")
|
|
118
|
+
.action((opts) => {
|
|
119
|
+
const agent = opts.agent;
|
|
120
|
+
if (!["codex", "claude", "pi", "copilot"].includes(agent)) {
|
|
121
|
+
process.stderr.write(`Unknown agent kind "${opts.agent}".\n`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const result = applyAgentConfig({
|
|
126
|
+
agent,
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
profileOverride: opts.profile ?? null,
|
|
129
|
+
codexHomeDir: agent === "codex" ? path.join(getCopillmHome(), "codex") : undefined
|
|
130
|
+
});
|
|
131
|
+
for (const line of formatApplyNotes(result, agent)) {
|
|
132
|
+
process.stdout.write(`${line}\n`);
|
|
133
|
+
}
|
|
134
|
+
if (result.active === null) {
|
|
135
|
+
process.stdout.write("(no agent.toml — nothing to do)\n");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
handleAgentConfigError(error);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function handleAgentConfigError(error) {
|
|
144
|
+
if (error instanceof AgentConfigError) {
|
|
145
|
+
process.stderr.write(`copillm config: ${error.message}\n`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const AGENT_DISPLAY_NAMES = {
|
|
2
|
+
codex: "Codex CLI",
|
|
3
|
+
claude: "Claude Code",
|
|
4
|
+
pi: "pi coding agent"
|
|
5
|
+
};
|
|
6
|
+
export function renderEnvBlock(input) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
lines.push(`# ${AGENT_DISPLAY_NAMES[input.agent]} \u2192 copillm`);
|
|
9
|
+
for (const [key, value] of Object.entries(input.env)) {
|
|
10
|
+
const line = renderEnvLine(key, value, input.shell);
|
|
11
|
+
const comment = input.inlineComments?.[key];
|
|
12
|
+
lines.push(comment ? `${line} # ${comment}` : line);
|
|
13
|
+
}
|
|
14
|
+
if (input.trailingNotes) {
|
|
15
|
+
for (const note of input.trailingNotes) {
|
|
16
|
+
lines.push(`# ${note}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return lines.join("\n");
|
|
20
|
+
}
|
|
21
|
+
export function renderEnvLine(key, value, shell) {
|
|
22
|
+
switch (shell) {
|
|
23
|
+
case "sh":
|
|
24
|
+
return `export ${key}="${escapeForDoubleQuotes(value)}"`;
|
|
25
|
+
case "fish":
|
|
26
|
+
return `set -gx ${key} "${escapeForDoubleQuotes(value)}"`;
|
|
27
|
+
case "powershell":
|
|
28
|
+
return `$env:${key} = "${escapeForPowerShellDoubleQuotes(value)}"`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isShellSyntax(value) {
|
|
32
|
+
return value === "sh" || value === "fish" || value === "powershell";
|
|
33
|
+
}
|
|
34
|
+
function escapeForDoubleQuotes(value) {
|
|
35
|
+
return value
|
|
36
|
+
.replace(/\\/g, "\\\\")
|
|
37
|
+
.replace(/"/g, '\\"')
|
|
38
|
+
.replace(/\$/g, "\\$")
|
|
39
|
+
.replace(/`/g, "\\`");
|
|
40
|
+
}
|
|
41
|
+
function escapeForPowerShellDoubleQuotes(value) {
|
|
42
|
+
return value.replace(/`/g, "``").replace(/"/g, '`"').replace(/\$/g, "`$");
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveAgent } from "./resolveAgent.js";
|
|
3
|
+
export async function launchAgent(opts) {
|
|
4
|
+
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
5
|
+
let resolved;
|
|
6
|
+
try {
|
|
7
|
+
resolved = await resolveAgent(opts.agent, { pinnedSpec: opts.pinnedSpec, log });
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11
|
+
log(message);
|
|
12
|
+
log(installHint(opts.agent));
|
|
13
|
+
return 127;
|
|
14
|
+
}
|
|
15
|
+
log(resolved.displayLine);
|
|
16
|
+
const childEnv = { ...process.env, ...opts.env };
|
|
17
|
+
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved.binPath);
|
|
18
|
+
const child = spawn(resolved.binPath, opts.args, {
|
|
19
|
+
stdio: "inherit",
|
|
20
|
+
env: childEnv,
|
|
21
|
+
shell: useShell
|
|
22
|
+
});
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
child.once("error", reject);
|
|
25
|
+
child.once("exit", (code, signal) => {
|
|
26
|
+
if (signal) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(process.pid, signal);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// fall through
|
|
32
|
+
}
|
|
33
|
+
resolve(128);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
resolve(code ?? 0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function installHint(agent) {
|
|
41
|
+
if (agent === "codex") {
|
|
42
|
+
return [
|
|
43
|
+
"Hint: install Codex CLI manually with one of:",
|
|
44
|
+
" brew install codex",
|
|
45
|
+
" npm i -g @openai/codex",
|
|
46
|
+
" https://github.com/openai/codex/releases"
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
if (agent === "pi") {
|
|
50
|
+
return [
|
|
51
|
+
"Hint: install pi coding agent manually with:",
|
|
52
|
+
" npm i -g @earendil-works/pi-coding-agent"
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
"Hint: install Claude Code manually with:",
|
|
57
|
+
" npm i -g @anthropic-ai/claude-code"
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|