ktalk-cli 1.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.
- ktalk_cli/CLAUDE.md +30 -0
- ktalk_cli/__init__.py +23 -0
- ktalk_cli/auth.py +267 -0
- ktalk_cli/calendar_reader.py +149 -0
- ktalk_cli/cli.py +445 -0
- ktalk_cli/cli_contacts.py +55 -0
- ktalk_cli/cli_content.py +167 -0
- ktalk_cli/cli_meeting.py +68 -0
- ktalk_cli/cli_meeting_args.py +123 -0
- ktalk_cli/cli_meeting_confirm.py +256 -0
- ktalk_cli/cli_meetings_read.py +115 -0
- ktalk_cli/cli_sanction.py +108 -0
- ktalk_cli/cli_store.py +59 -0
- ktalk_cli/cli_sync.py +169 -0
- ktalk_cli/cli_token.py +88 -0
- ktalk_cli/client.py +348 -0
- ktalk_cli/config.py +152 -0
- ktalk_cli/confirmation.py +129 -0
- ktalk_cli/contacts.py +45 -0
- ktalk_cli/contour_diagnostics.py +107 -0
- ktalk_cli/download.py +109 -0
- ktalk_cli/endpoints.py +145 -0
- ktalk_cli/enrichment.py +83 -0
- ktalk_cli/formatters.py +589 -0
- ktalk_cli/host_config.py +136 -0
- ktalk_cli/meeting_body.py +178 -0
- ktalk_cli/meeting_cancel.py +33 -0
- ktalk_cli/meeting_scheduling.py +120 -0
- ktalk_cli/pagination.py +108 -0
- ktalk_cli/reconciliation.py +59 -0
- ktalk_cli/registry.py +562 -0
- ktalk_cli/rooms.py +62 -0
- ktalk_cli/store.py +88 -0
- ktalk_cli/store_migration.py +89 -0
- ktalk_cli/token_file.py +101 -0
- ktalk_cli/write_journal.py +94 -0
- ktalk_cli/write_sanction.py +187 -0
- ktalk_cli-1.0.0.dist-info/METADATA +391 -0
- ktalk_cli-1.0.0.dist-info/RECORD +42 -0
- ktalk_cli-1.0.0.dist-info/WHEEL +4 -0
- ktalk_cli-1.0.0.dist-info/entry_points.txt +2 -0
- ktalk_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
ktalk_cli/cli.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Command-line interface for the KTalk recordings registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ktalk_cli.cli_contacts import cmd_search_contacts
|
|
12
|
+
from ktalk_cli.cli_contacts import register_subparsers as register_contacts_subparsers
|
|
13
|
+
from ktalk_cli.cli_content import (
|
|
14
|
+
cmd_download_recording,
|
|
15
|
+
cmd_get_participants,
|
|
16
|
+
cmd_get_recording,
|
|
17
|
+
cmd_get_summary,
|
|
18
|
+
cmd_get_summary_type,
|
|
19
|
+
cmd_get_transcript,
|
|
20
|
+
cmd_list_recordings,
|
|
21
|
+
)
|
|
22
|
+
from ktalk_cli.cli_content import register_subparsers as register_content_subparsers
|
|
23
|
+
from ktalk_cli.cli_meeting import cmd_cancel_meeting_preview, cmd_create_meeting_preview
|
|
24
|
+
from ktalk_cli.cli_meeting import register_subparsers as register_meeting_subparsers
|
|
25
|
+
from ktalk_cli.cli_meeting_confirm import (
|
|
26
|
+
cmd_cancel_meeting_confirm,
|
|
27
|
+
cmd_create_meeting_confirm,
|
|
28
|
+
)
|
|
29
|
+
from ktalk_cli.cli_meeting_confirm import (
|
|
30
|
+
register_subparsers as register_meeting_confirm_subparsers,
|
|
31
|
+
)
|
|
32
|
+
from ktalk_cli.cli_meetings_read import (
|
|
33
|
+
cmd_get_chat_messages,
|
|
34
|
+
cmd_get_room,
|
|
35
|
+
cmd_list_archive,
|
|
36
|
+
cmd_list_calendar,
|
|
37
|
+
)
|
|
38
|
+
from ktalk_cli.cli_meetings_read import register_subparsers as register_meetings_read_subparsers
|
|
39
|
+
from ktalk_cli.cli_sanction import cmd_sanction
|
|
40
|
+
from ktalk_cli.cli_sanction import register_subparsers as register_sanction_subparsers
|
|
41
|
+
from ktalk_cli.cli_token import cmd_token
|
|
42
|
+
from ktalk_cli.cli_token import register_subparsers as register_token_subparsers
|
|
43
|
+
from ktalk_cli.cli_store import cmd_migrate_to_central_store
|
|
44
|
+
from ktalk_cli.cli_store import register_subparsers as register_store_subparsers
|
|
45
|
+
from ktalk_cli.cli_sync import cmd_auth_status, cmd_sync
|
|
46
|
+
from ktalk_cli.config import redact_secrets, resolve_db_path
|
|
47
|
+
from ktalk_cli.host_config import HostConfig, discover_host_config
|
|
48
|
+
from ktalk_cli.registry import Registry, migrate_from_vault, render_markdown_mirror
|
|
49
|
+
|
|
50
|
+
_STATUSES = ("new", "processing", "done", "skipped", "partial")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
from ktalk_cli import __dist_name__ # ADR-022 §7/§8: имя из метаданных, не литерал
|
|
55
|
+
|
|
56
|
+
parser = argparse.ArgumentParser(prog="ktalk", description="Реестр записей Kontur Talk")
|
|
57
|
+
parser.add_argument("--db", default=None, help="Путь к SQLite-базе реестра")
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--version",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help=f"Версия пакета {__dist_name__}",
|
|
62
|
+
)
|
|
63
|
+
sub = parser.add_subparsers(dest="command")
|
|
64
|
+
|
|
65
|
+
p_list = sub.add_parser("list", help="Список записей")
|
|
66
|
+
p_list.add_argument("--status", default=None)
|
|
67
|
+
p_list.add_argument("--json", action="store_true")
|
|
68
|
+
|
|
69
|
+
p_show = sub.add_parser("show", help="Детали записи")
|
|
70
|
+
p_show.add_argument("id")
|
|
71
|
+
p_show.add_argument("--json", action="store_true")
|
|
72
|
+
|
|
73
|
+
p_mp = sub.add_parser("mark-processing", help="В обработку")
|
|
74
|
+
p_mp.add_argument("id")
|
|
75
|
+
|
|
76
|
+
p_md = sub.add_parser("mark-done", help="Завершить обработку")
|
|
77
|
+
p_md.add_argument("id")
|
|
78
|
+
p_md.add_argument("--transcript", required=True)
|
|
79
|
+
p_md.add_argument("--protocol", required=True)
|
|
80
|
+
p_md.add_argument("--type", dest="meeting_type", default=None)
|
|
81
|
+
|
|
82
|
+
p_pp = sub.add_parser("mark-partial", help="Частичная обработка")
|
|
83
|
+
p_pp.add_argument("id")
|
|
84
|
+
p_pp.add_argument("--transcript", default=None)
|
|
85
|
+
p_pp.add_argument("--protocol", default=None)
|
|
86
|
+
|
|
87
|
+
p_sk = sub.add_parser("mark-skipped", help="Пропустить")
|
|
88
|
+
p_sk.add_argument("id")
|
|
89
|
+
|
|
90
|
+
p_vi = sub.add_parser("set-vault-id", help="Привязать профиль к участнику")
|
|
91
|
+
p_vi.add_argument("id")
|
|
92
|
+
p_vi.add_argument("ktalk_id")
|
|
93
|
+
p_vi.add_argument("vault_id")
|
|
94
|
+
|
|
95
|
+
p_dash = sub.add_parser("dashboard", help="Дашборд")
|
|
96
|
+
p_dash.add_argument("--json", action="store_true")
|
|
97
|
+
|
|
98
|
+
p_exp = sub.add_parser("export", help="Сгенерировать markdown-зеркало")
|
|
99
|
+
p_exp.add_argument("--out", default=None)
|
|
100
|
+
p_exp.add_argument("--full", action="store_true")
|
|
101
|
+
p_exp.add_argument("--json", action="store_true")
|
|
102
|
+
|
|
103
|
+
p_mig = sub.add_parser("migrate", help="Импорт из markdown-реестров")
|
|
104
|
+
p_mig.add_argument("vault_path")
|
|
105
|
+
p_mig.add_argument("--dry-run", action="store_true")
|
|
106
|
+
p_mig.add_argument("--json", action="store_true")
|
|
107
|
+
|
|
108
|
+
p_sync = sub.add_parser("sync", help="Синхронизация с KTalk")
|
|
109
|
+
p_sync.add_argument("--days", type=int, default=7)
|
|
110
|
+
p_sync.add_argument("--json", action="store_true")
|
|
111
|
+
p_sync.add_argument("--dry-run", action="store_true", help="Сверка id без записи (FR-15)")
|
|
112
|
+
|
|
113
|
+
p_auth = sub.add_parser("auth-status", help="Диагностика авторизации (FR-11)")
|
|
114
|
+
p_auth.add_argument("--json", action="store_true")
|
|
115
|
+
|
|
116
|
+
p_config = sub.add_parser("config", help="Конфигурация проекта-хозяина (.ktalk.toml)")
|
|
117
|
+
config_sub = p_config.add_subparsers(dest="config_command")
|
|
118
|
+
p_config_show = config_sub.add_parser("show", help="Показать резолвленный .ktalk.toml")
|
|
119
|
+
p_config_show.add_argument("--json", action="store_true")
|
|
120
|
+
|
|
121
|
+
register_meeting_subparsers(sub)
|
|
122
|
+
register_meeting_confirm_subparsers(sub)
|
|
123
|
+
register_sanction_subparsers(sub)
|
|
124
|
+
register_token_subparsers(sub)
|
|
125
|
+
register_contacts_subparsers(sub)
|
|
126
|
+
register_content_subparsers(sub)
|
|
127
|
+
register_meetings_read_subparsers(sub)
|
|
128
|
+
register_store_subparsers(sub)
|
|
129
|
+
|
|
130
|
+
return parser
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print_json(data) -> None:
|
|
134
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _cmd_list(reg: Registry, args) -> int:
|
|
138
|
+
recs = reg.list_recordings(status=args.status)
|
|
139
|
+
if args.json:
|
|
140
|
+
_print_json({"recordings": recs})
|
|
141
|
+
return 0
|
|
142
|
+
if not recs:
|
|
143
|
+
print("Записей не найдено.")
|
|
144
|
+
return 0
|
|
145
|
+
for r in recs:
|
|
146
|
+
dur = f"{r['duration_min']} мин" if r["duration_min"] else "—"
|
|
147
|
+
print(f"{r['recording_id']} [{r['status']}] {r['date']} {dur} {r['name']}")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_show(reg: Registry, args) -> int:
|
|
152
|
+
rec = reg.get_recording(args.id)
|
|
153
|
+
if rec is None:
|
|
154
|
+
print(f"Запись не найдена: {args.id}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
rec = dict(rec)
|
|
157
|
+
rec["participants"] = reg.get_participants(args.id)
|
|
158
|
+
if args.json:
|
|
159
|
+
_print_json(rec)
|
|
160
|
+
return 0
|
|
161
|
+
print(f"# {rec['name']}")
|
|
162
|
+
print(f"- ID: {rec['recording_id']}")
|
|
163
|
+
print(f"- Статус: {rec['status']}")
|
|
164
|
+
print(f"- Дата: {rec['date']}")
|
|
165
|
+
print(f"- Длительность: {rec['duration_min']} мин")
|
|
166
|
+
print(f"- Транскрипт: {rec['transcript_path'] or '—'}")
|
|
167
|
+
print(f"- Протокол: {rec['protocol_path'] or '—'}")
|
|
168
|
+
print("- Участники:")
|
|
169
|
+
for p in rec["participants"]:
|
|
170
|
+
vault = f" -> {p['vault_id']}" if p["vault_id"] else ""
|
|
171
|
+
print(f" - {p['name']} (ktalk:{p['ktalk_id']}){vault}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_mark_processing(reg: Registry, args) -> int:
|
|
176
|
+
reg.mark_processing(args.id)
|
|
177
|
+
print(f"{args.id}: processing")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cmd_mark_done(reg: Registry, args) -> int:
|
|
182
|
+
reg.mark_done(
|
|
183
|
+
args.id,
|
|
184
|
+
transcript_path=args.transcript,
|
|
185
|
+
protocol_path=args.protocol,
|
|
186
|
+
meeting_type=args.meeting_type,
|
|
187
|
+
)
|
|
188
|
+
print(f"{args.id}: done")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_mark_partial(reg: Registry, args) -> int:
|
|
193
|
+
reg.mark_partial(args.id, transcript_path=args.transcript, protocol_path=args.protocol)
|
|
194
|
+
print(f"{args.id}: partial")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cmd_mark_skipped(reg: Registry, args) -> int:
|
|
199
|
+
reg.mark_skipped(args.id)
|
|
200
|
+
print(f"{args.id}: skipped")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _cmd_set_vault_id(reg: Registry, args) -> int:
|
|
205
|
+
reg.set_vault_id(args.id, args.ktalk_id, args.vault_id)
|
|
206
|
+
print(f"{args.id}/{args.ktalk_id} -> {args.vault_id}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cmd_dashboard(reg: Registry, args) -> int:
|
|
211
|
+
recs = reg.list_recordings()
|
|
212
|
+
new = [r for r in recs if r["status"] == "new"]
|
|
213
|
+
stats = {s: sum(1 for r in recs if r["status"] == s) for s in _STATUSES}
|
|
214
|
+
if args.json:
|
|
215
|
+
_print_json({"new": new, "stats": stats})
|
|
216
|
+
return 0
|
|
217
|
+
print("# Дашборд KTalk\n")
|
|
218
|
+
print("## Новые записи")
|
|
219
|
+
if not new:
|
|
220
|
+
print("(нет)")
|
|
221
|
+
for i, r in enumerate(new, 1):
|
|
222
|
+
print(f"{i}. {r['recording_id']} {r['date']} {r['name']}")
|
|
223
|
+
print(
|
|
224
|
+
f"\nСтатистика: новых {stats['new']}, в обработке {stats['processing']}, "
|
|
225
|
+
f"обработано {stats['done']}, пропущено {stats['skipped']}, "
|
|
226
|
+
f"частично {stats['partial']}"
|
|
227
|
+
)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _cmd_export(reg: Registry, args) -> int:
|
|
232
|
+
"""Code review (epic-capability-pairing, Р5): без `--out`, зеркало раньше всегда
|
|
233
|
+
ложилось рядом с резолвленной БД (`.parent`). Когда БД резолвится к машинному
|
|
234
|
+
дефолту централизованного хранилища (нет `--db`/`KTALK_REGISTRY_DB`/
|
|
235
|
+
`.ktalk.toml`), `.parent` — корень самого хранилища, что нарушает ADR-013/
|
|
236
|
+
NFR-16 («export SHALL keep generating that markdown mirror in the host
|
|
237
|
+
project, not in the store»). `args.db_from_machine_default` (см. `main()`)
|
|
238
|
+
различает этот случай — зеркало уходит в проект-хозяина (`CLAUDE_PROJECT_DIR`,
|
|
239
|
+
если задан, иначе cwd — тот же контракт, что у `discover_host_config` для
|
|
240
|
+
голого CLI-вызова), не в хранилище. Явный `--db`/env/конфиг хозяина не
|
|
241
|
+
затронуты — там `.parent` уже указывает в проект-хозяина, как и раньше."""
|
|
242
|
+
text = render_markdown_mirror(reg, full=args.full)
|
|
243
|
+
if args.out:
|
|
244
|
+
out_path = Path(args.out)
|
|
245
|
+
elif getattr(args, "db_from_machine_default", False):
|
|
246
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
247
|
+
host_root = Path(project_dir) if project_dir else Path.cwd()
|
|
248
|
+
out_path = host_root / "registry.md"
|
|
249
|
+
else:
|
|
250
|
+
out_path = Path(resolve_db_path(args.db)).parent / "registry.md"
|
|
251
|
+
out_path.write_text(text, encoding="utf-8")
|
|
252
|
+
if args.json:
|
|
253
|
+
_print_json({"written": str(out_path)})
|
|
254
|
+
else:
|
|
255
|
+
print(f"Зеркало записано: {out_path}")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _cmd_migrate(reg: Registry, args) -> int:
|
|
260
|
+
summary = migrate_from_vault(reg, args.vault_path, dry_run=args.dry_run)
|
|
261
|
+
if args.json:
|
|
262
|
+
_print_json(summary)
|
|
263
|
+
else:
|
|
264
|
+
print(f"Импортировано записей: {summary['recordings']}")
|
|
265
|
+
print(f"Участников: {summary['participants']}")
|
|
266
|
+
print(f"По статусам: {summary['by_status']}")
|
|
267
|
+
if args.dry_run:
|
|
268
|
+
print("(dry-run: ничего не записано)")
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _host_config_to_dict(host_config: HostConfig | None) -> dict:
|
|
273
|
+
if host_config is None:
|
|
274
|
+
return {"registry": {}, "directories": {}, "routing": {}, "integrations": {}}
|
|
275
|
+
return {
|
|
276
|
+
"registry": host_config.registry,
|
|
277
|
+
"directories": host_config.directories,
|
|
278
|
+
"routing": host_config.routing,
|
|
279
|
+
"integrations": host_config.integrations,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cmd_config(reg: Registry | None, args) -> int:
|
|
284
|
+
if getattr(args, "config_command", None) != "show":
|
|
285
|
+
print("Неизвестная подкоманда config (доступно: show)", file=sys.stderr)
|
|
286
|
+
return 2
|
|
287
|
+
host_config = discover_host_config()
|
|
288
|
+
data = _host_config_to_dict(host_config)
|
|
289
|
+
if args.json:
|
|
290
|
+
_print_json(data)
|
|
291
|
+
return 0
|
|
292
|
+
print("# Конфигурация проекта-хозяина (.ktalk.toml)\n")
|
|
293
|
+
if host_config is None:
|
|
294
|
+
print("(конфиг не найден — машинные дефолты)")
|
|
295
|
+
return 0
|
|
296
|
+
for section_name in ("registry", "directories", "routing", "integrations"):
|
|
297
|
+
section = data[section_name]
|
|
298
|
+
print(f"## {section_name}")
|
|
299
|
+
if not section:
|
|
300
|
+
print("(не объявлено)")
|
|
301
|
+
else:
|
|
302
|
+
for key, value in section.items():
|
|
303
|
+
print(f"- {key} = {value}")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_REGISTRY_FREE_COMMANDS = {
|
|
308
|
+
"auth-status",
|
|
309
|
+
"config",
|
|
310
|
+
"create-meeting-preview",
|
|
311
|
+
"create-meeting-confirm",
|
|
312
|
+
"cancel-meeting-preview",
|
|
313
|
+
"cancel-meeting-confirm",
|
|
314
|
+
"search-contacts",
|
|
315
|
+
# ADR-016: санкция на запись — файл в $XDG_CONFIG_HOME, реестр не при чём.
|
|
316
|
+
"sanction",
|
|
317
|
+
# То же основание: файл session-токена (token_file.py) — секрет, не реестр.
|
|
318
|
+
"token",
|
|
319
|
+
# DEV-002 волны 3 (ADR-012 §2а): читающие CLI-эквиваленты MCP-инструментов —
|
|
320
|
+
# только сеть, реестр SQLite не открывают.
|
|
321
|
+
"list-recordings",
|
|
322
|
+
"get-recording",
|
|
323
|
+
"get-transcript",
|
|
324
|
+
"get-summary",
|
|
325
|
+
"get-summary-type",
|
|
326
|
+
"get-participants",
|
|
327
|
+
"download-recording",
|
|
328
|
+
"list-archive",
|
|
329
|
+
"get-chat-messages",
|
|
330
|
+
"list-calendar",
|
|
331
|
+
"get-room",
|
|
332
|
+
# MAJ-04 (security review SEC-003): управляет source/target сама, минуя
|
|
333
|
+
# стандартный Registry(resolve_db_path(args.db)) — если бы команда не была
|
|
334
|
+
# здесь, main() открыл бы ПОСТОРОННИЙ Registry по машинному дефолту как
|
|
335
|
+
# побочный эффект простого запуска команды, что противоречит NFR-12
|
|
336
|
+
# ("миграция — явный шаг, без скрытых побочных эффектов").
|
|
337
|
+
"migrate-to-central-store",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
_HANDLERS = {
|
|
342
|
+
"list": _cmd_list,
|
|
343
|
+
"show": _cmd_show,
|
|
344
|
+
"mark-processing": _cmd_mark_processing,
|
|
345
|
+
"mark-done": _cmd_mark_done,
|
|
346
|
+
"mark-partial": _cmd_mark_partial,
|
|
347
|
+
"mark-skipped": _cmd_mark_skipped,
|
|
348
|
+
"set-vault-id": _cmd_set_vault_id,
|
|
349
|
+
"dashboard": _cmd_dashboard,
|
|
350
|
+
"export": _cmd_export,
|
|
351
|
+
"migrate": _cmd_migrate,
|
|
352
|
+
"sync": cmd_sync,
|
|
353
|
+
"auth-status": cmd_auth_status,
|
|
354
|
+
"config": _cmd_config,
|
|
355
|
+
"create-meeting-preview": cmd_create_meeting_preview,
|
|
356
|
+
"create-meeting-confirm": cmd_create_meeting_confirm,
|
|
357
|
+
"cancel-meeting-preview": cmd_cancel_meeting_preview,
|
|
358
|
+
"cancel-meeting-confirm": cmd_cancel_meeting_confirm,
|
|
359
|
+
"search-contacts": cmd_search_contacts,
|
|
360
|
+
"sanction": cmd_sanction,
|
|
361
|
+
"token": cmd_token,
|
|
362
|
+
"list-recordings": cmd_list_recordings,
|
|
363
|
+
"get-recording": cmd_get_recording,
|
|
364
|
+
"get-transcript": cmd_get_transcript,
|
|
365
|
+
"get-summary": cmd_get_summary,
|
|
366
|
+
"get-summary-type": cmd_get_summary_type,
|
|
367
|
+
"get-participants": cmd_get_participants,
|
|
368
|
+
"download-recording": cmd_download_recording,
|
|
369
|
+
"list-archive": cmd_list_archive,
|
|
370
|
+
"get-chat-messages": cmd_get_chat_messages,
|
|
371
|
+
"list-calendar": cmd_list_calendar,
|
|
372
|
+
"get-room": cmd_get_room,
|
|
373
|
+
"migrate-to-central-store": cmd_migrate_to_central_store,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def main(argv: list[str] | None = None) -> int:
|
|
378
|
+
parser = build_parser()
|
|
379
|
+
args = parser.parse_args(argv)
|
|
380
|
+
if getattr(args, "version", False):
|
|
381
|
+
from ktalk_cli import __dist_name__, __version__
|
|
382
|
+
|
|
383
|
+
print(f"{__dist_name__} {__version__}")
|
|
384
|
+
return 0
|
|
385
|
+
if not args.command:
|
|
386
|
+
parser.print_help()
|
|
387
|
+
return 2
|
|
388
|
+
handler = _HANDLERS.get(args.command)
|
|
389
|
+
if handler is None:
|
|
390
|
+
parser.print_help()
|
|
391
|
+
return 2
|
|
392
|
+
try:
|
|
393
|
+
if args.command in _REGISTRY_FREE_COMMANDS:
|
|
394
|
+
return handler(None, args)
|
|
395
|
+
# MAJ-01 (security review SEC-003): третий источник приоритета FR-23
|
|
396
|
+
# (`.ktalk.toml` → registry.db_path) подключается здесь — раньше
|
|
397
|
+
# discover_host_config() не вызывался в этой ветке, третий источник был
|
|
398
|
+
# мёртв в проде. Не для _REGISTRY_FREE_COMMANDS — эти команды не открывают
|
|
399
|
+
# реестр и не нуждаются в резолвинге пути к нему вовсе.
|
|
400
|
+
host_config = discover_host_config()
|
|
401
|
+
# MAJ-02: окно мутации umask сужено ровно до конструктора Registry (где
|
|
402
|
+
# sqlite3.connect создаёт registry.db/-wal/-shm, см. registry.py.__init__) —
|
|
403
|
+
# не до конца обработки хендлера. Восстанавливается сразу после открытия,
|
|
404
|
+
# до того как хендлер начнёт писать что-либо ещё (например, `_cmd_export`
|
|
405
|
+
# → registry.md вне хранилища не должен унаследовать 0600 без явного
|
|
406
|
+
# решения). resolve_store_root() (store.py) продолжает мутировать umask
|
|
407
|
+
# сама для вызывающих, не оборачивающих её сами (обратная совместимость,
|
|
408
|
+
# см. dev-заметку) — здесь мутация полностью укрывает и этот случай.
|
|
409
|
+
old_umask = os.umask(0o077)
|
|
410
|
+
try:
|
|
411
|
+
raw_cli_db = args.db
|
|
412
|
+
db_path = resolve_db_path(raw_cli_db, host_config=host_config)
|
|
413
|
+
# MAJ-03: единственный резолв пути на команду. Хендлеры, которым нужен
|
|
414
|
+
# каталог реестра (`_cmd_export` -> registry.md), обязаны опираться на
|
|
415
|
+
# уже резолвленный путь — повторный resolve_db_path(args.db) без
|
|
416
|
+
# host_config терял третий источник FR-23 и уводил зеркало к машинному
|
|
417
|
+
# дефолту, пока сам реестр читался из `.ktalk.toml`.
|
|
418
|
+
args.db = str(db_path)
|
|
419
|
+
# Code review (epic-capability-pairing, Р5): признак «путь резолвился к
|
|
420
|
+
# машинному дефолту централизованного хранилища», а не к --db/env/
|
|
421
|
+
# конфигу хозяина — считается ДО перезаписи `args.db` выше, той же
|
|
422
|
+
# логикой приоритета, что `resolve_db_path` (ADR-013 §3), без повторного
|
|
423
|
+
# вызова `resolve_store_root()` (не создавать хранилище стороны ради
|
|
424
|
+
# одной проверки). Использует только `_cmd_export`, чтобы не положить
|
|
425
|
+
# зеркало проекта-хозяина внутрь хранилища (NFR-16 AC).
|
|
426
|
+
args.db_from_machine_default = not (
|
|
427
|
+
raw_cli_db
|
|
428
|
+
or os.environ.get("KTALK_REGISTRY_DB")
|
|
429
|
+
or (host_config is not None and host_config.registry.get("db_path"))
|
|
430
|
+
)
|
|
431
|
+
reg = Registry(db_path)
|
|
432
|
+
finally:
|
|
433
|
+
os.umask(old_umask)
|
|
434
|
+
with reg:
|
|
435
|
+
return handler(reg, args)
|
|
436
|
+
except Exception as exc: # noqa: BLE001 - surface as CLI error
|
|
437
|
+
# NFR-5: последний рубеж маскирования перед печатью — покрывает и КTalkError
|
|
438
|
+
# (уже не несёт секрет по конструкции), и любое непредвиденное исключение,
|
|
439
|
+
# которое сюда всплывёт (см. ktalk_cli.config.redact_secrets).
|
|
440
|
+
print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""ADR-010-spec §Dev: CLI `search-contacts --query` — текстовый поиск, не
|
|
2
|
+
подставляет `key` никуда автоматически (оператор передаёт его вручную дальше
|
|
3
|
+
в `--required-attendee-key`).
|
|
4
|
+
|
|
5
|
+
DEV-009: коды возврата разведены на три исхода, не два — `0 найдено` и «сетевой/
|
|
6
|
+
авторизационный отказ» раньше делили один и тот же `rc == 1`, различимый только
|
|
7
|
+
каналом stdout/stderr. Потребитель, читающий только `rc`, эту разницу не видел
|
|
8
|
+
(находка SA-006/QA-005/PM). Таблица кодов:
|
|
9
|
+
|
|
10
|
+
0 — найден хотя бы один кандидат
|
|
11
|
+
1 — сетевая/авторизационная ошибка (без изменений, `Ошибка:` на stderr)
|
|
12
|
+
2 — 0 кандидатов, отказа не было (`Ничего не найдено` на stdout)
|
|
13
|
+
|
|
14
|
+
Совместимость: единственный существующий потребитель кода возврата —
|
|
15
|
+
`tests/test_search_contacts.py::test_ac_10_1_zero_matches_cli_message_names_the_query_and_nonzero_exit`,
|
|
16
|
+
он проверяет `rc != 0`, не конкретное значение — новый код 2 его не ломает.
|
|
17
|
+
MCP-инструмент `ktalk_search_contacts` (`tools_contacts.py`) код возврата CLI не
|
|
18
|
+
использует вовсе (свой процесс, отдаёт текст через `render_tool_output`).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
from ktalk_cli.client import KTalkClient
|
|
28
|
+
from ktalk_cli.config import Settings, redact_secrets
|
|
29
|
+
from ktalk_cli.contacts import search_contacts
|
|
30
|
+
from ktalk_cli.formatters import format_raw, format_search_contacts
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_subparsers(sub) -> None:
|
|
34
|
+
p = sub.add_parser("search-contacts", help="Поиск контактов (ADR-010)")
|
|
35
|
+
p.add_argument("--query", required=True, help="Текст имени/фамилии (не логин)")
|
|
36
|
+
p.add_argument("--json", action="store_true")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _search_over_network(query: str) -> list[dict]:
|
|
40
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
41
|
+
return await search_contacts(client, query)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_search_contacts(_reg, args: argparse.Namespace) -> int:
|
|
45
|
+
try:
|
|
46
|
+
candidates = asyncio.run(_search_over_network(args.query))
|
|
47
|
+
except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
|
|
48
|
+
print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
if args.json:
|
|
52
|
+
print(format_raw({"query": args.query, "candidates": candidates}))
|
|
53
|
+
else:
|
|
54
|
+
print(format_search_contacts(candidates, query=args.query))
|
|
55
|
+
return 0 if candidates else 2
|
ktalk_cli/cli_content.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""CLI-эквиваленты MCP-инструментов сущности «Запись» (DEV-002 волны 3, ADR-012
|
|
2
|
+
§2а): `list-recordings`, `get-recording`, `get-transcript`, `get-summary`,
|
|
3
|
+
`get-summary-type`, `get-participants`, `download-recording` — те же операции,
|
|
4
|
+
что `tools_recordings.py`, вторая обёртка над тем же клиентом/форматтерами (не
|
|
5
|
+
дублирует логику: `render_tool_output`/`render_transcript_output` общие с MCP).
|
|
6
|
+
|
|
7
|
+
Все команды — `_REGISTRY_FREE_COMMANDS` (не открывают SQLite, читают только сеть).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
from ktalk_cli.client import KTalkClient
|
|
18
|
+
from ktalk_cli.config import Settings, redact_secrets
|
|
19
|
+
from ktalk_cli.download import download_recording_file
|
|
20
|
+
from ktalk_cli.formatters import (
|
|
21
|
+
format_download_result,
|
|
22
|
+
format_participants,
|
|
23
|
+
format_recording,
|
|
24
|
+
format_recordings_list,
|
|
25
|
+
format_summary,
|
|
26
|
+
format_summary_by_type,
|
|
27
|
+
render_tool_output,
|
|
28
|
+
render_transcript_output,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_subparsers(sub) -> None:
|
|
33
|
+
p_list = sub.add_parser("list-recordings", help="Список записей KTalk (сеть)")
|
|
34
|
+
p_list.add_argument("--query", default=None)
|
|
35
|
+
p_list.add_argument("--start-from", default=None)
|
|
36
|
+
p_list.add_argument("--start-to", default=None)
|
|
37
|
+
p_list.add_argument("--top", type=int, default=30)
|
|
38
|
+
p_list.add_argument("--order", default="byTimeNewFirst")
|
|
39
|
+
p_list.add_argument("--page-token", default=None)
|
|
40
|
+
p_list.add_argument("--json", action="store_true")
|
|
41
|
+
|
|
42
|
+
p_get = sub.add_parser("get-recording", help="Детали записи (сеть)")
|
|
43
|
+
p_get.add_argument("recording_key")
|
|
44
|
+
p_get.add_argument("--json", action="store_true")
|
|
45
|
+
|
|
46
|
+
p_tr = sub.add_parser("get-transcript", help="Транскрипт записи (сеть, с чанкингом)")
|
|
47
|
+
p_tr.add_argument("recording_key")
|
|
48
|
+
p_tr.add_argument("--chunk", type=int, default=0)
|
|
49
|
+
p_tr.add_argument("--chunk-size", type=int, default=30000)
|
|
50
|
+
p_tr.add_argument("--json", action="store_true")
|
|
51
|
+
|
|
52
|
+
p_sum = sub.add_parser("get-summary", help="Полное саммари записи (сеть)")
|
|
53
|
+
p_sum.add_argument("recording_key")
|
|
54
|
+
p_sum.add_argument("--json", action="store_true")
|
|
55
|
+
|
|
56
|
+
p_sum_type = sub.add_parser("get-summary-type", help="Саммари одного типа (сеть)")
|
|
57
|
+
p_sum_type.add_argument("recording_key")
|
|
58
|
+
p_sum_type.add_argument("--type", dest="summary_type", required=True)
|
|
59
|
+
p_sum_type.add_argument("--json", action="store_true")
|
|
60
|
+
|
|
61
|
+
p_part = sub.add_parser("get-participants", help="Полный состав участников (сеть, FR-8)")
|
|
62
|
+
p_part.add_argument("recording_key")
|
|
63
|
+
p_part.add_argument("--json", action="store_true")
|
|
64
|
+
|
|
65
|
+
p_dl = sub.add_parser("download-recording", help="Скачать видеофайл записи (сеть, FR-7)")
|
|
66
|
+
p_dl.add_argument("recording_key")
|
|
67
|
+
p_dl.add_argument("--target", required=True, help="Путь для записи файла")
|
|
68
|
+
p_dl.add_argument("--quality", default=None)
|
|
69
|
+
p_dl.add_argument("--json", action="store_true")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run(
|
|
73
|
+
coro: Awaitable[object],
|
|
74
|
+
formatter: Callable[..., str],
|
|
75
|
+
*,
|
|
76
|
+
json_flag: bool,
|
|
77
|
+
**formatter_kwargs: object,
|
|
78
|
+
) -> int:
|
|
79
|
+
try:
|
|
80
|
+
data = asyncio.run(coro)
|
|
81
|
+
except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
|
|
82
|
+
print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
fmt = "raw" if json_flag else "markdown"
|
|
85
|
+
print(render_tool_output(data, fmt, formatter, **formatter_kwargs))
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _list_recordings(args: argparse.Namespace) -> dict:
|
|
90
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
91
|
+
return await client.list_recordings(
|
|
92
|
+
query=args.query,
|
|
93
|
+
start_from=args.start_from,
|
|
94
|
+
start_to=args.start_to,
|
|
95
|
+
top=args.top,
|
|
96
|
+
order_mode=args.order,
|
|
97
|
+
page_token=args.page_token,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_list_recordings(_reg, args: argparse.Namespace) -> int:
|
|
102
|
+
return _run(_list_recordings(args), format_recordings_list, json_flag=args.json)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _get_recording(args: argparse.Namespace) -> dict:
|
|
106
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
107
|
+
return await client.get_recording(args.recording_key)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_get_recording(_reg, args: argparse.Namespace) -> int:
|
|
111
|
+
return _run(_get_recording(args), format_recording, json_flag=args.json)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_get_transcript(_reg, args: argparse.Namespace) -> int:
|
|
115
|
+
async def _fetch() -> dict:
|
|
116
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
117
|
+
return await client.get_transcript(args.recording_key)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
data = asyncio.run(_fetch())
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
|
|
122
|
+
print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
fmt = "raw" if args.json else "markdown"
|
|
125
|
+
print(render_transcript_output(data, fmt, args.chunk, args.chunk_size))
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _get_summary(args: argparse.Namespace) -> dict:
|
|
130
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
131
|
+
return await client.get_summary(args.recording_key)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_get_summary(_reg, args: argparse.Namespace) -> int:
|
|
135
|
+
return _run(_get_summary(args), format_summary, json_flag=args.json)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _get_summary_by_type(args: argparse.Namespace) -> dict:
|
|
139
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
140
|
+
return await client.get_summary_by_type(args.recording_key, args.summary_type)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_get_summary_type(_reg, args: argparse.Namespace) -> int:
|
|
144
|
+
return _run(
|
|
145
|
+
_get_summary_by_type(args),
|
|
146
|
+
format_summary_by_type,
|
|
147
|
+
json_flag=args.json,
|
|
148
|
+
summary_type=args.summary_type,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _get_participants(args: argparse.Namespace) -> dict:
|
|
153
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
154
|
+
return await client.get_full_participants(args.recording_key)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_get_participants(_reg, args: argparse.Namespace) -> int:
|
|
158
|
+
return _run(_get_participants(args), format_participants, json_flag=args.json)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _download_recording(args: argparse.Namespace) -> dict:
|
|
162
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
163
|
+
return await download_recording_file(client, args.recording_key, args.target, args.quality)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_download_recording(_reg, args: argparse.Namespace) -> int:
|
|
167
|
+
return _run(_download_recording(args), format_download_result, json_flag=args.json)
|