@techgoblin/gobstack 0.5.0-beta.8 → 0.6.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +161 -124
- package/VERSION +1 -1
- package/automations/drift-audit.sh +4 -4
- package/bans/layer-check.sh +10 -8
- package/bin/goblin +69 -57
- package/bin/goblin-audit +11 -13
- package/bin/goblin-bans +11 -11
- package/bin/goblin-extras +342 -0
- package/bin/goblin-init +382 -711
- package/bin/goblin-install +160 -114
- package/bin/goblin-lib.sh +234 -1
- package/bin/goblin-map +226 -21
- package/bin/goblin-mcp.js +492 -0
- package/bin/goblin-model +4 -4
- package/bin/goblin-upgrade +1 -1
- package/bin/goblin-verify +159 -145
- package/bin/goblin.js +35 -51
- package/docs/ADOPTION.md +15 -15
- package/docs/CONTRACTS.md +16 -15
- package/docs/DESIGN.md +1 -1
- package/docs/ENFORCEMENT.md +89 -90
- package/docs/FLOWS.md +1 -1
- package/docs/GLOSSARY.md +3 -3
- package/docs/GUARDRAILS.md +5 -5
- package/docs/GUIDE.md +194 -177
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITS.md +25 -0
- package/docs/LOOP.md +12 -12
- package/docs/RE-PLAYBOOK.md +3 -3
- package/docs/ROLES.md +5 -5
- package/extras-catalogue/catalogue.tsv +42 -0
- package/extras-catalogue/payload/README.md +14 -0
- package/extras-catalogue/payload/taste-skill/taste/REFERENCE.md +3 -0
- package/extras-catalogue/payload/taste-skill/taste/SKILL.md +9 -0
- package/manifest/bans.tsv +8 -8
- package/manifest/classes.tsv +3 -3
- package/manifest/enforcement.tsv +40 -40
- package/manifest/glossary.tsv +3 -3
- package/manifest/playbooks.tsv +1 -1
- package/package.json +3 -1
- package/presets/electron-overlay.yaml +2 -2
- package/presets/fleet.yaml +8 -7
- package/presets/game.yaml +1 -1
- package/presets/research.yaml +1 -1
- package/presets/service.yaml +1 -1
- package/presets/software.yaml +1 -1
- package/skills/goblin-bootstrap/SKILL.md +2 -2
- package/templates/AGENTS.md.tmpl +8 -18
- package/templates/HANDOFF.md.tmpl +5 -5
- package/templates/agents-block.tmpl +45 -0
- package/templates/audit-waiver.tsv.tmpl +2 -2
- package/templates/boundary-waivers.tmpl +1 -1
- package/templates/checks/gate.sh.tmpl +6 -6
- package/templates/install-hooks.allowlist.tmpl +1 -1
- package/templates/ci/goblin-gate.yml.tmpl +0 -46
- package/templates/goblin.yaml.tmpl +0 -146
- package/templates/loop/decisions.tsv.tmpl +0 -1
- package/templates/loop/predicate.tmpl +0 -16
package/bin/goblin
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# goblin — the
|
|
2
|
+
# goblin — the CLI dispatcher.
|
|
3
3
|
#
|
|
4
|
-
# gob
|
|
5
|
-
# gob
|
|
6
|
-
# gob
|
|
7
|
-
# gob
|
|
8
|
-
# gob
|
|
9
|
-
#
|
|
10
|
-
# gob
|
|
4
|
+
# gob init [--heuristic | --write <proposal>] [--target <dir>] [...]
|
|
5
|
+
# gob map [target] [--agent | --heuristic] [--write <dir>] [--force]
|
|
6
|
+
# gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
|
|
7
|
+
# gob bans [--only <id[,id...]>] [--list]
|
|
8
|
+
# gob extras list [category] | show <id> | install <id...> [--target <dir>]
|
|
9
|
+
# [--platform <id>] [--with-mcp-config] [--dry-run]
|
|
10
|
+
# gob mcp serve the harness over MCP stdio
|
|
11
|
+
# gob uninstall --target <dir>
|
|
11
12
|
# gob --version
|
|
12
13
|
#
|
|
14
|
+
# v2 SURFACE (AI-driven development): the CLI calls no AI API. `gob init` and `gob map`
|
|
15
|
+
# EMIT a prompt + schema that the agent already running in this repo fulfils with its own
|
|
16
|
+
# tools; heuristic detection demotes to the --heuristic fallback.
|
|
17
|
+
#
|
|
18
|
+
# UNWIRED this session (code kept under bin/, deletion is session 3): doctor, audit,
|
|
19
|
+
# upgrade, emit/sync. `sync` becomes an init-internal step (hidden --resync flag).
|
|
20
|
+
#
|
|
13
21
|
# Identity: the package is gobstack (npm @techgoblin/gobstack), the command is `gob`
|
|
14
|
-
# (`goblin` remains as a legacy alias).
|
|
15
|
-
# SHELL only — the node shim and npm packaging are W2, doctor/emit are W4a (exit-2
|
|
16
|
-
# placeholders naming their workstream), upgrade is W3 (same). G3 forbids a runtime
|
|
17
|
-
# rewrite: the engine stays bash, this file only routes and propagates.
|
|
22
|
+
# (`goblin` remains as a legacy alias).
|
|
18
23
|
#
|
|
19
24
|
# The four-value verify contract is unchanged and non-negotiable (bin/goblin-verify:5-9):
|
|
20
25
|
# 0 every executed check passed
|
|
@@ -22,11 +27,10 @@
|
|
|
22
27
|
# 2 could not run
|
|
23
28
|
# 3 the manifest itself is broken
|
|
24
29
|
# Every subcommand propagates the engine's exit code verbatim; no wrapper translates a
|
|
25
|
-
# 1 into a 0.
|
|
30
|
+
# 1 into a 0.
|
|
26
31
|
#
|
|
27
|
-
# Root resolution: find_installed_root()
|
|
28
|
-
#
|
|
29
|
-
# then $PWD — the D20 nested-repo fix). W1 changes nothing about its logic.
|
|
32
|
+
# Root resolution: find_installed_root() walks up from $PWD to an AGENTS.md carrying the
|
|
33
|
+
# gob marker block; fall back to git toplevel, then $PWD — the D20 nested-repo fix.
|
|
30
34
|
#
|
|
31
35
|
# No npm, no jq, no yq, no network. bash/awk/sed/grep only.
|
|
32
36
|
|
|
@@ -42,27 +46,31 @@ usage() {
|
|
|
42
46
|
cat <<'USAGE'
|
|
43
47
|
gob — the gobstack command line.
|
|
44
48
|
|
|
45
|
-
gob
|
|
46
|
-
gob
|
|
47
|
-
gob
|
|
48
|
-
gob
|
|
49
|
-
gob
|
|
50
|
-
gob
|
|
51
|
-
gob
|
|
52
|
-
gob map [target] [--force] # the standalone feature-map generator (no install needed)
|
|
53
|
-
gob upgrade [--target .] [--dry-run] [--yes] [--engine-dir <path>]
|
|
49
|
+
gob init [--heuristic | --write <proposal>] [--target <dir>] [--class app|A-F] [--dry-run]
|
|
50
|
+
gob map [target] [--agent | --heuristic] [--write <dir>] [--force]
|
|
51
|
+
gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
|
|
52
|
+
gob bans [--only <id[,id...]>] [--list]
|
|
53
|
+
gob extras list [category] | show <id> | install <id...> # the curated extras catalogue
|
|
54
|
+
gob mcp serve the harness to your agent over MCP stdio (verify/map/init tools)
|
|
55
|
+
gob uninstall --target <dir>
|
|
54
56
|
gob --version
|
|
55
57
|
|
|
58
|
+
gob init and gob map are AI-DRIVEN: they print a prompt + schema for the agent already
|
|
59
|
+
running in this repo, which writes the proposal file; `--write` validates and installs
|
|
60
|
+
it. The heuristic detectors are the --heuristic fallback.
|
|
61
|
+
|
|
56
62
|
Exit codes (verify): 0 pass | 1 a check failed | 2 could not run | 3 the manifest is
|
|
57
63
|
broken. Every subcommand propagates the engine's exit code verbatim.
|
|
58
64
|
USAGE
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
# ---- the repo-root locator
|
|
67
|
+
# ---- the repo-root locator (an AGENTS.md gob block is the install marker) ----
|
|
62
68
|
find_installed_root() {
|
|
63
69
|
local d="$PWD"
|
|
64
70
|
while :; do
|
|
65
|
-
if [ -f "$d
|
|
71
|
+
if [ -f "$d/AGENTS.md" ] && grep -q '^<!-- gob:begin' "$d/AGENTS.md" 2>/dev/null; then
|
|
72
|
+
printf '%s\n' "$d"; return 0
|
|
73
|
+
fi
|
|
66
74
|
[ "$d" = "/" ] && return 1
|
|
67
75
|
d=$(dirname "$d")
|
|
68
76
|
done
|
|
@@ -74,6 +82,15 @@ CMD="${1:-}"
|
|
|
74
82
|
shift
|
|
75
83
|
|
|
76
84
|
case "$CMD" in
|
|
85
|
+
init)
|
|
86
|
+
# The prompt+schema engine: prints the agent brief, or installs a proposal.
|
|
87
|
+
exec bash "$SRC/bin/goblin-init" "$@"
|
|
88
|
+
;;
|
|
89
|
+
map)
|
|
90
|
+
# The standalone feature-map generator: `--agent` (default) prints prompt+schema;
|
|
91
|
+
# --heuristic runs the scanner. Routes and propagates its 0/1/2 contract verbatim.
|
|
92
|
+
exec bash "$SRC/bin/goblin-map" "$@"
|
|
93
|
+
;;
|
|
77
94
|
verify)
|
|
78
95
|
# The engine is whatever the per-repo chain resolves (W1 §2.3): --source, vendored,
|
|
79
96
|
# engine_dir:, GOBLIN_ENGINE_DIR, ~/.goblin/engine — all inside goblin-verify. The
|
|
@@ -83,38 +100,22 @@ case "$CMD" in
|
|
|
83
100
|
bans)
|
|
84
101
|
exec bash "$SRC/bin/goblin-bans" "$@"
|
|
85
102
|
;;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# propagates the doctor's 0/1/2 verbatim, like every other subcommand.
|
|
92
|
-
exec bash "$SRC/bin/goblin-doctor" "$@"
|
|
103
|
+
extras)
|
|
104
|
+
# The curated catalogue browser + installer (bin/goblin-extras): list/show/install
|
|
105
|
+
# over extras-catalogue/catalogue.tsv. The catalogue is the ALLOWLIST: nothing is
|
|
106
|
+
# ever installed that a catalogue row does not name, and `gob init` only suggests.
|
|
107
|
+
exec bash "$SRC/bin/goblin-extras" "$@"
|
|
93
108
|
;;
|
|
94
|
-
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
109
|
+
mcp)
|
|
110
|
+
# The MCP stdio server (bin/goblin-mcp.js): JSON-RPC 2.0 over stdin/stdout, a LOCAL
|
|
111
|
+
# tool server over the vendored harness — no API call, no socket. node is the same
|
|
112
|
+
# runtime bin/goblin.js already needs; it is exec'd directly, args pass through.
|
|
113
|
+
exec node "$SRC/bin/goblin-mcp.js" "$@"
|
|
98
114
|
;;
|
|
99
|
-
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
exec bash "$SRC/bin/goblin-
|
|
103
|
-
;;
|
|
104
|
-
init)
|
|
105
|
-
# W6: the first-run wizard. It drives install/emit/verify and propagates their
|
|
106
|
-
# exit contract verbatim, like every other subcommand here.
|
|
107
|
-
exec bash "$SRC/bin/goblin-init" "$@"
|
|
108
|
-
;;
|
|
109
|
-
map)
|
|
110
|
-
# The standalone feature-map generator: routes and propagates its 0/1/2 contract
|
|
111
|
-
# verbatim, like every other subcommand. It needs no .goblin/ install by design.
|
|
112
|
-
exec bash "$SRC/bin/goblin-map" "$@"
|
|
113
|
-
;;
|
|
114
|
-
upgrade)
|
|
115
|
-
# W3: the migration lives in its own checkout-level script (W3-SPEC §1.1);
|
|
116
|
-
# the dispatcher routes and propagates the four-value contract verbatim.
|
|
117
|
-
exec bash "$SRC/bin/goblin-upgrade" "$@"
|
|
115
|
+
uninstall)
|
|
116
|
+
# Routes into the installer's uninstall job. `--uninstall` is appended FIRST so the
|
|
117
|
+
# user's own `--target <dir>` and options still parse.
|
|
118
|
+
exec bash "$SRC/bin/goblin-install" --uninstall "$@"
|
|
118
119
|
;;
|
|
119
120
|
--version|-V|-v)
|
|
120
121
|
printf '%s\n' "$VERSION"
|
|
@@ -124,6 +125,17 @@ case "$CMD" in
|
|
|
124
125
|
usage
|
|
125
126
|
exit 0
|
|
126
127
|
;;
|
|
128
|
+
doctor|audit|upgrade|emit|sync|install)
|
|
129
|
+
# Unwired from usage in v2 (code kept; deletion is session 3). The refusal names
|
|
130
|
+
# what replaced each verb rather than silently executing the old path.
|
|
131
|
+
g_err "gob $CMD is unwired in v2."
|
|
132
|
+
case "$CMD" in
|
|
133
|
+
install|init-legacy) g_err "fix: gob init --write <proposal> installs a repo now." ;;
|
|
134
|
+
sync|emit) g_err "fix: gob init --write <proposal> runs the sync step internally." ;;
|
|
135
|
+
doctor|audit|upgrade) g_err "fix: gob verify (audit stays a deliberate manual step; see docs/)." ;;
|
|
136
|
+
esac
|
|
137
|
+
exit 2
|
|
138
|
+
;;
|
|
127
139
|
*)
|
|
128
140
|
g_err "unknown subcommand: $CMD"
|
|
129
141
|
usage >&2
|
package/bin/goblin-audit
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
#
|
|
17
17
|
# Exit codes:
|
|
18
18
|
# 0 the record was written (whether or not it is clean)
|
|
19
|
-
# 2 usage, or no .
|
|
19
|
+
# 2 usage, or no AGENTS.md frontmatter to read `security.audit_cmd` from
|
|
20
20
|
# 3 the class declares no audit command (security.audit_cmd is empty) - nothing to run
|
|
21
21
|
# 4 the declared command could not run
|
|
22
22
|
# 5 the output could not be parsed as an audit report - REFUSING to write an empty record,
|
|
23
23
|
# because an empty record reads to SC-07 as "clean" and that would be a fabricated pass
|
|
24
24
|
set -uo pipefail
|
|
25
25
|
|
|
26
|
-
GOBLIN_AUDIT_VERSION="0.
|
|
26
|
+
GOBLIN_AUDIT_VERSION="0.6.0-alpha.1"
|
|
27
27
|
|
|
28
28
|
usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
29
29
|
|
|
@@ -40,22 +40,20 @@ done
|
|
|
40
40
|
[ -n "$TARGET" ] || { printf 'goblin-audit: --target needs a directory\n' >&2; exit 2; }
|
|
41
41
|
cd "$TARGET" || { printf 'goblin-audit: no such directory: %s\n' "$TARGET" >&2; exit 2; }
|
|
42
42
|
|
|
43
|
-
CONFIG=".
|
|
44
|
-
[ -f "$CONFIG" ]
|
|
43
|
+
CONFIG="AGENTS.md"
|
|
44
|
+
[ -f "$CONFIG" ] && grep -q "^$GOB_AGENTS_BEGIN" "$CONFIG" 2>/dev/null \
|
|
45
|
+
|| { printf 'goblin-audit: %s has no %s gob block - run gob init first\n' "$TARGET" "$CONFIG" >&2; exit 2; }
|
|
45
46
|
|
|
46
47
|
# Self-contained on purpose: the audit runner must work even if goblin-lib.sh was edited or is
|
|
47
48
|
# missing, because it is the one tool a human runs by hand.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
v = $0; sub("^ " k ":[[:space:]]*", "", v); print v; exit
|
|
54
|
-
}
|
|
55
|
-
' "$1"
|
|
49
|
+
g_agents_read_inline() { # <file> <dotted-key> — the one reader the audit needs, inline
|
|
50
|
+
local f="$1" k="$2" line
|
|
51
|
+
while IFS= read -r line; do
|
|
52
|
+
case "$line" in "$k:"*) printf '%s\n' "${line#"$k":}"; return 0 ;; esac
|
|
53
|
+
done < "$f"
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
AUDIT_CMD=$(
|
|
56
|
+
AUDIT_CMD=$(g_agents_read_inline "$CONFIG" security.audit_cmd)
|
|
59
57
|
if [ -z "$AUDIT_CMD" ]; then
|
|
60
58
|
printf 'goblin-audit: this class declares no audit command (security.audit_cmd is empty in\n'
|
|
61
59
|
printf ' %s). Nothing to run, and no record to write.\n' "$CONFIG"
|
package/bin/goblin-bans
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
set -uo pipefail
|
|
30
30
|
|
|
31
|
-
GOBLIN_BANS_VERSION="0.
|
|
31
|
+
GOBLIN_BANS_VERSION="0.6.0-alpha.1"
|
|
32
32
|
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
33
33
|
# shellcheck source=goblin-lib.sh
|
|
34
34
|
. "$SELF_DIR/goblin-lib.sh"
|
|
@@ -49,7 +49,7 @@ done
|
|
|
49
49
|
find_installed_root() {
|
|
50
50
|
local d="$PWD"
|
|
51
51
|
while :; do
|
|
52
|
-
if [ -f "$d
|
|
52
|
+
if [ -f "$d/AGENTS.md" ] && grep -q "^$GOB_AGENTS_BEGIN" "$d/AGENTS.md" 2>/dev/null; then printf '%s\n' "$d"; return 0; fi
|
|
53
53
|
[ "$d" = "/" ] && return 1
|
|
54
54
|
d=$(dirname "$d")
|
|
55
55
|
done
|
|
@@ -60,9 +60,9 @@ if [ -z "$ROOT" ]; then
|
|
|
60
60
|
[ -n "$ROOT" ] || ROOT="$PWD"
|
|
61
61
|
fi
|
|
62
62
|
|
|
63
|
-
CONFIG="$ROOT
|
|
64
|
-
if [ -f "$ROOT/.
|
|
65
|
-
BANS="$ROOT/.
|
|
63
|
+
CONFIG="$ROOT/AGENTS.md"
|
|
64
|
+
if [ -f "$ROOT/.gob/manifest/bans.tsv" ]; then
|
|
65
|
+
BANS="$ROOT/.gob/manifest/bans.tsv"
|
|
66
66
|
else
|
|
67
67
|
BANS="$SELF_DIR/../manifest/bans.tsv"
|
|
68
68
|
fi
|
|
@@ -85,13 +85,11 @@ fi
|
|
|
85
85
|
# ---- the config: which bans this project turns on, and its narrow exceptions --
|
|
86
86
|
ENABLED=""
|
|
87
87
|
if [ -f "$CONFIG" ]; then
|
|
88
|
-
ENABLED=$(
|
|
89
|
-
ENABLED=${ENABLED#[}; ENABLED=${ENABLED%]}
|
|
90
|
-
ENABLED=$(printf '%s' "$ENABLED" | tr ',' ' ' | tr -s ' ' ' ')
|
|
88
|
+
ENABLED=$(g_agents_list "$CONFIG" bans | tr '\n' ' ')
|
|
91
89
|
# The electron opt-in (software class): `electron: true` turns the electron ban set ON even
|
|
92
90
|
# when a hand-edited bans: list omits it — the declaration is the contract, and the engine
|
|
93
91
|
# (not the LLM) is what makes a listed ban non-skippable. A repo without the key is unaffected.
|
|
94
|
-
if [ "$(
|
|
92
|
+
if [ "$(g_agents_read "$CONFIG" electron)" = "true" ]; then
|
|
95
93
|
for e in BN-06 BN-07 BN-08 BN-09; do
|
|
96
94
|
case " $ENABLED " in *" $e "*) ;; *) ENABLED="$ENABLED $e" ;; esac
|
|
97
95
|
done
|
|
@@ -113,7 +111,9 @@ enabled() {
|
|
|
113
111
|
# keeps the old behaviour, which fails CLOSED, never open.
|
|
114
112
|
exempt_paths() { # exempt_paths <id> -> path prefixes, one per line
|
|
115
113
|
[ -f "$CONFIG" ] || return 0
|
|
116
|
-
|
|
114
|
+
# v2 shape: one flat `[BN-01 src, BN-02 app]` array — item 1 is the ban id, item 2 its
|
|
115
|
+
# path prefix (g_agents_list splits on the commas, so an item keeps its inner space).
|
|
116
|
+
g_agents_list "$CONFIG" bans_exempt | awk -v id="$1" '$1==id {print $2}'
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
only_selected() {
|
|
@@ -131,7 +131,7 @@ while IFS=$'\t' read -r id ban globs detect replacement escape reviewer source;
|
|
|
131
131
|
SEL=$((SEL + 1))
|
|
132
132
|
|
|
133
133
|
if ! enabled "$id"; then
|
|
134
|
-
SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to .
|
|
134
|
+
SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to the bans: array in AGENTS.md to turn it on)"
|
|
135
135
|
continue
|
|
136
136
|
fi
|
|
137
137
|
# A ban whose globs match no file is a SKIP with a reason, not a pass and not a fail.
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# goblin-extras — `gob extras`: the curated catalogue browser + installer (v2 W-extras).
|
|
3
|
+
#
|
|
4
|
+
# gob extras list [category] the catalogue, grouped by category
|
|
5
|
+
# gob extras show <id> one row, every field, + the install hint
|
|
6
|
+
# gob extras install <id...> [--target <dir>] [--platform <id>]
|
|
7
|
+
# [--with-mcp-config] [--dry-run]
|
|
8
|
+
#
|
|
9
|
+
# The ALLOWLIST RULE (non-negotiable): only ids that exist in catalogue.tsv are ever
|
|
10
|
+
# installed. An id the catalogue does not carry is refused by name — there is no path from
|
|
11
|
+
# a URL or a repo name on this command to a file write. `gob init` SUGGESTS rows and never
|
|
12
|
+
# auto-installs; a human (or the proposal the human approves) names ids explicitly, and
|
|
13
|
+
# the curator maintains the catalogue (add a row = PR or edit catalogue.tsv).
|
|
14
|
+
#
|
|
15
|
+
# Verdicts: RECOMMEND rows install; MAYBE rows install (an explicit choice, never
|
|
16
|
+
# pre-ticked at init); SKIP rows refuse — the curator's verdict is the contract.
|
|
17
|
+
#
|
|
18
|
+
# NO NETWORK. Skill and workflow installs copy from a VENDORED PAYLOAD the curator
|
|
19
|
+
# maintains under extras-catalogue/payload/<id>/ (payload/README.md is the layout
|
|
20
|
+
# contract); a row whose payload is absent is refused with the named fix, never
|
|
21
|
+
# downloaded. MCP installs are a printed mcp.json snippet plus — with
|
|
22
|
+
# --with-mcp-config — a merge into the target's .mcp.json that never overwrites an
|
|
23
|
+
# existing server entry (the ask-once rule .mcp.json has always had).
|
|
24
|
+
#
|
|
25
|
+
# Skill destinations follow the sync_platforms pattern: --platform <adapter-id> copies
|
|
26
|
+
# each vendored skill dir under that adapter's project skills root (read from
|
|
27
|
+
# adapters/<id>/adapter.tsv, column skills_path_project); with no --platform the skill
|
|
28
|
+
# lands in the repo-neutral .gob/extras/<skill-name>/ (standalone repos). Workflows copy
|
|
29
|
+
# the payload into .gob/playbooks/<id>/.
|
|
30
|
+
#
|
|
31
|
+
# Exit codes: 0 ok | 1 a refusal (named path + fix) | 2 bad input.
|
|
32
|
+
# No npm, no jq, no yq, no network. bash/awk/sed/grep only (python3 only for the
|
|
33
|
+
# .mcp.json merge, where a hand-rolled JSON edit would corrupt files it must not).
|
|
34
|
+
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
38
|
+
SRC=$(cd "$SELF_DIR/.." && pwd)
|
|
39
|
+
# shellcheck source=goblin-lib.sh
|
|
40
|
+
. "$SELF_DIR/goblin-lib.sh"
|
|
41
|
+
|
|
42
|
+
g_info() { printf '%s\n' "$*"; }
|
|
43
|
+
|
|
44
|
+
usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
45
|
+
|
|
46
|
+
# ---- the catalogue source (overridable for tests) ------------------------------
|
|
47
|
+
# The default is the shipped catalogue; GOB_EXTRAS_CATALOGUE points a test at a fixture.
|
|
48
|
+
# Same for the payload root: a test vendors a fixture payload and aims the installer at it.
|
|
49
|
+
CATALOGUE="${GOB_EXTRAS_CATALOGUE:-$SRC/extras-catalogue/catalogue.tsv}"
|
|
50
|
+
PAYLOAD="${GOB_EXTRAS_PAYLOAD:-$SRC/extras-catalogue/payload}"
|
|
51
|
+
|
|
52
|
+
die() { g_err "extras: $*"; exit "${2:-2}"; }
|
|
53
|
+
|
|
54
|
+
# catalogue_row <id> — the whole data row (tab-separated), empty when absent.
|
|
55
|
+
catalogue_row() {
|
|
56
|
+
awk -F'\t' -v id="$1" 'NR>1 && $1==id { print; exit }' "$CATALOGUE"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# catalogue_field <row> <n> — the nth tab field of a row.
|
|
60
|
+
catalogue_field() { printf '%s' "$1" | awk -F'\t' -v n="$2" '{ print $n }'; }
|
|
61
|
+
|
|
62
|
+
# catalogue_ids — every id, file order.
|
|
63
|
+
catalogue_ids() { awk -F'\t' 'NR>1 && $1!="" { print $1 }' "$CATALOGUE"; }
|
|
64
|
+
|
|
65
|
+
# category_order — the categories in catalogue file order of first appearance.
|
|
66
|
+
category_order() { awk -F'\t' 'NR>1 && !seen[$2]++ { print $2 }' "$CATALOGUE"; }
|
|
67
|
+
|
|
68
|
+
# ---- adapters (the platform enum is data, never literals) ----------------------
|
|
69
|
+
# MD-01: agent-family names are read from adapters/<id>/adapter.tsv at run time, never
|
|
70
|
+
# written here — the same discipline the sync engine's assembled platform list keeps.
|
|
71
|
+
adapter_ids() {
|
|
72
|
+
for t in "$SRC"/adapters/*/adapter.tsv; do
|
|
73
|
+
[ -f "$t" ] || continue
|
|
74
|
+
awk -F'\t' '!/^#/ && NF>1 && $1!="id" { print $1 }' "$t"
|
|
75
|
+
done
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# adapter_skills_root <id> — the PROJECT skills root (the pattern with its
|
|
79
|
+
# `<name>/SKILL.md` tail stripped), one dot-dir per agent family. Read at run time;
|
|
80
|
+
# the family names live in the adapter tables, never here (the MD-01 discipline).
|
|
81
|
+
adapter_skills_root() {
|
|
82
|
+
local t="$SRC/adapters/$1/adapter.tsv" pat
|
|
83
|
+
[ -f "$t" ] || return 0
|
|
84
|
+
pat=$(awk -F'\t' '!/^#/ && NF>1 && $1!="id" { print $4 }' "$t")
|
|
85
|
+
[ -n "$pat" ] || return 0
|
|
86
|
+
printf '%s\n' "${pat%%<name>*}"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# ---- list ----------------------------------------------------------------------
|
|
90
|
+
cmd_list() {
|
|
91
|
+
local want="${1:-}"
|
|
92
|
+
[ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
|
|
93
|
+
if [ -n "$want" ]; then
|
|
94
|
+
# An unknown category is bad input, not an empty table: a typo must not read as
|
|
95
|
+
# "the catalogue has nothing here". Captured, not piped: under pipefail a `grep -q`
|
|
96
|
+
# match closes the pipe early and SIGPIPE-kills the producer (pipeline 141), which
|
|
97
|
+
# would read as "unknown category" for a category the catalogue carries.
|
|
98
|
+
CATS=$(category_order)
|
|
99
|
+
printf '%s\n' "$CATS" | grep -qx "$want" || die "unknown category '$want' (want one of: $(printf '%s' "$CATS" | tr '\n' ' '))" 2
|
|
100
|
+
fi
|
|
101
|
+
local total
|
|
102
|
+
total=$(catalogue_ids | wc -l | tr -d ' ')
|
|
103
|
+
g_info "gob extras — the curated catalogue ($total rows; RECOMMEND pre-ticks at init, MAYBE is visible but never pre-ticked, SKIP refuses install)"
|
|
104
|
+
g_info "install: gob extras install <id...> · one row: gob extras show <id>"
|
|
105
|
+
local cat
|
|
106
|
+
for cat in $(category_order); do
|
|
107
|
+
[ -z "$want" ] || [ "$cat" = "$want" ] || continue
|
|
108
|
+
printf '\n[%s]\n' "$cat"
|
|
109
|
+
printf ' %-34s %-8s %-7s %-24s %-10s %s\n' "id" "kind" "stars" "license" "verdict" "matches"
|
|
110
|
+
awk -F'\t' -v cat="$cat" 'NR>1 && $2==cat {
|
|
111
|
+
printf " %-34s %-8s %-7s %-24s %-10s %s\n", $1, $3, $9, $8, $15, $11
|
|
112
|
+
}' "$CATALOGUE"
|
|
113
|
+
done
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ---- show ----------------------------------------------------------------------
|
|
118
|
+
cmd_show() {
|
|
119
|
+
local id="${1:-}"
|
|
120
|
+
[ -n "$id" ] || die "show: an id is required (gob extras list)" 2
|
|
121
|
+
[ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
|
|
122
|
+
local row
|
|
123
|
+
row=$(catalogue_row "$id")
|
|
124
|
+
[ -n "$row" ] || die "unknown id '$id' — gob extras list names the catalogue" 2
|
|
125
|
+
local labels=(id category kind name source_repo path_within_repo has_skill_md license
|
|
126
|
+
stars last_push matches conflicts requires install_hint verdict
|
|
127
|
+
reviewed_by reviewed_date)
|
|
128
|
+
local i
|
|
129
|
+
for i in $(seq 1 17); do
|
|
130
|
+
printf '%-16s %s\n' "${labels[$((i-1))]}:" "$(catalogue_field "$row" "$i")"
|
|
131
|
+
done
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# ---- install -------------------------------------------------------------------
|
|
136
|
+
TARGET="" PLATFORM="" WITH_MCP=0 DRYRUN=0 IDS=()
|
|
137
|
+
parse_install() {
|
|
138
|
+
while [ $# -gt 0 ]; do
|
|
139
|
+
case "$1" in
|
|
140
|
+
--target) TARGET="${2:-}"; shift 2 ;;
|
|
141
|
+
--platform) PLATFORM="${2:-}"; shift 2 ;;
|
|
142
|
+
--with-mcp-config) WITH_MCP=1; shift ;;
|
|
143
|
+
--dry-run) DRYRUN=1; shift ;;
|
|
144
|
+
--yes) shift ;; # accepted for pipe parity; install is already explicit
|
|
145
|
+
-h|--help) usage; exit 0 ;;
|
|
146
|
+
-*) die "install: unknown option: $1" 2 ;;
|
|
147
|
+
*) IDS+=("$1"); shift ;;
|
|
148
|
+
esac
|
|
149
|
+
done
|
|
150
|
+
[ "${#IDS[@]}" -gt 0 ] || die "install: at least one id is required (gob extras list)" 2
|
|
151
|
+
[ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
|
|
152
|
+
TARGET="${TARGET:-$PWD}"
|
|
153
|
+
TARGET=$(g_expand_tilde "$TARGET")
|
|
154
|
+
[ -d "$TARGET" ] || die "--target is not a directory: $TARGET" 2
|
|
155
|
+
TARGET=$(cd "$TARGET" && pwd)
|
|
156
|
+
if [ -n "$PLATFORM" ]; then
|
|
157
|
+
# Captured, not piped (see cmd_list: pipefail + grep -q closes the pipe early and
|
|
158
|
+
# SIGPIPE-kills the producer — a valid platform id read as unknown).
|
|
159
|
+
ADAPTERS=$(adapter_ids)
|
|
160
|
+
printf '%s\n' "$ADAPTERS" | grep -qx "$PLATFORM" \
|
|
161
|
+
|| die "unknown --platform '$PLATFORM' (the adapters this checkout ships: $(printf '%s' "$ADAPTERS" | tr '\n' ' '))" 2
|
|
162
|
+
fi
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# resolve_one <id> — validates against the ALLOWLIST and the curator's verdicts; prints
|
|
166
|
+
# the row. Every install path goes through here: no row, no install. That is the whole
|
|
167
|
+
# allowlist rule, in one gate.
|
|
168
|
+
resolve_one() {
|
|
169
|
+
local id="$1" row verdict
|
|
170
|
+
row=$(catalogue_row "$id")
|
|
171
|
+
# Captured, not piped (pipefail + grep -q early-exit trap — see cmd_list).
|
|
172
|
+
if [ -n "$row" ]; then
|
|
173
|
+
verdict=$(catalogue_field "$row" 15)
|
|
174
|
+
else
|
|
175
|
+
die "unknown id '$id' — nothing outside catalogue.tsv is ever installed (gob extras list names the allowlist)" 2
|
|
176
|
+
fi
|
|
177
|
+
case "$verdict" in
|
|
178
|
+
RECOMMEND|MAYBE) ;;
|
|
179
|
+
SKIP) die "id '$id' carries verdict SKIP — the curator declined it; it stays visible in the catalogue but refuses install" 1 ;;
|
|
180
|
+
*) die "id '$id' carries verdict '$verdict' — not an installable verdict (RECOMMEND|MAYBE|SKIP)" 2 ;;
|
|
181
|
+
esac
|
|
182
|
+
printf '%s\n' "$row"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# copy_tree <src> <dst> — byte-copy with the installer's ownership gate: an existing
|
|
186
|
+
# file is left alone when identical (unchanged) and REFUSED when it differs (a file the
|
|
187
|
+
# project wrote is never clobbered silently). Returns 0 ok, 1 on a refusal (named).
|
|
188
|
+
COPY_REFUSALS=0
|
|
189
|
+
copy_tree() {
|
|
190
|
+
local src="${1%/}" dst="$2" f rel sd dd
|
|
191
|
+
[ -d "$src" ] || die "payload missing: $src" 2
|
|
192
|
+
while IFS= read -r f; do
|
|
193
|
+
rel="${f#"$src"/}"
|
|
194
|
+
dd="$dst/$rel"
|
|
195
|
+
if [ "$DRYRUN" -eq 1 ]; then
|
|
196
|
+
g_info "plan: ${dd#"$TARGET"/}"
|
|
197
|
+
continue
|
|
198
|
+
fi
|
|
199
|
+
mkdir -p "$(dirname "$dd")" || die "cannot create $(dirname "$dd")" 1
|
|
200
|
+
if [ -f "$dd" ]; then
|
|
201
|
+
if [ "$(g_sha256_file "$dd")" = "$(g_sha256_file "$f")" ]; then
|
|
202
|
+
g_info "unchanged ${dd#"$TARGET"/}"
|
|
203
|
+
else
|
|
204
|
+
g_err "refusing to overwrite ${dd#"$TARGET"/}: it exists with different content and goblin-stack did not install it — remove or rename it first"
|
|
205
|
+
COPY_REFUSALS=$((COPY_REFUSALS + 1))
|
|
206
|
+
fi
|
|
207
|
+
continue
|
|
208
|
+
fi
|
|
209
|
+
cp "$f" "$dd" || die "cannot write $dd" 1
|
|
210
|
+
g_info "wrote ${dd#"$TARGET"/}"
|
|
211
|
+
done < <(find "$src" -type f | sort)
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# install_skill <row> — copy the payload's skill dirs into the platform skills root
|
|
216
|
+
# (per sync_platforms) or .gob/extras/ (standalone).
|
|
217
|
+
install_skill() {
|
|
218
|
+
local row="$1" id name src_root dst_root d n=0
|
|
219
|
+
id=$(catalogue_field "$row" 1)
|
|
220
|
+
src_root="$PAYLOAD/$id"
|
|
221
|
+
[ -d "$src_root" ] \
|
|
222
|
+
|| die "no vendored payload for '$id' at $src_root — extras install needs no network: vendor the accepted skill dirs there first (see extras-catalogue/payload/README.md)" 2
|
|
223
|
+
if [ -n "$PLATFORM" ]; then
|
|
224
|
+
dst_root="$TARGET/$(adapter_skills_root "$PLATFORM")"
|
|
225
|
+
dst_root="${dst_root%/}/"
|
|
226
|
+
else
|
|
227
|
+
dst_root="$TARGET/.gob/extras"
|
|
228
|
+
fi
|
|
229
|
+
[ -n "$(adapter_skills_root "$PLATFORM")" ] || [ -z "$PLATFORM" ] \
|
|
230
|
+
|| die "adapter '$PLATFORM' carries no project skills path" 2
|
|
231
|
+
for d in "$src_root"/*/; do
|
|
232
|
+
[ -d "$d" ] || continue
|
|
233
|
+
name=$(basename "$d")
|
|
234
|
+
n=$((n + 1))
|
|
235
|
+
g_info "extras: skill $name -> ${dst_root#"$TARGET"/}$name/"
|
|
236
|
+
copy_tree "$d" "$dst_root/$name"
|
|
237
|
+
done
|
|
238
|
+
[ "$n" -gt 0 ] || die "payload for '$id' holds no skill dirs ($src_root/*/ with SKILL.md each) — see extras-catalogue/payload/README.md" 2
|
|
239
|
+
return 0
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# install_workflow <row> — copy the payload into .gob/playbooks/<id>/.
|
|
243
|
+
install_workflow() {
|
|
244
|
+
local row="$1" id src dst
|
|
245
|
+
id=$(catalogue_field "$row" 1)
|
|
246
|
+
src="$PAYLOAD/$id"
|
|
247
|
+
[ -d "$src" ] \
|
|
248
|
+
|| die "no vendored payload for '$id' at $src — vendor the playbook files there first (see extras-catalogue/payload/README.md)" 2
|
|
249
|
+
dst="$TARGET/.gob/playbooks/$id"
|
|
250
|
+
g_info "extras: workflow $id -> ${dst#"$TARGET"/}/"
|
|
251
|
+
copy_tree "$src" "$dst"
|
|
252
|
+
return 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# install_mcp <row> — print the mcp.json snippet; --with-mcp-config merges it into
|
|
256
|
+
# the target's .mcp.json. The merge NEVER overwrites: an existing entry for the same
|
|
257
|
+
# server name must match the generated bytes (no-op) or the merge refuses (ask-once).
|
|
258
|
+
install_mcp() {
|
|
259
|
+
local row="$1" id hint cmd rest first_arg
|
|
260
|
+
id=$(catalogue_field "$row" 1)
|
|
261
|
+
hint=$(catalogue_field "$row" 14)
|
|
262
|
+
case "$hint" in
|
|
263
|
+
npx\ *|npm\ exec\ *)
|
|
264
|
+
read -r cmd rest <<< "$hint"
|
|
265
|
+
first_arg="$rest" ;;
|
|
266
|
+
*)
|
|
267
|
+
if [ "$WITH_MCP" -eq 1 ]; then
|
|
268
|
+
die "id '$id' carries install_hint '$hint' — only an 'npx <package>' hint can be merged into .mcp.json; follow the row's own hint by hand" 1
|
|
269
|
+
fi
|
|
270
|
+
g_info "extras: mcp $id — install per the row's own hint: $hint (no .mcp.json snippet is generated for it)"
|
|
271
|
+
return 0 ;;
|
|
272
|
+
esac
|
|
273
|
+
local snippet
|
|
274
|
+
snippet=$(python3 -c 'import json,sys; print(json.dumps({"mcpServers": {sys.argv[1]: {"command": sys.argv[2], "args": sys.argv[3].split()}}}, separators=(",",":")))' "$id" "$cmd" "$first_arg")
|
|
275
|
+
g_info "extras: mcp $id — mcp.json snippet:"
|
|
276
|
+
g_info " $snippet"
|
|
277
|
+
[ "$WITH_MCP" -eq 1 ] || return 0
|
|
278
|
+
[ "$DRYRUN" -eq 0 ] || { g_info "plan: merge $id into ${TARGET#.}/.mcp.json"; return 0; }
|
|
279
|
+
MERGE_OUT=$(MCP_ID="$id" MCP_SNIPPET="$snippet" python3 - "$TARGET/.mcp.json" <<'PYEOF'
|
|
280
|
+
import json, os, sys
|
|
281
|
+
path = sys.argv[1]
|
|
282
|
+
mcp_id = os.environ["MCP_ID"]
|
|
283
|
+
snippet = json.loads(os.environ["MCP_SNIPPET"])
|
|
284
|
+
server = snippet["mcpServers"][mcp_id]
|
|
285
|
+
data = {}
|
|
286
|
+
if os.path.exists(path):
|
|
287
|
+
raw = open(path).read().strip()
|
|
288
|
+
if raw:
|
|
289
|
+
try:
|
|
290
|
+
data = json.loads(raw)
|
|
291
|
+
except ValueError:
|
|
292
|
+
sys.stderr.write("extras: refusing %s: it is not valid JSON - edit it by hand\n" % path)
|
|
293
|
+
sys.exit(1)
|
|
294
|
+
servers = data.setdefault("mcpServers", {})
|
|
295
|
+
if mcp_id in servers:
|
|
296
|
+
if servers[mcp_id] == server:
|
|
297
|
+
print("extras: unchanged %s (already carries the %s registration)" % (path, mcp_id))
|
|
298
|
+
sys.exit(0)
|
|
299
|
+
sys.stderr.write("extras: refusing to overwrite the existing %s entry in %s - an mcp registration you customized is the project's (ask-once)\n" % (mcp_id, path))
|
|
300
|
+
sys.exit(1)
|
|
301
|
+
servers[mcp_id] = server
|
|
302
|
+
with open(path, "w") as fh:
|
|
303
|
+
json.dump(data, fh, separators=(",", ":"))
|
|
304
|
+
fh.write("\n")
|
|
305
|
+
print("extras: merged %s into %s" % (mcp_id, path))
|
|
306
|
+
PYEOF
|
|
307
|
+
)
|
|
308
|
+
local rc=$?
|
|
309
|
+
[ -n "$MERGE_OUT" ] && printf '%s\n' "$MERGE_OUT"
|
|
310
|
+
[ "$rc" -eq 0 ] || exit 1
|
|
311
|
+
return 0
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
cmd_install() {
|
|
315
|
+
parse_install "$@"
|
|
316
|
+
[ "$DRYRUN" -eq 0 ] || g_info "extras: --dry-run (nothing is written)"
|
|
317
|
+
local id row
|
|
318
|
+
for id in "${IDS[@]}"; do
|
|
319
|
+
row=$(resolve_one "$id") || exit $?
|
|
320
|
+
case "$(catalogue_field "$row" 3)" in
|
|
321
|
+
skill) install_skill "$row" ;;
|
|
322
|
+
workflow) install_workflow "$row" ;;
|
|
323
|
+
mcp) install_mcp "$row" ;;
|
|
324
|
+
*) die "row '$id' carries unknown kind '$(catalogue_field "$row" 3)' (want skill|mcp|workflow)" 2 ;;
|
|
325
|
+
esac
|
|
326
|
+
done
|
|
327
|
+
[ "$COPY_REFUSALS" -eq 0 ] || die "$COPY_REFUSALS file(s) refused (named above) — nothing overwritten" 1
|
|
328
|
+
return 0
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# ---- dispatch ------------------------------------------------------------------
|
|
332
|
+
CMD="${1:-}"
|
|
333
|
+
[ -n "$CMD" ] || { usage; exit 2; }
|
|
334
|
+
shift
|
|
335
|
+
case "$CMD" in
|
|
336
|
+
list) cmd_list "$@" ;;
|
|
337
|
+
show) cmd_show "$@" ;;
|
|
338
|
+
install) cmd_install "$@" ;;
|
|
339
|
+
-h|--help|help) usage; exit 0 ;;
|
|
340
|
+
*) g_err "extras: unknown subcommand: $CMD"; usage >&2; exit 2 ;;
|
|
341
|
+
esac
|
|
342
|
+
exit 0
|