solverforge-cli 3.3.3

CLI for scaffolding and managing SolverForge constraint solver projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
#!/usr/bin/env sh
set -eu

# Agent-centric installer for the SolverForge agent skills.
#
# The user chooses the agent harnesses they use; the installer resolves each
# harness's own skills directory, writes the skill trees there, and avoids
# creating duplicate discovery. Nothing is installed into a directory the user
# did not ask for, and `.agents` is never an implicit default.
#
# Default layout is one direct copy per selected harness ("each harness its
# own"). Two opt-ins are available: `--layout covering` computes a
# duplicate-free placement shared by the selected harnesses, and `--link`
# symlinks each destination to the bundled skill instead of copying it.
#
# Usage: scripts/install-skill [OPTIONS]
#
#   --agent <harness>   Harness to install for (repeatable): opencode, claude,
#                       codex. When omitted on a TTY the installer prompts.
#   --skill <name>      Skill to install (repeatable). Default: every skill
#                       under skills/.
#   --project <dir>     Install into a project instead of user scope.
#   --dir <path>        Explicit skills directory (repeatable); bypasses the
#                       harness matrix.
#   --link              Symlink each destination to the bundled skill instead
#                       of copying it.
#   --layout <mode>     covering (duplicate-free, may pick a shared directory)
#                       or per-harness (default: a copy in each harness dir).
#   --force             Allow duplicate discovery with --layout per-harness.
#   --list              Show install state and exit.
#   --uninstall         Remove installs created by this installer.
#   --help              Show this help.

usage() {
	sed -n '4,33p' "$0" | sed 's/^# \{0,1\}//'
}

script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
skills_root="$repo_root/skills"
[ -d "$skills_root" ] || {
	echo "error: skills directory not found at $skills_root" >&2
	exit 1
}

action=install
layout=per-harness
link_mode=0
force=0
project=""
agents=""
skills=""
explicit_dirs=""

agent_dir() {
	harness=$1
	if [ -n "$project" ]; then
		case "$harness" in
		opencode) printf '%s' "$project/.opencode/skills" ;;
		claude) printf '%s' "$project/.claude/skills" ;;
		codex) printf '%s' "$project/.agents/skills" ;;
		esac
		return
	fi
	case "$harness" in
	opencode) printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills" ;;
	claude) printf '%s' "$HOME/.claude/skills" ;;
	codex) printf '%s' "$HOME/.agents/skills" ;;
	esac
}

while [ $# -gt 0 ]; do
	case "$1" in
	--uninstall) action=uninstall ;;
	--list) action=list ;;
	--force) force=1 ;;
	--link) link_mode=1 ;;
	--project)
		shift
		[ -n "${1:-}" ] || { echo "error: --project expects a directory" >&2; exit 1; }
		project=$1
		[ -d "$project" ] || { echo "error: project directory '$project' does not exist" >&2; exit 1; }
		;;
	--dir)
		shift
		[ -n "${1:-}" ] || { echo "error: --dir expects a directory" >&2; exit 1; }
		explicit_dirs="$explicit_dirs
$1"
		;;
	--agent)
		shift
		case "${1:-}" in
		opencode | claude | codex) agents="$agents $1" ;;
		*)
			echo "error: --agent expects opencode, claude, or codex" >&2
			exit 1
			;;
		esac
		;;
	--skill)
		shift
		[ -n "${1:-}" ] || { echo "error: --skill expects a name" >&2; exit 1; }
		case "$1" in
		.|..|*[!A-Za-z0-9._-]*)
			echo "error: invalid skill name '$1'" >&2
			exit 1
			;;
		esac
		skills="$skills $1"
		;;
	--layout)
		shift
		case "${1:-}" in
		covering | per-harness) layout=$1 ;;
		*)
			echo "error: --layout expects covering or per-harness" >&2
			exit 1
			;;
		esac
		;;
	--help | -h)
		usage
		exit 0
		;;
	*)
		echo "error: unknown option '$1'" >&2
		usage >&2
		exit 1
		;;
	esac
	shift
done

selected_agent() {
	case " $agents " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}

select_agents_interactively() {
	[ -t 0 ] && [ -t 1 ] || {
		echo "error: no --agent given and no interactive terminal; pass --agent opencode|claude|codex" >&2
		exit 1
	}
	printf 'Select agent harnesses (comma separated) [opencode,claude,codex]: '
	read -r picked
	old_ifs=$IFS
	IFS=', '
	for entry in $picked; do
		[ -n "$entry" ] || continue
		case "$entry" in
		opencode | claude | codex) agents="$agents $entry" ;;
		*) echo "error: unknown harness '$entry'" >&2; exit 1 ;;
		esac
	done
	IFS=$old_ifs
	[ -n "$agents" ] || { echo "error: no harness selected" >&2; exit 1; }
}

