From 647772167e568dd2ca3749d95acfb56a4c0dfc64 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 9 Aug 2026 10:58:53 -0400 Subject: [PATCH 1/2] amico-linkedin: internal LinkedIn tool for Amicode public announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skill (surface:public, cli_tool ~/.local/bin/amico-linkedin) mirroring amico-slack: share-offsite fallback + OAuth + POST /rest/posts behind --confirm gate, LaTeX→Unicode, draft/preview/post/status/delete - CLI Python at tools/amico-linkedin (installed to ~/.local/bin) with token at ~/.amico/linkedin/token.json, LinkedIn-Version 202401 - First post framing: Amicode launch — Kimi K3 x [[625,50,3]] g=0.72 + Pasqal+Microsoft hackathon (2026-07-29) + Unitary Foundation QEC Challenge; draft note at vault-aaron/notes/linkedin-amicode-launch.md Refs #316 --- .../extension/skills/amico-linkedin/SKILL.md | 82 +++ tools/amico-linkedin | 476 ++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 packages/extension/skills/amico-linkedin/SKILL.md create mode 100755 tools/amico-linkedin diff --git a/packages/extension/skills/amico-linkedin/SKILL.md b/packages/extension/skills/amico-linkedin/SKILL.md new file mode 100644 index 0000000..bf3668f --- /dev/null +++ b/packages/extension/skills/amico-linkedin/SKILL.md @@ -0,0 +1,82 @@ +--- +name: amico-linkedin +description: Publish Amicode public announcements to the Harmoniqs LinkedIn Company Page — draft→preview→post via the LinkedIn API, plus no-auth share-offsite fallback. Internal tool. +agents: [researcher, pulse-designer, librarian] +surface: public +cli_tool: ~/.local/bin/amico-linkedin +--- + +# LinkedIn Publishing Skill (`amico-linkedin`) + +Internal tool to advertise Amicode public wins from within Amicode. First flight: Amicode launch announcement (Kimi K3 × `[[625,50,3]]` $g=0.72$, Pasqal + Microsoft hackathon, Unitary Foundation QEC Challenge). Mirrors `amico-slack` (Python CLI + `~/.amico/linkedin/token.json`, `--confirm` gate). + +## When to use + +- Drafting or posting a Harmoniqs Company Page announcement from Amicode. +- Sharing a QEC Challenge code / research note / vault note to LinkedIn. +- Checking LinkedIn org auth status before a campaign. + +## Core Rules + +1. **No silent posts.** `post` requires `--confirm` and an explicit org URN check. Drafts are local vault notes until confirmed. +2. **Honest claims.** `[[625,50,3]]` $d$ is `upper_bound` (witness-backed, CryptoMiniSat cert), $g$ inherits it, novelty not audited, seeded torics not raced — caveat in thread comment. +3. **LaTeX → Unicode.** LinkedIn has no LaTeX — flatten math the same way `amico-slack` does (`$\times$`→`×`, `$g=0.72$`→`g = 0.72`, `$r=\sqrt{2}$`→`r = √2`). +4. **Links are bare URLs** on LinkedIn (no Slack ``). Convert `[text](url)` → `text (url)` or bare `url`. +5. **Token is secret.** `~/.amico/linkedin/token.json` is gitignored, never echoed. Refresh every ~60 days; `status` warns at 7 days. + +## CLI Reference + +| Task | Command | +|---|---| +| Share as self (no auth, works today) | `amico-linkedin share --url [--text ]` | +| Start OAuth as Super Admin | `amico-linkedin auth --client-id --client-secret [--redirect http://localhost:8080/callback]` | +| Check org + token expiry | `amico-linkedin status` | +| Draft from vault note / URL | `amico-linkedin draft --from-note --url [--image ] [--out ]` | +| Preview (char count, image) | `amico-linkedin preview ` | +| Post as Harmoniqs (gated) | `amico-linkedin post --confirm` | +| Delete test post | `amico-linkedin delete ` | + +## Draft → Post flow (V1) + +```bash +# 0. No-auth path — works today (posts as YOU, not as Harmoniqs) +amico-linkedin share --url https://unitaryfoundation.github.io/qldpc-challenge/codes/625-50-3.html + +# 1. One-time auth as Super Admin (Aaron) — stores ~/.amico/linkedin/token.json +amico-linkedin auth --client-id $LINKEDIN_CLIENT_ID --client-secret $LINKEDIN_CLIENT_SECRET + +# 2. Verify +amico-linkedin status + +# 3. Draft from vault note + QEC URL +amico-linkedin draft --from-note ~/armonia/data/vaults/vault-aaron/notes/linkedin-amicode-launch.md \ + --url https://unitaryfoundation.github.io/qldpc-challenge/codes/625-50-3.html \ + --out /tmp/linkedin-draft.md + +# 4. Preview +amico-linkedin preview /tmp/linkedin-draft.md + +# 5. Post as Harmoniqs (confirm gate) +amico-linkedin post /tmp/linkedin-draft.md --confirm +``` + +## First post — Amicode launch framing + +This is the first public announcement of **Amicode** on the Harmoniqs LinkedIn page. Frame Amicode as the tool for *many* things (pulse design, QEC code search, Pasqal cloud compilation, multi-platform optimal control), not a single-code brag. Lead with Amicode, then proof points: + +- **Amicode** — Kimi K3-class agentic harness for quantum design (Piccolo/Piccolissimo + vault + catalog). +- **Pasqal + Microsoft hackathon (2026-07-29)** — Amicode × cloud-pasqal tutorials, pulser emulator → hardware, joint GTM. +- **Unitary Foundation QEC Challenge** — `[[625,50,3]]` holey rotated surface code, $g=0.72$ at $r=\sqrt{2}$ packing floor (top submitted geometric efficiency, $w\le4$ 2D-local single), verifier-passed, `L=13/19/25` family $0.533\to0.648\to0.720$ — proof Amicode + Kimi K3 ships verifiable research artifacts. + +Assets: verifier layout (r=√2 hero) + g progression chart. CTA: challenge page + research note. Honest caveat in first comment. + +## LinkedIn API notes (for `auth`/`post`) + +- OAuth: `https://www.linkedin.com/oauth/v2/authorization` + `accessToken`, scopes `w_organization_social r_organization_social` (and `w_member_social` for share fallback if needed). +- Posts (new): `POST https://api.linkedin.com/rest/posts` with headers `X-Restli-Protocol-Version: 2.0.0`, `LinkedIn-Version: 202401` (or latest), `Authorization: Bearer `. Body: `author: urn:li:organization:{id}`, `lifecycleState: PUBLISHED`, `visibility: PUBLIC`, `commentary` (text) + optional `content.media`. +- Org lookup: `GET https://api.linkedin.com/rest/organizations/{id}` or `GET /rest/organizationAcls?q=roleAssignee` to resolve URN from authenticated member. +- Token at `~/.amico/linkedin/token.json`: `{access_token, expires_at, refresh_token, org_urn, client_id}`. Never commit. + +## Vault contract + +Drafts reuse `notes/` as `type: note` with `tags: [announcement, linkedin]` + frontmatter `linkedin: {org, url, draft_id, image}`. No new folder for V1 (avoids `vault_contract` churn); promote to `team` via `visibility: team` + dream-promote PR when needed. If a typed `announcement` note type is added later, update `amico-vault` frontmatter in same PR (lint `tests/lint_vault_contract.sh` enforces both directions). diff --git a/tools/amico-linkedin b/tools/amico-linkedin new file mode 100755 index 0000000..91f2d88 --- /dev/null +++ b/tools/amico-linkedin @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +"""amico-linkedin — Amicode's LinkedIn CLI (Harmoniqs Company Page). + +Internal tool for Amicode public announcements. Day-0: share-offsite (no auth). +Full path: OAuth as Org Super Admin + POST /rest/posts (w_organization_social). + + share --url [--text ] [--open/--no-open] + auth --client-id --client-secret [--redirect ...] [--org ] + status + draft --from-note --url [--image ] [--out ] + preview + post --confirm + delete [--confirm] + +Token: ~/.amico/linkedin/token.json (gitignored, never committed) +Skill: packages/extension/skills/amico-linkedin/SKILL.md +""" +from __future__ import annotations + +import http.server +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from pathlib import Path + +API = "https://api.linkedin.com" +AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization" +TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" +TOKEN_FILE = Path.home() / ".amico" / "linkedin" / "token.json" +LINKEDIN_VERSION = os.environ.get("LINKEDIN_VERSION", "202401") +SHARE_URL = "https://www.linkedin.com/sharing/share-offsite/" + +UNICODE_MAP = { + r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\delta": "δ", + r"\epsilon": "ε", r"\theta": "θ", r"\lambda": "λ", r"\mu": "μ", + r"\pi": "π", r"\sigma": "σ", r"\phi": "φ", r"\omega": "ω", + r"\Omega": "Ω", r"\Delta": "Δ", r"\hbar": "ℏ", r"\infty": "∞", + r"\times": "×", r"\pm": "±", r"\leq": "≤", r"\geq": "≥", r"\neq": "≠", + r"\approx": "≈", r"\propto": "∝", r"\rightarrow": "→", r"\leftarrow": "←", + r"\langle": "⟨", r"\rangle": "⟩", r"\sqrt": "√", r"\cdot": "·", r"\rho": "ρ", +} +SUP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", + "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "-": "⁻", "+": "⁺"} + + +def die(msg: str, code: int = 1) -> None: + print(f"amico-linkedin: {msg}", file=sys.stderr) + sys.exit(code) + + +def flatten_math(text: str) -> str: + def fix(span: str) -> str: + s = span + for k, v in UNICODE_MAP.items(): + s = s.replace(k, v) + s = re.sub(r"\\[a-zA-Z]+", lambda m: m.group(0)[1:], s) + s = re.sub(r"\^\{([^}]*)\}", lambda m: "".join(SUP.get(c, c) for c in m.group(1)), s) + s = re.sub(r"\^([0-9+-])", lambda m: SUP.get(m.group(1), m.group(1)), s) + s = s.replace("{", "").replace("}", "").replace("~", " ").replace("\\,", " ").replace("\\;", " ") + return re.sub(r"\s+", " ", s).strip() + text = re.sub(r"\$\$([^$]+)\$\$", lambda m: fix(m.group(1)), text, flags=re.S) + text = re.sub(r"\$([^$\n]+)\$", lambda m: fix(m.group(1)), text) + return text + + +def linkedin_text(text: str) -> str: + """LinkedIn-safe: flatten math, md links → text (url), Slack mrkdwn → plain.""" + text = flatten_math(text) + # [text](url) → text (url) + text = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1 (\2)", text) + # Slack → text (url) / → url + text = re.sub(r"<(https?://[^|>]+)\|([^>]+)>", r"\2 (\1)", text) + text = re.sub(r"<(https?://[^>]+)>", r"\1", text) + # **bold** → bold (LinkedIn supports Unicode bold but plain is safer) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + return text.strip() + + +def load_token() -> dict | None: + if TOKEN_FILE.exists(): + try: + return json.loads(TOKEN_FILE.read_text()) + except Exception: + return None + return None + + +def save_token(data: dict) -> None: + TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) + TOKEN_FILE.write_text(json.dumps(data, indent=2)) + TOKEN_FILE.chmod(0o600) + + +def linkedin_api(method: str, path: str, token: str, body: dict | None = None) -> dict: + url = f"{API}{path}" + headers = { + "Authorization": f"Bearer {token}", + "X-Restli-Protocol-Version": "2.0.0", + "LinkedIn-Version": LINKEDIN_VERSION, + "Content-Type": "application/json", + } + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + body_text = e.read().decode()[:800] + die(f"{method} {path}: HTTP {e.code} — {body_text}") + except urllib.error.URLError as e: + die(f"{method} {path}: network — {e.reason}") + + +def _flag(args: list[str], name: str) -> str | None: + if name in args: + i = args.index(name) + if i + 1 >= len(args): + die(f"{name}: missing value") + return args[i + 1] + return None + + +def _has(args: list[str], name: str) -> bool: + return name in args + + +# ---------- commands ---------- + +def cmd_share(args: list[str]) -> None: + url = _flag(args, "--url") + if not url: + die("share: need --url ") + text = _flag(args, "--text") + no_open = _has(args, "--no-open") + params = {"url": url} + full = SHARE_URL + "?" + urllib.parse.urlencode(params) + if text: + # share-offsite only takes url; text is for manual paste — show it + print(linkedin_text(text)) + print() + print(f"share URL: {full}") + if not no_open and not _has(args, "--open"): + # default: open + try: + webbrowser.open(full) + print("opened in browser (use --no-open to suppress)") + except Exception: + pass + elif _has(args, "--open"): + webbrowser.open(full) + print("opened in browser") + + +def cmd_auth(args: list[str]) -> None: + client_id = _flag(args, "--client-id") or os.environ.get("LINKEDIN_CLIENT_ID", "") + client_secret = _flag(args, "--client-secret") or os.environ.get("LINKEDIN_CLIENT_SECRET", "") + redirect = _flag(args, "--redirect") or os.environ.get("LINKEDIN_REDIRECT_URI", "http://localhost:8080/callback") + org_hint = _flag(args, "--org") + if not client_id or not client_secret: + die("auth: need --client-id and --client-secret (or env LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET)\n" + " Create app at https://developer.linkedin.com/ → Products: Share on LinkedIn + Community Management API\n" + " Scopes needed: w_organization_social r_organization_social r_organization_admin") + scope = "w_organization_social r_organization_social r_organization_admin w_member_social" + state = os.urandom(8).hex() + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect, + "scope": scope, + "state": state, + } + auth_full = AUTH_URL + "?" + urllib.parse.urlencode(params) + print(f"1. Opening browser for LinkedIn OAuth (Super Admin consent):\n {auth_full}\n") + print(f" Redirect must be registered in your LinkedIn app: {redirect}\n") + try: + webbrowser.open(auth_full) + except Exception: + pass + + # Simple local callback server to capture ?code= + code_holder: dict = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + qs = urllib.parse.parse_qs(parsed.query) + if "code" in qs: + code_holder["code"] = qs["code"][0] + code_holder["state"] = qs.get("state", [""])[0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Amico LinkedIn: authorized

