ktalk-cli 4.0.0__tar.gz → 4.0.1__tar.gz

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 (133) hide show
  1. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/PKG-INFO +1 -1
  2. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/pyproject.toml +1 -1
  3. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_token.py +5 -4
  4. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/fs_guard.py +18 -3
  5. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/token_file.py +67 -20
  6. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_secret_masking.py +1 -1
  7. ktalk_cli-4.0.1/tests/test_windows_native_auth.py +522 -0
  8. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_write_journal.py +2 -2
  9. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/.gitignore +0 -0
  10. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/LICENSE +0 -0
  11. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/README.md +0 -0
  12. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/CLAUDE.md +0 -0
  13. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/__init__.py +0 -0
  14. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/auth.py +0 -0
  15. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/calendar_reader.py +0 -0
  16. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli.py +0 -0
  17. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_config.py +0 -0
  18. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_contacts.py +0 -0
  19. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_content.py +0 -0
  20. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_doctor.py +0 -0
  21. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_meeting.py +0 -0
  22. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_meeting_args.py +0 -0
  23. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_meeting_confirm.py +0 -0
  24. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_meetings_read.py +0 -0
  25. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_sanction.py +0 -0
  26. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_store.py +0 -0
  27. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/cli_sync.py +0 -0
  28. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/client.py +0 -0
  29. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/config.py +0 -0
  30. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/confirmation.py +0 -0
  31. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/contacts.py +0 -0
  32. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/contour_diagnostics.py +0 -0
  33. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/download.py +0 -0
  34. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/endpoints.py +0 -0
  35. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/enrichment.py +0 -0
  36. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/formatters.py +0 -0
  37. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/host_config.py +0 -0
  38. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/meeting_body.py +0 -0
  39. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/meeting_cancel.py +0 -0
  40. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/meeting_scheduling.py +0 -0
  41. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/pagination.py +0 -0
  42. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/reconciliation.py +0 -0
  43. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/registry.py +0 -0
  44. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/rooms.py +0 -0
  45. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/store.py +0 -0
  46. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/store_migration.py +0 -0
  47. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/talk_config.py +0 -0
  48. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/transcript_identity.py +0 -0
  49. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/write_journal.py +0 -0
  50. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/src/ktalk_cli/write_sanction.py +0 -0
  51. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/conftest.py +0 -0
  52. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/access-info-expired.json +0 -0
  53. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/access-info-full.json +0 -0
  54. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-8col.md +0 -0
  55. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-dup-id.md +0 -0
  56. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-escaped-pipe-7col.md +0 -0
  57. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-page1.json +0 -0
  58. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-page2-empty.json +0 -0
  59. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/archive-pipe-in-name.md +0 -0
  60. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/calendar-item-session.json +0 -0
  61. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/chat-messages-session.json +0 -0
  62. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/conference-history-session-oversized.json +0 -0
  63. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/conference-history-session.json +0 -0
  64. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/error-body-problem-json.json +0 -0
  65. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/error-body-validation.json +0 -0
  66. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recording-detail-session-full-participants.json +0 -0
  67. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recording-detail-session-oversized-partial.json +0 -0
  68. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recording-detail-with-qualities.json +0 -0
  69. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recording-list-item-session.json +0 -0
  70. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recordings-list-apikey-page1.json +0 -0
  71. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/recordings-list-apikey-page2.json +0 -0
  72. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/registry-archive-2026-04.md +0 -0
  73. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/registry.md +0 -0
  74. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/fixtures/room-detail-session.json +0 -0
  75. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_adr022_ktalk_cli_rename.py +0 -0
  76. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_archive.py +0 -0
  77. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_auth_modes.py +0 -0
  78. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_calendar.py +0 -0
  79. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_calendar_window_iso8601.py +0 -0
  80. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_chat.py +0 -0
  81. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli.py +0 -0
  82. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_config_set.py +0 -0
  83. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_config_show.py +0 -0
  84. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_content.py +0 -0
  85. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_export_store_isolation.py +0 -0
  86. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_host_config_wiring.py +0 -0
  87. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_meeting.py +0 -0
  88. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_meeting_sanctioned.py +0 -0
  89. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_meetings_surface.py +0 -0
  90. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_cli_migrate_to_central_store.py +0 -0
  91. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_client.py +0 -0
  92. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_code_review_dev003.py +0 -0
  93. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_concurrency.py +0 -0
  94. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_config.py +0 -0
  95. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_confirmation.py +0 -0
  96. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_contour_diagnostics.py +0 -0
  97. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_diagnostics.py +0 -0
  98. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_download.py +0 -0
  99. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_enrichment.py +0 -0
  100. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_formatters.py +0 -0
  101. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr19_auth_status.py +0 -0
  102. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr21_no_vault_layout.py +0 -0
  103. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr39_calendar_inclusive_end.py +0 -0
  104. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr40_timezone_format.py +0 -0
  105. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr41_last_synced.py +0 -0
  106. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr42_single_credential_source.py +0 -0
  107. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr43_legacy_key_removal_warning.py +0 -0
  108. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr44_auth_status_accepted_rejected.py +0 -0
  109. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr47_missing_fields_completeness.py +0 -0
  110. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_fr48_doctor_summary_diagnostics.py +0 -0
  111. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_host_config.py +0 -0
  112. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_issue3_null_id_fallback.py +0 -0
  113. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_legacy_config_detection.py +0 -0
  114. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_meeting_body.py +0 -0
  115. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_meeting_cancel.py +0 -0
  116. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_meeting_scheduling.py +0 -0
  117. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_migration.py +0 -0
  118. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_nfr17_identity_verification.py +0 -0
  119. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_pagination.py +0 -0
  120. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_reconciliation.py +0 -0
  121. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_registry.py +0 -0
  122. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_rooms.py +0 -0
  123. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_search_contacts.py +0 -0
  124. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_sec001_control_char.py +0 -0
  125. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_sec001_legacy_var_401.py +0 -0
  126. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_sec001_symlink_guard.py +0 -0
  127. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_settings_config_sources.py +0 -0
  128. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_store.py +0 -0
  129. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_store_migration.py +0 -0
  130. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_sync_honest_window.py +0 -0
  131. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_talk_config.py +0 -0
  132. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_token_file.py +0 -0
  133. {ktalk_cli-4.0.0 → ktalk_cli-4.0.1}/tests/test_write_sanction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ktalk-cli
3
- Version: 4.0.0
3
+ Version: 4.0.1
4
4
  Summary: CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries
5
5
  Project-URL: Homepage, https://github.com/mdemyanov/ktalk-cli
6
6
  Project-URL: Repository, https://github.com/mdemyanov/ktalk-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ktalk-cli"
3
- version = "4.0.0"
3
+ version = "4.0.1"
4
4
  description = "CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -65,10 +65,11 @@ def _cmd_status(args: argparse.Namespace) -> int:
65
65
  return EXIT_OK
66
66
  print(f"present: True, права {mode}")
67
67
  if token is None:
68
- print(
69
- "usable: False — файл пуст или его права шире 0600; "
70
- f"почините: chmod 600 {path}"
71
- )
68
+ # Совет приходит из `status_payload` — пара (причина × платформа):
69
+ # chmod существует только на платформах с выразимыми POSIX-битами и
70
+ # только для причины «права шире» (ADR-028 §3).
71
+ advice = payload["advice"] or "запишите токен: ktalk token set -"
72
+ print(f"usable: False — {advice}")
72
73
  return EXIT_OK
73
74
  print(f"usable: True, {_mask(token)}")
74
75
  return EXIT_OK
@@ -26,14 +26,29 @@ def open_temp_exclusive(tmp: Path, mode: int) -> int:
26
26
  `O_NOFOLLOW`/`O_EXCL` — симлинк, подложенный на место `.tmp` между
27
27
  проверкой и открытием, оставался рабочим путём записи.
28
28
 
