@groundtrack/hooks 0.1.7 → 0.1.9

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 (39) hide show
  1. package/README.md +124 -43
  2. package/dist/agent-instructions.d.ts +2 -1
  3. package/dist/agents.d.ts +36 -0
  4. package/dist/agents.js +27 -0
  5. package/dist/doctor.d.ts +6 -1
  6. package/dist/doctor.js +9 -6
  7. package/dist/enrich-store-event.js +2 -1
  8. package/dist/groundtrack.js +9 -195
  9. package/dist/session-import/cli.d.ts +21 -0
  10. package/dist/session-import/cli.js +335 -0
  11. package/dist/session-import/client.d.ts +13 -0
  12. package/dist/session-import/client.js +73 -0
  13. package/dist/session-import/codex.d.ts +37 -0
  14. package/dist/session-import/codex.js +134 -0
  15. package/dist/session-import/discovery.d.ts +22 -0
  16. package/dist/session-import/discovery.js +188 -0
  17. package/dist/session-import/prune.d.ts +5 -0
  18. package/dist/session-import/prune.js +120 -0
  19. package/dist/session-import/receipts.d.ts +20 -0
  20. package/dist/session-import/receipts.js +44 -0
  21. package/dist/session-import/redaction.d.ts +6 -0
  22. package/dist/session-import/redaction.js +61 -0
  23. package/dist/session-import/types.d.ts +28 -0
  24. package/dist/session-import/types.js +1 -0
  25. package/dist/setup/agents.d.ts +59 -0
  26. package/dist/setup/agents.js +160 -0
  27. package/dist/setup/cli.d.ts +16 -0
  28. package/dist/setup/cli.js +332 -0
  29. package/dist/setup/config.d.ts +9 -0
  30. package/dist/setup/config.js +143 -0
  31. package/dist/setup/connections.d.ts +26 -0
  32. package/dist/setup/connections.js +210 -0
  33. package/dist/setup/files.d.ts +18 -0
  34. package/dist/setup/files.js +104 -0
  35. package/dist/setup/plan.d.ts +30 -0
  36. package/dist/setup/plan.js +324 -0
  37. package/package.json +29 -2
  38. package/src/agents.ts +37 -0
  39. package/templates/AGENTS.md +2 -1
