gramatr 0.3.55 → 0.3.56

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.
package/bin/gramatr.js CHANGED
@@ -3,25 +3,18 @@
3
3
  * gramatr CLI entry point — thin JS wrapper that bootstraps TypeScript.
4
4
  * tsx is a production dependency, resolved directly from node_modules.
5
5
  */
6
- const { spawnSync, execSync } = require('child_process');
6
+ const { spawnSync } = require('child_process');
7
7
  const { join, dirname } = require('path');
8
8
 
9
9
  const script = join(__dirname, 'gramatr.ts');
10
10
  const args = process.argv.slice(2);
11
11
 
12
- // Try bun first (fastest)
13
- let hasBun = false;
14
- try {
15
- execSync('bun --version', { stdio: 'ignore' });
16
- hasBun = true;
17
- } catch {}
18
-
19
- if (hasBun) {
20
- const r = spawnSync('bun', [script, ...args], { stdio: 'inherit' });
21
- process.exit(r.status ?? 1);
22
- }
12
+ // gramatr standardizes on `npx tsx` — see issue #468 for the architectural
13
+ // rationale. bun detection was removed because it silently produced broken
14
+ // hook + statusline configs on hosts where the install-time PATH did not
15
+ // match the runtime PATH of spawned subprocesses.
23
16
 
