pairwit 0.0.2__tar.gz → 0.0.3__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.3}/PKG-INFO +3 -1
  2. {pairwit-0.0.2 → pairwit-0.0.3}/README.md +2 -0
  3. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/__init__.py +1 -1
  4. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/app.py +67 -10
  5. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/__init__.py +2 -0
  6. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/modals.py +252 -0
  7. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/util/env.py +44 -0
  8. {pairwit-0.0.2 → pairwit-0.0.3}/pyproject.toml +2 -2
  9. pairwit-0.0.2/site/README.md +0 -106
  10. {pairwit-0.0.2 → pairwit-0.0.3}/.gitignore +0 -0
  11. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ARCHITECTURE.md +0 -0
  12. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/__main__.py +0 -0
  13. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/__init__.py +0 -0
  14. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/analytics.py +0 -0
  15. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/context.py +0 -0
  16. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/errors.py +0 -0
  17. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/events.py +0 -0
  18. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/loop.py +0 -0
  19. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/stream.py +0 -0
  20. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/turn.py +0 -0
  21. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/__init__.py +0 -0
  22. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/catalog.py +0 -0
  23. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/clients.py +0 -0
  24. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/providers.py +0 -0
  25. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/types.py +0 -0
  26. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/assets/__init__.py +0 -0
  27. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/assets/pairwit-mascot-8bit.png +0 -0
  28. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/core.py +0 -0
  29. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/__init__.py +0 -0
  30. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/context.py +0 -0
  31. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/git.py +0 -0
  32. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/paths.py +0 -0
  33. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/permissions.py +0 -0
  34. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/prompts.py +0 -0
  35. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/schedules.py +0 -0
  36. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/sessions.py +0 -0
  37. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/__init__.py +0 -0
  38. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/approval.py +0 -0
  39. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/base.py +0 -0
  40. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/constants.py +0 -0
  41. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/execution.py +0 -0
  42. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/mcp.py +0 -0
  43. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/modes.py +0 -0
  44. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/registry.py +0 -0
  45. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/result.py +0 -0
  46. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/schemas.py +0 -0
  47. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/types.py +0 -0
  48. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/__init__.py +0 -0
  49. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/clipboard.py +0 -0
  50. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/theme.py +0 -0
  51. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/cards.py +0 -0
  52. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/chrome.py +0 -0
  53. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/editors.py +0 -0
  54. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/explorer.py +0 -0
  55. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/formatters.py +0 -0
  56. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/input_area.py +0 -0
  57. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/schedule.py +0 -0
  58. {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/tools_ui.py +0 -0
  59. {pairwit-0.0.2 → pairwit-0.0.3}/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.3
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 provider setup (API key → saved 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 provider setup (API key → saved 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.3"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -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"Provider ready: `{self._provider}` · `{self._model}`.\n"
763
+ f"Saved to `{env_path}`."
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",
@@ -761,3 +761,255 @@ class TreeContextMenu(ModalScreen[Optional[str]]):
761
761
  def on_option_selected(self, event: OptionList.OptionSelected) -> None:
762
762
  event.stop()
763
763
  self.dismiss(event.option_id)
764
+
765
+
766
+ class ProviderSetupScreen(ModalScreen[dict[str, str] | None]):
767
+ """First-run / missing-key wizard: pick provider, enter key, save ~/.pairwit/.env."""
768
+
769
+ BINDINGS = [
770
+ Binding("escape", "cancel", "Cancel", show=False),
771
+ ]
772
+
773
+ DEFAULT_CSS = f"""
774
+ ProviderSetupScreen {{
775
+ align: center middle;
776
+ background: #091e42 78%;
777
+ }}
778
+
779
+ ProviderSetupScreen #setup-modal-box {{
780
+ width: 72;
781
+ height: auto;
782
+ max-height: 24;
783
+ border: tall {KIMI_BLUE};
784
+ background: {KIMI_SURFACE};
785
+ padding: 1 2;
786
+ }}
787
+
788
+ ProviderSetupScreen #setup-title {{
789
+ color: {KIMI_TEXT};
790
+ text-style: bold;
791
+ padding: 0 0 1 0;
792
+ }}
793
+
794
+ ProviderSetupScreen #setup-hint {{
795
+ color: {KIMI_MUTED};
796
+ padding: 0 0 1 0;
797
+ }}
798
+
799
+ ProviderSetupScreen #setup-error {{
800
+ color: {KIMI_ORANGE};
801
+ padding: 0 0 1 0;
802
+ }}
803
+
804
+ ProviderSetupScreen OptionList {{
805
+ height: auto;
806
+ max-height: 12;
807
+ border: solid {KIMI_BORDER};
808
+ }}
809
+
810
+ ProviderSetupScreen .option-list--option-highlighted {{
811
+ background: {KIMI_BLUE} 18%;
812
+ color: {KIMI_TEXT};
813
+ }}
814
+
815
+ ProviderSetupScreen Input {{
816
+ border: solid {KIMI_BORDER};
817
+ background: {KIMI_SURFACE_ALT};
818
+ color: {KIMI_TEXT};
819
+ margin: 0 0 1 0;
820
+ }}
821
+ """
822
+
823
+ def __init__(
824
+ self,
825
+ *,
826
+ current_provider: str = "moonshot",
827
+ reason: str | None = None,
828
+ initial_provider: str | None = None,
829
+ ) -> None:
830
+ super().__init__()
831
+ self._current_provider = current_provider
832
+ self._reason = reason
833
+ self._providers = [PROVIDERS[pid] for pid in PROVIDER_ORDER if pid in PROVIDERS]
834
+ self._step = "pick" # pick | key
835
+ self._selected_id = initial_provider if initial_provider in PROVIDERS else None
836
+ self._error = ""
837
+
838
+ def compose(self) -> ComposeResult:
839
+ with Vertical(id="setup-modal-box"):
840
+ yield Static("Set up your AI provider", id="setup-title")
841
+ hint = (
842
+ "Pairwit needs a provider before it can chat. "
843
+ "Keys are saved to ~/.pairwit/.env (kept local)."
844
+ )
845
+ if self._reason:
846
+ hint = f"{self._reason.strip()}\n{hint}"
847
+ yield Static(hint, id="setup-hint")
848
+ yield Static("", id="setup-error")
849
+ yield OptionList(id="setup-provider-list")
850
+ yield Static("", id="setup-key-label")
851
+ yield Input(placeholder="API key", password=True, id="setup-api-key")
852
+ yield Static("", id="setup-base-label")
853
+ yield Input(placeholder="https://…/v1", id="setup-base-url")
854
+
855
+ def on_mount(self) -> None:
856
+ self._render_pick_step()
857
+ if self._selected_id:
858
+ self._enter_key_step(self._selected_id)
859
+
860
+ def _set_error(self, message: str) -> None:
861
+ self._error = message
862
+ self.query_one("#setup-error", Static).update(message)
863
+
864
+ def _render_pick_step(self) -> None:
865
+ self._step = "pick"
866
+ self._set_error("")
867
+ self.query_one("#setup-key-label", Static).display = False
868
+ self.query_one("#setup-api-key", Input).display = False
869
+ self.query_one("#setup-base-label", Static).display = False
870
+ self.query_one("#setup-base-url", Input).display = False
871
+ options: list[Option] = []
872
+ for index, spec in enumerate(self._providers):
873
+ if spec.id == "ollama":
874
+ extra = " · no API key"
875
+ elif spec.id == "custom":
876
+ extra = " · key + base URL"
877
+ else:
878
+ extra = f" · needs {spec.api_key_env}"
879
+ mark = " ← current" if spec.id == self._current_provider else ""
880
+ options.append(Option(f"{spec.label}{extra}{mark}", id=f"setup-{index}"))
881
+ option_list = self.query_one("#setup-provider-list", OptionList)
882
+ option_list.display = True
883
+ option_list.clear_options()
884
+ option_list.add_options(options)
885
+ option_list.focus()
886
+
887
+ def _enter_key_step(self, provider_id: str) -> None:
888
+ self._selected_id = provider_id
889
+ self._step = "key"
890
+ self._set_error("")
891
+ spec = PROVIDERS[provider_id]
892
+ self.query_one("#setup-provider-list", OptionList).display = False
893
+
894
+ if provider_id == "ollama":
895
+ self._finish_ollama()
896
+ return
897
+
898
+ key_label = self.query_one("#setup-key-label", Static)
899
+ key_input = self.query_one("#setup-api-key", Input)
900
+ base_label = self.query_one("#setup-base-label", Static)
901
+ base_input = self.query_one("#setup-base-url", Input)
902
+
903
+ key_label.display = True
904
+ key_input.display = True
905
+ key_label.update(f"{spec.label} — paste {spec.api_key_env} and press Enter")
906
+ key_input.value = ""
907
+ key_input.placeholder = spec.api_key_env or "API key"
908
+
909
+ if provider_id == "custom":
910
+ base_label.display = True
911
+ base_input.display = True
912
+ base_label.update("OpenAI-compatible base URL (PAIRWIT_BASE_URL)")
913
+ base_input.value = ""
914
+ base_input.focus()
915
+ else:
916
+ base_label.display = False
917
+ base_input.display = False
918
+ key_input.focus()
919
+
920
+ def _finish_ollama(self) -> None:
921
+ from pairwit.util.env import upsert_pairwit_env
922
+
923
+ path = upsert_pairwit_env(
924
+ {
925
+ "PAIRWIT_PROVIDER": "ollama",
926
+ "PAIRWIT_MODEL": PROVIDERS["ollama"].default_model,
927
+ }
928
+ )
929
+ self.dismiss(
930
+ {
931
+ "provider": "ollama",
932
+ "model": PROVIDERS["ollama"].default_model,
933
+ "env_path": str(path),
934
+ }
935
+ )
936
+
937
+ def _save_cloud_provider(self) -> None:
938
+ from pairwit.util.env import upsert_pairwit_env
939
+
940
+ provider_id = self._selected_id
941
+ if not provider_id or provider_id not in PROVIDERS:
942
+ self._set_error("Pick a provider first.")
943
+ return
944
+ spec = PROVIDERS[provider_id]
945
+ key = self.query_one("#setup-api-key", Input).value.strip()
946
+ if not key:
947
+ self._set_error(f"Enter {spec.api_key_env} to continue.")
948
+ self.query_one("#setup-api-key", Input).focus()
949
+ return
950
+
951
+ values: dict[str, str] = {
952
+ "PAIRWIT_PROVIDER": provider_id,
953
+ "PAIRWIT_MODEL": spec.default_model,
954
+ }
955
+ if spec.api_key_env:
956
+ values[spec.api_key_env] = key
957
+
958
+ if provider_id == "custom":
959
+ base = self.query_one("#setup-base-url", Input).value.strip().rstrip("/")
960
+ if not base:
961
+ self._set_error("Enter PAIRWIT_BASE_URL (e.g. https://api.example.com/v1).")
962
+ self.query_one("#setup-base-url", Input).focus()
963
+ return
964
+ values["PAIRWIT_BASE_URL"] = base
965
+
966
+ path = upsert_pairwit_env(values)
967
+ # Re-check after env write.
968
+ remaining = _provider_missing_key_message(provider_id)
969
+ if remaining:
970
+ self._set_error(remaining.split("\n", 1)[0].replace("❌ ", ""))
971
+ return
972
+
973
+ self.dismiss(
974
+ {
975
+ "provider": provider_id,
976
+ "model": spec.default_model,
977
+ "env_path": str(path),
978
+ }
979
+ )
980
+
981
+ def action_cancel(self) -> None:
982
+ if self._step == "key":
983
+ self._selected_id = None
984
+ self._render_pick_step()
985
+ return
986
+ self.dismiss(None)
987
+
988
+ @on(OptionList.OptionSelected, "#setup-provider-list")
989
+ def on_provider_selected(self, event: OptionList.OptionSelected) -> None:
990
+ event.stop()
991
+ option_id = event.option_id or ""
992
+ if not option_id.startswith("setup-"):
993
+ return
994
+ try:
995
+ index = int(option_id.split("-", 1)[1])
996
+ except ValueError:
997
+ return
998
+ if index < 0 or index >= len(self._providers):
999
+ return
1000
+ self._enter_key_step(self._providers[index].id)
1001
+
1002
+ @on(Input.Submitted, "#setup-api-key")
1003
+ def on_api_key_submitted(self, event: Input.Submitted) -> None:
1004
+ event.stop()
1005
+ self._save_cloud_provider()
1006
+
1007
+ @on(Input.Submitted, "#setup-base-url")
1008
+ def on_base_url_submitted(self, event: Input.Submitted) -> None:
1009
+ event.stop()
1010
+ # After base URL, move to key field if empty, else save.
1011
+ key = self.query_one("#setup-api-key", Input)
1012
+ if not key.value.strip():
1013
+ key.focus()
1014
+ return
1015
+ self._save_cloud_provider()
@@ -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.3"
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
File without changes
File without changes