shotgun-sh 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (130) hide show
  1. shotgun/__init__.py +5 -0
  2. shotgun/agents/__init__.py +1 -0
  3. shotgun/agents/agent_manager.py +651 -0
  4. shotgun/agents/common.py +549 -0
  5. shotgun/agents/config/__init__.py +13 -0
  6. shotgun/agents/config/constants.py +17 -0
  7. shotgun/agents/config/manager.py +294 -0
  8. shotgun/agents/config/models.py +185 -0
  9. shotgun/agents/config/provider.py +206 -0
  10. shotgun/agents/conversation_history.py +106 -0
  11. shotgun/agents/conversation_manager.py +105 -0
  12. shotgun/agents/export.py +96 -0
  13. shotgun/agents/history/__init__.py +5 -0
  14. shotgun/agents/history/compaction.py +85 -0
  15. shotgun/agents/history/constants.py +19 -0
  16. shotgun/agents/history/context_extraction.py +108 -0
  17. shotgun/agents/history/history_building.py +104 -0
  18. shotgun/agents/history/history_processors.py +426 -0
  19. shotgun/agents/history/message_utils.py +84 -0
  20. shotgun/agents/history/token_counting.py +429 -0
  21. shotgun/agents/history/token_estimation.py +138 -0
  22. shotgun/agents/messages.py +35 -0
  23. shotgun/agents/models.py +275 -0
  24. shotgun/agents/plan.py +98 -0
  25. shotgun/agents/research.py +108 -0
  26. shotgun/agents/specify.py +98 -0
  27. shotgun/agents/tasks.py +96 -0
  28. shotgun/agents/tools/__init__.py +34 -0
  29. shotgun/agents/tools/codebase/__init__.py +28 -0
  30. shotgun/agents/tools/codebase/codebase_shell.py +256 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +141 -0
  32. shotgun/agents/tools/codebase/file_read.py +144 -0
  33. shotgun/agents/tools/codebase/models.py +252 -0
  34. shotgun/agents/tools/codebase/query_graph.py +67 -0
  35. shotgun/agents/tools/codebase/retrieve_code.py +81 -0
  36. shotgun/agents/tools/file_management.py +218 -0
  37. shotgun/agents/tools/user_interaction.py +37 -0
  38. shotgun/agents/tools/web_search/__init__.py +60 -0
  39. shotgun/agents/tools/web_search/anthropic.py +144 -0
  40. shotgun/agents/tools/web_search/gemini.py +85 -0
  41. shotgun/agents/tools/web_search/openai.py +98 -0
  42. shotgun/agents/tools/web_search/utils.py +20 -0
  43. shotgun/build_constants.py +20 -0
  44. shotgun/cli/__init__.py +1 -0
  45. shotgun/cli/codebase/__init__.py +5 -0
  46. shotgun/cli/codebase/commands.py +202 -0
  47. shotgun/cli/codebase/models.py +21 -0
  48. shotgun/cli/config.py +275 -0
  49. shotgun/cli/export.py +81 -0
  50. shotgun/cli/models.py +10 -0
  51. shotgun/cli/plan.py +73 -0
  52. shotgun/cli/research.py +85 -0
  53. shotgun/cli/specify.py +69 -0
  54. shotgun/cli/tasks.py +78 -0
  55. shotgun/cli/update.py +152 -0
  56. shotgun/cli/utils.py +25 -0
  57. shotgun/codebase/__init__.py +12 -0
  58. shotgun/codebase/core/__init__.py +46 -0
  59. shotgun/codebase/core/change_detector.py +358 -0
  60. shotgun/codebase/core/code_retrieval.py +243 -0
  61. shotgun/codebase/core/ingestor.py +1497 -0
  62. shotgun/codebase/core/language_config.py +297 -0
  63. shotgun/codebase/core/manager.py +1662 -0
  64. shotgun/codebase/core/nl_query.py +331 -0
  65. shotgun/codebase/core/parser_loader.py +128 -0
  66. shotgun/codebase/models.py +111 -0
  67. shotgun/codebase/service.py +206 -0
  68. shotgun/logging_config.py +227 -0
  69. shotgun/main.py +167 -0
  70. shotgun/posthog_telemetry.py +158 -0
  71. shotgun/prompts/__init__.py +5 -0
  72. shotgun/prompts/agents/__init__.py +1 -0
  73. shotgun/prompts/agents/export.j2 +350 -0
  74. shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
  76. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  77. shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
  78. shotgun/prompts/agents/plan.j2 +144 -0
  79. shotgun/prompts/agents/research.j2 +69 -0
  80. shotgun/prompts/agents/specify.j2 +51 -0
  81. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
  82. shotgun/prompts/agents/state/system_state.j2 +31 -0
  83. shotgun/prompts/agents/tasks.j2 +143 -0
  84. shotgun/prompts/codebase/__init__.py +1 -0
  85. shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
  86. shotgun/prompts/codebase/cypher_system.j2 +28 -0
  87. shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
  88. shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
  89. shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
  90. shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
  91. shotgun/prompts/history/__init__.py +1 -0
  92. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  93. shotgun/prompts/history/summarization.j2 +46 -0
  94. shotgun/prompts/loader.py +140 -0
  95. shotgun/py.typed +0 -0
  96. shotgun/sdk/__init__.py +13 -0
  97. shotgun/sdk/codebase.py +219 -0
  98. shotgun/sdk/exceptions.py +17 -0
  99. shotgun/sdk/models.py +189 -0
  100. shotgun/sdk/services.py +23 -0
  101. shotgun/sentry_telemetry.py +87 -0
  102. shotgun/telemetry.py +93 -0
  103. shotgun/tui/__init__.py +0 -0
  104. shotgun/tui/app.py +116 -0
  105. shotgun/tui/commands/__init__.py +76 -0
  106. shotgun/tui/components/prompt_input.py +69 -0
  107. shotgun/tui/components/spinner.py +86 -0
  108. shotgun/tui/components/splash.py +25 -0
  109. shotgun/tui/components/vertical_tail.py +13 -0
  110. shotgun/tui/screens/chat.py +782 -0
  111. shotgun/tui/screens/chat.tcss +43 -0
  112. shotgun/tui/screens/chat_screen/__init__.py +0 -0
  113. shotgun/tui/screens/chat_screen/command_providers.py +219 -0
  114. shotgun/tui/screens/chat_screen/hint_message.py +40 -0
  115. shotgun/tui/screens/chat_screen/history.py +221 -0
  116. shotgun/tui/screens/directory_setup.py +113 -0
  117. shotgun/tui/screens/provider_config.py +221 -0
  118. shotgun/tui/screens/splash.py +31 -0
  119. shotgun/tui/styles.tcss +10 -0
  120. shotgun/tui/utils/__init__.py +5 -0
  121. shotgun/tui/utils/mode_progress.py +257 -0
  122. shotgun/utils/__init__.py +5 -0
  123. shotgun/utils/env_utils.py +35 -0
  124. shotgun/utils/file_system_utils.py +36 -0
  125. shotgun/utils/update_checker.py +375 -0
  126. shotgun_sh-0.1.0.dist-info/METADATA +466 -0
  127. shotgun_sh-0.1.0.dist-info/RECORD +130 -0
  128. shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
  129. shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
  130. shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,221 @@
