pairwit 0.0.2__tar.gz → 0.0.4__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 (59) hide show
  1. {pairwit-0.0.2 → pairwit-0.0.4}/PKG-INFO +3 -1
  2. {pairwit-0.0.2 → pairwit-0.0.4}/README.md +2 -0
  3. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/__init__.py +1 -1
  4. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/clients.py +117 -0
  5. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/providers.py +4 -0
  6. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/app.py +67 -10
  7. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/__init__.py +2 -0
  8. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/modals.py +447 -0
  9. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/util/env.py +44 -0
  10. {pairwit-0.0.2 → pairwit-0.0.4}/pyproject.toml +2 -2
  11. pairwit-0.0.2/site/README.md +0 -106
  12. {pairwit-0.0.2 → pairwit-0.0.4}/.gitignore +0 -0
  13. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ARCHITECTURE.md +0 -0
  14. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/__main__.py +0 -0
  15. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/__init__.py +0 -0
  16. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/analytics.py +0 -0
  17. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/context.py +0 -0
  18. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/errors.py +0 -0
  19. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/events.py +0 -0
  20. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/loop.py +0 -0
  21. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/stream.py +0 -0
  22. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/turn.py +0 -0
  23. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/__init__.py +0 -0
  24. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/catalog.py +0 -0
  25. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/types.py +0 -0
  26. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/assets/__init__.py +0 -0
  27. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/assets/pairwit-mascot-8bit.png +0 -0
  28. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/core.py +0 -0
  29. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/__init__.py +0 -0
  30. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/context.py +0 -0
  31. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/git.py +0 -0
  32. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/paths.py +0 -0
  33. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/permissions.py +0 -0
  34. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/prompts.py +0 -0
  35. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/schedules.py +0 -0
  36. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/sessions.py +0 -0
  37. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/__init__.py +0 -0
  38. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/approval.py +0 -0
  39. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/base.py +0 -0
  40. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/constants.py +0 -0
  41. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/execution.py +0 -0
  42. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/mcp.py +0 -0
  43. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/modes.py +0 -0
  44. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/registry.py +0 -0
  45. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/result.py +0 -0
  46. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/schemas.py +0 -0
  47. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/types.py +0 -0
  48. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/__init__.py +0 -0
  49. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/clipboard.py +0 -0
  50. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/theme.py +0 -0
  51. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/cards.py +0 -0
  52. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/chrome.py +0 -0
  53. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/editors.py +0 -0
  54. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/explorer.py +0 -0
  55. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/formatters.py +0 -0
  56. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/input_area.py +0 -0
  57. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/schedule.py +0 -0
  58. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/tools_ui.py +0 -0
  59. {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/util/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pairwit
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Consent-first coding agent for your terminal (Plan/Build, approvals, /schedule)
5
5
  Project-URL: Homepage, https://pairwit.dev
6
6
  Project-URL: Repository, https://github.com/pairwit/pairwit
@@ -35,6 +35,8 @@ pairwit
35
35
 
36
36
  Public alpha — APIs and UX may change. Feedback welcome.
37
37
 
38
+ On first launch, Pairwit opens a guided provider setup (pick provider → API key → model → connection check). Keys go to `~/.pairwit/.env`. On Windows, if `pairwit` is not on PATH, use `py -m pairwit`.
39
+
38
40
  Editable install from a checkout:
39
41
 
40
42
  ```bash
@@ -9,6 +9,8 @@ pairwit
9
9
 
10
10
  Public alpha — APIs and UX may change. Feedback welcome.
11
11
 
12
+ On first launch, Pairwit opens a guided provider setup (pick provider → API key → model → connection check). Keys go to `~/.pairwit/.env`. On Windows, if `pairwit` is not on PATH, use `py -m pairwit`.
13
+
12
14
  Editable install from a checkout:
13
15
 
14
16
  ```bash
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.0.2"
5
+ __version__ = "0.0.4"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -112,6 +112,123 @@ def _build_anthropic_client() -> Any:
112
112
  return _anthropic_mod.Anthropic(api_key=key)
113
113
 
114
114
 
115
+ PROVIDER_KEY_HINTS: dict[str, str] = {
116
+ "moonshot": "Get a key: https://platform.moonshot.ai",
117
+ "openai": "Get a key: https://platform.openai.com/api-keys",
118
+ "anthropic": "Get a key: https://console.anthropic.com/settings/keys",
119
+ "openrouter": "Get a key: https://openrouter.ai/keys",
120
+ "deepseek": "Get a key: https://platform.deepseek.com/api_keys",
121
+ "custom": "Use any OpenAI-compatible base URL + API key.",
122
+ "ollama": "Install Ollama and pull a model: https://ollama.com",
123
+ }
124
+
125
+
126
+ def _smoke_test_provider(
127
+ provider_id: str,
128
+ model: str,
129
+ *,
130
+ api_key: str | None = None,
131
+ base_url: str | None = None,
132
+ timeout_s: float = 12.0,
133
+ ) -> str | None:
134
+ """Return None on success, or a short error message if the provider cannot chat.
135
+
136
+ Optional api_key / base_url override process env so the wizard can verify
137
+ before writing ~/.pairwit/.env.
138
+ """
139
+ from pairwit.ai.catalog import _discover_ollama_models
140
+
141
+ provider_id = (provider_id or "").strip().lower()
142
+ model = (model or "").strip()
143
+ if provider_id not in PROVIDERS:
144
+ return f"Unknown provider: {provider_id}"
145
+ if not model:
146
+ return "Pick a model to continue."
147
+
148
+ if provider_id == "ollama":
149
+ models = _discover_ollama_models()
150
+ if not models:
151
+ return f"Ollama unreachable at {_ollama_host()} — start the daemon and pull a model."
152
+ ids = {m.id for m in models}
153
+ # Allow tag-less matches (llama3.2 vs llama3.2:latest)
154
+ if model not in ids and not any(mid.startswith(f"{model}:") or model.startswith(f"{mid}:") for mid in ids):
155
+ # Still OK if daemon is up — default model may not be pulled yet
156
+ return (
157
+ f"Ollama is up but model `{model}` was not found. "
158
+ f"Pull it (`ollama pull {model}`) or pick another model."
159
+ )
160
+ return None
161
+
162
+ if provider_id == "anthropic":
163
+ if _anthropic_mod is None:
164
+ return "Install the anthropic package: pip install anthropic"
165
+ if api_key is not None:
166
+ key = api_key.strip()
167
+ else:
168
+ key = (_provider_api_key("anthropic") or "").strip()
169
+ if not key:
170
+ return "ANTHROPIC_API_KEY is required."
171
+ try:
172
+ client = _anthropic_mod.Anthropic(api_key=key, timeout=timeout_s)
173
+ client.messages.create(
174
+ model=model,
175
+ max_tokens=1,
176
+ messages=[{"role": "user", "content": "ping"}],
177
+ )
178
+ except Exception as exc: # noqa: BLE001 — surface provider error to wizard
179
+ return _smoke_error_text(exc)
180
+ return None
181
+
182
+ # OpenAI-compatible providers
183
+ if api_key is not None:
184
+ key = api_key.strip()
185
+ else:
186
+ key = (_provider_api_key(provider_id) or "").strip()
187
+ if not key:
188
+ env_name = PROVIDERS[provider_id].api_key_env or "API key"
189
+ return f"{env_name} is required."
190
+
191
+ if provider_id == "custom":
192
+ if base_url is not None:
193
+ resolved_base = base_url.strip().rstrip("/")
194
+ else:
195
+ resolved_base = (_env_get("PAIRWIT_BASE_URL") or "").rstrip("/")
196
+ if not resolved_base:
197
+ return "PAIRWIT_BASE_URL is required for custom providers."
198
+ else:
199
+ resolved_base = _openai_compat_base_url(provider_id)
200
+
201
+ headers: dict[str, str] | None = None
202
+ if provider_id == "openrouter":
203
+ headers = {
204
+ "HTTP-Referer": "https://github.com/pairwit/pairwit",
205
+ "X-Title": "Pairwit",
206
+ }
207
+ try:
208
+ client = OpenAI(
209
+ api_key=key or "ollama",
210
+ base_url=resolved_base,
211
+ default_headers=headers,
212
+ timeout=timeout_s,
213
+ )
214
+ client.chat.completions.create(
215
+ model=model,
216
+ messages=[{"role": "user", "content": "ping"}],
217
+ max_tokens=1,
218
+ )
219
+ except Exception as exc: # noqa: BLE001
220
+ return _smoke_error_text(exc)
221
+ return None
222
+
223
+
224
+ def _smoke_error_text(exc: BaseException) -> str:
225
+ text = str(exc).strip() or exc.__class__.__name__
226
+ # Keep the modal readable
227
+ if len(text) > 180:
228
+ text = text[:177] + "…"
229
+ return text
230
+
231
+
115
232
  def _openai_tools_to_anthropic(tools: list[dict]) -> list[dict]:
116
233
  converted: list[dict] = []
117
234
  for tool in tools:
@@ -22,6 +22,7 @@ from pairwit.ai.catalog import (
22
22
  _set_active_pricing,
23
23
  )
24
24
  from pairwit.ai.clients import (
25
+ PROVIDER_KEY_HINTS,
25
26
  _anthropic_mod,
26
27
  _build_anthropic_client,
27
28
  _build_openai_client,
@@ -33,6 +34,7 @@ from pairwit.ai.clients import (
33
34
  _openai_tools_to_anthropic,
34
35
  _provider_api_key,
35
36
  _provider_missing_key_message,
37
+ _smoke_test_provider,
36
38
  )
37
39
  from pairwit.ai.types import ModelInfo, ProviderSpec
38
40
 
@@ -44,6 +46,7 @@ __all__ = [
44
46
  "MODEL_INPUT_USD_PER_M",
45
47
  "MODEL_OUTPUT_USD_PER_M",
46
48
  "PROVIDER",
49
+ "PROVIDER_KEY_HINTS",
47
50
  "PROVIDER_ORDER",
48
51
  "PROVIDERS",
49
52
  "ModelInfo",
@@ -67,4 +70,5 @@ __all__ = [
67
70
  "_provider_missing_key_message",
68
71
  "_provider_spec",
69
72
  "_set_active_pricing",
73
+ "_smoke_test_provider",
70
74
  ]
@@ -164,6 +164,7 @@ from pairwit.tui.widgets import (
164
164
  Prompt,
165
165
  PromptModal,
166
166
  ProviderPickerScreen,
167
+ ProviderSetupScreen,
167
168
  Response,
168
169
  SchedulePane,
169
170
  SessionStatsBar,
@@ -523,7 +524,9 @@ class PairwitApp(App):
523
524
  self._openai_client: OpenAI | None = None
524
525
  self._anthropic_client: Any | None = None
525
526
  self._provider_note: str | None = None
526
- self.rebuild_llm()
527
+ self._needs_provider_setup = bool(_provider_missing_key_message(self._provider))
528
+ if not self._needs_provider_setup:
529
+ self.rebuild_llm()
527
530
  self._refresh_mode_subtitle()
528
531
 
529
532
  def rebuild_llm(self) -> None:
@@ -710,6 +713,8 @@ class PairwitApp(App):
710
713
  self.call_after_refresh(self._show_startup_note, self._provider_note)
711
714
  if self._mcp_note:
712
715
  self.call_after_refresh(self._show_startup_note, self._mcp_note)
716
+ if self._needs_provider_setup:
717
+ self.call_after_refresh(self._launch_provider_setup)
713
718
  if self._pending_replay_banner:
714
719
  banner = self._pending_replay_banner
715
720
  self._pending_replay_banner = None
@@ -722,6 +727,46 @@ class PairwitApp(App):
722
727
  def _show_startup_note(self, text: str) -> None:
723
728
  self.run_worker(self._show_system_note(text))
724
729
 
730
+ def _launch_provider_setup(self) -> None:
731
+ self._run_provider_setup()
732
+
733
+ @work(exclusive=True)
734
+ async def _run_provider_setup(self) -> None:
735
+ """Block first-run until a usable provider is configured."""
736
+ reason = _provider_missing_key_message(self._provider)
737
+ while self._needs_provider_setup:
738
+ result = await self.push_screen_wait(
739
+ ProviderSetupScreen(
740
+ current_provider=self._provider,
741
+ reason=(reason or "").replace("❌ ", "").split("\n", 1)[0] or None,
742
+ )
743
+ )
744
+ if result is None:
745
+ self.exit(return_code=1)
746
+ return
747
+ self._provider = result["provider"]
748
+ self._model = result.get("model") or PROVIDERS[self._provider].default_model
749
+ try:
750
+ self.rebuild_llm()
751
+ except Exception as exc:
752
+ reason = str(exc)
753
+ await self._show_system_note(f"Could not start provider: {exc}")
754
+ continue
755
+ if _provider_missing_key_message(self._provider):
756
+ reason = _provider_missing_key_message(self._provider)
757
+ continue
758
+ self._needs_provider_setup = False
759
+ self._refresh_mode_subtitle()
760
+ env_path = result.get("env_path", "~/.pairwit/.env")
761
+ note = (
762
+ f"Connected · `{self._model}` · `{self._provider}`.\n"
763
+ f"Saved to `{env_path}`. Change later with /provider."
764
+ )
765
+ if self._provider_note:
766
+ note = f"{note}\n{self._provider_note}"
767
+ await self._show_system_note(note)
768
+ return
769
+
725
770
  def _startup_replay(self, banner: str) -> None:
726
771
  self.run_worker(self._replay_session_ui(banner=banner))
727
772
 
@@ -1601,8 +1646,17 @@ class PairwitApp(App):
1601
1646
  return
1602
1647
  fatal = _provider_missing_key_message(picked)
1603
1648
  if fatal:
1604
- await self._show_system_note(fatal.replace("❌ ", "").replace("\n", " "))
1605
- return
1649
+ setup = await self.push_screen_wait(
1650
+ ProviderSetupScreen(
1651
+ current_provider=self._provider,
1652
+ initial_provider=picked,
1653
+ reason=f"Configure {picked} before switching.",
1654
+ )
1655
+ )
1656
+ if not setup:
1657
+ return
1658
+ picked = setup["provider"]
1659
+ self._model = setup.get("model") or PROVIDERS[picked].default_model
1606
1660
  prev_provider = self._provider
1607
1661
  prev_model = self._model
1608
1662
  self._provider = picked
@@ -1615,11 +1669,13 @@ class PairwitApp(App):
1615
1669
  self._provider = prev_provider
1616
1670
  self._model = prev_model
1617
1671
  try:
1618
- self.rebuild_llm()
1672
+ if not self._needs_provider_setup:
1673
+ self.rebuild_llm()
1619
1674
  except Exception:
1620
1675
  pass
1621
1676
  await self._show_system_note(f"Could not switch provider: {exc}")
1622
1677
  return
1678
+ self._needs_provider_setup = False
1623
1679
  self._refresh_mode_subtitle()
1624
1680
  note = f"Provider set to `{self._provider}` · model `{self._model}`."
1625
1681
  if self._provider_note:
@@ -1710,6 +1766,13 @@ class PairwitApp(App):
1710
1766
 
1711
1767
  @on(Input.Submitted, "#prompt-input")
1712
1768
  async def on_input(self, event: Input.Submitted) -> None:
1769
+ if self._needs_provider_setup:
1770
+ event.input.clear()
1771
+ await self._show_system_note(
1772
+ "Finish provider setup first (or press Esc in the setup dialog to quit)."
1773
+ )
1774
+ self._launch_provider_setup()
1775
+ return
1713
1776
  raw = event.value.strip()
1714
1777
  if raw.startswith("/"):
1715
1778
  event.input.clear()
@@ -2294,12 +2357,6 @@ class PairwitApp(App):
2294
2357
 
2295
2358
  def agent_loop() -> None:
2296
2359
  resume = "--resume" in sys.argv[1:]
2297
- provider = (_env_get("PAIRWIT_PROVIDER") or DEFAULT_PROVIDER).strip().lower()
2298
- if provider not in PROVIDERS:
2299
- provider = DEFAULT_PROVIDER
2300
- fatal = _provider_missing_key_message(provider)
2301
- if fatal:
2302
- raise SystemExit(fatal)
2303
2360
  PairwitApp(resume=resume).run()
2304
2361
 
2305
2362
 
@@ -42,6 +42,7 @@ from pairwit.tui.widgets.modals import (
42
42
  ModelPickerScreen,
43
43
  PromptModal,
44
44
  ProviderPickerScreen,
45
+ ProviderSetupScreen,
45
46
  SessionsPickerScreen,
46
47
  ToolApprovalScreen,
47
48
  TreeContextMenu,
@@ -88,6 +89,7 @@ __all__ = [
88
89
  "Prompt",
89
90
  "PromptModal",
90
91
  "ProviderPickerScreen",
92
+ "ProviderSetupScreen",
91
93
  "Response",
92
94
  "SCHEDULE_DEFAULT_WIDTH",
93
95
  "SCHEDULE_MIN_WIDTH",
@@ -14,11 +14,15 @@ from textual.widgets import Input, OptionList, Static
14
14
  from textual.widgets._option_list import Option
15
15
 
16
16
  from pairwit.ai.providers import (
17
+ PROVIDER_KEY_HINTS,
17
18
  PROVIDER_ORDER,
18
19
  PROVIDERS,
19
20
  ModelInfo,
20
21
  _discover_ollama_models,
22
+ _models_for_provider,
23
+ _ollama_host,
21
24
  _provider_missing_key_message,
25
+ _smoke_test_provider,
22
26
  )
23
27
  from pairwit.core import MENTION_PICKER_MAX, MODEL
24
28
  from pairwit.tools.approval import (
@@ -761,3 +765,446 @@ class TreeContextMenu(ModalScreen[Optional[str]]):
761
765
  def on_option_selected(self, event: OptionList.OptionSelected) -> None:
762
766
  event.stop()
763
767
  self.dismiss(event.option_id)
768
+
769
+
770
+ class ProviderSetupScreen(ModalScreen[dict[str, str] | None]):
771
+ """Hermes-style first-run wizard: welcome → provider → key → model → verify."""
772
+
773
+ BINDINGS = [
774
+ Binding("escape", "cancel", "Back", show=False),
775
+ ]
776
+
777
+ STEPS = ("welcome", "provider", "key", "model", "verify")
778
+
779
+ DEFAULT_CSS = f"""
780
+ ProviderSetupScreen {{
781
+ align: center middle;
782
+ background: #091e42 78%;
783
+ }}
784
+
785
+ ProviderSetupScreen #setup-modal-box {{
786
+ width: 74;
787
+ height: auto;
788
+ max-height: 28;
789
+ border: tall {KIMI_BLUE};
790
+ background: {KIMI_SURFACE};
791
+ padding: 1 2;
792
+ }}
793
+
794
+ ProviderSetupScreen #setup-title {{
795
+ color: {KIMI_TEXT};
796
+ text-style: bold;
797
+ padding: 0 0 0 0;
798
+ }}
799
+
800
+ ProviderSetupScreen #setup-step {{
801
+ color: {KIMI_MUTED};
802
+ padding: 0 0 1 0;
803
+ }}
804
+
805
+ ProviderSetupScreen #setup-hint {{
806
+ color: {KIMI_MUTED};
807
+ padding: 0 0 1 0;
808
+ }}
809
+
810
+ ProviderSetupScreen #setup-error {{
811
+ color: {KIMI_ORANGE};
812
+ padding: 0 0 1 0;
813
+ }}
814
+
815
+ ProviderSetupScreen #setup-status {{
816
+ color: {KIMI_TEXT};
817
+ padding: 0 0 1 0;
818
+ }}
819
+
820
+ ProviderSetupScreen OptionList {{
821
+ height: auto;
822
+ max-height: 12;
823
+ border: solid {KIMI_BORDER};
824
+ }}
825
+
826
+ ProviderSetupScreen .option-list--option-highlighted {{
827
+ background: {KIMI_BLUE} 18%;
828
+ color: {KIMI_TEXT};
829
+ }}
830
+
831
+ ProviderSetupScreen Input {{
832
+ border: solid {KIMI_BORDER};
833
+ background: {KIMI_SURFACE_ALT};
834
+ color: {KIMI_TEXT};
835
+ margin: 0 0 1 0;
836
+ }}
837
+
838
+ ProviderSetupScreen #setup-footer {{
839
+ color: {KIMI_MUTED};
840
+ padding: 1 0 0 0;
841
+ }}
842
+ """
843
+
844
+ def __init__(
845
+ self,
846
+ *,
847
+ current_provider: str = "moonshot",
848
+ reason: str | None = None,
849
+ initial_provider: str | None = None,
850
+ ) -> None:
851
+ super().__init__()
852
+ self._current_provider = current_provider
853
+ self._reason = reason
854
+ self._providers = [PROVIDERS[pid] for pid in PROVIDER_ORDER if pid in PROVIDERS]
855
+ self._step = "welcome"
856
+ self._selected_id = initial_provider if initial_provider in PROVIDERS else None
857
+ self._api_key = ""
858
+ self._base_url = ""
859
+ self._model = (
860
+ PROVIDERS[self._selected_id].default_model
861
+ if self._selected_id
862
+ else PROVIDERS.get(current_provider, PROVIDERS["moonshot"]).default_model
863
+ )
864
+ self._verifying = False
865
+ self._verify_error = ""
866
+
867
+ def compose(self) -> ComposeResult:
868
+ with Vertical(id="setup-modal-box"):
869
+ yield Static("Welcome to Pairwit", id="setup-title")
870
+ yield Static("Step 1 of 5", id="setup-step")
871
+ yield Static("", id="setup-hint")
872
+ yield Static("", id="setup-error")
873
+ yield Static("", id="setup-status")
874
+ yield OptionList(id="setup-list")
875
+ yield Static("", id="setup-key-label")
876
+ yield Input(placeholder="API key", password=True, id="setup-api-key")
877
+ yield Static("", id="setup-base-label")
878
+ yield Input(placeholder="https://…/v1", id="setup-base-url")
879
+ yield Static("Enter · continue Esc · back / quit", id="setup-footer")
880
+
881
+ def on_mount(self) -> None:
882
+ if self._selected_id:
883
+ # Jump into credentials (or model for ollama) when opened from /provider.
884
+ if self._selected_id == "ollama":
885
+ self._show_step("model")
886
+ else:
887
+ self._show_step("key")
888
+ return
889
+ self._show_step("welcome")
890
+
891
+ def _set_error(self, message: str) -> None:
892
+ self.query_one("#setup-error", Static).update(message)
893
+
894
+ def _set_status(self, message: str) -> None:
895
+ self.query_one("#setup-status", Static).update(message)
896
+
897
+ def _hide_inputs(self) -> None:
898
+ self.query_one("#setup-key-label", Static).display = False
899
+ self.query_one("#setup-api-key", Input).display = False
900
+ self.query_one("#setup-base-label", Static).display = False
901
+ self.query_one("#setup-base-url", Input).display = False
902
+
903
+ def _step_index(self) -> int:
904
+ try:
905
+ return self.STEPS.index(self._step) + 1
906
+ except ValueError:
907
+ return 1
908
+
909
+ def _show_step(self, step: str) -> None:
910
+ self._step = step
911
+ self._set_error("")
912
+ self._set_status("")
913
+ self._hide_inputs()
914
+ title = self.query_one("#setup-title", Static)
915
+ step_label = self.query_one("#setup-step", Static)
916
+ hint = self.query_one("#setup-hint", Static)
917
+ option_list = self.query_one("#setup-list", OptionList)
918
+ footer = self.query_one("#setup-footer", Static)
919
+ step_label.update(f"Step {self._step_index()} of {len(self.STEPS)}")
920
+ option_list.display = True
921
+ option_list.clear_options()
922
+
923
+ if step == "welcome":
924
+ title.update("Welcome to Pairwit")
925
+ body = (
926
+ "Pairwit needs one AI provider before it can chat.\n"
927
+ "• Keys are saved locally to ~/.pairwit/.env\n"
928
+ "• Change later anytime with /provider\n"
929
+ "• Approvals stay on by default"
930
+ )
931
+ if self._reason:
932
+ body = f"{self._reason.strip()}\n\n{body}"
933
+ hint.update(body)
934
+ option_list.add_options([Option("Continue — choose a provider", id="act-continue")])
935
+ footer.update("Enter · continue Esc · quit")
936
+ option_list.focus()
937
+ return
938
+
939
+ if step == "provider":
940
+ title.update("Choose a provider")
941
+ hint.update("Moonshot is the recommended path. Ollama needs no API key.")
942
+ options: list[Option] = []
943
+ for index, spec in enumerate(self._providers):
944
+ if spec.id == "moonshot":
945
+ label = f"{spec.label} (recommended) · {spec.api_key_env}"
946
+ elif spec.id == "ollama":
947
+ label = f"{spec.label} · local, no API key"
948
+ elif spec.id == "custom":
949
+ label = f"{spec.label} · key + base URL"
950
+ else:
951
+ label = f"{spec.label} · {spec.api_key_env}"
952
+ if spec.id == self._current_provider:
953
+ label = f"{label} ← current"
954
+ options.append(Option(label, id=f"prov-{index}"))
955
+ option_list.add_options(options)
956
+ footer.update("Enter · select Esc · back")
957
+ option_list.focus()
958
+ return
959
+
960
+ if step == "key":
961
+ assert self._selected_id
962
+ spec = PROVIDERS[self._selected_id]
963
+ title.update(f"Add your {spec.label} key")
964
+ hint_line = PROVIDER_KEY_HINTS.get(spec.id, "")
965
+ hint.update(
966
+ f"Paste {spec.api_key_env} below. {hint_line}".strip()
967
+ )
968
+ option_list.display = False
969
+ key_label = self.query_one("#setup-key-label", Static)
970
+ key_input = self.query_one("#setup-api-key", Input)
971
+ key_label.display = True
972
+ key_input.display = True
973
+ key_label.update(spec.api_key_env or "API key")
974
+ key_input.value = self._api_key
975
+ key_input.placeholder = spec.api_key_env or "API key"
976
+ if spec.id == "custom":
977
+ base_label = self.query_one("#setup-base-label", Static)
978
+ base_input = self.query_one("#setup-base-url", Input)
979
+ base_label.display = True
980
+ base_input.display = True
981
+ base_label.update("PAIRWIT_BASE_URL (OpenAI-compatible)")
982
+ base_input.value = self._base_url
983
+ base_input.focus()
984
+ else:
985
+ key_input.focus()
986
+ footer.update("Enter · continue Esc · back")
987
+ return
988
+
989
+ if step == "model":
990
+ assert self._selected_id
991
+ spec = PROVIDERS[self._selected_id]
992
+ if not self._model:
993
+ self._model = spec.default_model
994
+ title.update("Confirm model")
995
+ hint.update(
996
+ f"Default for {spec.label} is `{spec.default_model}`. "
997
+ "Accept it, or pick another."
998
+ )
999
+ models = _models_for_provider(self._selected_id)
1000
+ options = [
1001
+ Option(
1002
+ f"Accept default · {spec.default_model}",
1003
+ id="model-default",
1004
+ )
1005
+ ]
1006
+ for index, info in enumerate(models[:12]):
1007
+ mark = " ✓" if info.id == self._model else ""
1008
+ options.append(
1009
+ Option(f"{info.label} ({info.id}){mark}", id=f"model-{index}")
1010
+ )
1011
+ option_list.add_options(options)
1012
+ footer.update("Enter · select Esc · back")
1013
+ option_list.focus()
1014
+ return
1015
+
1016
+ if step == "verify":
1017
+ assert self._selected_id
1018
+ title.update("Verify connection")
1019
+ hint.update(
1020
+ f"Testing `{self._model}` on {PROVIDERS[self._selected_id].label}…"
1021
+ )
1022
+ option_list.display = False
1023
+ footer.update("Please wait…")
1024
+ self._start_verify()
1025
+ return
1026
+
1027
+ def _start_verify(self) -> None:
1028
+ if self._verifying:
1029
+ return
1030
+ self._verifying = True
1031
+ self._set_status("Checking provider…")
1032
+ self._set_error("")
1033
+ self.run_worker(self._verify_worker, thread=True)
1034
+
1035
+ def _verify_worker(self) -> None:
1036
+ provider_id = self._selected_id or ""
1037
+ error = _smoke_test_provider(
1038
+ provider_id,
1039
+ self._model,
1040
+ api_key=self._api_key or None,
1041
+ base_url=self._base_url or None,
1042
+ )
1043
+ self.app.call_from_thread(self._on_verify_done, error)
1044
+
1045
+ def _on_verify_done(self, error: str | None) -> None:
1046
+ self._verifying = False
1047
+ if error:
1048
+ self._verify_error = error
1049
+ self._set_status("")
1050
+ self._set_error(error)
1051
+ option_list = self.query_one("#setup-list", OptionList)
1052
+ option_list.display = True
1053
+ option_list.clear_options()
1054
+ options = [
1055
+ Option("Retry connection test", id="act-retry"),
1056
+ ]
1057
+ if self._selected_id != "ollama":
1058
+ options.append(Option("Change API key", id="act-change-key"))
1059
+ options.append(Option("Choose another provider", id="act-change-provider"))
1060
+ option_list.add_options(options)
1061
+ self.query_one("#setup-footer", Static).update("Enter · choose Esc · back")
1062
+ option_list.focus()
1063
+ return
1064
+ self._finish_success()
1065
+
1066
+ def _finish_success(self) -> None:
1067
+ from pairwit.util.env import upsert_pairwit_env
1068
+
1069
+ provider_id = self._selected_id
1070
+ if not provider_id:
1071
+ self._set_error("No provider selected.")
1072
+ return
1073
+ spec = PROVIDERS[provider_id]
1074
+ values: dict[str, str] = {
1075
+ "PAIRWIT_PROVIDER": provider_id,
1076
+ "PAIRWIT_MODEL": self._model or spec.default_model,
1077
+ }
1078
+ if provider_id != "ollama" and spec.api_key_env and self._api_key:
1079
+ values[spec.api_key_env] = self._api_key
1080
+ if provider_id == "custom" and self._base_url:
1081
+ values["PAIRWIT_BASE_URL"] = self._base_url.rstrip("/")
1082
+ path = upsert_pairwit_env(values)
1083
+ self._set_status(f"Connected · {self._model} · {spec.label}")
1084
+ self.dismiss(
1085
+ {
1086
+ "provider": provider_id,
1087
+ "model": self._model or spec.default_model,
1088
+ "env_path": str(path),
1089
+ }
1090
+ )
1091
+
1092
+ def _advance_from_key(self) -> None:
1093
+ provider_id = self._selected_id
1094
+ if not provider_id:
1095
+ self._set_error("Pick a provider first.")
1096
+ return
1097
+ spec = PROVIDERS[provider_id]
1098
+ key = self.query_one("#setup-api-key", Input).value.strip()
1099
+ if not key:
1100
+ self._set_error(f"Enter {spec.api_key_env} to continue.")
1101
+ self.query_one("#setup-api-key", Input).focus()
1102
+ return
1103
+ self._api_key = key
1104
+ if provider_id == "custom":
1105
+ base = self.query_one("#setup-base-url", Input).value.strip().rstrip("/")
1106
+ if not base:
1107
+ self._set_error("Enter PAIRWIT_BASE_URL (e.g. https://api.example.com/v1).")
1108
+ self.query_one("#setup-base-url", Input).focus()
1109
+ return
1110
+ self._base_url = base
1111
+ if provider_id == "anthropic":
1112
+ from pairwit.ai.clients import _anthropic_mod
1113
+
1114
+ if _anthropic_mod is None:
1115
+ self._set_error("Install the anthropic package: pip install anthropic")
1116
+ return
1117
+ self._model = spec.default_model
1118
+ self._show_step("model")
1119
+
1120
+ def action_cancel(self) -> None:
1121
+ if self._verifying:
1122
+ return
1123
+ if self._step == "welcome":
1124
+ self.dismiss(None)
1125
+ return
1126
+ if self._step == "provider":
1127
+ self._show_step("welcome")
1128
+ return
1129
+ if self._step == "key":
1130
+ self._show_step("provider")
1131
+ return
1132
+ if self._step == "model":
1133
+ if self._selected_id == "ollama":
1134
+ self._show_step("provider")
1135
+ else:
1136
+ self._show_step("key")
1137
+ return
1138
+ if self._step == "verify":
1139
+ self._show_step("model")
1140
+ return
1141
+ self.dismiss(None)
1142
+
1143
+ @on(OptionList.OptionSelected, "#setup-list")
1144
+ def on_list_selected(self, event: OptionList.OptionSelected) -> None:
1145
+ event.stop()
1146
+ option_id = event.option_id or ""
1147
+ if option_id == "act-continue":
1148
+ self._show_step("provider")
1149
+ return
1150
+ if option_id == "act-retry":
1151
+ self._show_step("verify")
1152
+ return
1153
+ if option_id == "act-change-key":
1154
+ self._show_step("key")
1155
+ return
1156
+ if option_id == "act-change-provider":
1157
+ self._show_step("provider")
1158
+ return
1159
+ if option_id.startswith("prov-"):
1160
+ try:
1161
+ index = int(option_id.split("-", 1)[1])
1162
+ except ValueError:
1163
+ return
1164
+ if index < 0 or index >= len(self._providers):
1165
+ return
1166
+ self._selected_id = self._providers[index].id
1167
+ self._model = self._providers[index].default_model
1168
+ self._api_key = ""
1169
+ self._base_url = ""
1170
+ if self._selected_id == "ollama":
1171
+ host = _ollama_host()
1172
+ if not _discover_ollama_models():
1173
+ self._set_error(
1174
+ f"Ollama unreachable at {host}. Start it, then pick Ollama again."
1175
+ )
1176
+ return
1177
+ self._show_step("model")
1178
+ else:
1179
+ self._show_step("key")
1180
+ return
1181
+ if option_id == "model-default":
1182
+ assert self._selected_id
1183
+ self._model = PROVIDERS[self._selected_id].default_model
1184
+ self._show_step("verify")
1185
+ return
1186
+ if option_id.startswith("model-"):
1187
+ try:
1188
+ index = int(option_id.split("-", 1)[1])
1189
+ except ValueError:
1190
+ return
1191
+ models = _models_for_provider(self._selected_id or "")
1192
+ if index < 0 or index >= len(models):
1193
+ return
1194
+ self._model = models[index].id
1195
+ self._show_step("verify")
1196
+ return
1197
+
1198
+ @on(Input.Submitted, "#setup-api-key")
1199
+ def on_api_key_submitted(self, event: Input.Submitted) -> None:
1200
+ event.stop()
1201
+ self._advance_from_key()
1202
+
1203
+ @on(Input.Submitted, "#setup-base-url")
1204
+ def on_base_url_submitted(self, event: Input.Submitted) -> None:
1205
+ event.stop()
1206
+ key = self.query_one("#setup-api-key", Input)
1207
+ if not key.value.strip():
1208
+ key.focus()
1209
+ return
1210
+ self._advance_from_key()
@@ -53,3 +53,47 @@ def _env_flag(name: str, *, default: bool = True) -> bool:
53
53
  if value is None:
54
54
  return default
55
55
  return value.strip().lower() not in ("0", "false", "no", "off")
56
+
57
+
58
+ def upsert_pairwit_env(values: dict[str, str]) -> Path:
59
+ """Write/update keys in `~/.pairwit/.env` and apply them to the process env.
60
+
61
+ Preserves unrelated lines and comments. Creates the home dir if needed.
62
+ """
63
+ path = Path.home() / ".pairwit" / ".env"
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ cleaned = {key: value.strip() for key, value in values.items() if value and value.strip()}
67
+ if not cleaned:
68
+ return path
69
+
70
+ existing_lines: list[str] = []
71
+ if path.is_file():
72
+ existing_lines = path.read_text(encoding="utf-8").splitlines()
73
+
74
+ seen: set[str] = set()
75
+ out: list[str] = []
76
+ for line in existing_lines:
77
+ stripped = line.strip()
78
+ if not stripped or stripped.startswith("#") or "=" not in line:
79
+ out.append(line)
80
+ continue
81
+ key = stripped.split("=", 1)[0].strip()
82
+ if key in cleaned:
83
+ out.append(f"{key}={cleaned[key]}")
84
+ seen.add(key)
85
+ else:
86
+ out.append(line)
87
+
88
+ for key, value in cleaned.items():
89
+ if key not in seen:
90
+ out.append(f"{key}={value}")
91
+ os.environ[key] = value
92
+
93
+ # Keys already rewritten above still need process env updates.
94
+ for key, value in cleaned.items():
95
+ os.environ[key] = value
96
+
97
+ text = "\n".join(out).rstrip() + "\n"
98
+ path.write_text(text, encoding="utf-8")
99
+ return path
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pairwit"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "Consent-first coding agent for your terminal (Plan/Build, approvals, /schedule)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -41,7 +41,7 @@ packages = ["pairwit"]
41
41
  "pairwit/assets/pairwit-mascot-8bit.png" = "pairwit/assets/pairwit-mascot-8bit.png"
42
42
 
43
43
  [tool.hatch.build.targets.sdist]
44
- include = [
44
+ only-include = [
45
45
  "pairwit",
46
46
  "README.md",
47
47
  "pyproject.toml",
@@ -1,106 +0,0 @@
1
- # pairwit.dev site
2
-
3
- Static site for [pairwit.dev](https://pairwit.dev).
4
-
5
- | Page | File |
6
- |---|---|
7
- | Landing | `index.html` |
8
- | User guide | `guide.html` |
9
- | Styles (brand locked) | `styles.css` |
10
- | Behavior (howto walkthrough) | `script.js` |
11
-
12
- ## Brand — LOCKED
13
-
14
- Do not change fonts, palette, or light theme without an explicit brand decision.
15
- Matches the kimicode-light TUI.
16
-
17
- **Visual references (product UI):**
18
-
19
- Marketing page uses crisp HTML/`pw-frame` terminal recreations (same chrome everywhere). Soft JPEG captures are archived until we shoot retina PNGs.
20
-
21
- | File | What it documents |
22
- |---|---|
23
- | `assets/pairwit-mascot-8bit.png` | 8-bit cat + 2-wheel robot mascot (nav, hero, mocks, OG, favicon source) |
24
- | `assets/favicon.ico` / `favicon-16.png` / `favicon-32.png` / `favicon-48.png` / `apple-touch-icon.png` | Brand icons from the mascot |
25
- | `favicon.ico` (site root) | Browser auto-discovery at `/favicon.ico` |
26
- | `docs/site-captures/ref-desk.png` | Older desk capture (soft JPEG) |
27
- | `docs/site-captures/ref-chat.png` | Older chat capture (soft JPEG) |
28
- | `site/assets/shot-desk.png` / `shot-chat.png` | Cropped exports — interim only |
29
-
30
- How-to + feature mocks on the landing stay faithful to the live TUI chrome. Site uses the PNG; TUI welcome uses an ASCII sprite of the same cat-robot (`WelcomeMascot`) — half-block PNG downscales look muddy in terminal. PNG also ships in `pairwit/assets/`.
31
-
32
- | Token | Value | Role |
33
- |---|---|---|
34
- | Canvas | `#FDFCF5` | Page / TUI background |
35
- | Alt surface | `#F5F4F1` | Section bands, code chips |
36
- | Surface | `#FFFFFF` | Cards / shells |
37
- | Text | `#3D3929` | Primary ink |
38
- | Muted | `#8A8580` | Secondary copy |
39
- | Accent | `#0079BF` | Links, step numbers, grips |
40
- | Accent deep | `#026AA7` | Hover |
41
- | Border | `#E8E4D9` | Rules / chrome |
42
- | Green | `#6BAA84` | Success / completion tokens |
43
- | Orange | `#D4A574` | Cost / warm accents |
44
- | Brand font | **Sora** | Pairwit wordmark, headings |
45
- | Body font | **IBM Plex Sans** | Prose |
46
- | Mono font | **IBM Plex Mono** | Commands, shortcuts |
47
-
48
- Theme: light only (no dark mode on the marketing site).
49
-
50
- ## Next (do not skip) — agent reminder
51
-
52
- **Status:** not done yet. Finish before calling the marketing site “shipped.”
53
-
54
- 1. ~~**Mobile-first polish**~~ — done (nav menu, stacked hero/howto desk, safe-area, 44px targets). Re-check on real devices before ship.
55
- 2. **Cross-browser** — verify Chromium, Safari (desktop + iOS), Firefox; check `background-clip: text`, sticky nav, clipboard copy fallback already in `script.js`.
56
- 3. ~~**gtag**~~ — GA4 `G-LJ45HH53S5` on `index.html` + `guide.html`. Consent banner still optional later.
57
- 4. ~~**Deploy → Cloudflare Pages**~~ — project `pairwit` live at https://pairwit.pages.dev. Custom domains `pairwit.dev` / `www` registered but **DNS pending** (CNAME → `pairwit.pages.dev` or move zone to Cloudflare).
58
-
59
- ## Deploy status
60
-
61
- | Target | URL |
62
- |---|---|
63
- | Pages production | https://pairwit.pages.dev |
64
- | Custom domain | `pairwit.dev` + `www` added on Pages (DNS still pending) |
65
-
66
- ### Redeploy
67
-
68
- Requires Node ≥ 22:
69
-
70
- ```bash
71
- export NVM_DIR="$HOME/.nvm" && . "$NVM_DIR/nvm.sh" && nvm use 22
72
- cd site
73
- npx wrangler@latest pages deploy . --project-name pairwit --branch main --commit-dirty=true
74
- ```
75
-
76
- ### DNS for pairwit.dev
77
-
78
- Zone **pairwit.dev** is on Cloudflare (status pending until nameservers update).
79
-
80
- **At Hostinger (registrar)** set nameservers to:
81
-
82
- - `kristin.ns.cloudflare.com`
83
- - `quentin.ns.cloudflare.com`
84
-
85
- Then remove Hostinger parking DNS. Apex + `www` CNAMEs to `pairwit.pages.dev` are set in the Cloudflare zone (proxied).
86
-
87
- Dashboard: https://dash.cloudflare.com → select **pairwit.dev** → Overview (shows nameserver instructions).
88
-
89
-
90
- ```bash
91
- cd site
92
- python3 -m http.server 8080
93
- # open http://127.0.0.1:8080
94
- # guide: http://127.0.0.1:8080/guide.html
95
- ```
96
-
97
- ## Temporary Hostinger fallback
98
-
99
- Only if Pages is not ready yet:
100
-
101
- 1. Hostinger → manage **pairwit.dev**
102
- 2. Upload `index.html`, `guide.html`, `styles.css`, `script.js` (+ optional `assets/`) to `public_html/`
103
- 3. Visit https://pairwit.dev
104
-
105
- Install one-liner on the page: `pip install pairwit && pairwit`
106
- (Agent release on PyPI is still gated — update copy when you green-light the full package.)
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes