pidriver 0.4.2__tar.gz → 0.5.0__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 (82) hide show
  1. {pidriver-0.4.2 → pidriver-0.5.0}/PKG-INFO +4 -4
  2. {pidriver-0.4.2 → pidriver-0.5.0}/README.md +3 -3
  3. {pidriver-0.4.2 → pidriver-0.5.0}/pyproject.toml +1 -1
  4. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/__init__.py +1 -1
  5. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/_install.py +1 -1
  6. pidriver-0.5.0/src/pidriver/assets/pi_login.mjs +149 -0
  7. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/auth.py +7 -6
  8. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/config.py +2 -2
  9. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/events.py +2 -0
  10. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/session.py +28 -0
  11. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/README.md +7 -0
  12. pidriver-0.5.0/tests/test_auth.py +150 -0
  13. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_config.py +6 -0
  14. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_install.py +8 -8
  15. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_replay_session.py +8 -0
  16. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_session.py +67 -0
  17. pidriver-0.4.2/src/pidriver/assets/pi_login.mjs +0 -115
  18. pidriver-0.4.2/tests/test_auth.py +0 -81
  19. {pidriver-0.4.2 → pidriver-0.5.0}/.gitignore +0 -0
  20. {pidriver-0.4.2 → pidriver-0.5.0}/.sc/last-chat-settings.json +0 -0
  21. {pidriver-0.4.2 → pidriver-0.5.0}/LICENSE +0 -0
  22. {pidriver-0.4.2 → pidriver-0.5.0}/examples/basic_prompt.py +0 -0
  23. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/_transport.py +0 -0
  24. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/client.py +0 -0
  25. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/errors.py +0 -0
  26. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/instances.py +0 -0
  27. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/interaction.py +0 -0
  28. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/manager.py +0 -0
  29. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/models.py +0 -0
  30. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/py.typed +0 -0
  31. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/repository.py +0 -0
  32. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/staging.py +0 -0
  33. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/store.py +0 -0
  34. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/supervisor.py +0 -0
  35. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/usage.py +0 -0
  36. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/web.py +0 -0
  37. {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/workspace.py +0 -0
  38. {pidriver-0.4.2 → pidriver-0.5.0}/tests/conftest.py +0 -0
  39. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/_capture_driver.py +0 -0
  40. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/agent_end.json +0 -0
  41. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/agent_start.json +0 -0
  42. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
  43. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
  44. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
  45. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
  46. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
  47. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
  48. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
  49. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
  50. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
  51. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
  52. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
  53. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_start_assistant.json +0 -0
  54. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_start_user.json +0 -0
  55. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.text_delta.json +0 -0
  56. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
  57. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
  58. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
  59. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/ready.json +0 -0
  60. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/response_prompt_ack.json +0 -0
  61. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_end.json +0 -0
  62. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_start.json +0 -0
  63. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_update.json +0 -0
  64. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/turn_end.json +0 -0
  65. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/turn_start.json +0 -0
  66. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
  67. {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/payloads/get_state.response.json +0 -0
  68. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_event_summary.py +0 -0
  69. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_events.py +0 -0
  70. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_fixtures_replay.py +0 -0
  71. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_install_resolve.py +0 -0
  72. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_manager.py +0 -0
  73. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_models.py +0 -0
  74. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_repository.py +0 -0
  75. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_staging.py +0 -0
  76. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_store.py +0 -0
  77. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_supervisor.py +0 -0
  78. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_transport.py +0 -0
  79. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_ui_methods.py +0 -0
  80. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_usage.py +0 -0
  81. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_web.py +0 -0
  82. {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pidriver
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work.
5
5
  Project-URL: Homepage, https://github.com/bobuk/pidriver
6
6
  Project-URL: Repository, https://github.com/bobuk/pidriver
@@ -154,7 +154,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
154
154
  | `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
155
155
  | `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
156
156
  | `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
157
- | `pi_version` | `"0.87.1"` | Pinned pi version to install (overridable). |
157
+ | `pi_version` | `"1.5.0"` | Pinned pi version to install (overridable). |
158
158
  | `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
159
159
 
160
160
  **Model / provider**
@@ -421,7 +421,7 @@ the driver. Every event carries `.type` (the raw tag) and `.raw` (the decoded di
421
421
  | `ToolUpdate` | `tool_call_id`, `name`, `output` | Partial output while a tool runs. |
422
422
  | `ToolEnd` | `tool_call_id`, `name`, `result`, `is_error` | A tool finished. |
423
423
  | `Usage` | `totals` (a `UsageTotals`) | Token/cost for a step; also folded into `session.usage`. |
424
- | `AgentEnd` | `reason`, `final_text` | The run finished (`completed` / `cancelled` / `error` / `limit`). |
424
+ | `AgentEnd` | `reason`, `final_text` | The turn finished (`completed` / `cancelled` / `error` / `limit`): on upstream pi, at `agent_settled`, after any retries or compaction. |
425
425
  | `Error` | `message`, `code`, `fatal` | An error (or a failed command ack) surfaced by pi. |
426
426
  | `InteractionRequest` | `request_id`, `kind`, `prompt`, `options`, `tool_call_id`, `default` | The agent is blocked waiting for the host. |
427
427
  | `Event` | `type`, `raw` | Base / fallback for unrecognized records. |
@@ -604,7 +604,7 @@ for m in models:
604
604
  ## Provider OAuth login
605
605
 
606
606
  Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
607
- drives a bundled Node helper that reuses pi's own `AuthStorage` (no version skew), writing
607
+ drives a bundled Node helper that reuses pi's own login code (no version skew), writing
608
608
  credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
609
609
  install.
610
610
 
@@ -128,7 +128,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
128
128
  | `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
129
129
  | `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
130
130
  | `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
131
- | `pi_version` | `"0.87.1"` | Pinned pi version to install (overridable). |
131
+ | `pi_version` | `"1.5.0"` | Pinned pi version to install (overridable). |
132
132
  | `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
133
133
 
134
134
  **Model / provider**
@@ -395,7 +395,7 @@ the driver. Every event carries `.type` (the raw tag) and `.raw` (the decoded di
395
395
  | `ToolUpdate` | `tool_call_id`, `name`, `output` | Partial output while a tool runs. |
396
396
  | `ToolEnd` | `tool_call_id`, `name`, `result`, `is_error` | A tool finished. |
397
397
  | `Usage` | `totals` (a `UsageTotals`) | Token/cost for a step; also folded into `session.usage`. |
398
- | `AgentEnd` | `reason`, `final_text` | The run finished (`completed` / `cancelled` / `error` / `limit`). |
398
+ | `AgentEnd` | `reason`, `final_text` | The turn finished (`completed` / `cancelled` / `error` / `limit`): on upstream pi, at `agent_settled`, after any retries or compaction. |
399
399
  | `Error` | `message`, `code`, `fatal` | An error (or a failed command ack) surfaced by pi. |
400
400
  | `InteractionRequest` | `request_id`, `kind`, `prompt`, `options`, `tool_call_id`, `default` | The agent is blocked waiting for the host. |
401
401
  | `Event` | `type`, `raw` | Base / fallback for unrecognized records. |
@@ -578,7 +578,7 @@ for m in models:
578
578
  ## Provider OAuth login
579
579
 
580
580
  Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
581
- drives a bundled Node helper that reuses pi's own `AuthStorage` (no version skew), writing
581
+ drives a bundled Node helper that reuses pi's own login code (no version skew), writing
582
582
  credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
583
583
  install.
584
584
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pidriver"
3
- version = "0.4.2"
3
+ version = "0.5.0"
4
4
  description = "Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -123,7 +123,7 @@ from .supervisor import PiInstanceSupervisor
123
123
  from .usage import UsageTotals
124
124
  from .workspace import ProjectDir, list_candidate_projects, list_projects, resolve_project
125
125
 
126
- __version__ = "0.4.2"
126
+ __version__ = "0.5.0"
127
127
 
128
128
  __all__ = [
129
129
  "__version__",
@@ -286,7 +286,7 @@ def resolve_node_modules(config: PiConfig, *, runner: Runner = subprocess.run) -
286
286
  bin to its node_modules). Package identity comes from ``config.npm_package`` so this
287
287
  works for the omp/oh-my-pi fork too. Returns ``None`` when no install is found.
288
288
 
289
- Useful for tools that need pi's *bundled* Node libraries (e.g. its ``AuthStorage``
289
+ Useful for tools that need pi's *bundled* Node libraries (e.g. its login code
290
290
  for the OAuth flow — see :mod:`pidriver.auth`), not just the ``pi`` executable.
291
291
  """
292
292
  package = _package_components(config.npm_package)
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // pi OAuth/login helper for pidriver (driven by pidriver.auth).
3
+ //
4
+ // Reuses pi's own login code (public exports of @earendil-works/pi-coding-agent, which bundles the
5
+ // pi-ai OAuth providers — so there is no version skew and nothing extra to install): ModelRuntime
6
+ // on pi 1.x, AuthStorage before it. The package is found at PI_NODE_MODULES
7
+ // (<pi install_dir>/lib/node_modules). Credentials are read/written at PI_AUTH_PATH (the isolated
8
+ // <agent_dir>/auth.json).
9
+ //
10
+ // Commands:
11
+ // node pi_login.mjs list -> {"providers":[{id,name}], "status":{provider:type}}
12
+ // node pi_login.mjs logout <provider> -> {"ok":true}
13
+ // node pi_login.mjs login <provider> -> JSONL events on stdout; reads the auth code from stdin
14
+ //
15
+ // login protocol (one JSON object per line on stdout):
16
+ // {"event":"auth","url":"...","instructions":"..."} # open this URL in a browser
17
+ // {"event":"need_code","message":"...","placeholder":"..."} # then write the code as one stdin line
18
+ // {"event":"progress","message":"..."}
19
+ // {"event":"done"} # credentials saved to PI_AUTH_PATH
20
+ // {"event":"error","message":"..."}
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import readline from "node:readline";
25
+ import { pathToFileURL } from "node:url";
26
+
27
+ function emit(o) {
28
+ process.stdout.write(JSON.stringify(o) + "\n");
29
+ }
30
+
31
+ // Import pi from the managed install (PI_NODE_MODULES = <install>/lib/node_modules). We import the
32
+ // package's entry by absolute path: its `exports` map only declares the ESM `import` condition, so
33
+ // require.resolve can't find it, but a direct file import resolves its nested pi-ai too.
34
+ async function loadPi() {
35
+ const nm = process.env.PI_NODE_MODULES;
36
+ if (!nm) throw new Error("PI_NODE_MODULES is not set");
37
+ const pkgDir = join(nm, "@earendil-works", "pi-coding-agent");
38
+ const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
39
+ const rel = pkg?.exports?.["."]?.import || pkg.main || "./dist/index.js";
40
+ return await import(pathToFileURL(join(pkgDir, rel)).href);
41
+ }
42
+
43
+ async function readLine() {
44
+ return await new Promise((resolve) => {
45
+ const rl = readline.createInterface({ input: process.stdin });
46
+ rl.once("line", (line) => {
47
+ rl.close();
48
+ resolve(String(line).trim());
49
+ });
50
+ });
51
+ }
52
+
53
+ function selectLoginOption(prompt) {
54
+ const options = Array.isArray(prompt?.options) ? prompt.options : [];
55
+ const selected = options.find((option) => {
56
+ const haystack = String(`${option?.id || ""} ${option?.label || ""}`).toLowerCase();
57
+ return haystack.includes("browser") || haystack.includes("default");
58
+ }) || options[0];
59
+ emit({
60
+ event: "select",
61
+ message: prompt?.message || "Select login method",
62
+ options: options.map((option) => ({ id: option.id, label: option.label })),
63
+ selected: selected?.id || "",
64
+ });
65
+ return selected?.id;
66
+ }
67
+
68
+ async function askCode(prompt) {
69
+ emit({ event: "need_code", message: (prompt && prompt.message) || "Paste the authorization code",
70
+ placeholder: (prompt && prompt.placeholder) || "" });
71
+ return await readLine();
72
+ }
73
+
74
+ // pi 1.x: provider auth belongs to ModelRuntime, AuthStorage only keeps the credentials.
75
+ async function modelRuntimeAuth(pi) {
76
+ const runtime = await pi.ModelRuntime.create({
77
+ authPath: process.env.PI_AUTH_PATH,
78
+ allowModelNetwork: false,
79
+ refreshOnCreate: false,
80
+ });
81
+ return {
82
+ providers: () => runtime.getProviders()
83
+ .filter((p) => p.auth.oauth)
84
+ .map((p) => ({ id: p.id, name: p.auth.oauth.name || p.name })),
85
+ status: async () => Object.fromEntries(
86
+ (await runtime.listCredentials()).map((c) => [c.providerId, c.type])),
87
+ logout: (id) => runtime.logout(id),
88
+ login: (id) => runtime.login(id, "oauth", {
89
+ prompt: async (prompt) => (prompt.type === "select" ? selectLoginOption(prompt) : askCode(prompt)),
90
+ notify: (event) => {
91
+ if (event.type === "auth_url") emit({ event: "auth", url: event.url, instructions: event.instructions || "" });
92
+ else if (event.type === "device_code") emit({ event: "device_code", ...event, type: undefined });
93
+ else emit({ event: "progress", message: String(event.message) });
94
+ },
95
+ }, { getDeviceId: () => pi.SettingsManager.create(process.cwd(), pi.getAgentDir()).getOrCreateDeviceId() }),
96
+ };
97
+ }
98
+
99
+ // Before pi 1.x AuthStorage ran the logins itself.
100
+ function authStorageAuth(pi) {
101
+ const storage = pi.AuthStorage.create(process.env.PI_AUTH_PATH);
102
+ return {
103
+ providers: () => storage.getOAuthProviders().map((p) => ({ id: p.id, name: p.name })),
104
+ status: () => Object.fromEntries(
105
+ Object.entries(storage.getAll() || {}).map(([k, v]) => [k, v && v.type ? v.type : "unknown"])),
106
+ logout: (id) => storage.logout(id),
107
+ login: (id) => storage.login(id, {
108
+ onAuth: (info) => emit({ event: "auth", url: info.url, instructions: info.instructions || "" }),
109
+ onDeviceCode: (info) => emit({ event: "device_code", ...info }),
110
+ onProgress: (m) => emit({ event: "progress", message: String(m) }),
111
+ onManualCodeInput: async () => askCode(),
112
+ onPrompt: async (prompt) => askCode(prompt),
113
+ onSelect: async (prompt) => selectLoginOption(prompt),
114
+ }),
115
+ };
116
+ }
117
+
118
+ async function main() {
119
+ const pi = await loadPi();
120
+ if (!pi.ModelRuntime && !pi.AuthStorage) throw new Error("no login API exported by pi-coding-agent");
121
+ const auth = pi.ModelRuntime ? await modelRuntimeAuth(pi) : authStorageAuth(pi);
122
+ const [cmd, providerId] = process.argv.slice(2);
123
+
124
+ if (cmd === "list") {
125
+ emit({ providers: auth.providers(), status: await auth.status() });
126
+ return;
127
+ }
128
+
129
+ if (cmd === "logout") {
130
+ if (!providerId) throw new Error("logout requires a provider id");
131
+ await auth.logout(providerId);
132
+ emit({ ok: true });
133
+ return;
134
+ }
135
+
136
+ if (cmd === "login") {
137
+ if (!providerId) throw new Error("login requires a provider id");
138
+ await auth.login(providerId);
139
+ emit({ event: "done" });
140
+ return;
141
+ }
142
+
143
+ throw new Error(`unknown command: ${cmd}`);
144
+ }
145
+
146
+ main().then(() => process.exit(0)).catch((e) => {
147
+ emit({ event: "error", message: (e && e.message) || String(e) });
148
+ process.exit(1);
149
+ });
@@ -3,9 +3,10 @@
3
3
  Some providers (Claude Pro/Max subscriptions, Codex, …) authenticate the agent
4
4
  via OAuth rather than an API key, writing credentials into the *isolated* pi
5
5
  config (``<agent_dir>/auth.json``). pidriver drives that flow through a bundled
6
- Node helper (``assets/pi_login.mjs``) that reuses pi's OWN ``AuthStorage`` — a
7
- public export of the pi npm package — so there is never any version skew and
8
- nothing extra to install beyond pi itself.
6
+ Node helper (``assets/pi_login.mjs``) that reuses pi's OWN login code — public
7
+ exports of the pi npm package (``ModelRuntime`` on pi 1.x, ``AuthStorage`` before
8
+ it) — so there is never any version skew and nothing extra to install beyond pi
9
+ itself.
9
10
 
10
11
  The browser-authorize → paste-code handshake is exposed as a small async API so a
11
12
  host (a web app, a chat bot) can surface the authorize URL and feed the code back::
@@ -16,7 +17,7 @@ host (a web app, a chat bot) can surface the authorize URL and feed the code bac
16
17
  await login.submit_code(info["session"], code) # credentials now in auth.json
17
18
 
18
19
  Requires Node (the ``node`` binary) on PATH and a resolvable pi install (so its
19
- bundled ``AuthStorage`` can be imported). Both are *runtime* requirements checked
20
+ login code can be imported). Both are *runtime* requirements checked
20
21
  when a flow runs — importing this module never fails and never needs Node.
21
22
  """
22
23
 
@@ -73,7 +74,7 @@ def auth_context(config: PiConfig) -> tuple[Path, dict[str, str]]:
73
74
  """``(auth.json path, helper env)`` for running the login helper under ``config``.
74
75
 
75
76
  The env is ``config.to_env()`` plus ``PI_NODE_MODULES`` (where pi's bundled
76
- ``AuthStorage`` lives) and ``PI_AUTH_PATH`` (the isolated ``<agent_dir>/auth.json``
77
+ login code lives) and ``PI_AUTH_PATH`` (the isolated ``<agent_dir>/auth.json``
77
78
  to read/write) — so the OAuth credentials land in the same isolated config the
78
79
  driven agent reads. Raises :class:`PiInstallError` if ``agent_dir`` is unset or no
79
80
  pi install can be located.
@@ -86,7 +87,7 @@ def auth_context(config: PiConfig) -> tuple[Path, dict[str, str]]:
86
87
  node_modules = resolve_node_modules(config)
87
88
  if node_modules is None:
88
89
  raise PiInstallError(
89
- "pi install not found — install pi (it carries AuthStorage), or run a job once "
90
+ "pi install not found — install pi (it carries the login code), or run a job once "
90
91
  "so pidriver installs it, before attempting OAuth login."
91
92
  )
92
93
  env = dict(config.to_env())
@@ -110,7 +110,7 @@ class PiConfig:
110
110
  install_dir: Path | None = None # where to install/look for a managed pi
111
111
  auto_install: bool = True # npm-install pi into install_dir if missing
112
112
  npm_package: str = "@earendil-works/pi-coding-agent" # the npm package providing `pi`
113
- pi_version: str = "0.87.1" # known-good pin; overridable
113
+ pi_version: str = "1.5.0" # known-good pin; overridable
114
114
 
115
115
  # --- extensions / skills / packages ------------------------------------
116
116
  # User-chosen add-ons installed into the (isolated) pi config dir via
@@ -177,7 +177,7 @@ class PiConfig:
177
177
  """Build the full command line: ``[binary, "--mode", mode, ...]``."""
178
178
  argv: list[str] = [self.binary, "--mode", self.mode]
179
179
 
180
- if self.provider:
180
+ if self.provider and self.model: # pi >= 1.0 refuses --provider without --model
181
181
  argv += ["--provider", self.provider]
182
182
  if self.model:
183
183
  argv += ["--model", self.model]
@@ -128,6 +128,8 @@ class Usage(Event):
128
128
 
129
129
  @dataclass(frozen=True, slots=True)
130
130
  class AgentEnd(Event):
131
+ # The end of a whole turn: on upstream pi the session emits it at agent_settled, once retries,
132
+ # compaction recovery and queued work are over.
131
133
  # completed | cancelled | error | limit (kept a free str for forward-compat)
132
134
  reason: str = "completed"
133
135
  final_text: str = ""
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import time
22
22
  import uuid
23
23
  from collections.abc import AsyncIterator, Callable
24
+ from dataclasses import replace
24
25
  from typing import Any
25
26
 
26
27
  from .events import (
@@ -78,6 +79,7 @@ class PiSession:
78
79
  self._consumed = False
79
80
  self._ended = False
80
81
  self._closed = False
82
+ self._unsettled: AgentEnd | None = None
81
83
 
82
84
  # ── ManagedSession surface ─────────────────────────────────────────────
83
85
 
@@ -144,6 +146,15 @@ class PiSession:
144
146
 
145
147
  event = parse_event(record)
146
148
 
149
+ # Upstream pi's agent_end closes one low-level run: a retry, overflow recovery,
150
+ # compaction or queued work can still follow. The turn is over at agent_settled, so
151
+ # the run's end waits for it. omp's agent_end carries no willRetry and is final.
152
+ if isinstance(event, AgentEnd) and "willRetry" in record:
153
+ self._unsettled = _latest_end(self._unsettled, event)
154
+ continue
155
+ if rtype == "agent_settled":
156
+ event = self._settled_end(record)
157
+
147
158
  if isinstance(event, AgentStart) and event.session_id:
148
159
  self._pi_session_id = event.session_id
149
160
  if isinstance(event, AgentEnd):
@@ -165,6 +176,16 @@ class PiSession:
165
176
  fatal=False,
166
177
  )
167
178
 
179
+ def _settled_end(self, record: dict[str, Any]) -> AgentEnd:
180
+ """The :class:`AgentEnd` of a settled turn: its last run's end, ``cancelled`` if aborted.
181
+
182
+ Without a held end (a session attached after pi's ``agent_end`` was read by a previous
183
+ one) it still ends the turn, with no text — a turn left open would never finish.
184
+ """
185
+ held, self._unsettled = self._unsettled, None
186
+ end = held or AgentEnd(type=str(record.get("type", "")), raw=record)
187
+ return replace(end, reason="cancelled") if record.get("aborted") else end
188
+
168
189
  async def _on_interaction(self, request: InteractionRequest) -> None:
169
190
  """Let the handler try to auto-answer; otherwise mark it pending."""
170
191
  response = await self._handler(request, self)
@@ -253,3 +274,10 @@ class PiSession:
253
274
 
254
275
  def _opt_str(value: Any) -> str | None:
255
276
  return None if value is None else str(value)
277
+
278
+
279
+ def _latest_end(previous: AgentEnd | None, end: AgentEnd) -> AgentEnd:
280
+ """The newer run's end, keeping the last text a run produced (a failed retry has none)."""
281
+ if previous is not None and not end.final_text:
282
+ return replace(end, final_text=previous.final_text)
283
+ return end
@@ -16,6 +16,13 @@ git repo, for use as test fixtures for the pidriver ↔ omp-rpc mapping.
16
16
  > agent's **stdin**, so they do not appear on stdout. The two response fixtures under
17
17
  > `frames/` are reconstructed from what the driver sent (id matches the paired request).
18
18
 
19
+ ## Upstream pi
20
+
21
+ `pi-1.5.0/turns_and_abort.rpc.jsonl` is a real `pi --mode rpc` session of
22
+ `@earendil-works/pi-coding-agent@1.5.0` (OpenRouter, captured 2026-11-08, paths scrubbed): a turn with a
23
+ `write`, a follow-up turn with an edit, and a turn aborted during `bash`. Upstream pi ends each low-level
24
+ run with `agent_end` (`willRetry`) and the turn with `agent_settled` (`aborted` since 1.1.0).
25
+
19
26
  ## Layout
20
27
 
21
28
  ### `sessions/` — full real sessions (verbatim stdout)
@@ -0,0 +1,150 @@
1
+ """Tests for pidriver.auth — OAuth context, code extraction, and node guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from pidriver import PiConfig
11
+ from pidriver.auth import (
12
+ PiOAuthLogin,
13
+ auth_context,
14
+ extract_oauth_code,
15
+ helper_script_path,
16
+ )
17
+ from pidriver.errors import PiInstallError
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ ("raw", "expected"),
22
+ [
23
+ ("abc123", "abc123"),
24
+ (" abc123 ", "abc123"),
25
+ ("http://localhost/callback?code=xyz&state=1", "xyz"),
26
+ ("code=xyz", "xyz"),
27
+ ("foo?code=deep", "deep"),
28
+ ("", ""),
29
+ ],
30
+ )
31
+ def test_extract_oauth_code(raw: str, expected: str) -> None:
32
+ assert extract_oauth_code(raw) == expected
33
+
34
+
35
+ def test_helper_script_ships_and_is_readable() -> None:
36
+ path = helper_script_path()
37
+ assert path.name == "pi_login.mjs"
38
+ assert "AuthStorage" in path.read_text(encoding="utf-8")
39
+
40
+
41
+ def _seed_install(tmp_path: Path) -> Path:
42
+ install_dir = tmp_path / "pi"
43
+ pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
44
+ pkg.mkdir(parents=True)
45
+ return install_dir
46
+
47
+
48
+ def test_auth_context_builds_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
49
+ monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
50
+ install_dir = _seed_install(tmp_path)
51
+ agent_dir = tmp_path / "agent"
52
+ config = PiConfig(install_dir=install_dir, agent_dir=agent_dir)
53
+
54
+ auth_path, env = auth_context(config)
55
+ assert auth_path == agent_dir / "auth.json"
56
+ assert env["PI_NODE_MODULES"] == str(install_dir / "lib" / "node_modules")
57
+ assert env["PI_AUTH_PATH"] == str(agent_dir / "auth.json")
58
+
59
+
60
+ def test_auth_context_requires_agent_dir(tmp_path: Path) -> None:
61
+ with pytest.raises(PiInstallError):
62
+ auth_context(PiConfig(install_dir=_seed_install(tmp_path)))
63
+
64
+
65
+ def test_auth_context_requires_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
66
+ monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
67
+ config = PiConfig(install_dir=tmp_path / "empty", agent_dir=tmp_path / "agent")
68
+ with pytest.raises(PiInstallError):
69
+ auth_context(config)
70
+
71
+
72
+ async def test_start_without_node_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
73
+ monkeypatch.setattr("pidriver.auth.shutil.which", lambda name, **kw: None)
74
+ login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
75
+ with pytest.raises(PiInstallError):
76
+ await login.start("anthropic")
77
+
78
+
79
+ async def test_submit_code_unknown_session(tmp_path: Path) -> None:
80
+ login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
81
+ with pytest.raises(KeyError):
82
+ await login.submit_code("nope", "code")
83
+
84
+
85
+ # The helper against stand-ins for pi's two login APIs: ModelRuntime (pi 1.x), AuthStorage (before).
86
+ _MODEL_RUNTIME = """
87
+ export class ModelRuntime {
88
+ static async create() { return new ModelRuntime(); }
89
+ getProviders() {
90
+ return [{ id: "sub", name: "Sub", auth: { oauth: { name: "Sub login" } } },
91
+ { id: "key", name: "Key", auth: { apiKey: { name: "Key" } } }];
92
+ }
93
+ async listCredentials() { return [{ providerId: "sub", type: "oauth" }]; }
94
+ async logout() {}
95
+ async login(id, type, interaction) {
96
+ if (id !== "sub" || type !== "oauth") throw new Error("wrong login: " + id + " " + type);
97
+ const method = await interaction.prompt({ type: "select", message: "How?", options: [
98
+ { id: "device", label: "Device code" }, { id: "web", label: "Browser" }] });
99
+ if (method !== "web") throw new Error("wrong method: " + method);
100
+ interaction.notify({ type: "progress", message: "starting" });
101
+ interaction.notify({ type: "auth_url", url: "https://auth.test/go", instructions: "Open it" });
102
+ const code = await interaction.prompt({ type: "manual_code", message: "Paste it" });
103
+ if (code !== "abc") throw new Error("wrong code: " + code);
104
+ }
105
+ }
106
+ """
107
+
108
+ _AUTH_STORAGE = """
109
+ export class AuthStorage {
110
+ static create() { return new AuthStorage(); }
111
+ getOAuthProviders() { return [{ id: "old", name: "Old" }]; }
112
+ getAll() { return { old: { type: "oauth" } }; }
113
+ logout() {}
114
+ }
115
+ """
116
+
117
+ _needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
118
+
119
+
120
+ def _login_with(tmp_path: Path, source: str) -> PiOAuthLogin:
121
+ install_dir = _seed_install(tmp_path)
122
+ pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
123
+ (pkg / "package.json").write_text('{"type": "module", "main": "./index.js"}')
124
+ (pkg / "index.js").write_text(source)
125
+ return PiOAuthLogin(PiConfig(install_dir=install_dir, agent_dir=tmp_path / "agent"))
126
+
127
+
128
+ @_needs_node
129
+ async def test_helper_lists_oauth_providers_of_model_runtime(tmp_path: Path) -> None:
130
+ assert await _login_with(tmp_path, _MODEL_RUNTIME).list_providers() == {
131
+ "providers": [{"id": "sub", "name": "Sub login"}],
132
+ "status": {"sub": "oauth"},
133
+ }
134
+
135
+
136
+ @_needs_node
137
+ async def test_helper_logs_in_through_model_runtime(tmp_path: Path) -> None:
138
+ login = _login_with(tmp_path, _MODEL_RUNTIME)
139
+ info = await login.start("sub")
140
+ assert info["url"] == "https://auth.test/go"
141
+ assert info["instructions"] == "Open it"
142
+ await login.submit_code(info["session"], "http://localhost/?code=abc")
143
+
144
+
145
+ @_needs_node
146
+ async def test_helper_still_works_with_auth_storage(tmp_path: Path) -> None:
147
+ assert await _login_with(tmp_path, _AUTH_STORAGE).list_providers() == {
148
+ "providers": [{"id": "old", "name": "Old"}],
149
+ "status": {"old": "oauth"},
150
+ }
@@ -158,3 +158,9 @@ def test_offline_env() -> None:
158
158
  def test_extra_env_wins_over_defaults() -> None:
159
159
  env = PiConfig(extra_env={"PI_SKIP_VERSION_CHECK": "0"}).to_env(base_environ={})
160
160
  assert env["PI_SKIP_VERSION_CHECK"] == "0"
161
+
162
+
163
+ def test_provider_is_passed_only_with_a_model() -> None:
164
+ assert "--provider" not in PiConfig(provider="openrouter").to_argv()
165
+ argv = PiConfig(provider="openrouter", model="deepseek/x").to_argv()
166
+ assert argv[argv.index("--provider") + 1] == "openrouter"
@@ -14,7 +14,7 @@ from pidriver.errors import PiInstallError, PiNotFoundError
14
14
 
15
15
 
16
16
  def make_runner(
17
- install_dir: Path | None = None, *, version: str = "0.87.1", record: list | None = None
17
+ install_dir: Path | None = None, *, version: str = "1.5.0", record: list | None = None
18
18
  ):
19
19
  """A subprocess.run stand-in covering: npm install (creates the pi shim), `pi --version`
20
20
  (returns the version), and `pi install <source>` (records, succeeds)."""
@@ -53,20 +53,20 @@ def _npm_on_path(monkeypatch: pytest.MonkeyPatch):
53
53
 
54
54
  def test_install_pi_runs_npm_and_verifies(tmp_path: Path) -> None:
55
55
  rec: list = []
56
- dest = install_pi(tmp_path / "pi", "0.87.1", runner=make_runner(tmp_path / "pi", record=rec))
56
+ dest = install_pi(tmp_path / "pi", "1.5.0", runner=make_runner(tmp_path / "pi", record=rec))
57
57
  assert dest == managed_binary_path(tmp_path / "pi")
58
58
  assert dest.exists()
59
59
  npm_cmd = next(c for c in rec if "--prefix" in c)
60
60
  assert npm_cmd[:3] == ["/usr/bin/npm", "install", "-g"]
61
61
  assert "--ignore-scripts" in npm_cmd and "--min-release-age=0" in npm_cmd
62
62
  assert str(tmp_path / "pi") in npm_cmd
63
- assert "@earendil-works/pi-coding-agent@0.87.1" in npm_cmd
63
+ assert "@earendil-works/pi-coding-agent@1.5.0" in npm_cmd
64
64
 
65
65
 
66
66
  def test_install_pi_missing_npm_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
67
67
  monkeypatch.setattr("pidriver._install.shutil.which", lambda name: None)
68
68
  with pytest.raises(PiInstallError, match="npm not found"):
69
- install_pi(tmp_path / "pi", "0.87.1", runner=boom)
69
+ install_pi(tmp_path / "pi", "1.5.0", runner=boom)
70
70
 
71
71
 
72
72
  def test_install_pi_npm_failure_raises(tmp_path: Path) -> None:
@@ -74,13 +74,13 @@ def test_install_pi_npm_failure_raises(tmp_path: Path) -> None:
74
74
  return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="EACCES")
75
75
 
76
76
  with pytest.raises(PiInstallError, match="npm install .* failed"):
77
- install_pi(tmp_path / "pi", "0.87.1", runner=runner)
77
+ install_pi(tmp_path / "pi", "1.5.0", runner=runner)
78
78
 
79
79
 
80
80
  def test_install_pi_version_mismatch_raises(tmp_path: Path) -> None:
81
81
  with pytest.raises(PiInstallError, match="unexpected version"):
82
82
  install_pi(
83
- tmp_path / "pi", "0.87.1",
83
+ tmp_path / "pi", "1.5.0",
84
84
  runner=make_runner(tmp_path / "pi", version="0.1.0"),
85
85
  )
86
86
 
@@ -130,12 +130,12 @@ def test_ensure_binary_upgrades_outdated_managed_copy(tmp_path: Path) -> None:
130
130
 
131
131
  assert ensure_binary(cfg, runner=runner) == str(managed)
132
132
  assert any("--prefix" in cmd for cmd in calls)
133
- assert version == "0.87.1"
133
+ assert version == "1.5.0"
134
134
 
135
135
 
136
136
  def test_ensure_binary_auto_installs_when_missing(tmp_path: Path) -> None:
137
137
  install_dir = tmp_path / "pi"
138
- cfg = PiConfig(binary="pi", install_dir=install_dir, auto_install=True, pi_version="0.87.1")
138
+ cfg = PiConfig(binary="pi", install_dir=install_dir, auto_install=True, pi_version="1.5.0")
139
139
  resolved = ensure_binary(cfg, runner=make_runner(install_dir))
140
140
  assert resolved == str(managed_binary_path(install_dir))
141
141
  assert Path(resolved).exists()
@@ -137,3 +137,11 @@ async def test_session_replay_emits_text_and_tool_events():
137
137
  final_text = event.final_text
138
138
  assert {"ToolStart", "ToolEnd", "MessageDelta"} <= types
139
139
  assert final_text # agent_end carried a final assistant message
140
+
141
+
142
+ async def test_real_pi_turns_end_when_settled():
143
+ """pi 1.5.0: two finished turns and an aborted one, each one AgentEnd after agent_settled."""
144
+ frames = _load(FIXTURES / "pi-1.5.0" / "turns_and_abort.rpc.jsonl")
145
+ session = PiSession(ReplayTransport(frames))
146
+ ends = [(e.reason, e.final_text) async for e in session if isinstance(e, AgentEnd)]
147
+ assert ends == [("completed", "DONE-1"), ("completed", "DONE-2"), ("cancelled", "")]
@@ -8,6 +8,7 @@ from typing import Any
8
8
  import pytest
9
9
 
10
10
  from pidriver import (
11
+ AgentEnd,
11
12
  AskHost,
12
13
  AutoApprove,
13
14
  Decision,
@@ -281,3 +282,69 @@ async def test_client_send_initial_false_skips_prompt():
281
282
  client = PiClient(config=None, transport_factory=lambda cfg: transport)
282
283
  await client.start("ignored", cwd="/proj", send_initial=False)
283
284
  assert transport.sent == []
285
+
286
+
287
+ # ── End of a turn (upstream pi: agent_end, then agent_settled) ─────────────────
288
+
289
+
290
+ def _end(text: str = "", *, will_retry: bool = False) -> dict[str, Any]:
291
+ messages = [{"role": "assistant", "content": [{"type": "text", "text": text}]}] if text else []
292
+ return {"type": "agent_end", "messages": messages, "willRetry": will_retry}
293
+
294
+
295
+ async def _ends(records: list[dict[str, Any]]) -> tuple[PiSession, list[tuple[str, str]]]:
296
+ session = PiSession(FakeTransport(records))
297
+ ends = [(e.reason, e.final_text) async for e in session if isinstance(e, AgentEnd)]
298
+ return session, ends
299
+
300
+
301
+ async def test_turn_ends_at_settled_not_at_agent_end():
302
+ transport = FakeTransport([_end("done"), {"type": "agent_settled"}])
303
+ session = PiSession(transport)
304
+ ends = []
305
+ async for event in session:
306
+ if isinstance(event, AgentEnd):
307
+ assert not transport._records # agent_settled has already been read
308
+ ends.append((event.reason, event.final_text))
309
+ assert ends == [("completed", "done")]
310
+ assert session.ended
311
+
312
+
313
+ async def test_a_retry_is_one_turn_with_the_retrys_text():
314
+ _, ends = await _ends(
315
+ [_end(will_retry=True), {"type": "agent_start"}, _end("fixed"), {"type": "agent_settled"}]
316
+ )
317
+ assert ends == [("completed", "fixed")]
318
+
319
+
320
+ async def test_the_last_text_survives_a_run_that_has_none():
321
+ _, ends = await _ends([_end("answer", will_retry=True), _end(), {"type": "agent_settled"}])
322
+ assert ends == [("completed", "answer")]
323
+
324
+
325
+ async def test_two_turns_end_separately():
326
+ _, ends = await _ends(
327
+ [_end("one"), {"type": "agent_settled"}, _end("two"), {"type": "agent_settled"}]
328
+ )
329
+ assert ends == [("completed", "one"), ("completed", "two")]
330
+
331
+
332
+ async def test_an_aborted_turn_is_cancelled():
333
+ _, ends = await _ends([_end("partial"), {"type": "agent_settled", "aborted": True}])
334
+ assert ends == [("cancelled", "partial")]
335
+
336
+
337
+ async def test_settled_without_an_end_still_ends_the_turn():
338
+ _, ends = await _ends([{"type": "agent_settled"}])
339
+ assert ends == [("completed", "")]
340
+
341
+
342
+ async def test_a_process_that_dies_before_settling_has_not_finished():
343
+ session, ends = await _ends([_end("half")])
344
+ assert ends == []
345
+ assert not session.ended
346
+
347
+
348
+ async def test_agent_end_without_will_retry_is_final(): # omp
349
+ _, ends = await _ends([{"type": "agent_end", "messages": []}])
350
+ assert ends == [("completed", "")]
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env node
2
- // pi OAuth/login helper for pidriver (driven by pidriver.auth).
3
- //
4
- // Reuses pi's own AuthStorage (public export of @earendil-works/pi-coding-agent, which bundles
5
- // the pi-ai OAuth providers — so there is no version skew and nothing extra to install). Resolve
6
- // it by running with NODE_PATH=<pi install_dir>/lib/node_modules. Credentials are read/written at
7
- // PI_AUTH_PATH (the isolated <agent_dir>/auth.json).
8
- //
9
- // Commands:
10
- // node pi_login.mjs list -> {"providers":[{id,name}], "status":{provider:type}}
11
- // node pi_login.mjs logout <provider> -> {"ok":true}
12
- // node pi_login.mjs login <provider> -> JSONL events on stdout; reads the auth code from stdin
13
- //
14
- // login protocol (one JSON object per line on stdout):
15
- // {"event":"auth","url":"...","instructions":"..."} # open this URL in a browser
16
- // {"event":"need_code","message":"...","placeholder":"..."} # then write the code as one stdin line
17
- // {"event":"progress","message":"..."}
18
- // {"event":"done"} # credentials saved to PI_AUTH_PATH
19
- // {"event":"error","message":"..."}
20
-
21
- import { readFileSync } from "node:fs";
22
- import { join } from "node:path";
23
- import readline from "node:readline";
24
- import { pathToFileURL } from "node:url";
25
-
26
- function emit(o) {
27
- process.stdout.write(JSON.stringify(o) + "\n");
28
- }
29
-
30
- // Import pi's bundled AuthStorage from the managed install (PI_NODE_MODULES = <install>/lib/node_modules).
31
- // We import the package's entry by absolute path: its `exports` map only declares the ESM `import`
32
- // condition, so require.resolve can't find it, but a direct file import resolves its nested pi-ai too.
33
- async function loadAuthStorage() {
34
- const nm = process.env.PI_NODE_MODULES;
35
- if (!nm) throw new Error("PI_NODE_MODULES is not set");
36
- const pkgDir = join(nm, "@earendil-works", "pi-coding-agent");
37
- const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
38
- const rel = pkg?.exports?.["."]?.import || pkg.main || "./dist/index.js";
39
- const mod = await import(pathToFileURL(join(pkgDir, rel)).href);
40
- if (!mod.AuthStorage) throw new Error("AuthStorage not exported by pi-coding-agent");
41
- return mod.AuthStorage;
42
- }
43
-
44
- async function readLine() {
45
- return await new Promise((resolve) => {
46
- const rl = readline.createInterface({ input: process.stdin });
47
- rl.once("line", (line) => {
48
- rl.close();
49
- resolve(String(line).trim());
50
- });
51
- });
52
- }
53
-
54
- function selectLoginOption(prompt) {
55
- const options = Array.isArray(prompt?.options) ? prompt.options : [];
56
- const selected = options.find((option) => {
57
- const haystack = String(`${option?.id || ""} ${option?.label || ""}`).toLowerCase();
58
- return haystack.includes("browser") || haystack.includes("default");
59
- }) || options[0];
60
- emit({
61
- event: "select",
62
- message: prompt?.message || "Select login method",
63
- options: options.map((option) => ({ id: option.id, label: option.label })),
64
- selected: selected?.id || "",
65
- });
66
- return selected?.id;
67
- }
68
-
69
- async function main() {
70
- const AuthStorage = await loadAuthStorage();
71
- const storage = AuthStorage.create(process.env.PI_AUTH_PATH);
72
- const [cmd, providerId] = process.argv.slice(2);
73
-
74
- if (cmd === "list") {
75
- const providers = storage.getOAuthProviders().map((p) => ({ id: p.id, name: p.name }));
76
- const all = storage.getAll() || {};
77
- const status = {};
78
- for (const [k, v] of Object.entries(all)) status[k] = v && v.type ? v.type : "unknown";
79
- emit({ providers, status });
80
- return;
81
- }
82
-
83
- if (cmd === "logout") {
84
- if (!providerId) throw new Error("logout requires a provider id");
85
- storage.logout(providerId);
86
- emit({ ok: true });
87
- return;
88
- }
89
-
90
- if (cmd === "login") {
91
- if (!providerId) throw new Error("login requires a provider id");
92
- const askCode = async (prompt) => {
93
- emit({ event: "need_code", message: (prompt && prompt.message) || "Paste the authorization code",
94
- placeholder: (prompt && prompt.placeholder) || "" });
95
- return await readLine();
96
- };
97
- await storage.login(providerId, {
98
- onAuth: (info) => emit({ event: "auth", url: info.url, instructions: info.instructions || "" }),
99
- onDeviceCode: (info) => emit({ event: "device_code", ...info }),
100
- onProgress: (m) => emit({ event: "progress", message: String(m) }),
101
- onManualCodeInput: async () => askCode(),
102
- onPrompt: async (prompt) => askCode(prompt),
103
- onSelect: async (prompt) => selectLoginOption(prompt),
104
- });
105
- emit({ event: "done" });
106
- return;
107
- }
108
-
109
- throw new Error(`unknown command: ${cmd}`);
110
- }
111
-
112
- main().catch((e) => {
113
- emit({ event: "error", message: (e && e.message) || String(e) });
114
- process.exit(1);
115
- });
@@ -1,81 +0,0 @@
1
- """Tests for pidriver.auth — OAuth context, code extraction, and node guard."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- from pidriver import PiConfig
10
- from pidriver.auth import (
11
- PiOAuthLogin,
12
- auth_context,
13
- extract_oauth_code,
14
- helper_script_path,
15
- )
16
- from pidriver.errors import PiInstallError
17
-
18
-
19
- @pytest.mark.parametrize(
20
- ("raw", "expected"),
21
- [
22
- ("abc123", "abc123"),
23
- (" abc123 ", "abc123"),
24
- ("http://localhost/callback?code=xyz&state=1", "xyz"),
25
- ("code=xyz", "xyz"),
26
- ("foo?code=deep", "deep"),
27
- ("", ""),
28
- ],
29
- )
30
- def test_extract_oauth_code(raw: str, expected: str) -> None:
31
- assert extract_oauth_code(raw) == expected
32
-
33
-
34
- def test_helper_script_ships_and_is_readable() -> None:
35
- path = helper_script_path()
36
- assert path.name == "pi_login.mjs"
37
- assert "AuthStorage" in path.read_text(encoding="utf-8")
38
-
39
-
40
- def _seed_install(tmp_path: Path) -> Path:
41
- install_dir = tmp_path / "pi"
42
- pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
43
- pkg.mkdir(parents=True)
44
- return install_dir
45
-
46
-
47
- def test_auth_context_builds_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
48
- monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
49
- install_dir = _seed_install(tmp_path)
50
- agent_dir = tmp_path / "agent"
51
- config = PiConfig(install_dir=install_dir, agent_dir=agent_dir)
52
-
53
- auth_path, env = auth_context(config)
54
- assert auth_path == agent_dir / "auth.json"
55
- assert env["PI_NODE_MODULES"] == str(install_dir / "lib" / "node_modules")
56
- assert env["PI_AUTH_PATH"] == str(agent_dir / "auth.json")
57
-
58
-
59
- def test_auth_context_requires_agent_dir(tmp_path: Path) -> None:
60
- with pytest.raises(PiInstallError):
61
- auth_context(PiConfig(install_dir=_seed_install(tmp_path)))
62
-
63
-
64
- def test_auth_context_requires_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
65
- monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
66
- config = PiConfig(install_dir=tmp_path / "empty", agent_dir=tmp_path / "agent")
67
- with pytest.raises(PiInstallError):
68
- auth_context(config)
69
-
70
-
71
- async def test_start_without_node_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
72
- monkeypatch.setattr("pidriver.auth.shutil.which", lambda name, **kw: None)
73
- login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
74
- with pytest.raises(PiInstallError):
75
- await login.start("anthropic")
76
-
77
-
78
- async def test_submit_code_unknown_session(tmp_path: Path) -> None:
79
- login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
80
- with pytest.raises(KeyError):
81
- await login.submit_code("nope", "code")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes