pcode 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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,356 @@
1
+ """Signing Meridian in through Claude Code, and installing or upgrading Meridian.
2
+
3
+ Sign-in always runs the real `claude auth login`, so it completes through
4
+ Anthropic's own flow and pcode never sees the credential. Meridian can keep
5
+ several account profiles, each with its own Claude config directory; the login
6
+ goes to whichever one the Meridian that pcode uses will read.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import plistlib
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ import httpx2
21
+
22
+ from pcode.auth import LoginError
23
+ from pcode.meridian_process import default_profile, meridian_profiles, profile_dir
24
+
25
+ PACKAGE = "@rynfar/meridian"
26
+ LOGIN_TIMEOUT_SECONDS = 300.0
27
+ # OSC (hyperlinks, titles) and CSI (colour, cursor) escapes in CLI output.
28
+ ESCAPES = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]")
29
+ REMOTE_VARIABLES = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER")
30
+ # Printed without a newline, so whatever the CLI says next lands on the same line.
31
+ PASTE_PROMPT = re.compile(r"Paste code here if prompted >\s*")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LoginTarget:
36
+ """Where `claude auth login` should write: a profile's config dir, or the default."""
37
+
38
+ config_dir: str | None
39
+ label: str
40
+ oauth_token: bool = False
41
+
42
+
43
+ def profile_target(profile: dict) -> LoginTarget:
44
+ token = bool(profile.get("oauthToken")) or profile.get("type") == "oauth-token"
45
+ return LoginTarget(profile_dir(profile), f"Meridian profile {profile['id']}", token)
46
+
47
+
48
+ def active_profile(base_url: str) -> str | None:
49
+ """The running proxy's active profile, or None when it has no profiles."""
50
+ key = os.environ.get("PCODE_MERIDIAN_API_KEY", "").strip()
51
+ try:
52
+ response = httpx2.get(
53
+ base_url + "/profiles/list",
54
+ headers={"x-api-key": key} if key else {},
55
+ timeout=2,
56
+ trust_env=False,
57
+ )
58
+ data = response.json()
59
+ except (httpx2.HTTPError, ValueError):
60
+ return None
61
+ if not isinstance(data, dict) or not data.get("profiles"):
62
+ return None
63
+ active = data.get("activeProfile")
64
+ return active if isinstance(active, str) and active else None
65
+
66
+
67
+ def login_target() -> LoginTarget:
68
+ """The login the Meridian this session uses reads, decided as `managed_endpoint` does.
69
+
70
+ A managed instance uses `default_profile()`, or Claude Code's own login when
71
+ there are no profiles; an external proxy uses whichever profile it reports
72
+ as active.
73
+ """
74
+ from pcode.meridian import DEFAULT_BASE_URL, meridian_base_url
75
+ from pcode.meridian_process import external_proxy_running, managed_base_url, managed_mode
76
+
77
+ default = LoginTarget(os.environ.get("CLAUDE_CONFIG_DIR") or None, "Claude Code's login")
78
+ configured = os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip()
79
+ mode = "off" if configured else managed_mode()
80
+ base = meridian_base_url() if configured else DEFAULT_BASE_URL
81
+ managed = managed_base_url() is not None or mode == "on"
82
+ if managed or (mode == "auto" and not external_proxy_running(base)):
83
+ profile = default_profile()
84
+ return profile_target(profile) if profile else default
85
+ active = active_profile(base)
86
+ if active is None:
87
+ return default
88
+ known = next((p for p in meridian_profiles() if p["id"] == active), {"id": active})
89
+ return profile_target(known)
90
+
91
+
92
+ def remote_session() -> bool:
93
+ return any(os.environ.get(name) for name in REMOTE_VARIABLES)
94
+
95
+
96
+ def claude_executable() -> str | None:
97
+ return os.environ.get("MERIDIAN_CLAUDE_PATH") or shutil.which("claude")
98
+
99
+
100
+ def login_env(target: LoginTarget) -> dict[str, str]:
101
+ env = dict(os.environ)
102
+ if target.config_dir:
103
+ env["CLAUDE_CONFIG_DIR"] = target.config_dir
104
+ return env
105
+
106
+
107
+ def auth_status(executable: str, env: dict[str, str]) -> dict:
108
+ """Only the non-identifying parts of `claude auth status`: no email or org."""
109
+ try:
110
+ result = subprocess.run(
111
+ [executable, "auth", "status", "--json"],
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=15,
115
+ env=env,
116
+ )
117
+ data = json.loads(result.stdout)
118
+ except (OSError, subprocess.SubprocessError, ValueError):
119
+ return {}
120
+ if not isinstance(data, dict):
121
+ return {}
122
+ return {k: data[k] for k in ("loggedIn", "authMethod", "subscriptionType") if k in data}
123
+
124
+
125
+ def manual_login_command(target: LoginTarget) -> str:
126
+ if target.config_dir:
127
+ return f"CLAUDE_CONFIG_DIR={target.config_dir} claude auth login"
128
+ return "claude auth login"
129
+
130
+
131
+ async def claude_login(
132
+ notify,
133
+ target: LoginTarget,
134
+ timeout: float = LOGIN_TIMEOUT_SECONDS,
135
+ *,
136
+ executable: str | None = None,
137
+ retry: str = "/login meridian",
138
+ extra_env: dict[str, str] | None = None,
139
+ for_meridian: bool = True,
140
+ ):
141
+ """Run `claude auth login --claudeai` for `target` and return its auth status.
142
+
143
+ `executable` defaults to the `claude` Meridian runs; `retry` is the command
144
+ the error messages suggest; `extra_env` overrides the inherited environment
145
+ for both the login and the status check. `for_meridian` names Meridian's
146
+ install knobs when no `claude` is found.
147
+ """
148
+ if target.oauth_token:
149
+ raise LoginError(
150
+ f"{target.label} signs in with a `claude setup-token` token, not a browser login. "
151
+ "Replace it with `meridian profile add <name> --oauth-token`."
152
+ )
153
+ executable = executable or claude_executable()
154
+ if executable is None and for_meridian:
155
+ raise LoginError(
156
+ "Claude Code (`claude`) is not on PATH, and Meridian signs in through it. "
157
+ f"Install Claude Code, or set MERIDIAN_CLAUDE_PATH, then retry {retry}."
158
+ )
159
+ if executable is None:
160
+ raise LoginError(f"Claude Code (`claude`) was not found. Install it, then retry {retry}.")
161
+ if remote_session():
162
+ raise LoginError(
163
+ "This looks like a remote session, so the browser cannot finish signing in here. "
164
+ f"Run `{manual_login_command(target)}` in a terminal on this machine, then retry."
165
+ )
166
+ env = {**login_env(target), **(extra_env or {})}
167
+ process = await asyncio.create_subprocess_exec(
168
+ executable,
169
+ "auth",
170
+ "login",
171
+ "--claudeai",
172
+ stdin=subprocess.DEVNULL,
173
+ stdout=subprocess.PIPE,
174
+ stderr=subprocess.STDOUT,
175
+ env=env,
176
+ )
177
+ try:
178
+ async with asyncio.timeout(timeout):
179
+ async for raw in process.stdout:
180
+ line = ESCAPES.sub("", raw.decode("utf-8", "replace"))
181
+ line = PASTE_PROMPT.sub("", line).strip()
182
+ if not line:
183
+ continue
184
+ if line.startswith("If the browser didn't open"):
185
+ # That URL leads to a code to paste, which pcode cannot pass on.
186
+ notify(
187
+ "If no browser opened, press Ctrl+C and run "
188
+ f"`{manual_login_command(target)}` in a terminal instead."
189
+ )
190
+ continue
191
+ notify(line)
192
+ code = await process.wait()
193
+ except TimeoutError:
194
+ raise LoginError(f"Claude sign-in timed out. Run {retry} to try again.") from None
195
+ finally:
196
+ if process.returncode is None:
197
+ process.kill()
198
+ await process.wait()
199
+ if code != 0:
200
+ raise LoginError(
201
+ f"Claude sign-in did not complete (exit {code}). "
202
+ f"Run `{manual_login_command(target)}` in a terminal to see why."
203
+ )
204
+ status = await asyncio.to_thread(auth_status, executable, env)
205
+ if status.get("loggedIn") is False:
206
+ raise LoginError(f"Claude Code still reports no login. Run {retry} to try again.")
207
+ return status
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class Install:
212
+ """One npm-global Meridian installation."""
213
+
214
+ root: Path
215
+ npm: str
216
+
217
+ @property
218
+ def version(self) -> str | None:
219
+ try:
220
+ return json.loads((self.root / "package.json").read_text()).get("version")
221
+ except (OSError, ValueError):
222
+ return None
223
+
224
+
225
+ def _run(args: list[str], env=None) -> str | None:
226
+ try:
227
+ result = subprocess.run(args, capture_output=True, text=True, timeout=60, env=env)
228
+ except (OSError, subprocess.SubprocessError):
229
+ return None
230
+ return result.stdout.strip() if result.returncode == 0 else None
231
+
232
+
233
+ def npm_env(npm: str) -> dict[str, str]:
234
+ """Put the npm's own bin directory first so its node runs the install scripts."""
235
+ env = dict(os.environ)
236
+ env["PATH"] = os.pathsep.join([str(Path(npm).parent), env.get("PATH", "")])
237
+ return env
238
+
239
+
240
+ def path_install() -> Install | None:
241
+ """The Meridian that `meridian` on PATH runs, owned by the npm on PATH."""
242
+ npm = shutil.which("npm")
243
+ if npm is None:
244
+ return None
245
+ root = _run([npm, "root", "-g"])
246
+ if not root:
247
+ return None
248
+ package = Path(root) / PACKAGE
249
+ return Install(package, npm) if (package / "package.json").is_file() else None
250
+
251
+
252
+ def package_root(path: str) -> Path | None:
253
+ """The `@rynfar/meridian` directory above any file inside the package."""
254
+ for parent in Path(path).parents:
255
+ if parent.name == "meridian" and parent.parent.name == "@rynfar":
256
+ return parent
257
+ return None
258
+
259
+
260
+ def proxy_install(base_url: str) -> Install | None:
261
+ """The installation a running proxy was started from, via its /health report."""
262
+ try:
263
+ health = httpx2.get(base_url + "/health", timeout=2, trust_env=False).json()
264
+ path = health["claudeExecutable"]["path"]
265
+ except (httpx2.HTTPError, ValueError, KeyError, TypeError):
266
+ return None
267
+ root = package_root(path) if isinstance(path, str) else None
268
+ if root is None:
269
+ return None
270
+ prefix = root.parent.parent.parent # <prefix>/lib/node_modules/@rynfar/meridian
271
+ if prefix.name == "lib":
272
+ prefix = prefix.parent
273
+ for npm in (prefix / "bin" / "npm", prefix / "npm"):
274
+ if npm.is_file():
275
+ return Install(root, str(npm))
276
+ return None
277
+
278
+
279
+ def running_version(base_url: str) -> str | None:
280
+ try:
281
+ return httpx2.get(base_url + "/health", timeout=2, trust_env=False).json().get("version")
282
+ except (httpx2.HTTPError, ValueError, AttributeError):
283
+ return None
284
+
285
+
286
+ def launch_agent_label() -> str | None:
287
+ """A macOS launchd agent that runs Meridian, if one is installed."""
288
+ agents = Path.home() / "Library" / "LaunchAgents"
289
+ for plist in sorted(agents.glob("*.plist")) if agents.is_dir() else ():
290
+ try:
291
+ data = plistlib.loads(plist.read_bytes())
292
+ except Exception:
293
+ continue
294
+ program = " ".join(
295
+ [str(data.get("Program", "")), *map(str, data.get("ProgramArguments", []))]
296
+ )
297
+ if "meridian" in program.lower() or "meridian" in str(data.get("Label", "")).lower():
298
+ return data.get("Label")
299
+ return None
300
+
301
+
302
+ def upgrade_meridian(out=None) -> int:
303
+ """Install or upgrade every Meridian pcode uses; returns a process exit code."""
304
+ from pcode.meridian import meridian_base_url
305
+
306
+ out = out or sys.stdout
307
+
308
+ def say(text: str) -> None:
309
+ print(text, file=out, flush=True)
310
+
311
+ try:
312
+ base = meridian_base_url()
313
+ except ValueError as error:
314
+ say(str(error))
315
+ return 2
316
+ installs: dict[Path, Install] = {}
317
+ for install in (path_install(), proxy_install(base)):
318
+ if install is not None:
319
+ installs.setdefault(install.root.resolve(), install)
320
+ if not installs:
321
+ npm = shutil.which("npm")
322
+ if npm is None:
323
+ say("Meridian is not installed and npm is not on PATH. Install Node.js, then retry.")
324
+ return 1
325
+ say(f"Meridian is not installed; installing {PACKAGE} with {npm}.")
326
+ return subprocess.call([npm, "install", "-g", f"{PACKAGE}@latest"], env=npm_env(npm))
327
+ failed = False
328
+ for install in installs.values():
329
+ before = install.version
330
+ say(f"Upgrading {install.root} ({before or 'unknown version'}) with {install.npm}")
331
+ code = subprocess.call(
332
+ [install.npm, "install", "-g", f"{PACKAGE}@latest"], env=npm_env(install.npm)
333
+ )
334
+ after = install.version
335
+ if code != 0:
336
+ failed = True
337
+ say(f"npm exited with {code}; {install.root} is still {after or 'unknown'}.")
338
+ elif after == before:
339
+ say(f"Already current: {after}.")
340
+ else:
341
+ say(f"Upgraded {before} -> {after}.")
342
+ running = running_version(base)
343
+ newest = max((i.version for i in installs.values() if i.version), default=None, key=_key)
344
+ if running and newest and _key(running) < _key(newest):
345
+ say(f"The proxy at {base} is still running {running}. Restart it to use {newest}.")
346
+ label = launch_agent_label()
347
+ if label:
348
+ say(f" launchctl kickstart -k gui/{os.getuid()}/{label}")
349
+ say("pcode sessions that started their own Meridian pick up the new version on restart.")
350
+ return 1 if failed else 0
351
+
352
+
353
+ def _key(version: str | None) -> tuple[int, ...]:
354
+ from pcode.meridian_process import version_tuple
355
+
356
+ return version_tuple(version) or ()