@ctrl-spc/cli 1.1.15 → 1.2.0

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 (42) hide show
  1. package/dist/agents.js +54 -0
  2. package/dist/collision.js +44 -0
  3. package/dist/commands/fetch.js +213 -0
  4. package/dist/commands/init.js +457 -0
  5. package/dist/commands/link.js +152 -0
  6. package/dist/commands/login.js +332 -0
  7. package/dist/commands/logout.js +14 -0
  8. package/dist/commands/run.js +622 -0
  9. package/dist/config.js +68 -0
  10. package/dist/env.js +5 -0
  11. package/dist/git.js +70 -0
  12. package/dist/index.js +56 -72
  13. package/dist/mcp.js +1732 -0
  14. package/dist/presence.js +62 -0
  15. package/dist/prompt.js +47 -0
  16. package/dist/protocol.js +117 -0
  17. package/dist/skills.js +148 -0
  18. package/dist/supabase.js +40 -10
  19. package/dist/topology.js +602 -0
  20. package/package.json +25 -23
  21. package/bin/ctrl-spc.js +0 -6
  22. package/dist/auth.js +0 -172
  23. package/dist/controlRequests.js +0 -164
  24. package/dist/daemon.js +0 -152
  25. package/dist/devices.js +0 -64
  26. package/dist/flagAssembler.js +0 -29
  27. package/dist/init.js +0 -79
  28. package/dist/launchd.js +0 -154
  29. package/dist/lifecycle.js +0 -37
  30. package/dist/mcpConfig.js +0 -12
  31. package/dist/repo.js +0 -52
  32. package/dist/session.js +0 -58
  33. package/dist/setup.js +0 -60
  34. package/dist/spawner.js +0 -140
  35. package/dist/windows-service.js +0 -142
  36. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
  37. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
  38. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
  39. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
  40. package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
  41. package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
  42. package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