29
- `O_NOFOLLOW` отказывается идти по уже существующему симлинку. `O_EXCL`
30
- отказывается, если `.tmp` уже существует (обычный файл — остаток
29
+ `O_NOFOLLOW` отказывается идти по уже существующему симлинку; подставляется
30
+ `getattr(os, "O_NOFOLLOW", 0)`, а не константой: атрибут есть не на всех
31
+ платформах (нативный Windows его не имеет — ADR-028 §1), и безусловная
32
+ константа роняла обе команды записи `AttributeError`-ом ещё до записи
33
+ значения. На POSIX `getattr` возвращает ту же константу — набор флагов
34
+ бит-в-бит прежний. Отсутствие `O_NOFOLLOW` защиты не снимает: `O_CREAT|O_EXCL`
35
+ отказывает на любом уже существующем `.tmp`, включая подложенный симлинк —
36
+ для create-new ссылка не преследуется ни на POSIX (`O_EXCL`, POSIX.1-2008),
37
+ ни на Windows (`CREATE_NEW`); ветка повтора снимает остаток `unlink`-ом,
38
+ который никогда не идёт по симлинку, и вторая попытка снова под `O_EXCL` —
39
+ выигравший гонку атакующий получает отказ, тихой записи по подмене нет.
40
+ Остаточная роль `O_NOFOLLOW` — различие errno (ELOOP против EEXIST) на
41
+ существующей записи; создать симлинк на Windows можно только с привилегией
42
+ (`SeCreateSymbolicLinkPrivilege` либо DevMode) — у непривилегированного
43
+ локального атакующего из модели угроз хода нет.
44
+
45
+ `O_EXCL` отказывается, если `.tmp` уже существует (обычный файл — остаток
31
46
  прерванной записи, либо симлинк, подложенный другим локальным
32
47
  пользователем) — обрабатывается только безобидная ветка: остаток
33
48
  удаляется (`unlink` никогда не идёт по симлинку) и попытка повторяется
34
49
  один раз; если атакующий выиграл гонку заново, вторая попытка тоже
35
50
  проваливается — тихого успеха через подмену нет."""
36
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
51
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
37
52
  try:
38
53
  return os.open(tmp, flags, mode)
39
54
  except FileExistsError:
@@ -12,7 +12,13 @@
12
12
 
13
13
  Права шире `0600` читаются как «файла нет» — тот же fail-closed барьер, что у
14
14
  санкции записи (SEC-006). Это защита не от владельца учётной записи, а от другого
15
- пользователя машины, которому файл стал доступен.
15
+ пользователя машины, которому файл стал доступен. Барьер применяется только там,
16
+ где POSIX-битам есть смысл (`os.name != "nt"`, ADR-028 §2): нативный `stat` на
17
+ Windows сообщает `0o666` для любого обычного файла, барьер отвергал бы там каждый
18
+ рукописный файл независимо от содержимого; границей защиты на этой платформе
19
+ служит изоляция профиля пользователя, и инструмент не посылает файлу явного
20
+ послабления. На обеих платформах пригодность означает корректную форму значения
21
+ (`_TOKEN_RE`), а не любое непустое содержимое (FR-51).
16
22
  """
17
23
 
18
24
  from __future__ import annotations
@@ -42,40 +48,81 @@ def token_path() -> Path:
42
48
  return root / "ktalk-mcp" / "token"
43
49
 
44
50
 
45
- def file_mode() -> str | None:
46
- """Права файла как `0600` — для диагностики. `None`, если файла нет."""
51
+ def _verdict() -> tuple[str | None, str | None, str | None]:
52
+ """Вердикт одним проходом: `(токен, права, причина отказа)`. Права — строка
53
+ вида `0600` для диагностики, `None`, если файла нет. Причина — `None` для
54
+ пригодного файла, иначе `not_present` / `wide_mode` / `unreadable` / `empty`
55
+ / `bad_content`; её читает совет диагностики (`status_payload`, ADR-028 §2-3).
56
+
57
+ Платформенная развилка читает `os.name` в момент вызова, а не на импорте:
58
+ ось тестов (NFR-26) подменяет атрибут в окне вызова."""
59
+ path = token_path()
47
60
  try:
48
- return format(stat.S_IMODE(token_path().stat().st_mode), "04o")
61
+ st_mode = stat.S_IMODE(path.stat().st_mode)
49
62
  except OSError:
50
- return None
63
+ return None, None, "not_present"
64
+ mode = format(st_mode, "04o")
65
+ if os.name != "nt" and st_mode & 0o077:
66
+ return None, mode, "wide_mode"
67
+ try:
68
+ raw = path.read_text(encoding="utf-8")
69
+ except (OSError, ValueError, UnicodeDecodeError):
70
+ return None, mode, "unreadable"
71
+ token = raw.strip()
72
+ if not token:
73
+ return None, mode, "empty"
74
+ if not _TOKEN_RE.match(token):
75
+ return None, mode, "bad_content"
76
+ return token, mode, None
77
+
78
+
79
+ def file_mode() -> str | None:
80
+ """Права файла как `0600` — для диагностики. `None`, если файла нет."""
81
+ return _verdict()[1]
51
82
 
52
83
 
53
84
  def read_token() -> str | None:
54
- """Значение токена или `None` — если файла нет, он нечитаем, пуст или его
55
- права шире `0600`. Ни одна из этих причин не является ошибкой: вызывающая
85
+ """Значение токена или `None` — если файла нет, он нечитаем, пуст, его
86
+ содержимое не по форме токена или (везде, кроме родной Windows) его права
87
+ шире `0600`. Ни одна из этих причин не является ошибкой: вызывающая
56
88
  сторона просто переходит к следующему источнику (или к отказу конфигурации)."""
57
- path = token_path()
58
- try:
59
- if stat.S_IMODE(path.stat().st_mode) & 0o077:
60
- return None
61
- raw = path.read_text(encoding="utf-8")
62
- except (OSError, ValueError, UnicodeDecodeError):
89
+ return _verdict()[0]
90
+
91
+
92
+ def _remediation_advice(cause: str | None) -> str | None:
93
+ """Совет для причины «не пригоден» — пара (причина × платформа), собирается
94
+ здесь же, чтобы `cli_token` и `doctor` печатали одно и то же (ADR-026,
95
+ ADR-028 §3): chmod-совет существует только там, где POSIX-биты выразимы, и
96
+ только для причины «права шире» — на родной Windows он невыполним и не
97
+ печатается."""
98
+ if cause is None:
63
99
  return None
64
- token = raw.strip()
65
- return token or None
100
+ if cause == "wide_mode" and os.name != "nt":
101
+ return f"права шире 0600; почините: chmod 600 {token_path()}"
102
+ if cause == "empty":
103
+ return "файл пуст; запишите токен заново: ktalk token set -"
104
+ if cause == "bad_content":
105
+ return (
106
+ "содержимое не похоже на токен (одно слово из букв и цифр, "
107
+ "16-64 символа); запишите заново: ktalk token set -"
108
+ )
109
+ return "запишите токен: ktalk token set -"
66
110
 
67
111
 
68
112
  def status_payload() -> dict:
69
- """Путь/права/пригодность файла токена одним словарём — единственная точка
70
- сборки, которую переиспользуют `cli_token.py::_cmd_status` и `cli_doctor.py`
113
+ """Путь/права/пригодность/причина одним словарём — единственная точка сборки,
114
+ которую переиспользуют `cli_token.py::_cmd_status` и `cli_doctor.py`
71
115
  (ADR-026 spec §2: значение совпадает буквальным переиспользованием кода, не
72
- соглашением между двумя независимо написанными местами)."""
73
- mode = file_mode()
116
+ соглашением между двумя независимо написанными местами). `cause`/`advice` —
117
+ пара (причина × платформа) из ADR-028 §3."""
118
+ token, mode, cause = _verdict()
74
119
  return {
75
120
  "path": str(token_path()),
76
121
  "present": mode is not None,
77
122
  "mode": mode,
78
- "usable": read_token() is not None,
123
+ "usable": token is not None,
124
+ "cause": cause,
125
+ "advice": _remediation_advice(cause),
79
126
  }
80
127
 
81
128
 
@@ -17,7 +17,7 @@ import pytest
17
17
  from pytest_httpx import HTTPXMock
18
18
 
19
19
  SECRET = "pk-supersecret-abcdef123456"
20
- SESSION_SECRET = "sess-supersecret-abcdef123456"
20
+ SESSION_SECRET = "sesssupersecretabcdef123456"
21
21
 
22
22
 
23
23
  @pytest.fixture