switchman-dev 0.1.5 → 0.1.6

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,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "switchman": {
4
+ "command": "switchman-mcp",
5
+ "args": []
6
+ }
7
+ }
8
+ }
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "switchman": {
4
+ "command": "switchman-mcp",
5
+ "args": []
6
+ }
7
+ }
8
+ }
package/README.md CHANGED
@@ -131,6 +131,7 @@ More help:
131
131
  - [Status and recovery](docs/status-and-recovery.md)
132
132
  - [Merge queue](docs/merge-queue.md)
133
133
  - [Stale lease policy](docs/stale-lease-policy.md)
134
+ - [Telemetry](docs/telemetry.md)
134
135
 
135
136
  ## More docs
136
137
 
@@ -138,6 +139,7 @@ More help:
138
139
  - [Pipelines and PRs](docs/pipelines.md)
139
140
  - [Stale lease policy](docs/stale-lease-policy.md)
140
141
  - [MCP tools](docs/mcp-tools.md)
142
+ - [Telemetry](docs/telemetry.md)
141
143
  - [Command reference](docs/command-reference.md)
142
144
 
143
145
  ## Feedback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchman-dev",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Project manager for AI coding assistants running safely on one codebase",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli/index.js CHANGED
@@ -44,6 +44,16 @@ import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCi
44
44
  import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
45
45
  import { buildQueueStatusSummary, runMergeQueue } from '../core/queue.js';
46
46
  import { DEFAULT_LEASE_POLICY, loadLeasePolicy, writeLeasePolicy } from '../core/policy.js';
47
+ import {
48
+ captureTelemetryEvent,
49
+ disableTelemetry,
50
+ enableTelemetry,
51
+ getTelemetryConfigPath,
52
+ getTelemetryRuntimeConfig,
53
+ loadTelemetryConfig,
54
+ maybePromptForTelemetry,
55
+ sendTelemetryEvent,
56
+ } from '../core/telemetry.js';
47
57
 
48
58
  function installMcpConfig(targetDirs) {
49
59
  return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
@@ -224,6 +234,20 @@ function printErrorWithNext(message, nextCommand = null) {
224
234
  }
225
235
  }
226
236
 
237
+ async function maybeCaptureTelemetry(event, properties = {}, { homeDir = null } = {}) {
238
+ try {
239
+ await maybePromptForTelemetry({ homeDir: homeDir || undefined });
240
+ await captureTelemetryEvent(event, {
241
+ app_version: program.version(),
242
+ os: process.platform,
243
+ node_version: process.version,
244
+ ...properties,
245
+ }, { homeDir: homeDir || undefined });
246
+ } catch {
247
+ // Telemetry must never block CLI usage.
248
+ }
249
+ }
250
+
227
251
  function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
228
252
  const dbPath = join(repoRoot, '.switchman', 'switchman.db');
229
253
  const rootMcpPath = join(repoRoot, '.mcp.json');
@@ -1133,7 +1157,7 @@ Examples:
1133
1157
  switchman setup --agents 5
1134
1158
  switchman setup --agents 3 --prefix team
1135
1159
  `)
1136
- .action((opts) => {
1160
+ .action(async (opts) => {
1137
1161
  const agentCount = parseInt(opts.agents);
1138
1162
 
1139
1163
  if (isNaN(agentCount) || agentCount < 1 || agentCount > 10) {
@@ -1217,6 +1241,10 @@ Examples:
1217
1241
 
1218
1242
  const verification = collectSetupVerification(repoRoot);
1219
1243
  renderSetupVerification(verification, { compact: true });
1244
+ await maybeCaptureTelemetry('setup_completed', {
1245
+ agent_count: agentCount,
1246
+ verification_ok: verification.ok,
1247
+ });
1220
1248
 
1221
1249
  } catch (err) {
1222
1250
  spinner.fail(err.message);
@@ -1236,7 +1264,7 @@ Examples:
1236
1264
 
1237
1265
  Use this after setup or whenever editor/config wiring feels off.
1238
1266
  `)
