pocket-coding 0.1.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.
poco/app.py ADDED
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ import threading
8
+ import time
9
+ from collections import deque
10
+ from copy import deepcopy
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+ from textual.app import App, ComposeResult
15
+ from textual.binding import Binding
16
+ from textual.containers import Container, Horizontal, Vertical, VerticalScroll
17
+ from textual.widgets import Button, Checkbox, ContentSwitcher, Footer, Header, Input, Label, Log, Static
18
+
19
+ from .bridge import AppConfig, BridgeApp
20
+
21
+
22
+ LOG = logging.getLogger("poco")
23
+ CONFIG_DIR = Path.home() / ".config" / "poco"
24
+ CONFIG_PATH = CONFIG_DIR / "config.json"
25
+ STATE_DIR = Path.home() / ".local" / "state" / "poco"
26
+ THREAD_STATE_PATH = STATE_DIR / "threads.json"
27
+ WORKER_STATE_PATH = STATE_DIR / "workers.json"
28
+
29
+ DEFAULT_CONFIG: Dict[str, Any] = {
30
+ "feishu": {
31
+ "app_id": "",
32
+ "app_secret": "",
33
+ "encrypt_key": "",
34
+ "verification_token": "",
35
+ "allowed_open_ids": [],
36
+ "allow_all_users": False,
37
+ },
38
+ "codex": {
39
+ "bin": "codex",
40
+ "app_server_args": "",
41
+ "model": "",
42
+ "approval_policy": "never",
43
+ "sandbox": "danger-full-access",
44
+ },
45
+ "bridge": {
46
+ "message_limit": 1400,
47
+ "live_update_initial_seconds": 1,
48
+ "live_update_max_seconds": 8,
49
+ "max_message_edits": 18,
50
+ },
51
+ }
52
+
53
+ INPUT_IDS = {
54
+ "feishu.app_id": "feishu_app_id",
55
+ "feishu.app_secret": "feishu_app_secret",
56
+ "feishu.encrypt_key": "feishu_encrypt_key",
57
+ "feishu.verification_token": "feishu_verification_token",
58
+ "feishu.allowed_open_ids": "feishu_allowed_open_ids",
59
+ "codex.bin": "codex_bin",
60
+ "codex.app_server_args": "codex_app_server_args",
61
+ "codex.model": "codex_model",
62
+ "codex.approval_policy": "codex_approval_policy",
63
+ "codex.sandbox": "codex_sandbox",
64
+ "bridge.message_limit": "bridge_message_limit",
65
+ "bridge.live_update_initial_seconds": "bridge_live_update_initial_seconds",
66
+ "bridge.live_update_max_seconds": "bridge_live_update_max_seconds",
67
+ "bridge.max_message_edits": "bridge_max_message_edits",
68
+ }
69
+
70
+
71
+ def deep_merge(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
72
+ merged = deepcopy(base)
73
+ for key, value in patch.items():
74
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
75
+ merged[key] = deep_merge(merged[key], value)
76
+ else:
77
+ merged[key] = value
78
+ return merged
79
+
80
+
81
+ def mask_secret(value: str) -> str:
82
+ if len(value) <= 6:
83
+ return "*" * len(value)
84
+ return value[:3] + "*" * (len(value) - 6) + value[-3:]
85
+
86
+
87
+ def config_ready(config: Dict[str, Any]) -> bool:
88
+ feishu = config["feishu"]
89
+ return bool(feishu["app_id"] and feishu["app_secret"])
90
+
91
+
92
+ def ensure_dirs() -> None:
93
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
94
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
95
+
96
+
97
+ def set_nested(config: Dict[str, Any], path: str, value: Any) -> Dict[str, Any]:
98
+ parts = path.split(".")
99
+ current = config
100
+ for part in parts[:-1]:
101
+ current = current.setdefault(part, {})
102
+ current[parts[-1]] = value
103
+ return config
104
+
105
+
106
+ class ConfigStore:
107
+ def __init__(self, path: Path) -> None:
108
+ self._path = path
109
+ self._lock = threading.Lock()
110
+
111
+ def load(self) -> Dict[str, Any]:
112
+ with self._lock:
113
+ if not self._path.exists():
114
+ return deepcopy(DEFAULT_CONFIG)
115
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
116
+ return deep_merge(DEFAULT_CONFIG, raw)
117
+
118
+ def save(self, config: Dict[str, Any]) -> None:
119
+ with self._lock:
120
+ self._path.parent.mkdir(parents=True, exist_ok=True)
121
+ self._path.write_text(
122
+ json.dumps(config, ensure_ascii=False, indent=2),
123
+ encoding="utf-8",
124
+ )
125
+ try:
126
+ os.chmod(self._path, 0o600)
127
+ except Exception:
128
+ LOG.exception("Failed to chmod config file")
129
+
130
+ def masked(self) -> Dict[str, Any]:
131
+ config = self.load()
132
+ masked = deepcopy(config)
133
+ if masked["feishu"]["app_secret"]:
134
+ masked["feishu"]["app_secret"] = mask_secret(masked["feishu"]["app_secret"])
135
+ return masked
136
+
137
+
138
+ class RingLogHandler(logging.Handler):
139
+ def __init__(self, limit: int = 500) -> None:
140
+ super().__init__()
141
+ self._records = deque(maxlen=limit)
142
+ self._lock = threading.Lock()
143
+
144
+ def emit(self, record: logging.LogRecord) -> None:
145
+ message = self.format(record)
146
+ payload = {
147
+ "time": int(record.created),
148
+ "level": record.levelname,
149
+ "logger": record.name,
150
+ "message": message,
151
+ }
152
+ with self._lock:
153
+ self._records.append(payload)
154
+
155
+ def snapshot(self, limit: int = 500) -> list[dict]:
156
+ with self._lock:
157
+ return list(self._records)[-limit:]
158
+
159
+
160
+ class BridgeRunner:
161
+ def __init__(self) -> None:
162
+ self._thread: Optional[threading.Thread] = None
163
+ self._last_error: str = ""
164
+ self._started_at: Optional[float] = None
165
+ self._lock = threading.Lock()
166
+
167
+ def status(self) -> Dict[str, Any]:
168
+ with self._lock:
169
+ return {
170
+ "running": self._thread is not None and self._thread.is_alive(),
171
+ "started_at": self._started_at,
172
+ "last_error": self._last_error,
173
+ }
174
+
175
+ def start(self, config: Dict[str, Any]) -> bool:
176
+ with self._lock:
177
+ if self._thread is not None and self._thread.is_alive():
178
+ return False
179
+ self._last_error = ""
180
+ app_config = build_app_config(config)
181
+ thread = threading.Thread(
182
+ target=self._run_bridge,
183
+ args=(app_config,),
184
+ daemon=True,
185
+ name="poco-bridge",
186
+ )
187
+ self._thread = thread
188
+ self._started_at = time.time()
189
+ thread.start()
190
+ return True
191
+
192
+ def _run_bridge(self, config: AppConfig) -> None:
193
+ try:
194
+ bridge = BridgeApp(config)
195
+ bridge.run()
196
+ except Exception as exc:
197
+ with self._lock:
198
+ self._last_error = str(exc)
199
+ LOG.exception("Bridge runtime crashed")
200
+
201
+
202
+ def build_app_config(config: Dict[str, Any]) -> AppConfig:
203
+ feishu = config["feishu"]
204
+ codex = config["codex"]
205
+ bridge = config["bridge"]
206
+ return AppConfig(
207
+ feishu_app_id=feishu["app_id"],
208
+ feishu_app_secret=feishu["app_secret"],
209
+ feishu_encrypt_key=feishu["encrypt_key"],
210
+ feishu_verification_token=feishu["verification_token"],
211
+ codex_bin=codex["bin"],
212
+ codex_app_server_args=codex["app_server_args"],
213
+ codex_model=codex["model"],
214
+ codex_approval_policy=codex["approval_policy"],
215
+ codex_sandbox=codex["sandbox"],
216
+ message_limit=int(bridge["message_limit"]),
217
+ live_update_initial_seconds=int(bridge["live_update_initial_seconds"]),
218
+ live_update_max_seconds=int(bridge["live_update_max_seconds"]),
219
+ max_message_edits=int(bridge["max_message_edits"]),
220
+ allowed_open_ids=set(feishu["allowed_open_ids"]),
221
+ allow_all_users=bool(feishu["allow_all_users"]),
222
+ thread_state_path=str(THREAD_STATE_PATH),
223
+ worker_state_path=str(WORKER_STATE_PATH),
224
+ )
225
+
226
+
227
+ class PoCoService:
228
+ def __init__(self, store: ConfigStore, logs: RingLogHandler) -> None:
229
+ self.store = store
230
+ self.logs = logs
231
+ self.bridge = BridgeRunner()
232
+
233
+ def load_config(self) -> Dict[str, Any]:
234
+ return self.store.load()
235
+
236
+ def save_config(self, config: Dict[str, Any]) -> None:
237
+ self.store.save(config)
238
+
239
+ def masked_config(self) -> Dict[str, Any]:
240
+ return self.store.masked()
241
+
242
+ def bridge_status(self) -> Dict[str, Any]:
243
+ return self.bridge.status()
244
+
245
+ def start_bridge(self) -> bool:
246
+ config = self.load_config()
247
+ if not config_ready(config):
248
+ raise ValueError("配置还不完整,至少需要 Feishu App ID 和 App Secret。")
249
+ return self.bridge.start(config)
250
+
251
+
252
+ class PoCoTui(App[None]):
253
+ CSS = """
254
+ Screen {
255
+ layout: vertical;
256
+ }
257
+
258
+ #main {
259
+ height: 1fr;
260
+ }
261
+
262
+ #actions {
263
+ height: auto;
264
+ padding: 0 1;
265
+ dock: top;
266
+ }
267
+
268
+ #summary-grid {
269
+ height: auto;
270
+ layout: grid;
271
+ grid-size: 2 4;
272
+ grid-gutter: 1 1;
273
+ padding: 1;
274
+ }
275
+
276
+ .panel {
277
+ border: round $primary;
278
+ padding: 0 1;
279
+ }
280
+
281
+ .field {
282
+ height: auto;
283
+ margin-bottom: 1;
284
+ }
285
+
286
+ .field Label {
287
+ padding-bottom: 0;
288
+ }
289
+
290
+ #config-scroll {
291
+ padding: 0 1 1 1;
292
+ }
293
+
294
+ #logs {
295
+ height: 1fr;
296
+ border: round $accent;
297
+ }
298
+ """
299
+
300
+ BINDINGS = [
301
+ Binding("q", "quit", "Quit"),
302
+ Binding("ctrl+s", "save_config", "Save"),
303
+ Binding("ctrl+r", "save_and_restart", "Save & Restart"),
304
+ Binding("f2", "show_dashboard", "Dashboard"),
305
+ Binding("f3", "show_config", "Config"),
306
+ Binding("f4", "show_logs", "Logs"),
307
+ ]
308
+
309
+ def __init__(self, service: PoCoService, *, focus_config: bool = False) -> None:
310
+ super().__init__()
311
+ self._service = service
312
+ self._focus_config = focus_config
313
+ self._log_cursor = 0
314
+
315
+ def compose(self) -> ComposeResult:
316
+ yield Header(show_clock=True)
317
+ with Horizontal(id="actions"):
318
+ yield Button("Save & Restart", id="save_and_restart", variant="success")
319
+ yield Button("Save Config", id="save_config", variant="primary")
320
+ yield Button("Dashboard", id="open_dashboard")
321
+ yield Button("Config", id="open_config")
322
+ yield Button("Logs", id="open_logs")
323
+ yield Button("Quit", id="quit_app", variant="error")
324
+ with ContentSwitcher(initial="dashboard", id="main"):
325
+ with Vertical(id="dashboard"):
326
+ with Container(classes="panel"):
327
+ yield Static("", id="status_summary")
328
+ with Container(classes="panel"):
329
+ yield Static("", id="status_hint")
330
+ with Container(classes="panel"):
331
+ yield Static("", id="status_paths")
332
+ with Vertical(id="config"):
333
+ with VerticalScroll(id="config-scroll"):
334
+ yield self._section_label("Feishu")
335
+ yield self._field("App ID", Input(id=INPUT_IDS["feishu.app_id"], placeholder="cli_xxx"))
336
+ yield self._field(
337
+ "App Secret",
338
+ Input(id=INPUT_IDS["feishu.app_secret"], password=True, placeholder="app secret"),
339
+ )
340
+ yield self._field("Encrypt Key", Input(id=INPUT_IDS["feishu.encrypt_key"]))
341
+ yield self._field("Verification Token", Input(id=INPUT_IDS["feishu.verification_token"]))
342
+ yield self._field(
343
+ "Allow all users",
344
+ Checkbox("", id="feishu_allow_all_users"),
345
+ )
346
+ yield self._field(
347
+ "Allowed open_ids",
348
+ Input(id=INPUT_IDS["feishu.allowed_open_ids"], placeholder="ou_xxx,ou_yyy"),
349
+ )
350
+
351
+ yield self._section_label("Codex")
352
+ yield self._field("Codex binary", Input(id=INPUT_IDS["codex.bin"], placeholder="codex"))
353
+ yield self._field("Model", Input(id=INPUT_IDS["codex.model"], placeholder="optional"))
354
+ yield self._field("App server args", Input(id=INPUT_IDS["codex.app_server_args"]))
355
+ yield self._field("Approval policy", Input(id=INPUT_IDS["codex.approval_policy"]))
356
+ yield self._field("Sandbox", Input(id=INPUT_IDS["codex.sandbox"]))
357
+
358
+ yield self._section_label("Bridge")
359
+ yield self._field("Message limit", Input(id=INPUT_IDS["bridge.message_limit"]))
360
+ yield self._field(
361
+ "Live update initial seconds",
362
+ Input(id=INPUT_IDS["bridge.live_update_initial_seconds"]),
363
+ )
364
+ yield self._field(
365
+ "Live update max seconds",
366
+ Input(id=INPUT_IDS["bridge.live_update_max_seconds"]),
367
+ )
368
+ yield self._field("Max message edits", Input(id=INPUT_IDS["bridge.max_message_edits"]))
369
+ with Vertical(id="logs-tab"):
370
+ yield Log(id="logs", auto_scroll=True, highlight=True)
371
+ yield Footer()
372
+
373
+ def _section_label(self, text: str) -> Static:
374
+ return Static(text, classes="panel")
375
+
376
+ def _field(self, label: str, widget: Input | Checkbox) -> Vertical:
377
+ return Vertical(Label(label), widget, classes="field")
378
+
379
+ def on_mount(self) -> None:
380
+ self.title = "PoCo"
381
+ self.sub_title = "Pocket Coding for Feishu"
382
+ self._load_form_from_config()
383
+ self._refresh_runtime()
384
+ self.set_interval(1.0, self._refresh_runtime)
385
+ self.call_after_refresh(self._boot)
386
+
387
+ def _boot(self) -> None:
388
+ config = self._service.load_config()
389
+ if self._focus_config or not config_ready(config):
390
+ self._activate_tab("config")
391
+ self.notify("需要先完成配置,再启动 bridge。", severity="warning")
392
+ self._focus_first_missing_required_field(config)
393
+ return
394
+ self._service.start_bridge()
395
+ self.notify("PoCo bridge 已启动。", severity="information")
396
+
397
+ def _refresh_runtime(self) -> None:
398
+ self._refresh_status()
399
+ self._refresh_logs()
400
+
401
+ def _refresh_status(self) -> None:
402
+ config = self._service.load_config()
403
+ bridge = self._service.bridge_status()
404
+ started_at = bridge["started_at"]
405
+ started_display = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(started_at)) if started_at else "-"
406
+ summary = (
407
+ f"config_ready: {config_ready(config)}\n"
408
+ f"bridge_running: {bridge['running']}\n"
409
+ f"bridge_started_at: {started_display}\n"
410
+ f"bridge_last_error: {bridge['last_error'] or '-'}"
411
+ )
412
+ hint = (
413
+ "F2 Dashboard F3 Config F4 Logs\n"
414
+ "Ctrl+S 保存配置 Ctrl+R 保存并重启\n"
415
+ "Save & Restart 会重启 PoCo 本身,并按新配置重新启动 bridge。"
416
+ )
417
+ paths = (
418
+ f"config_path: {CONFIG_PATH}\n"
419
+ f"thread_state_path: {THREAD_STATE_PATH}\n"
420
+ f"worker_state_path: {WORKER_STATE_PATH}"
421
+ )
422
+ self.query_one("#status_summary", Static).update(summary)
423
+ self.query_one("#status_hint", Static).update(hint)
424
+ self.query_one("#status_paths", Static).update(paths)
425
+
426
+ def _refresh_logs(self) -> None:
427
+ items = self._service.logs.snapshot(limit=500)
428
+ log_widget = self.query_one("#logs", Log)
429
+ if len(items) < self._log_cursor:
430
+ self._log_cursor = 0
431
+ log_widget.clear()
432
+ for item in items[self._log_cursor:]:
433
+ log_widget.write_line(item["message"])
434
+ self._log_cursor = len(items)
435
+
436
+ def _load_form_from_config(self) -> None:
437
+ config = self._service.load_config()
438
+ for path, widget_id in INPUT_IDS.items():
439
+ value = self._get_config_value(config, path)
440
+ text = ",".join(value) if isinstance(value, list) else str(value)
441
+ self.query_one(f"#{widget_id}", Input).value = text
442
+ allow_all = bool(config["feishu"]["allow_all_users"])
443
+ self.query_one("#feishu_allow_all_users", Checkbox).value = allow_all
444
+ self.query_one(f"#{INPUT_IDS['feishu.allowed_open_ids']}", Input).disabled = allow_all
445
+
446
+ def _config_from_form(self) -> Dict[str, Any]:
447
+ config = deepcopy(DEFAULT_CONFIG)
448
+ for path, widget_id in INPUT_IDS.items():
449
+ raw = self.query_one(f"#{widget_id}", Input).value.strip()
450
+ if path == "feishu.allowed_open_ids":
451
+ value = [item.strip() for item in raw.split(",") if item.strip()]
452
+ elif path.startswith("bridge."):
453
+ try:
454
+ value = int(raw)
455
+ except ValueError as exc:
456
+ raise ValueError(f"{path} 必须是整数。") from exc
457
+ else:
458
+ value = raw
459
+ set_nested(config, path, value)
460
+ config["feishu"]["allow_all_users"] = self.query_one("#feishu_allow_all_users", Checkbox).value
461
+ return deep_merge(self._service.load_config(), config)
462
+
463
+ def _get_config_value(self, config: Dict[str, Any], path: str) -> Any:
464
+ value: Any = config
465
+ for part in path.split("."):
466
+ value = value[part]
467
+ return value
468
+
469
+ def _focus_first_missing_required_field(self, config: Dict[str, Any]) -> None:
470
+ if not config["feishu"]["app_id"]:
471
+ self.query_one(f"#{INPUT_IDS['feishu.app_id']}", Input).focus()
472
+ return
473
+ if not config["feishu"]["app_secret"]:
474
+ self.query_one(f"#{INPUT_IDS['feishu.app_secret']}", Input).focus()
475
+ return
476
+ self.query_one(f"#{INPUT_IDS['codex.bin']}", Input).focus()
477
+
478
+ def _activate_tab(self, tab_id: str) -> None:
479
+ self.query_one(ContentSwitcher).current = tab_id
480
+
481
+ def action_save_config(self) -> None:
482
+ self._save_config()
483
+
484
+ def action_save_and_restart(self) -> None:
485
+ self._save_and_restart()
486
+
487
+ def action_show_dashboard(self) -> None:
488
+ self._activate_tab("dashboard")
489
+
490
+ def action_show_config(self) -> None:
491
+ self._activate_tab("config")
492
+
493
+ def action_show_logs(self) -> None:
494
+ self._activate_tab("logs-tab")
495
+
496
+ def on_button_pressed(self, event: Button.Pressed) -> None:
497
+ button_id = event.button.id
498
+ if button_id == "save_and_restart":
499
+ self._save_and_restart()
500
+ return
501
+ if button_id == "save_config":
502
+ self._save_config()
503
+ return
504
+ if button_id == "open_dashboard":
505
+ self._activate_tab("dashboard")
506
+ return
507
+ if button_id == "open_config":
508
+ self._activate_tab("config")
509
+ return
510
+ if button_id == "open_logs":
511
+ self._activate_tab("logs-tab")
512
+ return
513
+ if button_id == "quit_app":
514
+ self.exit()
515
+
516
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
517
+ if event.checkbox.id != "feishu_allow_all_users":
518
+ return
519
+ allowed = self.query_one(f"#{INPUT_IDS['feishu.allowed_open_ids']}", Input)
520
+ allowed.disabled = event.value
521
+
522
+ def _save_config(self) -> None:
523
+ try:
524
+ config = self._config_from_form()
525
+ except ValueError as exc:
526
+ self.notify(str(exc), severity="error")
527
+ return
528
+ self._service.save_config(config)
529
+ self._refresh_status()
530
+ self.notify("配置已保存。", severity="information")
531
+
532
+ def _save_and_restart(self) -> None:
533
+ try:
534
+ config = self._config_from_form()
535
+ except ValueError as exc:
536
+ self.notify(str(exc), severity="error")
537
+ return
538
+ self._service.save_config(config)
539
+ self.exit("restart")
540
+
541
+
542
+ def parse_args() -> argparse.Namespace:
543
+ parser = argparse.ArgumentParser(description="PoCo")
544
+ parser.add_argument("--log-level", default="INFO")
545
+ sub = parser.add_subparsers(dest="command")
546
+ config_cmd = sub.add_parser("config", help="open PoCo and focus config tab")
547
+ config_cmd.add_argument("--show", action="store_true", help="show masked config and exit")
548
+ return parser.parse_args()
549
+
550
+
551
+ def main() -> None:
552
+ args = parse_args()
553
+ logging.basicConfig(
554
+ level=getattr(logging, str(getattr(args, "log_level", "INFO")).upper(), logging.INFO),
555
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
556
+ )
557
+ ensure_dirs()
558
+ root_logger = logging.getLogger()
559
+ ring = RingLogHandler()
560
+ ring.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
561
+ root_logger.addHandler(ring)
562
+
563
+ store = ConfigStore(CONFIG_PATH)
564
+ service = PoCoService(store, ring)
565
+
566
+ if args.command == "config" and args.show:
567
+ print(json.dumps(service.masked_config(), ensure_ascii=False, indent=2))
568
+ return
569
+
570
+ app = PoCoTui(service, focus_config=args.command == "config")
571
+ result = app.run()
572
+ if result == "restart":
573
+ os.execv(sys.executable, [sys.executable, *sys.argv])
574
+
575
+
576
+ if __name__ == "__main__":
577
+ main()