@groundtrack/hooks 0.1.7 → 0.1.8

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.
@@ -0,0 +1,26 @@
1
+ import type { Agent } from "../agents.js";
2
+ import { type SetupEnvironment } from "./agents.js";
3
+ import { type AgentPlan } from "./plan.js";
4
+ export type CommandResult = {
5
+ exitCode: number | null;
6
+ stdout: string;
7
+ stderr: string;
8
+ };
9
+ export type ClientCommand = {
10
+ binary: string;
11
+ args: string[];
12
+ };
13
+ export type ClientRunner = (command: ClientCommand, context: SetupEnvironment, interactive: boolean) => Promise<CommandResult>;
14
+ export type Connection = {
15
+ agent: Agent;
16
+ status: "ready" | "authenticated" | "needs_project_approval" | "manual" | "failed";
17
+ command?: ClientCommand;
18
+ offer: boolean;
19
+ detail?: string;
20
+ };
21
+ /** Probe output can include headers. Keep it bounded and private; never persist or print it. */
22
+ export declare const runClient: ClientRunner;
23
+ export declare function prepareConnections(agents: AgentPlan[], context: SetupEnvironment, url: string, runner?: ClientRunner): Promise<Connection[]>;
24
+ export declare function connectionPrompt(agents: AgentPlan[]): string;
25
+ export declare function connectAgents(connections: Connection[], context: SetupEnvironment, runner?: ClientRunner, onStart?: (agent: Agent) => void): Promise<Connection[]>;
26
+ export declare function formatNextSteps(agents: AgentPlan[], context: SetupEnvironment, connections?: Connection[]): string;
@@ -0,0 +1,210 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { configPaths, executable, readConfig, serverMap } from "./agents.js";
4
+ import { readFile } from "./files.js";
5
+ import { activationSteps } from "./plan.js";
6
+ /** Probe output can include headers. Keep it bounded and private; never persist or print it. */
7
+ export const runClient = (command, context, interactive) => new Promise((resolve) => {
8
+ const child = spawn(command.binary, command.args, {
9
+ cwd: context.repoDir,
10
+ env: context.env,
11
+ stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
12
+ timeout: interactive ? undefined : 5_000,
13
+ windowsHide: true,
14
+ shell: context.platform === "win32" && /\.(?:cmd|bat)$/i.test(command.binary),
15
+ });
16
+ let stdout = "";
17
+ let stderr = "";
18
+ child.stdout?.on("data", (chunk) => {
19
+ stdout = (stdout + chunk.toString()).slice(-65_536);
20
+ });
21
+ child.stderr?.on("data", (chunk) => {
22
+ stderr = (stderr + chunk.toString()).slice(-65_536);
23
+ });
24
+ child.once("error", () => resolve({ exitCode: null, stdout: "", stderr: "" }));
25
+ child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr }));
26
+ });
27
+ function record(value) {
28
+ return value && typeof value === "object" && !Array.isArray(value)
29
+ ? value
30
+ : undefined;
31
+ }
32
+ function codexServer(result) {
33
+ if (result.exitCode !== 0)
34
+ return undefined;
35
+ try {
36
+ return record(JSON.parse(result.stdout));
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function hasAuthOptions(server) {
43
+ return [
44
+ "oauth",
45
+ "auth",
46
+ "bearer_token_env_var",
47
+ "http_headers",
48
+ "env_http_headers",
49
+ "http_headers_helper",
50
+ ].some((key) => server[key] != null);
51
+ }
52
+ async function prepareConnection(agent, context, url, runner) {
53
+ const binary = executable(agent.agent, context);
54
+ const base = { agent: agent.agent, offer: false };
55
+ if (agent.agent === "cursor" || !binary)
56
+ return { ...base, status: "manual" };
57
+ const command = {
58
+ binary,
59
+ args: agent.agent === "opencode" ? ["mcp", "auth", agent.alias] : ["mcp", "login", agent.alias],
60
+ };
61
+ if (agent.agent === "opencode") {
62
+ const probe = await runner({ binary, args: ["mcp", "auth", "list"] }, context, false);
63
+ const alias = agent.alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ const output = stripVTControlCharacters(probe.stdout);
65
+ if (probe.exitCode === 0 &&
66
+ new RegExp(`(?:^|\\s)${alias}\\s+authenticated\\s*$`, "im").test(output))
67
+ return { ...base, status: "authenticated" };
68
+ return {
69
+ ...base,
70
+ status: "ready",
71
+ command,
72
+ offer: !agent.existingConnection ||
73
+ new RegExp(`(?:^|\\s)${alias}\\s+(?:not authenticated|expired)`, "i").test(output),
74
+ };
75
+ }
76
+ if (agent.agent === "claude") {
77
+ const probe = await runner({ binary, args: ["mcp", "get", agent.alias] }, context, false);
78
+ const output = stripVTControlCharacters(`${probe.stdout}\n${probe.stderr}`);
79
+ if (/pending approval|awaiting approval/i.test(output))
80
+ return { ...base, status: "needs_project_approval" };
81
+ if (/status:\s*[^a-z0-9\n]*connected\b/i.test(output) && probe.exitCode === 0)
82
+ return { ...base, status: "authenticated" };
83
+ if (/status:[^\n]*disabled/i.test(output))
84
+ return {
85
+ ...base,
86
+ status: "failed",
87
+ detail: "Re-enable Groundtrack in Claude Code's /mcp panel.",
88
+ };
89
+ if (probe.exitCode !== 0)
90
+ return {
91
+ ...base,
92
+ status: "manual",
93
+ detail: "Open this project in Claude Code and use /mcp to approve and connect Groundtrack.",
94
+ };
95
+ return {
96
+ ...base,
97
+ status: "ready",
98
+ command,
99
+ offer: !agent.existingConnection || /needs authentication|not authenticated/i.test(output),
100
+ };
101
+ }
102
+ if (agent.agent === "codex") {
103
+ const probe = await runner({ binary, args: ["mcp", "get", agent.alias, "--json"] }, context, false);
104
+ let server = codexServer(probe);
105
+ if (!server && /no mcp server named/i.test(probe.stderr + probe.stdout)) {
106
+ // Untrusted project config is intentionally invisible to Codex. Supply only the
107
+ // selected endpoint for this login invocation; never grant project/hook trust.
108
+ const path = configPaths("codex", context).project;
109
+ const configured = record(serverMap("codex", readConfig("codex", readFile(path) ?? ""))[agent.alias]);
110
+ if (!configured || configured.url !== url || hasAuthOptions(configured))
111
+ return { ...base, status: "needs_project_approval" };
112
+ // Override the map so aliases containing dots remain literal TOML keys.
113
+ const override = `mcp_servers={${JSON.stringify(agent.alias)}={url=${JSON.stringify(url)}}}`;
114
+ const prefix = ["-c", override];
115
+ server = codexServer(await runner({ binary, args: [...prefix, "mcp", "get", agent.alias, "--json"] }, context, false));
116
+ command.args = [...prefix, "mcp", "login", agent.alias];
117
+ }
118
+ if (!server)
119
+ return {
120
+ ...base,
121
+ status: "manual",
122
+ detail: "Open and trust this project in Codex, then connect Groundtrack in /mcp or Settings → MCP servers.",
123
+ };
124
+ const transport = record(server.transport);
125
+ if (server.enabled === false || transport?.url !== url || transport.type !== "streamable_http")
126
+ return {
127
+ ...base,
128
+ status: "failed",
129
+ detail: "Codex is loading a disabled or different Groundtrack server. Resolve it in Codex's MCP settings before signing in.",
130
+ };
131
+ if (hasAuthOptions(transport))
132
+ return {
133
+ ...base,
134
+ status: "manual",
135
+ detail: "Codex uses the credentials configured for this server. Verify its connection in Codex.",
136
+ };
137
+ }
138
+ return { ...base, status: "ready", command, offer: !agent.existingConnection };
139
+ }
140
+ export async function prepareConnections(agents, context, url, runner = runClient) {
141
+ return Promise.all(agents.map(async (agent) => {
142
+ try {
143
+ return await prepareConnection(agent, context, url, runner);
144
+ }
145
+ catch {
146
+ return {
147
+ agent: agent.agent,
148
+ status: "manual",
149
+ offer: false,
150
+ detail: "Could not check this agent's sign-in requirements. Finish connecting in the agent.",
151
+ };
152
+ }
153
+ }));
154
+ }
155
+ export function connectionPrompt(agents) {
156
+ const names = new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(agents.map((agent) => agent.name));
157
+ return `Connect Groundtrack to ${names} now?`;
158
+ }
159
+ export async function connectAgents(connections, context, runner = runClient, onStart = () => { }) {
160
+ const results = [];
161
+ for (const connection of connections) {
162
+ if (connection.status !== "ready" || !connection.command) {
163
+ results.push(connection);
164
+ continue;
165
+ }
166
+ onStart(connection.agent);
167
+ let result;
168
+ try {
169
+ result = await runner(connection.command, context, true);
170
+ }
171
+ catch {
172
+ result = { exitCode: null, stdout: "", stderr: "" };
173
+ }
174
+ results.push({
175
+ ...connection,
176
+ offer: false,
177
+ status: result.exitCode === 0 ? "authenticated" : "failed",
178
+ detail: result.exitCode === 0
179
+ ? undefined
180
+ : "Sign-in did not complete. Your configuration is saved; finish connecting in the agent.",
181
+ });
182
+ }
183
+ return results;
184
+ }
185
+ export function formatNextSteps(agents, context, connections = []) {
186
+ const lines = [
187
+ "",
188
+ "Connection status and next steps",
189
+ `Project: ${context.repoDir}`,
190
+ "Run any remaining commands from this project folder.",
191
+ ];
192
+ for (const agent of agents) {
193
+ const connection = connections.find((item) => item.agent === agent.agent);
194
+ const authenticated = connection?.status === "authenticated";
195
+ const label = authenticated
196
+ ? "signed in to Groundtrack"
197
+ : connection?.status === "needs_project_approval"
198
+ ? "project approval needed"
199
+ : connection?.status === "failed"
200
+ ? "sign-in needs attention"
201
+ : "configured · finish connecting in the agent";
202
+ lines.push("", `${agent.name} — ${label}`);
203
+ if (connection?.detail)
204
+ lines.push(` ${connection.detail}`);
205
+ for (const step of activationSteps(agent.agent, agent.alias, agent.existingConnection, authenticated))
206
+ lines.push(` ${step}`);
207
+ }
208
+ lines.push("", "Files are configured. Live connection and hook activation must be verified in each agent.", "Add another agent anytime: groundtrack setup", "");
209
+ return lines.join("\n");
210
+ }
@@ -0,0 +1,18 @@
1
+ export type FileChange = {
2
+ path: string;
3
+ before: string | null;
4
+ after: string;
5
+ mode: number;
6
+ };
7
+ /** Refuse links at every level, including dangling links and linked config directories. */
8
+ export declare function checkPath(path: string): void;
9
+ export declare function readFile(path: string): string | null;
10
+ export declare class FilePlan {
11
+ private files;
12
+ read(path: string): string | null;
13
+ set(path: string, after: string): void;
14
+ get changes(): FileChange[];
15
+ }
16
+ export declare function writeAtomic(path: string, text: string, mode: number): void;
17
+ /** Back up before applying; roll back our writes on failure without overwriting concurrent edits. */
18
+ export declare function applyChanges(changes: FileChange[], backupRoot: string, write?: typeof writeAtomic): string | null;
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ /** Refuse links at every level, including dangling links and linked config directories. */
5
+ export function checkPath(path) {
6
+ const absolute = resolve(path);
7
+ const parent = dirname(absolute);
8
+ if (parent !== absolute)
9
+ checkPath(parent);
10
+ try {
11
+ if (lstatSync(absolute).isSymbolicLink())
12
+ throw new Error(`Symbolic link at ${absolute}. Update its target explicitly before setup.`);
13
+ }
14
+ catch (error) {
15
+ if (error.code !== "ENOENT")
16
+ throw error;
17
+ }
18
+ }
19
+ export function readFile(path) {
20
+ checkPath(path);
21
+ try {
22
+ if (!lstatSync(path).isFile())
23
+ throw new Error(`Expected a regular file: ${path}`);
24
+ return readFileSync(path, "utf8");
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT")
28
+ return null;
29
+ throw error;
30
+ }
31
+ }
32
+ export class FilePlan {
33
+ files = new Map();
34
+ read(path) {
35
+ return this.files.get(path)?.after ?? readFile(path);
36
+ }
37
+ set(path, after) {
38
+ const before = this.files.get(path)?.before ?? readFile(path);
39
+ if (before === after) {
40
+ this.files.delete(path);
41
+ return;
42
+ }
43
+ this.files.set(path, {
44
+ path,
45
+ before,
46
+ after,
47
+ mode: before === null ? 0o644 : lstatSync(path).mode & 0o777,
48
+ });
49
+ }
50
+ get changes() {
51
+ return [...this.files.values()];
52
+ }
53
+ }
54
+ export function writeAtomic(path, text, mode) {
55
+ checkPath(path);
56
+ mkdirSync(dirname(path), { recursive: true });
57
+ const temporary = join(dirname(path), `.groundtrack-${randomUUID()}.tmp`);
58
+ try {
59
+ writeFileSync(temporary, text, { mode, flag: "wx" });
60
+ renameSync(temporary, path);
61
+ }
62
+ finally {
63
+ rmSync(temporary, { force: true });
64
+ }
65
+ }
66
+ /** Back up before applying; roll back our writes on failure without overwriting concurrent edits. */
67
+ export function applyChanges(changes, backupRoot, write = writeAtomic) {
68
+ if (!changes.length)
69
+ return null;
70
+ for (const change of changes) {
71
+ if (readFile(change.path) !== change.before)
72
+ throw new Error(`${change.path} changed after the preview. Run setup again.`);
73
+ }
74
+ checkPath(backupRoot);
75
+ const backupDir = join(backupRoot, randomUUID());
76
+ mkdirSync(backupDir, { recursive: true, mode: 0o700 });
77
+ writeFileSync(join(backupDir, "files.json"), JSON.stringify(changes.map(({ path, before, mode }) => ({ path, before, mode }))), { mode: 0o600, flag: "wx" });
78
+ const written = [];
79
+ try {
80
+ for (const change of changes) {
81
+ if (readFile(change.path) !== change.before)
82
+ throw new Error(`${change.path} changed while setup was running.`);
83
+ write(change.path, change.after, change.mode);
84
+ written.push(change);
85
+ }
86
+ }
87
+ catch {
88
+ for (const change of written.reverse()) {
89
+ try {
90
+ if (readFile(change.path) !== change.after)
91
+ continue;
92
+ if (change.before === null)
93
+ rmSync(change.path);
94
+ else
95
+ writeAtomic(change.path, change.before, change.mode);
96
+ }
97
+ catch {
98
+ /* The private backup is retained if a file cannot be restored. */
99
+ }
100
+ }
101
+ throw new Error(`Setup could not save every file. Previous changes were rolled back where possible. Backups: ${backupDir}. Run setup again after resolving the file conflict.`);
102
+ }
103
+ return backupDir;
104
+ }
@@ -0,0 +1,30 @@
1
+ import { type Agent } from "../agents.js";
2
+ import { type SetupEnvironment } from "./agents.js";
3
+ import { type FileChange } from "./files.js";
4
+ export type AgentPlan = {
5
+ agent: Agent;
6
+ name: string;
7
+ alias: string;
8
+ existingConnection: boolean;
9
+ changes: FileChange[];
10
+ issue: string | null;
11
+ activation: "unverified";
12
+ nextSteps: string[];
13
+ };
14
+ export type SetupPlan = {
15
+ agents: AgentPlan[];
16
+ shared: FileChange[];
17
+ sharedIssue: string | null;
18
+ };
19
+ export declare function activationSteps(agent: Agent, alias: string, existing: boolean, authenticated?: boolean): string[];
20
+ export declare function buildSetupPlan(context: SetupEnvironment, agents: Agent[], url: string): SetupPlan;
21
+ export declare function planSummary(plan: SetupPlan, context: SetupEnvironment): {
22
+ agent: "claude" | "codex" | "cursor" | "opencode";
23
+ name: string;
24
+ status: string;
25
+ issue: string | null;
26
+ activation: "unverified";
27
+ files: string[];
28
+ nextSteps: string[];
29
+ }[];
30
+ export declare function selectionChange(context: SetupEnvironment, agents: Agent[]): FileChange[];