@@ -0,0 +1,160 @@
1
+ import { AGENTS, AGENT_IDS, isAgent, resolveAgent } from "../agents.js";
2
+ import { execFileSync } from "node:child_process";
3
+ import { accessSync, constants, existsSync, realpathSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, join, resolve } from "node:path";
6
+ import { at, object, readJson, readToml } from "./config.js";
7
+ import { readFile } from "./files.js";
8
+ export const MCP_URL = "https://app.groundtrack.dev/api/mcp";
9
+ export function environment(options = {}) {
10
+ let repoDir = resolve(options.repoDir ?? process.cwd());
11
+ if (!options.repoDir) {
12
+ try {
13
+ repoDir = execFileSync("git", ["-C", repoDir, "rev-parse", "--show-toplevel"], {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ timeout: 2_000,
17
+ }).trim();
18
+ }
19
+ catch {
20
+ /* A non-Git project can still use Groundtrack. */
21
+ }
22
+ }
23
+ return {
24
+ repoDir: realpathSync(repoDir),
25
+ homeDir: realpathSync(resolve(options.homeDir ?? homedir())),
26
+ env: options.env ?? process.env,
27
+ platform: options.platform ?? process.platform,
28
+ };
29
+ }
30
+ export function agentId(value) {
31
+ const agent = resolveAgent(value);
32
+ if (!agent)
33
+ throw new Error(`Unknown agent: ${value}. Choose ${AGENT_IDS.join(", ")}.`);
34
+ return agent.id;
35
+ }
36
+ export function executable(name, context) {
37
+ for (const dir of (context.env.PATH ?? "")
38
+ .split(context.platform === "win32" ? ";" : delimiter)
39
+ .filter(Boolean)) {
40
+ const suffixes = context.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
41
+ for (const suffix of suffixes) {
42
+ const path = join(dir, name + suffix);
43
+ try {
44
+ accessSync(path, constants.X_OK);
45
+ return path;
46
+ }
47
+ catch {
48
+ /* Continue through PATH. */
49
+ }
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ export function stateDir(context) {
55
+ return join(context.env.XDG_CONFIG_HOME || join(context.homeDir, ".config"), "groundtrack");
56
+ }
57
+ function firstExisting(paths) {
58
+ return paths.find((path) => existsSync(path)) ?? paths[0];
59
+ }
60
+ export function configPaths(agent, context) {
61
+ const { repoDir, homeDir, env } = context;
62
+ switch (agent) {
63
+ case "claude": {
64
+ const dir = env.CLAUDE_CONFIG_DIR || join(homeDir, ".claude");
65
+ return {
66
+ project: join(repoDir, ".mcp.json"),
67
+ user: env.CLAUDE_CONFIG_DIR ? join(dir, ".claude.json") : join(homeDir, ".claude.json"),
68
+ presence: [dir, join(repoDir, ".claude")],
69
+ };
70
+ }
71
+ case "codex": {
72
+ const dir = env.CODEX_HOME || join(homeDir, ".codex");
73
+ return {
74
+ project: join(repoDir, ".codex/config.toml"),
75
+ user: join(dir, "config.toml"),
76
+ presence: [
77
+ dir,
78
+ join(repoDir, ".codex"),
79
+ ...(context.platform === "darwin"
80
+ ? ["/Applications/Codex.app", join(homeDir, "Applications/Codex.app")]
81
+ : []),
82
+ ],
83
+ };
84
+ }
85
+ case "cursor":
86
+ return {
87
+ project: join(repoDir, ".cursor/mcp.json"),
88
+ user: join(homeDir, ".cursor/mcp.json"),
89
+ presence: [
90
+ join(homeDir, ".cursor"),
91
+ join(repoDir, ".cursor"),
92
+ ...(context.platform === "darwin"
93
+ ? ["/Applications/Cursor.app", join(homeDir, "Applications/Cursor.app")]
94
+ : []),
95
+ ],
96
+ };
97
+ case "opencode": {
98
+ const dir = join(env.XDG_CONFIG_HOME || join(homeDir, ".config"), "opencode");
99
+ return {
100
+ project: firstExisting([
101
+ "opencode.jsonc",
102
+ "opencode.json",
103
+ ".opencode/opencode.jsonc",
104
+ ".opencode/opencode.json",
105
+ ].map((path) => join(repoDir, path))),
106
+ user: env.OPENCODE_CONFIG ||
107
+ firstExisting([join(dir, "opencode.jsonc"), join(dir, "opencode.json")]),
108
+ presence: [dir, join(repoDir, ".opencode")],
109
+ };
110
+ }
111
+ }
112
+ }
113
+ export function serverPath(agent, config) {
114
+ if (agent === "codex")
115
+ return ["mcp_servers"];
116
+ if (agent !== "opencode")
117
+ return ["mcpServers"];
118
+ const native = at(config, ["mcp", "servers"]);
119
+ return native && typeof native === "object" && !("type" in native) ? ["mcp", "servers"] : ["mcp"];
120
+ }
121
+ export function readConfig(agent, text) {
122
+ return agent === "codex" ? readToml(text) : readJson(text);
123
+ }
124
+ export function serverMap(agent, config) {
125
+ return object(at(config, serverPath(agent, config)) ?? {}, "MCP servers");
126
+ }
127
+ export function isGroundtrack(alias, value, url) {
128
+ return (alias.toLowerCase().includes("groundtrack") ||
129
+ (typeof value === "object" && value !== null && "url" in value && value.url === url));
130
+ }
131
+ export function discoverAgents(context, url = MCP_URL) {
132
+ return AGENTS.map((agent) => {
133
+ const paths = configPaths(agent.id, context);
134
+ const detected = [...paths.presence, paths.project, paths.user].some((path) => existsSync(path)) ||
135
+ Boolean(executable(agent.id, context));
136
+ let configured = false;
137
+ let issue = null;
138
+ try {
139
+ for (const path of [paths.project, paths.user]) {
140
+ const text = readFile(path);
141
+ if (text !== null &&
142
+ Object.entries(serverMap(agent.id, readConfig(agent.id, text))).some(([alias, value]) => isGroundtrack(alias, value, url)))
143
+ configured = true;
144
+ }
145
+ }
146
+ catch (error) {
147
+ issue = error instanceof Error ? error.message : "Configuration could not be read.";
148
+ }
149
+ return { ...agent, detected, configured, issue };
150
+ });
151
+ }
152
+ export function savedAgents(context) {
153
+ const text = readFile(join(stateDir(context), "setup.json"));
154
+ if (text === null)
155
+ return null;
156
+ const value = readJson(text);
157
+ if (value.version !== 1 || !Array.isArray(value.agents))
158
+ throw new Error("Groundtrack's saved selection is invalid. Choose agents with --agent to replace it.");
159
+ return [...new Set(value.agents.filter(isAgent))];
160
+ }
@@ -0,0 +1,16 @@
1
+ import { type Agent } from "../agents.js";
2
+ export declare const USAGE: string;
3
+ export type Arguments = {
4
+ command: "setup" | "status" | "version" | "help";
5
+ agents: Agent[];
6
+ allDetected: boolean;
7
+ yes: boolean;
8
+ json: boolean;
9
+ dryRun: boolean;
10
+ connect: boolean;
11
+ repoDir?: string;
12
+ homeDir?: string;
13
+ url: string;
14
+ };
15
+ export declare function parseArguments(input: string[]): Arguments;
16
+ export declare function runCli(argv: string[]): Promise<void>;
@@ -0,0 +1,332 @@
1
+ import { connectAgents, connectionPrompt, formatNextSteps, prepareConnections, } from "./connections.js";
2
+ import { AGENT_IDS, requiresHookRuntime } from "../agents.js";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { basename, join } from "node:path";
5
+ import * as prompts from "@clack/prompts";
6
+ import { compareVersions } from "../doctor.js";
7
+ import { packageVersion } from "../version.js";
8
+ import { MCP_URL, agentId, discoverAgents, environment, executable, savedAgents, stateDir, } from "./agents.js";
9
+ import { applyChanges } from "./files.js";
10
+ import { buildSetupPlan, planSummary, selectionChange } from "./plan.js";
11
+ export const USAGE = `Groundtrack — shared memory in your coding agents
12
+
13
+ Usage:
14
+ groundtrack setup Choose agents for this project
15
+ groundtrack setup --agent codex Add Codex; keep existing setup
16
+ groundtrack setup --agent claude --agent cursor --yes
17
+ groundtrack setup --all-detected --yes Configure detected agents
18
+ groundtrack status Check selected agents
19
+ groundtrack doctor [--fix] Check or repair using the same flow
20
+ groundtrack import [options] Import local coding-agent sessions
21
+ groundtrack version
22
+
23
+ Options:
24
+ --agent, --client NAME ${AGENT_IDS.join(", ")} (repeatable)
25
+ --all-detected Explicitly select agents detected on this machine
26
+ --repo PATH Project directory (default: current directory)
27
+ --scope project Configure this project; other scopes are not supported
28
+ --url URL Groundtrack MCP endpoint (default: hosted Groundtrack)
29
+ --yes, -y Apply the selected setup without another prompt
30
+ --dry-run Preview files and next steps without changing anything
31
+ --connect Start supported agents' sign-in flows in this terminal
32
+ --json Machine-readable status; explicit --yes required to apply
33
+ --help, -h Show help
34
+ `;
35
+ export function parseArguments(input) {
36
+ const argv = [...input];
37
+ const result = {
38
+ command: "status",
39
+ agents: [],
40
+ allDetected: false,
41
+ yes: false,
42
+ json: false,
43
+ dryRun: false,
44
+ connect: false,
45
+ url: MCP_URL,
46
+ };
47
+ if (argv.includes("--help") || argv.includes("-h"))
48
+ return { ...result, command: "help" };
49
+ let command = argv[0]?.startsWith("-") ? "doctor" : (argv.shift() ?? "doctor");
50
+ if (argv.includes("--version"))
51
+ command = "version";
52
+ if (!["setup", "status", "doctor", "version"].includes(command))
53
+ throw new Error(`Unknown command: ${command}. Run groundtrack --help.`);
54
+ result.command = command === "doctor" ? "status" : command;
55
+ const take = (index, flag) => {
56
+ const value = argv[index + 1];
57
+ if (!value || value.startsWith("-"))
58
+ throw new Error(`${flag} requires a value.`);
59
+ return value;
60
+ };
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const arg = argv[i];
63
+ if (arg === "--agent" || arg === "--client")
64
+ result.agents.push(agentId(take(i++, arg)));
65
+ else if (arg === "--repo")
66
+ result.repoDir = take(i++, arg);
67
+ else if (arg === "--home")
68
+ result.homeDir = take(i++, arg);
69
+ else if (arg === "--url")
70
+ result.url = take(i++, arg);
71
+ else if (arg === "--scope") {
72
+ if (take(i++, arg) !== "project")
73
+ throw new Error("Setup currently supports --scope project. Choose agents independently of project scope.");
74
+ }
75
+ else if (arg === "--yes" || arg === "-y")
76
+ result.yes = true;
77
+ else if (arg === "--json")
78
+ result.json = true;
79
+ else if (arg === "--dry-run")
80
+ result.dryRun = true;
81
+ else if (arg === "--connect")
82
+ result.connect = true;
83
+ else if (arg === "--all-detected")
84
+ result.allDetected = true;
85
+ else if (arg === "--fix" && command === "doctor")
86
+ result.command = "setup";
87
+ else if (arg !== "--version")
88
+ throw new Error(`Unknown option: ${arg}. Run groundtrack --help.`);
89
+ }
90
+ result.agents = [...new Set(result.agents)];
91
+ if (result.allDetected && result.agents.length)
92
+ throw new Error("Choose --agent or --all-detected, not both.");
93
+ if (result.connect && (result.command !== "setup" || result.json || result.dryRun))
94
+ throw new Error("Use --connect with interactive setup, without --json or --dry-run.");
95
+ const url = new URL(result.url);
96
+ if ((url.protocol !== "https:" &&
97
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) ||
98
+ url.username ||
99
+ url.password ||
100
+ url.search ||
101
+ url.hash) {
102
+ throw new Error("Use an HTTPS MCP URL (or local HTTP), without credentials, query parameters, or fragments.");
103
+ }
104
+ result.url = url.href.replace(/\/$/, "");
105
+ return result;
106
+ }
107
+ function runtimeVersion(context) {
108
+ const binary = executable("groundtrack-hook", context);
109
+ if (!binary)
110
+ return null;
111
+ const result = spawnSync(binary, ["version", "--json"], {
112
+ env: context.env,
113
+ encoding: "utf8",
114
+ timeout: 2_000,
115
+ windowsHide: true,
116
+ shell: context.platform === "win32",
117
+ });
118
+ try {
119
+ const value = JSON.parse(result.stdout);
120
+ return result.status === 0 &&
121
+ value &&
122
+ typeof value === "object" &&
123
+ "version" in value &&
124
+ typeof value.version === "string"
125
+ ? value.version
126
+ : null;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ function run(binary, args, context, inherit = false) {
133
+ return new Promise((resolve) => {
134
+ const child = spawn(binary, args, {
135
+ cwd: context.repoDir,
136
+ env: context.env,
137
+ stdio: inherit ? "inherit" : "ignore",
138
+ windowsHide: true,
139
+ shell: context.platform === "win32",
140
+ timeout: inherit ? undefined : 120_000,
141
+ });
142
+ child.once("error", () => resolve(false));
143
+ child.once("close", (code) => resolve(code === 0));
144
+ });
145
+ }
146
+ function printPlan(plan, context, runtimeReady) {
147
+ process.stdout.write(`\nGroundtrack · ${basename(context.repoDir)}\nProject: ${context.repoDir}\n\n`);
148
+ for (const agent of planSummary(plan, context)) {
149
+ process.stdout.write(` ${agent.status === "blocked" ? "!" : agent.status === "configured" ? "✓" : "+"} ${agent.name}: ${agent.status === "configured" ? "already configured" : agent.status === "blocked" ? agent.issue : "ready to set up"}\n`);
150
+ for (const path of agent.files)
151
+ process.stdout.write(` ${path}\n`);
152
+ }
153
+ if (plan.shared.length)
154
+ process.stdout.write(" + Update shared AGENTS.md instructions\n");
155
+ if (!runtimeReady)
156
+ process.stdout.write(" + Install the shared Groundtrack hook runtime once\n");
157
+ process.stdout.write("\nScope: this project. Existing agent connections are kept.\n");
158
+ }
159
+ export async function runCli(argv) {
160
+ const args = parseArguments(argv);
161
+ if (args.command === "help") {
162
+ process.stdout.write(USAGE);
163
+ return;
164
+ }
165
+ if (args.command === "version") {
166
+ process.stdout.write(args.json ? `${JSON.stringify({ version: packageVersion() })}\n` : `${packageVersion()}\n`);
167
+ return;
168
+ }
169
+ const context = environment({ repoDir: args.repoDir, homeDir: args.homeDir });
170
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !args.json);
171
+ if (args.connect && !interactive)
172
+ throw new Error("--connect needs an interactive terminal. Run setup there, or use the per-agent sign-in instructions after setup.");
173
+ const discovered = discoverAgents(context, args.url);
174
+ let saved = null;
175
+ try {
176
+ saved = savedAgents(context);
177
+ }
178
+ catch (error) {
179
+ if (!args.agents.length && !args.allDetected)
180
+ throw error;
181
+ }
182
+ let targets = args.agents.length
183
+ ? args.agents
184
+ : args.allDetected
185
+ ? discovered.filter((agent) => agent.detected).map((agent) => agent.id)
186
+ : (saved ?? []);
187
+ let picked = false;
188
+ if (args.command === "setup" && !args.agents.length && !args.allDetected && interactive) {
189
+ prompts.intro(`Set up Groundtrack · ${basename(context.repoDir)}`);
190
+ const selection = await prompts.multiselect({
191
+ message: "Which coding agents do you use?",
192
+ options: discovered.map((agent) => ({
193
+ value: agent.id,
194
+ label: agent.name,
195
+ hint: agent.issue
196
+ ? "configuration needs attention"
197
+ : agent.configured
198
+ ? "already configured · keep"
199
+ : agent.detected
200
+ ? "detected"
201
+ : "not detected · can still set up",
202
+ })),
203
+ initialValues: saved ?? discovered.filter((agent) => agent.detected).map((agent) => agent.id),
204
+ required: false,
205
+ });
206
+ if (prompts.isCancel(selection) || selection.length === 0) {
207
+ prompts.cancel("No agents selected. Nothing changed.");
208
+ return;
209
+ }
210
+ targets = selection;
211
+ picked = true;
212
+ }
213
+ if (args.command === "status" && !targets.length)
214
+ targets = discovered
215
+ .filter((agent) => agent.detected || agent.configured)
216
+ .map((agent) => agent.id);
217
+ if (!targets.length) {
218
+ if (args.command === "setup")
219
+ throw new Error("Choose your agents: groundtrack setup --agent claude --agent codex --agent cursor --yes. In an interactive terminal, run groundtrack setup to choose from a list. No files changed.");
220
+ process.stdout.write(args.json
221
+ ? `${JSON.stringify({ agents: [], detected: discovered, runtime: null })}\n`
222
+ : "Groundtrack is not configured here. Run groundtrack setup to choose your agents.\n");
223
+ return;
224
+ }
225
+ const plan = buildSetupPlan(context, targets, args.url);
226
+ const needsRuntime = targets.some(requiresHookRuntime);
227
+ const installedVersion = needsRuntime ? runtimeVersion(context) : null;
228
+ const runtimeReady = !needsRuntime ||
229
+ (installedVersion !== null && compareVersions(installedVersion, packageVersion()) >= 0);
230
+ if (args.command === "status" || args.dryRun) {
231
+ if (args.json)
232
+ process.stdout.write(`${JSON.stringify({ agents: planSummary(plan, context), detected: discovered, runtimeReady, sharedIssue: plan.sharedIssue, sharedFiles: plan.shared.map((change) => change.path), dryRun: args.dryRun }, null, 2)}\n`);
233
+ else {
234
+ printPlan(plan, context, runtimeReady);
235
+ const other = discovered.filter((agent) => agent.detected && !targets.includes(agent.id));
236
+ if (other.length)
237
+ process.stdout.write(`\nAlso detected: ${other.map((agent) => agent.name).join(", ")}. Add them with groundtrack setup.\n`);
238
+ process.stdout.write("\nLive connection: not checked from this terminal.\nRun groundtrack setup to add or repair agents.\n");
239
+ }
240
+ process.exitCode =
241
+ plan.sharedIssue || plan.agents.some((agent) => agent.issue)
242
+ ? 1
243
+ : args.dryRun
244
+ ? 0
245
+ : !runtimeReady || plan.shared.length || plan.agents.some((agent) => agent.changes.length)
246
+ ? 1
247
+ : 0;
248
+ return;
249
+ }
250
+ if (!args.yes && !interactive)
251
+ throw new Error("Setup needs confirmation. Use --yes with your selected --agent values, or run groundtrack setup in an interactive terminal. No files changed.");
252
+ if (!args.json)
253
+ printPlan(plan, context, runtimeReady);
254
+ const ready = plan.agents.filter((agent) => !agent.issue);
255
+ if (plan.sharedIssue || !ready.length) {
256
+ if (args.json)
257
+ process.stdout.write(`${JSON.stringify({ agents: planSummary(plan, context), sharedIssue: plan.sharedIssue, applied: false })}\n`);
258
+ process.exitCode = 1;
259
+ return;
260
+ }
261
+ if (!args.yes &&
262
+ (!runtimeReady || plan.shared.length || ready.some((agent) => agent.changes.length))) {
263
+ const approved = await prompts.confirm({
264
+ message: `Configure ${ready.length} ${ready.length === 1 ? "agent" : "agents"} for this project?`,
265
+ initialValue: true,
266
+ });
267
+ if (prompts.isCancel(approved) || !approved) {
268
+ prompts.cancel("Nothing changed.");
269
+ return;
270
+ }
271
+ }
272
+ if (!runtimeReady && ready.some((agent) => requiresHookRuntime(agent.agent))) {
273
+ if (context.env.GROUNDTRACK_DOCTOR_SKIP_INSTALL === "1")
274
+ throw new Error("The Groundtrack hook runtime is missing or outdated. Install @groundtrack/hooks before applying this setup.");
275
+ const spinner = interactive ? prompts.spinner() : null;
276
+ spinner?.start("Installing the shared hook runtime");
277
+ const installed = await run("npm", ["install", "--global", `@groundtrack/hooks@${packageVersion()}`], context);
278
+ spinner?.stop(installed ? "Hook runtime installed" : "Hook runtime installation failed");
279
+ const version = runtimeVersion(context);
280
+ if (!installed || !version || compareVersions(version, packageVersion()) < 0)
281
+ throw new Error("The hook runtime could not be installed or is not on PATH. Run npm install --global @groundtrack/hooks, ensure groundtrack-hook is on PATH, then retry. Agent configuration was not changed.");
282
+ }
283
+ const preferences = picked ? targets : [...new Set([...(saved ?? []), ...targets])];
284
+ const changes = [
285
+ ...plan.shared,
286
+ ...ready.flatMap((agent) => agent.changes),
287
+ ...selectionChange(context, preferences),
288
+ ];
289
+ const backupDir = applyChanges(changes, join(stateDir(context), "backups"));
290
+ const after = buildSetupPlan(context, targets, args.url);
291
+ const localReady = !after.sharedIssue &&
292
+ !after.shared.length &&
293
+ after.agents.every((agent) => !agent.issue && !agent.changes.length);
294
+ let signInFailed = false;
295
+ if (args.json) {
296
+ process.stdout.write(`${JSON.stringify({ agents: planSummary(after, context), localReady, applied: true, changedFiles: changes.map((change) => change.path), backupDir }, null, 2)}\n`);
297
+ }
298
+ else {
299
+ process.stdout.write(`\n${ready.length} ${ready.length === 1 ? "agent configured" : "agents configured"}. ${changes.length ? `${changes.length} files updated.` : "Everything is already current."}\n`);
300
+ let connections = [];
301
+ if (interactive) {
302
+ const spinner = prompts.spinner();
303
+ spinner.start("Checking sign-in requirements");
304
+ connections = await prepareConnections(ready, context, args.url);
305
+ spinner.stop("Sign-in requirements checked");
306
+ signInFailed = connections.some((connection) => connection.status === "failed");
307
+ for (const connection of connections.filter((item) => item.status === "needs_project_approval")) {
308
+ const agent = ready.find((item) => item.agent === connection.agent);
309
+ process.stdout.write(`${agent.name}: approve this project in the agent before signing in.\n`);
310
+ }
311
+ }
312
+ const eligible = connections.filter((connection) => connection.status === "ready" && (args.connect || connection.offer));
313
+ let shouldConnect = args.connect;
314
+ if (interactive && !args.yes && eligible.length && !shouldConnect) {
315
+ const answer = await prompts.confirm({
316
+ message: connectionPrompt(ready.filter((agent) => eligible.some((item) => item.agent === agent.agent))),
317
+ initialValue: true,
318
+ });
319
+ shouldConnect = !prompts.isCancel(answer) && answer;
320
+ }
321
+ if (shouldConnect) {
322
+ const results = await connectAgents(eligible, context, undefined, (id) => {
323
+ const name = ready.find((agent) => agent.agent === id).name;
324
+ process.stdout.write(`\nConnecting Groundtrack to ${name}…\n`);
325
+ });
326
+ signInFailed ||= results.some((result) => result.status === "failed");
327
+ connections = connections.map((connection) => results.find((result) => result.agent === connection.agent) ?? connection);
328
+ }
329
+ process.stdout.write(formatNextSteps(ready, context, connections));
330
+ }
331
+ process.exitCode = localReady && !signInFailed ? 0 : 1;
332
+ }
@@ -0,0 +1,9 @@
1
+ export type JsonObject = Record<string, unknown>;
2
+ export type ConfigPath = Array<string | number>;
3
+ export declare function object(value: unknown, label: string): JsonObject;
4
+ export declare function at(value: unknown, path: ConfigPath): unknown;
5
+ export declare function readJson(text: string): JsonObject;
6
+ export declare function setJson(text: string, path: ConfigPath, value: unknown): string;
7
+ export declare function readToml(text: string): JsonObject;
8
+ /** Edit only the requested value; retain comments, quoting and unrelated tables. */
9
+ export declare function setToml(text: string, path: ConfigPath, value: unknown): string;
@@ -0,0 +1,143 @@
1
+ import { applyEdits, modify, parse, parseTree, printParseErrorCode, } from "jsonc-parser";
2
+ import { getStaticTOMLValue, parseTOML, traverseNodes } from "toml-eslint-parser";
3
+ export function object(value, label) {
4
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
5
+ throw new Error(`${label} must be an object. Existing configuration was left unchanged.`);
6
+ }
7
+ return value;
8
+ }
9
+ export function at(value, path) {
10
+ return path.reduce((current, key) => {
11
+ if (!current || typeof current !== "object")
12
+ return undefined;
13
+ return current[key];
14
+ }, value);
15
+ }
16
+ function checkDuplicateKeys(node) {
17
+ if (node?.type === "object") {
18
+ const keys = node.children?.map((child) => child.children?.[0]?.value) ?? [];
19
+ if (new Set(keys).size !== keys.length) {
20
+ throw new Error("Configuration contains duplicate keys. Resolve them before running setup.");
21
+ }
22
+ }
23
+ for (const child of node?.children ?? [])
24
+ checkDuplicateKeys(child);
25
+ }
26
+ export function readJson(text) {
27
+ const errors = [];
28
+ const value = parse(text || "{}", errors, { allowTrailingComma: true });
29
+ if (value === undefined &&
30
+ errors.every((error) => printParseErrorCode(error.error) === "ValueExpected"))
31
+ return {};
32
+ if (errors.length)
33
+ throw new Error(`Invalid JSON at offset ${errors[0].offset}. Fix the file and retry.`);
34
+ checkDuplicateKeys(parseTree(text || "{}"));
35
+ return object(value, "Configuration");
36
+ }
37
+ export function setJson(text, path, value) {
38
+ readJson(text);
39
+ const source = !parseTree(text) ? `${text}${text && !text.endsWith("\n") ? "\n" : ""}{}\n` : text;
40
+ const indent = /\n([\t ]+)\S/.exec(source)?.[1];
41
+ const updated = applyEdits(source, modify(source, path, value, {
42
+ formattingOptions: {
43
+ insertSpaces: !indent?.includes("\t"),
44
+ tabSize: indent?.length ?? 2,
45
+ eol: source.includes("\r\n") ? "\r\n" : "\n",
46
+ },
47
+ }));
48
+ readJson(updated);
49
+ return updated;
50
+ }
51
+ function tomlAst(text) {
52
+ try {
53
+ return parseTOML(text);
54
+ }
55
+ catch {
56
+ // Parser diagnostics can contain credentials from the offending line.
57
+ throw new Error("Invalid TOML. Fix the file before running setup; its contents were not changed.");
58
+ }
59
+ }
60
+ export function readToml(text) {
61
+ return object(getStaticTOMLValue(tomlAst(text)), "Configuration");
62
+ }
63
+ function nodePath(node) {
64
+ if (node.type === "TOMLTable")
65
+ return node.resolvedKey;
66
+ if (node.type === "Program" || node.type === "TOMLTopLevelTable")
67
+ return [];
68
+ if (node.type === "TOMLKeyValue")
69
+ return [...nodePath(node.parent), ...getStaticTOMLValue(node.key)];
70
+ if (node.parent?.type === "TOMLArray") {
71
+ return [...nodePath(node.parent), node.parent.elements.indexOf(node)];
72
+ }
73
+ return node.parent ? nodePath(node.parent) : [];
74
+ }
75
+ function renderToml(value) {
76
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
77
+ return JSON.stringify(value);
78
+ if (Array.isArray(value))
79
+ return `[${value.map(renderToml).join(", ")}]`;
80
+ return `{ ${Object.entries(object(value, "TOML value"))
81
+ .map(([key, item]) => `${JSON.stringify(key)} = ${renderToml(item)}`)
82
+ .join(", ")} }`;
83
+ }
84
+ /** Edit only the requested value; retain comments, quoting and unrelated tables. */
85
+ export function setToml(text, path, value) {
86
+ const ast = tomlAst(text);
87
+ let match;
88
+ const containers = [];
89
+ traverseNodes(ast, {
90
+ enterNode(node) {
91
+ const resolved = nodePath(node);
92
+ if (node.type === "TOMLKeyValue" && JSON.stringify(resolved) === JSON.stringify(path))
93
+ match = node;
94
+ if (["TOMLTable", "TOMLInlineTable", "TOMLTopLevelTable"].includes(node.type) &&
95
+ resolved.length < path.length &&
96
+ resolved.every((part, index) => part === path[index]) &&
97
+ path.slice(resolved.length).every((part) => typeof part === "string")) {
98
+ containers.push(node);
99
+ }
100
+ },
101
+ leaveNode() { },
102
+ });
103
+ let updated;
104
+ if (match) {
105
+ updated =
106
+ text.slice(0, match.value.range[0]) + renderToml(value) + text.slice(match.value.range[1]);
107
+ }
108
+ else {
109
+ const container = containers.sort((a, b) => nodePath(b).length - nodePath(a).length)[0];
110
+ if (!container)
111
+ throw new Error("This TOML layout cannot be updated automatically. Existing configuration was left unchanged.");
112
+ const key = path
113
+ .slice(nodePath(container).length)
114
+ .map((part) => JSON.stringify(part))
115
+ .join(".");
116
+ const entry = `${key} = ${renderToml(value)}`;
117
+ if (container.type === "TOMLInlineTable") {
118
+ const offset = container.range[1] - 1;
119
+ updated =
120
+ text.slice(0, offset).trimEnd() +
121
+ `${container.body.length ? ", " : " "}${entry} ` +
122
+ text.slice(offset);
123
+ }
124
+ else {
125
+ const firstTable = container.type === "TOMLTopLevelTable"
126
+ ? container.body.find((node) => node.type === "TOMLTable")
127
+ : undefined;
128
+ const position = firstTable?.range[0] ?? container.range[1];
129
+ // Insert after the complete line, including a possible trailing comment.
130
+ const newline = firstTable ? position : text.indexOf("\n", position);
131
+ const offset = newline < 0 ? text.length : firstTable ? position : newline + 1;
132
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
133
+ updated =
134
+ text.slice(0, offset) +
135
+ (offset && text[offset - 1] !== "\n" ? eol : "") +
136
+ entry +
137
+ eol +
138
+ text.slice(offset);
139
+ }
140
+ }
141
+ readToml(updated);
142
+ return updated;
143
+ }