diff --git a/skills/gitguardex/SKILL.md b/skills/gitguardex/SKILL.md index 5647cbc..ea5f0d8 100644 --- a/skills/gitguardex/SKILL.md +++ b/skills/gitguardex/SKILL.md @@ -12,4 +12,4 @@ Ops: `gx branch start "" ""`, `gx locks claim --branch "`, and noisy gx reads like `rtk gx status` / `rtk gx doctor`). Do not wrap commands whose stdout is parsed by scripts (`--json`, `--porcelain`, exact stdout contracts) or shell-ready output (`gx prompt --exec`). -To shrink gx's own large narrative output (e.g. `gx prompt`, `gx prompt --snippet`) before it lands in your context, set `GUARDEX_COMPRESS_CMD="stdout filter>"`; gx routes that output through the filter (terse/non-TTY mode, fail-open, JSON skipped). Unset = byte-for-byte unchanged. +To shrink gx's own large narrative output (e.g. `gx prompt`, `gx prompt --snippet`) before it lands in your context, set `GUARDEX_COMPRESS_CMD="stdout filter>"`; gx routes that output through the filter (terse/non-TTY mode, fail-open, JSON skipped). Unset = byte-for-byte unchanged. Confirm it is wired with `gx status` — it prints a `Token compression` line and flags a configured-but-missing binary. diff --git a/src/cli/commands/status.js b/src/cli/commands/status.js index ed2442b..177cc80 100644 --- a/src/cli/commands/status.js +++ b/src/cli/commands/status.js @@ -13,6 +13,7 @@ const toolchainModule = require('../../toolchain'); const { runtimeVersion, statusDot, + describeCompressor, printToolLogsSummary, getInvokedCliName, } = require('../../output'); @@ -180,6 +181,7 @@ function status(rawArgs) { : null, }, detectionError: toolchain.ok ? null : toolchain.error, + compression: describeCompressor(), }; if (options.json) { @@ -214,6 +216,16 @@ function status(rawArgs) { console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`); } } + if (payload.compression.configured) { + const { command, available } = payload.compression; + if (available === false) { + console.log( + `[${TOOL_NAME}] Token compression: ${statusDot('degraded')} ${command} — not found on PATH (gx output is not compressed)`, + ); + } else { + console.log(`[${TOOL_NAME}] Token compression: ${statusDot('active')} ${command}`); + } + } const inactiveOptionalCompanions = [...npmServices, ...localCompanionServices] .filter((service) => service.status !== 'active') .map((service) => service.displayName || service.name); diff --git a/src/output/index.js b/src/output/index.js index dc1994f..ca7b871 100644 --- a/src/output/index.js +++ b/src/output/index.js @@ -1,5 +1,6 @@ const { cp, + fs, path, packageJson, TOOL_NAME, @@ -744,6 +745,55 @@ function looksMachineReadable(text) { return head === '{' || head === '['; } +// isExecutableOnPath resolves whether `command` can be found, mirroring how a +// shell would locate it: an explicit path (contains a separator) is checked +// directly; a bare name is searched across PATH entries (honoring PATHEXT on +// Windows). Defensive — returns null (unknown) on any unexpected error rather +// than throwing, so callers can degrade to "configured, presence unknown". +function isExecutableOnPath(command, env = process.env) { + try { + const cmd = String(command || '').trim(); + if (!cmd) { + return false; + } + const exts = + process.platform === 'win32' + ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((ext) => ext.trim()) + .filter(Boolean) + : ['']; + const candidates = (base) => + exts.some((ext) => { + try { + return fs.existsSync(ext ? base + ext : base); + } catch { + return false; + } + }); + if (cmd.includes('/') || cmd.includes(path.sep)) { + return candidates(cmd); + } + const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean); + return dirs.some((dir) => candidates(path.join(dir, cmd))); + } catch { + return null; + } +} + +// describeCompressor reports the GUARDEX_COMPRESS_CMD token-compression setup so +// `gx status` can surface it. The feature fails open (silently prints raw output +// when the compressor is missing), which makes a misconfiguration invisible and +// quietly wastes tokens — this gives an operator a way to confirm it is wired. +// `available` is true/false when resolvable, or null when presence is unknown. +function describeCompressor(env = process.env) { + const argv = resolveCompressCommand(env); + if (!argv) { + return { configured: false, command: null, available: null }; + } + return { configured: true, command: argv[0], available: isExecutableOnPath(argv[0], env) }; +} + // compressBlock returns `text` unchanged unless a compressor is configured AND // we are in terse (agent / non-TTY) mode AND the block is large enough AND it // is not machine-readable. Any failure (missing binary, non-zero exit, empty @@ -820,6 +870,8 @@ module.exports = { printAutoFinishSummary, tokenizeCommand, resolveCompressCommand, + isExecutableOnPath, + describeCompressor, compressBlock, printCompressible, }; diff --git a/templates/codex/skills/gitguardex/SKILL.md b/templates/codex/skills/gitguardex/SKILL.md index 5647cbc..ea5f0d8 100644 --- a/templates/codex/skills/gitguardex/SKILL.md +++ b/templates/codex/skills/gitguardex/SKILL.md @@ -12,4 +12,4 @@ Ops: `gx branch start "" ""`, `gx locks claim --branch "`, and noisy gx reads like `rtk gx status` / `rtk gx doctor`). Do not wrap commands whose stdout is parsed by scripts (`--json`, `--porcelain`, exact stdout contracts) or shell-ready output (`gx prompt --exec`). -To shrink gx's own large narrative output (e.g. `gx prompt`, `gx prompt --snippet`) before it lands in your context, set `GUARDEX_COMPRESS_CMD="stdout filter>"`; gx routes that output through the filter (terse/non-TTY mode, fail-open, JSON skipped). Unset = byte-for-byte unchanged. +To shrink gx's own large narrative output (e.g. `gx prompt`, `gx prompt --snippet`) before it lands in your context, set `GUARDEX_COMPRESS_CMD="stdout filter>"`; gx routes that output through the filter (terse/non-TTY mode, fail-open, JSON skipped). Unset = byte-for-byte unchanged. Confirm it is wired with `gx status` — it prints a `Token compression` line and flags a configured-but-missing binary. diff --git a/test/output.test.js b/test/output.test.js index b641364..8ae4285 100644 --- a/test/output.test.js +++ b/test/output.test.js @@ -6,6 +6,8 @@ const { compressBlock, resolveCompressCommand, tokenizeCommand, + isExecutableOnPath, + describeCompressor, } = require('../src/output'); // A large lowercase block so the `tr a-z A-Z` stub visibly transforms it and it @@ -94,6 +96,36 @@ test('compressBlock passes text through unchanged when no compressor is configur assert.equal(compressBlock(BIG_BLOCK, { env: {}, force: true }), BIG_BLOCK); }); +test('isExecutableOnPath finds a bare command on PATH and rejects a missing one', () => { + const env = { PATH: process.env.PATH || '' }; + // `node` is on PATH (we are running under it); a random name is not. + assert.equal(isExecutableOnPath('node', env), true); + assert.equal(isExecutableOnPath('guardex-no-such-bin-zzz', env), false); + assert.equal(isExecutableOnPath('', env), false); +}); + +test('isExecutableOnPath checks an explicit path directly', () => { + assert.equal(isExecutableOnPath(process.execPath, { PATH: '' }), true); + assert.equal(isExecutableOnPath('/no/such/guardex/bin', { PATH: '' }), false); +}); + +test('describeCompressor reports configuration and binary availability', () => { + assert.deepEqual(describeCompressor({}), { + configured: false, + command: null, + available: null, + }); + assert.deepEqual(describeCompressor({ GUARDEX_COMPRESS_CMD: 'node --version', PATH: process.env.PATH }), { + configured: true, + command: 'node', + available: true, + }); + assert.deepEqual( + describeCompressor({ GUARDEX_COMPRESS_CMD: 'guardex-no-such-bin-zzz', PATH: process.env.PATH }), + { configured: true, command: 'guardex-no-such-bin-zzz', available: false }, + ); +}); + test('compressBlock runs the configured compressor on large blocks', () => { const out = compressBlock(BIG_BLOCK, { env: { GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' }, force: true }); assert.equal(out, BIG_BLOCK.toUpperCase()); diff --git a/test/status.test.js b/test/status.test.js index 6eb884d..75d0ed1 100644 --- a/test/status.test.js +++ b/test/status.test.js @@ -192,6 +192,50 @@ exit 1 assert.equal(ghService.status, 'active'); }); +test('status --json reports token compression as configured + available when the binary resolves', () => { + const repoDir = initRepo(); + const result = runNodeWithEnv(['status', '--target', repoDir, '--json'], repoDir, { + GUARDEX_COMPRESS_CMD: 'tr a-z A-Z', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.compression.configured, true); + assert.equal(payload.compression.command, 'tr'); + assert.equal(payload.compression.available, true); +}); + +test('status --json reports token compression as unconfigured when GUARDEX_COMPRESS_CMD is unset', () => { + const repoDir = initRepo(); + const result = runNodeWithEnv(['status', '--target', repoDir, '--json'], repoDir, { + GUARDEX_COMPRESS_CMD: '', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.compression.configured, false); + assert.equal(payload.compression.command, null); + assert.equal(payload.compression.available, null); +}); + +test('status surfaces a degraded line when the configured compressor binary is missing', () => { + const repoDir = initRepo(); + const missing = runNodeWithEnv(['status', '--target', repoDir], repoDir, { + GUARDEX_COMPRESS_CMD: 'guardex-no-such-compressor-zzz', + }); + assert.equal(missing.status, 0, missing.stderr || missing.stdout); + assert.match( + missing.stdout, + /Token compression: .*guardex-no-such-compressor-zzz — not found on PATH/, + ); + + // A resolvable binary prints the active line without the "not found" warning. + const present = runNodeWithEnv(['status', '--target', repoDir], repoDir, { + GUARDEX_COMPRESS_CMD: 'tr a-z A-Z', + }); + assert.equal(present.status, 0, present.stderr || present.stdout); + assert.match(present.stdout, /Token compression: .*\btr\b/); + assert.doesNotMatch(present.stdout, /not found on PATH/); +}); + test('warning-only degraded status avoids zero-error wording and points humans at doctor', () => { const repoDir = initRepo();