spawnio 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. spawn/__init__.py +6 -0
  2. spawn/cli/__init__.py +0 -0
  3. spawn/cli/app.py +218 -0
  4. spawn/cli/noninteractive.py +204 -0
  5. spawn/cli/prompts.py +267 -0
  6. spawn/core/__init__.py +0 -0
  7. spawn/core/exceptions.py +7 -0
  8. spawn/core/models.py +21 -0
  9. spawn/core/registry.py +152 -0
  10. spawn/generators/__init__.py +0 -0
  11. spawn/generators/custom_structure.py +508 -0
  12. spawn/generators/project_generator.py +115 -0
  13. spawn/github/__init__.py +0 -0
  14. spawn/github/exceptions.py +5 -0
  15. spawn/github/publisher.py +69 -0
  16. spawn/github/validators.py +23 -0
  17. spawn/templates/__init__.py +0 -0
  18. spawn/templates/agent/__init__.py +182 -0
  19. spawn/templates/agent/content.py +374 -0
  20. spawn/templates/automation/__init__.py +100 -0
  21. spawn/templates/automation/content.py +181 -0
  22. spawn/templates/backend_api/__init__.py +243 -0
  23. spawn/templates/backend_api/content.py +509 -0
  24. spawn/templates/base.py +58 -0
  25. spawn/templates/chatbot/__init__.py +243 -0
  26. spawn/templates/chatbot/content.py +668 -0
  27. spawn/templates/cli_application/__init__.py +211 -0
  28. spawn/templates/cli_application/content.py +512 -0
  29. spawn/templates/data_project/__init__.py +125 -0
  30. spawn/templates/data_project/analysis/__init__.py +74 -0
  31. spawn/templates/data_project/analysis/content.py +190 -0
  32. spawn/templates/data_project/dashboard/__init__.py +61 -0
  33. spawn/templates/data_project/dashboard/content.py +135 -0
  34. spawn/templates/data_project/etl/__init__.py +66 -0
  35. spawn/templates/data_project/etl/content.py +239 -0
  36. spawn/templates/data_project/ml/__init__.py +62 -0
  37. spawn/templates/data_project/ml/content.py +239 -0
  38. spawn/templates/mcp_server/__init__.py +83 -0
  39. spawn/templates/mcp_server/content.py +161 -0
  40. spawn/templates/rag/__init__.py +124 -0
  41. spawn/templates/rag/content.py +463 -0
  42. spawn/templates/shared_content.py +89 -0
  43. spawn/utils/__init__.py +0 -0
  44. spawn/utils/banner.py +41 -0
  45. spawn/utils/console.py +3 -0
  46. spawn/utils/doctor.py +594 -0
  47. spawn/utils/git.py +132 -0
  48. spawn/utils/success.py +47 -0
  49. spawn/utils/theme.py +25 -0
  50. spawn/utils/uv.py +61 -0
  51. spawn/utils/validators.py +15 -0
  52. spawnio-1.0.5.dist-info/METADATA +798 -0
  53. spawnio-1.0.5.dist-info/RECORD +56 -0
  54. spawnio-1.0.5.dist-info/WHEEL +4 -0
  55. spawnio-1.0.5.dist-info/entry_points.txt +2 -0
  56. spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,374 @@