1239
- .action((opts) => {
1267
+ .action(async (opts) => {
1240
1268
  const repoRoot = getRepo();
1241
1269
  const report = collectSetupVerification(repoRoot, { homeDir: opts.home || null });
1242
1270
 
@@ -1247,6 +1275,10 @@ Use this after setup or whenever editor/config wiring feels off.
1247
1275
  }
1248
1276
 
1249
1277
  renderSetupVerification(report);
1278
+ await maybeCaptureTelemetry(report.ok ? 'verify_setup_passed' : 'verify_setup_failed', {
1279
+ check_count: report.checks.length,
1280
+ next_step_count: report.next_steps.length,
1281
+ }, { homeDir: opts.home || null });
1250
1282
  if (!report.ok) process.exitCode = 1;
1251
1283
  });
1252
1284
 
@@ -1254,6 +1286,101 @@ Use this after setup or whenever editor/config wiring feels off.
1254
1286
  // ── mcp ───────────────────────────────────────────────────────────────────────
1255
1287
 
1256
1288
  const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
1289
+ const telemetryCmd = program.command('telemetry').description('Control anonymous opt-in telemetry for Switchman');
1290
+
1291
+ telemetryCmd
1292
+ .command('status')
1293
+ .description('Show whether telemetry is enabled and where events would be sent')
1294
+ .option('--home <path>', 'Override the home directory for telemetry config')
1295
+ .option('--json', 'Output raw JSON')
1296
+ .action((opts) => {
1297
+ const config = loadTelemetryConfig(opts.home || undefined);
1298
+ const runtime = getTelemetryRuntimeConfig();
1299
+ const payload = {
1300
+ enabled: config.telemetry_enabled === true,
1301
+ configured: Boolean(runtime.apiKey) && !runtime.disabled,
1302
+ install_id: config.telemetry_install_id,
1303
+ destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
1304
+ config_path: getTelemetryConfigPath(opts.home || undefined),
1305
+ };
1306
+
1307
+ if (opts.json) {
1308
+ console.log(JSON.stringify(payload, null, 2));
1309
+ return;
1310
+ }
1311
+
1312
+ console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
1313
+ console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
1314
+ console.log(`Config file: ${chalk.dim(payload.config_path)}`);
1315
+ if (payload.install_id) {
1316
+ console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
1317
+ }
1318
+ });
1319
+
1320
+ telemetryCmd
1321
+ .command('enable')
1322
+ .description('Enable anonymous telemetry for setup and operator workflows')
1323
+ .option('--home <path>', 'Override the home directory for telemetry config')
1324
+ .action((opts) => {
1325
+ const runtime = getTelemetryRuntimeConfig();
1326
+ if (!runtime.apiKey || runtime.disabled) {
1327
+ printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
1328
+ process.exitCode = 1;
1329
+ return;
1330
+ }
1331
+ const result = enableTelemetry(opts.home || undefined);
1332
+ console.log(`${chalk.green('✓')} Telemetry enabled`);
1333
+ console.log(` ${chalk.dim(result.path)}`);
1334
+ });
1335
+
1336
+ telemetryCmd
1337
+ .command('disable')
1338
+ .description('Disable anonymous telemetry')
1339
+ .option('--home <path>', 'Override the home directory for telemetry config')
1340
+ .action((opts) => {
1341
+ const result = disableTelemetry(opts.home || undefined);
1342
+ console.log(`${chalk.green('✓')} Telemetry disabled`);
1343
+ console.log(` ${chalk.dim(result.path)}`);
1344
+ });
1345
+
1346
+ telemetryCmd
1347
+ .command('test')
1348
+ .description('Send one test telemetry event and report whether delivery succeeded')
1349
+ .option('--home <path>', 'Override the home directory for telemetry config')
1350
+ .option('--json', 'Output raw JSON')
1351
+ .action(async (opts) => {
1352
+ const result = await sendTelemetryEvent('telemetry_test', {
1353
+ app_version: program.version(),
1354
+ os: process.platform,
1355
+ node_version: process.version,
1356
+ source: 'switchman-cli-test',
1357
+ }, { homeDir: opts.home || undefined });
1358
+
1359
+ if (opts.json) {
1360
+ console.log(JSON.stringify(result, null, 2));
1361
+ if (!result.ok) process.exitCode = 1;
1362
+ return;
1363
+ }
1364
+
1365
+ if (result.ok) {
1366
+ console.log(`${chalk.green('✓')} Telemetry test event delivered`);
1367
+ console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
1368
+ if (result.status) {
1369
+ console.log(` ${chalk.dim('status:')} ${result.status}`);
1370
+ }
1371
+ return;
1372
+ }
1373
+
1374
+ printErrorWithNext(`Telemetry test failed (${result.reason || 'unknown_error'}).`, 'switchman telemetry status');
1375
+ console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
1376
+ if (result.status) {
1377
+ console.log(` ${chalk.dim('status:')} ${result.status}`);
1378
+ }
1379
+ if (result.error) {
1380
+ console.log(` ${chalk.dim('error:')} ${result.error}`);
1381
+ }
1382
+ process.exitCode = 1;
1383
+ });
1257
1384
 
1258
1385
  mcpCmd
1259
1386
  .command('install')
@@ -1714,6 +1841,13 @@ Examples:
1714
1841
 
1715
1842
  if (aggregate.processed.length === 0) {
1716
1843
  console.log(chalk.dim('No queued merge items.'));
1844
+ await maybeCaptureTelemetry('queue_used', {
1845
+ watch,
1846
+ cycles: aggregate.cycles,
1847
+ processed_count: 0,
1848
+ merged_count: 0,
1849
+ blocked_count: 0,
1850
+ });
1717
1851
  return;
1718
1852
  }
1719
1853
 
@@ -1728,6 +1862,14 @@ Examples:
1728
1862
  if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
1729
1863
  }