1
+ """Screen for configuring provider API keys before entering chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import Button, Input, Label, ListItem, ListView, Static
13
+
14
+ from shotgun.agents.config import ConfigManager, ProviderType
15
+
16
+ if TYPE_CHECKING:
17
+ from ..app import ShotgunApp
18
+
19
+
20
+ class ProviderConfigScreen(Screen[None]):
21
+ """Collect API keys for available providers."""
22
+
23
+ CSS = """
24
+ ProviderConfig {
25
+ layout: vertical;
26
+ }
27
+
28
+ ProviderConfig > * {
29
+ height: auto;
30
+ }
31
+
32
+ #titlebox {
33
+ height: auto;
34
+ margin: 2 0;
35
+ padding: 1;
36
+ border: hkey $border;
37
+ content-align: center middle;
38
+
39
+ & > * {
40
+ text-align: center;
41
+ }
42
+ }
43
+
44
+ #provider-config-title {
45
+ padding: 1 0;
46
+ text-style: bold;
47
+ color: $text-accent;
48
+ }
49
+
50
+ #provider-list {
51
+ margin: 2 0;
52
+ height: auto;
53
+ & > * {
54
+ padding: 1 0;
55
+ }
56
+ }
57
+ #provider-actions {
58
+ padding: 1;
59
+ }
60
+ #provider-actions > * {
61
+ margin-right: 2;
62
+ }
63
+ #provider-list {
64
+ padding: 1;
65
+ }
66
+ """
67
+
68
+ BINDINGS = [
69
+ ("escape", "done", "Back"),
70
+ ]
71
+
72
+ selected_provider: reactive[ProviderType] = reactive(ProviderType.OPENAI)
73
+
74
+ def compose(self) -> ComposeResult:
75
+ with Vertical(id="titlebox"):
76
+ yield Static("Provider setup", id="provider-config-title")
77
+ yield Static(
78
+ "Select a provider and enter the API key needed to activate it.",
79
+ id="provider-config-summary",
80
+ )
81
+ yield ListView(*self._build_provider_items(), id="provider-list")
82
+ yield Input(
83
+ placeholder=self._input_placeholder(self.selected_provider),
84
+ password=True,
85
+ id="api-key",
86
+ )
87
+ with Horizontal(id="provider-actions"):
88
+ yield Button("Save key \\[ENTER]", variant="primary", id="save")
89
+ yield Button("Clear key", id="clear", variant="warning")
90
+ yield Button("Done \\[ESC]", id="done")
91
+
92
+ def on_mount(self) -> None:
93
+ self.refresh_provider_status()
94
+ list_view = self.query_one(ListView)
95
+ if list_view.children:
96
+ list_view.index = 0
97
+ self.selected_provider = ProviderType.OPENAI
98
+ self.set_focus(self.query_one("#api-key", Input))
99
+
100
+ def action_done(self) -> None:
101
+ self.dismiss()
102
+
103
+ @on(ListView.Highlighted)
104
+ def _on_provider_highlighted(self, event: ListView.Highlighted) -> None:
105
+ provider = self._provider_from_item(event.item)
106
+ if provider:
107
+ self.selected_provider = provider
108
+
109
+ @on(ListView.Selected)
110
+ def _on_provider_selected(self, event: ListView.Selected) -> None:
111
+ provider = self._provider_from_item(event.item)
112
+ if provider:
113
+ self.selected_provider = provider
114
+ self.set_focus(self.query_one("#api-key", Input))
115
+
116
+ @on(Button.Pressed, "#save")
117
+ def _on_save_pressed(self) -> None:
118
+ self._save_api_key()
119
+
120
+ @on(Button.Pressed, "#clear")
121
+ def _on_clear_pressed(self) -> None:
122
+ self._clear_api_key()
123
+
124
+ @on(Button.Pressed, "#done")
125
+ def _on_done_pressed(self) -> None:
126
+ self.action_done()
127
+
128
+ @on(Input.Submitted, "#api-key")
129
+ def _on_input_submitted(self, event: Input.Submitted) -> None:
130
+ del event # unused
131
+ self._save_api_key()
132
+
133
+ def watch_selected_provider(self, provider: ProviderType) -> None:
134
+ if not self.is_mounted:
135
+ return
136
+ input_widget = self.query_one("#api-key", Input)
137
+ input_widget.placeholder = self._input_placeholder(provider)
138
+ input_widget.value = ""
139
+
140
+ @property
141
+ def config_manager(self) -> ConfigManager:
142
+ app = cast("ShotgunApp", self.app)
143
+ return app.config_manager
144
+
145
+ def refresh_provider_status(self) -> None:
146
+ """Update the list view entries to reflect configured providers."""
147
+ for provider in ProviderType:
148
+ label = self.query_one(f"#label-{provider.value}", Label)
149
+ label.update(self._provider_label(provider))
150
+
151
+ def _build_provider_items(self) -> list[ListItem]:
152
+ items: list[ListItem] = []
153
+ for provider in ProviderType:
154
+ label = Label(self._provider_label(provider), id=f"label-{provider.value}")
155
+ items.append(ListItem(label, id=f"provider-{provider.value}"))
156
+ return items
157
+
158
+ def _provider_from_item(self, item: ListItem | None) -> ProviderType | None:
159
+ if item is None or item.id is None:
160
+ return None
161
+ provider_id = item.id.removeprefix("provider-")
162
+ try:
163
+ return ProviderType(provider_id)
164
+ except ValueError:
165
+ return None
166
+
167
+ def _provider_label(self, provider: ProviderType) -> str:
168
+ display = self._provider_display_name(provider)
169
+ status = (
170
+ "Configured"
171
+ if self.config_manager.has_provider_key(provider)
172
+ else "Not configured"
173
+ )
174
+ return f"{display} · {status}"
175
+
176
+ def _provider_display_name(self, provider: ProviderType) -> str:
177
+ names = {
178
+ ProviderType.OPENAI: "OpenAI",
179
+ ProviderType.ANTHROPIC: "Anthropic",
180
+ ProviderType.GOOGLE: "Google Gemini",
181
+ }
182
+ return names.get(provider, provider.value.title())
183
+
184
+ def _input_placeholder(self, provider: ProviderType) -> str:
185
+ return f"{self._provider_display_name(provider)} API key"
186
+
187
+ def _save_api_key(self) -> None:
188
+ input_widget = self.query_one("#api-key", Input)
189
+ api_key = input_widget.value.strip()
190
+
191
+ if not api_key:
192
+ self.notify("Enter an API key before saving.", severity="error")
193
+ return
194
+
195
+ try:
196
+ self.config_manager.update_provider(
197
+ self.selected_provider,
198
+ api_key=api_key,
199
+ )
200
+ except Exception as exc: # pragma: no cover - defensive; textual path
201
+ self.notify(f"Failed to save key: {exc}", severity="error")
202
+ return
203
+
204
+ input_widget.value = ""
205
+ self.refresh_provider_status()
206
+ self.notify(
207
+ f"Saved API key for {self._provider_display_name(self.selected_provider)}."
208
+ )
209
+
210
+ def _clear_api_key(self) -> None:
211
+ try:
212
+ self.config_manager.clear_provider_key(self.selected_provider)
213
+ except Exception as exc: # pragma: no cover - defensive; textual path
214
+ self.notify(f"Failed to clear key: {exc}", severity="error")
215
+ return
216
+
217
+ self.refresh_provider_status()
218
+ self.query_one("#api-key", Input).value = ""
219
+ self.notify(
220
+ f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
221
+ )
@@ -0,0 +1,31 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Container
3
+ from textual.screen import Screen
4
+
5
+ from ..components.splash import SplashWidget
6
+
7
+
8
+ class SplashScreen(Screen[None]):
9
+ CSS = """
10
+ #splash-container {
11
+ align: center middle;
12
+ width: 100%;
13
+ height: 100%;
14
+
15
+ }
16
+
17
+ SplashWidget {
18
+ color: $text-accent;
19
+ }
20
+ """
21
+ """Splash screen for the app."""
22
+
23
+ def on_mount(self) -> None:
24
+ self.set_timer(2, self.on_timer_tick)
25
+
26
+ def on_timer_tick(self) -> None:
27
+ self.dismiss()
28
+
29
+ def compose(self) -> ComposeResult:
30
+ with Container(id="splash-container"):
31
+ yield SplashWidget()
@@ -0,0 +1,10 @@
1
+ Screen {
2
+ background: $surface;
3
+ }
4
+
5
+ Input {
6
+ border: round $border-blurred;
7
+ &:focus {
8
+ border: round $border;
9
+ }
10
+ }
@@ -0,0 +1,5 @@
1
+ """TUI utilities package."""
2
+
3
+ from .mode_progress import ModeProgressChecker, PlaceholderHints
4
+
5
+ __all__ = ["ModeProgressChecker", "PlaceholderHints"]
@@ -0,0 +1,257 @@
1
+ """Utility module for checking mode progress in .shotgun directories."""
2
+
3
+ import random
4
+ from pathlib import Path
5
+
6
+ from shotgun.agents.models import AgentType
7
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
8
+
9
+
10
+ class ModeProgressChecker:
11
+ """Checks progress across different agent modes based on file contents."""
12
+
13
+ # Minimum file size in characters to consider a mode as "started"
14
+ MIN_CONTENT_SIZE = 20
15
+
16
+ # Map agent types to their corresponding files (in workflow order)
17
+ MODE_FILES = {
18
+ AgentType.RESEARCH: "research.md",
19
+ AgentType.SPECIFY: "specification.md",
20
+ AgentType.PLAN: "plan.md",
21
+ AgentType.TASKS: "tasks.md",
22
+ AgentType.EXPORT: "exports/", # Export mode creates files in exports folder
23
+ }
24
+
25
+ def __init__(self, base_path: Path | None = None):
26
+ """Initialize the progress checker.
27
+
28
+ Args:
29
+ base_path: Base path for .shotgun directory. Defaults to current directory.
30
+ """
31
+ self.base_path = base_path or get_shotgun_base_path()
32
+
33
+ def has_mode_content(self, mode: AgentType) -> bool:
34
+ """Check if a mode has meaningful content.
35
+
36
+ Args:
37
+ mode: The agent mode to check.
38
+
39
+ Returns:
40
+ True if the mode has a file with >20 characters.
41
+ """
42
+ if mode not in self.MODE_FILES:
43
+ return False
44
+
45
+ file_or_dir = self.MODE_FILES[mode]
46
+
47
+ # Special handling for export mode (checks directory)
48
+ if mode == AgentType.EXPORT:
49
+ export_path = self.base_path / file_or_dir
50
+ if export_path.exists() and export_path.is_dir():
51
+ # Check if any files exist in exports directory
52
+ for item in export_path.glob("*"):
53
+ if item.is_file() and not item.name.startswith("."):
54
+ try:
55
+ content = item.read_text(encoding="utf-8")
56
+ if len(content.strip()) > self.MIN_CONTENT_SIZE:
57
+ return True
58
+ except (OSError, UnicodeDecodeError):
59
+ continue
60
+ return False
61
+
62
+ # Check single file for other modes
63
+ file_path = self.base_path / file_or_dir
64
+ if not file_path.exists() or not file_path.is_file():
65
+ return False
66
+
67
+ try:
68
+ content = file_path.read_text(encoding="utf-8")
69
+ # Check if file has meaningful content
70
+ return len(content.strip()) > self.MIN_CONTENT_SIZE
71
+ except (OSError, UnicodeDecodeError):
72
+ return False
73
+
74
+ def get_next_suggested_mode(self, current_mode: AgentType) -> AgentType | None:
75
+ """Get the next suggested mode based on current progress.
76
+
77
+ Args:
78
+ current_mode: The current agent mode.
79
+
80
+ Returns:
81
+ The next suggested mode, or None if no suggestion.
82
+ """
83
+ mode_order = [
84
+ AgentType.RESEARCH,
85
+ AgentType.SPECIFY,
86
+ AgentType.TASKS,
87
+ AgentType.EXPORT,
88
+ ]
89
+
90
+ try:
91
+ current_index = mode_order.index(current_mode)
92
+ except ValueError:
93
+ # Mode not in standard order (e.g., PLAN mode)
94
+ return None
95
+
96
+ # Check if current mode has content
97
+ if not self.has_mode_content(current_mode):
98
+ # Current mode is empty, no suggestion for next mode
99
+ return None
100
+
101
+ # Get next mode in sequence
102
+ if current_index < len(mode_order) - 1:
103
+ return mode_order[current_index + 1]
104
+
105
+ # Export mode cycles back to Research
106
+ return mode_order[0]
107
+
108
+
109
+ class PlaceholderHints:
110
+ """Manages dynamic placeholder hints for each mode based on progress."""
111
+
112
+ # Placeholder variations for each mode and state
113
+ HINTS = {
114
+ # Research mode
115
+ AgentType.RESEARCH: {
116
+ False: [
117
+ "Research a product or idea (SHIFT+TAB to cycle modes)",
118
+ "What would you like to explore? Start your research journey here (SHIFT+TAB to switch modes)",
119
+ "Dive into discovery mode - research anything that sparks curiosity (SHIFT+TAB for mode menu)",
120
+ "Ready to investigate? Feed me your burning questions (SHIFT+TAB to explore other modes)",
121
+ " 🔍 The research rabbit hole awaits! What shall we uncover? (SHIFT+TAB for mode carousel)",
122
+ ],
123
+ True: [
124
+ "Research complete! SHIFT+TAB to move to Specify mode",
125
+ "Great research! Time to specify (SHIFT+TAB to Specify mode)",
126
+ "Research done! Ready to create specifications (SHIFT+TAB to Specify)",
127
+ "Findings gathered! Move to specifications (SHIFT+TAB for Specify mode)",
128
+ " 🎯 Research complete! Advance to Specify mode (SHIFT+TAB)",
129
+ ],
130
+ },
131
+ # Specify mode
132
+ AgentType.SPECIFY: {
133
+ False: [
134
+ "Create detailed specifications and requirements (SHIFT+TAB to switch modes)",
135
+ "Define your project specifications here (SHIFT+TAB to navigate modes)",
136
+ "Time to get specific - write comprehensive specs (SHIFT+TAB for mode options)",
137
+ "Specification station: Document requirements and designs (SHIFT+TAB to change modes)",
138
+ " 📋 Spec-tacular time! Let's architect your ideas (SHIFT+TAB for mode magic)",
139
+ ],
140
+ True: [
141
+ "Specifications complete! SHIFT+TAB to create a Plan",
142
+ "Specs ready! Time to plan (SHIFT+TAB to Plan mode)",
143
+ "Requirements defined! Move to planning (SHIFT+TAB to Plan)",
144
+ "Specifications done! Create your roadmap (SHIFT+TAB for Plan mode)",
145
+ " 🚀 Specs complete! Advance to Plan mode (SHIFT+TAB)",
146
+ ],
147
+ },
148
+ # Tasks mode
149
+ AgentType.TASKS: {
150
+ False: [
151
+ "Break down your project into actionable tasks (SHIFT+TAB for modes)",
152
+ "Task creation time! Define your implementation steps (SHIFT+TAB to switch)",
153
+ "Ready to get tactical? Create your task list (SHIFT+TAB for mode options)",
154
+ "Task command center: Organize your work items (SHIFT+TAB to navigate)",
155
+ " ✅ Task mode activated! Break it down into bite-sized pieces (SHIFT+TAB)",
156
+ ],
157
+ True: [
158
+ "Tasks defined! Ready to export or cycle back (SHIFT+TAB)",
159
+ "Task list complete! Export your work (SHIFT+TAB to Export)",
160
+ "All tasks created! Time to export (SHIFT+TAB for Export mode)",
161
+ "Implementation plan ready! Export everything (SHIFT+TAB to Export)",
162
+ " 🎊 Tasks complete! Export your masterpiece (SHIFT+TAB)",
163
+ ],
164
+ },
165
+ # Export mode
166
+ AgentType.EXPORT: {
167
+ False: [
168
+ "Export your complete project documentation (SHIFT+TAB for modes)",
169
+ "Ready to package everything? Export time! (SHIFT+TAB to switch)",
170
+ "Export station: Generate deliverables (SHIFT+TAB for mode menu)",
171
+ "Time to share your work! Export documents (SHIFT+TAB to navigate)",
172
+ " 📦 Export mode! Package and share your creation (SHIFT+TAB)",
173
+ ],
174
+ True: [
175
+ "Exported! Start new research or continue refining (SHIFT+TAB)",
176
+ "Export complete! New cycle begins (SHIFT+TAB to Research)",
177
+ "All exported! Ready for another round (SHIFT+TAB for Research)",
178
+ "Documents exported! Start fresh (SHIFT+TAB to Research mode)",
179
+ " 🎉 Export complete! Begin a new adventure (SHIFT+TAB)",
180
+ ],
181
+ },
182
+ # Plan mode
183
+ AgentType.PLAN: {
184
+ False: [
185
+ "Create a strategic plan for your project (SHIFT+TAB for modes)",
186
+ "Planning phase: Map out your roadmap (SHIFT+TAB to switch)",
187
+ "Time to strategize! Create your project plan (SHIFT+TAB for options)",
188
+ "Plan your approach and milestones (SHIFT+TAB to navigate)",
189
+ " 🗺️ Plan mode! Chart your course to success (SHIFT+TAB)",
190
+ ],
191
+ True: [
192
+ "Plan complete! Move to Tasks mode (SHIFT+TAB)",
193
+ "Strategy ready! Time for tasks (SHIFT+TAB to Tasks mode)",
194
+ "Roadmap done! Create task list (SHIFT+TAB for Tasks)",
195
+ "Planning complete! Break into tasks (SHIFT+TAB to Tasks)",
196
+ " ⚡ Plan ready! Advance to Tasks mode (SHIFT+TAB)",
197
+ ],
198
+ },
199
+ }
200
+
201
+ def __init__(self, base_path: Path | None = None):
202
+ """Initialize placeholder hints with progress checker.
203
+
204
+ Args:
205
+ base_path: Base path for checking progress. Defaults to current directory.
206
+ """
207
+ self.progress_checker = ModeProgressChecker(base_path)
208
+ self._cached_hints: dict[tuple[AgentType, bool], str] = {}
209
+ self._hint_indices: dict[tuple[AgentType, bool], int] = {}
210
+
211
+ def get_hint(self, current_mode: AgentType, force_refresh: bool = False) -> str:
212
+ """Get a dynamic hint based on current mode and progress.
213
+
214
+ Args:
215
+ current_mode: The current agent mode.
216
+ force_refresh: Force recalculation of progress state.
217
+
218
+ Returns:
219
+ A contextual hint string for the placeholder.
220
+ """
221
+ # Default hint if mode not configured
222
+ if current_mode not in self.HINTS:
223
+ return f"Enter your {current_mode.value} mode prompt (SHIFT+TAB to switch modes)"
224
+
225
+ # Determine if mode has content
226
+ has_content = self.progress_checker.has_mode_content(current_mode)
227
+
228
+ # Get hint variations for this mode and state
229
+ hints_list = self.HINTS[current_mode][has_content]
230
+
231
+ # Cache key for this mode and state
232
+ cache_key = (current_mode, has_content)
233
+
234
+ # Force refresh or first time
235
+ if force_refresh or cache_key not in self._cached_hints:
236
+ # Initialize index for this cache key if not exists
237
+ if cache_key not in self._hint_indices:
238
+ self._hint_indices[cache_key] = random.randint(0, len(hints_list) - 1) # noqa: S311
239
+
240
+ # Get hint at current index
241
+ hint_index = self._hint_indices[cache_key]
242
+ self._cached_hints[cache_key] = hints_list[hint_index]
243
+
244
+ return self._cached_hints[cache_key]
245
+
246
+ def get_placeholder_for_mode(self, current_mode: AgentType) -> str:
247
+ """Get placeholder text for a given mode.
248
+
249
+ This is an alias for get_hint() to maintain compatibility.
250
+
251
+ Args:
252
+ current_mode: The current agent mode.
253
+
254
+ Returns:
255
+ A contextual hint string for the placeholder.
256
+ """
257
+ return self.get_hint(current_mode)
@@ -0,0 +1,5 @@
1
+ """Utility functions for the Shotgun package."""
2
+
3
+ from .file_system_utils import ensure_shotgun_directory_exists, get_shotgun_home
4
+
5
+ __all__ = ["ensure_shotgun_directory_exists", "get_shotgun_home"]
@@ -0,0 +1,35 @@
1
+ """Utilities for working with environment variables."""
2
+
3
+
4
+ def is_truthy(value: str | None) -> bool:
5
+ """Check if a string value represents true.
6
+
7
+ Args:
8
+ value: String value to check (e.g., from environment variable)
9
+
10
+ Returns:
11
+ True if value is "true", "1", or "yes" (case-insensitive)
12
+ False otherwise (including None, empty string, or any other value)
13
+ """
14
+ if not value:
15
+ return False
16
+ return value.lower() in ("true", "1", "yes")
17
+
18
+
19
+ def is_falsy(value: str | None) -> bool:
20
+ """Check if a string value explicitly represents false.
21
+
22
+ Args:
23
+ value: String value to check (e.g., from environment variable)
24
+
25
+ Returns:
26
+ True if value is "false", "0", or "no" (case-insensitive)
27
+ False otherwise (including None, empty string, or any other value)
28
+
29
+ Note:
30
+ This is NOT the opposite of is_truthy(). A value can be neither
31
+ truthy nor falsy (e.g., None, "", "maybe", etc.)
32
+ """
33
+ if not value:
34
+ return False
35
+ return value.lower() in ("false", "0", "no")
@@ -0,0 +1,36 @@
1
+ """File system utility functions."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def get_shotgun_base_path() -> Path:
8
+ """Get the absolute path to the .shotgun directory."""
9
+ return Path.cwd() / ".shotgun"
10
+
11
+
12
+ def get_shotgun_home() -> Path:
13
+ """Get the Shotgun home directory path.
14
+
15
+ Can be overridden with SHOTGUN_HOME environment variable for testing.
16
+
17
+ Returns:
18
+ Path to shotgun home directory (default: ~/.shotgun-sh/)
19
+ """
20
+ # Allow override via environment variable (useful for testing)
21
+ if custom_home := os.environ.get("SHOTGUN_HOME"):
22
+ return Path(custom_home)
23
+
24
+ return Path.home() / ".shotgun-sh"
25
+
26
+
27
+ def ensure_shotgun_directory_exists() -> Path:
28
+ """Ensure the .shotgun directory exists and return its path.
29
+
30
+ Returns:
31
+ Path: The path to the .shotgun directory.
32
+ """
33
+ shotgun_dir = get_shotgun_base_path()
34
+ shotgun_dir.mkdir(exist_ok=True)
35
+ # Note: Removed logger to avoid circular dependency with logging_config
36
+ return shotgun_dir