You can close this tab and return to the terminal.

") + else: + self.send_response(400) + self.end_headers() + self.wfile.write(b"Missing code") + def log_message(self, *a, **k): + pass + + parsed_redir = urllib.parse.urlparse(redirect) + port = parsed_redir.port or 8080 + path = parsed_redir.path or "/callback" + print(f"2. Waiting for callback on http://localhost:{port}{path} ... (Ctrl-C to cancel, or paste ?code= manually)") + # If redirect is not localhost, just ask for paste + if parsed_redir.hostname not in ("localhost", "127.0.0.1"): + code = input("Paste the ?code= value from the redirect URL: ").strip() + code_holder["code"] = code + else: + try: + with http.server.HTTPServer(("127.0.0.1", port), Handler) as httpd: + httpd.timeout = 180 + httpd.handle_request() + except OSError as e: + die(f"callback server on :{port} failed: {e} — paste ?code= manually") + return + if "code" not in code_holder: + pasted = input("No callback received — paste ?code= if you have it: ").strip() + if pasted: + code_holder["code"] = pasted + else: + die("no code received — re-run auth and ensure redirect URI matches app settings") + + code = code_holder["code"] + # Exchange code for token + data = urllib.parse.urlencode({ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect, + "client_id": client_id, + "client_secret": client_secret, + }).encode() + req = urllib.request.Request(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + tok = json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + die(f"token exchange: HTTP {e.code} — {e.read().decode()[:800]}") + access = tok.get("access_token") + expires_in = tok.get("expires_in", 3600) + if not access: + die(f"token exchange: no access_token in response: {tok}") + import time + expires_at = int(time.time()) + int(expires_in) + # Resolve org URN if not hinted: try organizationAcls + org_urn = org_hint + if not org_urn: + try: + acls = linkedin_api("GET", "/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR", access) + elems = acls.get("elements", []) + if elems: + org_urn = elems[0].get("organization", "") + if org_urn and not org_urn.startswith("urn:li:organization:"): + org_urn = f"urn:li:organization:{org_urn.split(':')[-1]}" + print(f"Resolved org: {org_urn} ({len(elems)} admin org(s) found)") + if len(elems) > 1: + for e in elems[:5]: + print(f" - {e.get('organization')} role={e.get('role')}") + else: + print("note: no admin org found via organizationAcls — pass --org urn:li:organization:XXXX") + except SystemExit: + print("note: org lookup failed — save token anyway; set org via auth --org or edit token.json") + save_token({ + "access_token": access, + "expires_in": expires_in, + "expires_at": expires_at, + "refresh_token": tok.get("refresh_token"), + "scope": tok.get("scope"), + "org_urn": org_urn, + "client_id": client_id, + "redirect_uri": redirect, + }) + print(f"saved → {TOKEN_FILE} (expires in {expires_in//3600}h, at {expires_at})") + if org_urn: + print(f"org: {org_urn}") + print("next: amico-linkedin status") + + +def cmd_status(_args: list[str]) -> None: + tok = load_token() + if not tok: + die(f"not authed — no {TOKEN_FILE}\n run: amico-linkedin auth --client-id ... --client-secret ...") + import time + now = int(time.time()) + exp = tok.get("expires_at", 0) + remain = exp - now if exp else 0 + org = tok.get("org_urn") or "(no org — re-run auth --org urn:li:organization:XXXX)" + scope = tok.get("scope", "") + print(f"token: {TOKEN_FILE}") + print(f"org: {org}") + print(f"scope: {scope}") + if exp: + hrs = remain / 3600 + warn = " ⚠ expires soon — re-auth" if remain < 7*24*3600 else "" + print(f"expires_at: {exp} ({hrs:.1f}h remaining){warn}") + if remain <= 0: + print(" token expired — re-run amico-linkedin auth") + # Try a live check + access = tok.get("access_token", "") + if access and org.startswith("urn:li:organization:"): + oid = org.split(":")[-1] + try: + me = linkedin_api("GET", f"/rest/organizations/{oid}", access) + print(f"org name: {me.get('localizedName') or me.get('name') or '(ok)'}") + except SystemExit: + print("(live org check failed — token may be expired or missing r_organization_admin)") + elif access: + try: + acls = linkedin_api("GET", "/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR", access) + print(f"admin orgs: {len(acls.get('elements', []))}") + except SystemExit: + pass + + +def cmd_draft(args: list[str]) -> None: + from_note = _flag(args, "--from-note") + url = _flag(args, "--url") + image = _flag(args, "--image") + out = _flag(args, "--out") + if not from_note and not url: + die("draft: need --from-note and/or --url ") + parts: list[str] = [] + if from_note: + p = Path(from_note) + if not p.exists(): + die(f"draft: no such note: {from_note}") + raw = p.read_text() + # Strip YAML frontmatter if present + if raw.startswith("---"): + end = raw.find("\n---", 3) + if end != -1: + raw = raw[end+4:].lstrip() + # Strip HTML comments (caveat blocks) — they are for first-comment drafts, not main post + raw = re.sub(r"", "", raw, flags=re.S) + raw = raw.strip() + parts.append(raw) + if url: + # Avoid duplicating URL if note already contains it + note_has_url = url in parts[0] if parts else False + if not note_has_url: + parts.append(url.strip()) + body = "\n\n".join(parts) + body = linkedin_text(body) + # Collapse excessive blank lines + body = re.sub(r"\n{3,}", "\n\n", body) + # Enforce LinkedIn limits + LIMIT = 3000 + if len(body) > LIMIT: + print(f"warn: draft is {len(body)} chars > {LIMIT} — truncating preview, edit before post", file=sys.stderr) + if out: + op = Path(out) + op.write_text(body) + print(f"draft → {op} ({len(body)} chars)") + if image: + print(f"image: {image} (attach via post when media upload is wired)") + print(f"next: amico-linkedin preview {op}") + else: + print(body) + + +def cmd_preview(args: list[str]) -> None: + pos = [a for a in args if not a.startswith("-")] + if not pos: + die("preview: need ") + p = Path(pos[0]) + if not p.exists(): + die(f"preview: no such file: {p}") + text = p.read_text() + print(f"--- preview: {p} ---") + print(text) + print(f"--- /preview ---") + print(f"length: {len(text)} / 3000 chars") + if len(text) > 3000: + print("OVER LIMIT — must shorten before post", file=sys.stderr) + elif len(text) > 2800: + print("warn: near limit") + # Check token + tok = load_token() + if tok and tok.get("org_urn"): + print(f"will post as: {tok['org_urn']}") + else: + print("auth: not authed — post will fail; share fallback is: amico-linkedin share --url ") + + +def cmd_post(args: list[str]) -> None: + pos = [a for a in args if not a.startswith("-")] + if not pos: + die("post: need ") + if "--confirm" not in args: + die("post: refusing without --confirm (safety gate)\n re-run: amico-linkedin post --confirm") + p = Path(pos[0]) + if not p.exists(): + die(f"post: no such file: {p}") + tok = load_token() + if not tok or not tok.get("access_token"): + die(f"post: not authed — {TOKEN_FILE} missing\n run: amico-linkedin auth --client-id ... --client-secret ...") + org = tok.get("org_urn") or _flag(args, "--org") + if not org: + die("post: no org_urn in token — re-run auth --org urn:li:organization:XXXX or pass --org") + text = linkedin_text(p.read_text()) + if len(text) > 3000: + die(f"post: draft is {len(text)} chars > 3000 — shorten before posting") + access = tok["access_token"] + body = { + "author": org, + "lifecycleState": "PUBLISHED", + "visibility": "PUBLIC", + "commentary": text, + "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []}, + } + # For link unfurl, append URL as commentary already contains it; media upload is next slice + extra_image = _flag(args, "--image") + if extra_image: + print(f"note: --image {extra_image} — media upload not yet wired (V1 text-only); posting text + URL", file=sys.stderr) + print(f"posting as {org} ({len(text)} chars) ...") + resp = linkedin_api("POST", "/rest/posts", access, body) + urn = resp.get("id") or resp.get("urn") or resp.get("post") or json.dumps(resp)[:200] + print(f"posted → {urn}") + # Write back urn to draft frontmatter-ish trailer + trailer = f"\n\n\n" + try: + p.write_text(p.read_text() + trailer) + except Exception: + pass + + +def cmd_delete(args: list[str]) -> None: + pos = [a for a in args if not a.startswith("-")] + if not pos: + die("delete: need ") + if "--confirm" not in args: + die("delete: refusing without --confirm") + urn = pos[0] + tok = load_token() + if not tok: + die("delete: not authed") + # urn is like urn:li:share:xxx or urn:li:post:xxx — map to /rest/posts/{id} + pid = urn.split(":")[-1] + linkedin_api("DELETE", f"/rest/posts/{pid}", tok["access_token"]) + print(f"deleted {urn}") + + +USAGE = """usage: amico-linkedin … + share --url [--text ] [--no-open] + auth --client-id --client-secret [--redirect ] [--org ] + status + draft --from-note --url [--image ] [--out ] + preview + post --confirm [--org ] [--image ] + delete --confirm +""" + +def main(): + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help", "help"): + print(USAGE) + return + cmd, rest = args[0], args[1:] + { + "share": cmd_share, + "auth": cmd_auth, + "status": cmd_status, + "draft": cmd_draft, + "preview": cmd_preview, + "post": cmd_post, + "delete": cmd_delete, + }.get(cmd, lambda _a: die(f"unknown command '{cmd}'\n{USAGE}"))(rest) + +if __name__ == "__main__": + main() From 9ac2a0de9cdd4b43bc8a565c0527e3a28813d5a2 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 9 Aug 2026 11:02:26 -0400 Subject: [PATCH 2/2] =?UTF-8?q?remove=20amico-linkedin=20from=20public=20r?= =?UTF-8?q?epo=20=E2=80=94=20migrated=20to=20private=20armonissima=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public PR amicode#317 (amico-linkedin-316 branch) is fully private now: skill lives at armonissima/skills/amico-linkedin (surface:internal, cli_tool ~/.local/bin/amico-linkedin), not in harmoniqs/amicode. CLI remains at ~/.local/bin/amico-linkedin, token at ~/.amico/linkedin/token.json. Closing public PR; see armonissima@4091415. --- .../extension/skills/amico-linkedin/SKILL.md | 82 --- tools/amico-linkedin | 476 ------------------ 2 files changed, 558 deletions(-) delete mode 100644 packages/extension/skills/amico-linkedin/SKILL.md delete mode 100755 tools/amico-linkedin diff --git a/packages/extension/skills/amico-linkedin/SKILL.md b/packages/extension/skills/amico-linkedin/SKILL.md deleted file mode 100644 index bf3668f..0000000 --- a/packages/extension/skills/amico-linkedin/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: amico-linkedin -description: Publish Amicode public announcements to the Harmoniqs LinkedIn Company Page — draft→preview→post via the LinkedIn API, plus no-auth share-offsite fallback. Internal tool. -agents: [researcher, pulse-designer, librarian] -surface: public -cli_tool: ~/.local/bin/amico-linkedin ---- - -# LinkedIn Publishing Skill (`amico-linkedin`) - -Internal tool to advertise Amicode public wins from within Amicode. First flight: Amicode launch announcement (Kimi K3 × `[[625,50,3]]` $g=0.72$, Pasqal + Microsoft hackathon, Unitary Foundation QEC Challenge). Mirrors `amico-slack` (Python CLI + `~/.amico/linkedin/token.json`, `--confirm` gate). - -## When to use - -- Drafting or posting a Harmoniqs Company Page announcement from Amicode. -- Sharing a QEC Challenge code / research note / vault note to LinkedIn. -- Checking LinkedIn org auth status before a campaign. - -## Core Rules - -1. **No silent posts.** `post` requires `--confirm` and an explicit org URN check. Drafts are local vault notes until confirmed. -2. **Honest claims.** `[[625,50,3]]` $d$ is `upper_bound` (witness-backed, CryptoMiniSat cert), $g$ inherits it, novelty not audited, seeded torics not raced — caveat in thread comment. -3. **LaTeX → Unicode.** LinkedIn has no LaTeX — flatten math the same way `amico-slack` does (`$\times$`→`×`, `$g=0.72$`→`g = 0.72`, `$r=\sqrt{2}$`→`r = √2`). -4. **Links are bare URLs** on LinkedIn (no Slack ``). Convert `[text](url)` → `text (url)` or bare `url`. -5. **Token is secret.** `~/.amico/linkedin/token.json` is gitignored, never echoed. Refresh every ~60 days; `status` warns at 7 days. - -## CLI Reference - -| Task | Command | -|---|---| -| Share as self (no auth, works today) | `amico-linkedin share --url [--text ]` | -| Start OAuth as Super Admin | `amico-linkedin auth --client-id --client-secret [--redirect http://localhost:8080/callback]` | -| Check org + token expiry | `amico-linkedin status` | -| Draft from vault note / URL | `amico-linkedin draft --from-note --url [--image ] [--out ]` | -| Preview (char count, image) | `amico-linkedin preview ` | -| Post as Harmoniqs (gated) | `amico-linkedin post --confirm` | -| Delete test post | `amico-linkedin delete ` | - -## Draft → Post flow (V1) - -```bash -# 0. No-auth path — works today (posts as YOU, not as Harmoniqs) -amico-linkedin share --url https://unitaryfoundation.github.io/qldpc-challenge/codes/625-50-3.html - -# 1. One-time auth as Super Admin (Aaron) — stores ~/.amico/linkedin/token.json -amico-linkedin auth --client-id $LINKEDIN_CLIENT_ID --client-secret $LINKEDIN_CLIENT_SECRET - -# 2. Verify -amico-linkedin status - -# 3. Draft from vault note + QEC URL -amico-linkedin draft --from-note ~/armonia/data/vaults/vault-aaron/notes/linkedin-amicode-launch.md \ - --url https://unitaryfoundation.github.io/qldpc-challenge/codes/625-50-3.html \ - --out /tmp/linkedin-draft.md - -# 4. Preview -amico-linkedin preview /tmp/linkedin-draft.md - -# 5. Post as Harmoniqs (confirm gate) -amico-linkedin post /tmp/linkedin-draft.md --confirm -``` - -## First post — Amicode launch framing - -This is the first public announcement of **Amicode** on the Harmoniqs LinkedIn page. Frame Amicode as the tool for *many* things (pulse design, QEC code search, Pasqal cloud compilation, multi-platform optimal control), not a single-code brag. Lead with Amicode, then proof points: - -- **Amicode** — Kimi K3-class agentic harness for quantum design (Piccolo/Piccolissimo + vault + catalog). -- **Pasqal + Microsoft hackathon (2026-07-29)** — Amicode × cloud-pasqal tutorials, pulser emulator → hardware, joint GTM. -- **Unitary Foundation QEC Challenge** — `[[625,50,3]]` holey rotated surface code, $g=0.72$ at $r=\sqrt{2}$ packing floor (top submitted geometric efficiency, $w\le4$ 2D-local single), verifier-passed, `L=13/19/25` family $0.533\to0.648\to0.720$ — proof Amicode + Kimi K3 ships verifiable research artifacts. - -Assets: verifier layout (r=√2 hero) + g progression chart. CTA: challenge page + research note. Honest caveat in first comment. - -## LinkedIn API notes (for `auth`/`post`) - -- OAuth: `https://www.linkedin.com/oauth/v2/authorization` + `accessToken`, scopes `w_organization_social r_organization_social` (and `w_member_social` for share fallback if needed). -- Posts (new): `POST https://api.linkedin.com/rest/posts` with headers `X-Restli-Protocol-Version: 2.0.0`, `LinkedIn-Version: 202401` (or latest), `Authorization: Bearer `. Body: `author: urn:li:organization:{id}`, `lifecycleState: PUBLISHED`, `visibility: PUBLIC`, `commentary` (text) + optional `content.media`. -- Org lookup: `GET https://api.linkedin.com/rest/organizations/{id}` or `GET /rest/organizationAcls?q=roleAssignee` to resolve URN from authenticated member. -- Token at `~/.amico/linkedin/token.json`: `{access_token, expires_at, refresh_token, org_urn, client_id}`. Never commit. - -## Vault contract - -Drafts reuse `notes/` as `type: note` with `tags: [announcement, linkedin]` + frontmatter `linkedin: {org, url, draft_id, image}`. No new folder for V1 (avoids `vault_contract` churn); promote to `team` via `visibility: team` + dream-promote PR when needed. If a typed `announcement` note type is added later, update `amico-vault` frontmatter in same PR (lint `tests/lint_vault_contract.sh` enforces both directions). diff --git a/tools/amico-linkedin b/tools/amico-linkedin deleted file mode 100755 index 91f2d88..0000000 --- a/tools/amico-linkedin +++ /dev/null @@ -1,476 +0,0 @@ -#!/usr/bin/env python3 -"""amico-linkedin — Amicode's LinkedIn CLI (Harmoniqs Company Page). - -Internal tool for Amicode public announcements. Day-0: share-offsite (no auth). -Full path: OAuth as Org Super Admin + POST /rest/posts (w_organization_social). - - share --url [--text ] [--open/--no-open] - auth --client-id --client-secret [--redirect ...] [--org ] - status - draft --from-note --url [--image ] [--out ] - preview - post --confirm - delete [--confirm] - -Token: ~/.amico/linkedin/token.json (gitignored, never committed) -Skill: packages/extension/skills/amico-linkedin/SKILL.md -""" -from __future__ import annotations - -import http.server -import json -import os -import re -import sys -import urllib.error -import urllib.parse -import urllib.request -import webbrowser -from pathlib import Path - -API = "https://api.linkedin.com" -AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization" -TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" -TOKEN_FILE = Path.home() / ".amico" / "linkedin" / "token.json" -LINKEDIN_VERSION = os.environ.get("LINKEDIN_VERSION", "202401") -SHARE_URL = "https://www.linkedin.com/sharing/share-offsite/" - -UNICODE_MAP = { - r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\delta": "δ", - r"\epsilon": "ε", r"\theta": "θ", r"\lambda": "λ", r"\mu": "μ", - r"\pi": "π", r"\sigma": "σ", r"\phi": "φ", r"\omega": "ω", - r"\Omega": "Ω", r"\Delta": "Δ", r"\hbar": "ℏ", r"\infty": "∞", - r"\times": "×", r"\pm": "±", r"\leq": "≤", r"\geq": "≥", r"\neq": "≠", - r"\approx": "≈", r"\propto": "∝", r"\rightarrow": "→", r"\leftarrow": "←", - r"\langle": "⟨", r"\rangle": "⟩", r"\sqrt": "√", r"\cdot": "·", r"\rho": "ρ", -} -SUP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", - "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "-": "⁻", "+": "⁺"} - - -def die(msg: str, code: int = 1) -> None: - print(f"amico-linkedin: {msg}", file=sys.stderr) - sys.exit(code) - - -def flatten_math(text: str) -> str: - def fix(span: str) -> str: - s = span - for k, v in UNICODE_MAP.items(): - s = s.replace(k, v) - s = re.sub(r"\\[a-zA-Z]+", lambda m: m.group(0)[1:], s) - s = re.sub(r"\^\{([^}]*)\}", lambda m: "".join(SUP.get(c, c) for c in m.group(1)), s) - s = re.sub(r"\^([0-9+-])", lambda m: SUP.get(m.group(1), m.group(1)), s) - s = s.replace("{", "").replace("}", "").replace("~", " ").replace("\\,", " ").replace("\\;", " ") - return re.sub(r"\s+", " ", s).strip() - text = re.sub(r"\$\$([^$]+)\$\$", lambda m: fix(m.group(1)), text, flags=re.S) - text = re.sub(r"\$([^$\n]+)\$", lambda m: fix(m.group(1)), text) - return text - - -def linkedin_text(text: str) -> str: - """LinkedIn-safe: flatten math, md links → text (url), Slack mrkdwn → plain.""" - text = flatten_math(text) - # [text](url) → text (url) - text = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1 (\2)", text) - # Slack → text (url) / → url - text = re.sub(r"<(https?://[^|>]+)\|([^>]+)>", r"\2 (\1)", text) - text = re.sub(r"<(https?://[^>]+)>", r"\1", text) - # **bold** → bold (LinkedIn supports Unicode bold but plain is safer) - text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) - return text.strip() - - -def load_token() -> dict | None: - if TOKEN_FILE.exists(): - try: - return json.loads(TOKEN_FILE.read_text()) - except Exception: - return None - return None - - -def save_token(data: dict) -> None: - TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) - TOKEN_FILE.write_text(json.dumps(data, indent=2)) - TOKEN_FILE.chmod(0o600) - - -def linkedin_api(method: str, path: str, token: str, body: dict | None = None) -> dict: - url = f"{API}{path}" - headers = { - "Authorization": f"Bearer {token}", - "X-Restli-Protocol-Version": "2.0.0", - "LinkedIn-Version": LINKEDIN_VERSION, - "Content-Type": "application/json", - } - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=30) as r: - raw = r.read().decode() - return json.loads(raw) if raw else {} - except urllib.error.HTTPError as e: - body_text = e.read().decode()[:800] - die(f"{method} {path}: HTTP {e.code} — {body_text}") - except urllib.error.URLError as e: - die(f"{method} {path}: network — {e.reason}") - - -def _flag(args: list[str], name: str) -> str | None: - if name in args: - i = args.index(name) - if i + 1 >= len(args): - die(f"{name}: missing value") - return args[i + 1] - return None - - -def _has(args: list[str], name: str) -> bool: - return name in args - - -# ---------- commands ---------- - -def cmd_share(args: list[str]) -> None: - url = _flag(args, "--url") - if not url: - die("share: need --url ") - text = _flag(args, "--text") - no_open = _has(args, "--no-open") - params = {"url": url} - full = SHARE_URL + "?" + urllib.parse.urlencode(params) - if text: - # share-offsite only takes url; text is for manual paste — show it - print(linkedin_text(text)) - print() - print(f"share URL: {full}") - if not no_open and not _has(args, "--open"): - # default: open - try: - webbrowser.open(full) - print("opened in browser (use --no-open to suppress)") - except Exception: - pass - elif _has(args, "--open"): - webbrowser.open(full) - print("opened in browser") - - -def cmd_auth(args: list[str]) -> None: - client_id = _flag(args, "--client-id") or os.environ.get("LINKEDIN_CLIENT_ID", "") - client_secret = _flag(args, "--client-secret") or os.environ.get("LINKEDIN_CLIENT_SECRET", "") - redirect = _flag(args, "--redirect") or os.environ.get("LINKEDIN_REDIRECT_URI", "http://localhost:8080/callback") - org_hint = _flag(args, "--org") - if not client_id or not client_secret: - die("auth: need --client-id and --client-secret (or env LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET)\n" - " Create app at https://developer.linkedin.com/ → Products: Share on LinkedIn + Community Management API\n" - " Scopes needed: w_organization_social r_organization_social r_organization_admin") - scope = "w_organization_social r_organization_social r_organization_admin w_member_social" - state = os.urandom(8).hex() - params = { - "response_type": "code", - "client_id": client_id, - "redirect_uri": redirect, - "scope": scope, - "state": state, - } - auth_full = AUTH_URL + "?" + urllib.parse.urlencode(params) - print(f"1. Opening browser for LinkedIn OAuth (Super Admin consent):\n {auth_full}\n") - print(f" Redirect must be registered in your LinkedIn app: {redirect}\n") - try: - webbrowser.open(auth_full) - except Exception: - pass - - # Simple local callback server to capture ?code= - code_holder: dict = {} - - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - parsed = urllib.parse.urlparse(self.path) - qs = urllib.parse.parse_qs(parsed.query) - if "code" in qs: - code_holder["code"] = qs["code"][0] - code_holder["state"] = qs.get("state", [""])[0] - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(b"

