tasktree 0.0.5__tar.gz → 0.0.6__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 (44) hide show
  1. {tasktree-0.0.5 → tasktree-0.0.6}/PKG-INFO +32 -1
  2. {tasktree-0.0.5 → tasktree-0.0.6}/README.md +31 -0
  3. {tasktree-0.0.5 → tasktree-0.0.6}/pyproject.toml +1 -1
  4. tasktree-0.0.6/schema/README.md +118 -0
  5. tasktree-0.0.6/schema/tasktree-schema.json +163 -0
  6. tasktree-0.0.6/schema/vscode-settings-snippet.json +10 -0
  7. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/cli.py +66 -31
  8. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/parser.py +44 -7
  9. {tasktree-0.0.5 → tasktree-0.0.6}/tasktree.yaml +2 -0
  10. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_cli_options.py +222 -0
  11. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_working_directory.py +41 -0
  12. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_parser.py +48 -3
  13. {tasktree-0.0.5 → tasktree-0.0.6}/.github/workflows/release.yml +0 -0
  14. {tasktree-0.0.5 → tasktree-0.0.6}/.github/workflows/test.yml +0 -0
  15. {tasktree-0.0.5 → tasktree-0.0.6}/.gitignore +0 -0
  16. {tasktree-0.0.5 → tasktree-0.0.6}/.python-version +0 -0
  17. {tasktree-0.0.5 → tasktree-0.0.6}/CLAUDE.md +0 -0
  18. {tasktree-0.0.5 → tasktree-0.0.6}/example/source.txt +0 -0
  19. {tasktree-0.0.5 → tasktree-0.0.6}/example/tasktree.yaml +0 -0
  20. {tasktree-0.0.5 → tasktree-0.0.6}/requirements/future/docker-task-environments.md +0 -0
  21. {tasktree-0.0.5 → tasktree-0.0.6}/requirements/implemented/shell-environment-requirements.md +0 -0
  22. {tasktree-0.0.5 → tasktree-0.0.6}/src/__init__.py +0 -0
  23. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/__init__.py +0 -0
  24. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/executor.py +0 -0
  25. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/graph.py +0 -0
  26. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/hasher.py +0 -0
  27. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/state.py +0 -0
  28. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/tasks.py +0 -0
  29. {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/types.py +0 -0
  30. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_clean_state.py +0 -0
  31. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_dependency_execution.py +0 -0
  32. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_end_to_end.py +0 -0
  33. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_input_detection.py +0 -0
  34. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_missing_outputs.py +0 -0
  35. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_nested_imports.py +0 -0
  36. {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_state_persistence.py +0 -0
  37. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_cli.py +0 -0
  38. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_executor.py +0 -0
  39. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_graph.py +0 -0
  40. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_hasher.py +0 -0
  41. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_state.py +0 -0
  42. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_tasks.py +0 -0
  43. {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_types.py +0 -0
  44. {tasktree-0.0.5 → tasktree-0.0.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.5
3
+ Version: 0.0.6
4
4
  Summary: A task automation tool with incremental execution
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.1.0
@@ -188,6 +188,37 @@ cd tasktree
188
188
  pipx install .
189
189
  ```
190
190
 
191
+ ## Editor Support
192
+
193
+ Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
194
+
195
+ ### VS Code
196
+
197
+ Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
198
+
199
+ ```json
200
+ {
201
+ "yaml.schemas": {
202
+ "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
203
+ "tasktree.yaml",
204
+ "tt.yaml"
205
+ ]
206
+ }
207
+ }
208
+ ```
209
+
210
+ Or add a comment at the top of your `tasktree.yaml`:
211
+
212
+ ```yaml
213
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
214
+
215
+ tasks:
216
+ build:
217
+ cmd: cargo build
218
+ ```
219
+
220
+ See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
221
+
191
222
  ## Quick Start
192
223
 
193
224
  Create a `tasktree.yaml` (or `tt.yaml`) in your project:
@@ -174,6 +174,37 @@ cd tasktree
174
174
  pipx install .
175
175
  ```
176
176
 
177
+ ## Editor Support
178
+
179
+ Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
180
+
181
+ ### VS Code
182
+
183
+ Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
184
+
185
+ ```json
186
+ {
187
+ "yaml.schemas": {
188
+ "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
189
+ "tasktree.yaml",
190
+ "tt.yaml"
191
+ ]
192
+ }
193
+ }
194
+ ```
195
+
196
+ Or add a comment at the top of your `tasktree.yaml`:
197
+
198
+ ```yaml
199
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
200
+
201
+ tasks:
202
+ build:
203
+ cmd: cargo build
204
+ ```
205
+
206
+ See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
207
+
177
208
  ## Quick Start
178
209
 
179
210
  Create a `tasktree.yaml` (or `tt.yaml`) in your project:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.5"
3
+ version = "0.0.6"
4
4
  description = "A task automation tool with incremental execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,118 @@
1
+ # Task Tree YAML Schema
2
+
3
+ This directory contains the JSON Schema for Task Tree recipe files (`tasktree.yaml` or `tt.yaml`).
4
+
5
+ ## What is a YAML Schema?
6
+
7
+ The JSON Schema provides:
8
+ - **Autocomplete**: Get suggestions for task fields as you type
9
+ - **Validation**: Immediate feedback on syntax errors
10
+ - **Documentation**: Hover over fields to see descriptions
11
+ - **Type checking**: Ensure values match expected types
12
+
13
+ ## Usage
14
+
15
+ ### VS Code
16
+
17
+ For your project, copy the settings from `schema/vscode-settings-snippet.json` to your `.vscode/settings.json`:
18
+
19
+ ```json
20
+ {
21
+ "yaml.schemas": {
22
+ "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
23
+ "tasktree.yaml",
24
+ "tt.yaml"
25
+ ]
26
+ }
27
+ }
28
+ ```
29
+
30
+ Or add a comment at the top of your `tasktree.yaml`:
31
+
32
+ ```yaml
33
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
34
+
35
+ tasks:
36
+ build:
37
+ cmd: cargo build
38
+ ```
39
+
40
+ ### IntelliJ / PyCharm
41
+
42
+ 1. Go to **Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings**
43
+ 2. Add new mapping:
44
+ - **Name**: Task Tree
45
+ - **Schema file**: Point to `schema/tasktree-schema.json`
46
+ - **Schema version**: JSON Schema version 7
47
+ - **File path pattern**: `tasktree.yaml` or `tt.yaml`
48
+
49
+ ### Command Line Validation
50
+
51
+ You can validate your recipe files using tools like `check-jsonschema`:
52
+
53
+ ```bash
54
+ # Install
55
+ pip install check-jsonschema
56
+
57
+ # Validate
58
+ check-jsonschema --schemafile schema/tasktree-schema.json tasktree.yaml
59
+ ```
60
+
61
+ ## Schema Features
62
+
63
+ The schema validates:
64
+
65
+ - **Top-level structure**: Only `imports`, `environments`, and `tasks` are allowed at root
66
+ - **Required fields**: Tasks must have a `cmd` field
67
+ - **Field types**: Ensures strings, arrays, and objects are used correctly
68
+ - **Naming patterns**: Task names and namespaces must match `^[a-zA-Z][a-zA-Z0-9_-]*$`
69
+ - **Environment requirements**: Environments must specify a `shell`
70
+
71
+ ## Example
72
+
73
+ ```yaml
74
+ imports:
75
+ - file: common/base.yaml
76
+ as: base
77
+
78
+ environments:
79
+ default: bash-strict
80
+ bash-strict:
81
+ shell: /bin/bash
82
+ args: ['-e', '-u', '-o', 'pipefail']
83
+
84
+ tasks:
85
+ build:
86
+ desc: Build the application
87
+ deps: [base.setup]
88
+ inputs: ["src/**/*.rs"]
89
+ outputs: [target/release/bin]
90
+ cmd: cargo build --release
91
+
92
+ test:
93
+ desc: Run tests
94
+ deps: [build]
95
+ cmd: cargo test
96
+
97
+ deploy:
98
+ desc: Deploy to environment
99
+ deps: [build]
100
+ args: [environment, region=us-west-1]
101
+ cmd: |
102
+ echo "Deploying to {{environment}} in {{region}}"
103
+ ./deploy.sh {{environment}} {{region}}
104
+ ```
105
+
106
+ ## Contributing
107
+
108
+ If you find issues with the schema or want to improve it, please:
109
+
110
+ 1. Update `tasktree-schema.json`
111
+ 2. Test with your editor
112
+ 3. Submit a pull request
113
+
114
+ ## References
115
+
116
+ - [JSON Schema Specification](https://json-schema.org/)
117
+ - [VS Code YAML Extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml)
118
+ - [YAML Language Server](https://github.com/redhat-developer/yaml-language-server)
@@ -0,0 +1,163 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/kevinchannon/tasktree/schema/tasktree-schema.json",
4
+ "title": "Task Tree Recipe Schema",
5
+ "description": "Schema for Task Tree (tt) recipe files (tasktree.yaml or tt.yaml)",
6
+ "type": "object",
7
+ "properties": {
8
+ "imports": {
9
+ "description": "Import task definitions from other files",
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "file": {
15
+ "type": "string",
16
+ "description": "Path to the YAML file to import (relative to current file)"
17
+ },
18
+ "as": {
19
+ "type": "string",
20
+ "description": "Namespace to use for imported tasks",
21
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
22
+ }
23
+ },
24
+ "required": ["file", "as"],
25
+ "additionalProperties": false
26
+ }
27
+ },
28
+ "environments": {
29
+ "description": "Shell execution environment configurations",
30
+ "type": "object",
31
+ "properties": {
32
+ "default": {
33
+ "type": "string",
34
+ "description": "Name of the default environment to use"
35
+ }
36
+ },
37
+ "patternProperties": {
38
+ "^[a-zA-Z][a-zA-Z0-9_-]*$": {
39
+ "type": "object",
40
+ "properties": {
41
+ "shell": {
42
+ "type": "string",
43
+ "description": "Path to the shell executable (e.g., /bin/bash, python3, pwsh)"
44
+ },
45
+ "args": {
46
+ "description": "Arguments to pass to the shell",
47
+ "oneOf": [
48
+ {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "string"
52
+ }
53
+ },
54
+ {
55
+ "type": "string"
56
+ }
57
+ ]
58
+ },
59
+ "preamble": {
60
+ "type": "string",
61
+ "description": "Code to execute before the command (e.g., imports, setup)"
62
+ }
63
+ },
64
+ "required": ["shell"],
65
+ "additionalProperties": false
66
+ }
67
+ },
68
+ "additionalProperties": false
69
+ },
70
+ "tasks": {
71
+ "description": "Task definitions",
72
+ "type": "object",
73
+ "patternProperties": {
74
+ "^[a-zA-Z][a-zA-Z0-9_-]*$": {
75
+ "type": "object",
76
+ "properties": {
77
+ "desc": {
78
+ "type": "string",
79
+ "description": "Human-readable description of the task"
80
+ },
81
+ "deps": {
82
+ "description": "Task dependencies (must run before this task)",
83
+ "oneOf": [
84
+ {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "string"
88
+ }
89
+ },
90
+ {
91
+ "type": "string"
92
+ }
93
+ ]
94
+ },
95
+ "inputs": {
96
+ "description": "Input file patterns (glob supported). Task re-runs if inputs change.",
97
+ "oneOf": [
98
+ {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "string"
102
+ }
103
+ },
104
+ {
105
+ "type": "string"
106
+ }
107
+ ]
108
+ },
109
+ "outputs": {
110
+ "description": "Output file patterns (glob supported). Task re-runs if outputs missing.",
111
+ "oneOf": [
112
+ {
113
+ "type": "array",
114
+ "items": {
115
+ "type": "string"
116
+ }
117
+ },
118
+ {
119
+ "type": "string"
120
+ }
121
+ ]
122
+ },
123
+ "working_dir": {
124
+ "type": "string",
125
+ "description": "Directory to execute the command in (relative to recipe file)"
126
+ },
127
+ "args": {
128
+ "description": "Parameterized arguments for the task (format: name, name:type, name:type=default)",
129
+ "oneOf": [
130
+ {
131
+ "type": "array",
132
+ "items": {
133
+ "type": "string"
134
+ }
135
+ },
136
+ {
137
+ "type": "string"
138
+ }
139
+ ]
140
+ },
141
+ "env": {
142
+ "type": "string",
143
+ "description": "Environment to use for this task (overrides default)"
144
+ },
145
+ "cmd": {
146
+ "type": "string",
147
+ "description": "Shell command to execute. Use {{arg}} for parameter substitution."
148
+ }
149
+ },
150
+ "required": ["cmd"],
151
+ "additionalProperties": false
152
+ }
153
+ },
154
+ "additionalProperties": false
155
+ }
156
+ },
157
+ "additionalProperties": false,
158
+ "anyOf": [
159
+ { "required": ["tasks"] },
160
+ { "required": ["imports"] },
161
+ { "required": ["environments"] }
162
+ ]
163
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "yaml.schemas": {
3
+ "../schema/tasktree-schema.json": [
4
+ "tasktree.yaml",
5
+ "tt.yaml",
6
+ "**/tasktree.yaml",
7
+ "**/tt.yaml"
8
+ ]
9
+ }
10
+ }
@@ -26,11 +26,11 @@ app = typer.Typer(
26
26
  console = Console()
27
27
 
28
28
 
29
- def _list_tasks():
29
+ def _list_tasks(tasks_file: Optional[str] = None):
30
30
  """List all available tasks with descriptions."""
31
- recipe = _get_recipe()
31
+ recipe = _get_recipe(tasks_file)
32
32
  if recipe is None:
33
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
33
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
34
34
  raise typer.Exit(1)
35
35
 
36
36
  table = Table(title="Available Tasks")
@@ -45,11 +45,11 @@ def _list_tasks():
45
45
  console.print(table)
46
46
 
47
47
 
48
- def _show_task(task_name: str):
48
+ def _show_task(task_name: str, tasks_file: Optional[str] = None):
49
49
  """Show task definition with syntax highlighting."""
50
- recipe = _get_recipe()
50
+ recipe = _get_recipe(tasks_file)
51
51
  if recipe is None:
52
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
52
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
53
53
  raise typer.Exit(1)
54
54
 
55
55
  task = recipe.get_task(task_name)
@@ -79,17 +79,26 @@ def _show_task(task_name: str):
79
79
  task_dict = task_yaml[task_name]
80
80
  task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
81
81
 
82
+ # Configure YAML dumper to use literal block style for multiline strings
83
+ def literal_presenter(dumper, data):
84
+ """Use literal block style (|) for strings containing newlines."""
85
+ if '\n' in data:
86
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
87
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data)
88
+
89
+ yaml.add_representer(str, literal_presenter)
90
+
82
91
  # Format and highlight using Rich
