mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
Detect host-native browser tools before offering the isolated local Chromium fallback, add a common readiness fixture, harden managed browser startup, and verify standards installs expose one canonical QA skill.
880 lines
32 KiB
TypeScript
880 lines
32 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { SOURCE_ASSIGNMENTS } from './assignments';
|
|
|
|
export const PUBLIC_SKILLS = ['debug', 'design', 'plan', 'qa', 'review', 'ship'] as const;
|
|
export const COLLISION_SKILLS = ['qa', 'review', 'ship'] as const;
|
|
export type PublicSkill = (typeof PUBLIC_SKILLS)[number];
|
|
export type InstallScope = 'project' | 'global';
|
|
|
|
export interface AgentMatrixEntry {
|
|
agent: string;
|
|
label: string;
|
|
projectPath: readonly string[];
|
|
globalPath: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Destination paths are the contract exposed by skills CLI 1.5.x. Several
|
|
* standards-native hosts intentionally share the canonical .agents/skills
|
|
* location. Every matrix case gets its own project and HOME, so a shared path
|
|
* cannot make one host's result pass on another host's installation.
|
|
*/
|
|
export const AGENT_MATRIX: readonly AgentMatrixEntry[] = [
|
|
{
|
|
agent: 'claude-code',
|
|
label: 'Claude Code',
|
|
projectPath: ['.claude', 'skills'],
|
|
globalPath: ['.claude', 'skills'],
|
|
},
|
|
{
|
|
agent: 'codex',
|
|
label: 'Codex',
|
|
projectPath: ['.agents', 'skills'],
|
|
globalPath: ['.agents', 'skills'],
|
|
},
|
|
{
|
|
agent: 'kimi-code-cli',
|
|
label: 'Kimi Code CLI',
|
|
projectPath: ['.agents', 'skills'],
|
|
globalPath: ['.agents', 'skills'],
|
|
},
|
|
{
|
|
agent: 'cursor',
|
|
label: 'Cursor',
|
|
projectPath: ['.agents', 'skills'],
|
|
globalPath: ['.agents', 'skills'],
|
|
},
|
|
{
|
|
agent: 'pi',
|
|
label: 'Pi',
|
|
projectPath: ['.pi', 'skills'],
|
|
globalPath: ['.pi', 'agent', 'skills'],
|
|
},
|
|
{
|
|
agent: 'openclaw',
|
|
label: 'OpenClaw',
|
|
projectPath: ['skills'],
|
|
globalPath: ['.openclaw', 'skills'],
|
|
},
|
|
{
|
|
agent: 'github-copilot',
|
|
label: 'GitHub Copilot',
|
|
projectPath: ['.agents', 'skills'],
|
|
globalPath: ['.agents', 'skills'],
|
|
},
|
|
] as const;
|
|
|
|
export interface CheckResult {
|
|
id: string;
|
|
passed: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
export interface RepositoryInspection {
|
|
publicSkills: string[];
|
|
skillFiles: string[];
|
|
checks: CheckResult[];
|
|
passed: boolean;
|
|
}
|
|
|
|
export interface CommandEvidence {
|
|
argv: string[];
|
|
exitCode: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
durationMs: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface InstallCaseEvidence {
|
|
id: string;
|
|
agent: string;
|
|
agentLabel: string;
|
|
scope: InstallScope;
|
|
sourceKind: 'path-with-spaces' | 'source-symlink' | 'repository-root';
|
|
expectedRoot: string;
|
|
expectedSkills: string[];
|
|
installedSkills: string[];
|
|
checks: CheckResult[];
|
|
command: CommandEvidence;
|
|
passed: boolean;
|
|
}
|
|
|
|
export interface RemovalEvidence {
|
|
id: string;
|
|
agent: string;
|
|
scope: InstallScope;
|
|
supported: boolean;
|
|
removedSkills: string[];
|
|
checks: CheckResult[];
|
|
command?: CommandEvidence;
|
|
passed: boolean;
|
|
}
|
|
|
|
export interface InstallMatrixEvidence {
|
|
schemaVersion: 1;
|
|
mode: 'full';
|
|
generatedAt: string;
|
|
platform: NodeJS.Platform;
|
|
architecture: string;
|
|
repositoryRoot: string;
|
|
sourceProjection: 'repository-root-and-canonical-projection';
|
|
cli: {
|
|
executable: string;
|
|
version: string;
|
|
supportsCopy: boolean;
|
|
supportsRemoval: boolean;
|
|
versionCommand: CommandEvidence;
|
|
helpCommand: CommandEvidence;
|
|
};
|
|
repository: RepositoryInspection;
|
|
discovery: {
|
|
count: number | null;
|
|
names: string[];
|
|
checks: CheckResult[];
|
|
command: CommandEvidence;
|
|
passed: boolean;
|
|
};
|
|
installs: InstallCaseEvidence[];
|
|
removals: RemovalEvidence[];
|
|
summary: {
|
|
passed: boolean;
|
|
checks: number;
|
|
passedChecks: number;
|
|
failedChecks: number;
|
|
installCases: number;
|
|
removalCases: number;
|
|
};
|
|
limitations: string[];
|
|
}
|
|
|
|
export interface FullMatrixOptions {
|
|
repoRoot: string;
|
|
outputPath: string;
|
|
npxExecutable?: string;
|
|
}
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
export const DEFAULT_REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
|
|
|
|
function normalizeRelative(file: string): string {
|
|
return file.split(path.sep).join('/');
|
|
}
|
|
|
|
function walkFiles(root: string): string[] {
|
|
if (!fs.existsSync(root)) return [];
|
|
const results: string[] = [];
|
|
const visit = (directory: string): void => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const absolute = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) visit(absolute);
|
|
else if (entry.isFile() || entry.isSymbolicLink()) results.push(normalizeRelative(path.relative(root, absolute)));
|
|
}
|
|
};
|
|
visit(root);
|
|
return results.sort();
|
|
}
|
|
|
|
function frontmatterName(content: string): string | null {
|
|
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
return frontmatter?.match(/^name:\s*([^\s#]+)\s*$/m)?.[1] ?? null;
|
|
}
|
|
|
|
function record(checks: CheckResult[], id: string, passed: unknown, detail: string): boolean {
|
|
const result = Boolean(passed);
|
|
checks.push({ id, passed: result, detail });
|
|
return result;
|
|
}
|
|
|
|
export function inspectRepository(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspection {
|
|
const skillsRoot = path.join(repoRoot, 'skills');
|
|
const checks: CheckResult[] = [];
|
|
const publicSkills = fs.existsSync(skillsRoot)
|
|
? fs.readdirSync(skillsRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(skillsRoot, entry.name, 'SKILL.md')))
|
|
.map((entry) => entry.name)
|
|
.sort()
|
|
: [];
|
|
const skillFiles = walkFiles(skillsRoot)
|
|
.filter((file) => {
|
|
const parts = file.split('/');
|
|
return parts.length === 2 && parts[1] === 'SKILL.md' && (PUBLIC_SKILLS as readonly string[]).includes(parts[0]);
|
|
})
|
|
.sort();
|
|
const expectedFiles = PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort();
|
|
|
|
record(
|
|
checks,
|
|
'repository.public-skill-names',
|
|
JSON.stringify(publicSkills) === JSON.stringify([...PUBLIC_SKILLS]),
|
|
`expected ${PUBLIC_SKILLS.join(', ')}; found ${publicSkills.join(', ') || '(none)'}`,
|
|
);
|
|
record(
|
|
checks,
|
|
'repository.public-skill-files',
|
|
JSON.stringify(skillFiles) === JSON.stringify(expectedFiles),
|
|
`expected only ${expectedFiles.join(', ')}; found ${skillFiles.join(', ') || '(none)'}`,
|
|
);
|
|
|
|
const seenNames = new Map<string, string>();
|
|
for (const skill of PUBLIC_SKILLS) {
|
|
const file = path.join(skillsRoot, skill, 'SKILL.md');
|
|
const exists = fs.existsSync(file);
|
|
record(checks, `repository.${skill}.exists`, exists, exists ? normalizeRelative(path.relative(repoRoot, file)) : `missing ${file}`);
|
|
if (!exists) continue;
|
|
const name = frontmatterName(fs.readFileSync(file, 'utf8'));
|
|
record(checks, `repository.${skill}.frontmatter-name`, name === skill, `expected ${skill}; found ${name ?? '(missing)'}`);
|
|
if (name) {
|
|
const prior = seenNames.get(name);
|
|
record(checks, `repository.${skill}.unique-name`, !prior, prior ? `${name} also appears in ${prior}` : `${name} is unique`);
|
|
seenNames.set(name, normalizeRelative(path.relative(repoRoot, file)));
|
|
}
|
|
const references = path.join(skillsRoot, skill, 'references', 'legacy');
|
|
record(
|
|
checks,
|
|
`repository.${skill}.preserved-modules`,
|
|
fs.existsSync(references) && walkFiles(references).length > 0,
|
|
fs.existsSync(references) ? `${walkFiles(references).length} preserved module files` : `missing ${references}`,
|
|
);
|
|
}
|
|
|
|
for (const skill of COLLISION_SKILLS) {
|
|
record(
|
|
checks,
|
|
`repository.collision.${skill}.canonical`,
|
|
seenNames.get(skill) === `skills/${skill}/SKILL.md`,
|
|
`frontmatter name ${skill} resolves to ${seenNames.get(skill) ?? '(missing)'}`,
|
|
);
|
|
}
|
|
|
|
const compatibilityRoot = path.join(skillsRoot, '.compat');
|
|
const compatibilityAliases = fs.existsSync(compatibilityRoot)
|
|
? fs.readdirSync(compatibilityRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(compatibilityRoot, entry.name, 'SKILL.md')))
|
|
.map((entry) => entry.name)
|
|
.sort()
|
|
: [];
|
|
const expectedAliases = SOURCE_ASSIGNMENTS
|
|
.map((entry) => entry.source)
|
|
.filter((source) => !(PUBLIC_SKILLS as readonly string[]).includes(source))
|
|
.sort();
|
|
record(
|
|
checks,
|
|
'repository.compatibility-aliases',
|
|
JSON.stringify(compatibilityAliases) === JSON.stringify(expectedAliases),
|
|
`expected ${expectedAliases.length} aliases; found ${compatibilityAliases.length}`,
|
|
);
|
|
for (const assignment of SOURCE_ASSIGNMENTS) {
|
|
const aliasFile = path.join(compatibilityRoot, assignment.source, 'SKILL.md');
|
|
if (!fs.existsSync(aliasFile)) continue;
|
|
const alias = fs.readFileSync(aliasFile, 'utf8');
|
|
record(checks, `repository.compat.${assignment.source}.name`, frontmatterName(alias) === assignment.source, `name=${frontmatterName(alias) ?? '(missing)'}`);
|
|
record(checks, `repository.compat.${assignment.source}.internal`, /^metadata:\s*\n(?:[ \t]+.*\n)*?[ \t]+internal:\s*true\s*$/m.test(alias.slice(0, alias.indexOf('\n---', 4))), 'alias is internal');
|
|
record(checks, `repository.compat.${assignment.source}.thin`, alias.includes(assignment.replacement) && !alias.includes('GSTACK2_LEGACY_BODY_START') && alias.split('\n').length < 30, `replacement=${assignment.replacement}`);
|
|
}
|
|
|
|
return {
|
|
publicSkills,
|
|
skillFiles,
|
|
checks,
|
|
passed: checks.every((check) => check.passed),
|
|
};
|
|
}
|
|
|
|
/** Project the clean-checkout discovery surface. Ignored host trees are absent,
|
|
* while tracked 1.x compatibility entries remain present and internal. */
|
|
export function createCanonicalSourceProjection(repoRoot: string, destination: string): void {
|
|
const inspection = inspectRepository(repoRoot);
|
|
if (!inspection.passed) {
|
|
const failures = inspection.checks.filter((check) => !check.passed).map((check) => `${check.id}: ${check.detail}`);
|
|
throw new Error(`Cannot project an invalid public skill tree:\n${failures.join('\n')}`);
|
|
}
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
fs.cpSync(path.join(repoRoot, 'skills'), path.join(destination, 'skills'), {
|
|
recursive: true,
|
|
dereference: false,
|
|
errorOnExist: true,
|
|
force: false,
|
|
});
|
|
for (const entry of fs.readdirSync(repoRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name === 'skills' || entry.name.startsWith('.')) continue;
|
|
const legacySkill = path.join(repoRoot, entry.name, 'SKILL.md');
|
|
if (!fs.existsSync(legacySkill)) continue;
|
|
const content = fs.readFileSync(legacySkill, 'utf8');
|
|
if (!/^metadata:\s*\n(?:[ \t]+.*\n)*?[ \t]+internal:\s*true\s*$/m.test(content.slice(0, content.indexOf('\n---', 4)))) {
|
|
throw new Error(`Legacy compatibility skill is not internal: ${legacySkill}`);
|
|
}
|
|
const target = path.join(destination, entry.name, 'SKILL.md');
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.copyFileSync(legacySkill, target);
|
|
}
|
|
}
|
|
|
|
export function expectedInstallRoot(
|
|
entry: AgentMatrixEntry,
|
|
scope: InstallScope,
|
|
projectRoot: string,
|
|
homeRoot: string,
|
|
): string {
|
|
return path.join(scope === 'project' ? projectRoot : homeRoot, ...(scope === 'project' ? entry.projectPath : entry.globalPath));
|
|
}
|
|
|
|
function directoryHash(directory: string): string | null {
|
|
if (!fs.existsSync(directory)) return null;
|
|
const digest = createHash('sha256');
|
|
for (const relative of walkFiles(directory)) {
|
|
const absolute = path.join(directory, ...relative.split('/'));
|
|
const stat = fs.lstatSync(absolute);
|
|
digest.update(relative);
|
|
digest.update('\0');
|
|
if (stat.isSymbolicLink()) digest.update(`symlink:${fs.readlinkSync(absolute)}`);
|
|
else digest.update(fs.readFileSync(absolute));
|
|
digest.update('\0');
|
|
}
|
|
return digest.digest('hex');
|
|
}
|
|
|
|
function listInstalledSkills(root: string): string[] {
|
|
if (!fs.existsSync(root)) return [];
|
|
return fs.readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'SKILL.md')))
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
}
|
|
|
|
export function stripTerminalControls(value: string): string {
|
|
const terminalControls = [
|
|
/\x1B\][^\x07]*(?:\x07|\x1B\\)/g,
|
|
/\x1B\[[0-?]*[ -/]*[@-~]/g,
|
|
/\r(?=[^\n])/g,
|
|
];
|
|
let clean = value;
|
|
for (const control of terminalControls) clean = clean.replace(control, '');
|
|
return clean.trim();
|
|
}
|
|
|
|
function trimEvidenceOutput(value: string, maxCharacters = 16_000): string {
|
|
const clean = stripTerminalControls(value);
|
|
if (clean.length <= maxCharacters) return clean;
|
|
return `${clean.slice(0, maxCharacters)}\n[output truncated at ${maxCharacters} characters]`;
|
|
}
|
|
|
|
function execute(argv: string[], cwd: string, env: NodeJS.ProcessEnv): CommandEvidence {
|
|
const started = performance.now();
|
|
const result = spawnSync(argv[0], argv.slice(1), {
|
|
cwd,
|
|
env,
|
|
shell: false,
|
|
encoding: 'utf8',
|
|
timeout: 180_000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
return {
|
|
argv,
|
|
exitCode: result.status,
|
|
signal: result.signal,
|
|
durationMs: Math.round(performance.now() - started),
|
|
stdout: trimEvidenceOutput(result.stdout ?? ''),
|
|
stderr: trimEvidenceOutput(`${result.stderr ?? ''}${result.error ? `\n${result.error.message}` : ''}`),
|
|
};
|
|
}
|
|
|
|
export function skillsCliArgv(npxExecutable: string, args: readonly string[]): string[] {
|
|
return [npxExecutable, '--yes', 'skills', ...args];
|
|
}
|
|
|
|
function isolatedEnvironment(home: string, npmCache: string): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
XDG_CONFIG_HOME: path.join(home, '.config'),
|
|
XDG_DATA_HOME: path.join(home, '.local', 'share'),
|
|
XDG_CACHE_HOME: path.join(home, '.cache'),
|
|
npm_config_cache: npmCache,
|
|
npm_config_update_notifier: 'false',
|
|
DISABLE_TELEMETRY: '1',
|
|
NO_COLOR: '1',
|
|
FORCE_COLOR: '0',
|
|
};
|
|
// Host-specific state overrides would defeat HOME isolation if inherited.
|
|
for (const variable of ['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'OPENCLAW_HOME', 'PI_CONFIG_DIR']) delete env[variable];
|
|
return env;
|
|
}
|
|
|
|
function parseDiscovery(output: string): { count: number | null; names: string[] } {
|
|
const clean = stripTerminalControls(output);
|
|
const count = Number(clean.match(/Found\s+(\d+)\s+skills?/)?.[1]);
|
|
const names = clean
|
|
.split('\n')
|
|
.map((line) => line.match(/^\s*│\s{4}([a-z][a-z0-9-]*)\s*$/)?.[1] ?? null)
|
|
.filter((name): name is string => Boolean(name))
|
|
.filter((name, index, all) => all.indexOf(name) === index)
|
|
.sort();
|
|
return { count: Number.isFinite(count) ? count : null, names };
|
|
}
|
|
|
|
function verifyInstalledCase(
|
|
id: string,
|
|
entry: AgentMatrixEntry,
|
|
scope: InstallScope,
|
|
sourceKind: InstallCaseEvidence['sourceKind'],
|
|
expectedSkills: readonly string[],
|
|
sourceRoot: string,
|
|
sourceSkillSegments: readonly string[],
|
|
projectRoot: string,
|
|
homeRoot: string,
|
|
command: CommandEvidence,
|
|
): InstallCaseEvidence {
|
|
const checks: CheckResult[] = [];
|
|
const targetRoot = expectedInstallRoot(entry, scope, projectRoot, homeRoot);
|
|
const installedSkills = listInstalledSkills(targetRoot);
|
|
const sortedExpected = [...expectedSkills].sort();
|
|
record(checks, `${id}.command`, command.exitCode === 0, `exit=${command.exitCode}; signal=${command.signal ?? 'none'}`);
|
|
record(
|
|
checks,
|
|
`${id}.selected-skills`,
|
|
JSON.stringify(installedSkills) === JSON.stringify(sortedExpected),
|
|
`expected ${sortedExpected.join(', ')}; found ${installedSkills.join(', ') || '(none)'}`,
|
|
);
|
|
|
|
for (const skill of sortedExpected) {
|
|
const source = path.join(sourceRoot, ...sourceSkillSegments, skill);
|
|
const installed = path.join(targetRoot, skill);
|
|
const sourceHash = directoryHash(source);
|
|
const installedHash = directoryHash(installed);
|
|
record(checks, `${id}.${skill}.content`, sourceHash !== null && sourceHash === installedHash, `source=${sourceHash}; installed=${installedHash}`);
|
|
const copied = fs.existsSync(installed)
|
|
&& !fs.lstatSync(installed).isSymbolicLink()
|
|
&& !fs.lstatSync(path.join(installed, 'SKILL.md')).isSymbolicLink();
|
|
record(checks, `${id}.${skill}.copy`, copied, copied ? 'directory and SKILL.md are physical copies' : 'symlink detected or file missing');
|
|
const installedName = fs.existsSync(path.join(installed, 'SKILL.md'))
|
|
? frontmatterName(fs.readFileSync(path.join(installed, 'SKILL.md'), 'utf8'))
|
|
: null;
|
|
record(checks, `${id}.${skill}.canonical-name`, installedName === skill, `expected ${skill}; found ${installedName ?? '(missing)'}`);
|
|
}
|
|
|
|
return {
|
|
id,
|
|
agent: entry.agent,
|
|
agentLabel: entry.label,
|
|
scope,
|
|
sourceKind,
|
|
expectedRoot: targetRoot,
|
|
expectedSkills: sortedExpected,
|
|
installedSkills,
|
|
checks,
|
|
command,
|
|
passed: checks.every((check) => check.passed),
|
|
};
|
|
}
|
|
|
|
function runInstallCase(options: {
|
|
id: string;
|
|
entry: AgentMatrixEntry;
|
|
scope: InstallScope;
|
|
sourceKind: InstallCaseEvidence['sourceKind'];
|
|
sourceArgument: string;
|
|
sourceRoot: string;
|
|
expectedSkills: readonly string[];
|
|
explicitSelection: boolean;
|
|
sourceSkillSegments?: readonly string[];
|
|
workspaceRoot: string;
|
|
npmCache: string;
|
|
npxExecutable: string;
|
|
}): { evidence: InstallCaseEvidence; projectRoot: string; homeRoot: string; env: NodeJS.ProcessEnv } {
|
|
const caseRoot = path.join(options.workspaceRoot, 'cases', options.id);
|
|
const projectRoot = path.join(caseRoot, 'project with spaces');
|
|
const homeRoot = path.join(caseRoot, 'home with spaces');
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(homeRoot, { recursive: true });
|
|
const env = isolatedEnvironment(homeRoot, options.npmCache);
|
|
const args = ['add', options.sourceArgument];
|
|
if (options.explicitSelection) args.push('--skill', ...options.expectedSkills);
|
|
args.push('--agent', options.entry.agent, '--copy', '--yes');
|
|
if (options.scope === 'global') args.push('--global');
|
|
const command = execute(skillsCliArgv(options.npxExecutable, args), projectRoot, env);
|
|
return {
|
|
evidence: verifyInstalledCase(
|
|
options.id,
|
|
options.entry,
|
|
options.scope,
|
|
options.sourceKind,
|
|
options.expectedSkills,
|
|
options.sourceRoot,
|
|
options.sourceSkillSegments ?? ['skills'],
|
|
projectRoot,
|
|
homeRoot,
|
|
command,
|
|
),
|
|
projectRoot,
|
|
homeRoot,
|
|
env,
|
|
};
|
|
}
|
|
|
|
function runRemoval(options: {
|
|
id: string;
|
|
entry: AgentMatrixEntry;
|
|
scope: InstallScope;
|
|
skills: readonly string[];
|
|
projectRoot: string;
|
|
homeRoot: string;
|
|
env: NodeJS.ProcessEnv;
|
|
npxExecutable: string;
|
|
supported: boolean;
|
|
}): RemovalEvidence {
|
|
if (!options.supported) {
|
|
return {
|
|
id: options.id,
|
|
agent: options.entry.agent,
|
|
scope: options.scope,
|
|
supported: false,
|
|
removedSkills: [...options.skills],
|
|
checks: [],
|
|
passed: true,
|
|
};
|
|
}
|
|
const args = ['remove', '--skill', ...options.skills, '--agent', options.entry.agent, '--yes'];
|
|
if (options.scope === 'global') args.push('--global');
|
|
const command = execute(skillsCliArgv(options.npxExecutable, args), options.projectRoot, options.env);
|
|
const targetRoot = expectedInstallRoot(options.entry, options.scope, options.projectRoot, options.homeRoot);
|
|
const checks: CheckResult[] = [];
|
|
record(checks, `${options.id}.command`, command.exitCode === 0, `exit=${command.exitCode}; signal=${command.signal ?? 'none'}`);
|
|
for (const skill of options.skills) {
|
|
const removed = !fs.existsSync(path.join(targetRoot, skill));
|
|
record(checks, `${options.id}.${skill}.removed`, removed, removed ? 'removed' : `still present at ${path.join(targetRoot, skill)}`);
|
|
}
|
|
return {
|
|
id: options.id,
|
|
agent: options.entry.agent,
|
|
scope: options.scope,
|
|
supported: true,
|
|
removedSkills: [...options.skills],
|
|
checks,
|
|
command,
|
|
passed: checks.every((check) => check.passed),
|
|
};
|
|
}
|
|
|
|
export const runFastChecks = inspectRepository;
|
|
|
|
export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence {
|
|
if (!options.outputPath) throw new Error('Full install matrix requires a caller-supplied outputPath');
|
|
const repoRoot = path.resolve(options.repoRoot);
|
|
const outputPath = path.resolve(options.outputPath);
|
|
const npxExecutable = options.npxExecutable ?? (process.platform === 'win32' ? 'npx.cmd' : 'npx');
|
|
const repository = inspectRepository(repoRoot);
|
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack install matrix '));
|
|
const npmCache = path.join(workspaceRoot, 'npm cache');
|
|
const sourceRoot = path.join(workspaceRoot, 'canonical package', 'source with spaces');
|
|
const sourceLink = path.join(workspaceRoot, 'linked canonical source');
|
|
fs.mkdirSync(npmCache, { recursive: true });
|
|
|
|
let evidence: InstallMatrixEvidence | null = null;
|
|
try {
|
|
createCanonicalSourceProjection(repoRoot, sourceRoot);
|
|
fs.symlinkSync(sourceRoot, sourceLink, process.platform === 'win32' ? 'junction' : 'dir');
|
|
|
|
const controlHome = path.join(workspaceRoot, 'control home');
|
|
const controlProject = path.join(workspaceRoot, 'control project');
|
|
fs.mkdirSync(controlHome, { recursive: true });
|
|
fs.mkdirSync(controlProject, { recursive: true });
|
|
const controlEnv = isolatedEnvironment(controlHome, npmCache);
|
|
const versionCommand = execute(skillsCliArgv(npxExecutable, ['--version']), controlProject, controlEnv);
|
|
const helpCommand = execute(skillsCliArgv(npxExecutable, ['--help']), controlProject, controlEnv);
|
|
const version = versionCommand.stdout.split(/\s+/).find((part) => /^\d+\.\d+\.\d+/.test(part)) ?? 'unknown';
|
|
const supportsCopy = /--copy\b/.test(helpCommand.stdout);
|
|
const supportsRemoval = /remove\s+\[skills\]/.test(helpCommand.stdout) && /Remove Options/.test(helpCommand.stdout);
|
|
|
|
const discoveryCommand = execute(
|
|
// Exercise the repository root exactly as the documented
|
|
// `npx skills add time-attack/gstack` path will after checkout. The
|
|
// curated projection alone could hide stray root-level SKILL.md files.
|
|
skillsCliArgv(npxExecutable, ['add', repoRoot, '--list']),
|
|
controlProject,
|
|
controlEnv,
|
|
);
|
|
const parsedDiscovery = parseDiscovery(discoveryCommand.stdout);
|
|
const discoveryChecks: CheckResult[] = [];
|
|
record(discoveryChecks, 'discovery.command', discoveryCommand.exitCode === 0, `exit=${discoveryCommand.exitCode}`);
|
|
record(discoveryChecks, 'discovery.copy-supported', supportsCopy, supportsCopy ? '--copy is supported' : '--copy missing from CLI help');
|
|
record(discoveryChecks, 'discovery.count', parsedDiscovery.count === PUBLIC_SKILLS.length, `expected 6; found ${parsedDiscovery.count ?? '(unparsed)'}`);
|
|
record(
|
|
discoveryChecks,
|
|
'discovery.names',
|
|
JSON.stringify(parsedDiscovery.names) === JSON.stringify([...PUBLIC_SKILLS]),
|
|
`expected ${PUBLIC_SKILLS.join(', ')}; found ${parsedDiscovery.names.join(', ') || '(unparsed)'}`,
|
|
);
|
|
|
|
const installs: InstallCaseEvidence[] = [];
|
|
for (const [agentIndex, entry] of AGENT_MATRIX.entries()) {
|
|
for (const scope of ['project', 'global'] as const) {
|
|
const sourceKind: InstallCaseEvidence['sourceKind'] = (agentIndex + (scope === 'global' ? 1 : 0)) % 2 === 0
|
|
? 'path-with-spaces'
|
|
: 'source-symlink';
|
|
const id = `${entry.agent}-${scope}-default`;
|
|
installs.push(runInstallCase({
|
|
id,
|
|
entry,
|
|
scope,
|
|
sourceKind,
|
|
sourceArgument: sourceKind === 'source-symlink' ? sourceLink : sourceRoot,
|
|
sourceRoot,
|
|
expectedSkills: PUBLIC_SKILLS,
|
|
explicitSelection: false,
|
|
workspaceRoot,
|
|
npmCache,
|
|
npxExecutable,
|
|
}).evidence);
|
|
}
|
|
}
|
|
|
|
const cursor = AGENT_MATRIX.find((entry) => entry.agent === 'cursor')!;
|
|
const selectedProject = runInstallCase({
|
|
id: 'collision-selection-project',
|
|
entry: cursor,
|
|
scope: 'project',
|
|
sourceKind: 'repository-root',
|
|
sourceArgument: repoRoot,
|
|
sourceRoot: repoRoot,
|
|
expectedSkills: COLLISION_SKILLS,
|
|
explicitSelection: true,
|
|
workspaceRoot,
|
|
npmCache,
|
|
npxExecutable,
|
|
});
|
|
installs.push(selectedProject.evidence);
|
|
|
|
const codex = AGENT_MATRIX.find((entry) => entry.agent === 'codex')!;
|
|
const selectedGlobal = runInstallCase({
|
|
id: 'collision-selection-global',
|
|
entry: codex,
|
|
scope: 'global',
|
|
sourceKind: 'path-with-spaces',
|
|
sourceArgument: sourceRoot,
|
|
sourceRoot,
|
|
expectedSkills: COLLISION_SKILLS,
|
|
explicitSelection: true,
|
|
workspaceRoot,
|
|
npmCache,
|
|
npxExecutable,
|
|
});
|
|
installs.push(selectedGlobal.evidence);
|
|
|
|
const openclaw = AGENT_MATRIX.find((entry) => entry.agent === 'openclaw')!;
|
|
const shipOnly = runInstallCase({
|
|
id: 'single-skill-ship-project',
|
|
entry: openclaw,
|
|
scope: 'project',
|
|
sourceKind: 'path-with-spaces',
|
|
sourceArgument: sourceRoot,
|
|
sourceRoot,
|
|
expectedSkills: ['ship'],
|
|
explicitSelection: true,
|
|
workspaceRoot,
|
|
npmCache,
|
|
npxExecutable,
|
|
});
|
|
installs.push(shipOnly.evidence);
|
|
|
|
const compatibilityAlias = runInstallCase({
|
|
id: 'compatibility-alias-office-hours',
|
|
entry: codex,
|
|
scope: 'project',
|
|
sourceKind: 'repository-root',
|
|
sourceArgument: repoRoot,
|
|
sourceRoot: repoRoot,
|
|
sourceSkillSegments: ['skills', '.compat'],
|
|
expectedSkills: ['office-hours'],
|
|
explicitSelection: true,
|
|
workspaceRoot,
|
|
npmCache,
|
|
npxExecutable,
|
|
});
|
|
installs.push(compatibilityAlias.evidence);
|
|
|
|
const removals = [
|
|
runRemoval({
|
|
id: 'collision-removal-project',
|
|
entry: cursor,
|
|
scope: 'project',
|
|
skills: COLLISION_SKILLS,
|
|
projectRoot: selectedProject.projectRoot,
|
|
homeRoot: selectedProject.homeRoot,
|
|
env: selectedProject.env,
|
|
npxExecutable,
|
|
supported: supportsRemoval,
|
|
}),
|
|
runRemoval({
|
|
id: 'collision-removal-global',
|
|
entry: codex,
|
|
scope: 'global',
|
|
skills: COLLISION_SKILLS,
|
|
projectRoot: selectedGlobal.projectRoot,
|
|
homeRoot: selectedGlobal.homeRoot,
|
|
env: selectedGlobal.env,
|
|
npxExecutable,
|
|
supported: supportsRemoval,
|
|
}),
|
|
];
|
|
|
|
const allChecks = [
|
|
...repository.checks,
|
|
...discoveryChecks,
|
|
...installs.flatMap((install) => install.checks),
|
|
...removals.flatMap((removal) => removal.checks),
|
|
];
|
|
const failedChecks = allChecks.filter((check) => !check.passed).length;
|
|
const limitations = [
|
|
'The full matrix exercises the current local canonical skills/ tree through the published npx skills CLI; it does not fetch the not-yet-published branch from GitHub.',
|
|
'The source projection excludes ignored/generated legacy host trees because those files are not part of a clean standards-based package checkout.',
|
|
'This run proves filesystem installation/removal contracts; launching each host UI or agent process is outside the installer matrix.',
|
|
];
|
|
if (!supportsRemoval) limitations.push('The installed skills CLI did not advertise safe non-interactive removal, so removal cases were recorded as unsupported and skipped.');
|
|
|
|
evidence = {
|
|
schemaVersion: 1,
|
|
mode: 'full',
|
|
generatedAt: new Date().toISOString(),
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
repositoryRoot: repoRoot,
|
|
sourceProjection: 'repository-root-and-canonical-projection',
|
|
cli: {
|
|
executable: npxExecutable,
|
|
version,
|
|
supportsCopy,
|
|
supportsRemoval,
|
|
versionCommand,
|
|
helpCommand,
|
|
},
|
|
repository,
|
|
discovery: {
|
|
count: parsedDiscovery.count,
|
|
names: parsedDiscovery.names,
|
|
checks: discoveryChecks,
|
|
command: discoveryCommand,
|
|
passed: discoveryChecks.every((check) => check.passed),
|
|
},
|
|
installs,
|
|
removals,
|
|
summary: {
|
|
passed: failedChecks === 0,
|
|
checks: allChecks.length,
|
|
passedChecks: allChecks.length - failedChecks,
|
|
failedChecks,
|
|
installCases: installs.length,
|
|
removalCases: removals.length,
|
|
},
|
|
limitations,
|
|
};
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
const portableEvidence = replaceEvidencePaths(evidence, [
|
|
[repoRoot, '<REPOSITORY_ROOT>'],
|
|
[workspaceRoot, '<TEMP_MATRIX_ROOT>'],
|
|
]);
|
|
fs.writeFileSync(outputPath, `${JSON.stringify(portableEvidence, null, 2)}\n`, 'utf8');
|
|
return evidence;
|
|
} finally {
|
|
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function replaceEvidencePaths<T>(value: T, replacements: Array<[string, string]>): T {
|
|
if (typeof value === 'string') {
|
|
return replacements.reduce((current, [from, to]) => current.split(from).join(to), value) as T;
|
|
}
|
|
if (Array.isArray(value)) return value.map((entry) => replaceEvidencePaths(entry, replacements)) as T;
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [key, replaceEvidencePaths(child, replacements)]),
|
|
) as T;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
interface CliOptions {
|
|
full: boolean;
|
|
repoRoot: string;
|
|
outputPath?: string;
|
|
npxExecutable?: string;
|
|
}
|
|
|
|
function usage(): string {
|
|
return [
|
|
'Usage: bun run scripts/gstack2/test-install-matrix.ts [options]',
|
|
'',
|
|
'Default mode performs deterministic, network-free repository checks.',
|
|
'',
|
|
'Options:',
|
|
' --full Run the real npx skills install/remove matrix',
|
|
' --repo <path> Repository root (default: detected root)',
|
|
' --output <path> Machine-readable JSON evidence (defaults to the OS temp directory)',
|
|
' --npx <path> Override npx executable (useful on Windows/CI)',
|
|
' --help Show this help',
|
|
].join('\n');
|
|
}
|
|
|
|
function parseArgs(argv: string[]): CliOptions {
|
|
const options: CliOptions = { full: false, repoRoot: DEFAULT_REPO_ROOT };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--full') options.full = true;
|
|
else if (argument === '--repo') options.repoRoot = path.resolve(argv[++index] ?? '');
|
|
else if (argument === '--output') options.outputPath = path.resolve(argv[++index] ?? '');
|
|
else if (argument === '--npx') options.npxExecutable = argv[++index];
|
|
else if (argument === '--help' || argument === '-h') {
|
|
process.stdout.write(`${usage()}\n`);
|
|
process.exit(0);
|
|
} else throw new Error(`Unknown argument: ${argument}\n\n${usage()}`);
|
|
}
|
|
if (options.full && !options.outputPath) {
|
|
options.outputPath = path.join(os.tmpdir(), `gstack2-install-matrix-${process.pid}.json`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.full) {
|
|
const result = runFullMatrix({
|
|
repoRoot: options.repoRoot,
|
|
outputPath: options.outputPath!,
|
|
npxExecutable: options.npxExecutable,
|
|
});
|
|
process.stdout.write(
|
|
`GStack 2 install matrix ${result.summary.passed ? 'passed' : 'failed'}: `
|
|
+ `${result.summary.passedChecks}/${result.summary.checks} checks, `
|
|
+ `${result.summary.installCases} install cases, ${result.summary.removalCases} removal cases; `
|
|
+ `skills CLI ${result.cli.version}. Evidence: ${path.resolve(options.outputPath!)}\n`,
|
|
);
|
|
if (!result.summary.passed) process.exitCode = 1;
|
|
} else {
|
|
const result = runFastChecks(options.repoRoot);
|
|
process.stdout.write(
|
|
`GStack 2 install surface ${result.passed ? 'passed' : 'failed'}: `
|
|
+ `${result.checks.filter((check) => check.passed).length}/${result.checks.length} checks; `
|
|
+ `${result.publicSkills.length} public skills.\n`,
|
|
);
|
|
if (!result.passed) {
|
|
for (const failure of result.checks.filter((check) => !check.passed)) {
|
|
process.stderr.write(`- ${failure.id}: ${failure.detail}\n`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|