Files
Garry Tanandcapy-ai[bot] f4f16e83e0 v1.91.305.0 feat: render gstack per model with modules left out, measured before any model gets less (GSTA-140 PR 1a) (#3554)
* feat(modules): module catalog, per-model loadouts manifest and gstack-modules render (GSTA-140 PR 1a)

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

* test(modules): cover bin/gstack-modules and the loadouts schema; allow modules/ in pregate strays

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

* fix(modules): gen:skill-docs refuses an exclusion render without --out-dir; render tests set up their own arm

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

* fix(modules): skill exclusions honor pinned sections and section references; own-property loadout lookups; llms.txt follows the selection; ownership rechecked before swap

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

* chore: bump version and changelog (v1.91.296.0)

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

* docs: loadout render cost and the modules/ directory in PROJECT_STRUCTURE

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

* chore: unstamp #3554 (slot calls stamp); CHANGELOG entry back under [Unreleased]

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-10-29 04:22:41 +00:00

87 lines
5.8 KiB
TypeScript

/**
* bin/gstack-modules (GSTA-140, PR 1a): the module catalog and loadout renders.
*
* gstack-modules list [--json] [--pinned]
* gstack-modules validate
* gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
* [--install-root <abs>] [--model <id>] [--plan-mode on|off]
*
* Exit 0 on success, 2 on a refused input (ModuleError, printed with its code,
* fix and docs anchor), 1 on an unexpected failure.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveHostArg } from '../hosts/index';
import { resolveModel } from './models';
import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules';
import { renderLoadout } from './modules-render';
import type { Host } from './resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const USAGE = `Usage:
gstack-modules list [--json] [--pinned] every module id with its kind, source and pin
gstack-modules validate check ${LOADOUTS_FILE} against the catalog
gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
[--install-root <abs>] [--model <id>] [--plan-mode on|off]
render one loadout and write loadout.lock.json
Guide: docs/loadouts.md`;
export interface CliResult { code: number; stdout: string; stderr: string }
function flag(args: string[], name: string): string | undefined {
const index = args.findIndex(a => a === name || a.startsWith(`${name}=`));
if (index < 0) return undefined;
const value = args[index].startsWith(`${name}=`) ? args[index].slice(name.length + 1) : args[index + 1];
if (!value || value.startsWith('--')) throw new ModuleError('MODULE_SPEC_INVALID', `${name} requires a value`, `gstack-modules --help`);
return value;
}
export async function modulesMain(args: string[]): Promise<CliResult> {
const [command, ...rest] = args;
try {
if (!command || command === '--help' || command === '-h' || command === 'help') return { code: 0, stdout: USAGE + '\n', stderr: '' };
if (command === 'list') {
const manifest = loadLoadouts();
const catalog = moduleCatalog(manifest).filter(e => !rest.includes('--pinned') || e.pinned);
const rows = catalog.map(e => {
const bytes = fs.readFileSync(path.join(ROOT, e.source));
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
});
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
}
if (command === 'validate') {
const manifest = loadLoadouts();
const problems = validateLoadouts(manifest, moduleCatalog(manifest));
if (problems.length) return { code: 2, stdout: '', stderr: problems.map(p => `LOADOUTS_INVALID: ${p}`).join('\n') + '\n' };
return { code: 0, stdout: `LOADOUTS: ok (${Object.keys(manifest.loadouts).length} loadouts)\n`, stderr: '' };
}
if (command === 'render') {
const hostValue = flag(rest, '--host');
const outDir = flag(rest, '--out-dir');
if (!hostValue || !outDir) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --host and --out-dir', 'gstack-modules render --host claude --loadout full --out-dir <dir>');
let host: Host;
try { host = resolveHostArg(hostValue) as Host; } catch (error) { throw new ModuleError('MODULE_SPEC_INVALID', (error as Error).message, 'pass a host from docs/hosts.md, for example --host claude'); }
const loadout = flag(rest, '--loadout');
const modules = flag(rest, '--modules');
if (loadout === undefined && modules === undefined) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --loadout or --modules', 'add --loadout full for the control arm');
const modelValue = flag(rest, '--model');
const model = modelValue === undefined ? null : resolveModel(modelValue);
if (modelValue !== undefined && !model) throw new ModuleError('MODULE_SPEC_INVALID', `--model ${JSON.stringify(modelValue.slice(0, 80))} is not a model gstack knows`, 'pass the same --model setup uses, for example gpt-6-astra');
const planMode = flag(rest, '--plan-mode');
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
const installRoot = flag(rest, '--install-root');
if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use');
const selection = selectModules({ loadout, modules });
const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' });
return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' };
}
return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` };
} catch (error) {
if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` };
return { code: 1, stdout: '', stderr: `gstack-modules: unexpected failure: ${(error as Error).message}\n` };
}
}