1
+ INIT_CONTENT = ""
2
+
3
+ # ─── Shared content ───────────────────────────────────────────────────────
4
+
5
+ AGENT_PROMPT_TXT_CONTENT = """\
6
+ You are a helpful AI assistant with access to tools.
7
+ Use tools when they are helpful to answer the user's request.
8
+ Be concise and accurate in your responses.
9
+ """
10
+
11
+ SETTINGS_CONTENT = """\
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from dotenv import load_dotenv
16
+
17
+
18
+ def load_env() -> None:
19
+ load_dotenv()
20
+
21
+
22
+ def get_agent_prompt() -> str:
23
+ prompt_path = Path(__file__).parent.parent / "prompts" / "agent_prompt.txt"
24
+ return prompt_path.read_text(encoding="utf-8").strip()
25
+ """
26
+
27
+ CALCULATOR_TOOL_CONTENT = """\
28
+ import re
29
+
30
+
31
+ def calculate(expression: str) -> str:
32
+ \"\"\"
33
+ Evaluate a mathematical expression safely.
34
+ Supports: +, -, *, /, //, **, (, ), and decimal numbers.
35
+ Returns the result as a string, or an error message.
36
+ \"\"\"
37
+ # Strip whitespace and validate characters
38
+ cleaned = expression.strip()
39
+ if not re.match(r'^[\\d\\s\\+\\-\\*\\/\\(\\)\\.\\*\\*\\/\\/]+$', cleaned):
40
+ return f"Invalid expression: only numbers and operators (+,-,*,/,**,//) are allowed"
41
+ try:
42
+ result = eval(cleaned, {{"__builtins__": {{}}}}, {{}})
43
+ return str(result)
44
+ except ZeroDivisionError:
45
+ return "Error: division by zero"
46
+ except Exception as e:
47
+ return f"Error: {{e}}"
48
+ """
49
+
50
+ MAIN_CONTENT = """\
51
+ from src.agent.run import run_agent
52
+ from src.config.settings import load_env
53
+
54
+
55
+ def main() -> None:
56
+ load_env()
57
+ print("{project_name} Agent ready. Type 'quit' to exit.\\n")
58
+ while True:
59
+ user_input = input("You: ").strip()
60
+ if not user_input:
61
+ continue
62
+ if user_input.lower() in ("quit", "exit"):
63
+ break
64
+ print("\\nThinking...\\n")
65
+ response = run_agent(user_input)
66
+ print(f"Agent: {{response}}\\n")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
71
+ """
72
+
73
+ CONFTEST_CONTENT = """\
74
+ # conftest.py — no shared fixtures needed for agent tests
75
+ """
76
+
77
+ # ─── PydanticAI agent ─────────────────────────────────────────────────────
78
+
79
+ PYDANTIC_AI_AGENT_CONTENT = """\
80
+ import os
81
+
82
+ from pydantic_ai import Agent
83
+
84
+ from src.config.settings import get_agent_prompt
85
+ from src.tools.calculator import calculate as _calculate
86
+
87
+
88
+ def create_agent() -> Agent:
89
+ model = os.getenv("MODEL", "openai:gpt-4o-mini")
90
+ system_prompt = get_agent_prompt()
91
+ agent = Agent(model, system_prompt=system_prompt)
92
+
93
+ @agent.tool_plain
94
+ def calculate(expression: str) -> str:
95
+ \"\"\"Evaluate a mathematical expression. Supports +, -, *, /, **, //.\"\"\"
96
+ return _calculate(expression)
97
+
98
+ return agent
99
+
100
+
101
+ def run_agent(user_input: str) -> str:
102
+ agent = create_agent()
103
+ result = agent.run_sync(user_input)
104
+ return result.output
105
+ """
106
+
107
+ PYDANTIC_AI_TEST_CONTENT = """\
108
+ from unittest.mock import patch, MagicMock
109
+
110
+ from src.tools.calculator import calculate
111
+
112
+
113
+ def test_calculator_addition():
114
+ assert calculate("2 + 3") == "5"
115
+
116
+
117
+ def test_calculator_multiplication():
118
+ assert calculate("6 * 7") == "42"
119
+
120
+
121
+ def test_calculator_division():
122
+ result = calculate("10 / 4")
123
+ assert "2.5" in result
124
+
125
+
126
+ def test_calculator_invalid_expression():
127
+ result = calculate("import os")
128
+ assert "Invalid" in result or "Error" in result
129
+
130
+
131
+ def test_run_agent_mock():
132
+ mock_result = MagicMock()
133
+ mock_result.output = "The answer is 42"
134
+
135
+ with patch("src.agent.run.Agent") as MockAgent:
136
+ mock_instance = MagicMock()
137
+ mock_instance.run_sync.return_value = mock_result
138
+ MockAgent.return_value = mock_instance
139
+
140
+ from src.agent.run import run_agent
141
+
142
+ result = run_agent("What is 6 * 7?")
143
+
144
+ assert isinstance(result, str)
145
+ """
146
+
147
+ # ─── OpenAI Agents agent ──────────────────────────────────────────────────
148
+
149
+ OPENAI_AGENTS_AGENT_CONTENT = """\
150
+ import os
151
+
152
+ from agents import Agent, Runner, function_tool
153
+
154
+ from src.config.settings import get_agent_prompt
155
+ from src.tools.calculator import calculate as _calculate
156
+
157
+
158
+ @function_tool
159
+ def calculate(expression: str) -> str:
160
+ \"\"\"Evaluate a mathematical expression. Supports +, -, *, /, **, //.\"\"\"
161
+ return _calculate(expression)
162
+
163
+
164
+ def create_agent() -> Agent:
165
+ system_prompt = get_agent_prompt()
166
+ model = os.getenv("MODEL", "gpt-4o-mini")
167
+ return Agent(
168
+ name="{project_name}",
169
+ instructions=system_prompt,
170
+ model=model,
171
+ tools=[calculate],
172
+ )
173
+
174
+
175
+ def run_agent(user_input: str) -> str:
176
+ agent = create_agent()
177
+ result = Runner.run_sync(agent, user_input)
178
+ return result.final_output
179
+ """
180
+
181
+ OPENAI_AGENTS_TEST_CONTENT = """\
182
+ from unittest.mock import patch, MagicMock
183
+
184
+ from src.tools.calculator import calculate
185
+
186
+
187
+ def test_calculator_addition():
188
+ assert calculate("2 + 3") == "5"
189
+
190
+
191
+ def test_calculator_multiplication():
192
+ assert calculate("6 * 7") == "42"
193
+
194
+
195
+ def test_calculator_division():
196
+ result = calculate("10 / 4")
197
+ assert "2.5" in result
198
+
199
+
200
+ def test_calculator_invalid_expression():
201
+ result = calculate("import os")
202
+ assert "Invalid" in result or "Error" in result
203
+
204
+
205
+ def test_run_agent_mock():
206
+ mock_result = MagicMock()
207
+ mock_result.final_output = "The answer is 42"
208
+
209
+ with patch("src.agent.run.Runner") as MockRunner:
210
+ MockRunner.run_sync.return_value = mock_result
211
+
212
+ from src.agent.run import run_agent
213
+
214
+ result = run_agent("What is 6 * 7?")
215
+
216
+ assert isinstance(result, str)
217
+ """
218
+
219
+ # ─── Env examples ─────────────────────────────────────────────────────────
220
+
221
+ ENV_PYDANTIC_OPENAI = """\
222
+ APP_NAME={project_name}
223
+ OPENAI_API_KEY=
224
+ MODEL=openai:gpt-4o-mini
225
+ """
226
+
227
+ ENV_PYDANTIC_ANTHROPIC = """\
228
+ APP_NAME={project_name}
229
+ ANTHROPIC_API_KEY=
230
+ MODEL=anthropic:claude-3-5-haiku-latest
231
+ """
232
+
233
+ ENV_PYDANTIC_GEMINI = """\
234
+ APP_NAME={project_name}
235
+ GOOGLE_API_KEY=
236
+ MODEL=google:gemini-2.0-flash
237
+ """
238
+
239
+ ENV_PYDANTIC_OPENROUTER = """\
240
+ APP_NAME={project_name}
241
+ OPENROUTER_API_KEY=
242
+ MODEL=openai/gpt-4o-mini
243
+ """
244
+
245
+ ENV_PYDANTIC_OLLAMA = """\
246
+ APP_NAME={project_name}
247
+ OLLAMA_BASE_URL=http://localhost:11434
248
+ MODEL=llama3.2
249
+ """
250
+
251
+ ENV_PYDANTIC_GROQ = """\
252
+ APP_NAME={project_name}
253
+ GROQ_API_KEY=
254
+ MODEL=groq:llama-3.1-8b-instant
255
+ """
256
+
257
+ ENV_OPENAI_AGENTS_OPENAI = """\
258
+ APP_NAME={project_name}
259
+ OPENAI_API_KEY=
260
+ MODEL=gpt-4o-mini
261
+ """
262
+
263
+ ENV_OPENAI_AGENTS_OPENROUTER = """\
264
+ APP_NAME={project_name}
265
+ OPENAI_API_KEY=
266
+ OPENAI_BASE_URL=https://openrouter.ai/api/v1
267
+ MODEL=openai/gpt-4o-mini
268
+ OPENAI_AGENTS_DISABLE_TRACING=1
269
+ """
270
+
271
+ # ─── README ───────────────────────────────────────────────────────────────
272
+
273
+
274
+ def make_readme(framework: str, provider: str) -> str:
275
+ provider_key_map = {
276
+ "openai": "OPENAI_API_KEY=your-key",
277
+ "anthropic": "ANTHROPIC_API_KEY=your-key",
278
+ "gemini": "GOOGLE_API_KEY=your-key",
279
+ "openrouter": "OPENROUTER_API_KEY=your-key",
280
+ "ollama": "OLLAMA_BASE_URL=http://localhost:11434",
281
+ "groq": "GROQ_API_KEY=your-key",
282
+ }
283
+ key_line = provider_key_map.get(provider, "API_KEY=your-key")
284
+ return (
285
+ "# {project_name}\n\n"
286
+ f"An AI agent generated with Spawn, using {framework.title()} + {provider.title()}.\n\n"
287
+ "## Getting Started\n\n"
288
+ "1. Rename `.env.example` to `.env` and fill in your API key:\n\n"
289
+ "```env\n"
290
+ f"{key_line}\n"
291
+ "```\n\n"
292
+ "2. Run the agent:\n\n"
293
+ "```bash\n"
294
+ "uv run python -m src.main\n"
295
+ "```\n\n"
296
+ "## Example\n\n"
297
+ "```\n"
298
+ "You: What is 145 * 28?\n"
299
+ "Thinking...\n"
300
+ "Agent: 4060\n"
301
+ "```\n\n"
302
+ "## Project Structure\n\n"
303
+ "```\n"
304
+ "{project_name}/\n"
305
+ "├── src/\n"
306
+ "│ ├── agent/ # Agent definition and runner\n"
307
+ "│ ├── tools/ # Tool implementations\n"
308
+ "│ ├── prompts/ # agent_prompt.txt\n"
309
+ "│ ├── config/ # Settings and env loading\n"
310
+ "│ └── main.py\n"
311
+ "├── tests/\n"
312
+ "├── .env.example\n"
313
+ "└── README.md\n"
314
+ "```\n\n"
315
+ "## Running Tests\n\n"
316
+ "```bash\n"
317
+ "uv run pytest\n"
318
+ "```\n"
319
+ )
320
+
321
+
322
+ def make_agents_md(framework: str, provider: str) -> str:
323
+ if framework == "openai-agents" and provider == "openrouter":
324
+ key_name = "OPENAI_API_KEY` and `OPENAI_BASE_URL"
325
+ else:
326
+ provider_key_map = {
327
+ "openai": "OPENAI_API_KEY",
328
+ "anthropic": "ANTHROPIC_API_KEY",
329
+ "gemini": "GOOGLE_API_KEY",
330
+ "openrouter": "OPENROUTER_API_KEY",
331
+ "ollama": "OLLAMA_BASE_URL",
332
+ "groq": "GROQ_API_KEY",
333
+ }
334
+ key_name = provider_key_map.get(provider, "OPENAI_API_KEY")
335
+ return (
336
+ "# {project_name}\n\n"
337
+ f"AI agent using {framework.title()} with {provider.title()} provider.\n\n"
338
+ "## Required Environment Variable\n\n"
339
+ f"Set `{key_name}` in your `.env` file.\n"
340
+ )
341
+
342
+ # ─── GitHub Actions ───────────────────────────────────────────────────────
343
+
344
+ GITHUB_ACTIONS_CI_BASE = """\
345
+ name: CI
346
+
347
+ on:
348
+ push:
349
+ branches: [main]
350
+ pull_request:
351
+ branches: [main]
352
+
353
+ jobs:
354
+ ci:
355
+ runs-on: ubuntu-latest
356
+
357
+ steps:
358
+ - uses: actions/checkout@v4
359
+
360
+ - uses: astral-sh/setup-uv@v5
361
+
362
+ - name: Install dependencies
363
+ run: uv sync
364
+ """
365
+
366
+ GITHUB_ACTIONS_CI_RUFF_STEP = """\
367
+ - name: Lint
368
+ run: uv run ruff check .
369
+ """
370
+
371
+ GITHUB_ACTIONS_CI_PYTEST_STEP = """\
372
+ - name: Test
373
+ run: uv run pytest
374
+ """
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+
3
+ from spawn.templates.base import BaseTemplate
4
+ from spawn.templates.automation.content import (
5
+ INIT_CONTENT,
6
+ MAIN_CONTENT,
7
+ LOGGER_CONTENT,
8
+ REPORT_WORKFLOW_CONTENT,
9
+ DATA_TASK_CONTENT,
10
+ REPORT_TASK_CONTENT,
11
+ TEST_CONTENT,
12
+ ENV_EXAMPLE_CONTENT,
13
+ README_CONTENT,
14
+ GITHUB_ACTIONS_CI_BASE,
15
+ GITHUB_ACTIONS_CI_RUFF_STEP,
16
+ GITHUB_ACTIONS_CI_PYTEST_STEP,
17
+ )
18
+
19
+ AUTOMATION_FOLDERS = [
20
+ "src/workflows",
21
+ "src/tasks",
22
+ "src/integrations",
23
+ "src/utils",
24
+ "logs",
25
+ "tests",
26
+ ]
27
+
28
+ AUTOMATION_FILES = [
29
+ ("src/__init__.py", INIT_CONTENT),
30
+ ("src/workflows/__init__.py", INIT_CONTENT),
31
+ ("src/workflows/report_workflow.py", REPORT_WORKFLOW_CONTENT),
32
+ ("src/tasks/__init__.py", INIT_CONTENT),
33
+ ("src/tasks/data_task.py", DATA_TASK_CONTENT),
34
+ ("src/tasks/report_task.py", REPORT_TASK_CONTENT),
35
+ ("src/integrations/__init__.py", INIT_CONTENT),
36
+ ("src/utils/__init__.py", INIT_CONTENT),
37
+ ("src/utils/logger.py", LOGGER_CONTENT),
38
+ ("src/main.py", MAIN_CONTENT),
39
+ ("logs/.gitkeep", INIT_CONTENT),
40
+ ("tests/__init__.py", INIT_CONTENT),
41
+ ("tests/test_automation.py", TEST_CONTENT),
42
+ (".env.example", ENV_EXAMPLE_CONTENT),
43
+ ]
44
+
45
+
46
+ class AutomationTemplate(BaseTemplate):
47
+ def __init__(
48
+ self,
49
+ extras: list[str] | None = None,
50
+ ) -> None:
51
+ self.extras = extras or []
52
+ super().__init__(
53
+ name="Automation Tool",
54
+ folders=AUTOMATION_FOLDERS,
55
+ starter_files=AUTOMATION_FILES,
56
+ next_steps=[
57
+ "cd {project_name}",
58
+ "uv run python -m src.main",
59
+ ],
60
+ )
61
+
62
+ def get_readme_content(self, context: dict) -> str | None:
63
+ return README_CONTENT.format_map(context)
64
+
65
+ def get_dependencies(self) -> list[str]:
66
+ base = ["requests", "python-dotenv"]
67
+ if "pytest" in self.extras:
68
+ base += ["pytest"]
69
+ if "ruff" in self.extras:
70
+ base += ["ruff"]
71
+ return base
72
+
73
+ def post_install(self, project_path: Path) -> None:
74
+ pyproject = project_path / "pyproject.toml"
75
+ current = pyproject.read_text(encoding="utf-8")
76
+ additions = ""
77
+
78
+ if "pytest" in self.extras:
79
+ additions += (
80
+ "\n[tool.pytest.ini_options]\n"
81
+ 'testpaths = ["tests"]\n'
82
+ )
83
+
84
+ if "ruff" in self.extras:
85
+ additions += "\n[tool.ruff]\nline-length = 88\n"
86
+
87
+ if additions:
88
+ pyproject.write_text(current + additions, encoding="utf-8")
89
+
90
+ if "github-actions" in self.extras:
91
+ workflows_path = project_path / ".github" / "workflows"
92
+ workflows_path.mkdir(parents=True, exist_ok=True)
93
+ ci_content = GITHUB_ACTIONS_CI_BASE
94
+ if "ruff" in self.extras:
95
+ ci_content += GITHUB_ACTIONS_CI_RUFF_STEP
96
+ if "pytest" in self.extras:
97
+ ci_content += GITHUB_ACTIONS_CI_PYTEST_STEP
98
+ (workflows_path / "ci.yml").write_text(
99
+ ci_content, encoding="utf-8"
100
+ )
@@ -0,0 +1,181 @@
1
+ INIT_CONTENT = ""
2
+
3
+ MAIN_CONTENT = """\
4
+ import logging
5
+
6
+ from src.utils.logger import setup_logger
7
+ from src.workflows.report_workflow import run_report_workflow
8
+
9
+
10
+ def main() -> None:
11
+ setup_logger()
12
+ logger = logging.getLogger(__name__)
13
+ logger.info("Starting {project_name}")
14
+ run_report_workflow()
15
+ logger.info("{project_name} finished")
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
20
+ """
21
+
22
+ LOGGER_CONTENT = """\
23
+ import logging
24
+ from pathlib import Path
25
+
26
+
27
+ def setup_logger(log_file: str = "logs/app.log") -> None:
28
+ Path("logs").mkdir(exist_ok=True)
29
+ logging.basicConfig(
30
+ level=logging.INFO,
31
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
32
+ handlers=[
33
+ logging.FileHandler(log_file),
34
+ logging.StreamHandler(),
35
+ ],
36
+ )
37
+ """
38
+
39
+ REPORT_WORKFLOW_CONTENT = """\
40
+ import logging
41
+
42
+ from src.tasks.data_task import load_sample_data
43
+ from src.tasks.report_task import generate_report
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def run_report_workflow() -> None:
49
+ logger.info("Workflow started")
50
+ data = load_sample_data()
51
+ report = generate_report(data)
52
+ logger.info("Report: %s", report)
53
+ logger.info("Workflow complete")
54
+ """
55
+
56
+ DATA_TASK_CONTENT = """\
57
+ import logging
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ def load_sample_data() -> dict:
63
+ logger.info("Loading sample data")
64
+ return {{"records": 42, "source": "local"}}
65
+ """
66
+
67
+ REPORT_TASK_CONTENT = """\
68
+ import logging
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+
73
+ def generate_report(data: dict) -> str:
74
+ logger.info("Generating report")
75
+ return f"Records processed: {{data['records']}}"
76
+ """
77
+
78
+ TEST_CONTENT = """\
79
+ from src.tasks.data_task import load_sample_data
80
+ from src.tasks.report_task import generate_report
81
+
82
+
83
+ def test_load_sample_data():
84
+ data = load_sample_data()
85
+ assert isinstance(data, dict)
86
+ assert "records" in data
87
+
88
+
89
+ def test_generate_report():
90
+ data = {{"records": 10, "source": "test"}}
91
+ report = generate_report(data)
92
+ assert "10" in report
93
+ """
94
+
95
+ ENV_EXAMPLE_CONTENT = """\
96
+ APP_NAME={project_name}
97
+ DEBUG=True
98
+ """
99
+
100
+ README_CONTENT = """\
101
+ # {project_name}
102
+
103
+ An automation project generated with Spawn.
104
+
105
+ ## Getting Started
106
+
107
+ ```bash
108
+ uv run python -m src.main
109
+ ```
110
+
111
+ ## Project Structure
112
+
113
+ ```
114
+ {project_name}/
115
+ ├── src/
116
+ │ ├── workflows/ # Business workflows
117
+ │ ├── tasks/ # Reusable task modules
118
+ │ ├── integrations/ # External service connectors
119
+ │ ├── utils/ # Shared helpers
120
+ │ └── main.py # Entry point
121
+ ├── logs/ # Application logs
122
+ ├── tests/
123
+ ├── .env.example
124
+ └── README.md
125
+ ```
126
+
127
+ ## Running Tests
128
+
129
+ ```bash
130
+ uv run pytest
131
+ ```
132
+
133
+ ## Logs
134
+
135
+ Logs are written to `logs/app.log` and printed to the console.
136
+
137
+ ## Workflow Overview
138
+
139
+ Load sample data → Generate report → Log output → Workflow complete
140
+
141
+ ## Future Expansions
142
+
143
+ ```bash
144
+ spawn add scheduler
145
+ spawn add openai
146
+ spawn add slack
147
+ spawn add database
148
+ ```
149
+ """
150
+
151
+ GITHUB_ACTIONS_CI_BASE = """\
152
+ name: CI
153
+
154
+ on:
155
+ push:
156
+ branches: [main]
157
+ pull_request:
158
+ branches: [main]
159
+
160
+ jobs:
161
+ ci:
162
+ runs-on: ubuntu-latest
163
+
164
+ steps:
165
+ - uses: actions/checkout@v4
166
+
167
+ - uses: astral-sh/setup-uv@v5
168
+
169
+ - name: Install dependencies
170
+ run: uv sync
171
+ """
172
+
173
+ GITHUB_ACTIONS_CI_RUFF_STEP = """\
174
+ - name: Lint
175
+ run: uv run ruff check .
176
+ """
177
+
178
+ GITHUB_ACTIONS_CI_PYTEST_STEP = """\
179
+ - name: Test
180
+ run: uv run pytest
181
+ """