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.
Files changed (72) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +167 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +48 -0
  6. package/dist/cli.js +531 -0
  7. package/dist/client.d.ts +66 -0
  8. package/dist/client.js +142 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +123 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +403 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +198 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/prepare.d.ts +74 -0
  22. package/dist/commands/prepare.js +217 -0
  23. package/dist/commands/propose.d.ts +69 -0
  24. package/dist/commands/propose.js +284 -0
  25. package/dist/commands/repositories.d.ts +18 -0
  26. package/dist/commands/repositories.js +185 -0
  27. package/dist/commands/session.d.ts +35 -0
  28. package/dist/commands/session.js +118 -0
  29. package/dist/commands/setup.d.ts +98 -0
  30. package/dist/commands/setup.js +1600 -0
  31. package/dist/commands/status.d.ts +51 -0
  32. package/dist/commands/status.js +542 -0
  33. package/dist/commands/touch-map.d.ts +42 -0
  34. package/dist/commands/touch-map.js +251 -0
  35. package/dist/commands/whoami.d.ts +8 -0
  36. package/dist/commands/whoami.js +80 -0
  37. package/dist/conventions.d.ts +77 -0
  38. package/dist/conventions.js +183 -0
  39. package/dist/copy.d.ts +224 -0
  40. package/dist/copy.js +641 -0
  41. package/dist/currency.d.ts +31 -0
  42. package/dist/currency.js +72 -0
  43. package/dist/desktop-config.d.ts +85 -0
  44. package/dist/desktop-config.js +217 -0
  45. package/dist/gh.d.ts +80 -0
  46. package/dist/gh.js +188 -0
  47. package/dist/git.d.ts +91 -0
  48. package/dist/git.js +226 -0
  49. package/dist/legacy.d.ts +41 -0
  50. package/dist/legacy.js +143 -0
  51. package/dist/local-time.d.ts +66 -0
  52. package/dist/local-time.js +84 -0
  53. package/dist/markers.d.ts +76 -0
  54. package/dist/markers.js +125 -0
  55. package/dist/mcp-config.d.ts +109 -0
  56. package/dist/mcp-config.js +234 -0
  57. package/dist/release.d.ts +55 -0
  58. package/dist/release.js +67 -0
  59. package/dist/repository.d.ts +8 -0
  60. package/dist/repository.js +32 -0
  61. package/dist/seals.d.ts +48 -0
  62. package/dist/seals.js +112 -0
  63. package/dist/session.d.ts +84 -0
  64. package/dist/session.js +135 -0
  65. package/dist/store.d.ts +108 -0
  66. package/dist/store.js +237 -0
  67. package/dist/touch-map.d.ts +241 -0
  68. package/dist/touch-map.js +487 -0
  69. package/dist/wire.d.ts +674 -0
  70. package/dist/wire.js +20 -0
  71. package/package.json +19 -10
  72. package/bin/balladeer.js +0 -161
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which of a promise's scope markers no longer name anything in this checkout.
3
+ *
4
+ * A rename is the ordinary way a promise stops being retrievable. Somebody
5
+ * moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
6
+ * working, the check keeps passing, and the promise about it quietly stops
7
+ * overlapping any path an agent will ever name. Nothing on the server can see
8
+ * that: Balladeer holds no token for the repository and never reads a file, so
9
+ * the only place the fact exists is the working tree in front of the runner.
10
+ *
11
+ * So it is observed here, locally, and reported as attention rather than
12
+ * repaired. A marker is a piece of approved meaning; moving one is a semantic
13
+ * revision a named person makes, and a command that rewrote markers because a
14
+ * file moved would be editing what somebody agreed to on the strength of a
15
+ * `git mv`.
16
+ *
17
+ * What leaves this machine is nothing. The observation is printed where the
18
+ * person ran the command.
19
+ */
20
+ /** How many missing markers one run reports, and how many it will even look at. */
21
+ export declare const MARKER_OBSERVATION_LIMITS: {
22
+ /** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
23
+ readonly maxChecked: 500;
24
+ /** Promises named in the printed row. The rest are counted, not listed. */
25
+ readonly maxNamed: 5;
26
+ };
27
+ /**
28
+ * A marker is a path when it names one.
29
+ *
30
+ * The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
31
+ * Only the first can be missing from a checkout; the second is a topic, and
32
+ * reporting it as a missing file would tell a person to go looking for
33
+ * something that was never a file.
34
+ */
35
+ export declare function isPathShapedMarker(marker: string): boolean;
36
+ export type MarkerObservation = Readonly<{
37
+ promiseId: string;
38
+ title: string;
39
+ /** The markers this promise names that are not in this checkout. */
40
+ missing: readonly string[];
41
+ }>;
42
+ export type MarkerObservationResult = Readonly<{
43
+ /** Promises with at least one missing path marker, in the order they were given. */
44
+ observations: readonly MarkerObservation[];
45
+ /** How many path-shaped markers were actually looked at. */
46
+ checked: number;
47
+ /** True when the run stopped at its ceiling and did not look at every marker. */
48
+ stoppedEarly: boolean;
49
+ }>;
50
+ export type MarkerSubject = Readonly<{
51
+ promiseId: string;
52
+ title: string;
53
+ surfaces?: readonly string[];
54
+ labels?: readonly string[];
55
+ }>;
56
+ /**
57
+ * Look for each promise's path markers in this working tree.
58
+ *
59
+ * Bounded twice over: it stops after `maxChecked` markers whatever remains, and
60
+ * it says that it stopped. A repository with a thousand promises must not turn
61
+ * `status` into a filesystem walk, and a run that silently examined the first
62
+ * few hundred would report "nothing missing" about a catalog it never read.
63
+ */
64
+ export declare function observeMissingMarkers(repositoryRoot: string, subjects: readonly MarkerSubject[], limits?: Readonly<{
65
+ maxChecked: number;
66
+ }>, exists?: (path: string) => boolean): MarkerObservationResult;
67
+ /**
68
+ * The attention row a person reads, or nothing at all when every marker is
69
+ * still there.
70
+ *
71
+ * It names what to do and who does it: a marker is approved meaning, so the
72
+ * remedy is a revision somebody agrees to, never an edit this command makes.
73
+ */
74
+ export declare function staleMarkerRow(result: MarkerObservationResult, limits?: Readonly<{
75
+ maxNamed: number;
76
+ }>): string | undefined;
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute, join, normalize, sep } from "node:path";
3
+ /**
4
+ * Which of a promise's scope markers no longer name anything in this checkout.
5
+ *
6
+ * A rename is the ordinary way a promise stops being retrievable. Somebody
7
+ * moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
8
+ * working, the check keeps passing, and the promise about it quietly stops
9
+ * overlapping any path an agent will ever name. Nothing on the server can see
10
+ * that: Balladeer holds no token for the repository and never reads a file, so
11
+ * the only place the fact exists is the working tree in front of the runner.
12
+ *
13
+ * So it is observed here, locally, and reported as attention rather than
14
+ * repaired. A marker is a piece of approved meaning; moving one is a semantic
15
+ * revision a named person makes, and a command that rewrote markers because a
16
+ * file moved would be editing what somebody agreed to on the strength of a
17
+ * `git mv`.
18
+ *
19
+ * What leaves this machine is nothing. The observation is printed where the
20
+ * person ran the command.
21
+ */
22
+ /** How many missing markers one run reports, and how many it will even look at. */
23
+ export const MARKER_OBSERVATION_LIMITS = {
24
+ /** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
25
+ maxChecked: 500,
26
+ /** Promises named in the printed row. The rest are counted, not listed. */
27
+ maxNamed: 5,
28
+ };
29
+ /**
30
+ * A marker is a path when it names one.
31
+ *
32
+ * The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
33
+ * Only the first can be missing from a checkout; the second is a topic, and
34
+ * reporting it as a missing file would tell a person to go looking for
35
+ * something that was never a file.
36
+ */
37
+ export function isPathShapedMarker(marker) {
38
+ const trimmed = marker.trim();
39
+ if (trimmed === "")
40
+ return false;
41
+ if (trimmed.includes("/"))
42
+ return true;
43
+ return /^[^/]+\.[A-Za-z0-9]{1,8}$/.test(trimmed);
44
+ }
45
+ /**
46
+ * A marker refused rather than resolved.
47
+ *
48
+ * A marker that escapes the repository root, or that arrives absolute, is not a
49
+ * marker this checkout can answer for. It is skipped rather than reported
50
+ * missing: telling a person a path is gone because the command would not look
51
+ * outside their repository is a false alarm about their catalog.
52
+ */
53
+ function resolveInside(root, marker) {
54
+ const cleaned = marker.trim().replaceAll("\\", "/");
55
+ if (cleaned === "" || isAbsolute(cleaned))
56
+ return undefined;
57
+ const full = normalize(join(root, cleaned));
58
+ const bounded = root.endsWith(sep) ? root : `${root}${sep}`;
59
+ return full === root || full.startsWith(bounded) ? full : undefined;
60
+ }
61
+ /**
62
+ * Look for each promise's path markers in this working tree.
63
+ *
64
+ * Bounded twice over: it stops after `maxChecked` markers whatever remains, and
65
+ * it says that it stopped. A repository with a thousand promises must not turn
66
+ * `status` into a filesystem walk, and a run that silently examined the first
67
+ * few hundred would report "nothing missing" about a catalog it never read.
68
+ */
69
+ export function observeMissingMarkers(repositoryRoot, subjects, limits = MARKER_OBSERVATION_LIMITS, exists = existsSync) {
70
+ const root = normalize(repositoryRoot);
71
+ const observations = [];
72
+ let checked = 0;
73
+ let stoppedEarly = false;
74
+ for (const subject of subjects) {
75
+ const missing = [];
76
+ for (const marker of [...(subject.surfaces ?? []), ...(subject.labels ?? [])]) {
77
+ if (!isPathShapedMarker(marker))
78
+ continue;
79
+ if (checked >= limits.maxChecked) {
80
+ stoppedEarly = true;
81
+ break;
82
+ }
83
+ checked += 1;
84
+ const resolved = resolveInside(root, marker);
85
+ if (resolved === undefined)
86
+ continue;
87
+ if (!exists(resolved))
88
+ missing.push(marker.trim());
89
+ }
90
+ if (missing.length > 0) {
91
+ observations.push({ promiseId: subject.promiseId, title: subject.title, missing });
92
+ }
93
+ if (stoppedEarly)
94
+ break;
95
+ }
96
+ return { observations, checked, stoppedEarly };
97
+ }
98
+ /**
99
+ * The attention row a person reads, or nothing at all when every marker is
100
+ * still there.
101
+ *
102
+ * It names what to do and who does it: a marker is approved meaning, so the
103
+ * remedy is a revision somebody agrees to, never an edit this command makes.
104
+ */
105
+ export function staleMarkerRow(result, limits = MARKER_OBSERVATION_LIMITS) {
106
+ const { observations } = result;
107
+ if (observations.length === 0)
108
+ return undefined;
109
+ const named = observations.slice(0, Math.max(0, limits.maxNamed));
110
+ const lines = named.map((observation) => ` ${observation.promiseId}: ${observation.missing.join(", ")} (${observation.title})`);
111
+ const rest = observations.length - named.length;
112
+ const head = observations.length === 1
113
+ ? " Attention: 1 promise names a path that is not in this checkout, so it will not be found by the paths a change touches."
114
+ : ` Attention: ${observations.length} promises name a path that is not in this checkout, so they will not be found by the paths a change touches.`;
115
+ const tail = [
116
+ ...(rest > 0 ? [` and ${rest} more.`] : []),
117
+ ...(result.stoppedEarly
118
+ ? [
119
+ ` This run looked at ${result.checked} markers and stopped there, so there may be more.`,
120
+ ]
121
+ : []),
122
+ " A marker is part of what somebody agreed to. Propose a revision and let its owner decide; nothing here changed it.",
123
+ ];
124
+ return [head, ...lines, ...tail].join("\n");
125
+ }
@@ -0,0 +1,109 @@
1
+ export declare const MCP_CONFIG_FILE = ".mcp.json";
2
+ export type StdioMcpEntry = Readonly<{
3
+ command: string;
4
+ args: readonly string[];
5
+ env?: Readonly<Record<string, string>>;
6
+ }>;
7
+ export type HttpMcpEntry = Readonly<{
8
+ url: string;
9
+ headers: Readonly<Record<string, string>>;
10
+ }>;
11
+ export type McpEntry = StdioMcpEntry | HttpMcpEntry;
12
+ export type MergeResult = Readonly<{
13
+ kind: "written";
14
+ changed: boolean;
15
+ }> | Readonly<{
16
+ kind: "refused";
17
+ reason: string;
18
+ block: string;
19
+ }>;
20
+ /**
21
+ * The entry this command writes: this command's own stdio forwarder, bound to
22
+ * one repository.
23
+ *
24
+ * The forwarder reads the bearer from the credential store, so the file carries
25
+ * no secret and the connection works the moment the step finishes. The remote
26
+ * form the web setup page hands out is the alternative, and it is deliberately
27
+ * not what this command writes: it reads the bearer from an environment
28
+ * variable, which means a person has to open a 600-mode file, copy a credential
29
+ * out of it by hand, and restart their agent host before the agent this step
30
+ * just called "connected" can do anything. That is a third human act in a
31
+ * product whose whole claim is that there are two. `isOurEntry` still
32
+ * recognises that form, so a team who set a host up in the browser and then ran
33
+ * this command is never told their own entry is foreign.
34
+ *
35
+ * Before publication the entry names this checkout's built entry point by
36
+ * absolute path, because there is no registry specifier that resolves to this
37
+ * program. After publication it names the release tag rather than a version, so
38
+ * the same block works on a machine that never had a checkout and so the host
39
+ * resolves the newest published command every time it starts the server. A
40
+ * version written here is a version the repository keeps running until somebody
41
+ * remembers to edit the file, which is how a customer ends up months behind.
42
+ *
43
+ * `npm_config_prefer_online` is the belt to that brace. npm already revalidates
44
+ * a dist-tag against the registry on every `npx` run, which was measured rather
45
+ * than assumed, but a machine whose npm configuration sets `prefer-offline` or
46
+ * `offline` would resolve the tag out of its own cache and never ask. Setting
47
+ * the variable in the entry's own environment overrides that for this one
48
+ * process, so the check happens on the machines where it would otherwise be
49
+ * skipped. With no network the cached copy still runs, and the server then tells
50
+ * it that it is behind.
51
+ */
52
+ export declare function stdioEntry(repositoryId: string, publishedVersion: string | null | undefined): StdioMcpEntry;
53
+ /**
54
+ * Whether an entry already under our key is provably ours.
55
+ *
56
+ * Three shapes count, and nothing else. The HTTP form is ours when its URL
57
+ * points at the very control plane this session paired with; a URL anywhere else
58
+ * under our key is somebody's redirect, not our server. The published stdio form
59
+ * is ours when it runs `npx` on `balladeer@latest`, which is what this command
60
+ * writes now, or on an exact `balladeer@<major>.<minor>.<patch>` release, which
61
+ * is what it wrote before and what a repository set up earlier still carries;
62
+ * `balladeer@github:someone/thing`, `balladeer@https://...` and
63
+ * `balladeer@file:../x` are all valid npm specifiers that a looser check would
64
+ * have accepted, and each of them runs somebody else's program under our name.
65
+ * Recognising the old pinned form is what lets `setup --refresh` repair a stale
66
+ * install in place rather than refusing it as foreign. The checkout stdio form
67
+ * is ours when it runs `node` on
68
+ * an absolute path whose file is this command's entry point, with `mcp` as its
69
+ * subcommand: a relative path, a different file name, or a different interpreter
70
+ * is somebody else's program and this command will not silently replace it.
71
+ *
72
+ * Recognising the HTTP form matters as much as refusing the impostor: it is the
73
+ * entry the web setup page already hands people, so a team that onboarded
74
+ * through the browser and then runs this command must not be told their own
75
+ * Balladeer entry is foreign.
76
+ */
77
+ export declare function isOurEntry(entry: unknown, controlPlane: string): boolean;
78
+ /**
79
+ * Atomic, and never through a path something could have pre-planted: the
80
+ * temporary file is opened with `wx` under a random name in the same directory,
81
+ * flushed, and renamed over the target.
82
+ *
83
+ * Exported because the Claude desktop configuration is written under the same
84
+ * rule and for a stronger reason: that file belongs to a running application,
85
+ * and a half-written one is a chat client that starts with no servers at all.
86
+ */
87
+ export declare function writeJsonAtomically(path: string, contents: string): void;
88
+ /**
89
+ * Merges our entry into the repository's `.mcp.json`, or refuses.
90
+ *
91
+ * An unparseable file is never overwritten: it is somebody's configuration and
92
+ * this command cannot tell what is in it. A foreign entry under our key is never
93
+ * replaced either; the block to merge is printed instead, so a person decides.
94
+ */
95
+ export declare function mergeMcpConfig(repositoryRoot: string, entry: McpEntry, controlPlane: string): MergeResult;
96
+ /**
97
+ * The repository id an entry already under our key names, or nothing.
98
+ *
99
+ * `setup --refresh` runs where a stale install is, and the machine it runs on
100
+ * may hold no credential for this repository: the person who paired it was a
101
+ * colleague, or the store was cleared. The entry itself still says which
102
+ * repository this checkout was connected for, and rewriting the entry around
103
+ * the id it already carries repairs the file without guessing.
104
+ */
105
+ export declare function entryRepositoryId(entry: unknown): string | undefined;
106
+ /** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
107
+ export declare function currentEntry(parsed: unknown): unknown;
108
+ /** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
109
+ export declare function readMcpConfig(repositoryRoot: string): unknown;
@@ -0,0 +1,234 @@
1
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
+ import { PUBLISHED_SPECIFIER, checkoutEntryPath } from "./release.js";
5
+ export const MCP_CONFIG_FILE = ".mcp.json";
6
+ /**
7
+ * The entry this command writes: this command's own stdio forwarder, bound to
8
+ * one repository.
9
+ *
10
+ * The forwarder reads the bearer from the credential store, so the file carries
11
+ * no secret and the connection works the moment the step finishes. The remote
12
+ * form the web setup page hands out is the alternative, and it is deliberately
13
+ * not what this command writes: it reads the bearer from an environment
14
+ * variable, which means a person has to open a 600-mode file, copy a credential
15
+ * out of it by hand, and restart their agent host before the agent this step
16
+ * just called "connected" can do anything. That is a third human act in a
17
+ * product whose whole claim is that there are two. `isOurEntry` still
18
+ * recognises that form, so a team who set a host up in the browser and then ran
19
+ * this command is never told their own entry is foreign.
20
+ *
21
+ * Before publication the entry names this checkout's built entry point by
22
+ * absolute path, because there is no registry specifier that resolves to this
23
+ * program. After publication it names the release tag rather than a version, so
24
+ * the same block works on a machine that never had a checkout and so the host
25
+ * resolves the newest published command every time it starts the server. A
26
+ * version written here is a version the repository keeps running until somebody
27
+ * remembers to edit the file, which is how a customer ends up months behind.
28
+ *
29
+ * `npm_config_prefer_online` is the belt to that brace. npm already revalidates
30
+ * a dist-tag against the registry on every `npx` run, which was measured rather
31
+ * than assumed, but a machine whose npm configuration sets `prefer-offline` or
32
+ * `offline` would resolve the tag out of its own cache and never ask. Setting
33
+ * the variable in the entry's own environment overrides that for this one
34
+ * process, so the check happens on the machines where it would otherwise be
35
+ * skipped. With no network the cached copy still runs, and the server then tells
36
+ * it that it is behind.
37
+ */
38
+ export function stdioEntry(repositoryId, publishedVersion) {
39
+ return publishedVersion === null || publishedVersion === undefined
40
+ ? { command: "node", args: [checkoutEntryPath(), "mcp", "--repository", repositoryId] }
41
+ : {
42
+ command: "npx",
43
+ args: ["-y", PUBLISHED_SPECIFIER, "mcp", "--repository", repositoryId],
44
+ env: { npm_config_prefer_online: "true" },
45
+ };
46
+ }
47
+ function sameOrigin(left, right) {
48
+ try {
49
+ return new URL(left).origin === new URL(right).origin;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** `node`, however the host spells the path to it, and nothing else. */
56
+ function isNodeCommand(command) {
57
+ const name = basename(command);
58
+ return name === "node" || name === "node.exe";
59
+ }
60
+ /**
61
+ * Whether an entry already under our key is provably ours.
62
+ *
63
+ * Three shapes count, and nothing else. The HTTP form is ours when its URL
64
+ * points at the very control plane this session paired with; a URL anywhere else
65
+ * under our key is somebody's redirect, not our server. The published stdio form
66
+ * is ours when it runs `npx` on `balladeer@latest`, which is what this command
67
+ * writes now, or on an exact `balladeer@<major>.<minor>.<patch>` release, which
68
+ * is what it wrote before and what a repository set up earlier still carries;
69
+ * `balladeer@github:someone/thing`, `balladeer@https://...` and
70
+ * `balladeer@file:../x` are all valid npm specifiers that a looser check would
71
+ * have accepted, and each of them runs somebody else's program under our name.
72
+ * Recognising the old pinned form is what lets `setup --refresh` repair a stale
73
+ * install in place rather than refusing it as foreign. The checkout stdio form
74
+ * is ours when it runs `node` on
75
+ * an absolute path whose file is this command's entry point, with `mcp` as its
76
+ * subcommand: a relative path, a different file name, or a different interpreter
77
+ * is somebody else's program and this command will not silently replace it.
78
+ *
79
+ * Recognising the HTTP form matters as much as refusing the impostor: it is the
80
+ * entry the web setup page already hands people, so a team that onboarded
81
+ * through the browser and then runs this command must not be told their own
82
+ * Balladeer entry is foreign.
83
+ */
84
+ export function isOurEntry(entry, controlPlane) {
85
+ if (entry === null || typeof entry !== "object")
86
+ return false;
87
+ const record = entry;
88
+ if (typeof record.url === "string")
89
+ return sameOrigin(record.url, controlPlane);
90
+ if (typeof record.command !== "string" || !Array.isArray(record.args))
91
+ return false;
92
+ const args = record.args;
93
+ if (record.command === "npx") {
94
+ const specifier = args[1];
95
+ return (typeof specifier === "string" &&
96
+ /^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(specifier) &&
97
+ args[2] === "mcp");
98
+ }
99
+ if (isNodeCommand(record.command)) {
100
+ const script = args[0];
101
+ return (typeof script === "string" &&
102
+ isAbsolute(script) &&
103
+ basename(script) === "cli.js" &&
104
+ args[1] === "mcp");
105
+ }
106
+ return false;
107
+ }
108
+ function renderBlock(entry) {
109
+ return JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2);
110
+ }
111
+ /**
112
+ * Atomic, and never through a path something could have pre-planted: the
113
+ * temporary file is opened with `wx` under a random name in the same directory,
114
+ * flushed, and renamed over the target.
115
+ *
116
+ * Exported because the Claude desktop configuration is written under the same
117
+ * rule and for a stronger reason: that file belongs to a running application,
118
+ * and a half-written one is a chat client that starts with no servers at all.
119
+ */
120
+ export function writeJsonAtomically(path, contents) {
121
+ const temporary = join(dirname(path), `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
122
+ let descriptor;
123
+ try {
124
+ descriptor = openSync(temporary, "wx", 0o644);
125
+ writeSync(descriptor, contents);
126
+ fsyncSync(descriptor);
127
+ closeSync(descriptor);
128
+ descriptor = undefined;
129
+ renameSync(temporary, path);
130
+ }
131
+ catch (error) {
132
+ if (descriptor !== undefined) {
133
+ try {
134
+ closeSync(descriptor);
135
+ }
136
+ catch {
137
+ // The unlink below is the cleanup that matters.
138
+ }
139
+ }
140
+ try {
141
+ unlinkSync(temporary);
142
+ }
143
+ catch {
144
+ // The temporary file may never have been created.
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+ /**
150
+ * Merges our entry into the repository's `.mcp.json`, or refuses.
151
+ *
152
+ * An unparseable file is never overwritten: it is somebody's configuration and
153
+ * this command cannot tell what is in it. A foreign entry under our key is never
154
+ * replaced either; the block to merge is printed instead, so a person decides.
155
+ */
156
+ export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
157
+ const path = join(repositoryRoot, MCP_CONFIG_FILE);
158
+ const block = renderBlock(entry);
159
+ let existing = "";
160
+ try {
161
+ existing = readFileSync(path, "utf8");
162
+ }
163
+ catch {
164
+ writeJsonAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
165
+ return { kind: "written", changed: true };
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: `The repository's ${MCP_CONFIG_FILE} could not be parsed; I did not change it.`,
179
+ block,
180
+ };
181
+ }
182
+ const servers = parsed.mcpServers !== null &&
183
+ typeof parsed.mcpServers === "object" &&
184
+ !Array.isArray(parsed.mcpServers)
185
+ ? { ...parsed.mcpServers }
186
+ : {};
187
+ const current = servers.balladeer;
188
+ if (current !== undefined && !isOurEntry(current, controlPlane)) {
189
+ return {
190
+ kind: "refused",
191
+ reason: `${MCP_CONFIG_FILE} already has an entry named balladeer that is not this workspace's server; I did not change it.`,
192
+ block,
193
+ };
194
+ }
195
+ servers.balladeer = entry;
196
+ const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
197
+ if (next === existing)
198
+ return { kind: "written", changed: false };
199
+ writeJsonAtomically(path, next);
200
+ return { kind: "written", changed: true };
201
+ }
202
+ /**
203
+ * The repository id an entry already under our key names, or nothing.
204
+ *
205
+ * `setup --refresh` runs where a stale install is, and the machine it runs on
206
+ * may hold no credential for this repository: the person who paired it was a
207
+ * colleague, or the store was cleared. The entry itself still says which
208
+ * repository this checkout was connected for, and rewriting the entry around
209
+ * the id it already carries repairs the file without guessing.
210
+ */
211
+ export function entryRepositoryId(entry) {
212
+ const args = entry?.args;
213
+ if (!Array.isArray(args))
214
+ return undefined;
215
+ const at = args.indexOf("--repository");
216
+ const value = at === -1 ? undefined : args[at + 1];
217
+ return typeof value === "string" && value.length > 0 ? value : undefined;
218
+ }
219
+ /** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
220
+ export function currentEntry(parsed) {
221
+ const servers = parsed?.mcpServers;
222
+ if (servers === null || typeof servers !== "object")
223
+ return undefined;
224
+ return servers.balladeer;
225
+ }
226
+ /** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
227
+ export function readMcpConfig(repositoryRoot) {
228
+ try {
229
+ return JSON.parse(readFileSync(join(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How to say "run this command again", in a way that actually runs it.
3
+ *
4
+ * The package name `balladeer` is taken on npm by an unrelated 0.0.x package
5
+ * that installs a different program, and until the publish happens the version
6
+ * this repository builds is not on the registry at all. So this command never
7
+ * writes a registry invocation as a literal. It asks the server, which reads
8
+ * the publication from one setting, and falls back to the checkout form
9
+ * whenever it has not been told otherwise. A sentence that names a command a
10
+ * person cannot run is worse than no sentence at all.
11
+ *
12
+ * What that setting no longer decides is the specifier. Nothing this product
13
+ * hands a customer pins a version any more: a pinned line is what left legacy
14
+ * customers running a months-old command, because the line in their config and
15
+ * the line on their screen both named the version that was newest the day they
16
+ * ran setup. The tag is resolved against the registry on every invocation, so a
17
+ * publish reaches a machine at its next session start.
18
+ */
19
+ /** What a person runs from the root of a built checkout. */
20
+ export declare const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
21
+ /** The specifier every published surface names, which is a tag and never a pin. */
22
+ export declare const PUBLISHED_SPECIFIER = "balladeer@latest";
23
+ /**
24
+ * How to invoke this command, with no subcommand attached.
25
+ *
26
+ * The parameter still says whether the package is published, because until it
27
+ * is, the tag resolves to somebody else's package. It no longer says which
28
+ * version to name.
29
+ */
30
+ export declare function commandPrefix(publishedVersion: string | null | undefined): string;
31
+ /** One runnable line. */
32
+ export declare function commandLine(publishedVersion: string | null | undefined, subcommand: string): string;
33
+ /**
34
+ * The absolute path an agent host runs to reach this checkout's built command.
35
+ *
36
+ * Resolved from this module rather than from the working directory, because the
37
+ * `.mcp.json` entry is executed later, by another program, from a directory
38
+ * this command never sees. It is the same path whether this file is running
39
+ * from `src` under a loader or from `dist` after a build, because both sit one
40
+ * directory below the package root.
41
+ */
42
+ export declare function checkoutEntryPath(): string;
43
+ /**
44
+ * Whether this copy is running out of a registry install rather than a
45
+ * checkout.
46
+ *
47
+ * `setup --refresh` has to write the same invocation the first setup wrote, and
48
+ * it has to do it without a server to ask: a repair runs where a stale install
49
+ * is, which may be a laptop that cannot reach the control plane at that moment.
50
+ * The honest local answer is how this copy was itself obtained. npm and npx
51
+ * both unpack a registry install under a `node_modules` directory, and a
52
+ * checkout of this repository never has one on the path to its own entry point,
53
+ * so the presence of that segment is what separates the two.
54
+ */
55
+ export declare function runningFromRegistryInstall(): boolean;
@@ -0,0 +1,67 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * How to say "run this command again", in a way that actually runs it.
5
+ *
6
+ * The package name `balladeer` is taken on npm by an unrelated 0.0.x package
7
+ * that installs a different program, and until the publish happens the version
8
+ * this repository builds is not on the registry at all. So this command never
9
+ * writes a registry invocation as a literal. It asks the server, which reads
10
+ * the publication from one setting, and falls back to the checkout form
11
+ * whenever it has not been told otherwise. A sentence that names a command a
12
+ * person cannot run is worse than no sentence at all.
13
+ *
14
+ * What that setting no longer decides is the specifier. Nothing this product
15
+ * hands a customer pins a version any more: a pinned line is what left legacy
16
+ * customers running a months-old command, because the line in their config and
17
+ * the line on their screen both named the version that was newest the day they
18
+ * ran setup. The tag is resolved against the registry on every invocation, so a
19
+ * publish reaches a machine at its next session start.
20
+ */
21
+ /** What a person runs from the root of a built checkout. */
22
+ export const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
23
+ /** The specifier every published surface names, which is a tag and never a pin. */
24
+ export const PUBLISHED_SPECIFIER = "balladeer@latest";
25
+ /**
26
+ * How to invoke this command, with no subcommand attached.
27
+ *
28
+ * The parameter still says whether the package is published, because until it
29
+ * is, the tag resolves to somebody else's package. It no longer says which
30
+ * version to name.
31
+ */
32
+ export function commandPrefix(publishedVersion) {
33
+ return publishedVersion === null || publishedVersion === undefined
34
+ ? CHECKOUT_COMMAND
35
+ : `npx -y ${PUBLISHED_SPECIFIER}`;
36
+ }
37
+ /** One runnable line. */
38
+ export function commandLine(publishedVersion, subcommand) {
39
+ return `${commandPrefix(publishedVersion)} ${subcommand}`;
40
+ }
41
+ /**
42
+ * The absolute path an agent host runs to reach this checkout's built command.
43
+ *
44
+ * Resolved from this module rather than from the working directory, because the
45
+ * `.mcp.json` entry is executed later, by another program, from a directory
46
+ * this command never sees. It is the same path whether this file is running
47
+ * from `src` under a loader or from `dist` after a build, because both sit one
48
+ * directory below the package root.
49
+ */
50
+ export function checkoutEntryPath() {
51
+ return join(resolve(dirname(fileURLToPath(import.meta.url)), ".."), "dist", "cli.js");
52
+ }
53
+ /**
54
+ * Whether this copy is running out of a registry install rather than a
55
+ * checkout.
56
+ *
57
+ * `setup --refresh` has to write the same invocation the first setup wrote, and
58
+ * it has to do it without a server to ask: a repair runs where a stale install
59
+ * is, which may be a laptop that cannot reach the control plane at that moment.
60
+ * The honest local answer is how this copy was itself obtained. npm and npx
61
+ * both unpack a registry install under a `node_modules` directory, and a
62
+ * checkout of this repository never has one on the path to its own entry point,
63
+ * so the presence of that segment is what separates the two.
64
+ */
65
+ export function runningFromRegistryInstall() {
66
+ return checkoutEntryPath().split(/[\\/]/).includes("node_modules");
67
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The two display hints, and nothing else. They exist so a person can recognise
3
+ * their own pairing on the approval page, and the server refuses anything that
4
+ * is not the shape of an owner/name or a hostname, so a hint can never be
5
+ * written to read like Balladeer's own assertion.
6
+ */
7
+ export declare function repositoryHint(cwd?: string): string;
8
+ export declare function hostHint(): string;