diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000..57e49ad --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,30 @@ +# contrib — companion helpers for driving remote tools through sic + +Optional POSIX-sh helpers that pair `sic` with an **[lmcp](https://git.reauktion.de/marfrit/lmcp)** +server (a lightweight Lua MCP server). Install any of them on the hosts you reach with sic and +call them as `sic ...`. None of these are required by sic itself. + +## `lmcp-tool` +Call a host's lmcp (MCP) tools over a single stateless HTTP `tools/call`, with no persistent +MCP session: + +``` +sic lmcp-tool list # discover the tools this host offers +sic lmcp-tool web_search "query=today's news" # call one, args as key=value +sic lmcp-tool fetch url="https://example.com" +``` + +The `key=value` form is the important bit: a model never has to embed JSON in a shell string, +so apostrophes (`today's`, `what's`) ride safely inside double quotes and `lmcp-tool` does the +JSON-encoding. Raw JSON (`lmcp-tool '{"k":"v"}'`) still works. Host/port/token are read +from `$LMCP_HOST/$LMCP_PORT/$LMCP_TOKEN` or the local `lmcp.service` systemd unit. See the +[lmcp project](https://git.reauktion.de/marfrit/lmcp) for the server side. + +## `sicwrite` / `sicedit` +`sicd` consumes stdin for the netstring frame, so a remote command can't be fed content via a +pipe. These pass content as **argv** instead (which sic delivers losslessly): + +``` +sic sicwrite # write a file +sic sicedit # replace one exact occurrence +``` diff --git a/contrib/lmcp-tool b/contrib/lmcp-tool new file mode 100755 index 0000000..b67cc66 --- /dev/null +++ b/contrib/lmcp-tool @@ -0,0 +1,78 @@ +#!/bin/sh +# lmcp-tool — call a LOCAL lmcp server's tool over a stateless HTTP tools/call. +# +# Companion client for lmcp servers (the Lua MCP server): https://git.reauktion.de/marfrit/lmcp +# Meant to be run through sic — `sic lmcp-tool ...` — so a model drives a +# remote host's MCP tools without a persistent MCP session. Three forms: +# +# lmcp-tool list # DISCOVERY: available tools + descriptions +# lmcp-tool key=value [key=value ...] # args as pairs — NO json-in-shell (apostrophe-safe) +# lmcp-tool '{"k":"v"}' # raw JSON (single arg starting with '{') +# +# Prefer key=value: `sic host lmcp-tool web_search "query=today's news"` — an apostrophe +# inside double quotes is fine, and lmcp-tool JSON-encodes the pairs for you. +# +# host/port/token come from, in order: $LMCP_HOST/$LMCP_PORT/$LMCP_TOKEN → the lmcp.service +# systemd unit Environment= → the file it points at via LMCP_CONF (sudo -n if root-only; +# key godparticle|token|bearer). Defaults to 127.0.0.1:8080. +tool="$1" +[ -n "$tool" ] || { echo "usage: lmcp-tool [ key=value ... | '{json}' ] | lmcp-tool list" >&2; exit 2; } +shift + +host="$LMCP_HOST"; port="$LMCP_PORT"; tok="$LMCP_TOKEN" +env=$(systemctl show lmcp.service -p Environment 2>/dev/null | tr ' ' '\n') +[ -z "$host" ] && host=$(printf '%s\n' "$env" | sed -n 's/^LMCP_HOST=//p' | head -1) +[ -z "$port" ] && port=$(printf '%s\n' "$env" | sed -n 's/^LMCP_PORT=//p' | head -1) +[ -z "$tok" ] && tok=$(printf '%s\n' "$env" | sed -n 's/^LMCP_TOKEN=//p' | head -1) +if [ -z "$tok" ]; then + conf=$(printf '%s\n' "$env" | sed -n 's/^LMCP_CONF=//p' | head -1) + [ -n "$conf" ] && tok=$( { cat "$conf" 2>/dev/null || sudo -n cat "$conf" 2>/dev/null; } \ + | grep -iE 'godparticle|token|bearer' | grep -oE '[A-Za-z0-9_-]{30,}' | head -1) +fi +[ -n "$host" ] || host=127.0.0.1 +[ -n "$port" ] || port=8080 + +post() { + curl -s -m 120 -X POST "http://${host}:${port}/mcp" \ + -H "Authorization: Bearer ${tok}" -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" -d "$1" | sed -n 's/^data: //p' +} + +if [ "$tool" = "list" ] || [ "$tool" = "tools" ] || [ "$tool" = "--list" ]; then + post '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -c ' +import sys, json +raw = sys.stdin.read().strip() +if not raw: sys.stderr.write("lmcp-tool: empty response (unreachable or auth failed)\n"); sys.exit(1) +d = json.loads(raw.splitlines()[-1]) +if "error" in d: sys.stderr.write("lmcp error: %s\n" % d["error"]); sys.exit(1) +for t in d.get("result", {}).get("tools", []): + desc = (t.get("description") or "").splitlines()[0] if t.get("description") else "" + print("%-16s %s" % (t.get("name","?"), desc)) +' + exit $? +fi + +# build the arguments JSON +if [ $# -eq 0 ]; then + args='{}' +elif [ $# -eq 1 ] && [ "${1#\{}" != "$1" ]; then + args="$1" # single arg starting with '{' = raw JSON +else + args=$(python3 -c 'import sys, json +print(json.dumps({a.partition("=")[0]: a.partition("=")[2] for a in sys.argv[1:]}))' "$@") \ + || { echo "lmcp-tool: could not build args from key=value pairs" >&2; exit 2; } +fi + +body=$(printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"%s","arguments":%s}}' "$tool" "$args") +post "$body" | python3 -c ' +import sys, json +raw = sys.stdin.read().strip() +if not raw: sys.stderr.write("lmcp-tool: empty response (unreachable or auth failed)\n"); sys.exit(1) +d = json.loads(raw.splitlines()[-1]) +if "error" in d: + sys.stderr.write("lmcp error: %s\n" % d["error"]) + sys.stderr.write("hint: run lmcp-tool list to see valid tool names\n"); sys.exit(1) +r = d.get("result", {}) +print("\n".join(c["text"] for c in r.get("content", []) if c.get("type") == "text")) +sys.exit(1 if r.get("isError") else 0) +' diff --git a/contrib/sicedit b/contrib/sicedit new file mode 100755 index 0000000..b96159f --- /dev/null +++ b/contrib/sicedit @@ -0,0 +1,13 @@ +#!/bin/sh +# sicedit — replace the single exact occurrence of +# with in (edit_file semantics). Args pass losslessly via sic. +[ $# -ge 3 ] || { echo "usage: sicedit " >&2; exit 2; } +python3 - "$1" "$2" "$3" <<'PY' +import sys +p,o,n=sys.argv[1],sys.argv[2],sys.argv[3] +s=open(p).read() +c=s.count(o) +if c==0: sys.stderr.write("sicedit: old_string not found\n"); sys.exit(1) +if c>1: sys.stderr.write("sicedit: old_string not unique (%d matches)\n"%c); sys.exit(1) +open(p,'w').write(s.replace(o,n,1)) +PY diff --git a/contrib/sicwrite b/contrib/sicwrite new file mode 100755 index 0000000..fd63bfe --- /dev/null +++ b/contrib/sicwrite @@ -0,0 +1,5 @@ +#!/bin/sh +# sicwrite — write content (passed as one argv by sic, so any +# bytes survive) to path. Replaces the write_file tool for sic on any host. +[ $# -ge 2 ] || { echo "usage: sicwrite " >&2; exit 2; } +printf '%s' "$2" > "$1"