1730
1864
  }
1865
+
1866
+ await maybeCaptureTelemetry('queue_used', {
1867
+ watch,
1868
+ cycles: aggregate.cycles,
1869
+ processed_count: aggregate.processed.length,
1870
+ merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
1871
+ blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
1872
+ });
1731
1873
  } catch (err) {
1732
1874
  console.error(chalk.red(err.message));
1733
1875
  process.exitCode = 1;
@@ -2834,6 +2976,13 @@ Use this first when the repo feels stuck.
2834
2976
  if (opts.json) break;
2835
2977
  sleepSync(watchIntervalMs);
2836
2978
  }
2979
+
2980
+ if (watch) {
2981
+ await maybeCaptureTelemetry('status_watch_used', {
2982
+ cycles,
2983
+ interval_ms: watchIntervalMs,
2984
+ });
2985
+ }
2837
2986
  });
2838
2987
 
2839
2988
  program
@@ -3145,6 +3294,15 @@ gateCmd
3145
3294
  }
3146
3295
  }
3147
3296
 
3297
+ await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
3298
+ worktree_count: report.worktrees.length,
3299
+ unclaimed_change_count: result.unclaimed_changes.length,
3300
+ file_conflict_count: result.file_conflicts.length,
3301
+ ownership_conflict_count: result.ownership_conflicts.length,
3302
+ semantic_conflict_count: result.semantic_conflicts.length,
3303
+ branch_conflict_count: result.branch_conflicts.length,
3304
+ });
3305
+
3148
3306
  if (!ok) process.exitCode = 1;
3149
3307
  });
3150
3308
 