83
92
  yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
84
93
  syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
85
94
  console.print(syntax)
86
95
 
87
96
 
88
- def _show_tree(task_name: str):
97
+ def _show_tree(task_name: str, tasks_file: Optional[str] = None):
89
98
  """Show dependency tree structure."""
90
- recipe = _get_recipe()
99
+ recipe = _get_recipe(tasks_file)
91
100
  if recipe is None:
92
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
101
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
93
102
  raise typer.Exit(1)
94
103
 
95
104
  task = recipe.get_task(task_name)
@@ -169,6 +178,7 @@ def main(
169
178
  list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
170
179
  show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
171
180
  tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
181
+ tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
172
182
  init: Optional[bool] = typer.Option(
173
183
  None, "--init", "-i", help="Create a blank tasktree.yaml"
174
184
  ),
@@ -208,15 +218,15 @@ def main(
208
218
  """
209
219
 
210
220
  if list_opt:
211
- _list_tasks()
221
+ _list_tasks(tasks_file)
212
222
  raise typer.Exit()
213
223
 
214
224
  if show:
215
- _show_task(show)
225
+ _show_task(show, tasks_file)
216
226
  raise typer.Exit()
217
227
 
218
228
  if tree:
219
- _show_tree(tree)
229
+ _show_tree(tree, tasks_file)
220
230
  raise typer.Exit()
221
231
 
222
232
  if init:
@@ -224,17 +234,17 @@ def main(
224
234
  raise typer.Exit()
225
235
 
226
236
  if clean or clean_state or reset:
227
- _clean_state()
237
+ _clean_state(tasks_file)
228
238
  raise typer.Exit()
229
239
 
230
240
  if task_args:
231
241
  # --only implies --force
232
242
  force_execution = force or only or False
233
- _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
243
+ _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
234
244
  else:
235
- recipe = _get_recipe()
245
+ recipe = _get_recipe(tasks_file)
236
246
  if recipe is None:
237
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
247
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
238
248
  console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
239
249
  raise typer.Exit(1)
240
250
 
@@ -245,13 +255,19 @@ def main(
245
255
  console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
246
256
 
247
257
 
248
- def _clean_state() -> None:
258
+ def _clean_state(tasks_file: Optional[str] = None) -> None:
249
259
  """Remove the .tasktree-state file to reset task execution state."""
250
- recipe_path = find_recipe_file()
251
- if recipe_path is None:
252
- console.print("[yellow]No recipe file found[/yellow]")
253
- console.print("State file location depends on recipe file location")
254
- raise typer.Exit(1)
260
+ if tasks_file:
261
+ recipe_path = Path(tasks_file)
262
+ if not recipe_path.exists():
263
+ console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
264
+ raise typer.Exit(1)
265
+ else:
266
+ recipe_path = find_recipe_file()
267
+ if recipe_path is None:
268
+ console.print("[yellow]No recipe file found[/yellow]")
269
+ console.print("State file location depends on recipe file location")
270
+ raise typer.Exit(1)
255
271
 
256
272
  project_root = recipe_path.parent
257
273
  state_path = project_root / ".tasktree-state"
@@ -264,29 +280,48 @@ def _clean_state() -> None:
264
280
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
265
281
 
266
282
 
267
- def _get_recipe() -> Optional[Recipe]:
268
- """Get parsed recipe or None if not found."""
269
- recipe_path = find_recipe_file()
270
- if recipe_path is None:
271
- return None
283
+ def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
284
+ """Get parsed recipe or None if not found.
285
+
286
+ Args:
287
+ recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
288
+ """
289
+ if recipe_file:
290
+ recipe_path = Path(recipe_file)
291
+ if not recipe_path.exists():
292
+ console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
293
+ raise typer.Exit(1)
294
+ # When explicitly specified, project root is current working directory
295
+ project_root = Path.cwd()
296
+ else:
297
+ try:
298
+ recipe_path = find_recipe_file()
299
+ if recipe_path is None:
300
+ return None
301
+ except ValueError as e:
302
+ # Multiple recipe files found
303
+ console.print(f"[red]{e}[/red]")
304
+ raise typer.Exit(1)
305
+ # When auto-discovered, project root is recipe file's parent
306
+ project_root = None
272
307
 
273
308
  try:
274
- return parse_recipe(recipe_path)
309
+ return parse_recipe(recipe_path, project_root)
275
310
  except Exception as e:
276
311
  console.print(f"[red]Error parsing recipe: {e}[/red]")
277
312
  raise typer.Exit(1)
278
313
 
279
314
 
280
- def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
315
+ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
281
316
  if not args:
282
317
  return
283
318
 
284
319
  task_name = args[0]
285
320
  task_args = args[1:]
286
321
 
287
- recipe = _get_recipe()
322
+ recipe = _get_recipe(tasks_file)
288
323
  if recipe is None:
289
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
324
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
290
325
  raise typer.Exit(1)
291
326
 
292
327
  # Apply global environment override if provided
@@ -94,13 +94,25 @@ class Recipe:
94
94
 
95
95
 
96
96
  def find_recipe_file(start_dir: Path | None = None) -> Path | None:
97
- """Find recipe file (tasktree.yaml or tt.yaml) in current or parent directories.
97
+ """Find recipe file in current or parent directories.
98
+
99
+ Looks for recipe files matching these patterns (in order of preference):
100
+ - tasktree.yaml
101
+ - tasktree.yml
102
+ - tt.yaml
103
+ - *.tasks
104
+
105
+ If multiple recipe files are found in the same directory, raises ValueError
106
+ with instructions to use --tasks option.
98
107
 
99
108
  Args:
100
109
  start_dir: Directory to start searching from (defaults to cwd)
101
110
 
102
111
  Returns:
103
112
  Path to recipe file if found, None otherwise
113
+
114
+ Raises:
115
+ ValueError: If multiple recipe files found in the same directory
104
116
  """
105
117
  if start_dir is None:
106
118
  start_dir = Path.cwd()
@@ -109,10 +121,30 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
109
121
 
110
122
  # Search up the directory tree
111
123
  while True:
112
- for filename in ["tasktree.yaml", "tt.yaml"]:
124
+ candidates = []
125
+
126
+ # Check for exact filenames first
127
+ for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
113
128
  recipe_path = current / filename
114
129
  if recipe_path.exists():
115
- return recipe_path
130
+ candidates.append(recipe_path)
131
+
132
+ # Check for *.tasks files
133
+ for tasks_file in current.glob("*.tasks"):
134
+ if tasks_file.is_file():
135
+ candidates.append(tasks_file)
136
+
137
+ if len(candidates) > 1:
138
+ # Multiple recipe files found - ambiguous
139
+ filenames = [c.name for c in candidates]
140
+ raise ValueError(
141
+ f"Multiple recipe files found in {current}:\n"
142
+ f" {', '.join(filenames)}\n\n"
143
+ f"Please specify which file to use with --tasks (-T):\n"
144
+ f" tt --tasks {filenames[0]} <task-name>"
145
+ )
146
+ elif len(candidates) == 1:
147
+ return candidates[0]
116
148
 
117
149
  # Move to parent directory
118
150
  parent = current.parent
@@ -186,11 +218,13 @@ def _parse_file_with_env(
186
218
  return tasks, environments, default_env
187
219
 
188
220
 
189
- def parse_recipe(recipe_path: Path) -> Recipe:
221
+ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
190
222
  """Parse a recipe file and handle imports recursively.
191
223
 
192
224
  Args:
193
225
  recipe_path: Path to the main recipe file
226
+ project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
227
+ When using --tasks option, this should be the current working directory.
194
228
 
195
229
  Returns:
196
230
  Recipe object with all tasks (including recursively imported tasks)
@@ -204,7 +238,9 @@ def parse_recipe(recipe_path: Path) -> Recipe:
204
238
  if not recipe_path.exists():
205
239
  raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
206
240
 
207
- project_root = recipe_path.parent
241
+ # Default project root to recipe file's parent if not specified
242
+ if project_root is None:
243
+ project_root = recipe_path.parent
208
244
 
209
245
  # Parse main file - it will recursively handle all imports
210
246
  tasks, environments, default_env = _parse_file_with_env(
@@ -263,8 +299,9 @@ def _parse_file(
263
299
  tasks: dict[str, Task] = {}
264
300
  file_dir = file_path.parent
265
301
 
266
- # Default working directory is the file's directory
267
- default_working_dir = str(file_dir.relative_to(project_root)) if file_dir != project_root else "."
302
+ # Default working directory is the project root (where tt is invoked)
303
+ # NOT the directory where the tasks file is located
304
+ default_working_dir = "."
268
305
 
269
306
  # Track local import namespaces for dependency rewriting
270
307
  local_import_namespaces: set[str] = set()
@@ -1,3 +1,5 @@
1
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
2
+
1
3
  tasks:
2
4
  dev-setup:
3
5
  desc: Install development dependencies
@@ -209,6 +209,69 @@ tasks:
209
209
  os.chdir(original_cwd)
210
210
 
211
211
 
212
+ class TestShowOption(unittest.TestCase):
213
+ """Test the --show option displays task definitions correctly."""
214
+
215
+ def setUp(self):
216
+ """Set up test fixtures."""
217
+ self.runner = CliRunner()
218
+ self.env = {"NO_COLOR": "1"}
219
+
220
+ def test_show_multiline_command_preserves_newlines(self):
221
+ """Test that --show displays multiline commands with proper newlines, not escaped \\n."""
222
+ with self.runner.isolated_filesystem():
223
+ recipe_file = Path("tasktree.yaml")
224
+ recipe_file.write_text(
225
+ """
226
+ tasks:
227
+ multiline:
228
+ desc: Task with multiline command
229
+ cmd: |
230
+ echo "Line 1"
231
+ echo "Line 2"
232
+ echo "Line 3"
233
+ """
234
+ )
235
+
236
+ result = self.runner.invoke(app, ["--show", "multiline"], env=self.env)
237
+
238
+ self.assertEqual(result.exit_code, 0)
239
+
240
+ # Should show the literal block style indicator
241
+ self.assertIn("cmd: |", result.stdout)
242
+
243
+ # Should show each line on a separate line (not escaped \\n)
244
+ self.assertIn('echo "Line 1"', result.stdout)
245
+ self.assertIn('echo "Line 2"', result.stdout)
246
+ self.assertIn('echo "Line 3"', result.stdout)
247
+
248
+ # Should NOT show escaped newlines
249
+ self.assertNotIn("\\n", result.stdout)
250
+
251
+ def test_show_single_line_command(self):
252
+ """Test that --show displays single-line commands cleanly."""
253
+ with self.runner.isolated_filesystem():
254
+ recipe_file = Path("tasktree.yaml")
255
+ recipe_file.write_text(
256
+ """
257
+ tasks:
258
+ single:
259
+ desc: Task with single line command
260
+ cmd: echo "Hello world"
261
+ """
262
+ )
263
+
264
+ result = self.runner.invoke(app, ["--show", "single"], env=self.env)
265
+
266
+ self.assertEqual(result.exit_code, 0)
267
+
268
+ # Should show the command on a single line
269
+ self.assertIn('cmd: echo "Hello world"', result.stdout)
270
+
271
+ # Should NOT use literal block style for single-line commands
272
+ self.assertNotIn("cmd: |", result.stdout)
273
+
274
+
212
275
  class TestForceOption(unittest.TestCase):
213
276
  """Test the --force/-f option forces re-run of all tasks."""
214
277
 
@@ -649,5 +712,164 @@ tasks:
649
712
  os.chdir(original_cwd)
650
713
 
651
714
 
715
+ class TestTasksFileOption(unittest.TestCase):
716
+ """Test the --tasks/-T option for specifying recipe files."""
717
+
718
+ def setUp(self):
719
+ """Set up test fixtures."""
720
+ self.runner = CliRunner()
721
+ self.env = {"NO_COLOR": "1"}
722
+
723
+ def test_tasks_option_with_yml_extension(self):
724
+ """Test --tasks option works with .yml extension."""
725
+ with self.runner.isolated_filesystem():
726
+ recipe_file = Path("tasktree.yml")
727
+ recipe_file.write_text(
728
+ """
729
+ tasks:
730
+ build:
731
+ desc: Build with yml
732
+ cmd: echo "Building from yml"
733
+ """
734
+ )
735
+
736
+ result = self.runner.invoke(app, ["--tasks", "tasktree.yml", "build"], env=self.env)
737
+
738
+ self.assertEqual(result.exit_code, 0)
739
+ self.assertIn("build", result.stdout)
740
+ self.assertIn("completed successfully", result.stdout)
741
+
742
+ def test_tasks_option_with_tasks_extension(self):
743
+ """Test --tasks option works with .tasks extension."""
744
+ with self.runner.isolated_filesystem():
745
+ recipe_file = Path("build.tasks")
746
+ recipe_file.write_text(
747
+ """
748
+ tasks:
749
+ compile:
750
+ desc: Compile code
751
+ cmd: echo "Compiling"
752
+ """
753
+ )
754
+
755
+ result = self.runner.invoke(app, ["--tasks", "build.tasks", "compile"], env=self.env)
756
+
757
+ self.assertEqual(result.exit_code, 0)
758
+ self.assertIn("compile", result.stdout)
759
+ self.assertIn("completed successfully", result.stdout)
760
+
761
+ def test_tasks_option_with_short_flag(self):
762
+ """Test -T short flag works."""
763
+ with self.runner.isolated_filesystem():
764
+ recipe_file = Path("my.tasks")
765
+ recipe_file.write_text(
766
+ """
767
+ tasks:
768
+ test:
769
+ cmd: echo "Testing"
770
+ """
771
+ )
772
+
773
+ result = self.runner.invoke(app, ["-T", "my.tasks", "test"], env=self.env)
774
+
775
+ self.assertEqual(result.exit_code, 0)
776
+ self.assertIn("test", result.stdout)
777
+ self.assertIn("completed successfully", result.stdout)
778
+
779
+ def test_multiple_recipe_files_without_tasks_option_fails(self):
780
+ """Test that having multiple recipe files without --tasks raises error."""
781
+ with self.runner.isolated_filesystem():
782
+ # Create multiple recipe files
783
+ Path("tasktree.yaml").write_text("tasks:\n build:\n cmd: echo yaml")
784
+ Path("tasktree.yml").write_text("tasks:\n build:\n cmd: echo yml")
785
+
786
+ # Should fail with helpful error message
787
+ result = self.runner.invoke(app, ["build"], env=self.env)
788
+
789
+ self.assertNotEqual(result.exit_code, 0)
790
+ self.assertIn("Multiple recipe files found", result.stdout)
791
+ self.assertIn("--tasks", result.stdout)
792
+
793
+ def test_tasks_option_selects_specific_file_when_multiple_exist(self):
794
+ """Test --tasks option selects specific file when multiple exist."""
795
+ with self.runner.isolated_filesystem():
796
+ # Create multiple recipe files with different task names
797
+ Path("tasktree.yaml").write_text(
798
+ """
799
+ tasks:
800
+ yaml-task:
801
+ cmd: echo "From yaml"
802
+ """
803
+ )
804
+ Path("build.tasks").write_text(
805
+ """
806
+ tasks:
807
+ tasks-task:
808
+ cmd: echo "From tasks"
809
+ """
810
+ )
811
+
812
+ # Use --tasks to select the .tasks file - should be able to run tasks-task
813
+ result = self.runner.invoke(app, ["--tasks", "build.tasks", "tasks-task"], env=self.env)
814
+ self.assertEqual(result.exit_code, 0)
815
+ self.assertIn("tasks-task", result.stdout)
816
+
817
+ # Should not be able to run yaml-task from build.tasks
818
+ result = self.runner.invoke(app, ["--tasks", "build.tasks", "yaml-task"], env=self.env)
819
+ self.assertNotEqual(result.exit_code, 0)
820
+ self.assertIn("Task not found", result.stdout)
821
+
822
+ def test_tasks_option_with_list(self):
823
+ """Test --tasks option works with --list."""
824
+ with self.runner.isolated_filesystem():
825
+ recipe_file = Path("custom.tasks")
826
+ recipe_file.write_text(
827
+ """
828
+ tasks:
829
+ task1:
830
+ desc: First task
831
+ cmd: echo one
832
+ task2:
833
+ desc: Second task
834
+ cmd: echo two
835
+ """
836
+ )
837
+
838
+ result = self.runner.invoke(app, ["--tasks", "custom.tasks", "--list"], env=self.env)
839
+
840
+ self.assertEqual(result.exit_code, 0)
841
+ self.assertIn("task1", result.stdout)
842
+ self.assertIn("task2", result.stdout)
843
+ self.assertIn("First task", result.stdout)
844
+
845
+ def test_tasks_option_with_show(self):
846
+ """Test --tasks option works with --show."""
847
+ with self.runner.isolated_filesystem():
848
+ recipe_file = Path("my.tasks")
849
+ recipe_file.write_text(
850
+ """
851
+ tasks:
852
+ build:
853
+ desc: Build task
854
+ cmd: echo building
855
+ """
856
+ )
857
+
858
+ result = self.runner.invoke(app, ["--tasks", "my.tasks", "--show", "build"], env=self.env)
859
+
860
+ self.assertEqual(result.exit_code, 0)
861
+ self.assertIn("build:", result.stdout)
862
+ self.assertIn("desc: Build task", result.stdout)
863
+
864
+ def test_tasks_option_with_nonexistent_file(self):
865
+ """Test --tasks option with nonexistent file shows error."""
866
+ with self.runner.isolated_filesystem():
867
+ result = self.runner.invoke(app, ["--tasks", "nonexistent.yaml", "build"], env=self.env)
868
+
869
+ self.assertNotEqual(result.exit_code, 0)
870
+ self.assertIn("Recipe file not found", result.stdout)
871
+ self.assertIn("nonexistent.yaml", result.stdout)
872
+
873
+
652
874
  if __name__ == "__main__":
653
875
  unittest.main()
@@ -102,6 +102,47 @@ tasks:
102
102
  finally:
103
103
  os.chdir(original_cwd)
104
104
 
105
+ def test_default_working_dir_is_invocation_dir_not_tasks_file_dir(self):
106
+ """Test that without explicit working_dir, tasks run from where tt is invoked, not where the tasks file is."""
107
+ with TemporaryDirectory() as tmpdir:
108
+ project_root = Path(tmpdir)
109
+
110
+ # Create subdirectory with tasks file
111
+ subdir = project_root / "config"
112
+ subdir.mkdir()
113
+
114
+ # Create tasks file in subdirectory
115
+ tasks_file = subdir / "build.tasks"
116
+ tasks_file.write_text("""
117
+ tasks:
118
+ check-location:
119
+ desc: Check where we execute from
120
+ cmd: pwd > location.txt
121
+ """)
122
+
123
+ original_cwd = os.getcwd()
124
+ try:
125
+ # Invoke tt from project root, pointing to tasks file in subdir
126
+ os.chdir(project_root)
127
+
128
+ result = self.runner.invoke(app, ["--tasks", "config/build.tasks", "check-location"], env=self.env)
129
+ self.assertEqual(result.exit_code, 0)
130
+
131
+ # Verify output was created in project root (where we invoked tt)
132
+ # NOT in config/ (where the tasks file is)
133
+ output_in_root = project_root / "location.txt"
134
+ output_in_subdir = subdir / "location.txt"
135
+
136
+ self.assertTrue(output_in_root.exists(), "Output should be in invocation directory (project root)")
137
+ self.assertFalse(output_in_subdir.exists(), "Output should NOT be in tasks file directory")
138
+
139
+ # Verify pwd shows project root path
140
+ pwd_output = output_in_root.read_text().strip()
141
+ self.assertEqual(Path(pwd_output).resolve(), project_root.resolve())
142
+
143
+ finally:
144
+ os.chdir(original_cwd)
145
+
105
146
 
106
147
  if __name__ == "__main__":
107
148
  unittest.main()
@@ -910,8 +910,8 @@ class TestFindRecipeFile(unittest.TestCase):
910
910
  result = find_recipe_file(project_root)
911
911
  self.assertIsNone(result)
912
912
 
913
- def test_find_recipe_file_prefers_tasktree(self):
914
- """Test prefers tasktree.yaml over tt.yaml."""
913
+ def test_find_recipe_file_multiple_files_raises_error(self):
914
+ """Test raises error when multiple recipe files found."""
915
915
  with TemporaryDirectory() as tmpdir:
916
916
  project_root = Path(tmpdir).resolve()
917
917
  tasktree_path = project_root / "tasktree.yaml"
@@ -921,8 +921,53 @@ class TestFindRecipeFile(unittest.TestCase):
921
921
  tasktree_path.write_text("tasks:\n build:\n cmd: echo from tasktree")
922
922
  tt_path.write_text("tasks:\n build:\n cmd: echo from tt")
923
923
 
924
+ # Should raise ValueError with helpful message
925
+ with self.assertRaises(ValueError) as cm:
926
+ find_recipe_file(project_root)
927
+
928
+ error_msg = str(cm.exception)
929
+ self.assertIn("Multiple recipe files found", error_msg)
930
+ self.assertIn("tasktree.yaml", error_msg)
931
+ self.assertIn("tt.yaml", error_msg)
932
+ self.assertIn("--tasks", error_msg)
933
+
934
+ def test_find_recipe_file_yml_extension(self):
935
+ """Test finds tasktree.yml (with .yml extension)."""
936
+ with TemporaryDirectory() as tmpdir:
937
+ project_root = Path(tmpdir).resolve()
938
+ recipe_path = project_root / "tasktree.yml"
939
+ recipe_path.write_text("tasks:\n build:\n cmd: echo test")
940
+
941
+ result = find_recipe_file(project_root)
942
+ self.assertEqual(result, recipe_path)
943
+
944
+ def test_find_recipe_file_tasks_extension(self):
945
+ """Test finds *.tasks files."""
946
+ with TemporaryDirectory() as tmpdir:
947
+ project_root = Path(tmpdir).resolve()
948
+ recipe_path = project_root / "build.tasks"
949
+ recipe_path.write_text("tasks:\n build:\n cmd: echo test")
950
+
924
951
  result = find_recipe_file(project_root)
925
- self.assertEqual(result, tasktree_path)
952
+ self.assertEqual(result, recipe_path)
953
+
954
+ def test_find_recipe_file_multiple_tasks_files_raises_error(self):
955
+ """Test raises error when multiple *.tasks files found."""
956
+ with TemporaryDirectory() as tmpdir:
957
+ project_root = Path(tmpdir).resolve()
958
+ build_tasks = project_root / "build.tasks"
959
+ test_tasks = project_root / "test.tasks"
960
+
961
+ build_tasks.write_text("tasks:\n build:\n cmd: echo build")
962
+ test_tasks.write_text("tasks:\n test:\n cmd: echo test")
963
+
964
+ # Should raise ValueError
965
+ with self.assertRaises(ValueError) as cm:
966
+ find_recipe_file(project_root)
967
+
968
+ error_msg = str(cm.exception)
969
+ self.assertIn("Multiple recipe files found", error_msg)
970
+ self.assertIn("--tasks", error_msg)
926
971
 
927
972
 
928
973
  class TestEnvironmentParsing(unittest.TestCase):
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