all_skills() {
	for dir in "$skills_root"/*/; do
		[ -f "$dir/SKILL.md" ] || continue
		basename "$dir"
	done
}

selected_skills() {
	if [ -n "${skills# }" ]; then
		printf '%s\n' ${skills# }
		return
	fi
	all_skills
}

# An install may only name a bundled skill; uninstall must still accept a name
# whose bundle entry was removed after install, so existence is install-only.
require_bundled_skills() {
	[ "$action" = install ] || return 0
	[ -n "${skills# }" ] || return 0
	for name in ${skills# }; do
		[ -f "$skills_root/$name/SKILL.md" ] || {
			echo "error: unknown skill '$name' (see $skills_root)" >&2
			exit 1
		}
	done
}

# Destination list: "label<TAB>dir" lines.
destinations() {
	if [ -n "$explicit_dirs" ]; then
		index=0
		printf '%s\n' "$explicit_dirs" | while IFS= read -r dir; do
			[ -n "$dir" ] || continue
			index=$((index + 1))
			printf 'custom-%s\t%s\n' "$index" "$dir"
		done
		return
	fi

	has_oc=0
	has_cl=0
	has_cx=0
	if selected_agent opencode; then has_oc=1; fi
	if selected_agent claude; then has_cl=1; fi
	if selected_agent codex; then has_cx=1; fi

	# A harness that scans more than one selected destination discovers the
	# same skill twice. `covering` picks a shared directory where one exists;
	# `per-harness` is refused unless forced.
	if [ "$layout" = "covering" ]; then
		if [ "$has_oc" = 1 ] && [ "$has_cl" = 1 ] && [ "$has_cx" = 1 ]; then
			echo "error: opencode scans the claude and codex skill directories, so {opencode,claude,codex} has no duplicate-free placement; select two harnesses, or pass --layout per-harness --force to accept duplicate discovery" >&2
			exit 1
		fi
		if [ "$has_oc" = 1 ] && [ "$has_cl" = 1 ]; then
			printf 'shared\t%s\n' "$(agent_dir claude)"
			return
		fi
		if [ "$has_oc" = 1 ] && [ "$has_cx" = 1 ]; then
			printf 'shared\t%s\n' "$(agent_dir codex)"
			return
		fi
	fi

	if [ "$layout" = "per-harness" ] && [ "$has_oc" = 1 ] && { [ "$has_cl" = 1 ] || [ "$has_cx" = 1 ]; }; then
		if [ "$force" != 1 ]; then
			echo "error: a per-harness copy for opencode plus claude/codex makes opencode discover the same skill twice; pass --layout covering, or --force to accept duplicate discovery" >&2
			exit 1
		fi
		echo "warning: opencode scans the claude and codex directories; per-harness copies will duplicate discovery" >&2
	fi

	if [ "$has_oc" = 1 ]; then printf 'opencode\t%s\n' "$(agent_dir opencode)"; fi
	if [ "$has_cl" = 1 ]; then printf 'claude\t%s\n' "$(agent_dir claude)"; fi
	if [ "$has_cx" = 1 ]; then printf 'codex\t%s\n' "$(agent_dir codex)"; fi
}

receipt_name=.solverforge-skill
link_receipt_prefix=.solverforge-skill-link-
rcpt_tmp=

# Parses a link receipt into rc_format, rc_skill, rc_mode, and rc_target. The
# receipt must be a regular file; a symlinked receipt is not trusted.
parse_link_receipt() {
	rc_format=
	rc_skill=
	rc_mode=
	rc_target=
	[ -f "$1" ] && [ ! -L "$1" ] || return 1
	while IFS= read -r line || [ -n "$line" ]; do
		case "$line" in
		format=*) rc_format=${line#format=} ;;
		skill=*) rc_skill=${line#skill=} ;;
		mode=*) rc_mode=${line#mode=} ;;
		target=*) rc_target=${line#target=} ;;
		esac
	done < "$1"
	[ "$rc_format" = "1" ] && [ "$rc_mode" = "link" ] && [ -n "$rc_skill" ] && [ -n "$rc_target" ]
}

managed_copy() {
	[ -f "$1/$receipt_name" ] && [ ! -L "$1/$receipt_name" ] || return 1
	[ "$(cat "$1/$receipt_name" 2>/dev/null)" = "$2" ]
}

link_receipt_path() {
	printf '%s/%s%s' "$1" "$link_receipt_prefix" "$2"
}

# A link is installer-owned only when the sidecar receipt is intact, describes
# this exact skill, and the destination symlink still points at the recorded
# target. A receipt whose link was replaced or deleted describes nothing the
# installer may treat as its own work.
managed_link() {
	parse_link_receipt "$(link_receipt_path "$1" "$2")" || return 1
	[ "$rc_skill" = "$2" ] || return 1
	[ -L "$1/$2" ] || return 1
	[ "$(readlink "$1/$2")" = "$rc_target" ]
}

has_link_receipt() {
	[ -e "$(link_receipt_path "$1" "$2")" ]
}

# Writes `<path>` with one argument per line via a temporary sibling plus
# rename, so an interrupted write can never leave a truncated receipt.
write_file_atomically() {
	target=$1
	shift
	rcpt_tmp=$(mktemp "${target}.XXXXXX") || {
		echo "error: cannot create a temporary file next to $target" >&2
		exit 1
	}
	for line
	do
		printf '%s\n' "$line" >> "$rcpt_tmp" || {
			rm -f "$rcpt_tmp"
			rcpt_tmp=
			echo "error: cannot write a temporary file next to $target" >&2
			exit 1
		}
	done
	mv -f "$rcpt_tmp" "$target" || {
		rm -f "$rcpt_tmp"
		rcpt_tmp=
		echo "error: cannot replace $target" >&2
		exit 1
	}
	rcpt_tmp=
}

install_one() {
	skill=$1
	dir=$2
	src="$skills_root/$skill"
	dest="$dir/$skill"

	if [ "$action" = list ]; then
		if managed_link "$dir" "$skill"; then
			printf 'link       %-12s %s -> %s\n' "$skill" "$dest" "$(readlink "$dest")"
		elif has_link_receipt "$dir" "$skill"; then
			printf 'stale-rcpt %-12s %s (receipt without its recorded link)\n' "$skill" "$dest"
		elif managed_copy "$dest" "$skill"; then
			if diff -r -x "$receipt_name" "$src" "$dest" >/dev/null 2>&1; then
				printf 'current    %-12s %s\n' "$skill" "$dest"
			else
				printf 'outdated   %-12s %s\n' "$skill" "$dest"
			fi
		elif [ -e "$dest" ] || [ -L "$dest" ]; then
			printf 'foreign    %-12s %s (left untouched)\n' "$skill" "$dest"
		else
			printf 'absent     %-12s %s\n' "$skill" "$dest"
		fi
		return
	fi

	if [ "$action" = uninstall ]; then
		if managed_link "$dir" "$skill"; then
			rm -f "$dest" "$(link_receipt_path "$dir" "$skill")"
			printf 'removed    %-12s %s\n' "$skill" "$dest"
		elif has_link_receipt "$dir" "$skill"; then
			# The receipt no longer describes the destination (the link was
			# replaced, deleted, or the receipt corrupted); remove only the
			# installer-owned receipt and leave the destination untouched.
			rm -f "$(link_receipt_path "$dir" "$skill")"
			printf 'stale-rcpt %-12s receipt removed; destination left untouched\n' "$skill"
		elif managed_copy "$dest" "$skill"; then
			rm -rf "$dest"
			printf 'removed    %-12s %s\n' "$skill" "$dest"
		elif [ -e "$dest" ] || [ -L "$dest" ]; then
			printf 'untouched  %-12s %s (not installed by this installer)\n' "$skill" "$dest"
		fi
		return
	fi

	# install
	if [ -L "$dest" ]; then
		if managed_link "$dir" "$skill"; then
			rm -f "$dest" "$(link_receipt_path "$dir" "$skill")"
		else
			echo "error: $dest is a symlink not managed by this installer" >&2
			exit 1
		fi
	elif [ -e "$dest" ] && ! managed_copy "$dest" "$skill"; then
		echo "error: $dest exists and was not installed by this installer" >&2
		exit 1
	fi

	mkdir -p "$dir"

	if [ "$link_mode" = 1 ]; then
		rm -rf "$dest"
		ln -s "$src" "$dest"
		write_file_atomically "$(link_receipt_path "$dir" "$skill")" \
			"format=1" "skill=$skill" "mode=link" "target=$src"
		printf 'linked     %-12s %s -> %s\n' "$skill" "$dest" "$src"
		return
	fi

	if [ -d "$dest" ] && managed_copy "$dest" "$skill" && diff -r -x "$receipt_name" "$src" "$dest" >/dev/null 2>&1; then
		rm -f "$(link_receipt_path "$dir" "$skill")"
		printf 'current    %-12s %s\n' "$skill" "$dest"
		return
	fi

	rm -rf "$dest"
	rm -f "$(link_receipt_path "$dir" "$skill")"
	cp -R "$src" "$dest"
	# Preserve executable bits on bundled tooling regardless of the copy tool.
	find "$dest" -type f \( -name '*.sh' -o -name '*.py' \) -exec chmod +x {} + 2>/dev/null || true
	# Ownership is recorded here, not in the bundle: a skill copied by hand must
	# never be mistaken for an installer-owned entry.
	rm -f "$dest/$receipt_name"
	write_file_atomically "$dest/$receipt_name" "$skill"
	printf 'copied     %-12s %s\n' "$skill" "$dest"
}

if [ -z "${agents# }" ] && [ -z "$explicit_dirs" ] && [ "$action" = install ]; then
	select_agents_interactively
fi

require_bundled_skills

dest_file=$(mktemp)
trap 'rm -f "$dest_file" ${rcpt_tmp:+"$rcpt_tmp"}' EXIT HUP INT TERM
destinations > "$dest_file"

if [ ! -s "$dest_file" ]; then
	echo "error: nothing to install; pass --agent or --dir" >&2
	exit 1
fi

while IFS="$(printf '\t')" read -r label dir; do
	[ -n "$label" ] || continue
	[ -n "$dir" ] || continue
	for skill in $(selected_skills); do
		install_one "$skill" "$dir"
	done
done < "$dest_file"

if [ "$action" = install ]; then
	echo "done. restart your agent so it rescans skill directories."
fi