balladeer 0.0.5 → 1.0.1
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 +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +403 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -161
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this copy is the newest one, answered by the server on every call.
|
|
3
|
+
*
|
|
4
|
+
* Legacy customers ran a months-old command because nothing ever told them
|
|
5
|
+
* otherwise: the invocation in their config named a version, the version kept
|
|
6
|
+
* working, and a working thing announces nothing. So the server stamps what the
|
|
7
|
+
* newest published version is on every answer it gives a program, and this
|
|
8
|
+
* module is where the command notices.
|
|
9
|
+
*
|
|
10
|
+
* It is a nag, never a refusal. Refusing is the floor's job, and the floor is
|
|
11
|
+
* the server's to enforce; a copy that is merely behind still works, and a
|
|
12
|
+
* command that stopped working the day after a publish would be worse than the
|
|
13
|
+
* problem it fixes.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
|
|
16
|
+
export declare const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
|
|
17
|
+
export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
18
|
+
/**
|
|
19
|
+
* Reads the version signals off one response.
|
|
20
|
+
*
|
|
21
|
+
* The comparison is this command's own, against the number the server named.
|
|
22
|
+
* The server's sentence is used for the wording when it sent one, because only
|
|
23
|
+
* the server knows whether the registry form or the checkout form is the line a
|
|
24
|
+
* person can actually run, but the decision to say anything at all is made here
|
|
25
|
+
* against a version this program knows for certain: its own.
|
|
26
|
+
*/
|
|
27
|
+
export declare function noteServerVersion(headers: Headers): void;
|
|
28
|
+
/** The one line this run prints about itself, or nothing when it is current. */
|
|
29
|
+
export declare function updateNotice(): string | undefined;
|
|
30
|
+
/** For tests, and for a process that runs more than one command. */
|
|
31
|
+
export declare function resetUpdateNotice(): void;
|
package/dist/currency.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { CLI_VERSION } from "./wire.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether this copy is the newest one, answered by the server on every call.
|
|
4
|
+
*
|
|
5
|
+
* Legacy customers ran a months-old command because nothing ever told them
|
|
6
|
+
* otherwise: the invocation in their config named a version, the version kept
|
|
7
|
+
* working, and a working thing announces nothing. So the server stamps what the
|
|
8
|
+
* newest published version is on every answer it gives a program, and this
|
|
9
|
+
* module is where the command notices.
|
|
10
|
+
*
|
|
11
|
+
* It is a nag, never a refusal. Refusing is the floor's job, and the floor is
|
|
12
|
+
* the server's to enforce; a copy that is merely behind still works, and a
|
|
13
|
+
* command that stopped working the day after a publish would be worse than the
|
|
14
|
+
* problem it fixes.
|
|
15
|
+
*/
|
|
16
|
+
export const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
|
|
17
|
+
export const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
|
|
18
|
+
export const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
19
|
+
/**
|
|
20
|
+
* How much of the server's sentence may be printed. It is one line from the
|
|
21
|
+
* control plane this machine paired with, and a ceiling means a server that
|
|
22
|
+
* answers with pages of text cannot turn a terminal into its own output.
|
|
23
|
+
*/
|
|
24
|
+
const ADVICE_LIMIT = 300;
|
|
25
|
+
const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
26
|
+
let notice;
|
|
27
|
+
function older(left, right) {
|
|
28
|
+
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
29
|
+
for (let index = 0; index < 3; index += 1) {
|
|
30
|
+
const difference = a[index] - b[index];
|
|
31
|
+
if (difference !== 0)
|
|
32
|
+
return difference < 0;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** Control characters stripped, length bounded: a server writes words, not terminal control. */
|
|
37
|
+
function bounded(value) {
|
|
38
|
+
return value
|
|
39
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
40
|
+
.trim()
|
|
41
|
+
.slice(0, ADVICE_LIMIT);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads the version signals off one response.
|
|
45
|
+
*
|
|
46
|
+
* The comparison is this command's own, against the number the server named.
|
|
47
|
+
* The server's sentence is used for the wording when it sent one, because only
|
|
48
|
+
* the server knows whether the registry form or the checkout form is the line a
|
|
49
|
+
* person can actually run, but the decision to say anything at all is made here
|
|
50
|
+
* against a version this program knows for certain: its own.
|
|
51
|
+
*/
|
|
52
|
+
export function noteServerVersion(headers) {
|
|
53
|
+
const newest = headers.get(CLI_NEWEST_HEADER)?.trim();
|
|
54
|
+
if (newest === undefined || !VERSION.test(newest))
|
|
55
|
+
return;
|
|
56
|
+
if (!older(CLI_VERSION, newest))
|
|
57
|
+
return;
|
|
58
|
+
const advice = headers.get(CLI_ADVICE_HEADER);
|
|
59
|
+
const line = advice === null ? "" : bounded(advice);
|
|
60
|
+
notice =
|
|
61
|
+
line === ""
|
|
62
|
+
? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run setup --refresh, or restart this session, before continuing.`
|
|
63
|
+
: line;
|
|
64
|
+
}
|
|
65
|
+
/** The one line this run prints about itself, or nothing when it is current. */
|
|
66
|
+
export function updateNotice() {
|
|
67
|
+
return notice;
|
|
68
|
+
}
|
|
69
|
+
/** For tests, and for a process that runs more than one command. */
|
|
70
|
+
export function resetUpdateNotice() {
|
|
71
|
+
notice = undefined;
|
|
72
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type StdioMcpEntry } from "./mcp-config.js";
|
|
2
|
+
/** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
|
|
3
|
+
export declare const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
|
|
4
|
+
/**
|
|
5
|
+
* Where that file lives, or why this machine has no such place.
|
|
6
|
+
*
|
|
7
|
+
* Two platforms are documented and both are handled: macOS keeps it under
|
|
8
|
+
* `Library/Application Support/Claude`, Windows under `%APPDATA%\Claude`.
|
|
9
|
+
* Claude desktop is published for those two and no others, so every other
|
|
10
|
+
* platform is told so by name rather than written to on a guess. A person
|
|
11
|
+
* running an unofficial build, or a Windows install whose packaging redirects
|
|
12
|
+
* that directory, points `BALLADEER_CLAUDE_DESKTOP_CONFIG` at the file itself
|
|
13
|
+
* and this writes exactly there.
|
|
14
|
+
*/
|
|
15
|
+
export type DesktopLocation = Readonly<{
|
|
16
|
+
kind: "path";
|
|
17
|
+
path: string;
|
|
18
|
+
directory: string;
|
|
19
|
+
}> | Readonly<{
|
|
20
|
+
kind: "unsupported";
|
|
21
|
+
platform: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function desktopConfigLocation(environment?: NodeJS.ProcessEnv, platform?: string): DesktopLocation;
|
|
25
|
+
/**
|
|
26
|
+
* One key per repository, named after the repository.
|
|
27
|
+
*
|
|
28
|
+
* The desktop app has no notion of a project: every server in that file is
|
|
29
|
+
* loaded into every chat. A person who plans work in two repositories therefore
|
|
30
|
+
* has two of our servers running at once, and the only thing that tells them
|
|
31
|
+
* apart in the app's own connector list is the key. `balladeer-owner-name` is
|
|
32
|
+
* that name. A directory whose GitHub origin this command could not read has no
|
|
33
|
+
* name to use, so the key falls back to the repository's own id, which is unique
|
|
34
|
+
* by construction and still tells two entries apart.
|
|
35
|
+
*/
|
|
36
|
+
export declare function desktopServerKey(repositoryName: string, repositoryId: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* The entry the desktop app can actually start, which is not the one a coding
|
|
39
|
+
* agent gets.
|
|
40
|
+
*
|
|
41
|
+
* Claude desktop launches a stdio server from the application rather than from a
|
|
42
|
+
* login shell, with a minimal PATH carrying none of the places a developer's
|
|
43
|
+
* node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
|
|
44
|
+
* to nothing there, and what a person gets instead of a server is a line in a
|
|
45
|
+
* log file nobody opens. This names the interpreter running setup by its
|
|
46
|
+
* absolute path and this command's own entry point by its absolute path, so the
|
|
47
|
+
* entry needs no PATH at all.
|
|
48
|
+
*
|
|
49
|
+
* The environment is written for the same reason. The forwarder reads the bearer
|
|
50
|
+
* out of this machine's credential store, and where that store is depends on a
|
|
51
|
+
* variable the desktop app never inherits, so the resolved directory goes into
|
|
52
|
+
* the entry rather than being left to a shell that is not there. It is a path
|
|
53
|
+
* and never a secret: the credential itself stays in the store, mode 600.
|
|
54
|
+
*/
|
|
55
|
+
export declare function desktopStdioEntry(repositoryId: string, environment?: NodeJS.ProcessEnv, controlPlane?: string, nodePath?: string, entryPath?: string): StdioMcpEntry;
|
|
56
|
+
export type DesktopMergeResult = Readonly<{
|
|
57
|
+
kind: "written";
|
|
58
|
+
changed: boolean;
|
|
59
|
+
path: string;
|
|
60
|
+
key: string;
|
|
61
|
+
}> | Readonly<{
|
|
62
|
+
kind: "absent";
|
|
63
|
+
directory: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
}> | Readonly<{
|
|
66
|
+
kind: "refused";
|
|
67
|
+
reason: string;
|
|
68
|
+
block: string;
|
|
69
|
+
}>;
|
|
70
|
+
export declare function desktopBlock(key: string, entry: StdioMcpEntry): string;
|
|
71
|
+
/**
|
|
72
|
+
* Merges our entry into the desktop app's configuration, or refuses.
|
|
73
|
+
*
|
|
74
|
+
* Everything already in that file stays in it. Other people's servers are other
|
|
75
|
+
* people's servers, and so is every top-level key beside `mcpServers`, which is
|
|
76
|
+
* where the app keeps settings this command knows nothing about. An unparseable
|
|
77
|
+
* file is never overwritten, and a key of ours holding somebody else's server is
|
|
78
|
+
* never replaced: the block is printed and a person decides.
|
|
79
|
+
*
|
|
80
|
+
* The key is found by what the entry says rather than by what it is called. An
|
|
81
|
+
* entry of ours already bound to this repository is rewritten where it sits,
|
|
82
|
+
* whatever it was named, so a repository renamed on GitHub does not quietly
|
|
83
|
+
* acquire a second server that starts alongside the first.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mergeDesktopConfig(location: DesktopLocation, key: string, entry: StdioMcpEntry, repositoryId: string, controlPlane: string): DesktopMergeResult;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { entryRepositoryId, isOurEntry, writeJsonAtomically, } from "./mcp-config.js";
|
|
4
|
+
import { checkoutEntryPath } from "./release.js";
|
|
5
|
+
import { configHome } from "./store.js";
|
|
6
|
+
import { DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
7
|
+
/** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
|
|
8
|
+
export const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
|
|
9
|
+
export function desktopConfigLocation(environment = process.env, platform = process.platform) {
|
|
10
|
+
const override = environment.BALLADEER_CLAUDE_DESKTOP_CONFIG?.trim();
|
|
11
|
+
if (override !== undefined && override.length > 0) {
|
|
12
|
+
// An override that is not absolute would be resolved against whatever
|
|
13
|
+
// directory this command happens to have been started in, which is never
|
|
14
|
+
// the directory the person meant.
|
|
15
|
+
return isAbsolute(override)
|
|
16
|
+
? { kind: "path", path: override, directory: parentOf(override) }
|
|
17
|
+
: {
|
|
18
|
+
kind: "unsupported",
|
|
19
|
+
platform,
|
|
20
|
+
reason: `BALLADEER_CLAUDE_DESKTOP_CONFIG is set to ${override}, which is not an absolute path, so I did not guess what it meant.`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const home = homeOf(environment);
|
|
24
|
+
if (platform === "darwin") {
|
|
25
|
+
if (home === undefined)
|
|
26
|
+
return noHome(platform);
|
|
27
|
+
const directory = join(home, "Library", "Application Support", "Claude");
|
|
28
|
+
return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
|
|
29
|
+
}
|
|
30
|
+
if (platform === "win32") {
|
|
31
|
+
const appData = environment.APPDATA?.trim();
|
|
32
|
+
const roaming = appData !== undefined && appData.length > 0
|
|
33
|
+
? appData
|
|
34
|
+
: home === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: join(home, "AppData", "Roaming");
|
|
37
|
+
if (roaming === undefined)
|
|
38
|
+
return noHome(platform);
|
|
39
|
+
const directory = join(roaming, "Claude");
|
|
40
|
+
return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "unsupported",
|
|
44
|
+
platform,
|
|
45
|
+
reason: `Claude desktop is published for macOS and Windows, and this machine reports ${platform}, so there is no ${DESKTOP_CONFIG_FILE} here to write. If you run an unofficial build, set BALLADEER_CLAUDE_DESKTOP_CONFIG to the absolute path of its ${DESKTOP_CONFIG_FILE} and run this again.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The home directory this environment names, and never the one the operating
|
|
50
|
+
* system would name instead.
|
|
51
|
+
*
|
|
52
|
+
* Asking the OS whose home this is would be a second source, and it answers for
|
|
53
|
+
* the account the process runs under rather than for the environment it was
|
|
54
|
+
* handed. A command given a bounded environment on purpose, which is what the
|
|
55
|
+
* tests hand it and what a service account hands it, would reach past that
|
|
56
|
+
* environment and write into a real person's chat client. So an environment that
|
|
57
|
+
* names no home is an answer: this run cannot tell where Claude desktop's
|
|
58
|
+
* configuration is, and it says so rather than guessing.
|
|
59
|
+
*/
|
|
60
|
+
function homeOf(environment) {
|
|
61
|
+
for (const value of [environment.HOME, environment.USERPROFILE]) {
|
|
62
|
+
const trimmed = value?.trim();
|
|
63
|
+
if (trimmed !== undefined && trimmed.length > 0)
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function noHome(platform) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "unsupported",
|
|
71
|
+
platform,
|
|
72
|
+
reason: `This process was given no HOME, so I could not tell where Claude desktop keeps ${DESKTOP_CONFIG_FILE} and did not guess. Set BALLADEER_CLAUDE_DESKTOP_CONFIG to its absolute path, or run this from a shell that sets HOME.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function parentOf(path) {
|
|
76
|
+
const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
77
|
+
return at <= 0 ? path : path.slice(0, at);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One key per repository, named after the repository.
|
|
81
|
+
*
|
|
82
|
+
* The desktop app has no notion of a project: every server in that file is
|
|
83
|
+
* loaded into every chat. A person who plans work in two repositories therefore
|
|
84
|
+
* has two of our servers running at once, and the only thing that tells them
|
|
85
|
+
* apart in the app's own connector list is the key. `balladeer-owner-name` is
|
|
86
|
+
* that name. A directory whose GitHub origin this command could not read has no
|
|
87
|
+
* name to use, so the key falls back to the repository's own id, which is unique
|
|
88
|
+
* by construction and still tells two entries apart.
|
|
89
|
+
*/
|
|
90
|
+
export function desktopServerKey(repositoryName, repositoryId) {
|
|
91
|
+
const cleaned = repositoryName
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "");
|
|
95
|
+
const usable = cleaned.length > 0 && cleaned !== "unknown-unknown" ? cleaned : repositoryId.toLowerCase();
|
|
96
|
+
return `balladeer-${usable}`.slice(0, 100).replace(/-+$/, "");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The entry the desktop app can actually start, which is not the one a coding
|
|
100
|
+
* agent gets.
|
|
101
|
+
*
|
|
102
|
+
* Claude desktop launches a stdio server from the application rather than from a
|
|
103
|
+
* login shell, with a minimal PATH carrying none of the places a developer's
|
|
104
|
+
* node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
|
|
105
|
+
* to nothing there, and what a person gets instead of a server is a line in a
|
|
106
|
+
* log file nobody opens. This names the interpreter running setup by its
|
|
107
|
+
* absolute path and this command's own entry point by its absolute path, so the
|
|
108
|
+
* entry needs no PATH at all.
|
|
109
|
+
*
|
|
110
|
+
* The environment is written for the same reason. The forwarder reads the bearer
|
|
111
|
+
* out of this machine's credential store, and where that store is depends on a
|
|
112
|
+
* variable the desktop app never inherits, so the resolved directory goes into
|
|
113
|
+
* the entry rather than being left to a shell that is not there. It is a path
|
|
114
|
+
* and never a secret: the credential itself stays in the store, mode 600.
|
|
115
|
+
*/
|
|
116
|
+
export function desktopStdioEntry(repositoryId, environment = process.env, controlPlane, nodePath = process.execPath, entryPath = checkoutEntryPath()) {
|
|
117
|
+
const env = { BALLADEER_CONFIG_HOME: configHome(environment) };
|
|
118
|
+
// Only when it is not the default. A deployment nobody named is the one the
|
|
119
|
+
// forwarder already reaches on its own, and writing it down would pin an
|
|
120
|
+
// address that a later release moves.
|
|
121
|
+
if (controlPlane !== undefined && controlPlane !== DEFAULT_CONTROL_PLANE) {
|
|
122
|
+
env.BALLADEER_CONTROL_PLANE = controlPlane;
|
|
123
|
+
}
|
|
124
|
+
return { command: nodePath, args: [entryPath, "mcp", "--repository", repositoryId], env };
|
|
125
|
+
}
|
|
126
|
+
export function desktopBlock(key, entry) {
|
|
127
|
+
return JSON.stringify({ mcpServers: { [key]: entry } }, null, 2);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Merges our entry into the desktop app's configuration, or refuses.
|
|
131
|
+
*
|
|
132
|
+
* Everything already in that file stays in it. Other people's servers are other
|
|
133
|
+
* people's servers, and so is every top-level key beside `mcpServers`, which is
|
|
134
|
+
* where the app keeps settings this command knows nothing about. An unparseable
|
|
135
|
+
* file is never overwritten, and a key of ours holding somebody else's server is
|
|
136
|
+
* never replaced: the block is printed and a person decides.
|
|
137
|
+
*
|
|
138
|
+
* The key is found by what the entry says rather than by what it is called. An
|
|
139
|
+
* entry of ours already bound to this repository is rewritten where it sits,
|
|
140
|
+
* whatever it was named, so a repository renamed on GitHub does not quietly
|
|
141
|
+
* acquire a second server that starts alongside the first.
|
|
142
|
+
*/
|
|
143
|
+
export function mergeDesktopConfig(location, key, entry, repositoryId, controlPlane) {
|
|
144
|
+
const block = desktopBlock(key, entry);
|
|
145
|
+
if (location.kind === "unsupported") {
|
|
146
|
+
return { kind: "refused", reason: location.reason, block };
|
|
147
|
+
}
|
|
148
|
+
// The app creates its own directory the first time it runs. Creating one it
|
|
149
|
+
// never made would leave a configuration file behind for an application that
|
|
150
|
+
// is not installed, and the person would read "connected" about a chat client
|
|
151
|
+
// they do not have.
|
|
152
|
+
if (!existsSync(location.directory)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "absent",
|
|
155
|
+
directory: location.directory,
|
|
156
|
+
reason: `Claude desktop was not found on this machine: ${location.directory} does not exist, so I wrote no ${DESKTOP_CONFIG_FILE}. Install Claude desktop and open it once, then run this again.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
let existing = "";
|
|
160
|
+
try {
|
|
161
|
+
existing = readFileSync(location.path, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
writeJsonAtomically(location.path, `${block}\n`);
|
|
165
|
+
return { kind: "written", changed: true, path: location.path, key };
|
|
166
|
+
}
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
const value = JSON.parse(existing);
|
|
170
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
171
|
+
throw new Error("not an object");
|
|
172
|
+
}
|
|
173
|
+
parsed = value;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {
|
|
177
|
+
kind: "refused",
|
|
178
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} could not be parsed; I did not change it.`,
|
|
179
|
+
block,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const raw = parsed.mcpServers;
|
|
183
|
+
if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
|
|
184
|
+
return {
|
|
185
|
+
kind: "refused",
|
|
186
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} has an mcpServers value that is not a set of servers; I did not change it.`,
|
|
187
|
+
block,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const servers = raw === undefined ? {} : { ...raw };
|
|
191
|
+
const target = ourKeyFor(servers, repositoryId, controlPlane) ?? key;
|
|
192
|
+
const current = servers[target];
|
|
193
|
+
if (current !== undefined && !isOurEntry(current, controlPlane)) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "refused",
|
|
196
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} already has an entry named ${target} that is not this workspace's server; I did not change it.`,
|
|
197
|
+
block: desktopBlock(target, entry),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
servers[target] = entry;
|
|
201
|
+
const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
|
|
202
|
+
if (next === existing) {
|
|
203
|
+
return { kind: "written", changed: false, path: location.path, key: target };
|
|
204
|
+
}
|
|
205
|
+
writeJsonAtomically(location.path, next);
|
|
206
|
+
return { kind: "written", changed: true, path: location.path, key: target };
|
|
207
|
+
}
|
|
208
|
+
/** The key already carrying our server for this repository, whatever it is called. */
|
|
209
|
+
function ourKeyFor(servers, repositoryId, controlPlane) {
|
|
210
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
211
|
+
if (!isOurEntry(value, controlPlane))
|
|
212
|
+
continue;
|
|
213
|
+
if (entryRepositoryId(value) === repositoryId)
|
|
214
|
+
return name;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
package/dist/gh.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type CommandResult = Readonly<{
|
|
2
|
+
ok: boolean;
|
|
3
|
+
code: number | null;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function runCommand(file: string, args: readonly string[], options?: Readonly<{
|
|
8
|
+
cwd?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* The whole environment the child gets, when the caller needs to name one.
|
|
12
|
+
* Left out, the child inherits this process's, which is what every other
|
|
13
|
+
* call here wants.
|
|
14
|
+
*/
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
}>): Promise<CommandResult>;
|
|
17
|
+
export type RepositoryFacts = Readonly<{
|
|
18
|
+
githubRepositoryId: string;
|
|
19
|
+
githubOwnerId: string;
|
|
20
|
+
defaultBranch: string;
|
|
21
|
+
/** Whether the person can push to, or administer, this repository. */
|
|
22
|
+
canWrite: boolean;
|
|
23
|
+
/** True when the facts came from an authenticated read. */
|
|
24
|
+
authenticated: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
export type RepositoryLookup = Readonly<{
|
|
27
|
+
kind: "found";
|
|
28
|
+
facts: RepositoryFacts;
|
|
29
|
+
}>
|
|
30
|
+
/**
|
|
31
|
+
* Why the read did not happen, and what the person does about it. The two
|
|
32
|
+
* travel together because they came apart once: a developer with no `gh`
|
|
33
|
+
* installed at all was told to run `gh auth login`, which is a command they do
|
|
34
|
+
* not have.
|
|
35
|
+
*/
|
|
36
|
+
| Readonly<{
|
|
37
|
+
kind: "unavailable";
|
|
38
|
+
reason: string;
|
|
39
|
+
next: string;
|
|
40
|
+
}>;
|
|
41
|
+
export declare function ghAvailable(): Promise<boolean>;
|
|
42
|
+
export declare function ghSignedIn(): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Reads a repository's immutable ids, its default branch, and what the person
|
|
45
|
+
* may do with it.
|
|
46
|
+
*
|
|
47
|
+
* The unauthenticated read is deliberately NOT a fallback for enrolling. It
|
|
48
|
+
* proves nothing about who is asking, so a workspace could otherwise end up
|
|
49
|
+
* holding an enrollment, a CI identity, and eventually a protection claim about
|
|
50
|
+
* a repository nobody in it controls. It exists only so the command can render
|
|
51
|
+
* the do-it-by-hand block with real values.
|
|
52
|
+
*/
|
|
53
|
+
export declare function readRepositoryFacts(repository: string): Promise<RepositoryLookup>;
|
|
54
|
+
/** The public read, for rendering the fallback block only. */
|
|
55
|
+
export declare function readPublicRepositoryFacts(repository: string): Promise<RepositoryLookup>;
|
|
56
|
+
/**
|
|
57
|
+
* What this machine's own GitHub access can see, so an agent has a list to
|
|
58
|
+
* offer instead of asking a person to type repository names from memory.
|
|
59
|
+
*
|
|
60
|
+
* Read through the person's own `gh`, exactly as every other GitHub read here
|
|
61
|
+
* is: Balladeer holds no GitHub token and this list never leaves the machine
|
|
62
|
+
* unless a person picks something off it. Archived repositories are dropped,
|
|
63
|
+
* because enrolling one records a claim about a repository nobody is going to
|
|
64
|
+
* push to again.
|
|
65
|
+
*/
|
|
66
|
+
export type VisibleRepository = Readonly<{
|
|
67
|
+
repository: string;
|
|
68
|
+
canWrite: boolean;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* The repositories one GitHub account or organization holds, newest first.
|
|
72
|
+
*
|
|
73
|
+
* `owner` empty means the signed-in account's own. A refusal is an empty list
|
|
74
|
+
* rather than an error: this is a convenience for offering a choice, and a
|
|
75
|
+
* person who cannot list an organization can still name a repository by hand.
|
|
76
|
+
*/
|
|
77
|
+
export declare function listVisibleRepositories(owner: string | undefined, limit?: number): Promise<readonly VisibleRepository[]>;
|
|
78
|
+
/** The signed-in GitHub account's login, or nothing. */
|
|
79
|
+
export declare function ghLogin(): Promise<string | undefined>;
|
|
80
|
+
export declare function setRepositoryVariable(repository: string, name: string, value: string): Promise<CommandResult>;
|
package/dist/gh.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The person's own GitHub access, used through the tool they already signed in
|
|
4
|
+
* to. Balladeer holds no GitHub token and this command never sends one anywhere.
|
|
5
|
+
*
|
|
6
|
+
* Every call goes through `execFile` with an argument array, never a shell, so
|
|
7
|
+
* nothing here can be turned into a command by a repository name. Standard error
|
|
8
|
+
* is captured rather than inherited, because a `gh` failure has to become one
|
|
9
|
+
* plain sentence with a next action rather than provider noise in the middle of
|
|
10
|
+
* a step.
|
|
11
|
+
*/
|
|
12
|
+
const TIMEOUT_MS = 20_000;
|
|
13
|
+
export function runCommand(file, args, options = {}) {
|
|
14
|
+
return new Promise((done) => {
|
|
15
|
+
execFile(file, [...args], {
|
|
16
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
17
|
+
...(options.env === undefined ? {} : { env: { ...options.env } }),
|
|
18
|
+
timeout: options.timeoutMs ?? TIMEOUT_MS,
|
|
19
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
}, (error, stdout, stderr) => {
|
|
22
|
+
const code = error === null ? 0 : typeof error.code === "number" ? error.code : 1;
|
|
23
|
+
done({
|
|
24
|
+
ok: error === null,
|
|
25
|
+
code,
|
|
26
|
+
stdout: String(stdout),
|
|
27
|
+
// Bounded: a refusal is a sentence, never a page of output.
|
|
28
|
+
stderr: String(stderr).trim().slice(0, 500),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function readFacts(raw, authenticated) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const id = typeof parsed.id === "number" ? String(parsed.id) : undefined;
|
|
42
|
+
const ownerId = typeof parsed.owner?.id === "number" ? String(parsed.owner.id) : undefined;
|
|
43
|
+
const branch = typeof parsed.default_branch === "string" ? parsed.default_branch : undefined;
|
|
44
|
+
if (!id || !ownerId || !branch)
|
|
45
|
+
return undefined;
|
|
46
|
+
// GitHub returns `permissions` only for an authenticated request, so its
|
|
47
|
+
// absence is exactly the case where nothing was proved about the caller.
|
|
48
|
+
const canWrite = parsed.permissions?.admin === true || parsed.permissions?.push === true;
|
|
49
|
+
return {
|
|
50
|
+
githubRepositoryId: id,
|
|
51
|
+
githubOwnerId: ownerId,
|
|
52
|
+
defaultBranch: branch,
|
|
53
|
+
canWrite,
|
|
54
|
+
authenticated,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function ghAvailable() {
|
|
58
|
+
return (await runCommand("gh", ["--version"])).ok;
|
|
59
|
+
}
|
|
60
|
+
export async function ghSignedIn() {
|
|
61
|
+
return (await runCommand("gh", ["auth", "status"])).ok;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reads a repository's immutable ids, its default branch, and what the person
|
|
65
|
+
* may do with it.
|
|
66
|
+
*
|
|
67
|
+
* The unauthenticated read is deliberately NOT a fallback for enrolling. It
|
|
68
|
+
* proves nothing about who is asking, so a workspace could otherwise end up
|
|
69
|
+
* holding an enrollment, a CI identity, and eventually a protection claim about
|
|
70
|
+
* a repository nobody in it controls. It exists only so the command can render
|
|
71
|
+
* the do-it-by-hand block with real values.
|
|
72
|
+
*/
|
|
73
|
+
export async function readRepositoryFacts(repository) {
|
|
74
|
+
if (await ghAvailable()) {
|
|
75
|
+
if (!(await ghSignedIn())) {
|
|
76
|
+
return {
|
|
77
|
+
kind: "unavailable",
|
|
78
|
+
reason: "gh is installed but not signed in",
|
|
79
|
+
next: "Run `gh auth login`, then run this command again.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const answer = await runCommand("gh", ["api", `repos/${repository}`]);
|
|
83
|
+
if (answer.ok) {
|
|
84
|
+
const facts = readFacts(answer.stdout, true);
|
|
85
|
+
if (facts)
|
|
86
|
+
return { kind: "found", facts };
|
|
87
|
+
return {
|
|
88
|
+
kind: "unavailable",
|
|
89
|
+
reason: "GitHub's answer did not carry the repository's ids",
|
|
90
|
+
next: "Check that the name is right and that GitHub is reachable, then run this command again.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
kind: "unavailable",
|
|
95
|
+
reason: answer.stderr || `gh could not read repos/${repository}`,
|
|
96
|
+
next: `Check that ${repository} exists and that your GitHub account can see it, then run this command again.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: "unavailable",
|
|
101
|
+
reason: "the GitHub CLI is not installed",
|
|
102
|
+
next: "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The public read is a display convenience with no next action of its own: the
|
|
107
|
+
* caller is already printing the by-hand block, and this only fills its values.
|
|
108
|
+
*/
|
|
109
|
+
const PUBLIC_NEXT = "Add the repository by hand instead.";
|
|
110
|
+
/** The public read, for rendering the fallback block only. */
|
|
111
|
+
export async function readPublicRepositoryFacts(repository) {
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await fetch(`https://api.github.com/repos/${repository}`, {
|
|
115
|
+
headers: { accept: "application/vnd.github+json" },
|
|
116
|
+
redirect: "manual",
|
|
117
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { kind: "unavailable", reason: "GitHub could not be reached", next: PUBLIC_NEXT };
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
return {
|
|
125
|
+
kind: "unavailable",
|
|
126
|
+
reason: `GitHub answered ${response.status}`,
|
|
127
|
+
next: PUBLIC_NEXT,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const facts = readFacts(await response.text(), false);
|
|
131
|
+
return facts === undefined
|
|
132
|
+
? {
|
|
133
|
+
kind: "unavailable",
|
|
134
|
+
reason: "GitHub's answer did not carry the repository's ids",
|
|
135
|
+
next: PUBLIC_NEXT,
|
|
136
|
+
}
|
|
137
|
+
: { kind: "found", facts };
|
|
138
|
+
}
|
|
139
|
+
const WRITE_PERMISSIONS = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
|
|
140
|
+
function readRepositoryList(raw) {
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
if (!Array.isArray(parsed))
|
|
149
|
+
return [];
|
|
150
|
+
const rows = [];
|
|
151
|
+
for (const entry of parsed) {
|
|
152
|
+
const name = typeof entry?.nameWithOwner === "string" ? entry.nameWithOwner : undefined;
|
|
153
|
+
if (name === undefined || entry.isArchived === true)
|
|
154
|
+
continue;
|
|
155
|
+
const permission = typeof entry.viewerPermission === "string" ? entry.viewerPermission.toUpperCase() : "";
|
|
156
|
+
rows.push({ repository: name, canWrite: WRITE_PERMISSIONS.has(permission) });
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The repositories one GitHub account or organization holds, newest first.
|
|
162
|
+
*
|
|
163
|
+
* `owner` empty means the signed-in account's own. A refusal is an empty list
|
|
164
|
+
* rather than an error: this is a convenience for offering a choice, and a
|
|
165
|
+
* person who cannot list an organization can still name a repository by hand.
|
|
166
|
+
*/
|
|
167
|
+
export async function listVisibleRepositories(owner, limit = 100) {
|
|
168
|
+
const answer = await runCommand("gh", [
|
|
169
|
+
"repo",
|
|
170
|
+
"list",
|
|
171
|
+
...(owner === undefined ? [] : [owner]),
|
|
172
|
+
"--limit",
|
|
173
|
+
String(limit),
|
|
174
|
+
"--no-archived",
|
|
175
|
+
"--json",
|
|
176
|
+
"nameWithOwner,isArchived,viewerPermission",
|
|
177
|
+
]);
|
|
178
|
+
return answer.ok ? readRepositoryList(answer.stdout) : [];
|
|
179
|
+
}
|
|
180
|
+
/** The signed-in GitHub account's login, or nothing. */
|
|
181
|
+
export async function ghLogin() {
|
|
182
|
+
const answer = await runCommand("gh", ["api", "user", "--jq", ".login"]);
|
|
183
|
+
const login = answer.stdout.trim();
|
|
184
|
+
return answer.ok && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined;
|
|
185
|
+
}
|
|
186
|
+
export async function setRepositoryVariable(repository, name, value) {
|
|
187
|
+
return runCommand("gh", ["variable", "set", name, "--repo", repository, "--body", value]);
|
|
188
|
+
}
|