Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d576886737 | |||
| ef1d6b517d |
@@ -1,6 +1,7 @@
|
||||
# sic
|
||||
|
||||
Run a command on a remote host without a shell re-parsing its arguments.
|
||||
Run a command on a remote host — or inside a container several hops deep —
|
||||
without a shell re-parsing its arguments.
|
||||
|
||||
`ssh host touch 'a b'` creates two files: `ssh` joins its arguments with spaces
|
||||
and hands the result to the remote login shell, which splits it again. `sic host
|
||||
@@ -50,6 +51,38 @@ sic host1 echo '$HOME' # literal, no expansion
|
||||
sic --sh host1 'echo hi | wc -c'
|
||||
```
|
||||
|
||||
## Nesting
|
||||
|
||||
The frame survives being wrapped again, so `sic` reaches *into* containers with no
|
||||
shell anywhere in the path, at any depth. A target is a host followed by container
|
||||
hops separated by `/`:
|
||||
|
||||
```
|
||||
sic host1/app cat /etc/os-release # into the 'app' guest on host1
|
||||
sic host2/ct/svc touch 'a b' # host2 -> ct -> svc, ONE file, three layers deep
|
||||
```
|
||||
|
||||
Each host's hop runtimes come from `/etc/sic/hosts.toml`, one stanza per host:
|
||||
|
||||
```toml
|
||||
[host1]
|
||||
nest = ["incus"] # host1/app -> incus exec app -- <argv>
|
||||
|
||||
[host2]
|
||||
nest = ["pct", "docker"] # host2/ct/svc -> pct exec ct -- docker exec svc -- <argv>
|
||||
```
|
||||
|
||||
This is what separates `sic` from a shell helper script. The `ssh` equivalent —
|
||||
|
||||
```
|
||||
ssh host2 "pct exec ct -- docker exec svc -- touch \"a b\""
|
||||
```
|
||||
|
||||
nests quotes inside quotes inside quotes, and every layer re-splits the arguments; a
|
||||
body containing a space, `$()`, a pipe, or a newline breaks a different layer each time.
|
||||
`sic` frames the argv once and re-frames it at each hop, so `touch 'a b'` is one file
|
||||
whether it runs on the host or three containers deep. No escaping, at any level.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
@@ -88,5 +121,6 @@ sic host echo hello world
|
||||
## See also
|
||||
|
||||
- `docs/design.md` — wire format, exec modes, transport and security, prior art.
|
||||
- `docs/design-nested.md` — how nested container hops resolve.
|
||||
- `demos/quoting-hell.py` — six cases run both ways, plain `ssh` versus `sic`.
|
||||
- `SKILL.md`, `SKILL-bg.md` — agent skill definitions (foreground and background).
|
||||
|
||||
+27
-6
@@ -6,6 +6,8 @@
|
||||
# remote host's MCP tools without a persistent MCP session. Three forms:
|
||||
#
|
||||
# lmcp-tool list # DISCOVERY: available tools + descriptions
|
||||
# lmcp-tool list --json # same, but full tools/list incl. inputSchema
|
||||
# lmcp-tool --json <tool> key=value ... # full result object (content[] + isError)
|
||||
# lmcp-tool <tool> key=value [key=value ...] # args as pairs — NO json-in-shell (apostrophe-safe)
|
||||
# lmcp-tool fetch_url https://example.com # a bare http(s):// arg is auto-mapped to url=
|
||||
# lmcp-tool <tool> '{"k":"v"}' # raw JSON (single arg starting with '{')
|
||||
@@ -16,9 +18,14 @@
|
||||
# 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.
|
||||
# --json darf vorne stehen (maschinelle Verbraucher) oder bei `list` hinten.
|
||||
json_out=0
|
||||
if [ "$1" = "--json" ]; then json_out=1; shift; fi
|
||||
|
||||
tool="$1"
|
||||
[ -n "$tool" ] || { echo "usage: lmcp-tool <tool> [ key=value ... | '{json}' ] | lmcp-tool list" >&2; exit 2; }
|
||||
[ -n "$tool" ] || { echo "usage: lmcp-tool [--json] <tool> [ key=value ... | '{json}' ] | lmcp-tool list [--json]" >&2; exit 2; }
|
||||
shift
|
||||
[ "$1" = "--json" ] && { json_out=1; shift; }
|
||||
|
||||
host="$LMCP_HOST"; port="$LMCP_PORT"; tok="$LMCP_TOKEN"
|
||||
env=$(systemctl show lmcp.service -p Environment 2>/dev/null | tr ' ' '\n')
|
||||
@@ -46,10 +53,17 @@ 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))
|
||||
'
|
||||
res = d.get("result", {})
|
||||
if sys.argv[1] == "1":
|
||||
# Vollstaendig, inklusive inputSchema - das braucht ein Modell, um die
|
||||
# Parameter richtig zu setzen. Die Prosa-Beschreibung allein reicht nicht.
|
||||
json.dump(res, sys.stdout, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
for t in res.get("tools", []):
|
||||
desc = (t.get("description") or "").splitlines()[0] if t.get("description") else ""
|
||||
print("%-16s %s" % (t.get("name","?"), desc))
|
||||
' "$json_out"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
@@ -83,6 +97,13 @@ 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", {})
|
||||
if sys.argv[1] == "1":
|
||||
# MCP-Semantik: der Inhalt ist auch bei isError massgeblich und gehoert
|
||||
# an das Modell durchgereicht. Rueckgabewert 0, sobald eine gueltige
|
||||
# Antwort vorliegt - 1 bleibt dem Transportfehler vorbehalten.
|
||||
json.dump(r, sys.stdout, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
sys.exit(0)
|
||||
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)
|
||||
'
|
||||
' "$json_out"
|
||||
|
||||
Reference in New Issue
Block a user