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,185 @@
1
+ import { ClientTooOldError, TransportError, request } from "../client.js";
2
+ import { ghAvailable, ghLogin, ghSignedIn, listVisibleRepositories } from "../gh.js";
3
+ import { commandLine } from "../release.js";
4
+ import { StoreError, findSession, readCredentials } from "../store.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import {} from "../wire.js";
7
+ /** How many to offer. A list nobody reads to the end is a list nobody chooses from. */
8
+ const LIMIT = 100;
9
+ function emit(options, step) {
10
+ if (options.json)
11
+ options.write(`${JSON.stringify(step)}\n`);
12
+ }
13
+ function say(options, text) {
14
+ if (!options.json)
15
+ options.write(`${text}\n`);
16
+ }
17
+ /**
18
+ * What this machine could add to Balladeer, and which of them are already in.
19
+ *
20
+ * It exists so the agent asking "which repositories shall I set up" has
21
+ * something to show. Setting one up is still `setup`, run in a checkout of it or
22
+ * named with `--repository`; this command changes nothing and sends nothing
23
+ * anywhere. The one network call it makes is to Balladeer, to find out which of
24
+ * these are already enrolled, and it is skipped entirely when no session is
25
+ * stored.
26
+ */
27
+ export async function runRepositories(options) {
28
+ const here = repositoryHint(options.cwd);
29
+ const known = here === "unknown/unknown" ? undefined : here;
30
+ if (!(await ghAvailable())) {
31
+ return withoutGh(options, known, "the GitHub CLI is not installed", "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again to see the whole list.");
32
+ }
33
+ if (!(await ghSignedIn())) {
34
+ return withoutGh(options, known, "gh is installed but not signed in", "Run `gh auth login`, then run this command again to see the whole list.");
35
+ }
36
+ // The account's own repositories, and the organization this checkout belongs
37
+ // to. A Didero engineer sitting in one of their own repositories wants the
38
+ // other eleven, and those belong to the organization rather than to them.
39
+ const owners = new Set([undefined]);
40
+ const hereOwner = known?.split("/")[0];
41
+ if (hereOwner !== undefined)
42
+ owners.add(hereOwner);
43
+ const login = await ghLogin();
44
+ if (login !== undefined &&
45
+ hereOwner !== undefined &&
46
+ login.toLowerCase() === hereOwner.toLowerCase()) {
47
+ owners.delete(hereOwner);
48
+ }
49
+ const seen = new Map();
50
+ for (const owner of owners) {
51
+ for (const found of await listVisibleRepositories(owner, LIMIT)) {
52
+ if (!seen.has(found.repository.toLowerCase())) {
53
+ seen.set(found.repository.toLowerCase(), found.canWrite);
54
+ }
55
+ }
56
+ }
57
+ if (known !== undefined && !seen.has(known.toLowerCase()))
58
+ seen.set(known.toLowerCase(), true);
59
+ const enrolled = await enrolledNames(options);
60
+ const rows = [...seen.keys()]
61
+ .map((name) => ({
62
+ repository: name,
63
+ here: known !== undefined && name === known.toLowerCase(),
64
+ ...(enrolled === undefined ? {} : { enrolled: enrolled.has(name) }),
65
+ }))
66
+ // The one you are standing in first, then the rest alphabetically, so the
67
+ // default is the first thing read rather than something to hunt for.
68
+ .sort((left, right) => left.here === right.here
69
+ ? left.repository.localeCompare(right.repository)
70
+ : left.here
71
+ ? -1
72
+ : 1);
73
+ if (rows.length === 0) {
74
+ say(options, "Your GitHub account can see no repositories to add.");
75
+ emit(options, {
76
+ step: "repositories",
77
+ source: "none",
78
+ workspaceKnown: enrolled !== undefined,
79
+ repositories: [],
80
+ });
81
+ return 0;
82
+ }
83
+ report(options, rows, enrolled !== undefined);
84
+ emit(options, {
85
+ step: "repositories",
86
+ source: "gh",
87
+ workspaceKnown: enrolled !== undefined,
88
+ repositories: rows,
89
+ });
90
+ return 0;
91
+ }
92
+ /** The listing a person reads, and the one line that sets any of them up. */
93
+ function report(options, rows, workspaceKnown) {
94
+ say(options, workspaceKnown
95
+ ? "Repositories your GitHub account can see. Ones already in Balladeer are marked."
96
+ : "Repositories your GitHub account can see. Nothing is stored for this Balladeer, so which of them are already added is unknown.");
97
+ say(options, "");
98
+ for (const row of rows) {
99
+ const marks = [
100
+ row.here ? "this directory" : undefined,
101
+ row.enrolled === true ? "already in Balladeer" : undefined,
102
+ ].filter((mark) => mark !== undefined);
103
+ say(options, ` ${row.repository}${marks.length === 0 ? "" : ` (${marks.join(", ")})`}`);
104
+ }
105
+ const addable = rows.filter((row) => row.enrolled !== true);
106
+ say(options, "");
107
+ if (addable.length === 0) {
108
+ say(options, "Every repository this machine can see is already in Balladeer.");
109
+ return;
110
+ }
111
+ say(options, "Add one, or several, with:");
112
+ say(options, ` ${commandLine(null, `setup ${addable
113
+ .slice(0, 2)
114
+ .map((row) => `--repository ${row.repository}`)
115
+ .join(" ")}`)}`);
116
+ say(options, "Connecting a repository's coding agent and its CI happens in a checkout of that repository, so run setup there for each one. Naming a repository from somewhere else adds it to the workspace and stops there, which is said again when it happens.");
117
+ }
118
+ /**
119
+ * Which repositories the workspace already holds, or nothing at all.
120
+ *
121
+ * Nothing rather than an empty set when there is no session or the read failed:
122
+ * an empty set would render every repository as addable, and an agent reading
123
+ * that would offer to add repositories the workspace already has.
124
+ */
125
+ async function enrolledNames(options) {
126
+ let session;
127
+ try {
128
+ session = findSession(readCredentials(options.environment), options.controlPlane);
129
+ }
130
+ catch (error) {
131
+ if (!(error instanceof StoreError))
132
+ throw error;
133
+ return undefined;
134
+ }
135
+ if (session === undefined)
136
+ return undefined;
137
+ try {
138
+ const state = await request(options.controlPlane, {
139
+ method: "GET",
140
+ path: "/api/setup/v1/state",
141
+ bearer: session.token,
142
+ });
143
+ return new Set(state.repositories
144
+ .filter((repository) => repository.status === "active")
145
+ .map((repository) => repository.displayName.toLowerCase()));
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ClientTooOldError || error instanceof TransportError)
149
+ return undefined;
150
+ return undefined;
151
+ }
152
+ }
153
+ /**
154
+ * The answer when `gh` cannot list anything.
155
+ *
156
+ * Still an answer rather than a refusal: this directory's own repository is a
157
+ * real thing an agent can offer, and telling somebody with no `gh` installed
158
+ * that there is nothing to add would be false.
159
+ */
160
+ function withoutGh(options, here, reason, next) {
161
+ if (here === undefined) {
162
+ say(options, `I could not list your repositories: ${reason}.`);
163
+ say(options, ` This directory has no GitHub origin remote either, so I have nothing to offer.`);
164
+ say(options, ` Next: ${next}`);
165
+ emit(options, {
166
+ step: "repositories",
167
+ source: "none",
168
+ workspaceKnown: false,
169
+ repositories: [],
170
+ reason,
171
+ });
172
+ return 0;
173
+ }
174
+ say(options, `I could not list your repositories: ${reason}.`);
175
+ say(options, ` This directory's origin remote is ${here}, which is the one setup adds by default.`);
176
+ say(options, ` Next: ${next}`);
177
+ emit(options, {
178
+ step: "repositories",
179
+ source: "origin",
180
+ workspaceKnown: false,
181
+ repositories: [{ repository: here.toLowerCase(), here: true }],
182
+ reason,
183
+ });
184
+ return 0;
185
+ }
@@ -0,0 +1,35 @@
1
+ export type SessionOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ /**
5
+ * Read the session out of the commit at HEAD and tell Balladeer which commit
6
+ * it wrote, instead of printing an id.
7
+ */
8
+ record: boolean;
9
+ /** Start a new session even though one is still current. */
10
+ fresh: boolean;
11
+ /** The repository whose connection to use, named as `owner/name`. */
12
+ repo?: string;
13
+ /** The repository whose connection to use, named by id, as `mcp` takes it. */
14
+ repository?: string;
15
+ environment: NodeJS.ProcessEnv;
16
+ cwd: string;
17
+ now?: string;
18
+ write: (text: string) => void;
19
+ }>;
20
+ /**
21
+ * The id that joins what an agent was told to what it then wrote.
22
+ *
23
+ * Retrieval happens before the commit exists, so nothing ties the two together
24
+ * on its own, and without the tie a red check on Tuesday cannot be read back
25
+ * against what Balladeer had said on Monday. The agent's own working session
26
+ * spans both: it reads the index under this id and writes the same id into the
27
+ * commit it produces.
28
+ *
29
+ * `balladeer session` prints the id and the exact line to write.
30
+ * `balladeer session --record` reads that line back out of the commit at HEAD
31
+ * and tells Balladeer which commit the session wrote. The commit message itself
32
+ * never leaves this machine: only the pair of a session id and a forty-
33
+ * character SHA is sent, and the SHA is a fact CI already reports.
34
+ */
35
+ export declare function runSession(options: SessionOptions): Promise<number>;
@@ -0,0 +1,118 @@
1
+ import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
2
+ import { headCommit } from "../git.js";
3
+ import { repositoryHint } from "../repository.js";
4
+ import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
5
+ import { StoreError, readCredentials } from "../store.js";
6
+ import {} from "../wire.js";
7
+ /**
8
+ * The id that joins what an agent was told to what it then wrote.
9
+ *
10
+ * Retrieval happens before the commit exists, so nothing ties the two together
11
+ * on its own, and without the tie a red check on Tuesday cannot be read back
12
+ * against what Balladeer had said on Monday. The agent's own working session
13
+ * spans both: it reads the index under this id and writes the same id into the
14
+ * commit it produces.
15
+ *
16
+ * `balladeer session` prints the id and the exact line to write.
17
+ * `balladeer session --record` reads that line back out of the commit at HEAD
18
+ * and tells Balladeer which commit the session wrote. The commit message itself
19
+ * never leaves this machine: only the pair of a session id and a forty-
20
+ * character SHA is sent, and the SHA is a fact CI already reports.
21
+ */
22
+ export async function runSession(options) {
23
+ const emit = (step) => {
24
+ if (options.json)
25
+ options.write(`${JSON.stringify(step)}\n`);
26
+ };
27
+ const say = (text) => {
28
+ if (!options.json)
29
+ options.write(`${text}\n`);
30
+ };
31
+ const fail = (reason, message, exitCode) => {
32
+ if (options.json)
33
+ emit({ step: "error", reason, message, changed: false, exitCode });
34
+ else
35
+ options.write(`${message}\n`);
36
+ return exitCode;
37
+ };
38
+ let credentials;
39
+ try {
40
+ credentials = readCredentials(options.environment);
41
+ }
42
+ catch (error) {
43
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
44
+ }
45
+ const here = options.repo ?? repositoryHint(options.cwd);
46
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
47
+ if (selection.kind === "refused") {
48
+ const message = selection.missingFor === undefined
49
+ ? selection.reason
50
+ : noAgentCredentialSentence(selection.missingFor);
51
+ return fail("no_agent_credential_on_this_machine", message, 4);
52
+ }
53
+ const agent = selection.agent;
54
+ if (options.record)
55
+ return recordCommit(options, agent, emit, say, fail);
56
+ let session;
57
+ try {
58
+ session = currentSession({
59
+ repository: agent.repositoryId,
60
+ now: options.now ?? new Date().toISOString(),
61
+ ...(options.fresh ? { forceNew: true } : {}),
62
+ environment: options.environment,
63
+ });
64
+ }
65
+ catch (error) {
66
+ return fail(error instanceof StoreError ? error.code : "session_store_unwritable", error instanceof StoreError ? error.message : String(error), 4);
67
+ }
68
+ const trailer = agentSessionTrailerLine(session.sessionId);
69
+ emit({
70
+ step: "session",
71
+ sessionId: session.sessionId,
72
+ startedAt: session.startedAt,
73
+ minted: session.minted,
74
+ trailer,
75
+ changed: session.minted,
76
+ });
77
+ say(session.sessionId);
78
+ say("");
79
+ say(session.minted
80
+ ? "This is a new session. Pass it to every promise read you make in this repository."
81
+ : "This session is already open. Pass it to every promise read you make in this repository, and use --new to start a different one.");
82
+ say(`Then write this line into the commit or pull-request body you produce:`);
83
+ say("");
84
+ say(` ${trailer}`);
85
+ say("");
86
+ say(`and run \`balladeer session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
87
+ return 0;
88
+ }
89
+ async function recordCommit(options, agent, emit, say, fail) {
90
+ const head = await headCommit(options.cwd);
91
+ if (head === undefined) {
92
+ return fail("no_commit_here", "There is no commit to record: this directory is not a git checkout, or it has no commits yet.", 4);
93
+ }
94
+ const sessionId = readAgentSessionTrailer(head.message);
95
+ if (sessionId === undefined) {
96
+ // Said in full rather than as a code. The likeliest reader of this line is
97
+ // an agent that forgot the trailer, and the remedy is one line it can add
98
+ // to the commit it just made.
99
+ return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id balladeer session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
100
+ }
101
+ const call = await callAgentTool(agent, "record_session_commit", {
102
+ session: sessionId,
103
+ commitSha: head.sha,
104
+ });
105
+ if (call.kind !== "result") {
106
+ return fail(call.kind === "tool_refusal" ? "refused" : call.kind, call.kind === "tool_refusal"
107
+ ? call.text
108
+ : `Balladeer could not record this commit (${call.kind}). Nothing was recorded, and running this again after the connection is back is safe: the same pair recorded twice is recorded once.`, 5);
109
+ }
110
+ emit({
111
+ step: "session_commit",
112
+ sessionId,
113
+ commitSha: head.sha,
114
+ changed: true,
115
+ });
116
+ say(`Recorded ${head.sha.slice(0, 12)} as written by this session.`);
117
+ return 0;
118
+ }
@@ -0,0 +1,98 @@
1
+ export type SetupOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ wait: boolean;
5
+ repo?: string;
6
+ createWorkspace?: string;
7
+ /**
8
+ * Every repository this run was told to set up, in the order they were named.
9
+ *
10
+ * One of them is this run's own: the steps after the enrollment write files
11
+ * and push a branch, and they can only do that to the working tree this
12
+ * process was started in. The rest are added to the workspace and nothing
13
+ * else, which is exactly what an agent enrolling a team's repositories from
14
+ * one checkout can honestly do from here, and the run says so per repository.
15
+ */
16
+ repositories?: readonly string[];
17
+ /**
18
+ * Repair the two files a previous setup wrote in this repository, and do
19
+ * nothing else. No pairing, no enrollment, no credential, no network: a
20
+ * stale install is repaired where it is, which may be a laptop that cannot
21
+ * reach the control plane at that moment.
22
+ */
23
+ refresh?: boolean;
24
+ /**
25
+ * Set up even though an earlier Balladeer is still installed on this machine.
26
+ *
27
+ * The check that stops this run is a warning about two programs claiming one
28
+ * name, not a safety property, so somebody who has decided to run both says so
29
+ * and this run carries on exactly as it would have.
30
+ */
31
+ force?: boolean;
32
+ /**
33
+ * Whether to also connect Claude desktop chat for this repository.
34
+ *
35
+ * Three states, because there are three answers and only two of them are a
36
+ * flag. `true` is `--claude-desktop`: connect it, and say out loud when this
37
+ * machine has no such app rather than skipping in silence. `false` is
38
+ * `--no-claude-desktop`: do not touch that file and do not mention it.
39
+ * Undefined is the ordinary run, which connects it where Claude desktop is
40
+ * installed and says nothing at all where it is not. Writing it by default is
41
+ * the same bargain `.mcp.json` already makes: the entry is additive, it
42
+ * carries no credential, it never replaces anybody else's server, and the
43
+ * people this is for write their plan in that chat before they open a coding
44
+ * agent at all.
45
+ */
46
+ claudeDesktop?: boolean;
47
+ environment: NodeJS.ProcessEnv;
48
+ cwd: string;
49
+ write: (text: string) => void;
50
+ sleep?: (ms: number) => Promise<void>;
51
+ now?: () => Date;
52
+ }>;
53
+ /** The bound the control plane's own name check holds, refused here so a name
54
+ * nobody could create never costs a pairing code. */
55
+ export declare const CREATE_WORKSPACE_MAX_LENGTH = 120;
56
+ /**
57
+ * Which repository this run acts on, and which it only adds.
58
+ *
59
+ * Every step after the enrollment acts on the working tree this process was
60
+ * started in: it writes `.mcp.json` and the conventions block there, and it
61
+ * cuts, commits and pushes the CI branch to that tree's own remote. So exactly
62
+ * one named repository can be this run's own, and it has to be the one this
63
+ * directory is a checkout of. Naming one repository while sitting in another
64
+ * would enroll one and change the other, and push a branch to a repository
65
+ * nobody named.
66
+ *
67
+ * A directory with no readable GitHub origin is not a disagreement: naming a
68
+ * repository is exactly how a person adds one from somewhere that is not a
69
+ * checkout of it, and every step below says what it could not do there.
70
+ */
71
+ type Chosen = Readonly<{
72
+ kind: "chosen";
73
+ primary: string;
74
+ also: readonly string[];
75
+ }> | Readonly<{
76
+ kind: "mismatch";
77
+ message: string;
78
+ }>;
79
+ export declare function chooseRepositories(named: readonly string[], cwd: string): Chosen;
80
+ /**
81
+ * One run. It prints what it found, does what it can, and exits within seconds.
82
+ * Waiting is the person's choice, behind `--wait`, because an agent shell kills
83
+ * a command that blocks for minutes and the pairing would be lost with it.
84
+ */
85
+ export declare function runSetup(options: SetupOptions): Promise<number>;
86
+ /**
87
+ * Which invocation this repair writes, decided without asking anybody.
88
+ *
89
+ * A repair runs where the stale install is, and the server is not always
90
+ * reachable from there, so the two honest local sources are how this copy was
91
+ * itself obtained and what the file already says. A copy running out of a
92
+ * registry install writes the registry form. A copy running out of a checkout
93
+ * leaves a registry form alone rather than downgrading a published install to
94
+ * an absolute path on one person's laptop, and writes the checkout form only
95
+ * where the file was already a checkout form or had no entry at all.
96
+ */
97
+ export declare function refreshPublishedForm(existing: unknown): string | null;
98
+ export {};