@tacitai/mcp-server 0.1.0 → 0.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 (69) hide show
  1. package/README.md +3 -3
  2. package/dist/auth.d.ts +32 -0
  3. package/dist/auth.js +64 -0
  4. package/dist/client.d.ts +60 -5
  5. package/dist/client.js +234 -53
  6. package/dist/index.js +128 -106
  7. package/dist/oauth.d.ts +115 -0
  8. package/dist/oauth.js +619 -0
  9. package/dist/prompts.d.ts +2 -0
  10. package/dist/prompts.js +12 -0
  11. package/dist/setup.d.ts +28 -5
  12. package/dist/setup.js +267 -60
  13. package/dist/tools/agents.d.ts +277 -0
  14. package/dist/tools/agents.js +335 -0
  15. package/dist/tools/annotations.d.ts +117 -0
  16. package/dist/tools/annotations.js +130 -0
  17. package/dist/tools/artifacts.d.ts +178 -8
  18. package/dist/tools/artifacts.js +372 -81
  19. package/dist/tools/chronicle.d.ts +3 -0
  20. package/dist/tools/chronicle.js +165 -0
  21. package/dist/tools/connector_write_back.d.ts +3 -0
  22. package/dist/tools/connector_write_back.js +27 -0
  23. package/dist/tools/converse.d.ts +10 -0
  24. package/dist/tools/converse.js +88 -0
  25. package/dist/tools/crews.d.ts +3 -0
  26. package/dist/tools/crews.js +115 -0
  27. package/dist/tools/drafts.d.ts +83 -0
  28. package/dist/tools/drafts.js +86 -0
  29. package/dist/tools/errors.d.ts +15 -0
  30. package/dist/tools/errors.js +21 -0
  31. package/dist/tools/export.d.ts +54 -0
  32. package/dist/tools/export.js +55 -0
  33. package/dist/tools/gm.d.ts +214 -0
  34. package/dist/tools/gm.js +198 -0
  35. package/dist/tools/ingest.d.ts +58 -4
  36. package/dist/tools/ingest.js +109 -76
  37. package/dist/tools/kb.d.ts +544 -0
  38. package/dist/tools/kb.js +687 -0
  39. package/dist/tools/lake-structure.d.ts +3 -0
  40. package/dist/tools/lake-structure.js +126 -0
  41. package/dist/tools/lake.d.ts +932 -0
  42. package/dist/tools/lake.js +1017 -0
  43. package/dist/tools/move.d.ts +200 -0
  44. package/dist/tools/move.js +96 -0
  45. package/dist/tools/outbox.d.ts +274 -0
  46. package/dist/tools/outbox.js +223 -0
  47. package/dist/tools/people.d.ts +3 -0
  48. package/dist/tools/people.js +59 -0
  49. package/dist/tools/pyramid.d.ts +554 -0
  50. package/dist/tools/pyramid.js +758 -0
  51. package/dist/tools/recall.d.ts +2 -0
  52. package/dist/tools/recall.js +28 -17
  53. package/dist/tools/registry.d.ts +17 -0
  54. package/dist/tools/registry.js +197 -0
  55. package/dist/tools/research.d.ts +47 -0
  56. package/dist/tools/research.js +575 -0
  57. package/dist/tools/search.d.ts +9 -0
  58. package/dist/tools/search.js +86 -25
  59. package/dist/tools/sharing.d.ts +63 -0
  60. package/dist/tools/sharing.js +157 -0
  61. package/dist/tools/synthesize.d.ts +54 -2
  62. package/dist/tools/synthesize.js +80 -23
  63. package/dist/tools/web-url.d.ts +57 -0
  64. package/dist/tools/web-url.js +74 -0
  65. package/dist/tools/workspaces.d.ts +3 -0
  66. package/dist/tools/workspaces.js +47 -0
  67. package/dist/transport.d.ts +9 -0
  68. package/dist/transport.js +209 -0
  69. package/package.json +5 -4
package/dist/setup.js CHANGED
@@ -1,67 +1,45 @@
1
- "use strict";
2
1
  // SPDX-License-Identifier: MIT
