Files
gstack/test/relink-disabled-router.test.ts
Garry Tanandcapy-ai[bot] 39e0148bdd v1.92.67.0 fix(setup): read skill names without a process per skill (#2120) (#3752)
* fix(setup): read skill names without a process per skill (#2120)

On Windows on ARM64, x64-emulated Git Bash intermittently deadlocks in MSYS2's
fork() emulation, and ./setup hung in the loops that read every SKILL.md name
through grep | sed | tr plus a basename (about four process starts per skill).
A sourced helper, bin/gstack-skill-name.sh, reads the name with a bash read
loop and no subshell; gstack-patch-names, setup's link and cleanup loops and
gstack-uninstall's inventory use it and parameter expansion instead of
basename. Codex links keep their directory names.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: fixtures that copy gstack-patch-names also copy its sourced helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: linker harnesses source the skill-name helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment for fork-free skill names (#2120)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: name the host linkers that still run basename

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(qa-lazy-sections): the setup-install harness sources the skill-name helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-02 09:16:58 +00:00

86 lines
3.9 KiB
TypeScript

/**
* C8 wiring (#3018): Claude reads the router straight from the install root,
* so `gstack-config set disabled_skills` must leave disabled skills out of
* that file in the same command (gstack-config → gstack-relink → render), and
* clearing the list must restore the committed router byte for byte. setup's
* own renders pass the same list (--disabled-skills) on every host.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh'];
let tmp: string;
let install: string;
function config(...args: string[]) {
return spawnSync('bash', [path.join(install, 'bin', 'gstack-config'), ...args], {
encoding: 'utf8',
timeout: 60_000,
env: {
...process.env,
GSTACK_HOME: path.join(tmp, 'state'),
GSTACK_STATE_ROOT: '',
GSTACK_STATE_DIR: '',
GSTACK_INSTALL_DIR: install,
GSTACK_SKILLS_DIR: path.join(tmp, 'skills'),
GSTACK_SETUP_RUNNING: '',
},
});
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-c8-relink-'));
install = path.join(tmp, 'gstack');
fs.mkdirSync(path.join(install, 'bin'), { recursive: true });
for (const b of BINS) fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(install, 'bin', b));
for (const b of BINS) fs.chmodSync(path.join(install, 'bin', b), 0o755);
// The generator reads templates from the real checkout and writes only to its out-dir.
fs.symlinkSync(path.join(ROOT, 'scripts'), path.join(install, 'scripts'));
fs.copyFileSync(path.join(ROOT, 'SKILL.md'), path.join(install, 'SKILL.md'));
for (const skill of ['make-pdf', 'retro', 'ship']) {
fs.mkdirSync(path.join(install, skill));
fs.writeFileSync(path.join(install, skill, 'SKILL.md'), `---\nname: ${skill}\ndescription: test\n---\n# ${skill}\n`);
}
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
describe('C8: disabled_skills reaches the served Claude router', () => {
test('gstack-config set renders the router without the disabled skills, and "" restores the committed bytes', () => {
const committed = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf8');
expect(committed).toMatch(/→ invoke `\/make-pdf`/);
const set = config('set', 'disabled_skills', 'make-pdf,retro');
expect(set.status, set.stderr).toBe(0);
const router = fs.readFileSync(path.join(install, 'SKILL.md'), 'utf8');
expect(router).toContain('Disabled on this install (never invoke or suggest them): `/make-pdf`, `/retro`.');
expect(router).not.toMatch(/→ invoke `\/make-pdf`/);
expect(router).not.toMatch(/→ invoke `\/retro`/);
expect(router).toContain('→ invoke `/ship`');
const clear = config('set', 'disabled_skills', '');
expect(clear.status, clear.stderr).toBe(0);
expect(fs.readFileSync(path.join(install, 'SKILL.md'), 'utf8')).toBe(committed);
});
test('a router that is not gstack-generated is never rewritten', () => {
const own = '# my own router\n';
fs.writeFileSync(path.join(install, 'SKILL.md'), own);
const set = config('set', 'disabled_skills', 'retro');
expect(set.status, set.stderr).toBe(0);
expect(fs.readFileSync(path.join(install, 'SKILL.md'), 'utf8')).toBe(own);
});
test('every setup render of the router passes the disabled list', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const renders = setup.split('\n').filter(l => /bun_cmd run gen:skill-docs(?::user)? --host/.test(l) && !/^\s*(echo|log)\b/.test(l));
expect(renders.length).toBeGreaterThanOrEqual(10);
for (const line of renders) expect(line).toContain('${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}');
});
});