netizen-cli 0.10.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 (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """Create or update the Bot-only Feishu/Lark app used by Netizen.
3
+
4
+ This helper runs in the selected installation without requiring Lark CLI.
5
+ Its stdout is a private machine-readable credential channel consumed by setup;
6
+ all user-facing progress goes to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from collections.abc import Callable, Mapping, Sequence
15
+ from typing import Any, IO
16
+
17
+
18
+ REQUIRED_TENANT_SCOPES = (
19
+ "im:message",
20
+ "im:message.group_msg",
21
+ "im:message.group_at_msg.include_bot:readonly",
22
+ "im:message.p2p_msg:readonly",
23
+ "im:chat:read",
24
+ "im:chat.members:read",
25
+ "im:resource",
26
+ "im:message:send_as_bot",
27
+ )
28
+ TENANT_SCOPE_ALTERNATIVES = {
29
+ "im:chat:read": frozenset(("im:chat", "im:chat:read", "im:chat:readonly")),
30
+ }
31
+ TENANT_EVENTS = ("im.message.receive_v1",)
32
+ CALLBACKS = ("card.action.trigger",)
33
+
34
+
35
+ class OnboardingError(RuntimeError):
36
+ """The registration flow did not return a safe credential pair."""
37
+
38
+
39
+ RegisterApp = Callable[..., Mapping[str, Any]]
40
+ QrRenderer = Callable[[str, IO[str]], None]
41
+
42
+
43
+ def _registration_options(app_id: str | None) -> dict[str, Any]:
44
+ options: dict[str, Any] = {
45
+ "source": "netizen-installer",
46
+ "app_preset": {
47
+ "name": "{user}'s Netizen",
48
+ "desc": "Use native Codex threads from Feishu or Lark.",
49
+ },
50
+ "addons": {
51
+ "preset": False,
52
+ "scopes": {"tenant": list(REQUIRED_TENANT_SCOPES)},
53
+ "events": {"items": {"tenant": list(TENANT_EVENTS)}},
54
+ "callbacks": {"items": list(CALLBACKS)},
55
+ },
56
+ }
57
+ if app_id is not None:
58
+ options["app_id"] = app_id
59
+ return options
60
+
61
+
62
+ def _default_qr_renderer(url: str, output: IO[str]) -> None:
63
+ import qrcode
64
+
65
+ code = qrcode.QRCode(
66
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
67
+ border=2,
68
+ )
69
+ code.add_data(url)
70
+ code.make(fit=True)
71
+ code.print_ascii(out=output, tty=output.isatty(), invert=True)
72
+
73
+
74
+ def _credential_payload(result: Mapping[str, Any]) -> dict[str, str | int]:
75
+ app_id = result.get("client_id")
76
+ app_secret = result.get("client_secret")
77
+ if (
78
+ not isinstance(app_id, str)
79
+ or not app_id.startswith("cli_")
80
+ or app_id.strip() != app_id
81
+ or any(ord(character) < 0x20 for character in app_id)
82
+ ):
83
+ raise OnboardingError("registration returned an invalid App ID")
84
+ if (
85
+ not isinstance(app_secret, str)
86
+ or not app_secret
87
+ or app_secret.strip() != app_secret
88
+ or any(ord(character) < 0x20 for character in app_secret)
89
+ ):
90
+ raise OnboardingError("registration returned an invalid App Secret")
91
+ return {"version": 1, "appId": app_id, "appSecret": app_secret}
92
+
93
+
94
+ def run_registration(
95
+ app_id: str | None,
96
+ *,
97
+ register_app: RegisterApp,
98
+ render_qr: QrRenderer = _default_qr_renderer,
99
+ stdout: IO[str] | None = None,
100
+ stderr: IO[str] | None = None,
101
+ ) -> None:
102
+ """Run one official device flow and emit credentials only on success."""
103
+
104
+ credential_output = sys.stdout if stdout is None else stdout
105
+ progress_output = sys.stderr if stderr is None else stderr
106
+ if app_id is not None and not app_id.startswith("cli_"):
107
+ raise OnboardingError("existing App ID must start with cli_")
108
+
109
+ def on_qr_code(info: Mapping[str, Any]) -> None:
110
+ url = info.get("url")
111
+ if not isinstance(url, str) or not url.startswith("https://"):
112
+ raise OnboardingError("registration returned an invalid verification URL")
113
+ print("", file=progress_output)
114
+ print(
115
+ "Open this Feishu/Lark URL and confirm the requested Bot access:",
116
+ file=progress_output,
117
+ )
118
+ print(url, file=progress_output)
119
+ print("Or scan this QR code:", file=progress_output)
120
+ try:
121
+ render_qr(url, progress_output)
122
+ except Exception:
123
+ print(
124
+ "(QR rendering is unavailable; use the URL above.)",
125
+ file=progress_output,
126
+ )
127
+ print(
128
+ "Keep netizen setup running while completing the browser confirmation. "
129
+ "Agents: send the verification URL to the user, retain this process, "
130
+ "and continue reading its output after confirmation. "
131
+ "The link expires after about 10 minutes; rerun setup if it expires. "
132
+ "App credentials are saved automatically; do not request or send the "
133
+ "App Secret in chat.",
134
+ file=progress_output,
135
+ )
136
+ progress_output.flush()
137
+
138
+ def on_status_change(info: Mapping[str, Any]) -> None:
139
+ status = info.get("status")
140
+ if status == "domain_switched":
141
+ print("Continuing with the Lark account domain...", file=progress_output)
142
+ elif status == "slow_down":
143
+ print("Waiting for confirmation...", file=progress_output)
144
+
145
+ result = register_app(
146
+ on_qr_code=on_qr_code,
147
+ on_status_change=on_status_change,
148
+ **_registration_options(app_id),
149
+ )
150
+ payload = _credential_payload(result)
151
+ if app_id is not None and payload["appId"] != app_id:
152
+ raise OnboardingError("registration returned a different App ID")
153
+ json.dump(payload, credential_output, ensure_ascii=True, separators=(",", ":"))
154
+ credential_output.write("\n")
155
+ credential_output.flush()
156
+
157
+
158
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
159
+ parser = argparse.ArgumentParser(description="Netizen Feishu/Lark app onboarding")
160
+ parser.add_argument("--app-id", help="update this existing cli_ application")
161
+ return parser.parse_args(argv)
162
+
163
+
164
+ def main(argv: Sequence[str] | None = None) -> int:
165
+ args = parse_args(argv)
166
+ try:
167
+ import lark_oapi as lark
168
+
169
+ run_registration(args.app_id, register_app=lark.register_app)
170
+ return 0
171
+ except KeyboardInterrupt:
172
+ print("netizen: Feishu/Lark app setup interrupted", file=sys.stderr)
173
+ return 130
174
+ except Exception:
175
+ # Credential material is only emitted after complete validation. Keep
176
+ # failures generic so an unexpected SDK exception can never echo a
177
+ # sensitive response into the terminal or installer logs.
178
+ print(
179
+ "netizen: Feishu/Lark app setup did not complete; "
180
+ "rerun netizen setup or choose manual credentials",
181
+ file=sys.stderr,
182
+ )
183
+ return 1
184
+
185
+
186
+ if __name__ == "__main__":
187
+ raise SystemExit(main())
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Check the effective tenant permission contract for the configured app."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from collections.abc import Callable, Sequence
10
+ from pathlib import Path
11
+ from typing import Any, IO
12
+
13
+
14
+ from .feishu_app_onboarding import (
15
+ REQUIRED_TENANT_SCOPES,
16
+ TENANT_SCOPE_ALTERNATIVES,
17
+ )
18
+ from .lark_app import load_lark_app
19
+
20
+
21
+ class PermissionCheckError(RuntimeError):
22
+ """The platform did not return a trustworthy tenant permission state."""
23
+
24
+
25
+ ScopeLister = Callable[[], Any]
26
+
27
+
28
+ def _missing_required_scopes(response: Any) -> tuple[str, ...]:
29
+ success = getattr(response, "success", None)
30
+ if not callable(success) or not success():
31
+ raise PermissionCheckError("the tenant permission query failed")
32
+ data = getattr(response, "data", None)
33
+ scopes = getattr(data, "scopes", None)
34
+ if not isinstance(scopes, list):
35
+ raise PermissionCheckError("the tenant permission query returned invalid data")
36
+
37
+ granted: set[str] = set()
38
+ for scope in scopes:
39
+ name = getattr(scope, "scope_name", None)
40
+ status = getattr(scope, "grant_status", None)
41
+ scope_type = getattr(scope, "scope_type", None)
42
+ if (
43
+ not isinstance(name, str)
44
+ or not name
45
+ or isinstance(status, bool)
46
+ or not isinstance(status, int)
47
+ or scope_type not in {"tenant", "user"}
48
+ ):
49
+ raise PermissionCheckError(
50
+ "the tenant permission query returned an invalid scope"
51
+ )
52
+ if scope_type == "tenant" and status == 1:
53
+ granted.add(name)
54
+ return tuple(
55
+ scope
56
+ for scope in REQUIRED_TENANT_SCOPES
57
+ if not (TENANT_SCOPE_ALTERNATIVES.get(scope, frozenset((scope,))) & granted)
58
+ )
59
+
60
+
61
+ def run_permission_check(
62
+ *,
63
+ list_scopes: ScopeLister,
64
+ stdout: IO[str] | None = None,
65
+ ) -> None:
66
+ output = sys.stdout if stdout is None else stdout
67
+ missing = _missing_required_scopes(list_scopes())
68
+ print(
69
+ json.dumps(
70
+ {"version": 1, "missingScopes": list(missing)},
71
+ separators=(",", ":"),
72
+ ),
73
+ file=output,
74
+ )
75
+
76
+
77
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
78
+ parser = argparse.ArgumentParser(description=__doc__)
79
+ parser.add_argument("--lark-app-config", type=Path, required=True)
80
+ return parser.parse_args(argv)
81
+
82
+
83
+ def main(
84
+ argv: Sequence[str] | None = None,
85
+ *,
86
+ client_factory: Callable[[str, str], Any] | None = None,
87
+ ) -> int:
88
+ args = parse_args(argv)
89
+ try:
90
+ credentials = load_lark_app(args.lark_app_config)
91
+ if client_factory is None:
92
+ import lark_oapi as lark
93
+ from lark_oapi.api.application.v6 import ListScopeRequest
94
+
95
+ client = (
96
+ lark.Client.builder()
97
+ .app_id(credentials.app_id)
98
+ .app_secret(credentials.app_secret)
99
+ .log_level(lark.LogLevel.ERROR)
100
+ .timeout(15)
101
+ .build()
102
+ )
103
+ else:
104
+ client = client_factory(credentials.app_id, credentials.app_secret)
105
+
106
+ def list_scopes() -> Any:
107
+ if client_factory is None:
108
+ request = ListScopeRequest.builder().build()
109
+ else:
110
+ request = None
111
+ return client.application.v6.scope.list(request)
112
+
113
+ run_permission_check(list_scopes=list_scopes)
114
+ except KeyboardInterrupt:
115
+ return 130
116
+ except Exception:
117
+ print("Feishu/Lark permission verification did not complete.", file=sys.stderr)
118
+ return 1
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())
@@ -0,0 +1,63 @@
1
+ """Bounded, read-only Git status for Project display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+
9
+ _GIT_STATUS_TIMEOUT_SECONDS = 2.0
10
+ _BRANCH_HEADER_PREFIX = "## "
11
+
12
+
13
+ async def git_branch_status(cwd: Path) -> str | None:
14
+ """Return Git's porcelain branch header content for ``cwd`` when available."""
15
+
16
+ process: asyncio.subprocess.Process | None = None
17
+ try:
18
+ async with asyncio.timeout(_GIT_STATUS_TIMEOUT_SECONDS):
19
+ process = await asyncio.create_subprocess_exec(
20
+ "git",
21
+ "--no-optional-locks",
22
+ "-C",
23
+ str(cwd),
24
+ "-c",
25
+ "core.fsmonitor=false",
26
+ "status",
27
+ "--porcelain=v1",
28
+ "--branch",
29
+ "--untracked-files=no",
30
+ "--ignore-submodules=all",
31
+ "--",
32
+ ".git",
33
+ stdin=asyncio.subprocess.DEVNULL,
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.DEVNULL,
36
+ )
37
+ stdout, _stderr = await process.communicate()
38
+ except asyncio.CancelledError:
39
+ if process is not None:
40
+ await _kill_and_reap(process)
41
+ raise
42
+ except (OSError, TimeoutError):
43
+ if process is not None:
44
+ await _kill_and_reap(process)
45
+ return None
46
+ if process.returncode != 0:
47
+ return None
48
+ header, _, _remainder = stdout.decode(
49
+ "utf-8", errors="replace"
50
+ ).partition("\n")
51
+ if not header.startswith(_BRANCH_HEADER_PREFIX):
52
+ return None
53
+ status = header.removeprefix(_BRANCH_HEADER_PREFIX).strip()
54
+ return status or None
55
+
56
+
57
+ async def _kill_and_reap(process: asyncio.subprocess.Process) -> None:
58
+ if process.returncode is None:
59
+ try:
60
+ process.kill()
61
+ except ProcessLookupError:
62
+ pass
63
+ await process.wait()