thth 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. thth/VERSION +1 -0
  2. thth/__init__.py +22 -0
  3. thth/__main__.py +18 -0
  4. thth/account_cli.py +474 -0
  5. thth/account_report.py +740 -0
  6. thth/accounts.example/bluesky.json +25 -0
  7. thth/accounts.example/mastodon.json +25 -0
  8. thth/accounts.example/threads.json +25 -0
  9. thth/accounts.py +418 -0
  10. thth/adapters/__init__.py +71 -0
  11. thth/adapters/base.py +314 -0
  12. thth/adapters/bluesky.py +772 -0
  13. thth/adapters/mastodon.py +705 -0
  14. thth/adapters/threads.py +580 -0
  15. thth/appenv.py +204 -0
  16. thth/approval.py +231 -0
  17. thth/ask.py +527 -0
  18. thth/ask_cli.py +142 -0
  19. thth/bundle.py +428 -0
  20. thth/cli.py +2572 -0
  21. thth/collect.py +897 -0
  22. thth/core.py +814 -0
  23. thth/doctor.py +281 -0
  24. thth/forms.py +255 -0
  25. thth/inflight.py +69 -0
  26. thth/jst.py +43 -0
  27. thth/lint.py +186 -0
  28. thth/lock.py +185 -0
  29. thth/maintain.py +294 -0
  30. thth/mcp_server.py +280 -0
  31. thth/measured.py +455 -0
  32. thth/oauth.py +718 -0
  33. thth/postid.py +74 -0
  34. thth/queuefile.py +199 -0
  35. thth/redact.py +41 -0
  36. thth/replies.py +175 -0
  37. thth/report.py +418 -0
  38. thth/runs.py +61 -0
  39. thth/scopes.py +22 -0
  40. thth/secrets_fs.py +68 -0
  41. thth/select.py +338 -0
  42. thth/selfupdate.py +516 -0
  43. thth/sent.py +129 -0
  44. thth/share.py +666 -0
  45. thth/share_cli.py +140 -0
  46. thth/skills/thth/SKILL.md +117 -0
  47. thth/systemd_gen.py +116 -0
  48. thth/threadrun.py +611 -0
  49. thth/threadshape.py +607 -0
  50. thth/threadthrow.py +553 -0
  51. thth/topic_advice.py +753 -0
  52. thth/topic_cli.py +2267 -0
  53. thth/topic_models.py +2248 -0
  54. thth/topic_store.py +418 -0
  55. thth/topics.py +802 -0
  56. thth/writeback.py +408 -0
  57. thth-2.0.0.dist-info/METADATA +95 -0
  58. thth-2.0.0.dist-info/RECORD +61 -0
  59. thth-2.0.0.dist-info/WHEEL +4 -0
  60. thth-2.0.0.dist-info/entry_points.txt +3 -0
  61. thth-2.0.0.dist-info/licenses/LICENSE +21 -0
