mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
wip: gstack-harness registrar (GSTA-135)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
398618259d
commit
b6f80f77d4
4 files changed
+804
No files matched your search
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-harness — register gstack's skills with the agent harness it is
|
||||
* installed into (OpenClaw, Hermes, dots, QM, any other), the harness's own
|
||||
* way, and take them back out. lib/harness-register.ts holds the targets and
|
||||
* the ledger; docs/hosts.md has the per-harness install, verify and uninstall
|
||||
* blocks.
|
||||
*
|
||||
* Usage:
|
||||
* gstack-harness detect [--json]
|
||||
* gstack-harness register <target> [--skills router|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
|
||||
* gstack-harness unregister <target|registration id> [--remote]
|
||||
* gstack-harness status [--json] [--no-check]
|
||||
* gstack-harness refresh
|
||||
* gstack-harness auto
|
||||
* gstack-harness manifest <target> [--skills-dir <dir>]
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import {
|
||||
HarnessError, TARGETS, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
|
||||
type RegisterResult, type SkillsMode, type TargetName,
|
||||
} from '../lib/harness-register';
|
||||
import { registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name: string) => { const i = argv.indexOf(name); if (i < 0) return undefined; const v = argv[i + 1]; argv.splice(i, 2); return v; };
|
||||
const bool = (name: string) => { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; };
|
||||
|
||||
const checkout = path.resolve(fs.realpathSync(process.argv[1]), '..', '..');
|
||||
const version = (() => { try { return fs.readFileSync(path.join(checkout, 'VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();
|
||||
const stateRoot = resolveStateRoot(process.env);
|
||||
const env = process.env;
|
||||
const home = os.homedir();
|
||||
const self = path.join(checkout, 'bin', 'gstack-harness');
|
||||
|
||||
function receipt(r: RegisterResult): void {
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
(code ? console.error : console.log)(text);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const json = bool('--json');
|
||||
const sub = argv.shift();
|
||||
const base = { checkout, stateRoot, env, home, version };
|
||||
switch (sub) {
|
||||
case 'detect': {
|
||||
const d = detect(env, home);
|
||||
if (json) console.log(JSON.stringify(d));
|
||||
else console.log('inside: ' + (d.inside.join(', ') || 'none') + '\npresent: ' + (d.present.join(', ') || 'none'));
|
||||
return 0;
|
||||
}
|
||||
case 'register': {
|
||||
const mode = flag('--skills') as SkillsMode | undefined;
|
||||
const skillsDir = flag('--skills-dir'); const context = flag('--context');
|
||||
const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry');
|
||||
const t = getTarget(argv.shift() ?? '');
|
||||
if (print) { console.log(printPayload(checkout)); return 0; }
|
||||
if (t.name === 'qm') {
|
||||
const r = await registerQm({ ...base, yes });
|
||||
console.log('gstack-harness: QM: pack ' + r.packId + ' at ' + r.ref + ' (' + r.status + '); imported: ' + JSON.stringify(r.imported).slice(0, 300));
|
||||
console.log(' detach locally: ' + self + ' unregister ' + r.id + ' archive the org pack: ' + self + ' unregister ' + r.id + ' --remote');
|
||||
return 0;
|
||||
}
|
||||
if (t.mechanism === 'verify-only') {
|
||||
const seen = harnessSees(t, '', env);
|
||||
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 }));
|
||||
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);
|
||||
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) }));
|
||||
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 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;
|
||||
}
|
||||
case 'refresh': {
|
||||
for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r);
|
||||
return 0;
|
||||
}
|
||||
case 'auto': {
|
||||
const d = detect(env, home);
|
||||
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; }
|
||||
}
|
||||
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;
|
||||
}
|
||||
case 'manifest': {
|
||||
const t = getTarget(argv.shift() ?? 'generic');
|
||||
const skills = sourceSkills(t.render ? t : TARGETS.generic, checkout, fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-')));
|
||||
console.log(JSON.stringify({ schema: 1, gstack_version: version, target: t.name, skills: skills.map(s => ({ name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') })) }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
case '--help': case '-h': case 'help': return usage(0);
|
||||
default: return usage(2);
|
||||
}
|
||||
}
|
||||
|
||||
main().then(code => process.exit(code), (e) => {
|
||||
if (e instanceof HarnessError) { console.error(e.line()); process.exit(1); }
|
||||
throw e;
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-harness-ledger.sh — sourced; removes what gstack-harness registered,
|
||||
# from its ledger, without bun (bin/gstack-uninstall runs it before it removes
|
||||
# the checkout and the state root). Ledger format: lib/harness-register.ts.
|
||||
#
|
||||
# gstack_harness_unregister_ledger <ledger> remove one registration
|
||||
# gstack_harness_unregister_all remove every registration under
|
||||
# $GSTACK_STATE_ROOT/harness
|
||||
#
|
||||
# A file whose bytes still match the ledger is removed; an edited one is moved
|
||||
# to $GSTACK_STATE_ROOT/backups/harness/<stamp>/; a context block is cut out of
|
||||
# its file between the gstack:begin/end harness-routing markers (the file is
|
||||
# removed only when nothing else is left in it); folders gstack created are
|
||||
# removed only when empty. A QM pack is never touched here: detaching it from
|
||||
# the org is `gstack-harness unregister qm --remote`.
|
||||
|
||||
_gstack_harness_sha() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
||||
else shasum -a 256 "$1" | cut -d' ' -f1; fi
|
||||
}
|
||||
|
||||
_gstack_harness_cut_block() {
|
||||
local file="$1" tmp
|
||||
[ -f "$file" ] || return 0
|
||||
tmp="$file.gstack-cut.$$"
|
||||
awk 'index($0, "<!-- gstack:begin harness-routing -->") { skip = 1 } !skip { print } index($0, "<!-- gstack:end harness-routing -->") { skip = 0 }' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$tmp" "$file"; else mv "$tmp" "$file"; fi
|
||||
}
|
||||
|
||||
gstack_harness_unregister_ledger() {
|
||||
local ledger="$1" root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" stamp kind a b target="" dest="" 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 ;;
|
||||
file)
|
||||
{ [ -f "$b" ] && [ ! -L "$b" ]; } || continue
|
||||
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
|
||||
else mkdir -p "$backup$(dirname "$b")" && mv "$b" "$backup$b" && echo " kept your edit: $b moved to $backup$b"; fi ;;
|
||||
block) _gstack_harness_cut_block "$b" ;;
|
||||
esac
|
||||
done < "$ledger"
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
[ "$kind" = dir ] && [ -d "$b" ] && rmdir "$b" 2>/dev/null
|
||||
done < "$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 ;;
|
||||
esac
|
||||
rm -f "$ledger"
|
||||
}
|
||||
|
||||
gstack_harness_unregister_all() {
|
||||
local root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" l
|
||||
for l in "$root"/harness/*.ledger; do
|
||||
[ -f "$l" ] || continue
|
||||
gstack_harness_unregister_ledger "$l" && echo "harness: $(basename "$l" .ledger)"
|
||||
done
|
||||
}
|
||||
|
||||
_gstack_harness_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[ -f "$_gstack_harness_dir/gstack-install-registry.sh" ] && . "$_gstack_harness_dir/gstack-install-registry.sh"
|
||||
Reference in new issue
Block a user