package/dist/agents.js ADDED
@@ -0,0 +1,54 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+ const AGENT_COMMANDS = ['claude', 'codex'];
5
+ const DEFAULT_FALLBACK_PATHS = {
6
+ claude: [
7
+ join(homedir(), '.local', 'bin', 'claude'),
8
+ '/opt/homebrew/bin/claude',
9
+ '/usr/local/bin/claude',
10
+ ],
11
+ codex: [
12
+ process.env.CODEX_CLI_PATH ?? '',
13
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
14
+ join(homedir(), '.local', 'bin', 'codex'),
15
+ '/opt/homebrew/bin/codex',
16
+ '/usr/local/bin/codex',
17
+ ].filter(Boolean),
18
+ };
19
+ /** True if `command` resolves to an executable file in some directory on `PATH`. */
20
+ function isExecutable(path) {
21
+ try {
22
+ accessSync(path, constants.X_OK);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * Resolves the executable used for detection and MCP registration. PATH is
31
+ * preferred, then well-known install locations. The macOS Codex app bundles
32
+ * its CLI inside the application, so PATH-only detection misses it in a
33
+ * normal Terminal session.
34
+ */
35
+ export function resolveAgentCommand(command, options = {}) {
36
+ const dirs = (options.path ?? process.env.PATH ?? '').split(delimiter).filter(Boolean);
37
+ for (const dir of dirs) {
38
+ const path = join(dir, command);
39
+ try {
40
+ if (isExecutable(path))
41
+ return path;
42
+ }
43
+ catch { /* continue */ }
44
+ }
45
+ const fallbackPaths = options.fallbackPaths?.[command] ?? DEFAULT_FALLBACK_PATHS[command];
46
+ return fallbackPaths.find(isExecutable) ?? null;
47
+ }
48
+ /**
49
+ * Detects which supported agents (spec §4: "claude / codex on PATH") are
50
+ * installed and available on this machine, in a stable order.
51
+ */
52
+ export function detectAgents(options = {}) {
53
+ return AGENT_COMMANDS.filter((agent) => Boolean(resolveAgentCommand(agent, options)));
54
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Canonical repository-relative path used by work reservations.
3
+ *
4
+ * The empty string represents the repository root. Absolute paths and parent
5
+ * traversal are rejected before a claim reaches Postgres, so two agents use
6
+ * the same segment-aware overlap rules on every platform.
7
+ */
8
+ export function normalizeReservationPath(input) {
9
+ if (input.includes('\0'))
10
+ throw new Error('Reservation paths cannot contain NUL bytes.');
11
+ const normalizedSeparators = input.trim().replaceAll('\\', '/');
12
+ if (/^[a-zA-Z]:\//.test(normalizedSeparators) || normalizedSeparators.startsWith('/')) {
13
+ throw new Error(`Reservation path must be relative to its repository: ${input}`);
14
+ }
15
+ const parts = [];
16
+ for (const part of normalizedSeparators.split('/')) {
17
+ if (!part || part === '.')
18
+ continue;
19
+ if (part === '..')
20
+ throw new Error(`Reservation path cannot traverse outside its repository: ${input}`);
21
+ parts.push(part);
22
+ }
23
+ return parts.join('/');
24
+ }
25
+ /** Segment-aware prefix overlap: `web` overlaps `web/src`, not `webapp`. */
26
+ export function reservationPathsOverlap(left, right) {
27
+ const a = normalizeReservationPath(left);
28
+ const b = normalizeReservationPath(right);
29
+ if (!a || !b)
30
+ return true;
31
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
32
+ }
33
+ /** Returns the first conflicting pair, or null when all proposed scopes are disjoint. */
34
+ export function findReservationOverlap(existing, proposed) {
35
+ for (const candidate of proposed) {
36
+ for (const active of existing) {
37
+ if (active.repositoryId === candidate.repositoryId &&
38
+ reservationPathsOverlap(active.path, candidate.path)) {
39
+ return { existing: active, proposed: candidate };
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,213 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { getMachineIdentity } from '../config.js';
5
+ import { select } from '../prompt.js';
6
+ import { getClient } from '../supabase.js';
7
+ import { inspectLocalCheckout, observeLocalWorkspace, registerMachineWorkspace, } from './run.js';
8
+ import { chooseTopologyProject } from './link.js';
9
+ export function parseFetchArgs(argv) {
10
+ const flags = { yes: false };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const token = argv[i];
13
+ if (token === '--yes' || token === '-y') {
14
+ flags.yes = true;
15
+ continue;
16
+ }
17
+ if (token === '--project' || token === '--destination') {
18
+ const value = argv[i + 1];
19
+ if (!value || value.startsWith('--'))
20
+ throw new Error(`${token} requires a value`);
21
+ if (token === '--project') {
22
+ if (flags.project)
23
+ throw new Error('--project may only be provided once');
24
+ flags.project = value;
25
+ }
26
+ else {
27
+ if (flags.destination)
28
+ throw new Error('--destination may only be provided once');
29
+ flags.destination = value;
30
+ }
31
+ i++;
32
+ continue;
33
+ }
34
+ if (token.startsWith('-'))
35
+ throw new Error(`Unknown argument: ${token}`);
36
+ if (flags.repository)
37
+ throw new Error('Provide only one repository id');
38
+ flags.repository = token;
39
+ }
40
+ return flags;
41
+ }
42
+ function one(value) {
43
+ return Array.isArray(value) ? value[0] ?? null : value;
44
+ }
45
+ export async function loadProjectFetchTargets(client, projectId) {
46
+ const { data, error } = await client
47
+ .from('project_repository_scopes')
48
+ .select('repository_scope_id,required,' +
49
+ 'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
50
+ 'repository:repositories!repository_scopes_repository_id_fkey(id,name,remote_key))')
51
+ .eq('project_id', projectId)
52
+ .eq('active', true);
53
+ if (error)
54
+ throw new Error(`Could not load project repositories: ${error.message}`);
55
+ const targets = new Map();
56
+ for (const raw of (data ?? [])) {
57
+ const scope = one(raw.scope);
58
+ const repository = scope ? one(scope.repository) : null;
59
+ if (!repository)
60
+ continue;
61
+ const target = targets.get(repository.id) ?? {
62
+ repositoryId: repository.id,
63
+ repositoryName: repository.name,
64
+ remoteKey: repository.remote_key,
65
+ scopeIds: [],
66
+ required: false,
67
+ };
68
+ target.scopeIds.push(raw.repository_scope_id);
69
+ target.required ||= raw.required;
70
+ targets.set(repository.id, target);
71
+ }
72
+ return [...targets.values()]
73
+ .map((target) => ({ ...target, scopeIds: [...new Set(target.scopeIds)].sort() }))
74
+ .sort((left, right) => left.repositoryName.localeCompare(right.repositoryName) || left.repositoryId.localeCompare(right.repositoryId));
75
+ }
76
+ export function cloneUrlForRemoteKey(remoteKey) {
77
+ const slash = remoteKey.indexOf('/');
78
+ if (slash < 1)
79
+ throw new Error(`Repository remote is not fetchable: ${remoteKey}`);
80
+ const host = remoteKey.slice(0, slash);
81
+ const path = remoteKey.slice(slash + 1);
82
+ return host.includes(':')
83
+ ? `ssh://git@${host}/${path}.git`
84
+ : `https://${host}/${path}.git`;
85
+ }
86
+ async function chooseFetchTarget(targets, repositoryId) {
87
+ if (repositoryId) {
88
+ const match = targets.find((target) => target.repositoryId === repositoryId || target.scopeIds.includes(repositoryId));
89
+ if (!match)
90
+ throw new Error(`Repository or scope "${repositoryId}" is not active in this project.`);
91
+ return match;
92
+ }
93
+ if (targets.length === 0)
94
+ throw new Error('This project has no missing repositories to fetch.');
95
+ if (targets.length === 1)
96
+ return targets[0];
97
+ if (!process.stdin.isTTY) {
98
+ throw new Error(`Choose one missing repository id: ` +
99
+ targets.map((target) => `${target.repositoryName} (${target.repositoryId})`).join(', '));
100
+ }
101
+ return select('Which missing repository should CTRL+SPC fetch?', targets.map((target) => ({
102
+ label: `${target.repositoryName} · ${target.required ? 'required' : 'optional'} · ${target.remoteKey}`,
103
+ value: target,
104
+ })));
105
+ }
106
+ async function confirmFetch(target, destination, approved) {
107
+ if (approved)
108
+ return true;
109
+ if (!process.stdin.isTTY) {
110
+ throw new Error('Fetching requires explicit approval. Re-run with --yes after reviewing the repository and destination.');
111
+ }
112
+ return select(`Clone ${target.remoteKey} to ${destination}?`, [
113
+ { label: 'No, leave it unavailable', value: false },
114
+ { label: 'Yes, clone and link it', value: true },
115
+ ]);
116
+ }
117
+ function defaultClone(remote, destination) {
118
+ execFileSync('git', ['clone', remote, destination], { stdio: 'inherit' });
119
+ }
120
+ export async function fetchRepository(argv = [], deps = {}) {
121
+ let flags;
122
+ try {
123
+ flags = parseFetchArgs(argv);
124
+ }
125
+ catch (err) {
126
+ console.error(err instanceof Error ? err.message : String(err));
127
+ console.error('Usage: ctrl-spc fetch [repository-id] [--project <project-id>] [--destination <path>] [--yes]');
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ const cwd = process.cwd();
132
+ const observe = deps.observeWorkspace ?? observeLocalWorkspace;
133
+ const observation = observe(cwd);
134
+ const client = await (deps.getClient ?? getClient)();
135
+ const { data: userData, error: userError } = await client.auth.getUser();
136
+ if (userError || !userData.user) {
137
+ console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ try {
142
+ const observedRemoteKeys = observation.checkouts
143
+ .map((checkout) => checkout.remoteKey)
144
+ .filter((remote) => remote !== null);
145
+ const project = await chooseTopologyProject(client, flags.project, observedRemoteKeys, { action: 'fetch', allowAllWhenNoMatch: true });
146
+ const machine = getMachineIdentity();
147
+ const workspace = await registerMachineWorkspace(client, userData.user.id, project.id, machine, cwd, { observeWorkspace: () => observation, inspectCheckout: deps.inspectCheckout });
148
+ const allTargets = await loadProjectFetchTargets(client, project.id);
149
+ const { data: checkoutData, error: checkoutError } = await client
150
+ .from('repository_checkouts')
151
+ .select('repository_id,availability,verified_remote_key')
152
+ .eq('machine_workspace_id', workspace.workspaceId);
153
+ if (checkoutError)
154
+ throw new Error(`Could not load local repository checkouts: ${checkoutError.message}`);
155
+ const available = new Set((checkoutData ?? [])
156
+ .filter((checkout) => checkout.availability === 'available')
157
+ .map((checkout) => `${checkout.repository_id}\u0000${checkout.verified_remote_key ?? ''}`));
158
+ const missingTargets = allTargets.filter((target) => !available.has(`${target.repositoryId}\u0000${target.remoteKey}`));
159
+ if (flags.repository) {
160
+ const requested = allTargets.find((target) => target.repositoryId === flags.repository || target.scopeIds.includes(flags.repository));
161
+ if (requested && !missingTargets.includes(requested)) {
162
+ throw new Error(`${requested.repositoryName} is already available on this machine; nothing was cloned.`);
163
+ }
164
+ }
165
+ const target = await chooseFetchTarget(missingTargets, flags.repository);
166
+ const folderName = basename(target.remoteKey);
167
+ const destination = resolve(cwd, flags.destination ?? join(observation.workspaceRoot, folderName));
168
+ const inspect = deps.inspectCheckout ?? inspectLocalCheckout;
169
+ let checkout = existsSync(destination) ? inspect(destination) : null;
170
+ if (existsSync(destination) && checkout?.remoteKey !== target.remoteKey) {
171
+ throw new Error(`Destination already exists but is not ${target.remoteKey}: ${destination}. ` +
172
+ 'Nothing was cloned or linked.');
173
+ }
174
+ const approved = await (deps.confirm ?? confirmFetch)(target, destination, flags.yes);
175
+ if (!approved) {
176
+ console.log(`Fetch declined. ${target.repositoryName} remains unavailable; nothing was cloned or linked.`);
177
+ return;
178
+ }
179
+ if (!checkout) {
180
+ const remote = cloneUrlForRemoteKey(target.remoteKey);
181
+ try {
182
+ ;
183
+ (deps.clone ?? defaultClone)(remote, destination);
184
+ }
185
+ catch (err) {
186
+ throw new Error(`Clone failed; the repository was not linked. ` +
187
+ `${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+ checkout = inspect(destination);
190
+ }
191
+ if (!checkout || checkout.remoteKey !== target.remoteKey) {
192
+ throw new Error(`Clone completed but origin verification failed for ${destination}. ` +
193
+ `Expected ${target.remoteKey}; found ${checkout?.remoteKey ?? 'no origin'}. The checkout was not linked.`);
194
+ }
195
+ const { error: registerError } = await client.from('repository_checkouts').upsert({
196
+ machine_workspace_id: workspace.workspaceId,
197
+ repository_id: target.repositoryId,
198
+ local_path: checkout.localPath,
199
+ availability: 'available',
200
+ verified_remote_key: checkout.remoteKey,
201
+ head_sha: checkout.headSha,
202
+ is_worktree: checkout.isWorktree,
203
+ verified_at: new Date().toISOString(),
204
+ }, { onConflict: 'machine_workspace_id,repository_id,local_path' });
205
+ if (registerError)
206
+ throw new Error(`Could not register fetched checkout: ${registerError.message}`);
207
+ console.log(`Fetched and linked ${target.repositoryName} — ${checkout.localPath}`);
208
+ }
209
+ catch (err) {
210
+ console.error(err instanceof Error ? err.message : String(err));
211
+ process.exitCode = 1;
212
+ }
213
+ }