thth/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.0.0
thth/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """THTH(ThreadsThrower)core パッケージ。"""
2
+ from __future__ import annotations
3
+
4
+ import os as _os
5
+
6
+ # **`VERSION` は 1 か所だけ**(設計 v1.0.0・Track C1)。`thth --version` と
7
+ # `thth board` の先頭がここを読む。パッケージと同じディレクトリに置く
8
+ # (`thth/accounts.py` の `APP_DIR` と同じ、`__file__` 基準の流儀)。
9
+ _VERSION_PATH = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "VERSION")
10
+
11
+
12
+ def _read_version() -> str:
13
+ try:
14
+ with open(_VERSION_PATH, encoding="utf-8") as f:
15
+ return f.read().strip()
16
+ except OSError:
17
+ # **VERSION が無くても import 自体は落とさない**(既存の 0.x には
18
+ # 無かった・設計 §0「いまの本番は版を持たない」)。
19
+ return "0.0.0"
20
+
21
+
22
+ __version__ = _read_version()
thth/__main__.py ADDED
@@ -0,0 +1,18 @@
1
+ """`python -m thth …` の入口(`bin/thth` と同じ `thth.cli.main` を呼ぶだけ)。
2
+
3
+ なぜ要るか(2026-09-12・C2 の乾式試験で踏んだ): まっさらな clone を置いた直後は
4
+ `bin/thth` に PATH が通っていない。そこで最初に打たれるのは `python -m thth doctor`
5
+ だが、`thth/__main__.py` が無いと **`'thth' is a package and cannot be directly
6
+ executed`** で落ちる。**道具が無いのではなく入口が無いだけ**なのに、そうは読めない。
7
+
8
+ `bin/thth` は残す(symlink して PATH に置く運用・`tests/test_cli_entrypoint.py`)。
9
+ ここは判断を持たず、`sys.path` にも触らない(`-m` で起動された時点で解決済み)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ from .cli import main
16
+
17
+ if __name__ == "__main__":
18
+ sys.exit(main(sys.argv[1:]))
thth/account_cli.py ADDED
@@ -0,0 +1,474 @@
1
+ """`thth account` の枝分かれ(`add`・`migrate`)。設計 v2 §3「台帳を repo の外へ」。
2
+
3
+ なぜ別 module か(2026-09-13・並行 Track との境界): `thth/cli.py` の
4
+ `build_parser()` は別の Track が同時に触る。ここに足す行を **1 行だけ**にして
5
+ 衝突を作らない。`build_parser()` は `account_cli.register(sub)` を呼ぶだけで、
6
+ `thth account` の枝は全部この file にある。
7
+
8
+ **`thth account` の形**(既存の呼び方を壊さない):
9
+
10
+ thth account 全アカウントの状態を一枚で(従来どおり)
11
+ thth account <name> 1 本の状態を一枚で(従来どおり)
12
+ thth account migrate [--dry-run] repo の中の台帳を $THTH_ROOT/accounts/ へ写す
13
+ thth account add <name> --media … --project … 雛形から 1 本書く
14
+
15
+ `migrate` と `add` は**予約語**。同じ名前のアカウントは持てない(`account` は
16
+ `<project>-<media>` の綴りなので、実際に当たることはない)。
17
+
18
+ argparse の subparsers を使わないのは、既存の `account` 位置引数(`nargs="?"`)と
19
+ subparsers が同じ位置を取り合って `thth account <name>` が壊れるため。位置引数を
20
+ 2 本にして、1 本目が予約語かどうかで振り分ける。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import sys
30
+
31
+ from . import accounts as accounts_mod
32
+
33
+ # 雛形の置き場。**`THTH_APP_DIR` を見ない**——雛形は道具に同梱されているもので、
34
+ # 台帳の置き場(差し替え可能)とは別。この module 自身の場所から引く。
35
+ #
36
+ # **2 か所を順に見る**(監査 2・2026-09-13)。前は「package の親/accounts.example」
37
+ # だけを見ていた。repo から走らせる分には当たるが、**`pip install thth` した人の
38
+ # 手元では package の親は `site-packages/` で、そこに雛形は無い**——
39
+ # `thth account add` が `雛形がありません: …/site-packages/accounts.example/threads.json`
40
+ # で rc=2 になっていた(wheel にも sdist にも雛形が入っていなかった)。
41
+ #
42
+ # (a) `thth/accounts.example/` … 配布物の中(`pyproject.toml` の `force-include`)
43
+ # (b) `../accounts.example/` … repo から走らせたとき(repo の置き場は変えない)
44
+ #
45
+ # **両方効かせる。** (a) だけにすると repo での開発が止まり、(b) だけにすると
46
+ # 配った先で止まる。
47
+ def _find_example_dir() -> str:
48
+ here = os.path.dirname(os.path.abspath(__file__))
49
+ 同梱 = os.path.join(here, "accounts.example")
50
+ if os.path.isdir(同梱):
51
+ return 同梱
52
+ return os.path.join(os.path.dirname(here), "accounts.example")
53
+
54
+
55
+ EXAMPLE_DIR = _find_example_dir()
56
+
57
+ MEDIA_CHOICES = ("threads", "bluesky", "mastodon")
58
+
59
+ # **アカウント名はファイル名になる**(`<accounts_dir>/<name>.json`)。区切りや
60
+ # `..` を混ぜると置き場の外に書けてしまう——`thth account add ../pwned` が
61
+ # `accounts/../pwned.json` を書いて rc=0 で終わり、`list_account_names()` には
62
+ # 出ないので board からも見えなかった(監査 1・P2-1)。`thth posts` が post_id を
63
+ # 検査するのと同じ守り方(`tests/test_posts.py::test_post_idにパス区切りがあれば書かない`)。
64
+ NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
65
+
66
+
67
+ def name_is_safe(name: str) -> bool:
68
+ """置き場の中の 1 ファイルに必ず収まる名前か。`.`・`..` は名前ではない。"""
69
+ return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..")
70
+
71
+ # `thth account` の 1 本目の位置引数が、アカウント名ではなく枝の名前になるもの。
72
+ VERBS = ("add", "migrate")
73
+
74
+
75
+ # --------------------------------------------------------------------------
76
+ # 置き場
77
+ # --------------------------------------------------------------------------
78
+
79
+ def target_accounts_dir() -> str:
80
+ """**書き込む先**。`$THTH_ACCOUNTS_DIR` があればそこ、無ければ
81
+ `$THTH_ROOT/accounts/`。
82
+
83
+ **repo の中(互換の (c))には絶対に書かない。** 読むほうは 1 版だけ互換を
84
+ 残すが(`accounts.accounts_dir_info()`)、道具が新しく台帳を作るときは常に
85
+ 外。そうしないと「外へ出す」作業のさなかに repo の中が増える。
86
+ """
87
+ # **相対パスはその場で絶対にする**(監査 1・P3・`accounts.env_accounts_dir()`)。
88
+ env = accounts_mod.env_accounts_dir()
89
+ return env if env else accounts_mod.root_accounts_dir()
90
+
91
+
92
+ def where_line() -> str:
93
+ """台帳の置き場を **1 行**で言う(`thth doctor`・`thth board` が出す)。"""
94
+ info = accounts_mod.accounts_dir_info()
95
+ source = info["source"]
96
+ if source == accounts_mod.SOURCE_APP_REPO:
97
+ return (f"台帳の置き場: {info['path']}(**repo の中・互換**。"
98
+ f"`thth account migrate` で {target_accounts_dir()} へ出してください)")
99
+ if source == accounts_mod.SOURCE_ENV:
100
+ return f"台帳の置き場: {info['path']}(${accounts_mod.ACCOUNTS_DIR_ENV})"
101
+ return f"台帳の置き場: {info['path']}($THTH_ROOT/accounts)"
102
+
103
+
104
+ # --------------------------------------------------------------------------
105
+ # thth account migrate
106
+ # --------------------------------------------------------------------------
107
+
108
+ def plan_migration() -> dict:
109
+ """repo の中の台帳を外へ写す計画を立てる(**何も変えない**)。
110
+
111
+ - `copy` … 外に無いので写すもの
112
+ - `same` … 外に同じ中身で既にあるもの(=写し済み・何もしない)
113
+ - `differ` … 外にあるが**中身が違う**もの(**上書きしない**・名指しで断る)
114
+
115
+ `differ` を上書きしない理由: 外へ出した後は**外が正**で、運用がそこを直す。
116
+ repo の中は古いまま残る(消すのは別の日)。上書きすると、**運用が外で直した
117
+ ものを、消し忘れた repo の台帳が黙って巻き戻す。**
118
+ """
119
+ src = accounts_mod.legacy_accounts_dir()
120
+ dst = target_accounts_dir()
121
+ out = {"src": src, "dst": dst, "copy": [], "same": [], "differ": [],
122
+ "src_missing": False, "same_place": False}
123
+ if os.path.realpath(src) == os.path.realpath(dst):
124
+ out["same_place"] = True
125
+ return out
126
+ if not os.path.isdir(src):
127
+ out["src_missing"] = True
128
+ return out
129
+ for name in sorted(n for n in os.listdir(src) if n.endswith(".json")):
130
+ s = os.path.join(src, name)
131
+ d = os.path.join(dst, name)
132
+ if not os.path.exists(d):
133
+ out["copy"].append(name)
134
+ continue
135
+ with open(s, "rb") as f:
136
+ a = f.read()
137
+ with open(d, "rb") as f:
138
+ b = f.read()
139
+ (out["same"] if a == b else out["differ"]).append(name)
140
+ return out
141
+
142
+
143
+ def cmd_migrate(args) -> int:
144
+ """`thth account migrate [--dry-run]`: repo の中の台帳を外へ **copy** する。
145
+
146
+ **移動しない・repo は触らない**(設計 v2 §7-1「`git mv` ではなく copy」)。
147
+ VM は `/srv/thth/app` を `merge --ff-only` で更新する clone なので、そこの
148
+ 作業ツリーを道具が動かすと次の更新が止まる。**repo の `accounts/` を消すのは
149
+ 別の日、人の手**。
150
+
151
+ 冪等: 2 回目からは全部 `same` になり、何も書かない。
152
+ """
153
+ plan = plan_migration()
154
+ log = print
155
+ if plan["same_place"]:
156
+ log(f"写す先と写し元が同じです({plan['dst']})。することはありません。")
157
+ return 0
158
+ if plan["src_missing"]:
159
+ log(f"repo の中に台帳はありません({plan['src']})。することはありません。")
160
+ return 0
161
+
162
+ dry = getattr(args, "dry_run", False)
163
+ log(f"写し元(repo の中): {plan['src']}")
164
+ log(f"写し先(正)   : {plan['dst']}")
165
+ log("")
166
+ if not plan["copy"] and not plan["same"] and not plan["differ"]:
167
+ log("台帳が 1 本もありません。することはありません。")
168
+ return 0
169
+
170
+ for name in plan["copy"]:
171
+ log(f" {'写す(予定)' if dry else '写した'}: {name}")
172
+ for name in plan["same"]:
173
+ log(f" 写し済み(同じ中身なので触りません): {name}")
174
+ for name in plan["differ"]:
175
+ log(f" **中身が違います。上書きしません**: {name}")
176
+
177
+ if not dry:
178
+ os.makedirs(plan["dst"], exist_ok=True)
179
+ for name in plan["copy"]:
180
+ shutil.copy2(os.path.join(plan["src"], name),
181
+ os.path.join(plan["dst"], name))
182
+
183
+ log("")
184
+ if plan["differ"]:
185
+ log(f"**{len(plan['differ'])} 本は写していません**——外の台帳と repo の台帳の"
186
+ f"中身が違います。外が正です。repo の側が古いだけなら、"
187
+ f"repo の `accounts/` を消す日にまとめて片付けてください。")
188
+ return 1
189
+ 総数 = len(plan["copy"]) + len(plan["same"])
190
+ if dry:
191
+ log(f"--dry-run なので何も書いていません。"
192
+ f"よければ `thth account migrate` を打ってください。")
193
+ elif plan["copy"]:
194
+ log(f"{len(plan['copy'])} 本を写しました。`thth board` で {総数} 本が"
195
+ f"変わらず見えることを確かめてください。"
196
+ f"**repo の `accounts/` はそのまま残っています**(消すのは別の日)。")
197
+ else:
198
+ # **冪等。** 2 回目からはここに来る——「写した」と言わない。
199
+ log(f"写すものはありませんでした({総数} 本とも写し済み)。")
200
+ return 0
201
+
202
+
203
+ # --------------------------------------------------------------------------
204
+ # thth account add
205
+ # --------------------------------------------------------------------------
206
+
207
+ def example_path(media: str) -> str:
208
+ return os.path.join(EXAMPLE_DIR, f"{media}.json")
209
+
210
+
211
+ def build_ledger(name: str, *, media: str, project: str, handle: str | None = None,
212
+ instance: str | None = None, repo_dir: str | None = None,
213
+ redirect_uri: str | None = None) -> dict:
214
+ """`accounts.example/<media>.json` の雛形から 1 本ぶんを組み立てる。
215
+
216
+ **`production` と `scheduled` は必ず false**(設計 §4.2「`production: true` を
217
+ 自分で書かない限り dry-run」)。道具が作ったものが、いきなり本物を投げる形で
218
+ 生まれてはいけない。雛形が万一 true でも、ここで落とす。
219
+ """
220
+ path = example_path(media)
221
+ if not os.path.exists(path):
222
+ raise FileNotFoundError(path)
223
+ with open(path, encoding="utf-8") as f:
224
+ data = json.load(f)
225
+ data["account"] = name
226
+ data["project"] = project
227
+ data["media"] = media
228
+ # 既定の handle は **project**(`nigamilab-threads` の handle は `nigamilab`)。
229
+ # アカウント名をそのまま入れると `@nigamilab-threads` という実在しない綴りが
230
+ # board に並ぶ。
231
+ #
232
+ # **既定を許すのは Threads だけ**(監査 2・C10・2026-09-13)。Threads の
233
+ # handle は利用者名そのものなので `--project` の値がだいたい当たるが、
234
+ # **Bluesky は `name.bsky.social`、Mastodon は `@` を除いた利用者名+instance**
235
+ # なので、`--project` の値はほぼ外れる。外れた handle は board に実在しない
236
+ # 綴りで並ぶだけでなく、**Bluesky では `thth auth` の取り違え検査に引っかかって
237
+ # 認可が保存されない**(`oauth.run_auth_bluesky()`)。だから `cmd_add()` は
238
+ # その 2 媒体で `--handle` を必須にする——ここは受け取った値を入れるだけ。
239
+ data["handle"] = handle or project
240
+ if redirect_uri is not None:
241
+ # Threads だけ(`cmd_add()` が他媒体を断る)。省略時は雛形のダミーのまま。
242
+ data["redirect_uri"] = redirect_uri
243
+ if instance is not None:
244
+ # Mastodon は `instance`、Bluesky は `service`(既存の台帳の綴り)。
245
+ data["service" if media == "bluesky" else "instance"] = instance
246
+ data["repo_dir"] = repo_dir or f"$THTH_ROOT/repos/{project}"
247
+ data["env"] = f"~/.config/thth/{name}.env"
248
+ data["token"] = f"~/.config/thth/{name}.token"
249
+ data["production"] = False
250
+ data["scheduled"] = False
251
+ return data
252
+
253
+
254
+ def _互換の台帳() -> dict | None:
255
+ """いま読んでいるのが **repo の中(互換 (c))** なら、その置き場と台帳の名前。
256
+
257
+ そうでなければ `None`。`add` が「この N 本が読まれなくなる」と言うために使う。
258
+ """
259
+ info = accounts_mod.accounts_dir_info()
260
+ if info["source"] != accounts_mod.SOURCE_APP_REPO:
261
+ return None
262
+ try:
263
+ names = sorted(n for n in os.listdir(info["path"]) if n.endswith(".json"))
264
+ except OSError:
265
+ names = []
266
+ return {"path": info["path"], "names": names}
267
+
268
+
269
+ def cmd_add(args) -> int:
270
+ """`thth account add <name> --media … --project … [--handle …] [--instance …] [--repo-dir …]`。
271
+
272
+ 書く先は **`$THTH_ROOT/accounts/<name>.json`**(repo の中ではない)。
273
+ 既にあれば**上書きしない**(loud reject・作法 5)。
274
+ """
275
+ name = args.name
276
+ if not name:
277
+ print("account add には名前が要ります: thth account add <name> --media … --project …",
278
+ file=sys.stderr)
279
+ return 2
280
+ if not name_is_safe(name):
281
+ # **置き場の外に書かせない**(監査 1・P2-1)。`thth account add ../pwned` が
282
+ # `accounts/../pwned.json` を書いて rc=0 で終わっていた——しかも
283
+ # `list_account_names()` は `.json` の直下しか見ないので board に出ない。
284
+ print(f"アカウント名に使えない字が入っています: {name!r}", file=sys.stderr)
285
+ print("使えるのは英数字と `_`・`.`・`-` だけです"
286
+ "(名前はそのままファイル名になります。`/` や `..` は置き場の外を指せます)。",
287
+ file=sys.stderr)
288
+ return 2
289
+ if not args.media:
290
+ print(f"--media が要ります({'|'.join(MEDIA_CHOICES)})", file=sys.stderr)
291
+ return 2
292
+ if args.media not in MEDIA_CHOICES:
293
+ print(f"--media は {'|'.join(MEDIA_CHOICES)} のどれか(受け取った: {args.media})",
294
+ file=sys.stderr)
295
+ return 2
296
+ if not args.project:
297
+ print("--project が要ります(clone の dir 名・board の見出し)", file=sys.stderr)
298
+ return 2
299
+
300
+ # **媒体ごとに、既定で当たらない欄は必須にする**(監査 2・C10・2026-09-13)。
301
+ #
302
+ # handle の既定は `--project` の値。**Threads は利用者名がそのまま handle** な
303
+ # ので当たることが多いが、**Bluesky は `name.bsky.social`**(ドメイン形)、
304
+ # **Mastodon は利用者名+instance** なので、既定はほぼ外れる。外れたまま書くと:
305
+ # - board に実在しない綴りが並ぶ(見た人が直せない)
306
+ # - Bluesky は `thth auth` の取り違え検査が「台帳の handle と App Password の
307
+ # handle が違う」と言って**認可を保存しない**(`oauth.run_auth_bluesky()`)
308
+ # **黙って外れた値を書くより、ここで 1 回止まって聞くほうが安い。**
309
+ if args.media in ("bluesky", "mastodon") and not args.handle:
310
+ print(f"--handle が要ります({args.media} は `--project` の値では当たりません)",
311
+ file=sys.stderr)
312
+ if args.media == "bluesky":
313
+ print(f" 例: thth account add {name} --media bluesky "
314
+ f"--project {args.project} --handle name.bsky.social", file=sys.stderr)
315
+ else:
316
+ print(f" 例: thth account add {name} --media mastodon "
317
+ f"--project {args.project} --handle user "
318
+ f"--instance https://mastodon.social", file=sys.stderr)
319
+ return 2
320
+ if args.media == "mastodon" and not args.instance:
321
+ # instance が無いと、雛形の `https://mastodon.example`(存在しない)が
322
+ # そのまま残る。**どのインスタンスかは道具には推測できない。**
323
+ print("--instance が要ります(Mastodon はインスタンスごとに口が違います)",
324
+ file=sys.stderr)
325
+ print(f" 例: thth account add {name} --media mastodon "
326
+ f"--project {args.project} --handle user "
327
+ f"--instance https://mastodon.social", file=sys.stderr)
328
+ return 2
329
+ if args.redirect_uri and args.media != "threads":
330
+ # **黙って捨てない**(作法 5)。`redirect_uri` は Threads の OAuth 往復
331
+ # (`thth auth`)だけが読む欄で、他媒体の雛形には無い。
332
+ print(f"--redirect-uri は threads のときだけ使えます(受け取った媒体: {args.media})",
333
+ file=sys.stderr)
334
+ return 2
335
+
336
+ # **互換 (c) のまま `add` を打たせない**(監査 1・P1-3)。
337
+ #
338
+ # 読みが repo の中に落ちている機械(=VM)で `add` を 1 本打つと、書く先の
339
+ # `$THTH_ROOT/accounts/` が**その瞬間に出来る**。解決順は「ディレクトリが
340
+ # あるか」だけで (b) を正とするので、**次の実行から repo の N 本は一切
341
+ # 読まれない**——`thth run kopicha-threads` が「台帳が無い」の rc=2 になる。
342
+ # 前はこれを何も言わずにやっていた。**順番は `migrate` → `add`。**
343
+ 互換 = _互換の台帳()
344
+ if 互換 and not getattr(args, "force", False):
345
+ print(f"**先に `thth account migrate` を打ってください。**", file=sys.stderr)
346
+ print(f"いま台帳を読んでいるのは repo の中です: {互換['path']}({len(互換['names'])} 本)",
347
+ file=sys.stderr)
348
+ for n in 互換["names"]:
349
+ print(f" - {n}", file=sys.stderr)
350
+ print(f"ここで `add` を打つと {target_accounts_dir()} が出来て、"
351
+ f"**この {len(互換['names'])} 本は以後読まれません**"
352
+ f"(次の実行で「台帳が無い」になります)。", file=sys.stderr)
353
+ print(f" 1) thth account migrate (repo の中を外へ copy・repo は触りません)",
354
+ file=sys.stderr)
355
+ print(f" 2) thth account add {name} --media {args.media} --project {args.project}",
356
+ file=sys.stderr)
357
+ print(f"**上の台帳が自分のものでなければ**(clone に同梱されていた他人の台帳)、"
358
+ f"`--force` を付けて進んでかまいません。", file=sys.stderr)
359
+ return 1
360
+
361
+ try:
362
+ data = build_ledger(name, media=args.media, project=args.project,
363
+ handle=args.handle, instance=args.instance,
364
+ repo_dir=args.repo_dir, redirect_uri=args.redirect_uri)
365
+ except FileNotFoundError as e:
366
+ print(f"雛形がありません: {e}", file=sys.stderr)
367
+ return 2
368
+
369
+ dst_dir = target_accounts_dir()
370
+ path = os.path.join(dst_dir, f"{name}.json")
371
+ if os.path.exists(path):
372
+ print(f"既にあります。上書きしません: {path}", file=sys.stderr)
373
+ return 1
374
+ try:
375
+ os.makedirs(dst_dir, exist_ok=True)
376
+ with open(path, "w", encoding="utf-8") as f:
377
+ json.dump(data, f, ensure_ascii=False, indent=2)
378
+ f.write("\n")
379
+ except OSError as e:
380
+ # **traceback にしない**(監査 1・P3)。`$THTH_ROOT/accounts` がファイル
381
+ # だと `FileExistsError`、書けない場所だと `PermissionError` が素通りして
382
+ # いた。どちらも「打った人が直せること」なので、言葉で言う。
383
+ print(f"台帳を書けませんでした: {path}({e.strerror or e})", file=sys.stderr)
384
+ if os.path.exists(dst_dir) and not os.path.isdir(dst_dir):
385
+ print(f" 台帳の置き場がファイルになっています: {dst_dir}"
386
+ f"(ディレクトリでなければなりません)", file=sys.stderr)
387
+ return 2
388
+
389
+ if args.json:
390
+ print(json.dumps({"path": path, "account": data}, ensure_ascii=False))
391
+ return 0
392
+ print(f"書きました: {path}")
393
+ print(f" production: false(**このままでは投げません**。"
394
+ f"本番にするときだけ手で true に)")
395
+ print(f" scheduled: false(timer に載せるときだけ手で true に)")
396
+ print(f" repo_dir: {data['repo_dir']}")
397
+ print("")
398
+ # **雛形のダミーが残っているなら、書いたその場で名指しする**(監査 2・C10・
399
+ # 2026-09-13)。前はここが何も言わず、`thth auth` を打った人が
400
+ # `https://example.invalid/` の認可 URL をブラウザで開いて初めて詰まった
401
+ # ——**どこにも書かれていない手作業**が `add` と `auth` の間に挟まっていた。
402
+ for d in accounts_mod.dummy_fields(data):
403
+ 前置き = "**`thth auth` の前に。** " if d["field"] == "redirect_uri" else ""
404
+ print(f"**{d['field']} はダミーのままです**({d['value']})。{前置き}{d['next']}")
405
+ print(f"次の一手: `thth doctor {name}`(トークンがまだなので rc=2 で止まります)"
406
+ f" → `thth token set {name}` → `thth board`")
407
+ return 0
408
+
409
+
410
+ # --------------------------------------------------------------------------
411
+ # 振り分けと登録
412
+ # --------------------------------------------------------------------------
413
+
414
+ def dispatch(args) -> int:
415
+ verb = getattr(args, "account", None)
416
+ if verb == "migrate":
417
+ if args.name:
418
+ print(f"`thth account migrate` は名前を取りません(受け取った: {args.name})",
419
+ file=sys.stderr)
420
+ return 2
421
+ return cmd_migrate(args)
422
+ if verb == "add":
423
+ return cmd_add(args)
424
+ if args.name:
425
+ print(f"`thth account` が読めません(`{verb} {args.name}`)。"
426
+ f"使い方: thth account [<name>] / thth account add <name> … / "
427
+ f"thth account migrate", file=sys.stderr)
428
+ return 2
429
+ # 従来どおり「1 アカウント(省略時は全部)の状態を一枚で述べる」。
430
+ from . import cli
431
+ return cli.cmd_account(args)
432
+
433
+
434
+ def register(sub) -> None:
435
+ """`build_parser()` が作った `account` の parser に `add`・`migrate` を足す。
436
+
437
+ **`build_parser()` に足すのはこの呼び出しの 1 行だけ**(並行 Track との衝突を
438
+ 作らない)。既存の `account` 位置引数・`--json`・`--no-remote` はそのまま。
439
+ """
440
+ p = sub.choices["account"]
441
+ # epilog の改行をそのまま出す(既定の formatter は詰めてしまい、4 つの
442
+ # 呼び方が 1 段落になって読めなくなる)。
443
+ p.formatter_class = argparse.RawDescriptionHelpFormatter
444
+ p.add_argument("name", nargs="?",
445
+ help="`add` のときのアカウント名(`<project>-<media>`)")
446
+ p.add_argument("--media", default=None, choices=MEDIA_CHOICES,
447
+ help="`add` のとき: 媒体")
448
+ p.add_argument("--project", default=None,
449
+ help="`add` のとき: clone の dir 名・board の見出し")
450
+ p.add_argument("--handle", default=None,
451
+ help="`add` のとき: 表示上の handle(bluesky・mastodon では必須)")
452
+ p.add_argument("--instance", default=None,
453
+ help="`add` のとき: Mastodon の instance(必須)/ Bluesky の service")
454
+ p.add_argument("--redirect-uri", default=None, dest="redirect_uri",
455
+ help="`add`(threads)のとき: Meta アプリに登録した認可の戻り先。"
456
+ "省略すると雛形のダミーのままで、`thth auth` が rc=2 で断ります")
457
+ p.add_argument("--repo-dir", default=None, dest="repo_dir",
458
+ help="`add` のとき: 原稿 repo(既定 `$THTH_ROOT/repos/<project>`)")
459
+ p.add_argument("--dry-run", action="store_true", dest="dry_run",
460
+ help="`migrate` のとき: 何も書かずに計画だけ出す")
461
+ p.add_argument("--force", action="store_true",
462
+ help="`add` のとき: repo の中の台帳が読めなくなるのを承知で進む")
463
+ p.epilog = ("thth account 全アカウントの状態を一枚で\n"
464
+ "thth account <name> 1 本の状態を一枚で\n"
465
+ "thth account migrate [--dry-run] repo の中の台帳を "
466
+ "$THTH_ROOT/accounts/ へ写す(copy・repo は触らない)\n"
467
+ "thth account add <name> --media threads|bluesky|mastodon "
468
+ "--project <p> [--handle …] [--instance …] [--redirect-uri …] "
469
+ "[--repo-dir …]\n"
470
+ " threads : --handle は省略可(既定は --project の値)。"
471
+ "--redirect-uri を省くと雛形のダミーのまま\n"
472
+ " bluesky : --handle name.bsky.social が必須\n"
473
+ " mastodon : --handle user と --instance https://… が必須")
474
+ p.set_defaults(func=dispatch)