mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
wip: harness registration in setup, uninstall, tests
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b6f80f77d4
commit
bbebe4e56d
11 files changed
+425
-104
No files matched your search
@@ -76,8 +76,8 @@ installs and passes the conformance tests but has no certification run yet;
|
||||
| GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` |
|
||||
| Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` |
|
||||
| Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` |
|
||||
| OpenClaw | instruction-only | `--host openclaw` (prints the digest path; ACP spawns Claude Code — [docs/OPENCLAW.md](docs/OPENCLAW.md)) | "Load gstack. Run /review" | advisory, not blocked | — | re-copy the digest after upgrades |
|
||||
| Hermes | instruction-only | `--host hermes` (prints the digest path and `gen:skill-docs --host hermes`) | copy the digest, or render skills yourself | advisory, not blocked | — | re-copy the digest after upgrades |
|
||||
| OpenClaw | experimental | `--host openclaw` (one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the `gstack` skill | advisory, not blocked | — | `./setup --host openclaw` |
|
||||
| Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` |
|
||||
| GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — |
|
||||
|
||||
Per-host notes (Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md).
|
||||
|
||||
+32
-16
@@ -36,13 +36,16 @@ const env = process.env;
|
||||
const home = os.homedir();
|
||||
const self = path.join(checkout, 'bin', 'gstack-harness');
|
||||
|
||||
function receipt(r: RegisterResult): void {
|
||||
function receiptLines(r: RegisterResult): string[] {
|
||||
const t = TARGETS[r.target];
|
||||
console.log('gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')');
|
||||
if (r.router) console.log(' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : ''));
|
||||
console.log(' ledger: ' + r.ledger);
|
||||
console.log(' remove: ' + self + ' unregister ' + r.id);
|
||||
return [
|
||||
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
|
||||
...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []),
|
||||
' ledger: ' + r.ledger,
|
||||
' remove: ' + self + ' unregister ' + r.id,
|
||||
];
|
||||
}
|
||||
const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n'));
|
||||
|
||||
function usage(code: number): never {
|
||||
const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n');
|
||||
@@ -74,28 +77,37 @@ async function main(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
if (t.mechanism === 'verify-only') {
|
||||
const seen = harnessSees(t, '', env);
|
||||
const seen = harnessSees(t, '', env, ['review', 'gstack-review'], true);
|
||||
if (seen.visible === 'yes') { console.log('gstack-harness: ' + t.displayName + ': discovered gstack skills through ~/.claude/skills (Claude-rendered, unverified in a live session); checked: ' + seen.checked); return 0; }
|
||||
console.log('gstack-harness: ' + t.displayName + ': unverified (' + (seen.checked ? seen.checked + ' did not list gstack' : t.name + ' is not on PATH') + '). Install gstack for Claude Code (./setup), or paste the block from: ' + self + ' register generic --print');
|
||||
return 3;
|
||||
}
|
||||
receipt(register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry }));
|
||||
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry });
|
||||
if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') }));
|
||||
else receipt(r);
|
||||
return 0;
|
||||
}
|
||||
case 'unregister': {
|
||||
const remote = bool('--remote');
|
||||
const name = argv.shift() ?? '';
|
||||
const ids = listLedgers(stateRoot).filter(l => l.ledger.meta.id === name || l.ledger.meta.target === name);
|
||||
if (remote) for (const l of ids.filter(l => l.ledger.meta.target === 'qm')) console.log('gstack-harness: archived QM pack ' + await removeQmPack(stateRoot, env, l.ledger.meta.id));
|
||||
for (const id of unregister(stateRoot, checkout, name, env)) console.log('gstack-harness: removed ' + id);
|
||||
const matches = listLedgers(stateRoot).filter(l => l.ledger.meta.id === name || l.ledger.meta.target === name);
|
||||
if (!matches.length) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no registration named ' + JSON.stringify(name));
|
||||
for (const l of matches) {
|
||||
if (remote && l.ledger.meta.target === 'qm') {
|
||||
console.log('gstack-harness: archived QM pack ' + await removeQmPack(stateRoot, env, l.ledger.meta.id));
|
||||
fs.rmSync(l.file);
|
||||
console.log('gstack-harness: removed ' + l.ledger.meta.id);
|
||||
} else for (const id of unregister(stateRoot, checkout, l.ledger.meta.id, env)) console.log('gstack-harness: removed ' + id);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'status': {
|
||||
const rows = status(stateRoot, version, env, !bool('--no-check'));
|
||||
const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...harnessSees(TARGETS[n], '', env) }));
|
||||
const check = !bool('--no-check');
|
||||
const rows = status(stateRoot, version, env, check);
|
||||
const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) }));
|
||||
if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; }
|
||||
if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine');
|
||||
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + '\t' + r.id);
|
||||
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id);
|
||||
for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : ''));
|
||||
return 0;
|
||||
}
|
||||
@@ -105,14 +117,18 @@ async function main(): Promise<number> {
|
||||
}
|
||||
case 'auto': {
|
||||
const d = detect(env, home);
|
||||
let failed = 0;
|
||||
const done = new Set<string>();
|
||||
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
|
||||
try { receipt(register({ ...base, target: TARGETS[n], registry: true })); }
|
||||
catch (e) { if (e instanceof HarnessError) console.error(e.line()); else throw e; }
|
||||
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
|
||||
}
|
||||
try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
|
||||
for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) {
|
||||
console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n);
|
||||
}
|
||||
return 0;
|
||||
return failed;
|
||||
}
|
||||
case 'manifest': {
|
||||
const t = getTarget(argv.shift() ?? 'generic');
|
||||
|
||||
@@ -28,13 +28,13 @@ _gstack_harness_cut_block() {
|
||||
}
|
||||
|
||||
gstack_harness_unregister_ledger() {
|
||||
local ledger="$1" root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" stamp kind a b target="" dest="" backup
|
||||
local ledger="$1" root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" stamp kind a b target="" dest="" pack="" backup
|
||||
[ -f "$ledger" ] || return 0
|
||||
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)-$$"
|
||||
backup="$root/backups/harness/$stamp"
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
case "$kind" in
|
||||
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; esac ;;
|
||||
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;;
|
||||
file)
|
||||
{ [ -f "$b" ] && [ ! -L "$b" ]; } || continue
|
||||
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
|
||||
@@ -48,6 +48,7 @@ gstack_harness_unregister_ledger() {
|
||||
case "$target" in
|
||||
openclaw|hermes)
|
||||
if command -v gstack_install_registry_remove >/dev/null 2>&1; then gstack_install_registry_remove "$target" "$dest" || true; fi ;;
|
||||
qm) [ -z "$pack" ] || echo " QM: dropped the local record of pack $pack; the pack stays in your QM org until an org admin archives it (DELETE $dest/v1/admin/skill-packs/$pack)" ;;
|
||||
esac
|
||||
rm -f "$ledger"
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ gstack_install_render_for() {
|
||||
gstack_host_tier() {
|
||||
case "$1" in
|
||||
claude) echo full ;;
|
||||
codex|kiro|factory|opencode|cursor|copilot|agy) echo experimental ;;
|
||||
slate|openclaw|hermes|gbrain) echo instruction-only ;;
|
||||
codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;;
|
||||
slate|gbrain) echo instruction-only ;;
|
||||
*) echo unknown ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
# ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks
|
||||
# ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks
|
||||
# ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks
|
||||
# harness registrations — every file gstack-harness wrote into OpenClaw,
|
||||
# Hermes, a Dot or another harness (from its
|
||||
# ledger; edited files move to a backup), and
|
||||
# its gstack:begin/end context block
|
||||
# installs.tsv rows — install registry rows for removed installs
|
||||
# ~/.gstack/ — global state (config, analytics, sessions, projects,
|
||||
# repos, installation-id, browse error logs)
|
||||
@@ -257,6 +261,18 @@ if [ -x "$GSTACK_CONFIG" ] && [ "$("$GSTACK_CONFIG" get memorable_recall 2>/dev/
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove harness registrations (bin/gstack-harness) ──────
|
||||
# Each ledger names exactly the files gstack-harness wrote into a harness's own
|
||||
# skills folder; read before the checkout and the state root go. A QM org pack
|
||||
# is left alone: `gstack-harness unregister qm --remote` archives it.
|
||||
if [ -d "$RESOLVED_ROOT/harness" ] && [ -f "$(dirname "$0")/gstack-harness-ledger.sh" ]; then
|
||||
while IFS= read -r _HARNESS_LINE; do
|
||||
case "$_HARNESS_LINE" in harness:*) REMOVED+=("$_HARNESS_LINE") ;; ?*) echo "$_HARNESS_LINE" ;; esac
|
||||
done <<EOF
|
||||
$(GSTACK_STATE_ROOT="$RESOLVED_ROOT"; . "$(dirname "$0")/gstack-harness-ledger.sh" && gstack_harness_unregister_all)
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ─── Remove global Claude skills ────────────────────────────
|
||||
CLAUDE_SKILLS="$HOME/.claude/skills"
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ import { defineHost, CROSS_MODEL_RESOLVERS, preambleToolGlossary } from './defin
|
||||
const hermes = defineHost({
|
||||
name: 'hermes',
|
||||
displayName: 'Hermes',
|
||||
tier: 'instruction-only',
|
||||
tier: 'experimental',
|
||||
capabilities: { toolExecution: true, questions: 'native', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' },
|
||||
|
||||
// Hermes indexes skills by frontmatter name, first match wins (#2825).
|
||||
@@ -43,11 +43,11 @@ const hermes = defineHost({
|
||||
// If Hermes has GBrain as a mod, brain features activate automatically.
|
||||
],
|
||||
|
||||
// No full install arm — users can hand-copy the instruction-only digest
|
||||
// (setup's explainer arm prints this path; never auto-copied).
|
||||
// ./setup --host hermes (and setup run inside Hermes) registers every skill
|
||||
// through gstack-harness into $HERMES_HOME/skills/gstack (one category).
|
||||
install: {
|
||||
linkingStrategy: 'symlink-generated',
|
||||
instructionTier: { rulesFile: 'agents-digest/gstack-AGENTS.md' },
|
||||
registration: 'harness',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: Hermes Agent <agent@nousresearch.com>',
|
||||
|
||||
+5
-4
@@ -3,7 +3,7 @@ import { defineHost, CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_RE
|
||||
const openclaw = defineHost({
|
||||
name: 'openclaw',
|
||||
displayName: 'OpenClaw',
|
||||
tier: 'instruction-only',
|
||||
tier: 'experimental',
|
||||
capabilities: { toolExecution: true, questions: 'prose', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' },
|
||||
|
||||
extraPathRewrites: [
|
||||
@@ -14,11 +14,12 @@ const openclaw = defineHost({
|
||||
// Suppress Claude-specific preamble sections that don't apply to OpenClaw
|
||||
suppressedResolvers: [...CROSS_MODEL_RESOLVERS, ...GBRAIN_RESOLVERS],
|
||||
|
||||
// No full install arm — users can hand-copy the instruction-only digest
|
||||
// (setup's explainer arm prints this path; never auto-copied).
|
||||
// ./setup --host openclaw (and setup run inside OpenClaw) registers through
|
||||
// gstack-harness: one gstack router skill in $OPENCLAW_STATE_DIR/skills, or
|
||||
// every skill with --skills all. OpenClaw's ACP route (docs/OPENCLAW.md) stays.
|
||||
install: {
|
||||
linkingStrategy: 'symlink-generated',
|
||||
instructionTier: { rulesFile: 'agents-digest/gstack-AGENTS.md' },
|
||||
registration: 'harness',
|
||||
},
|
||||
|
||||
coAuthorTrailer: 'Co-Authored-By: OpenClaw Agent <agent@openclaw.ai>',
|
||||
|
||||
+47
-24
@@ -30,6 +30,8 @@ export const ROUTER_DESCRIPTION_MAX = 300;
|
||||
export const CONTEXT_BLOCK_MAX = 600;
|
||||
export const BLOCK_BEGIN = '<!-- gstack:begin harness-routing -->';
|
||||
export const BLOCK_END = '<!-- gstack:end harness-routing -->';
|
||||
/** Where a Dot keeps its own skills (feder-cr/dots nanobot/dots/skills.py DOT_SKILLS_DIR). */
|
||||
export const DOT_SKILLS_DIR = '/home/dot/skills';
|
||||
export const ROUTER_DESCRIPTION = 'gstack engineering workflows: code review, QA testing, debugging, security audit, plan and design review, shipping PRs, release docs, retros. Load first for any coding, review, test, plan or release task; it lists every gstack skill and where to read it.';
|
||||
|
||||
export interface Target {
|
||||
@@ -46,8 +48,8 @@ export interface Target {
|
||||
inside: (env: Env, ancestry: string[]) => boolean;
|
||||
/** Installed on this machine (binary on PATH or its home folder). */
|
||||
present: (env: Env, home: string) => boolean;
|
||||
/** The harness's own command that lists the skills it sees, when it has one. */
|
||||
listCommand?: (dest: string) => { argv: string[]; env: Env; expect: RegExp };
|
||||
/** The harness's own command that lists the skills it sees, and how one skill name shows in its output. */
|
||||
listCommand?: (dest: string) => { argv: string[]; env: Env; listed: (name: string) => RegExp };
|
||||
}
|
||||
|
||||
const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
|
||||
@@ -61,20 +63,20 @@ export const TARGETS: Record<TargetName, Target> = {
|
||||
openclaw: {
|
||||
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'router', modes: ['router', 'all'],
|
||||
dest: (env, home) => path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'skills'),
|
||||
inside: (env, anc) => !!env.OPENCLAW_SESSION || ancestorIs(anc, 'openclaw'),
|
||||
inside: (env, anc) => env.OPENCLAW_SHELL === 'exec' || ancestorIs(anc, 'openclaw'),
|
||||
present: (env, home) => onPath('openclaw', env) || fs.existsSync(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw')),
|
||||
listCommand: dest => ({ argv: ['openclaw', 'skills', 'list', '--json'], env: { OPENCLAW_STATE_DIR: path.dirname(dest) }, expect: /"name":\s*"gstack(-[a-z0-9-]+)?"/ }),
|
||||
listCommand: dest => ({ argv: ['openclaw', 'skills', 'list', '--json'], env: { OPENCLAW_STATE_DIR: path.dirname(dest) }, listed: name => new RegExp('"name":\\s*"' + name + '"') }),
|
||||
},
|
||||
hermes: {
|
||||
name: 'hermes', displayName: 'Hermes', mechanism: 'skill-folder', render: 'host', defaultMode: 'all', modes: ['all'],
|
||||
dest: (env, home) => path.join(env.HERMES_HOME || path.join(home, '.hermes'), 'skills', 'gstack'),
|
||||
inside: (env, anc) => !!env.HERMES_SESSION_KEY || ancestorIs(anc, 'hermes'),
|
||||
inside: (env, anc) => env.HERMES_AGENT === 'true' || env.AI_AGENT === 'hermes-agent' || ancestorIs(anc, 'hermes'),
|
||||
present: (env, home) => onPath('hermes', env) || fs.existsSync(env.HERMES_HOME || path.join(home, '.hermes')),
|
||||
listCommand: dest => ({ argv: ['hermes', 'skills', 'list'], env: { HERMES_HOME: path.dirname(path.dirname(dest)) }, expect: /gstack-[a-z0-9-]+/ }),
|
||||
listCommand: dest => ({ argv: ['hermes', 'skills', 'list'], env: { HERMES_HOME: path.dirname(path.dirname(dest)) }, listed: name => new RegExp('\\s' + name + '\\s') }),
|
||||
},
|
||||
dots: {
|
||||
name: 'dots', displayName: 'dots (feder-cr)', mechanism: 'skill-folder', render: 'claude', defaultMode: 'router', modes: ['router', 'all'],
|
||||
dest: (_env, home) => path.join(home, 'skills'),
|
||||
dest: () => DOT_SKILLS_DIR,
|
||||
inside: (env) => fs.existsSync('/opt/invisible-dots') && (env.HOME ?? '') === '/home/dot',
|
||||
present: () => fs.existsSync('/opt/invisible-dots'),
|
||||
},
|
||||
@@ -93,12 +95,12 @@ export const TARGETS: Record<TargetName, Target> = {
|
||||
muse: {
|
||||
name: 'muse', displayName: 'Muse Code', mechanism: 'verify-only', render: null, defaultMode: 'all', modes: ['all'],
|
||||
dest: () => null, inside: () => false, present: env => onPath('muse', env),
|
||||
listCommand: () => ({ argv: ['muse', 'skills', 'list'], env: {}, expect: /\b(gstack|review|ship|office-hours)\b/ }),
|
||||
listCommand: () => ({ argv: ['muse', 'skills', 'list'], env: {}, listed: name => new RegExp('\\b' + name + '\\b') }),
|
||||
},
|
||||
grok: {
|
||||
name: 'grok', displayName: 'Grok Build', mechanism: 'verify-only', render: null, defaultMode: 'all', modes: ['all'],
|
||||
dest: () => null, inside: () => false, present: env => onPath('grok', env),
|
||||
listCommand: () => ({ argv: ['grok', 'inspect'], env: {}, expect: /\b(gstack|review|ship|office-hours)\b/ }),
|
||||
listCommand: () => ({ argv: ['grok', 'inspect'], env: {}, listed: name => new RegExp('\\b' + name + '\\b') }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -186,6 +188,14 @@ export function oneLineUse(description: string): string {
|
||||
return s.length > 160 ? s.slice(0, 157).trimEnd() + '...' : s;
|
||||
}
|
||||
|
||||
/** Agent Skills frontmatter in its strictest reading (dots): single-line name equal to the folder, single-line description of at most 1024 characters. */
|
||||
export function flatFrontmatter(md: string, name: string): string {
|
||||
const end = md.startsWith('---\n') ? md.indexOf('\n---', 4) : -1;
|
||||
const desc = frontmatterField(md, 'description').replace(/\s+/g, ' ').trim();
|
||||
const body = end < 0 ? md : md.slice(md.indexOf('\n', end + 4) + 1);
|
||||
return '---\nname: ' + name + '\ndescription: ' + (desc.length > 1024 ? desc.slice(0, 1021).trimEnd() + '...' : desc) + '\n---\n' + body;
|
||||
}
|
||||
|
||||
function skillDirs(root: string): SkillEntry[] {
|
||||
if (!fs.existsSync(root)) return [];
|
||||
return fs.readdirSync(root).sort().flatMap(name => {
|
||||
@@ -324,13 +334,13 @@ function resolveDest(opts: RegisterOptions, id?: string): string | null {
|
||||
}
|
||||
|
||||
/** Desired files (absolute path -> bytes) and the router path, for a skill-folder or generic target. */
|
||||
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; skills: number } {
|
||||
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; names: string[] } {
|
||||
const t = opts.target;
|
||||
const skills = sourceSkills(t, opts.checkout, scratch);
|
||||
const files = new Map<string, Buffer>();
|
||||
const copyDir = (from: string, to: string) => { for (const f of walkFiles(from)) files.set(path.join(to, path.relative(from, f)), fs.readFileSync(f)); };
|
||||
const claudeFiles = (s: SkillEntry, to: string) => {
|
||||
files.set(path.join(to, 'SKILL.md'), fs.readFileSync(path.join(s.dir, 'SKILL.md')));
|
||||
files.set(path.join(to, 'SKILL.md'), Buffer.from(flatFrontmatter(fs.readFileSync(path.join(s.dir, 'SKILL.md'), 'utf8'), path.basename(to))));
|
||||
if (fs.existsSync(path.join(s.dir, 'sections'))) copyDir(path.join(s.dir, 'sections'), path.join(to, 'sections'));
|
||||
};
|
||||
if (mode === 'all') {
|
||||
@@ -338,7 +348,7 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
|
||||
const to = path.join(dest, path.basename(s.dir));
|
||||
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
|
||||
}
|
||||
return { files, router: null, skills: skills.length };
|
||||
return { files, router: null, names: skills.map(s => t.render === 'host' ? s.name : path.basename(s.dir)) };
|
||||
}
|
||||
const routerDir = path.join(dest, 'gstack');
|
||||
const entries = skills.map(s => {
|
||||
@@ -349,7 +359,7 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
|
||||
});
|
||||
const router = path.join(routerDir, 'SKILL.md');
|
||||
files.set(router, Buffer.from(routerSkill(entries, t)));
|
||||
return { files, router, skills: skills.length };
|
||||
return { files, router, names: ['gstack', ...entries.map(e => e.name)] };
|
||||
}
|
||||
|
||||
function backupFile(file: string, stateRoot: string, stamp: string): void {
|
||||
@@ -396,7 +406,9 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
const prev = readLedger(lfile);
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-'));
|
||||
try {
|
||||
const { files, router, skills } = planPayload(opts, dest, mode, scratch);
|
||||
const { files, router, names } = planPayload(opts, dest, mode, scratch);
|
||||
const skills = mode === 'router' ? names.length - 1 : names.length;
|
||||
const visible = mode === 'router' ? ['gstack'] : names;
|
||||
const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha]));
|
||||
const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]);
|
||||
const owned = new Set([...prevFiles.keys()].map(topOf));
|
||||
@@ -406,8 +418,15 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
if (st.isSymbolicLink()) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', top + ' is a symlink');
|
||||
if (!owned.has(top)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', top + ' already exists and gstack did not write it');
|
||||
}
|
||||
const ours = new Set([...files.keys()].map(topOf));
|
||||
if (fs.existsSync(dest)) for (const e of fs.readdirSync(dest)) {
|
||||
const md = path.join(dest, e, 'SKILL.md');
|
||||
if (ours.has(path.join(dest, e)) || owned.has(path.join(dest, e)) || !fs.existsSync(md)) continue;
|
||||
const other = frontmatterField(fs.readFileSync(md, 'utf8'), 'name');
|
||||
if (visible.includes(other)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'a skill named ' + other + ' already exists at ' + path.join(dest, e) + ' and gstack did not write it');
|
||||
}
|
||||
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
|
||||
const same = prev && prev.meta.mode === mode && prev.files.length === want.length
|
||||
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
|
||||
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
|
||||
const block = opts.context ? contextBlock(router ?? '') : null;
|
||||
if (block && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
|
||||
@@ -435,7 +454,7 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
written++;
|
||||
}
|
||||
writeLedger(lfile, {
|
||||
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, ...(router ? { router } : {}), ...(opts.context ? { context: path.resolve(opts.context) } : {}) },
|
||||
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(opts.context ? { context: path.resolve(opts.context) } : {}) },
|
||||
files: want, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks: opts.context ? [path.resolve(opts.context)] : [],
|
||||
});
|
||||
}
|
||||
@@ -467,9 +486,9 @@ export function unregister(stateRoot: string, checkout: string, idOrTarget: stri
|
||||
}
|
||||
|
||||
/** Re-register every file registration this checkout made (setup runs it on every pass, so upgrades refresh them). */
|
||||
export function refresh(base: Omit<RegisterOptions, 'target'>): RegisterResult[] {
|
||||
export function refresh(base: Omit<RegisterOptions, 'target'>, skip: Set<string> = new Set()): RegisterResult[] {
|
||||
return listLedgers(base.stateRoot)
|
||||
.filter(({ ledger: l }) => l.meta.checkout === base.checkout && l.meta.target !== 'qm')
|
||||
.filter(({ ledger: l }) => l.meta.checkout === base.checkout && l.meta.target !== 'qm' && !skip.has(l.meta.id))
|
||||
.map(({ ledger: l }) => register({
|
||||
...base, target: getTarget(l.meta.target), mode: l.meta.mode as SkillsMode,
|
||||
skillsDir: l.meta.context && l.meta.dest.startsWith(ledgerDir(base.stateRoot)) ? undefined : l.meta.dest,
|
||||
@@ -477,19 +496,23 @@ export function refresh(base: Omit<RegisterOptions, 'target'>): RegisterResult[]
|
||||
}));
|
||||
}
|
||||
|
||||
export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string }
|
||||
export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] }
|
||||
|
||||
export function harnessSees(t: Target, dest: string, env: Env): { visible: 'yes' | 'no' | 'unknown'; checked?: string } {
|
||||
/** Runs the harness's own skill list: 'yes' when every name shows (or, with any, at least one). */
|
||||
export function harnessSees(t: Target, dest: string, env: Env, names: string[], any = false): { visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } {
|
||||
const lc = t.listCommand?.(dest);
|
||||
if (!lc || !onPath(lc.argv[0], env)) return { visible: 'unknown' };
|
||||
const r = spawnSync(lc.argv[0], lc.argv.slice(1), { env: { ...process.env, ...env, ...lc.env }, encoding: 'utf8', timeout: 60_000 });
|
||||
return { visible: lc.expect.test((r.stdout ?? '') + (r.stderr ?? '')) ? 'yes' : 'no', checked: lc.argv.join(' ') };
|
||||
if (!lc || !names.length || !onPath(lc.argv[0], env)) return { visible: 'unknown' };
|
||||
const r = spawnSync(lc.argv[0], lc.argv.slice(1), { env: { ...process.env, ...env, ...lc.env }, encoding: 'utf8', timeout: 120_000 });
|
||||
const text = (r.stdout ?? '') + (r.stderr ?? '');
|
||||
const missing = names.filter(n => !lc.listed(n).test(text));
|
||||
const ok = any ? missing.length < names.length : missing.length === 0;
|
||||
return { visible: ok ? 'yes' : 'no', checked: lc.argv.join(' '), ...(ok || any ? {} : { missing }) };
|
||||
}
|
||||
|
||||
export function status(stateRoot: string, version: string, env: Env, check = true): StatusRow[] {
|
||||
return listLedgers(stateRoot).map(({ ledger: l }) => {
|
||||
const t = (TARGETS as Record<string, Target>)[l.meta.target];
|
||||
const seen = check && t ? harnessSees(t, l.meta.dest, env) : { visible: 'unknown' as const };
|
||||
const seen = check && t ? harnessSees(t, l.meta.dest, env, (l.meta.names ?? '').split(',').filter(Boolean)) : { visible: 'unknown' as const };
|
||||
return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen };
|
||||
});
|
||||
}
|
||||
@@ -131,6 +131,12 @@ export interface HostConfig {
|
||||
* overwrite a user's own AGENTS.md. See scripts/gen-agents-digest.ts.
|
||||
*/
|
||||
instructionTier?: { rulesFile: string };
|
||||
/**
|
||||
* 'harness': setup's arm for this host is `gstack-harness register <host>`
|
||||
* (lib/harness-register.ts): the host's render copied into the harness's own
|
||||
* skills folder with an ownership ledger, not linked into a host skills dir.
|
||||
*/
|
||||
registration?: 'harness';
|
||||
};
|
||||
|
||||
// --- Host-Specific Behavioral Config ---
|
||||
@@ -189,6 +195,9 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read
|
||||
if (!['native', 'prose'].includes(caps.questions)) errors.push(`capabilities.questions must be 'native' or 'prose'`);
|
||||
if (!['enforced', 'advisory'].includes(caps.safetyHooks)) errors.push(`capabilities.safetyHooks must be 'enforced' or 'advisory'`);
|
||||
}
|
||||
if (config.install.registration === 'harness' && config.tier !== 'experimental') {
|
||||
errors.push('install.registration harness needs tier experimental (setup registers it through gstack-harness)');
|
||||
}
|
||||
if (config.tier === 'instruction-only' && !config.install.instructionTier && config.name !== 'slate' && config.name !== 'gbrain') {
|
||||
errors.push('instruction-only hosts must declare install.instructionTier (what setup tells the user to copy)');
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ Usage: ./setup [options]
|
||||
Options:
|
||||
--host <name> Install for one host. Default: claude. Hosts by tier:
|
||||
full: claude
|
||||
experimental: codex, kiro, factory, opencode, cursor, copilot, agy
|
||||
instruction-only: slate, openclaw, hermes, gbrain
|
||||
experimental: codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes
|
||||
instruction-only: slate, gbrain
|
||||
auto: every installed agent that setup detects
|
||||
An explicit --host never changes another host's install.
|
||||
--global Allow this checkout to replace an existing global install
|
||||
@@ -42,6 +42,11 @@ Options:
|
||||
installs Chromium later, on the first browser skill run;
|
||||
bin/gstack-capy-install passes --no-browser unless it is
|
||||
given --with-browser.
|
||||
--no-harness-auto Do not register gstack's skills with the agent harness this
|
||||
setup runs inside (OpenClaw, Hermes, a Dot). Same as
|
||||
GSTACK_HARNESS_AUTO=0. Registration only happens without
|
||||
--host (or with --host auto); bin/gstack-harness registers
|
||||
with any harness by hand (docs/hosts.md).
|
||||
--refresh-registered
|
||||
Refresh every install registered for this checkout (what
|
||||
/gstack-upgrade runs), then print one row per install.
|
||||
@@ -179,12 +184,13 @@ CLAUDE_MODEL_ARG=""
|
||||
CLAUDE_MODEL_SET=0
|
||||
REFRESH_REGISTERED=0
|
||||
HOST_FLAG_SET=0
|
||||
HARNESS_AUTO=1; [ "${GSTACK_HARNESS_AUTO:-1}" = 0 ] && HARNESS_AUTO=0
|
||||
# Reject unknown options before anything is written, with the closest flag.
|
||||
_setup_unknown_option() {
|
||||
local bad="$1" next="${2:-}" best="" best_d=99 flag d value=""
|
||||
for flag in --host --model --claude-model --global --local --prefix --no-prefix --team --no-team \
|
||||
--plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \
|
||||
--refresh-registered --status --no-browser --quiet --help; do
|
||||
--refresh-registered --status --no-browser --no-harness-auto --quiet --help; do
|
||||
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
|
||||
n = length(a); m = length(b)
|
||||
for (i = 0; i <= n; i++) d[i, 0] = i
|
||||
@@ -226,31 +232,15 @@ while [ $# -gt 0 ]; do
|
||||
--timeline-stop-hook=*) TIMELINE_STOP_HOOK_MODE="${1#--timeline-stop-hook=}"; shift ;;
|
||||
--refresh-registered) REFRESH_REGISTERED=1; shift ;;
|
||||
--no-browser) export GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_SKIP_FONTS=1; shift ;;
|
||||
--no-harness-auto) HARNESS_AUTO=0; shift ;;
|
||||
-q|--quiet) QUIET=1; shift ;;
|
||||
*) _setup_unknown_option "$@"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Shared by the instruction-tier explainer arms (openclaw, hermes). The digest
|
||||
# path is anchored to the script's own directory — $(pwd) would print a
|
||||
# nonexistent path when setup is invoked from anywhere else.
|
||||
print_instruction_tier() {
|
||||
local source_dir
|
||||
source_dir="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
echo ""
|
||||
echo "Instruction-only tier (no install): copy the 2KB rules digest into a"
|
||||
echo "location your agent reads (e.g. append to your project's AGENTS.md)."
|
||||
echo "It carries gstack's ethos, reuse ladder, and voice rules:"
|
||||
echo ""
|
||||
echo " $source_dir/agents-digest/gstack-AGENTS.md"
|
||||
echo ""
|
||||
echo "Re-copy it after upgrading gstack — the digest's first line shows its version."
|
||||
echo ""
|
||||
}
|
||||
|
||||
[ "$HOST" = "antigravity" ] && HOST="agy" # Antigravity CLI is named after its binary
|
||||
case "$HOST" in
|
||||
claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto) ;;
|
||||
claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto) ;;
|
||||
slate)
|
||||
echo ""
|
||||
echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —"
|
||||
@@ -261,33 +251,6 @@ case "$HOST" in
|
||||
echo " ./setup --host claude"
|
||||
echo ""
|
||||
exit 0 ;;
|
||||
openclaw)
|
||||
echo ""
|
||||
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
||||
echo "sessions natively via ACP. gstack provides methodology artifacts, not a"
|
||||
echo "full skill installation."
|
||||
echo ""
|
||||
echo "To integrate gstack with OpenClaw:"
|
||||
echo " 1. Tell your OpenClaw agent: 'install gstack for openclaw'"
|
||||
echo " 2. Or generate artifacts: bun run gen:skill-docs --host openclaw"
|
||||
echo " (in an install, add 'openclaw' to .gstack-installed-hosts first, or ./setup prunes them)"
|
||||
echo " 3. See docs/OPENCLAW.md for the full architecture"
|
||||
print_instruction_tier
|
||||
exit 0 ;;
|
||||
hermes)
|
||||
echo ""
|
||||
echo "Hermes is an instruction-only gstack host: setup installs nothing for"
|
||||
echo "Hermes and changed nothing on this machine."
|
||||
echo ""
|
||||
echo "To use gstack with Hermes:"
|
||||
echo " 1. Copy the rules digest below into a file Hermes reads, or"
|
||||
echo " 2. Render Hermes-flavoured skills yourself (not certified; #2826):"
|
||||
echo " bun run gen:skill-docs --host hermes"
|
||||
echo " then point a Hermes profile's skills.external_dirs at .hermes/skills."
|
||||
echo " Add 'hermes' to .gstack-installed-hosts in this checkout first: ./setup"
|
||||
echo " prunes renders of hosts that file does not name (#1694)."
|
||||
print_instruction_tier
|
||||
exit 0 ;;
|
||||
gbrain)
|
||||
echo ""
|
||||
echo "GBrain is a mod for gstack — it makes coding skills brain-aware."
|
||||
@@ -553,6 +516,9 @@ claude "*) _persist_claude_model ;;
|
||||
$_RF_TARGETS
|
||||
EOF
|
||||
grep -q ' failed ' "$_RF_SUMMARY" && _RF_FAILED=1
|
||||
if [ "$HARNESS_AUTO" -eq 1 ] && [ -d "$GSTACK_STATE_ROOT/harness" ]; then
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" refresh || _RF_FAILED=1
|
||||
fi
|
||||
echo ""
|
||||
if [ "$_RF_FAILED" -eq 1 ]; then
|
||||
echo "Upgrade summary (NOT every install was refreshed; see failed rows):"
|
||||
@@ -1414,6 +1380,7 @@ INSTALL_OPENCODE=0
|
||||
INSTALL_CURSOR=0
|
||||
INSTALL_COPILOT=0
|
||||
INSTALL_AGY=0
|
||||
INSTALL_HARNESS=""
|
||||
if [ "$HOST" = "auto" ]; then
|
||||
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
|
||||
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
|
||||
@@ -1450,13 +1417,15 @@ elif [ "$HOST" = "copilot" ]; then
|
||||
INSTALL_COPILOT=1
|
||||
elif [ "$HOST" = "agy" ]; then
|
||||
INSTALL_AGY=1
|
||||
elif [ "$HOST" = "openclaw" ] || [ "$HOST" = "hermes" ]; then
|
||||
INSTALL_HARNESS="$HOST"
|
||||
fi
|
||||
|
||||
# A host that passes --host validation but sets no INSTALL_* flag would
|
||||
# silently configure nothing and exit 0 (the #2361 slate failure class).
|
||||
# Fail loudly if a future host lands in the accept-list without a dispatch arm.
|
||||
if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then
|
||||
echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy (informational: slate, openclaw, hermes, gbrain)." >&2
|
||||
if [ "$HOST" != "auto" ] && [ -z "$INSTALL_HARNESS" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then
|
||||
echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes (informational: slate, gbrain)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -2354,6 +2323,25 @@ fi
|
||||
# 3. Ensure ~/.gstack global state directory exists
|
||||
mkdir -p "$HOME/.gstack/projects"
|
||||
|
||||
# OpenClaw and Hermes: gstack-harness copies this checkout's render for the
|
||||
# harness into the harness's own skills folder, records every file in a ledger
|
||||
# (bin/gstack-uninstall removes exactly those) and never edits harness config
|
||||
# (lib/harness-register.ts). Nothing else in this run applies to them.
|
||||
_gstack_harness() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" "$@"; }
|
||||
if [ -n "$INSTALL_HARNESS" ]; then
|
||||
_setup_arm_begin "$INSTALL_HARNESS" global "$INSTALL_HARNESS"
|
||||
if ! _HARNESS_JSON="$(_gstack_harness register "$INSTALL_HARNESS" --json --no-registry)"; then
|
||||
_SETUP_REFUSED="gstack-harness could not register $INSTALL_HARNESS (reason above); nothing was changed for it"
|
||||
exit 1
|
||||
fi
|
||||
_HARNESS_DEST="$(printf '%s\n' "$_HARNESS_JSON" | sed -n 's/.*"dest":"\([^"]*\)".*/\1/p')"
|
||||
_setup_arm_publish "$INSTALL_HARNESS" global - "$_HARNESS_DEST" "$SOURCE_GSTACK_DIR" - -
|
||||
log "$(printf '%s\n' "$_HARNESS_JSON" | sed -n 's/.*"receipt":"\([^"]*\)".*/\1/p')"
|
||||
_setup_print_summary
|
||||
printf '%s' "$_SETUP_ROWS" | _setup_ready_line
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Helper: link a skill's runtime assets into its installed dir ────────────
|
||||
# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317,
|
||||
# #2454): review/checklist.md + specialists/, qa/templates + references,
|
||||
@@ -4810,4 +4798,11 @@ if [ -n "${_HOOK_REFUSED:-}" ]; then
|
||||
fi
|
||||
# The final line names every host this run installed, from the same rows as
|
||||
# the summary table; instruction-only hosts write no row and print none.
|
||||
# Inside an agent harness (OpenClaw, Hermes, a Dot) with no --host naming
|
||||
# another host, register gstack's skills with that harness too, and refresh the
|
||||
# harness registrations this checkout made before (docs/hosts.md).
|
||||
if [ "$HARNESS_AUTO" -eq 1 ] && [ "$LOCAL_INSTALL" -eq 0 ] && { [ "$HOST_FLAG_SET" -eq 0 ] || [ "$HOST" = auto ]; }; then
|
||||
_HARNESS_OUT="$(_gstack_harness auto 2>&1)" || log "gstack-harness: registration with the harness failed; everything else is installed. Retry: $SOURCE_GSTACK_DIR/bin/gstack-harness auto"
|
||||
[ -z "$_HARNESS_OUT" ] || { log ""; log "$_HARNESS_OUT"; log " Turn this off: ./setup --no-harness-auto (or GSTACK_HARNESS_AUTO=0)"; }
|
||||
fi
|
||||
printf '%s' "$_SETUP_ROWS" | _setup_ready_line
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* gstack-harness (GSTA-135): registering gstack's skills with the agent harness
|
||||
* it is installed into, and taking them back out. Unit cases call the registrar
|
||||
* in-process against temporary harness folders; setup cases run the real
|
||||
* ./setup and bin/gstack-uninstall in the install fixture (test/helpers/install-fixture.ts).
|
||||
*/
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
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 {
|
||||
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
|
||||
contextBlock, detect, listLedgers, readLedger, register, unregister, type RegisterOptions,
|
||||
} from '../lib/harness-register';
|
||||
import { qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
|
||||
const temps: string[] = [];
|
||||
const tmp = () => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-test-'))); temps.push(d); return d; };
|
||||
afterEach(() => { cleanupFixtures(); for (const d of temps.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
afterAll(cleanupSeed);
|
||||
|
||||
function opts(dir: string, target: keyof typeof TARGETS, extra: Partial<RegisterOptions> = {}): RegisterOptions {
|
||||
return { target: TARGETS[target], checkout: ROOT, stateRoot: path.join(dir, 'state'), env: {}, home: path.join(dir, 'home'), version: VERSION, ...extra };
|
||||
}
|
||||
const files = (dir: string): string[] => fs.existsSync(dir)
|
||||
? fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(e => e.isFile()).map(e => path.relative(dir, path.join(e.parentPath, e.name))).sort() : [];
|
||||
|
||||
/** feder-cr/dots nanobot/dots/skills.py parse_skill: single-line fields, name equal to its folder, description 1..1024. */
|
||||
function dotsAccepts(md: string, folder: string): boolean {
|
||||
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md);
|
||||
if (!m) return false;
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of m[1].split(/\r?\n/)) { const f = /^([a-z]+):[ \t]*(.*?)[ \t]*$/.exec(line); if (f) fields[f[1]] = f[2].replace(/^["']+|["']+$/g, ''); }
|
||||
const name = fields.name ?? ''; const description = fields.description ?? '';
|
||||
return name === folder && /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name) && description.length >= 1 && description.length <= 1024;
|
||||
}
|
||||
|
||||
describe('gstack-harness budgets and reason codes', () => {
|
||||
test('every-session text stays within its budget', () => {
|
||||
expect(ROUTER_DESCRIPTION.length).toBeLessThanOrEqual(ROUTER_DESCRIPTION_MAX);
|
||||
const block = contextBlock('/home/someone/.local/share/agent-harness/skills/gstack/SKILL.md');
|
||||
expect(block.length).toBeLessThanOrEqual(CONTEXT_BLOCK_MAX);
|
||||
expect(block.startsWith(BLOCK_BEGIN) && block.endsWith(BLOCK_END)).toBe(true);
|
||||
});
|
||||
|
||||
test('every reason code has a section in docs/troubleshooting.md', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8');
|
||||
for (const [code, c] of Object.entries(HARNESS_CODES)) {
|
||||
const at = doc.indexOf('<a id="' + c.anchor + '"></a>');
|
||||
expect(at, code).toBeGreaterThan(-1);
|
||||
expect(doc.slice(at).split('\n')[1], code).toMatch(/^#{2,4} /);
|
||||
}
|
||||
});
|
||||
|
||||
test('inside-harness detection reads the markers each harness sets for its commands', () => {
|
||||
const home = tmp();
|
||||
expect(detect({ OPENCLAW_SHELL: 'exec' }, home, []).inside).toEqual(['openclaw']);
|
||||
expect(detect({ HERMES_AGENT: 'true' }, home, []).inside).toEqual(['hermes']);
|
||||
expect(detect({ AI_AGENT: 'hermes-agent' }, home, []).inside).toEqual(['hermes']);
|
||||
expect(detect({}, home, ['bash -c', 'node openclaw.mjs']).inside).toEqual(['openclaw']);
|
||||
expect(detect({}, home, ['python3 hermes']).inside).toEqual(['hermes']);
|
||||
expect(detect({ OPENCLAW_SHELL: 'interactive' }, home, ['node server.js', 'vim openclaw.json']).inside).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-harness register and unregister (skill folders)', () => {
|
||||
test('OpenClaw router: one top-level skill, idempotent, removed exactly, user skills survive', () => {
|
||||
const d = tmp();
|
||||
const skills = path.join(d, 'oc', 'skills');
|
||||
fs.mkdirSync(path.join(skills, 'mine'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skills, 'mine', 'SKILL.md'), '---\nname: mine\ndescription: my own\n---\nmine\n');
|
||||
const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } }));
|
||||
expect(r.mode).toBe('router');
|
||||
expect(r.skills).toBeGreaterThan(20);
|
||||
expect(fs.readdirSync(skills).sort()).toEqual(['gstack', 'mine']);
|
||||
const router = fs.readFileSync(path.join(skills, 'gstack', 'SKILL.md'), 'utf8');
|
||||
expect(router).toContain('description: ' + ROUTER_DESCRIPTION);
|
||||
for (const [, p] of router.matchAll(/Read \x60([^\x60]+)\x60/g)) expect(fs.existsSync(p), p).toBe(true);
|
||||
expect(register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } })).status).toBe('unchanged');
|
||||
unregister(path.join(d, 'state'), ROOT, r.id);
|
||||
expect(files(skills)).toEqual(['mine/SKILL.md']);
|
||||
expect(listLedgers(path.join(d, 'state'))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
test('OpenClaw all: every skill a top-level folder with a unique name', () => {
|
||||
const d = tmp();
|
||||
const r = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'skills') }));
|
||||
const names = fs.readdirSync(path.join(d, 'skills')).map(n => fs.readFileSync(path.join(d, 'skills', n, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m)![1]);
|
||||
expect(names.length).toBe(r.skills);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(readLedger(r.ledger)!.meta.names.split(',').sort()).toEqual([...names].sort());
|
||||
}, 60_000);
|
||||
|
||||
test('Hermes: every skill in one gstack category, no runtime folder a scan could take for a skill', () => {
|
||||
const d = tmp();
|
||||
const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } }));
|
||||
const dest = path.join(d, 'hermes', 'skills', 'gstack');
|
||||
expect(r.dest).toBe(dest);
|
||||
const mds = files(dest).filter(f => f.endsWith('SKILL.md'));
|
||||
expect(mds.every(f => f.split('/').length === 2), mds.filter(f => f.split('/').length !== 2).join(', ')).toBe(true);
|
||||
for (const f of mds) expect(fs.readFileSync(path.join(dest, f), 'utf8')).toMatch(new RegExp('^---\\nname: ' + path.dirname(f) + '\\n'));
|
||||
expect(mds.length).toBe(r.skills);
|
||||
}, 60_000);
|
||||
|
||||
test('refuses an entry gstack did not write, a same-named skill, a symlinked folder, and unsafe destinations', () => {
|
||||
const d = tmp();
|
||||
const skills = path.join(d, 'skills');
|
||||
fs.mkdirSync(path.join(skills, 'gstack'), { recursive: true });
|
||||
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
|
||||
expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNOWNED_ENTRY');
|
||||
expect(files(skills)).toEqual([]);
|
||||
fs.rmSync(path.join(skills, 'gstack'), { recursive: true });
|
||||
fs.mkdirSync(path.join(skills, 'other'));
|
||||
fs.writeFileSync(path.join(skills, 'other', 'SKILL.md'), '---\nname: gstack\ndescription: x\n---\nx\n');
|
||||
expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNOWNED_ENTRY');
|
||||
fs.rmSync(path.join(skills, 'other'), { recursive: true });
|
||||
fs.symlinkSync(d, path.join(skills, 'gstack'));
|
||||
expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNSAFE_DESTINATION');
|
||||
expect(code(() => register(opts(d, 'dots', { skillsDir: path.join(ROOT, '.agents', 'skills') })))).toBe('HARNESS_UNSAFE_DESTINATION');
|
||||
expect(code(() => register(opts(d, 'dots', { skillsDir: path.join(d, 'home') })))).toBe('HARNESS_UNSAFE_DESTINATION');
|
||||
expect(code(() => register(opts(d, 'generic')))).toBe('HARNESS_NO_DESTINATION');
|
||||
expect(code(() => register(opts(d, 'hermes', { mode: 'router', env: { HERMES_HOME: path.join(d, 'h') } })))).toBe('HARNESS_MODE_UNSUPPORTED');
|
||||
}, 60_000);
|
||||
|
||||
test('dots: router and every skill pass the engine\'s skill parser', () => {
|
||||
expect(TARGETS.dots.dest({}, '/root')).toBe(DOT_SKILLS_DIR);
|
||||
const d = tmp();
|
||||
register(opts(d, 'dots', { skillsDir: path.join(d, 'router') }));
|
||||
expect(dotsAccepts(fs.readFileSync(path.join(d, 'router', 'gstack', 'SKILL.md'), 'utf8'), 'gstack')).toBe(true);
|
||||
register(opts(d, 'dots', { mode: 'all', skillsDir: path.join(d, 'all') }));
|
||||
for (const name of fs.readdirSync(path.join(d, 'all'))) {
|
||||
const md = path.join(d, 'all', name, 'SKILL.md');
|
||||
expect(dotsAccepts(fs.readFileSync(md, 'utf8'), name), name).toBe(true);
|
||||
expect(fs.statSync(md).size).toBeLessThanOrEqual(256 * 1024);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('generic: the context block is replaced in place, then cut out; the user\'s lines and edits survive', () => {
|
||||
const d = tmp();
|
||||
const ctx = path.join(d, 'agent', 'AGENTS.md');
|
||||
fs.mkdirSync(path.dirname(ctx), { recursive: true });
|
||||
fs.writeFileSync(ctx, '# my rules\nbe kind\n');
|
||||
const r = register(opts(d, 'generic', { context: ctx }));
|
||||
expect(fs.readFileSync(ctx, 'utf8').split(BLOCK_BEGIN).length).toBe(2);
|
||||
register(opts(d, 'generic', { context: ctx }));
|
||||
expect(fs.readFileSync(ctx, 'utf8').split(BLOCK_BEGIN).length).toBe(2);
|
||||
fs.appendFileSync(r.router!, '\nmy note\n');
|
||||
unregister(path.join(d, 'state'), ROOT, 'generic');
|
||||
expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n\n');
|
||||
expect(fs.existsSync(r.router!)).toBe(false);
|
||||
const kept = files(path.join(d, 'state', 'backups', 'harness'));
|
||||
expect(kept.length).toBe(1);
|
||||
expect(kept[0].endsWith('gstack/SKILL.md')).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('gstack-harness QM (org skill pack, against a fake admin API)', () => {
|
||||
function fakeQm(failImports = 0) {
|
||||
const packs: Array<{ id: string; url: string; ref: string; config: unknown }> = [];
|
||||
const calls: string[] = [];
|
||||
let fails = failImports;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
calls.push(req.method + ' ' + u.pathname);
|
||||
if (req.headers.get('authorization') !== 'Bearer t0k') return new Response('{"error":"unauthorized"}', { status: 401 });
|
||||
if (req.method === 'GET' && u.pathname === '/v1/admin/skill-packs') return Response.json({ packs });
|
||||
if (req.method === 'POST' && u.pathname === '/v1/admin/skill-packs') {
|
||||
const b = await req.json() as { url: string; ref: string; config: unknown };
|
||||
const pack = { id: 'pk' + (packs.length + 1), url: b.url, ref: b.ref, config: b.config };
|
||||
packs.push(pack);
|
||||
return Response.json({ pack });
|
||||
}
|
||||
if (req.method === 'POST' && u.pathname.endsWith('/import')) {
|
||||
if (fails-- > 0) return new Response('{"error":"boom"}', { status: 502 });
|
||||
return Response.json({ imported: ['review', 'ship'], excluded: [] });
|
||||
}
|
||||
if (req.method === 'DELETE') { packs.splice(packs.findIndex(p => u.pathname.endsWith(p.id)), 1); return Response.json({ removed: true }); }
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
return { server, packs, calls, env: { GSTACK_QM_URL: 'http://127.0.0.1:' + server.port, GSTACK_QM_ADMIN_TOKEN: 't0k' } };
|
||||
}
|
||||
|
||||
test('without a credential or --yes it refuses and prints the request, never a token', async () => {
|
||||
const d = tmp();
|
||||
const err = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: {}, version: VERSION, yes: true }).catch(e => e);
|
||||
expect(err.code).toBe('HARNESS_QM_CREDENTIALS');
|
||||
expect(err.message).toContain('/v1/admin/skill-packs');
|
||||
const noYes = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: { GSTACK_QM_URL: 'http://127.0.0.1:9', GSTACK_QM_ADMIN_TOKEN: 'sekret' }, version: VERSION, yes: false }).catch(e => e);
|
||||
expect(noYes.code).toBe('HARNESS_QM_CREDENTIALS');
|
||||
expect(noYes.message).not.toContain('sekret');
|
||||
expect(qmRequest('abc')).toContain('"ref":"abc"');
|
||||
});
|
||||
|
||||
test('registers once, a retry after a failed import reuses the pack, two machines share it, --remote archives it', async () => {
|
||||
const q = fakeQm(1);
|
||||
try {
|
||||
const a = tmp(); const b = tmp();
|
||||
const r1 = await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
expect(q.packs.length).toBe(1);
|
||||
expect(q.calls.filter(c => c === 'POST /v1/admin/skill-packs').length).toBe(1);
|
||||
expect(q.calls.filter(c => c.endsWith('/import')).length).toBe(2);
|
||||
const ledger = fs.readFileSync(path.join(a, 'state', 'harness', r1.id + '.ledger'), 'utf8');
|
||||
expect(ledger).toContain('qm_pack\tpk1');
|
||||
expect(ledger).not.toContain('t0k');
|
||||
expect((await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged');
|
||||
await registerQm({ checkout: ROOT, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
expect(q.packs.length).toBe(1);
|
||||
expect(await removeQmPack(path.join(a, 'state'), q.env, r1.id)).toBe('pk1');
|
||||
expect(q.packs.length).toBe(0);
|
||||
} finally { q.server.stop(true); }
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup and uninstall with harnesses', () => {
|
||||
const harnessDirs = ['.openclaw', '.hermes'];
|
||||
for (const [host, dest, probe] of [['openclaw', '.openclaw/skills', 'gstack/SKILL.md'], ['hermes', '.hermes/skills/gstack', 'gstack-review/SKILL.md']] as const) {
|
||||
test(host + ': ./setup --host ' + host + ' registers, reruns unchanged, and gstack-uninstall removes it', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, path.join(f.dir, 'gstack'));
|
||||
const r = runSetup(f, path.join(src, 'setup'), ['--host', host]);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.existsSync(path.join(f.home, dest, probe))).toBe(true);
|
||||
for (const other of ['.claude/skills', '.codex/skills', ...harnessDirs.filter(h => !dest.startsWith(h))]) expect(fs.existsSync(path.join(f.home, other)), other).toBe(false);
|
||||
expect(registryRows(f).map(row => [row[0], row[3], row[5]])).toEqual([[host, path.join(f.home, dest), fs.realpathSync(src)]]);
|
||||
expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack ready (' + host + ')');
|
||||
const again = runSetup(f, path.join(src, 'setup'), ['--host', host]);
|
||||
expect(again.stdout + again.stderr).toMatch(new RegExp('\\b' + host + '\\s+experimental\\s+global\\s+unchanged'));
|
||||
const un = spawnSync('bash', [path.join(src, 'bin/gstack-uninstall'), '--force'], { cwd: f.home, env: { ...f.env, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(un.status, un.stdout + un.stderr).toBe(0);
|
||||
expect(files(path.join(f.home, dest)).filter(p => !p.startsWith('.'))).toEqual([]);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
test('setup inside OpenClaw registers it too; --host claude or the off switch writes nothing under OpenClaw', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, path.join(f.dir, 'gstack'));
|
||||
const inside = { OPENCLAW_SHELL: 'exec' };
|
||||
const off = runSetup(f, path.join(src, 'setup'), [], { env: { ...inside, GSTACK_HARNESS_AUTO: '0' } });
|
||||
expect(off.status, off.stdout + off.stderr).toBe(0);
|
||||
expect(fs.existsSync(path.join(f.home, '.openclaw'))).toBe(false);
|
||||
const explicit = runSetup(f, path.join(src, 'setup'), ['--host', 'claude'], { env: inside });
|
||||
expect(explicit.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(f.home, '.openclaw'))).toBe(false);
|
||||
const before = tree(path.join(f.home, '.claude'));
|
||||
const auto = runSetup(f, path.join(src, 'setup'), [], { env: inside });
|
||||
expect(auto.status, auto.stdout + auto.stderr).toBe(0);
|
||||
expect(fs.existsSync(path.join(f.home, '.openclaw/skills/gstack/SKILL.md'))).toBe(true);
|
||||
expect(auto.stdout).toContain('gstack-harness: OpenClaw:');
|
||||
expect(auto.stdout).toContain('--no-harness-auto');
|
||||
expect(auto.stdout.trim().split('\n').at(-1)).toBe('gstack ready (claude)');
|
||||
expect(tree(path.join(f.home, '.claude'))).toEqual(before);
|
||||
}, 180_000);
|
||||
});
|
||||
Reference in new issue
Block a user