3
2
  /**
4
3
  * setup — wizard subcommand for @tacit/mcp-server
5
4
  *
6
5
  * Usage:
7
- * node dist/index.js setup [--dry-run] [--api-url <url>] [--token <token>] [--smoke]
6
+ * node dist/index.js setup [--dry-run] [--api-url <url>] [--token <token>] [--profile <name>] [--smoke]
8
7
  *
9
- * Contract (§2.1 of sparc_specification.md, Phase-2 QE resolutions):
8
+ * Contract (§2.1 of sparc_specification.md, Phase-2 QE resolutions + W5 device-auth):
10
9
  * F1 smoke — calls tacit_synthesize via getClient()/postSSE; exits non-zero on empty/error.
11
- * F2 env-ref — writes TACIT_TOKEN as the LITERAL placeholder "${TACIT_TOKEN}" (never the real value).
12
- * Merge — deep-merges ONLY mcpServers.tacit; all other keys preserved.
13
- * Idempotent — same --api-url → byte-identical mcp.json.
10
+ * F2 env-ref — writes TACIT_TOKEN_* as the LITERAL placeholder "${TACIT_TOKEN_*}" (never the real value).
11
+ * Merge — deep-merges ONLY mcpServers[profile]; all other keys preserved.
12
+ * Idempotent — same --api-url + --profile → byte-identical mcp.json.
14
13
  * Secrets — token NEVER written to any output stream.
15
14
  *
15
+ * W5 additions:
16
+ * --profile <name> — profile name (default: 'tacit'); drives env-var name + mcp.json key.
17
+ * Device-auth flow — when --token is NOT provided and token env var is absent, opens browser
18
+ * to ${apiUrl}/auth/device, waits for localhost:9123 callback, exchanges
19
+ * code for token, derives profile from workspace_name (unless --profile set).
20
+ * Metadata file — writes ~/.tacit/profiles/<profile>.json with { expires_at, api_url }.
21
+ *
16
22
  * Security invariants:
17
23
  * - Token is NEVER logged or written to disk.
18
24
  * - Authorization header value excluded from all logging paths.
25
+ * - State nonce generated from crypto.randomBytes (never user-provided).
26
+ * - Tampered state in device-auth callback → exit 1 (S-10/AC-13).
19
27
  */
20
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
- if (k2 === undefined) k2 = k;
22
- var desc = Object.getOwnPropertyDescriptor(m, k);
23
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
- desc = { enumerable: true, get: function() { return m[k]; } };
25
- }
26
- Object.defineProperty(o, k2, desc);
27
- }) : (function(o, m, k, k2) {
28
- if (k2 === undefined) k2 = k;
29
- o[k2] = m[k];
30
- }));
31
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
- Object.defineProperty(o, "default", { enumerable: true, value: v });
33
- }) : function(o, v) {
34
- o["default"] = v;
35
- });
36
- var __importStar = (this && this.__importStar) || (function () {
37
- var ownKeys = function(o) {
38
- ownKeys = Object.getOwnPropertyNames || function (o) {
39
- var ar = [];
40
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
- return ar;
42
- };
43
- return ownKeys(o);
44
- };
45
- return function (mod) {
46
- if (mod && mod.__esModule) return mod;
47
- var result = {};
48
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
- __setModuleDefault(result, mod);
50
- return result;
51
- };
52
- })();
53
- Object.defineProperty(exports, "__esModule", { value: true });
54
- exports.setup = setup;
55
- const fs = __importStar(require("fs"));
56
- const path = __importStar(require("path"));
57
- const os = __importStar(require("os"));
58
- const client_js_1 = require("./client.js");
28
+ import * as fs from 'fs';
29
+ import * as path from 'path';
30
+ import * as os from 'os';
31
+ import * as http from 'http';
32
+ import * as crypto from 'crypto';
33
+ import { execFile } from 'child_process';
34
+ import { TacitClient, TacitApiError } from './client.js';
59
35
  function parseArgs(argv) {
60
36
  const opts = {
61
37
  dryRun: false,
62
38
  apiUrl: null,
63
39
  token: null,
64
40
  smoke: false,
41
+ profile: 'tacit',
42
+ profileExplicit: false,
65
43
  };
66
44
  for (let i = 0; i < argv.length; i++) {
67
45
  const arg = argv[i];
@@ -89,9 +67,44 @@ function parseArgs(argv) {
89
67
  opts.token = next;
90
68
  i++;
91
69
  }
70
+ else if (arg === '--profile') {
71
+ const next = argv[i + 1];
72
+ if (!next || next.startsWith('--')) {
73
+ process.stderr.write('Error: --profile requires a value\n');
74
+ process.exit(1);
75
+ }
76
+ opts.profile = next;
77
+ opts.profileExplicit = true;
78
+ i++;
79
+ }
92
80
  }
93
81
  return opts;
94
82
  }
