Skill: pseudobun/signal-cli (version 0.1.0)

--- name: signal-cli description: Send Signal messages and look up Signal recipients via the local signal-cli installation on macOS. Use when the user asks to message someone on Signal, send a Signal text/attachment, list Signal contacts, or resolve a recipient by name/nickname/phone number. --- # signal-cli (Signal Messaging) Use the local `signal-cli` binary. ## Preconditions - `signal-cli` is installed and already linked/registered. - For safety: confirm recipient + final message text with the user before sending. ## Quick patterns ### Discover available accounts ```bash signal-cli listAccounts ``` ### List contacts (JSON) ```bash signal-cli -o json -u "+386..." listContacts ``` ### Find a contact by name/nickname/number Prefer the bundled script (handles fuzzy-ish matching + multiple matches): ```bash python3 scripts/find_contact.py --account "+386..." --query "Name" ``` ### Send a message Prefer the bundled script (resolves contact names to numbers): ```bash python3 scripts/send_message.py --account "+386..." --to "Name" --text "Heyo ..." ``` If `--to` is already a phone number in E.164 (e.g. `+386...`), it sends directly. ## Safety checklist (always) - If resolving by name returns multiple matches, present options and ask the user which one. - If message contains sensitive info, ask explicitly before sending via Signal. - Default to `--service-environment live` (signal-cli default) and normal trust behavior.

path: scripts/send_message.py
content: #!/usr/bin/env python3
import argparse, json, re, subprocess, sys
from pathlib import Path

def run(cmd):
    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if p.returncode != 0:
        raise RuntimeError(f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stderr.strip()}")
    return p.stdout

def norm(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "").strip().lower())

def is_e164(s: str) -> bool:
    return bool(re.fullmatch(r"\+[1-9]\d{6,14}", (s or "").strip()))

FIELDS = ["name", "givenName", "familyName", "nickName", "nickGivenName", "nickFamilyName", "username", "number"]

def score_contact(c: dict, qn: str) -> int:
    best = 0
    for f in FIELDS:
        v = c.get(f)
        if not v:
            continue
        vn = norm(str(v))
        if vn == qn:
            best = max(best, 100)
        elif vn.startswith(qn) and qn:
            best = max(best, 60)
        elif qn and qn in vn:
            best = max(best, 40)
    return best

def resolve_number(account: str, to: str):
    to = to.strip()
    if is_e164(to):
        return to, []

    out = run(["signal-cli", "-o", "json", "-u", account, "listContacts"])
    contacts = json.loads(out)
    qn = norm(to)
    matches = []
    for c in contacts:
        s = score_contact(c, qn)
        if s > 0 and c.get("number"):
            matches.append((s, c))
    matches.sort(key=lambda t: (-t[0], norm(t[1].get('name') or ''), t[1].get('number') or ''))

    if not matches:
        raise RuntimeError(f"No Signal contact match for '{to}'. Provide a phone number like +386... or add contact in Signal.")

    top_score = matches[0][0]
    top = [c for s, c in matches if s == top_score]

    if len(top) != 1:
        # ambiguous
        simplified = [{
            "number": c.get("number"),
            "name": c.get("name"),
            "nickName": c.get("nickName"),
            "givenName": c.get("givenName"),
            "familyName": c.get("familyName"),
            "score": top_score,
        } for c in top[:10]]
        return None, simplified

    return top[0].get("number"), []


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--account", required=True, help="Sender account number (E.164), used as -u")
    ap.add_argument("--to", required=True, help="Recipient: E.164 (+...) OR a name/nickname to resolve via listContacts")
    ap.add_argument("--text", required=True, help="Message text")
    ap.add_argument("--attachment", action="append", default=[], help="File path(s) to attach")
    args = ap.parse_args()

    number, ambiguous = resolve_number(args.account, args.to)
    if number is None:
        print(json.dumps({"error": "ambiguous_recipient", "candidates": ambiguous}, ensure_ascii=False, indent=2))
        sys.exit(3)

    cmd = ["signal-cli", "-u", args.account, "send", number, "-m", args.text]
    for a in args.attachment:
        p = Path(a).expanduser()
        cmd += ["-a", str(p)]

    out = run(cmd)
    # signal-cli prints to stderr sometimes; success is mostly silent. Emit a small JSON success.
    print(json.dumps({"ok": True, "account": args.account, "to": number}, ensure_ascii=False))

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(str(e), file=sys.stderr)
        sys.exit(2)


path: scripts/find_contact.py
content: #!/usr/bin/env python3
import argparse, json, re, subprocess, sys

def run(cmd):
    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if p.returncode != 0:
        raise RuntimeError(f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stderr.strip()}")
    return p.stdout

def norm(s: str) -> str:
    s = (s or "").strip().lower()
    s = re.sub(r"\s+", " ", s)
    return s

def is_e164(s: str) -> bool:
    return bool(re.fullmatch(r"\+[1-9]\d{6,14}", (s or "").strip()))

FIELDS = ["name", "givenName", "familyName", "nickName", "nickGivenName", "nickFamilyName", "username", "number", "uuid"]

def score_contact(c: dict, qn: str) -> int:
    # higher is better
    best = 0
    for f in FIELDS:
        v = c.get(f)
        if not v:
            continue
        vn = norm(str(v))
        if not vn:
            continue
        if vn == qn:
            best = max(best, 100)
        elif qn and vn.startswith(qn):
            best = max(best, 60)
        elif qn and qn in vn:
            best = max(best, 40)
    return best

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--account", required=True, help="Sender account number (E.164), used as -u")
    ap.add_argument("--query", required=True, help="Name/nickname/number fragment to search")
    ap.add_argument("--limit", type=int, default=20)
    args = ap.parse_args()

    q = args.query.strip()
    qn = norm(q)

    out = run(["signal-cli", "-o", "json", "-u", args.account, "listContacts"])
    contacts = json.loads(out)

    matches = []

    if is_e164(q):
        for c in contacts:
            if c.get("number") == q:
                matches.append({"score": 100, "contact": c})
        if not matches:
            # allow sending to unlisted numbers too; still show nothing here
            pass
    else:
        for c in contacts:
            s = score_contact(c, qn)
            if s > 0:
                matches.append({"score": s, "contact": c})

    matches.sort(key=lambda m: (-m["score"], norm(m["contact"].get("name") or ""), m["contact"].get("number") or ""))
    matches = matches[: args.limit]

    # output compact JSON list
    simplified = []
    for m in matches:
        c = m["contact"]
        simplified.append({
            "score": m["score"],
            "number": c.get("number"),
            "name": c.get("name"),
            "nickName": c.get("nickName"),
            "givenName": c.get("givenName"),
            "familyName": c.get("familyName"),
            "uuid": c.get("uuid"),
            "username": c.get("username"),
            "isBlocked": c.get("isBlocked"),
            "unregistered": c.get("unregistered"),
        })

    print(json.dumps(simplified, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(str(e), file=sys.stderr)
        sys.exit(2)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)