Amico LinkedIn: authorized

You can close this tab and return to the terminal.

") - else: - self.send_response(400) - self.end_headers() - self.wfile.write(b"Missing code") - def log_message(self, *a, **k): - pass - - parsed_redir = urllib.parse.urlparse(redirect) - port = parsed_redir.port or 8080 - path = parsed_redir.path or "/callback" - print(f"2. Waiting for callback on http://localhost:{port}{path} ... (Ctrl-C to cancel, or paste ?code= manually)") - # If redirect is not localhost, just ask for paste - if parsed_redir.hostname not in ("localhost", "127.0.0.1"): - code = input("Paste the ?code= value from the redirect URL: ").strip() - code_holder["code"] = code - else: - try: - with http.server.HTTPServer(("127.0.0.1", port), Handler) as httpd: - httpd.timeout = 180 - httpd.handle_request() - except OSError as e: - die(f"callback server on :{port} failed: {e} — paste ?code= manually") - return - if "code" not in code_holder: - pasted = input("No callback received — paste ?code= if you have it: ").strip() - if pasted: - code_holder["code"] = pasted - else: - die("no code received — re-run auth and ensure redirect URI matches app settings") - - code = code_holder["code"] - # Exchange code for token - data = urllib.parse.urlencode({ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect, - "client_id": client_id, - "client_secret": client_secret, - }).encode() - req = urllib.request.Request(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - try: - with urllib.request.urlopen(req, timeout=30) as r: - tok = json.loads(r.read().decode()) - except urllib.error.HTTPError as e: - die(f"token exchange: HTTP {e.code} — {e.read().decode()[:800]}") - access = tok.get("access_token") - expires_in = tok.get("expires_in", 3600) - if not access: - die(f"token exchange: no access_token in response: {tok}") - import time - expires_at = int(time.time()) + int(expires_in) - # Resolve org URN if not hinted: try organizationAcls - org_urn = org_hint - if not org_urn: - try: - acls = linkedin_api("GET", "/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR", access) - elems = acls.get("elements", []) - if elems: - org_urn = elems[0].get("organization", "") - if org_urn and not org_urn.startswith("urn:li:organization:"): - org_urn = f"urn:li:organization:{org_urn.split(':')[-1]}" - print(f"Resolved org: {org_urn} ({len(elems)} admin org(s) found)") - if len(elems) > 1: - for e in elems[:5]: - print(f" - {e.get('organization')} role={e.get('role')}") - else: - print("note: no admin org found via organizationAcls — pass --org urn:li:organization:XXXX") - except SystemExit: - print("note: org lookup failed — save token anyway; set org via auth --org or edit token.json") - save_token({ - "access_token": access, - "expires_in": expires_in, - "expires_at": expires_at, - "refresh_token": tok.get("refresh_token"), - "scope": tok.get("scope"), - "org_urn": org_urn, - "client_id": client_id, - "redirect_uri": redirect, - }) - print(f"saved → {TOKEN_FILE} (expires in {expires_in//3600}h, at {expires_at})") - if org_urn: - print(f"org: {org_urn}") - print("next: amico-linkedin status") - - -def cmd_status(_args: list[str]) -> None: - tok = load_token() - if not tok: - die(f"not authed — no {TOKEN_FILE}\n run: amico-linkedin auth --client-id ... --client-secret ...") - import time - now = int(time.time()) - exp = tok.get("expires_at", 0) - remain = exp - now if exp else 0 - org = tok.get("org_urn") or "(no org — re-run auth --org urn:li:organization:XXXX)" - scope = tok.get("scope", "") - print(f"token: {TOKEN_FILE}") - print(f"org: {org}") - print(f"scope: {scope}") - if exp: - hrs = remain / 3600 - warn = " ⚠ expires soon — re-auth" if remain < 7*24*3600 else "" - print(f"expires_at: {exp} ({hrs:.1f}h remaining){warn}") - if remain <= 0: - print(" token expired — re-run amico-linkedin auth") - # Try a live check - access = tok.get("access_token", "") - if access and org.startswith("urn:li:organization:"): - oid = org.split(":")[-1] - try: - me = linkedin_api("GET", f"/rest/organizations/{oid}", access) - print(f"org name: {me.get('localizedName') or me.get('name') or '(ok)'}") - except SystemExit: - print("(live org check failed — token may be expired or missing r_organization_admin)") - elif access: - try: - acls = linkedin_api("GET", "/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR", access) - print(f"admin orgs: {len(acls.get('elements', []))}") - except SystemExit: - pass - - -def cmd_draft(args: list[str]) -> None: - from_note = _flag(args, "--from-note") - url = _flag(args, "--url") - image = _flag(args, "--image") - out = _flag(args, "--out") - if not from_note and not url: - die("draft: need --from-note and/or --url ") - parts: list[str] = [] - if from_note: - p = Path(from_note) - if not p.exists(): - die(f"draft: no such note: {from_note}") - raw = p.read_text() - # Strip YAML frontmatter if present - if raw.startswith("---"): - end = raw.find("\n---", 3) - if end != -1: - raw = raw[end+4:].lstrip() - # Strip HTML comments (caveat blocks) — they are for first-comment drafts, not main post - raw = re.sub(r"", "", raw, flags=re.S) - raw = raw.strip() - parts.append(raw) - if url: - # Avoid duplicating URL if note already contains it - note_has_url = url in parts[0] if parts else False - if not note_has_url: - parts.append(url.strip()) - body = "\n\n".join(parts) - body = linkedin_text(body) - # Collapse excessive blank lines - body = re.sub(r"\n{3,}", "\n\n", body) - # Enforce LinkedIn limits - LIMIT = 3000 - if len(body) > LIMIT: - print(f"warn: draft is {len(body)} chars > {LIMIT} — truncating preview, edit before post", file=sys.stderr) - if out: - op = Path(out) - op.write_text(body) - print(f"draft → {op} ({len(body)} chars)") - if image: - print(f"image: {image} (attach via post when media upload is wired)") - print(f"next: amico-linkedin preview {op}") - else: - print(body) - - -def cmd_preview(args: list[str]) -> None: - pos = [a for a in args if not a.startswith("-")] - if not pos: - die("preview: need ") - p = Path(pos[0]) - if not p.exists(): - die(f"preview: no such file: {p}") - text = p.read_text() - print(f"--- preview: {p} ---") - print(text) - print(f"--- /preview ---") - print(f"length: {len(text)} / 3000 chars") - if len(text) > 3000: - print("OVER LIMIT — must shorten before post", file=sys.stderr) - elif len(text) > 2800: - print("warn: near limit") - # Check token - tok = load_token() - if tok and tok.get("org_urn"): - print(f"will post as: {tok['org_urn']}") - else: - print("auth: not authed — post will fail; share fallback is: amico-linkedin share --url ") - - -def cmd_post(args: list[str]) -> None: - pos = [a for a in args if not a.startswith("-")] - if not pos: - die("post: need ") - if "--confirm" not in args: - die("post: refusing without --confirm (safety gate)\n re-run: amico-linkedin post --confirm") - p = Path(pos[0]) - if not p.exists(): - die(f"post: no such file: {p}") - tok = load_token() - if not tok or not tok.get("access_token"): - die(f"post: not authed — {TOKEN_FILE} missing\n run: amico-linkedin auth --client-id ... --client-secret ...") - org = tok.get("org_urn") or _flag(args, "--org") - if not org: - die("post: no org_urn in token — re-run auth --org urn:li:organization:XXXX or pass --org") - text = linkedin_text(p.read_text()) - if len(text) > 3000: - die(f"post: draft is {len(text)} chars > 3000 — shorten before posting") - access = tok["access_token"] - body = { - "author": org, - "lifecycleState": "PUBLISHED", - "visibility": "PUBLIC", - "commentary": text, - "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []}, - } - # For link unfurl, append URL as commentary already contains it; media upload is next slice - extra_image = _flag(args, "--image") - if extra_image: - print(f"note: --image {extra_image} — media upload not yet wired (V1 text-only); posting text + URL", file=sys.stderr) - print(f"posting as {org} ({len(text)} chars) ...") - resp = linkedin_api("POST", "/rest/posts", access, body) - urn = resp.get("id") or resp.get("urn") or resp.get("post") or json.dumps(resp)[:200] - print(f"posted → {urn}") - # Write back urn to draft frontmatter-ish trailer - trailer = f"\n\n\n" - try: - p.write_text(p.read_text() + trailer) - except Exception: - pass - - -def cmd_delete(args: list[str]) -> None: - pos = [a for a in args if not a.startswith("-")] - if not pos: - die("delete: need ") - if "--confirm" not in args: - die("delete: refusing without --confirm") - urn = pos[0] - tok = load_token() - if not tok: - die("delete: not authed") - # urn is like urn:li:share:xxx or urn:li:post:xxx — map to /rest/posts/{id} - pid = urn.split(":")[-1] - linkedin_api("DELETE", f"/rest/posts/{pid}", tok["access_token"]) - print(f"deleted {urn}") - - -USAGE = """usage: amico-linkedin … - share --url [--text ] [--no-open] - auth --client-id --client-secret [--redirect ] [--org ] - status - draft --from-note --url [--image ] [--out ] - preview - post --confirm [--org ] [--image ] - delete --confirm -""" - -def main(): - args = sys.argv[1:] - if not args or args[0] in ("-h", "--help", "help"): - print(USAGE) - return - cmd, rest = args[0], args[1:] - { - "share": cmd_share, - "auth": cmd_auth, - "status": cmd_status, - "draft": cmd_draft, - "preview": cmd_preview, - "post": cmd_post, - "delete": cmd_delete, - }.get(cmd, lambda _a: die(f"unknown command '{cmd}'\n{USAGE}"))(rest) - -if __name__ == "__main__": - main()