24
- // Resolve tsx from node_modules (installed as dependency)
17
+ // Resolve tsx from node_modules (tsx is a production dependency)
25
18
  try {
26
19
  const tsxBin = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs');
27
20
  const r = spawnSync(process.execPath, [tsxBin, script, ...args], { stdio: 'inherit' });
package/bin/gramatr.ts CHANGED
@@ -6,14 +6,12 @@ import { homedir } from 'os';
6
6
  import { dirname, join } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { detectTargets, findTarget, summarizeDetectedLocalTargets, type IntegrationTargetId } from '../core/targets.ts';
9
+ import { VERSION } from '../core/version.ts';
9
10
  import { findStaleArtifacts, runLegacyMigration } from '../core/migration.ts';
10
- import { detectTsRunner } from '../core/install.ts';
11
11
  import {
12
12
  formatDetectionLines,
13
13
  formatDoctorLines,
14
- formatInstallMenuLines,
15
14
  formatRemoteGuidanceLines,
16
- resolveInteractiveSelection,
17
15
  } from '../core/installer-cli.ts';
18
16
 
19
17
  const currentFile = fileURLToPath(import.meta.url);
@@ -45,34 +43,17 @@ function renderRemoteGuidance(): void {
45
43
  for (const line of formatRemoteGuidanceLines(detectTargets())) log(line);
46
44
  }
47
45
 
48
- function prompt(question: string): string {
49
- process.stdout.write(question);
50
- const input = spawnSync('bash', ['-lc', 'IFS= read -r line; printf "%s" "$line"'], {
51
- stdio: ['inherit', 'pipe', 'inherit'],
52
- });
53
- return input.stdout?.toString('utf8').trim() || '';
54
- }
55
-
56
- function renderInstallMenu(): IntegrationTargetId[] {
57
- const rendered = formatInstallMenuLines(detectTargets());
58
- for (const line of rendered.lines) log(line);
59
- return rendered.targetIds;
60
- }
61
-
62
46
  function runTs(script: string, extraArgs: string[] = []): void {
63
- const runner = detectTsRunner();
64
- if (runner === 'bun') {
65
- run('bun', [script, ...extraArgs]);
66
- } else {
67
- // Resolve tsx from this package's node_modules (not CWD)
68
- try {
69
- const { dirname, join } = require('path');
70
- const tsxCli = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs');
71
- run(process.execPath, [tsxCli, script, ...extraArgs]);
72
- } catch {
73
- // Fallback: hope tsx is globally available
74
- run('npx', ['tsx', script, ...extraArgs]);
75
- }
47
+ // Resolve tsx from this package's node_modules (not CWD) so `npx tsx` works
48
+ // even on hosts where the user hasn't globally installed tsx.
49
+ try {
50
+ const { dirname, join } = require('path');
51
+ const tsxCli = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs');
52
+ run(process.execPath, [tsxCli, script, ...extraArgs]);
53
+ } catch {
54
+ // Fallback: global npx tsx
55
+ const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
56
+ run(npxBin, ['tsx', script, ...extraArgs]);
76
57
  }
77
58
  }
78
59
 
@@ -83,6 +64,122 @@ const forwardedFlags = process.argv.slice(2).filter(a =>
83
64
  (process.argv[process.argv.indexOf(a) - 1] === '--timezone')
84
65
  );
85
66
 
67
+ interface InstallAllResult {
68
+ id: IntegrationTargetId;
69
+ label: string;
70
+ status: 'ok' | 'fail' | 'skipped';
71
+ message?: string;
72
+ }
73
+
74
+ const INSTALL_ALL_CANDIDATES: Array<{ id: IntegrationTargetId; label: string }> = [
75
+ { id: 'claude-code', label: 'Claude Code' },
76
+ { id: 'codex', label: 'Codex' },
77
+ { id: 'gemini-cli', label: 'Gemini CLI' },
78
+ { id: 'claude-desktop', label: 'Claude Desktop' },
79
+ { id: 'chatgpt-desktop', label: 'ChatGPT Desktop' },
80
+ ];
81
+
82
+ function installAll(): void {
83
+ const detections = detectTargets();
84
+ const detectedIds = new Set(
85
+ detections.filter((t) => t.kind === 'local' && t.detection.detected).map((t) => t.id),
86
+ );
87
+
88
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
89
+ log(` gramatr v${VERSION} — install all detected`);
90
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
91
+ log('');
92
+
93
+ const results: InstallAllResult[] = [];
94
+
95
+ for (const candidate of INSTALL_ALL_CANDIDATES) {
96
+ if (!detectedIds.has(candidate.id)) {
97
+ results.push({ id: candidate.id, label: candidate.label, status: 'skipped', message: 'not detected' });
98
+ continue;
99
+ }
100
+ log(`━━━ Installing ${candidate.label} ━━━`);
101
+ try {
102
+ installTargetForAll(candidate.id);
103
+ results.push({ id: candidate.id, label: candidate.label, status: 'ok' });
104
+ } catch (err: any) {
105
+ results.push({
106
+ id: candidate.id,
107
+ label: candidate.label,
108
+ status: 'fail',
109
+ message: err?.message || String(err),
110
+ });
111
+ log(` X ${candidate.label} install failed: ${err?.message || err}`);
112
+ }
113
+ log('');
114
+ }
115
+
116
+ // Summary
117
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
118
+ log(' gramatr install summary');
119
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
120
+ const pad = (s: string, n: number) => s + ' '.repeat(Math.max(1, n - s.length));
121
+ for (const r of results) {
122
+ const statusStr =
123
+ r.status === 'ok' ? 'OK'
124
+ : r.status === 'fail' ? 'FAIL'
125
+ : 'not detected — skipped';
126
+ log(` ${pad(r.label, 18)}${statusStr}${r.message && r.status === 'fail' ? ` (${r.message})` : ''}`);
127
+ }
128
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
129
+ log('');
130
+
131
+ const okList = results.filter((r) => r.status === 'ok');
132
+ if (okList.length > 0) {
133
+ log(' Next steps:');
134
+ let step = 1;
135
+ for (const r of okList) {
136
+ switch (r.id) {
137
+ case 'claude-code':
138
+ log(` ${step++}. Restart Claude Code to pick up MCP server config`);
139
+ break;
140
+ case 'codex':
141
+ log(` ${step++}. Restart Codex to load updated hooks`);
142
+ break;
143
+ case 'gemini-cli':
144
+ log(` ${step++}. Restart Gemini CLI to load the extension`);
145
+ break;
146
+ case 'claude-desktop':
147
+ log(` ${step++}. Restart Claude Desktop to load MCP server`);
148
+ break;
149
+ case 'chatgpt-desktop':
150
+ log(` ${step++}. Restart ChatGPT Desktop to load MCP server`);
151
+ break;
152
+ }
153
+ }
154
+ log('');
155
+ }
156
+
157
+ const anyFail = results.some((r) => r.status === 'fail');
158
+ if (anyFail) process.exit(1);
159
+ }
160
+
161
+ function installTargetForAll(targetId: IntegrationTargetId): void {
162
+ switch (targetId) {
163
+ case 'claude-code':
164
+ runTs(join(binDir, 'install.ts'), forwardedFlags);
165
+ return;
166
+ case 'codex':
167
+ runTs(join(clientDir, 'codex', 'install.ts'), forwardedFlags);
168
+ return;
169
+ case 'gemini-cli':
170
+ runTs(join(clientDir, 'gemini', 'install.ts'), forwardedFlags);
171
+ return;
172
+ case 'claude-desktop':
173
+ runTs(join(clientDir, 'desktop', 'install.ts'), forwardedFlags);
174
+ return;
175
+ case 'chatgpt-desktop':
176
+ runTs(join(clientDir, 'chatgpt', 'install.ts'), forwardedFlags);
177
+ return;
178
+ default:
179
+ throw new Error(`Cannot install remote target '${targetId}' via install-all`);
180
+ }
181
+ }
182
+
86
183
  function installTarget(targetId: IntegrationTargetId): void {
87
184
  switch (targetId) {
88
185
  case 'claude-code':
@@ -154,31 +251,22 @@ function upgrade(): void {
154
251
  renderRemoteGuidance();
155
252
  }
156
253
 
157
- function interactiveInstall(): void {
158
- renderDetections();
159
- log('');
160
- const targetIds = renderInstallMenu();
161
- const defaultChoice = summarizeDetectedLocalTargets()[0] || 'claude-code';
162
- const answer = prompt(`Install target(s) [${defaultChoice}]: `) || defaultChoice;
163
- const selections = resolveInteractiveSelection(
164
- answer,
165
- targetIds,
166
- summarizeDetectedLocalTargets(),
167
- (id) => findTarget(id) as { id: IntegrationTargetId; kind: 'local' | 'remote' } | undefined,
168
- );
169
- if (selections.length === 0) {
170
- throw new Error('No install targets selected');
171
- }
172
-
173
- for (const selection of selections) {
174
- installTarget(selection);
175
- }
176
-
177
- renderRemoteGuidance();
178
- }
179
-
180
254
  function main(): void {
181
- const [, , command = 'install', targetArg] = process.argv;
255
+ // Skip forwarded-only flags (--yes, --name <v>, --timezone <v>) when picking
256
+ // the command and target. Keep command-specific flags like --apply, --detect,
257
+ // --help, --version so existing subcommands continue to work.
258
+ const raw = process.argv.slice(2);
259
+ const FORWARDED_ONLY = new Set(['--yes', '-y']);
260
+ const FORWARDED_WITH_VALUE = new Set(['--name', '--timezone']);
261
+ const positionals: string[] = [];
262
+ for (let i = 0; i < raw.length; i++) {
263
+ const a = raw[i];
264
+ if (FORWARDED_ONLY.has(a)) continue;
265
+ if (FORWARDED_WITH_VALUE.has(a)) { i++; continue; }
266
+ positionals.push(a);
267
+ }
268
+ const command = positionals[0] ?? 'install';
269
+ const targetArg = positionals[1];
182
270
 
183
271
  switch (command) {
184
272
  case 'install':
@@ -186,22 +274,32 @@ function main(): void {
186
274
  renderDetections();
187
275
  return;
188
276
  }
189
- if (targetArg) {
190
- if (targetArg === 'all') {
191
- const targets = summarizeDetectedLocalTargets();
192
- if (targets.length === 0) {
193
- log('No detected local targets to install.');
194
- return;
195
- }
196
- for (const target of targets) installTarget(target);
197
- return;
277
+ if (!targetArg || targetArg === 'all') {
278
+ installAll();
279
+ return;
280
+ }
281
+ if (targetArg === 'help' || targetArg === '--help' || targetArg === '-h') {
282
+ log('gramatr install — install gramatr into detected AI platforms');
283
+ log('');
284
+ log('Usage:');
285
+ log(' npx gramatr install Detect every platform and install all');
286
+ log(' npx gramatr install all Same as above (explicit)');
287
+ log(' npx gramatr install <platform> Install a single platform');
288
+ log('');
289
+ log('Supported platforms:');
290
+ for (const c of INSTALL_ALL_CANDIDATES) {
291
+ log(` ${c.id.padEnd(16)}${c.label}`);
198
292
  }
293
+ return;
294
+ }
295
+ {
199
296
  const target = findTarget(targetArg);
200
- if (!target) throw new Error(`Unknown target: ${targetArg}`);
297
+ if (!target) {
298
+ log(`Unknown target: ${targetArg}. Run 'gramatr install help' for usage.`);
299
+ process.exit(1);
300
+ }
201
301
  installTarget(target.id);
202
- return;
203
302
  }
204
- interactiveInstall();
205
303
  return;
206
304
  case 'detect':
207
305
  renderDetections();
@@ -235,12 +333,7 @@ function main(): void {
235
333
  return;
236
334
  case '--version':
237
335
  case '-v':
238
- try {
239
- const { VERSION } = require('../core/version.ts');
240
- log(VERSION);
241
- } catch {
242
- log('0.3.0');
243
- }
336
+ log(VERSION);
244
337
  return;
245
338
  default:
246
339
  log(`Unknown command: ${command}. Run 'gramatr help' for usage.`);
package/bin/install.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { join, dirname, basename, resolve } from 'path';
18
18
  import { execSync, spawnSync } from 'child_process';
19
19
  import { createInterface } from 'readline';
20
- import { buildClaudeHooksFile, detectTsRunner } from '../core/install.ts';
20
+ import { buildClaudeHooksFile } from '../core/install.ts';
21
21
  import { VERSION } from '../core/version.ts';
22
22
 
23
23
  // ── Constants ──
@@ -141,7 +141,7 @@ function timestamp(): string {
141
141
 
142
142
  // ── Prerequisites ──
143
143
 
144
- async function checkPrereqs(): Promise<{ tsRunner: string }> {
144
+ async function checkPrereqs(): Promise<void> {
145
145
  // Node.js
146
146
  const nodeVer = process.versions.node;
147
147
  const major = parseInt(nodeVer.split('.')[0], 10);
@@ -151,18 +151,8 @@ async function checkPrereqs(): Promise<{ tsRunner: string }> {
151
151
  }
152
152
  log(`OK Node.js v${nodeVer}`);
153
153
 
154
- // Detect best TypeScript runner
155
- const tsRunner = detectTsRunner();
156
- if (tsRunner === 'bun') {
157
- try {
158
- const ver = execSync('bun --version', { encoding: 'utf8' }).trim();
159
- log(`OK TS runner: bun ${ver}`);
160
- } catch {
161
- log(`OK TS runner: bun`);
162
- }
163
- } else {
164
- log(`OK TS runner: npx tsx (install bun for faster hooks)`);
165
- }
154
+ // TS runner is hardcoded to `npx tsx` (see core/install.ts and issue #468)
155
+ log('OK TS runner: npx tsx');
166
156
 
167
157
  // Claude Code
168
158
  if (!existsSync(CLAUDE_DIR)) {
@@ -170,8 +160,6 @@ async function checkPrereqs(): Promise<{ tsRunner: string }> {
170
160
  process.exit(1);
171
161
  }
172
162
  log('OK Claude Code directory exists');
173
-
174
- return { tsRunner };
175
163
  }
176
164
 
177
165
  // ── Legacy Detection ──
@@ -287,6 +275,11 @@ function installClientFiles(): void {
287
275
  }
288
276
  log('OK Installed core modules');
289
277
 
278
+ // package.json — copied so core/version.ts can resolve the installed
279
+ // version at runtime. Single source of truth; see core/version.ts.
280
+ copyFileIfExists(join(SCRIPT_DIR, 'package.json'), join(CLIENT_DIR, 'package.json'));
281
+ log('OK Installed package.json (version source of truth)');
282
+
290
283
  // CLAUDE.md
291
284
  copyFileIfExists(join(SCRIPT_DIR, 'CLAUDE.md'), join(CLIENT_DIR, 'CLAUDE.md'));
292
285
  log('OK Installed CLAUDE.md (minimal — server delivers behavioral rules)');
@@ -351,7 +344,11 @@ function installClaudeMd(): void {
351
344
 
352
345
  // ── Step 3: Auth ──
353
346
 
354
- async function handleAuth(legacyToken: string, tsRunner: string): Promise<{ url: string; token: string }> {
347
+ // npx on Windows is shipped as `npx.cmd`. spawnSync without shell: true cannot
348
+ // resolve .cmd shims, so we fall back to the platform-specific binary name.
349
+ const NPX_BIN = process.platform === 'win32' ? 'npx.cmd' : 'npx';
350
+
351
+ async function handleAuth(legacyToken: string): Promise<{ url: string; token: string }> {
355
352
  log('━━━ Step 3: Configuring gramatr MCP server ━━━');
356
353
  log('');
357
354
 
@@ -387,10 +384,11 @@ async function handleAuth(legacyToken: string, tsRunner: string): Promise<{ url:
387
384
  if (existsSync(loginScript)) {
388
385
  // Run gmtr-login as subprocess but with proper stdio handling
389
386
  // Use spawnSync so stdin is properly passed through (no stall)
390
- const result = spawnSync(tsRunner, [loginScript], {
387
+ const result = spawnSync(NPX_BIN, ['tsx', loginScript], {
391
388
  stdio: 'inherit',
392
389
  env: { ...process.env },
393
390
  });
391
+ void result;
394
392
 
395
393
  // Re-read token after login
396
394
  if (existsSync(GMTR_JSON)) {
@@ -479,7 +477,7 @@ async function configureIdentity(): Promise<void> {
479
477
 
480
478
  // ── Step 4: Settings merge ──
481
479
 
482
- function updateClaudeSettings(tsRunner: string, url: string, token: string): void {
480
+ function updateClaudeSettings(url: string, token: string): void {
483
481
  log('━━━ Step 4: Updating Claude Code settings ━━━');
484
482
  log('');
485
483
 
@@ -493,7 +491,7 @@ function updateClaudeSettings(tsRunner: string, url: string, token: string): voi
493
491
  log(`OK Backed up settings to ${basename(backup)}`);
494
492
 
495
493
  const includeOptionalUx = process.env.GMTR_ENABLE_OPTIONAL_CLAUDE_UX === '1';
496
- const hooksConfig = buildClaudeHooksFile(CLIENT_DIR, { includeOptionalUx, tsRunner });
494
+ const hooksConfig = buildClaudeHooksFile(CLIENT_DIR, { includeOptionalUx });
497
495
 
498
496
  const settings = readJson(CLAUDE_SETTINGS);
499
497
 
@@ -529,7 +527,7 @@ function updateClaudeSettings(tsRunner: string, url: string, token: string): voi
529
527
  // Status line
530
528
  settings.statusLine = {
531
529
  type: 'command',
532
- command: `${tsRunner} ${CLIENT_DIR}/bin/statusline.ts`,
530
+ command: `npx tsx ${CLIENT_DIR}/bin/statusline.ts`,
533
531
  };
534
532
  log('OK Configured status line');
535
533
 
@@ -586,47 +584,6 @@ function registerMcpServer(url: string, token: string): void {
586
584
  log('');
587
585
  }
588
586
 
589
- // ── Step 4c: Additional CLIs ──
590
-
591
- async function installAdditionalClis(): Promise<void> {
592
- log('━━━ Step 4c: Additional CLI platforms ━━━');
593
- log('');
594
-
595
- // Codex
596
- const codexDir = join(HOME, '.codex');
597
- if (existsSync(codexDir) || which('codex')) {
598
- log(' Codex CLI detected — installing gramatr hooks...');
599
- try {
600
- const { main: installCodex } = await import('../codex/install.ts');
601
- installCodex();
602
- log(' OK Codex hooks installed');
603
- } catch (err: any) {
604
- log(` X Codex install failed (non-fatal): ${err.message}`);
605
- }
606
- log('');
607
- } else {
608
- log(' -- Codex CLI not detected (skipping)');
609
- }
610
-
611
- // Gemini
612
- const geminiDir = join(HOME, '.gemini');
613
- if (existsSync(geminiDir) || which('gemini')) {
614
- log(' Gemini CLI detected — installing gramatr extension...');
615
- try {
616
- const { main: installGemini } = await import('../gemini/install.ts');
617
- await installGemini();
618
- log(' OK Gemini extension installed');
619
- } catch (err: any) {
620
- log(` X Gemini install failed (non-fatal): ${err.message}`);
621
- }
622
- log('');
623
- } else {
624
- log(' -- Gemini CLI not detected (skipping)');
625
- }
626
-
627
- log('');
628
- }
629
-
630
587
  // ── Step 5: Verification ──
631
588
 
632
589
  function verify(url: string, token: string): boolean {
@@ -716,7 +673,7 @@ async function main(): Promise<void> {
716
673
  log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
717
674
  log('');
718
675
 
719
- const { tsRunner } = await checkPrereqs();
676
+ await checkPrereqs();
720
677
  log('');
721
678
 
722
679
  const { legacyToken } = await handleLegacy();
@@ -724,11 +681,10 @@ async function main(): Promise<void> {
724
681
  installClientFiles();
725
682
  installClaudeMd();
726
683
 
727
- const { url, token } = await handleAuth(legacyToken, tsRunner);
684
+ const { url, token } = await handleAuth(legacyToken);
728
685
  await configureIdentity();
729
- updateClaudeSettings(tsRunner, url, token);
686
+ updateClaudeSettings(url, token);
730
687
  registerMcpServer(url, token);
731
- await installAdditionalClis();
732
688
 
733
689
  const allOk = verify(url, token);
734
690
 
package/core/install.ts CHANGED
@@ -23,7 +23,7 @@ interface HookSpec {
23
23
  }
24
24
 
25
25
  interface ClaudeHookOptions {
26
- tsRunner?: string;
26
+ includeOptionalUx?: boolean;
27
27
  }
28
28
 
29
29
  const CLAUDE_HOOKS: HookSpec[] = [
@@ -62,21 +62,14 @@ const CODEX_HOOKS: HookSpec[] = [
62
62
  ];
63
63
 
64
64
  /**
65
- * Detect the best available TypeScript runner.
66
- * Priority: bun (fastest) > npx tsx (universal Node fallback)
65
+ * All hook commands are invoked via `npx tsx`. This is the single, portable
66
+ * runner for every gramatr client install. See issue #468 for the architectural
67
+ * rationale: bun detection at install time silently produced broken configs on
68
+ * hosts where the runtime PATH did not match the installer's PATH.
67
69
  */
68
- export function detectTsRunner(): string {
69
- try {
70
- const { execSync } = require('child_process');
71
- execSync('bun --version', { stdio: 'ignore' });
72
- return 'bun';
73
- } catch {
74
- return 'npx tsx';
75
- }
76
- }
70
+ const TS_RUNNER = 'npx tsx';
77
71
 
78
- function buildHooksFile(clientDir: string, specs: HookSpec[], tsRunner?: string): InstallHooksFile {
79
- const runner = tsRunner || detectTsRunner();
72
+ function buildHooksFile(clientDir: string, specs: HookSpec[]): InstallHooksFile {
80
73
  const hooks: Record<string, InstallHookMatcherEntry[]> = {};
81
74
 
82
75
  for (const spec of specs) {
@@ -84,7 +77,7 @@ function buildHooksFile(clientDir: string, specs: HookSpec[], tsRunner?: string)
84
77
  hooks: [
85
78
  {
86
79
  type: 'command',
87
- command: `${runner} "${clientDir}/${spec.relativeCommand}"`,
80
+ command: `${TS_RUNNER} "${clientDir}/${spec.relativeCommand}"`,
88
81
  statusMessage: spec.statusMessage,
89
82
  timeout: spec.timeout,
90
83
  },
@@ -104,11 +97,11 @@ function buildHooksFile(clientDir: string, specs: HookSpec[], tsRunner?: string)
104
97
 
105
98
  export function buildClaudeHooksFile(
106
99
  clientDir: string,
107
- options: ClaudeHookOptions = {},
100
+ _options: ClaudeHookOptions = {},
108
101
  ): InstallHooksFile {
109
- return buildHooksFile(clientDir, CLAUDE_HOOKS, options.tsRunner);
102
+ return buildHooksFile(clientDir, CLAUDE_HOOKS);
110
103
  }
111
104
 
112
- export function buildCodexHooksFile(clientDir: string, tsRunner?: string): InstallHooksFile {
113
- return buildHooksFile(clientDir, CODEX_HOOKS, tsRunner);
105
+ export function buildCodexHooksFile(clientDir: string): InstallHooksFile {
106
+ return buildHooksFile(clientDir, CODEX_HOOKS);
114
107
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * version-check.ts — opportunistic npm registry version check.
3
+ *
4
+ * Queries https://registry.npmjs.org/gramatr/latest on a 3s timeout, caches
5
+ * the result for one hour under ~/.gmtr-client/.cache/version-check.json, and
6
+ * reports whether the installed client is behind the published version.
7
+ *
8
+ * Design constraints (see issue #468 sibling work):
9
+ * - Never throws. Any failure returns null and the caller proceeds normally.
10
+ * - Never writes to stdout — stdout is Claude Code's context channel.
11
+ * - Fast cache-hit path (no network, no heavy work).
12
+ * - Zero new runtime dependencies. Uses global fetch (Node 18+).
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
16
+ import { dirname, join } from 'path';
17
+ import { homedir } from 'os';
18
+
19
+ const REGISTRY_URL = 'https://registry.npmjs.org/gramatr/latest';
20
+ const FETCH_TIMEOUT_MS = 3000;
21
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
22
+
23
+ export interface VersionCheckResult {
24
+ latestVersion: string;
25
+ installedVersion: string;
26
+ isOutdated: boolean;
27
+ cached: boolean;
28
+ }
29
+
30
+ interface CacheFile {
31
+ latestVersion: string;
32
+ fetchedAt: number;
33
+ lastNotifiedVersion?: string;
34
+ }
35
+
36
+ /**
37
+ * Compare two semver-style version strings ("X.Y.Z").
38
+ * Returns:
39
+ * -1 if a < b
40
+ * 0 if a === b
41
+ * 1 if a > b
42
+ *
43
+ * Non-numeric or missing segments are treated as 0.
44
+ */
45
+ export function compareVersions(a: string, b: string): number {
46
+ const pa = a.split('.').map((x) => parseInt(x, 10) || 0);
47
+ const pb = b.split('.').map((x) => parseInt(x, 10) || 0);
48
+ const len = Math.max(pa.length, pb.length);
49
+ for (let i = 0; i < len; i++) {
50
+ const av = pa[i] ?? 0;
51
+ const bv = pb[i] ?? 0;
52
+ if (av < bv) return -1;
53
+ if (av > bv) return 1;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ export function getCachePath(home: string = homedir()): string {
59
+ return join(home, '.gmtr-client', '.cache', 'version-check.json');
60
+ }
61
+
62
+ function readCache(path: string): CacheFile | null {
63
+ try {
64
+ if (!existsSync(path)) return null;
65
+ const raw = readFileSync(path, 'utf8');
66
+ const parsed = JSON.parse(raw) as CacheFile;
67
+ if (typeof parsed.latestVersion !== 'string' || typeof parsed.fetchedAt !== 'number') {
68
+ return null;
69
+ }
70
+ return parsed;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writeCache(path: string, data: CacheFile): void {
77
+ try {
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
80
+ } catch {
81
+ // Cache is best-effort. Silent failure is acceptable.
82
+ }
83
+ }
84
+
85
+ async function fetchLatestVersion(): Promise<string | null> {
86
+ try {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
89
+ try {
90
+ const res = await fetch(REGISTRY_URL, {
91
+ signal: controller.signal,
92
+ headers: { Accept: 'application/json' },
93
+ });
94
+ if (!res.ok) return null;
95
+ const body = (await res.json()) as { version?: string };
96
+ if (typeof body?.version !== 'string') return null;
97
+ return body.version;
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Check the installed version against the latest published on npm.
108
+ * Returns null on any failure — callers must treat this as optional.
109
+ */
110
+ export async function checkLatestVersion(
111
+ installedVersion: string,
112
+ options: { cachePath?: string; now?: number } = {},
113
+ ): Promise<VersionCheckResult | null> {
114
+ try {
115
+ const cachePath = options.cachePath ?? getCachePath();
116
+ const now = options.now ?? Date.now();
117
+
118
+ const cached = readCache(cachePath);
119
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
120
+ return {
121
+ latestVersion: cached.latestVersion,
122
+ installedVersion,
123
+ isOutdated: compareVersions(cached.latestVersion, installedVersion) > 0,
124
+ cached: true,
125
+ };
126
+ }
127
+
128
+ const latestVersion = await fetchLatestVersion();
129
+ if (!latestVersion) return null;
130
+
131
+ writeCache(cachePath, {
132
+ latestVersion,
133
+ fetchedAt: now,
134
+ lastNotifiedVersion: cached?.lastNotifiedVersion,
135
+ });
136
+
137
+ return {
138
+ latestVersion,
139
+ installedVersion,
140
+ isOutdated: compareVersions(latestVersion, installedVersion) > 0,
141
+ cached: false,
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Record that the user has been notified for a given latest version.
150
+ * Suppresses repeat notifications until a newer version is published.
151
+ */
152
+ export function markNotified(latestVersion: string, cachePath: string = getCachePath()): void {
153
+ try {
154
+ const current = readCache(cachePath);
155
+ if (!current) return;
156
+ writeCache(cachePath, { ...current, lastNotifiedVersion: latestVersion });
157
+ } catch {
158
+ // Best-effort.
159
+ }
160
+ }
161
+
162
+ export function shouldNotify(
163
+ result: VersionCheckResult,
164
+ cachePath: string = getCachePath(),
165
+ ): boolean {
166
+ if (!result.isOutdated) return false;
167
+ const cached = readCache(cachePath);
168
+ if (cached?.lastNotifiedVersion === result.latestVersion) return false;
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Format the upgrade notification banner. Caller decides where to write it
174
+ * (must be stderr — stdout is reserved for Claude context).
175
+ */
176
+ export function formatUpgradeNotification(installed: string, latest: string): string {
177
+ const bar = '\u2501'.repeat(60);
178
+ return [
179
+ bar,
180
+ ' gramatr update available',
181
+ '',
182
+ ` Installed: ${installed}`,
183
+ ` Latest: ${latest}`,
184
+ '',
185
+ ' To upgrade:',
186
+ ' 1. Type /exit to leave Claude Code',
187
+ ' 2. Run: npx gramatr@latest install claude-code',
188
+ ' 3. Restart: claude --resume',
189
+ '',
190
+ " Why restart? gramatr's hooks are loaded by Claude Code at",
191
+ ' session start. New hook code requires a fresh session.',
192
+ bar,
193
+ '',
194
+ ].join('\n');
195
+ }
196
+
197
+ /**
198
+ * One-shot helper for hooks: check and, if appropriate, print the
199
+ * notification to stderr. Safe to call from any hook — never throws,
200
+ * never blocks longer than FETCH_TIMEOUT_MS in the cache-miss path.
201
+ */
202
+ export async function runVersionCheckAndNotify(
203
+ installedVersion: string,
204
+ options: { cachePath?: string; stream?: NodeJS.WritableStream } = {},
205
+ ): Promise<VersionCheckResult | null> {
206
+ const stream = options.stream ?? process.stderr;
207
+ const cachePath = options.cachePath ?? getCachePath();
208
+ const result = await checkLatestVersion(installedVersion, { cachePath });
209
+ if (!result) return null;
210
+ if (shouldNotify(result, cachePath)) {
211
+ try {
212
+ stream.write(formatUpgradeNotification(result.installedVersion, result.latestVersion));
213
+ } catch {
214
+ // Silent — never break the hook.
215
+ }
216
+ markNotified(result.latestVersion, cachePath);
217
+ }
218
+ return result;
219
+ }
package/core/version.ts CHANGED
@@ -1,2 +1,47 @@
1
- /** Auto-generated by version-sync.ts — do not edit */
2
- export const VERSION = '0.3.55';
1
+ /**
2
+ * version.ts runtime resolution of the installed gramatr version.
3
+ *
4
+ * Reads `version` from the nearest package.json walking up from this module's
5
+ * location. package.json is the SINGLE source of truth — it's the file the
6
+ * version-bump process already updates, so there is zero possibility of drift.
7
+ *
8
+ * Works in two environments:
9
+ * 1. Source checkout: packages/client/core/version.ts →
10
+ * packages/client/package.json (found one directory up).
11
+ * 2. Installed client: ~/gmtr-client/core/version.ts →
12
+ * ~/gmtr-client/package.json (copied by installClientFiles()).
13
+ *
14
+ * If the file cannot be resolved (unexpected layout), falls back to '0.0.0'
15
+ * rather than throwing — the version check is opportunistic and must never
16
+ * break the hook.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from 'fs';
20
+ import { dirname, join } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ function findPackageJson(startDir: string): string | null {
24
+ let dir = startDir;
25
+ for (let i = 0; i < 5; i++) {
26
+ const candidate = join(dir, 'package.json');
27
+ if (existsSync(candidate)) return candidate;
28
+ const parent = dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function resolveVersion(): string {
36
+ try {
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ const pkgPath = findPackageJson(here);
39
+ if (!pkgPath) return '0.0.0';
40
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
41
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
42
+ } catch {
43
+ return '0.0.0';
44
+ }
45
+ }
46
+
47
+ export const VERSION: string = resolveVersion();
@@ -88,7 +88,7 @@ export function buildGeminiHooksFile(): GeminiHooksFile {
88
88
  hooks: [
89
89
  {
90
90
  type: 'command',
91
- command: 'bun "${extensionPath}/hooks/session-start.ts"',
91
+ command: 'npx tsx "${extensionPath}/hooks/session-start.ts"',
92
92
  name: 'gramatr-session-start',
93
93
  timeout: 15,
94
94
  description: 'Load gramatr session context and handoff',
@@ -101,7 +101,7 @@ export function buildGeminiHooksFile(): GeminiHooksFile {
101
101
  hooks: [
102
102
  {
103
103
  type: 'command',
104
- command: 'bun "${extensionPath}/hooks/user-prompt-submit.ts"',
104
+ command: 'npx tsx "${extensionPath}/hooks/user-prompt-submit.ts"',
105
105
  name: 'gramatr-prompt-routing',
106
106
  timeout: 15,
107
107
  description: 'Route prompt through gramatr intelligence',
@@ -114,7 +114,7 @@ export function buildGeminiHooksFile(): GeminiHooksFile {
114
114
  hooks: [
115
115
  {
116
116
  type: 'command',
117
- command: 'bun "${extensionPath}/hooks/stop.ts"',
117
+ command: 'npx tsx "${extensionPath}/hooks/stop.ts"',
118
118
  name: 'gramatr-session-end',
119
119
  timeout: 10,
120
120
  description: 'Submit classification feedback to gramatr',
@@ -33,6 +33,8 @@ import {
33
33
  writeCurrentProjectContextFile,
34
34
  persistSessionRegistration,
35
35
  } from '../core/session.ts';
36
+ import { runVersionCheckAndNotify } from '../core/version-check.ts';
37
+ import { VERSION } from '../core/version.ts';
36
38
 
37
39
  // ── stdout (Claude context injection) ──
38
40
  // Claude Code captures stdout from SessionStart hooks and injects it as context.
@@ -172,7 +174,8 @@ function syncRatingsInBackground(): void {
172
174
  const syncScript = join(process.env.GMTR_DIR || join(process.env.HOME || '', 'gmtr-client'), 'hooks', 'sync-ratings.hook.ts');
173
175
  if (existsSync(syncScript)) {
174
176
  try {
175
- const child = spawn('bun', ['run', syncScript], {
177
+ const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
178
+ const child = spawn(npxBin, ['tsx', syncScript], {
176
179
  detached: true,
177
180
  stdio: 'ignore',
178
181
  });
@@ -477,6 +480,14 @@ async function main(): Promise<void> {
477
480
  }
478
481
  }
479
482
 
483
+ // Opportunistic npm version check — prints a notification to stderr if a
484
+ // newer gramatr is published. Never blocks or crashes the session start.
485
+ try {
486
+ await runVersionCheckAndNotify(VERSION);
487
+ } catch {
488
+ // Silent — version check is strictly optional.
489
+ }
490
+
480
491
  log('');
481
492
  log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
482
493
  log('Session initialization complete');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gramatr",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
4
4
  "description": "grāmatr — context engineering layer for AI coding agents. Every prompt gets a pre-computed intelligence packet: decision routing, capability audit, behavioral directives, memory pre-load, and ISC scaffolds. Continuity across sessions for Claude Code, Codex, and Gemini CLI.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -43,11 +43,9 @@
43
43
  "lint": "pnpm exec biome lint --files-ignore-unknown=true package.json bin chatgpt codex core desktop gemini hooks lib tools web vitest.config.ts",
44
44
  "test": "vitest run",
45
45
  "test:coverage": "vitest run --coverage",
46
- "version:patch": "npm version patch --no-git-tag-version && npm run version:sync",
47
- "version:minor": "npm version minor --no-git-tag-version && npm run version:sync",
48
- "version:major": "npm version major --no-git-tag-version && npm run version:sync",
49
- "version:sync": "npx tsx bin/version-sync.ts",
50
- "prepublishOnly": "npm run version:sync"
46
+ "version:patch": "npm version patch --no-git-tag-version",
47
+ "version:minor": "npm version minor --no-git-tag-version",
48
+ "version:major": "npm version major --no-git-tag-version"
51
49
  },
52
50
  "engines": {
53
51
  "node": ">=20.0.0"
@@ -1,46 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * version-sync — stamps the current package.json version into files that display it.
4
- *
5
- * Called automatically by:
6
- * npm run version:patch → 0.3.0 → 0.3.1
7
- * npm run version:minor → 0.3.0 → 0.4.0
8
- * npm run version:major → 0.3.0 → 1.0.0
9
- * npm run prepublishOnly → ensures sync before publish
10
- */
11
-
12
- import { readFileSync, writeFileSync, existsSync } from 'fs';
13
- import { join, dirname } from 'path';
14
- import { fileURLToPath } from 'url';
15
-
16
- const __dirname = dirname(fileURLToPath(import.meta.url));
17
- const clientDir = dirname(__dirname);
18
- const pkg = JSON.parse(readFileSync(join(clientDir, 'package.json'), 'utf8'));
19
- const version = pkg.version;
20
-
21
- console.log(`gramatr version: ${version}`);
22
-
23
- // 1. Stamp into settings.json template (install.ts writes this)
24
- // No file to patch — install.ts reads package.json at runtime.
25
-
26
- // 2. Stamp into CLAUDE.md if it has a version marker
27
- const claudeMd = join(clientDir, 'CLAUDE.md');
28
- if (existsSync(claudeMd)) {
29
- let content = readFileSync(claudeMd, 'utf8');
30
- const versionPattern = /gramatr\s+v[\d.]+/g;
31
- const newContent = content.replace(versionPattern, `gramatr v${version}`);
32
- if (newContent !== content) {
33
- writeFileSync(claudeMd, newContent);
34
- console.log(` OK Stamped version in CLAUDE.md`);
35
- }
36
- }
37
-
38
- // 3. Write a version.ts module that other TS files can import
39
- const versionTs = join(clientDir, 'core', 'version.ts');
40
- writeFileSync(versionTs, `/** Auto-generated by version-sync.ts — do not edit */\nexport const VERSION = '${version}';\n`);
41
- console.log(` OK Wrote core/version.ts`);
42
-
43
- // 4. Log next steps
44
- console.log('');
45
- console.log(` To publish: npm publish --access public`);
46
- console.log(` To tag: git tag v${version} && git push origin v${version}`);