83
+ // ─── Profile / env-var helpers ────────────────────────────────────────────────
84
+ /**
85
+ * Derive the environment variable name for a given profile name.
86
+ *
87
+ * AC-3, AC-4, AC-10:
88
+ * 'tacit' → 'TACIT_TOKEN'
89
+ * 'tacit-acme' → 'TACIT_TOKEN_ACME'
90
+ * 'my-workspace' → 'TACIT_TOKEN_MY_WORKSPACE'
91
+ */
92
+ export function profileToEnvVar(profile) {
93
+ // Strip leading 'tacit-' prefix if present
94
+ const stripped = profile.startsWith('tacit-') ? profile.slice(6) : profile;
95
+ // Special case: default profile ('tacit') → stripped = '' → 'TACIT_TOKEN'
96
+ if (stripped === '' || stripped === 'tacit') {
97
+ return 'TACIT_TOKEN';
98
+ }
99
+ return 'TACIT_TOKEN_' + stripped.replace(/-/g, '_').toUpperCase();
100
+ }
101
+ /**
102
+ * Derive a profile name from a workspace_name returned by device-auth exchange.
103
+ * AC-12: lowercased, spaces→hyphens, non-alphanumeric-hyphen stripped.
104
+ */
105
+ export function workspaceNameToProfile(name) {
106
+ return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
107
+ }
95
108
  function claudeDir() {
96
109
  return path.join(os.homedir(), '.claude');
97
110
  }
@@ -113,26 +126,32 @@ function readExistingConfig() {
113
126
  return {};
114
127
  }
115
128
  }
