da9bd53cae
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 session 邊界。 ## inkstone/ISEP#93 —— 逾期和掛著沒人接的事會主動叫 - scripts/isep-nag 撈三種沒人會叫的事(逾期 milestone/等 leo 的票/掉在地上的棒子) - scripts/isep-notify 發 Telegram,而且**發不出去的時候不會安靜** - hooks/overdue-nag-guard.sh SessionStart 跑一次(不輪詢、不 fan-out、不掛 Actions) - hooks/tests/overdue-nag.test.sh 35 條,全程離線 實跑撈得出票上點名的那五個逾期 milestone(08-24 三個、08-26 兩個)。 沒東西可報時會說「查過了,沒有」——安靜跟壞掉長得一模一樣。 🔴 工單補的那個限制(今天實測出來的)已經處理: 「能不能發得出去」取決於這個 session 載到的 ISEP 是哪一版 (prod-write-guard v0.10.0 才認得出 notify_leo 不是部署,而 hook 註冊路徑 在 session 啟動當下就寫死了)。所以 isep-notify 會**先拿真的要送的那一則 去問這個 session 註冊的那支閘**,把判定寫成檔(誰都查得到),然後: · 放行 ⇒ 送,並驗內層 data.data.ok(外層 200 不算送到) · 會擋 ⇒ **不繞路**,改貼回票上並把原文印在眼前 用 git 歷史裡的真跡(c263866 那一版閘)測過「會擋」那條路。 ⛔ 實測發現通道本身現在是斷的:實例上找不到 notify_leo 工作流(404)。 不是閘、不是網路、不是金鑰。詳情與修法寫在 docs/TESTING.md 最後一段。 退路兩次都走通了(inkstone/ISEP#93 comment 5182、5184)。 ## inkstone/ISEP#89 —— wiki 太長時有人整理 - scripts/wiki-compress audit/plan/apply/verify/bench 五個動詞 - hooks/wiki-size-guard.sh SessionStart 點名太長的檔;寫檔時擋「沒走流程的壓縮」 - hooks/tests/wiki-compress.test.sh 25 條,全程離線、不碰真的 wiki 設計上最重要的一條:**只搬不改**,正文一個字都不動。 「合併同類、濃縮成一行」要重寫正文,而重寫的當下沒有人會發現弄丟了什麼。 所以機器只做「搬 + 目錄 + 標 ×N/↻」,合併留給人。 拿現在的 mistakes.md 複本實壓過(不動真的 wiki): 7,681 行 → 1,199 行 260 條一條都沒少(verify 用內文雜湊逐條對帳) 80 個查詢命中率 80/80,定位成本 2,956 → 131 行,**快 22.5 倍**(bench) verify 反向測過:真的弄丟一條時它抓得到(抓不到的對帳表比沒有更糟)。 ## 盤點數字 在這棵樹上當場數的,不是拿上一版加減推的: ls hooks/*.sh | wc -l → 55(was 53) grep -c '"command":' hooks.json → 71(was 68) plugin.json/README/hooks-inventory 三處同步改。 **版本號沒動**(0.11.0),待總管定版。
417 lines
19 KiB
Python
Executable File
417 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""isep-notify — 發一則 Telegram 給 leo,而且**發不出去的時候不會安靜**
|
||
(inkstone/ISEP#93)
|
||
|
||
━━ 這支存在的理由(2026-08-28 實測出來的限制)━━━━━━━━━━━━━━━━━━━━━━━━
|
||
通道本身早就有(`notify_leo`,一條 curl、不需金鑰,見頂層 wiki `agent-memory.md`)。
|
||
問題不在通道,在**閘**:
|
||
|
||
· `prod-write-guard.sh` 擋「打到線上實例的寫入型請求」,而發一則 Telegram
|
||
在它眼裡跟「部署一個工作流上線」長得一模一樣(同一個 named webhook 家族路徑)
|
||
· v0.10.0 已經修好——只放行 `notify_leo` 這**一個名字**
|
||
· **但 hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後,
|
||
要開一個**新** session 才會載到新那份
|
||
|
||
⇒ 所以「能不能發得出去」是**每個 session 各自不同的事實**,不是全域設定。
|
||
這支的工作就是:**先去問這個 session 的閘,再決定怎麼辦**,而不是先射再說。
|
||
|
||
🔴 **發不出去而靜默,等於沒做。** 三條出路,一條都不能省:
|
||
① 先問閘(`--gate`):把「這個 session 載到的是哪一版、它會不會擋」變成查得到的事實
|
||
② 閘說會擋 ⇒ **不送**(不繞路,見下),改走退路:貼回票上 + 大聲印在眼前
|
||
③ 送出去了也要驗**內層** `data.data.ok`——外層 200 不算送到(wiki 記過這個坑)
|
||
|
||
━━ 為什麼閘說會擋就不送,而不是「反正 python 打得出去」━━━━━━━━━━━━━━━
|
||
這支用 urllib 打 HTTP,`prod-write-guard` 掛在 `Bash` 上——**它看不到這支**。
|
||
也就是說:不問閘就送,一定送得出去。**而那正是不能做的事。**
|
||
|
||
leo 2026-08-11:「hook 要不讓它對正式環境做任何推送,不是某種推送」
|
||
leo(同期):「人就會學會繞過它,那它就等於不存在」
|
||
|
||
一支「繞過閘也照送」的工具,會讓那道閘對整條 python 路徑失效。
|
||
⇒ 本支**自己去問那道閘**,閘說不行就不行。這比 shell 那條路更嚴,不是更鬆。
|
||
|
||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
isep-notify --gate 只印閘的判定(不送、不碰網路)
|
||
isep-notify --text "…" 送一則
|
||
isep-notify --text-file <檔> 內文放檔案(長訊息用)
|
||
isep-notify --text "…" --fallback-ticket inkstone/ISEP#93
|
||
送不出去時貼回那張票
|
||
isep-notify --text "…" --dry-run 全流程走一遍,只是不真的送
|
||
|
||
離開碼:0 = leo 拿得到了(Telegram 送達,或退路留在票上)
|
||
1 = 兩條路都沒走通(這時 stderr 一定有一段紅字,不會安靜)
|
||
|
||
環境變數(都有預設,測試才需要動):
|
||
ISEP_NOTIFY_URL notify_leo 的 trigger 網址
|
||
ISEP_NOTIFY_IDENTITY 貼票時的【身份】欄
|
||
ISEP_NOTIFY_STATE_DIR 閘判定快取的位置(預設 ~/.claude/isep-nag)
|
||
ISEP_NOTIFY_GUARD 指定要問哪一支 prod-write-guard(測試用)
|
||
ISEP_NOTIFY_OFFLINE=1 不打任何網路(測試用)
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
NOTIFY_URL = os.environ.get(
|
||
"ISEP_NOTIFY_URL",
|
||
"https://arcrun-cypher-executor.leo21c.workers.dev"
|
||
"/webhooks/named/leo/notify_leo/trigger",
|
||
)
|
||
IDENTITY = os.environ.get("ISEP_NOTIFY_IDENTITY", "【身份】總管/inkstone/ISEP/-")
|
||
STATE_DIR = os.environ.get("ISEP_NOTIFY_STATE_DIR") or os.path.join(
|
||
os.path.expanduser("~"), ".claude", "isep-nag")
|
||
GITEA_HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
|
||
PROD_STAMP = "/tmp/.prod-write-ok"
|
||
GATE_FACT = "gate-verdict.json"
|
||
|
||
|
||
# ══ 一、這個 session 的閘是哪一份、哪一版 ═══════════════════════════════
|
||
#
|
||
# 🔴 這一段就是工單說的「**把『這個 session 的閘是哪一版』變成它自己查得到的事實**」。
|
||
# 三個來源,可信度由高到低——**而且一定把來源講出來**,
|
||
# 因為「我問到的那份」跟「這個 session 真的註冊的那份」可能不是同一個檔。
|
||
def find_guard():
|
||
"""回 (guard 路徑, plugin 根目錄, 來源說明, 可信度)。找不到回 (None, ...)。"""
|
||
cands = []
|
||
forced = os.environ.get("ISEP_NOTIFY_GUARD")
|
||
if forced:
|
||
# 測試用:直接指定要問哪一支(測「舊版會擋」時把歷史版本擺進來)
|
||
return forced, os.path.dirname(os.path.dirname(forced)), "ISEP_NOTIFY_GUARD 指定", "high"
|
||
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
||
if root:
|
||
# hook 裡跑:這就是**這個 session 真的註冊的那一份**,沒有第二種可能
|
||
cands.append((root, "CLAUDE_PLUGIN_ROOT(這個 session 註冊的那一份)", "high"))
|
||
remembered = _read_fact()
|
||
if remembered and remembered.get("plugin_root"):
|
||
cands.append((remembered["plugin_root"],
|
||
"SessionStart 當時記下來的那一份", "high"))
|
||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
cands.append((here, "本腳本自己所在的那一份(不一定是 session 註冊的)", "low"))
|
||
|
||
for r, why, conf in cands:
|
||
g = os.path.join(r, "hooks", "prod-write-guard.sh")
|
||
if os.path.isfile(g):
|
||
return g, r, why, conf
|
||
return None, None, "找不到任何 prod-write-guard.sh", "none"
|
||
|
||
|
||
def plugin_version(root):
|
||
try:
|
||
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
|
||
return json.load(f).get("version") or "未知"
|
||
except Exception:
|
||
return "未知"
|
||
|
||
|
||
def probe_gate(url=None):
|
||
"""拿**這一支真的要送的請求**去問閘:你會不會擋?
|
||
|
||
回一個 dict,永遠有 verdict:
|
||
pass 閘看過了,放行
|
||
block 閘會擋(=這個 session 載到的是舊版,或白名單被改窄了)
|
||
unknown 問不出來(沒有閘、戳記在場、腳本壞了)——**不會謊稱 pass**
|
||
"""
|
||
url = url or NOTIFY_URL
|
||
guard, root, why, conf = find_guard()
|
||
fact = {
|
||
"at": int(time.time()),
|
||
"url": url,
|
||
"guard": guard,
|
||
"plugin_root": root,
|
||
"source": why,
|
||
"confidence": conf,
|
||
"version": plugin_version(root) if root else "未知",
|
||
}
|
||
if not guard:
|
||
fact.update(verdict="unknown", reason="這個環境找不到 ISEP 的 prod-write-guard"
|
||
"(沒有閘 ⇒ 沒有東西被繞過)")
|
||
return fact
|
||
|
||
# 🔴 戳記在場就**不探針**:`prod-write-guard` 的戳記是**單次、用完即丟**的,
|
||
# 探一次就把總管剛按下去的那一次授權燒掉了。
|
||
# (而戳記在場本來就代表「現在允許寫」⇒ 送得出去,不必問。)
|
||
if os.path.exists(PROD_STAMP):
|
||
fact.update(verdict="unknown",
|
||
reason="/tmp/.prod-write-ok 在場:探針會把它燒掉(單次用完即丟),"
|
||
"所以不問。戳記在場=現在允許寫入")
|
||
return fact
|
||
|
||
payload = json.dumps({
|
||
"tool_name": "Bash",
|
||
"tool_input": {
|
||
"command": ("curl -s -X POST %s -H 'Content-Type: application/json' "
|
||
"-d '{\"text\":\"[總管] gate probe\"}'" % url),
|
||
},
|
||
})
|
||
try:
|
||
p = subprocess.run(["bash", guard], input=payload, capture_output=True,
|
||
text=True, timeout=30)
|
||
except Exception as e:
|
||
fact.update(verdict="unknown", reason="問不動那支閘:%s" % e)
|
||
return fact
|
||
|
||
if p.returncode == 0:
|
||
fact.update(verdict="pass", reason="閘看過這一則通知,放行")
|
||
elif p.returncode == 2:
|
||
fact.update(verdict="block",
|
||
reason="閘會擋。這個 session 載到的 ISEP 是 v%s——"
|
||
"它的 prod-write-guard 還認不出 notify_leo 不是部署"
|
||
% fact["version"],
|
||
gate_says=(p.stderr or "").strip()[:400])
|
||
else:
|
||
fact.update(verdict="unknown",
|
||
reason="那支閘回了離開碼 %d(既不是放行也不是擋)" % p.returncode)
|
||
return fact
|
||
|
||
|
||
def _fact_path():
|
||
return os.path.join(STATE_DIR, GATE_FACT)
|
||
|
||
|
||
def _read_fact():
|
||
try:
|
||
with open(_fact_path()) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def remember_fact(fact):
|
||
"""把判定寫成檔:之後任何一支腳本(含手動跑的)都查得到,不必自己再探一次。"""
|
||
try:
|
||
os.makedirs(STATE_DIR, exist_ok=True)
|
||
with open(_fact_path(), "w") as f:
|
||
json.dump(fact, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def gate_report(fact):
|
||
icon = {"pass": "🟢", "block": "🔴", "unknown": "🟡"}.get(fact["verdict"], "🟡")
|
||
lines = [
|
||
"%s 這個 session 的通知閘:**%s**" % (icon, fact["verdict"]),
|
||
" ISEP 版本:v%s" % fact.get("version", "未知"),
|
||
" 問的是哪一份:%s" % (fact.get("guard") or "(找不到)"),
|
||
" 來源可信度:%s(%s)" % (fact.get("confidence"), fact.get("source")),
|
||
" 理由:%s" % fact.get("reason", ""),
|
||
]
|
||
if fact["verdict"] == "block":
|
||
lines += [
|
||
"",
|
||
" ⇒ **這個 session 發不出 Telegram**,不是網路問題,是閘的版本問題。",
|
||
" ⇒ 解法(兩步,缺一不可):",
|
||
" claude plugin update isep@inkstone # 拿到 v0.10.0 以上",
|
||
" **開一個新的 session** # 註冊路徑是啟動當下寫死的",
|
||
" ⇒ 在那之前,本支一律走退路(貼回票上+印在眼前),不會安靜。",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ══ 二、真的送 ═══════════════════════════════════════════════════════════
|
||
def send_telegram(text):
|
||
"""回 (ok, 說明)。**內層 data.data.ok 才算送到**——外層 200 不算。"""
|
||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||
body = json.dumps({"text": text}).encode()
|
||
# 🔴 一定要帶 User-Agent(2026-08-28 實撞):不帶的話 urllib 送出的是
|
||
# `Python-urllib/3.x`,**Cloudflare 直接回 403 error code 1010**(bad user agent)。
|
||
# 症狀非常會騙人——wiki 說「一條 curl、不需任何金鑰」是對的,
|
||
# curl 自己帶 UA,所以同一個網址用 curl 通、用 python 不通
|
||
# ⇒ 會被誤讀成「通道壞了」或「這個 session 沒權限」,而兩個都不是。
|
||
req = urllib.request.Request(
|
||
NOTIFY_URL, data=body, method="POST",
|
||
headers={"Content-Type": "application/json",
|
||
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#93)"})
|
||
try:
|
||
raw = urllib.request.urlopen(req, timeout=30).read().decode()
|
||
except urllib.error.HTTPError as e:
|
||
return False, "HTTP %s:%s" % (e.code, e.read().decode()[:200])
|
||
except Exception as e:
|
||
return False, "連不上:%s" % e
|
||
try:
|
||
d = json.loads(raw)
|
||
except Exception:
|
||
return False, "回應不是 JSON:%s" % raw[:200]
|
||
# 🔴 wiki 記過這個坑:外層 success=true、內層 404 也發生過
|
||
inner = ((d.get("data") or {}).get("data") or {})
|
||
if inner.get("ok") is True:
|
||
return True, "送到了(message_id=%s)" % inner.get("message_id")
|
||
if "找不到 workflow" in raw or "not found" in raw.lower():
|
||
# 🔴 這一種要分開講:**閘沒擋、網路也通,是那台實例上根本沒有這支工作流**
|
||
# (wiki `agent-memory.md` 記過一次:2026-08-10 KV 整批換新時定義消失)。
|
||
# 講成「發不出去」會讓人去修錯的地方——修閘、修 token,而斷點不在那裡。
|
||
return False, ("通道本身斷了:實例上找不到 `notify_leo` 工作流。"
|
||
"**這不是閘擋的、也不是網路問題**——要有人把它重新 push 上去"
|
||
"(`mira` repo `workflows/notify-leo.yaml`)。原文:%s" % raw[:200])
|
||
return False, "外層通了但**內層沒 ok**:%s" % raw[:300]
|
||
|
||
|
||
# ══ 三、退路:貼回票上 ═══════════════════════════════════════════════════
|
||
def gitea_token():
|
||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||
v = os.environ.get(env)
|
||
if v:
|
||
return v
|
||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||
host = GITEA_HOST.split("//")[-1].rstrip("/")
|
||
try:
|
||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||
capture_output=True, text=True, timeout=20).stdout
|
||
except Exception:
|
||
out = ""
|
||
for line in out.splitlines():
|
||
if host in line:
|
||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||
if m:
|
||
return m.group(1)
|
||
return None
|
||
|
||
|
||
def comment_on_ticket(ref, text, gate_fact):
|
||
"""把那則發不出去的訊息貼回票上。回 (ok, 說明)。
|
||
|
||
🔴 **同一則不重複貼**:內容雜湊存在狀態目錄,24 小時內一樣就跳過。
|
||
票被洗版的下場跟發不出去一樣——leo 會學會不看它。
|
||
"""
|
||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
|
||
if not m:
|
||
return False, "票的寫法是 owner/repo#N,你給的是:%s" % ref
|
||
owner, repo, num = m.group(1), m.group(2), m.group(3)
|
||
|
||
key = hashlib.sha256(("%s|%s" % (ref, text)).encode()).hexdigest()[:16]
|
||
seen = os.path.join(STATE_DIR, "commented-%s" % key)
|
||
if os.path.exists(seen) and time.time() - os.path.getmtime(seen) < 24 * 3600:
|
||
return True, "24 小時內已經貼過一模一樣的內容,跳過(不洗版)"
|
||
|
||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||
tok = gitea_token()
|
||
if not tok:
|
||
return False, "拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"
|
||
|
||
body = "\n".join([
|
||
IDENTITY,
|
||
"",
|
||
"🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。",
|
||
"",
|
||
gate_report(gate_fact),
|
||
"",
|
||
"---",
|
||
"",
|
||
text,
|
||
])
|
||
url = "%s/api/v1/repos/%s/%s/issues/%s/comments" % (GITEA_HOST, owner, repo, num)
|
||
req = urllib.request.Request(
|
||
url, data=json.dumps({"body": body}).encode(), method="POST",
|
||
headers={"Authorization": "token %s" % tok,
|
||
"Content-Type": "application/json"})
|
||
try:
|
||
r = json.load(urllib.request.urlopen(req, timeout=40))
|
||
except urllib.error.HTTPError as e:
|
||
return False, "Gitea %s:%s" % (e.code, e.read().decode()[:200])
|
||
except Exception as e:
|
||
return False, "連不上 Gitea:%s" % e
|
||
try:
|
||
os.makedirs(STATE_DIR, exist_ok=True)
|
||
open(seen, "w").close()
|
||
except Exception:
|
||
pass
|
||
return True, "已貼到 %s(%s)" % (ref, r.get("html_url") or "")
|
||
|
||
|
||
# ══ 四、CLI ══════════════════════════════════════════════════════════════
|
||
def notify(text, fallback_ticket=None, dry_run=False):
|
||
"""回 (離開碼, 給人看的報告)。**任何一條路都會留下一段話,不會安靜。**"""
|
||
fact = probe_gate()
|
||
remember_fact(fact)
|
||
out = [gate_report(fact), ""]
|
||
|
||
if dry_run:
|
||
out.append("🧪 --dry-run:到此為止,什麼都沒送。要送的內容是:")
|
||
out.append("")
|
||
out.append(text)
|
||
return 0, "\n".join(out)
|
||
|
||
if fact["verdict"] == "block":
|
||
out.append("⛔ 閘說會擋 ⇒ **本支不繞路**,直接走退路。")
|
||
sent, why = False, "沒有嘗試(閘會擋,繞過去等於拆了那道閘)"
|
||
else:
|
||
sent, why = send_telegram(text)
|
||
out.append(("✅ Telegram:%s" if sent else "❌ Telegram:%s") % why)
|
||
|
||
if sent:
|
||
return 0, "\n".join(out)
|
||
|
||
# ── 退路 ──────────────────────────────────────────────────────────
|
||
if fallback_ticket:
|
||
ok, detail = comment_on_ticket(fallback_ticket, text, fact)
|
||
out.append(("✅ 退路(貼回票上):%s" if ok else "❌ 退路(貼回票上):%s") % detail)
|
||
else:
|
||
ok, detail = False, "沒有給 --fallback-ticket,沒有票可以貼"
|
||
out.append("⚠️ 退路(貼回票上):%s" % detail)
|
||
|
||
out += [
|
||
"",
|
||
"🔴 **leo 的手機上沒有出現這則。** 原文照抄在下面,"
|
||
"看到這段的人有義務把它講出去:",
|
||
"",
|
||
text,
|
||
]
|
||
return (0 if ok else 1), "\n".join(out)
|
||
|
||
|
||
def main(argv):
|
||
text, ticket, dry, want_json = None, None, False, False
|
||
mode = "notify"
|
||
i = 0
|
||
while i < len(argv):
|
||
a = argv[i]
|
||
if a == "--gate":
|
||
mode = "gate"
|
||
elif a == "--text":
|
||
i += 1; text = argv[i]
|
||
elif a == "--text-file":
|
||
i += 1
|
||
with open(argv[i]) as f:
|
||
text = f.read()
|
||
elif a == "--fallback-ticket":
|
||
i += 1; ticket = argv[i]
|
||
elif a == "--dry-run":
|
||
dry = True
|
||
elif a == "--json":
|
||
want_json = True
|
||
elif a in ("-h", "--help"):
|
||
print(__doc__)
|
||
return 0
|
||
else:
|
||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr)
|
||
return 2
|
||
i += 1
|
||
|
||
if mode == "gate":
|
||
fact = probe_gate()
|
||
remember_fact(fact)
|
||
print(json.dumps(fact, ensure_ascii=False, indent=2) if want_json
|
||
else gate_report(fact))
|
||
return 0
|
||
|
||
if not text or not text.strip():
|
||
print("🔴 沒有內容可以發(--text 或 --text-file)", file=sys.stderr)
|
||
return 2
|
||
if not text.lstrip().startswith("["):
|
||
text = "[總管] " + text.lstrip() # 署名鐵律:text 開頭必署名
|
||
|
||
code, report = notify(text, ticket, dry)
|
||
print(report, file=(sys.stdout if code == 0 else sys.stderr))
|
||
return code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv[1:]))
|