@@ -0,0 +1,210 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { dirname, join } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import readline from 'readline/promises';
6
+
7
+ export const DEFAULT_TELEMETRY_HOST = 'https://us.i.posthog.com';
8
+
9
+ function normalizeBoolean(value) {
10
+ if (typeof value === 'boolean') return value;
11
+ if (typeof value !== 'string') return null;
12
+ const lowered = value.trim().toLowerCase();
13
+ if (['1', 'true', 'yes', 'y', 'on'].includes(lowered)) return true;
14
+ if (['0', 'false', 'no', 'n', 'off'].includes(lowered)) return false;
15
+ return null;
16
+ }
17
+
18
+ export function getTelemetryConfigPath(homeDir = homedir()) {
19
+ return join(homeDir, '.switchman', 'config.json');
20
+ }
21
+
22
+ export function getTelemetryRuntimeConfig(env = process.env) {
23
+ return {
24
+ apiKey: env.SWITCHMAN_TELEMETRY_API_KEY || env.POSTHOG_API_KEY || null,
25
+ host: env.SWITCHMAN_TELEMETRY_HOST || env.POSTHOG_HOST || DEFAULT_TELEMETRY_HOST,
26
+ disabled: normalizeBoolean(env.SWITCHMAN_TELEMETRY_DISABLED) === true,
27
+ };
28
+ }
29
+
30
+ export function loadTelemetryConfig(homeDir = homedir()) {
31
+ const configPath = getTelemetryConfigPath(homeDir);
32
+ if (!existsSync(configPath)) {
33
+ return {
34
+ telemetry_enabled: null,
35
+ telemetry_install_id: null,
36
+ telemetry_prompted_at: null,
37
+ };
38
+ }
39
+
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
42
+ return {
43
+ telemetry_enabled: typeof parsed?.telemetry_enabled === 'boolean' ? parsed.telemetry_enabled : null,
44
+ telemetry_install_id: typeof parsed?.telemetry_install_id === 'string' ? parsed.telemetry_install_id : null,
45
+ telemetry_prompted_at: typeof parsed?.telemetry_prompted_at === 'string' ? parsed.telemetry_prompted_at : null,
46
+ };
47
+ } catch {
48
+ return {
49
+ telemetry_enabled: null,
50
+ telemetry_install_id: null,
51
+ telemetry_prompted_at: null,
52
+ };
53
+ }
54
+ }
55
+
56
+ export function writeTelemetryConfig(homeDir = homedir(), config = {}) {
57
+ const configPath = getTelemetryConfigPath(homeDir);
58
+ mkdirSync(dirname(configPath), { recursive: true });
59
+ const normalized = {
60
+ telemetry_enabled: typeof config.telemetry_enabled === 'boolean' ? config.telemetry_enabled : null,
61
+ telemetry_install_id: typeof config.telemetry_install_id === 'string' ? config.telemetry_install_id : (config.telemetry_enabled ? randomUUID() : null),
62
+ telemetry_prompted_at: typeof config.telemetry_prompted_at === 'string' ? config.telemetry_prompted_at : null,
63
+ };
64
+ writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`);
65
+ return { path: configPath, config: normalized };
66
+ }
67
+
68
+ export function enableTelemetry(homeDir = homedir()) {
69
+ const current = loadTelemetryConfig(homeDir);
70
+ return writeTelemetryConfig(homeDir, {
71
+ ...current,
72
+ telemetry_enabled: true,
73
+ telemetry_install_id: current.telemetry_install_id || randomUUID(),
74
+ telemetry_prompted_at: new Date().toISOString(),
75
+ });
76
+ }
77
+
78
+ export function disableTelemetry(homeDir = homedir()) {
79
+ const current = loadTelemetryConfig(homeDir);
80
+ return writeTelemetryConfig(homeDir, {
81
+ ...current,
82
+ telemetry_enabled: false,
83
+ telemetry_install_id: current.telemetry_install_id || randomUUID(),
84
+ telemetry_prompted_at: new Date().toISOString(),
85
+ });
86
+ }
87
+
88
+ export async function maybePromptForTelemetry({ homeDir = homedir(), stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
89
+ const runtime = getTelemetryRuntimeConfig(env);
90
+ if (!runtime.apiKey || runtime.disabled) {
91
+ return { prompted: false, enabled: false, available: Boolean(runtime.apiKey) && !runtime.disabled };
92
+ }
93
+
94
+ const current = loadTelemetryConfig(homeDir);
95
+ if (typeof current.telemetry_enabled === 'boolean') {
96
+ return { prompted: false, enabled: current.telemetry_enabled, available: true };
97
+ }
98
+
99
+ if (!stdin?.isTTY || !stdout?.isTTY) {
100
+ return { prompted: false, enabled: false, available: true };
101
+ }
102
+
103
+ const rl = readline.createInterface({ input: stdin, output: stdout });
104
+ try {
105
+ stdout.write('\nHelp improve Switchman?\n');
106
+ stdout.write('If you opt in, Switchman will send anonymous usage events like setup success,\n');
107
+ stdout.write('verify-setup pass, status --watch, queue usage, and gate outcomes.\n');
108
+ stdout.write('No code, prompts, file contents, repo names, or secrets are collected.\n\n');
109
+ const answer = await rl.question('Enable telemetry? [y/N] ');
110
+ const enabled = ['y', 'yes'].includes(String(answer || '').trim().toLowerCase());
111
+ if (enabled) {
112
+ enableTelemetry(homeDir);
113
+ } else {
114
+ disableTelemetry(homeDir);
115
+ }
116
+ return { prompted: true, enabled, available: true };
117
+ } finally {
118
+ rl.close();
119
+ }
120
+ }
121
+
122
+ export async function captureTelemetryEvent(event, properties = {}, {
123
+ homeDir = homedir(),
124
+ env = process.env,
125
+ timeoutMs = 1500,
126
+ } = {}) {
127
+ const result = await sendTelemetryEvent(event, properties, {
128
+ homeDir,
129
+ env,
130
+ timeoutMs,
131
+ });
132
+ return result.ok;
133
+ }
134
+
135
+ export async function sendTelemetryEvent(event, properties = {}, {
136
+ homeDir = homedir(),
137
+ env = process.env,
138
+ timeoutMs = 1500,
139
+ } = {}) {
140
+ const runtime = getTelemetryRuntimeConfig(env);
141
+ if (!runtime.apiKey) {
142
+ return {
143
+ ok: false,
144
+ reason: 'not_configured',
145
+ status: null,
146
+ destination: runtime.host,
147
+ };
148
+ }
149
+ if (runtime.disabled) {
150
+ return {
151
+ ok: false,
152
+ reason: 'disabled_by_env',
153
+ status: null,
154
+ destination: runtime.host,
155
+ };
156
+ }
157
+ if (typeof fetch !== 'function') {
158
+ return {
159
+ ok: false,
160
+ reason: 'fetch_unavailable',
161
+ status: null,
162
+ destination: runtime.host,
163
+ };
164
+ }
165
+
166
+ const config = loadTelemetryConfig(homeDir);
167
+ if (config.telemetry_enabled !== true || !config.telemetry_install_id) {
168
+ return {
169
+ ok: false,
170
+ reason: 'not_enabled',
171
+ status: null,
172
+ destination: runtime.host,
173
+ };
174
+ }
175
+
176
+ try {
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
179
+ const response = await fetch(`${runtime.host.replace(/\/$/, '')}/capture/`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({
183
+ api_key: runtime.apiKey,
184
+ event,
185
+ distinct_id: config.telemetry_install_id,
186
+ properties: {
187
+ source: 'switchman-cli',
188
+ ...properties,
189
+ },
190
+ timestamp: new Date().toISOString(),
191
+ }),
192
+ signal: controller.signal,
193
+ });
194
+ clearTimeout(timer);
195
+ return {
196
+ ok: response.ok,
197
+ reason: response.ok ? null : 'http_error',
198
+ status: response.status,
199
+ destination: runtime.host,
200
+ };
201
+ } catch (err) {
202
+ return {
203
+ ok: false,
204
+ reason: err?.name === 'AbortError' ? 'timeout' : 'network_error',
205
+ status: null,
206
+ destination: runtime.host,
207
+ error: String(err?.message || err),
208
+ };
209
+ }
210
+ }