116
- function buildTacitEntry(apiUrl) {
129
+ /**
130
+ * Build the MCP server entry for a given profile.
131
+ * The env-var placeholder is the LITERAL string "${TACIT_TOKEN_*}" — never the real value.
132
+ */
133
+ function buildProfileEntry(apiUrl, envVarName) {
117
134
  return {
118
135
  command: 'npx',
119
- args: ['-y', '@tacit/mcp-server'],
136
+ args: ['-y', '@tacitai/mcp-server'],
120
137
  env: {
121
138
  // F2: literal placeholder — Claude Code expands ${VAR} at launch; real token NEVER on disk.
122
- TACIT_TOKEN: '${TACIT_TOKEN}',
139
+ [envVarName]: `\${${envVarName}}`,
123
140
  // TACIT_API_URL is a non-secret URL; written as-is.
124
141
  TACIT_API_URL: apiUrl,
125
142
  },
126
143
  };
127
144
  }
128
- function mergeConfig(existing, tacitEntry) {
145
+ /**
146
+ * Deep-merge ONLY the mcpServers[profileName] key; all other keys preserved.
147
+ */
148
+ function mergeConfig(existing, profileName, entry) {
129
149
  const servers = existing.mcpServers ?? {};
130
- // Deep-merge ONLY tacit key; all other mcpServers entries and top-level keys preserved.
131
150
  const merged = {
132
151
  ...existing,
133
152
  mcpServers: {
134
153
  ...servers,
135
- tacit: tacitEntry,
154
+ [profileName]: entry,
136
155
  },
137
156
  };
138
157
  return merged;
@@ -140,10 +159,159 @@ function mergeConfig(existing, tacitEntry) {
140
159
  function configToBytes(cfg) {
141
160
  return JSON.stringify(cfg, null, 2) + '\n';
142
161
  }
162
+ // ─── Metadata file ────────────────────────────────────────────────────────────
163
+ /**
164
+ * Write ~/.tacit/profiles/<profile>.json with { expires_at, api_url }.
165
+ * Token plaintext is NEVER written here — metadata only.
166
+ */
167
+ function writeProfileMetadata(profile, expiresAt, apiUrl) {
168
+ const metaDir = path.join(os.homedir(), '.tacit', 'profiles');
169
+ fs.mkdirSync(metaDir, { recursive: true });
170
+ fs.writeFileSync(path.join(metaDir, `${profile}.json`), JSON.stringify({ expires_at: expiresAt, api_url: apiUrl }, null, 2));
171
+ }
172
+ // ─── Device-auth flow ─────────────────────────────────────────────────────────
173
+ const DEVICE_AUTH_CALLBACK_PORT = 9123;
174
+ const DEVICE_AUTH_TIMEOUT_MS = 120_000;
175
+ /**
176
+ * Opens a browser to the device-auth URL and waits for the OAuth callback.
177
+ * Returns the token, expiry, and workspace name from the exchange response.
178
+ *
179
+ * Security:
180
+ * - State nonce is generated via crypto.randomBytes (never user-provided).
181
+ * - Tampered state → exit 1 (S-10 / AC-13).
182
+ * - Token plaintext is NEVER logged.
183
+ *
184
+ * Error paths:
185
+ * - Port 9123 occupied → print error + --token fallback instructions → exit 1 (AC-15).
186
+ * - 120s timeout → exit 1 (AC-15).
187
+ * - Browser open fails → print URL to stdout for manual copy-paste (AC-16 fallback).
188
+ */
189
+ async function deviceAuthFlow(apiUrl) {
190
+ // 1. Generate a cryptographically random state nonce (32 bytes, hex).
191
+ const stateNonce = crypto.randomBytes(32).toString('hex');
192
+ return new Promise((resolve, reject) => {
193
+ let server = null;
194
+ let timeoutHandle = null;
195
+ const cleanup = () => {
196
+ if (timeoutHandle)
197
+ clearTimeout(timeoutHandle);
198
+ if (server) {
199
+ server.close(() => { });
200
+ }
201
+ };
202
+ // 2. Start localhost:9123 HTTP server for callback.
203
+ server = http.createServer(async (req, res) => {
204
+ const reqUrl = req.url ?? '';
205
+ if (!reqUrl.startsWith('/callback')) {
206
+ res.writeHead(404);
207
+ res.end('Not found');
208
+ return;
209
+ }
210
+ const parsedUrl = new URL(reqUrl, `http://localhost:${DEVICE_AUTH_CALLBACK_PORT}`);
211
+ const returnedState = parsedUrl.searchParams.get('state') ?? '';
212
+ const code = parsedUrl.searchParams.get('code') ?? '';
213
+ // 6a. Verify state nonce — tampered state → exit 1 (S-10/AC-13).
214
+ if (returnedState !== stateNonce) {
215
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
216
+ res.end('Invalid state parameter.');
217
+ cleanup();
218
+ reject(new Error('CSRF state mismatch: state nonce did not match. Aborting device auth.'));
219
+ return;
220
+ }
221
+ if (!code) {
222
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
223
+ res.end('Missing code parameter.');
224
+ cleanup();
225
+ reject(new Error('Device auth callback missing code parameter.'));
226
+ return;
227
+ }
228
+ // 6c. Exchange code for token.
229
+ try {
230
+ res.writeHead(200, { 'Content-Type': 'text/html' });
231
+ res.end('<html><body><h1>Authentication complete. You may close this tab.</h1></body></html>');
232
+ const exchangeRes = await fetch(`${apiUrl}/api/v1/auth/device-exchange`, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({ code }),
236
+ });
237
+ if (!exchangeRes.ok) {
238
+ const errBody = await exchangeRes.text().catch(() => '');
239
+ cleanup();
240
+ reject(new Error(`Device auth exchange failed (${exchangeRes.status}): ${errBody}`));
241
+ return;
242
+ }
243
+ const data = await exchangeRes.json();
244
+ const token = typeof data['token'] === 'string' ? data['token'] : '';
245
+ const expiresAt = typeof data['expires_at'] === 'string' ? data['expires_at'] : '';
246
+ const workspaceName = typeof data['workspace_name'] === 'string' ? data['workspace_name'] : 'tacit';
247
+ if (!token) {
248
+ cleanup();
249
+ reject(new Error('Device auth exchange returned empty token.'));
250
+ return;
251
+ }
252
+ cleanup();
253
+ resolve({ token, expiresAt, workspaceName });
254
+ }
255
+ catch (err) {
256
+ cleanup();
257
+ reject(err);
258
+ }
259
+ });
260
+ server.on('error', (err) => {
261
+ if (err.code === 'EADDRINUSE') {
262
+ process.stderr.write(`Error: port ${DEVICE_AUTH_CALLBACK_PORT} is already in use.\n` +
263
+ `Cannot start device-auth callback server.\n` +
264
+ `Use --token <your-token> instead:\n` +
265
+ ` npx @tacitai/mcp-server setup --api-url ${apiUrl} --token <your-token>\n` +
266
+ `Get your token from: ${apiUrl}/settings\n`);
267
+ process.exit(1);
268
+ }
269
+ cleanup();
270
+ reject(err);
271
+ });
272
+ server.listen(DEVICE_AUTH_CALLBACK_PORT, '127.0.0.1', () => {
273
+ // 3. Build device URL.
274
+ const callbackUrl = `http://localhost:${DEVICE_AUTH_CALLBACK_PORT}/callback`;
275
+ const deviceUrl = `${apiUrl}/auth/device?callback=${encodeURIComponent(callbackUrl)}&state=${stateNonce}`;
276
+ // 4. Try to open the browser.
277
+ // Use execFile with an argument array (not exec) to avoid shell injection
278
+ // — deviceUrl is built from user-supplied --api-url and must not be
279
+ // interpolated into a shell command string.
280
+ const platform = process.platform;
281
+ const [openBin, openArgs] = platform === 'win32'
282
+ ? ['cmd', ['/c', 'start', '', deviceUrl]]
283
+ : platform === 'darwin'
284
+ ? ['open', [deviceUrl]]
285
+ : ['xdg-open', [deviceUrl]];
286
+ execFile(openBin, openArgs, (err) => {
287
+ if (err) {
288
+ // AC-16 fallback: print URL for manual copy-paste.
289
+ process.stdout.write(`Could not open browser automatically.\n` +
290
+ `Open this URL in your browser to authenticate:\n` +
291
+ ` ${deviceUrl}\n`);
292
+ }
293
+ else {
294
+ process.stdout.write(`Opening browser for authentication...\n` +
295
+ `If the browser does not open, visit:\n` +
296
+ ` ${deviceUrl}\n`);
297
+ }
298
+ });
299
+ // 5. Timeout guard (AC-15).
300
+ timeoutHandle = setTimeout(() => {
301
+ cleanup();
302
+ process.stderr.write(`Error: device-auth timed out after ${DEVICE_AUTH_TIMEOUT_MS / 1000}s.\n` +
303
+ `Use --token <your-token> instead:\n` +
304
+ ` npx @tacitai/mcp-server setup --api-url ${apiUrl} --token <your-token>\n` +
305
+ `Get your token from: ${apiUrl}/settings\n`);
306
+ process.exit(1);
307
+ }, DEVICE_AUTH_TIMEOUT_MS);
308
+ });
309
+ });
310
+ }
143
311
  // ─── Smoke synthesis ──────────────────────────────────────────────────────────
144
312
  const SMOKE_QUERY = 'What is in this knowledge base?';
145
313
  async function runSmoke(apiUrl, token) {
146
- const client = new client_js_1.TacitClient({
314
+ const client = new TacitClient({
147
315
  apiUrl,
148
316
  token: token ?? process.env['TACIT_TOKEN'] ?? '',
149
317
  });
@@ -157,7 +325,7 @@ async function runSmoke(apiUrl, token) {
157
325
  }
158
326
  }
159
327
  catch (err) {
160
- const msg = err instanceof client_js_1.TacitApiError
328
+ const msg = err instanceof TacitApiError
161
329
  ? `API error ${err.statusCode}: ${err.body}`
162
330
  : err instanceof Error ? err.message : String(err);
163
331
  process.stderr.write(`SMOKE_SYNTH_FAIL: ${msg}\n`);
@@ -172,23 +340,52 @@ async function runSmoke(apiUrl, token) {
172
340
  process.exit(0);
173
341
  }
174
342
  // ─── Main export ──────────────────────────────────────────────────────────────
175
- async function setup(argv) {
343
+ export async function setup(argv) {
176
344
  const opts = parseArgs(argv);
177
- // Resolve api-url: flag > env. Token: flag > env (never logged).
345
+ // Resolve api-url: flag > env.
178
346
  const apiUrl = opts.apiUrl ?? process.env['TACIT_API_URL'] ?? null;
179
- const token = opts.token ?? process.env['TACIT_TOKEN'] ?? null;
180
347
  if (!apiUrl) {
181
348
  process.stderr.write('Error: no API URL configured.\n' +
182
349
  'Provide --api-url <url> or set TACIT_API_URL environment variable.\n' +
183
- 'Example: node dist/index.js setup --api-url http://localhost:7473 --token <your-token>\n');
350
+ 'Example: npx @tacitai/mcp-server setup --api-url https://tacit-beta-prod.fly.dev --token <your-token>\n' +
351
+ '\nGet your token from: https://beta.tacitai.app → Settings → Bearer token\n');
184
352
  process.exit(1);
185
353
  }
186
- const tacitEntry = buildTacitEntry(apiUrl);
354
+ // Determine profile and token.
355
+ let profile = opts.profile;
356
+ let token = opts.token;
357
+ let expiresAt = '';
358
+ // Check env var for this profile (to support existing token path).
359
+ const envVarNameForProfile = profileToEnvVar(profile);
360
+ const envToken = process.env[envVarNameForProfile] ?? process.env['TACIT_TOKEN'] ?? null;
361
+ if (!token && !envToken && !opts.dryRun) {
362
+ // Neither --token nor env set and not a dry-run → run automated device-auth flow.
363
+ let deviceResult;
364
+ try {
365
+ deviceResult = await deviceAuthFlow(apiUrl);
366
+ }
367
+ catch (err) {
368
+ const msg = err instanceof Error ? err.message : String(err);
369
+ process.stderr.write(`Error during device authentication: ${msg}\n`);
370
+ process.exit(1);
371
+ return; // unreachable; satisfies TypeScript
372
+ }
373
+ // Token from device flow — use in memory only (NEVER log/write).
374
+ token = deviceResult.token;
375
+ expiresAt = deviceResult.expiresAt;
376
+ // AC-12: derive profile from workspace_name unless --profile was explicit.
377
+ if (!opts.profileExplicit && deviceResult.workspaceName) {
378
+ profile = workspaceNameToProfile(deviceResult.workspaceName);
379
+ }
380
+ }
381
+ // Final env-var name derived from (possibly updated) profile.
382
+ const envVarName = profileToEnvVar(profile);
383
+ const profileEntry = buildProfileEntry(apiUrl, envVarName);
187
384
  const existing = readExistingConfig();
188
- const merged = mergeConfig(existing, tacitEntry);
385
+ const merged = mergeConfig(existing, profile, profileEntry);
189
386
  const output = configToBytes(merged);
190
387
  // D10: emit confirmation string BEFORE writing (also printed in dry-run).
191
- process.stdout.write('Writing tacit entry to ~/.claude/mcp.json...\n');
388
+ process.stdout.write(`Writing tacit entry to ~/.claude/mcp.json (profile: ${profile})...\n`);
192
389
  if (opts.dryRun) {
193
390
  process.stdout.write('(dry-run) Would write:\n');
194
391
  process.stdout.write(output);
@@ -202,6 +399,16 @@ async function setup(argv) {
202
399
  }
203
400
  const dest = mcpJsonPath();
204
401
  fs.writeFileSync(dest, output, { encoding: 'utf-8', mode: 0o600 });
402
+ // AC-12: write ~/.tacit/profiles/<profile>.json metadata (token NOT included).
403
+ if (expiresAt) {
404
+ writeProfileMetadata(profile, expiresAt, apiUrl);
405
+ }
406
+ // AC-11: print export snippet to stdout so user can set the env var.
407
+ // Token itself is NOT printed here — user must have received it through the device flow.
408
+ process.stdout.write(`\nSetup complete. Add this to your shell profile:\n` +
409
+ ` export ${envVarName}=<your-token>\n` +
410
+ `\nOr for this session:\n` +
411
+ ` export ${envVarName}=<your-token>\n`);
205
412
  if (opts.smoke) {
206
413
  await runSmoke(apiUrl, token);
207
414
  // runSmoke calls process.exit internally; return here for type safety.